SSO Flow
SSO Flow
Single sign-on across all UNVRSL apps — the complete login flow, cross-app navigation, and service-to-service authentication.
The Full Login Flow
User visits app.medkronos.com
│
▼
App checks for local JWT (in memory or cookie)
│
├── Has valid JWT → serve content
│
└── No JWT → redirect to core.auth
│
▼
core.auth/login?client_id=APP_ID&redirect_uri=CALLBACK&connection_ref=CONN_TOKEN
│
▼
core.auth detects: user already has active session (via core.auth session cookie)?
│
├── Yes + app already authorized → skip UI, issue auth code immediately
├── Yes + app NOT authorized → show app authorization screen
└── No → render login/registration UI
│
▼
core.auth creates:
├── Session record (DB) — or refreshes existing session
└── Authorization code (short-lived, one-time, 60 seconds)
│
▼
Redirect to: app.medkronos.com/auth/callback?code=***&state=RANDOM
│
▼
App backend exchanges auth code via core.console, which relays to core.auth's token endpoint:
POST core.console.medkronos.com/v1/auth/token
├── code: AUTH_CODE
├── client_id: APP_ID
├── redirect_uri: CALLBACK
└── Authorization: Bearer <project_api_key>
│
▼
core.auth validates:
├── Auth code is valid and unused (one-time use)
├── Auth code is not expired (60 second window)
├── Client credentials match
├── Redirect URI matches registered URIs
└── Returns:
├── Body: { "access_token": "eyJ...", "user_info": {...} }
└── Set-Cookie: session_ref=<session-uuid>; HttpOnly; Secure; SameSite=Lax; Path=/session/refresh
│
▼
App creates/updates linked_users record
App stores JWT (in memory for SPA, or in a short-lived cookie for server-rendered)
Session cookie is set automatically by the browser
│
▼
User is logged in ✓Key Security Details
State parameter: The
state parameter is a random string generated by the app, sent in the redirect, and verified when the callback returns. This prevents CSRF attacks — an attacker can't forge a callback because they don't know the state value.Authorization code: Short-lived (60 seconds), single-use. Even if intercepted, it's useless after 60 seconds or after first use.
Code exchange: The auth code is exchanged server-to-server (app backend → core.console → core.auth). The code is never exposed to the browser after the initial redirect. The project's API key authenticates the app in the client-to-console hop.
Session cookie: core.auth sets the
session_ref cookie during the token exchange. This cookie is the refresh credential — it's sent automatically when the app hits /session/refresh. No refresh token is returned.Session Renewal via App Usage (The Google Model)
Every time a user interacts with a Medkronos app, the app needs a valid JWT. When the JWT expires (~15 min), the app hits the refresh endpoint (routed through core.console to core.auth). This refreshes the core.auth session cookie, resetting its TTL.
The result: as long as a user actively uses any Medkronos app, their core.auth session never expires. This is the same model Google uses — being logged into Gmail on Android keeps you logged into Google everywhere.
User uses todo-app (JWT valid) → no refresh needed
User uses todo-app (JWT expired) → app refreshes via core.console
│
▼
core.console → relays to core.auth:
├── Validates session_ref cookie (or X-Session-Ref header)
├── Updates session.last_activity
├── Issues new JWT
├── Refreshes session_ref cookie TTL (resets 30-day expiry)
└── Returns new JWT
│
▼
User's core.auth session is now fresh (30 days from now)
User switches to lang-learner → session_ref cookie is valid → silent SSOThis means:
- A user who uses at least one Medkronos app daily stays logged in indefinitely
- A user who stops using all apps for 30 days gets logged out
- No periodic pings or keep-alive needed — the refresh happens naturally during app usage
Cross-App Token Sharing
When a logged-in user navigates from one app to another, they don't need to re-enter credentials:
User at todo-app.medkronos.com (logged in)
│
▼
User clicks link to lang-learner.medkronos.com
│
▼
Lang-learner checks for valid JWT → none
│
▼
Redirects to core.auth/login?client_id=LANG_LEARNER&...
│
▼
core.auth detects: user already has active session (via core.auth session cookie)
│
▼
core.auth skips login UI, issues new auth code for lang-learner
│
▼
Redirect back to lang-learner with auth code
│
▼
Lang-learner exchanges code → gets its own JWT + session_ref cookie
│
▼
User is logged in to lang-learner without re-entering credentials ✓The core.auth Session Cookie
core.auth sets its own session cookie (on the
core.auth.medkronos.com domain) when the user logs in. This cookie is how core.auth knows the user is already authenticated when another app redirects to it.- HttpOnly, Secure, SameSite=Lax — not accessible to JavaScript, HTTPS only, sent on top-level navigations
- Separate from app sessions — this is core.auth's own session, not any app's
- Used only for SSO detection — when core.auth receives a login request and has this cookie, it skips the login UI
Per-App JWTs
Each app gets its own JWT and session. Sessions are never shared between apps. This means:
- Revoking one app's session doesn't affect others
- Each app has independent scopes
- Each app has independent session lifetimes
Silent Refresh (Frontend)
For SPA (single-page application) frontends, implement silent refresh to keep the JWT valid without user interaction:
const ACCESS_TOKEN_TTL = 15 * 60 * 1000; // 15 minutes
const REFRESH_BUFFER = 2 * 60 * 1000; // 2 minutes before expiry
let refreshTimer;
function scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(async () => {
const response = await fetch('https://core.console.medkronos.com/v1/auth/session/refresh', {
method: 'POST',
credentials: 'include' // sends session_ref cookie
});
if (response.ok) {
const { access_token } = await response.json();
// Update stored JWT
scheduleRefresh(); // Schedule next refresh
} else {
// Session invalid — redirect to login
window.location.href = '/auth/login';
}
}, ACCESS_TOKEN_TTL - REFRESH_BUFFER);
}
// Start on page load
scheduleRefresh();Service-to-Service Authentication (Internal)
For internal calls between core services (e.g., core.console → core.storage for file upload), core.console may obtain a service token from core.auth for the relayed hop.
> Important: Internal service-to-service calls (core.ai → core.auth for quotas, core.ai → core.storage for media) remain direct between core services — they are not project traffic. The hard rule governs PROJECT-facing traffic only: apps/projects never call microservices directly.
What Apps See
Apps call core.console with their project API key. Console handles authentication, routing, logging, and billing — the service token is an internal detail:
App backend needs to upload file to core.storage on behalf of user
│
▼
App calls core.console with project API key:
POST core.console.medkronos.com/v1/storage/upload
Authorization: Bearer <project_api_key>
Headers: X-User-Context: <user_id>
│
▼
core.console:
├── Validates project API key
├── Checks security rules (IP/domain)
├── Logs request
├── (internally) obtains service token from core.auth
├── Routes to core.storage with service token
│
▼
core.storage processes request, deducts from user's quotaInternal Service Token Flow (Behind the Console)
core.console requests service token from core.auth:
POST core.auth.medkronos.com/api/service-token
├── console credentials (internal, shared secret)
├── service: "core.storage"
├── scope: "file.upload"
└── user_id: (the user on whose behalf)
│
▼
core.auth validates console credentials, checks user has storage quota
│
▼
core.auth issues short-lived service token (1–5 min)
│
▼
core.console includes service token in core.storage request
Authorization: Bearer <service_token>
│
▼
core.storage validates service token (HMAC, same JWT format)
│
▼
core.storage processes request, deducts from user's quotaService Token Properties
- Short-lived — 1–5 minutes, no refresh
- Scoped — specific service + specific operation
- User-bound — tied to a specific user's quota
- App-bound — issued to a specific app, not transferable
- JWT format — same HS256 structure as access tokens, just different claims
App Authorization Consent Screen
When a user logs in or registers through an app (identified by the
connection_ref token), and the user hasn't previously authorized that app, core.auth shows an authorization consent screen before completing the flow.User completes login/registration
│
▼
core.auth checks: has user already authorized this app?
│
├── Yes → proceed to auth code issuance
└── No → show authorization consent screen
│
▼
"[App Name] wants to access your account"
"This app may request your personal info, like name, email, etc."
"Storage and AI usage in this app count towards your global account usage."
│
├── User clicks "Authorize" → record authorization, proceed
└── User clicks "Deny" → redirect back to app with errorPhase 1 (current): No granular scoping. Authorization is a binary yes/no — the app gets access to the user's basic profile data. The consent screen is informational, not configurational.
Phase 2+: The consent screen will show specific scopes the app is requesting, and the user can grant/deny individual permissions.
The authorization record is stored in
user_app_access. If the user denies, no record is created and the app receives an access_denied error in the callback.Logout
Single-App Logout
User clicks "Log out" in todo-app
│
▼
App calls core.console: POST /v1/auth/session/logout
│
▼
core.console → relays to core.auth:
├── Project API key (authenticates the app)
├── JWT (to identify session via sid claim)
└── session_ref cookie (sent automatically)
│
▼
core.auth:
├── Revokes the session row
├── Clears session_ref cookie (Set-Cookie with past expiry)
├── Logs logout event
└── Returns success
│
▼
App clears local JWT
│
▼
User is logged out of todo-app
(User still has sessions on other apps)Global Logout ("Log Out Everywhere")
User clicks "Log out everywhere" in account dashboard
│
▼
Account dashboard calls: POST /session/logout-all
├── JWT
└── password (re-verification)
│
▼
core.auth:
├── Revokes ALL sessions for this user
├── Logs global logout event
└── Returns success
│
▼
All JWTs expire within 15 minutes
User must re-authenticate everywhereNext: Database Schemas — complete SQL DDL for all tables.