Core Docs

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 SSO


This 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 quota


Internal 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 quota


Service 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 error


Phase 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 everywhere





Next: Database Schemas — complete SQL DDL for all tables.