security:sso-web
Differences
This shows you the differences between two versions of the page.
| Next revision | Previous revision | ||
| security:sso-web [2026/06/16 06:15] – created phong2018 | security:sso-web [2026/06/16 06:38] (current) – phong2018 | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| - | ====== OIDC SSO System — Traditional Web Application + JWT + JWKS ====== | + | ====== OIDC SSO System — Multi-Application |
| - | **Document Version: | + | **Document Version: |
| **Last Updated:** 2026-06-16 | **Last Updated:** 2026-06-16 | ||
| - | **Scope:** Single Sign-On | + | **Changes in v2.0:** Added full RSA Key Usage section; annotated every step in both scenarios |
| - | using Authorization Code Flow (confidential client), JWT access | + | with which key (private / public) is used and by which component. |
| - | | + | **Scope:** Single Sign-On |
| + | sharing one ServerSSO. A user authenticates once and gains access | ||
| + | | ||
| ---- | ---- | ||
| Line 12: | Line 14: | ||
| - [[# | - [[# | ||
| - | - [[#key-differences-from-spa-pkce|Key Differences from SPA/PKCE]] | + | - [[#what-makes-this-different|What Makes This Different]] |
| - [[# | - [[# | ||
| - | - [[#oidc-authorization-code-flow-confidential-client|OIDC Authorization Code Flow — Confidential Client]] | + | - [[#rsa-key-usage-private-key-vs-public-key|RSA Key Usage — Private Key vs Public Key]] |
| - | - [[#jwt--jwks-explained|JWT & JWKS Explained]] | + | - [[#sso-session-vs-app-session|SSO Session vs Application Session]] |
| - | - [[#session-management|Session Management]] | + | - [[#jwt--jwks-in-multi-app-sso|JWT & JWKS in Multi-App SSO]] |
| - | - [[# | + | - [[# |
| - | - [[# | + | - [[# |
| - [[# | - [[# | ||
| - [[# | - [[# | ||
| - | - [[# | ||
| - [[# | - [[# | ||
| - [[# | - [[# | ||
| Line 30: | Line 31: | ||
| ===== Architecture Overview ===== | ===== Architecture Overview ===== | ||
| - | |||
| - | This document describes a server-side SSO system where: | ||
| - | |||
| - | * The **Web application** is a traditional server-rendered app (PHP, Java, Python/ | ||
| - | * The web app is a **confidential client** — it has a '' | ||
| - | * The browser never sees tokens directly — they are stored server-side in a session | ||
| - | * **ServerA** and **ServerB** are backend microservices/ | ||
| - | * JWT validation on all resource servers uses **JWKS** — no SSO contact per request | ||
| < | < | ||
| - | ┌──────────────────────────────────────────────────────────────────────────┐ | + | ┌─────────────────────────────────────────────────────────────────────────────────┐ |
| - | │ | + | │ SYSTEM TOPOLOGY |
| - | │ │ | + | │ |
| - | │ | + | │ User' |
| - | │ ┌────────┐ | + | │ ┌──────────────────────────────┐ |
| - | │ │ | + | │ │ |
| - | │ │(Browser│ | + | │ |
| - | │ | + | │ │ │ │ |
| - | │ │ | + | │ │ Cookies held by browser: |
| - | │ | + | │ │ ┌─────────────────────────┐ │ |
| - | │ └──────────────┬──────────────┘ | + | │ │ │ session_a |
| - | │ │ │ | + | │ │ |
| - | │ ┌────────────────────────┼────────────────┐ | + | │ │ │ sso_session (sso domain)│ │ |
| - | │ | + | │ |
| - | │ | + | │ └───────────┬──────────────────┘ │ |
| - | │ ┌──────────────────┐ | + | │ │ HTTP + cookies |
| - | │ │ | + | │ ┌─────────┴─────────┐ |
| - | │ | + | │ │ |
| - | │ │ JWT via JWKS │ | + | │ |
| - | │ └──────────────────┘ └──────────────────┘ | + | │ ┌──────────────┐ ┌──────────────┐ |
| - | │ │ │ | + | │ │ WebA │ │ WebB |
| - | │ | + | │ │ Confidential │ │ Confidential │ │ (OIDC Identity Provider) │ │ |
| - | │ │ ServerSSO | + | │ │ OIDC Client |
| - | │ │ | + | │ │ |
| - | │ │ / | + | │ │ Session Store│ |
| - | │ └──────────────────────────────────────┘ | + | │ │ (Redis/ |
| - | └────────────────────────────────────────────────────────────────────────┘ | + | │ └──────────────┘ |
| + | └─────────────────────────────────────────────────────────────────────────────────┘ | ||
| </ | </ | ||
| - | ---- | + | **Core SSO Principle: |
| - | ===== Key Differences from SPA/PKCE ===== | + | The browser holds **three independent cookies** at steady state: |
| + | - '' | ||
| + | - '' | ||
| + | - '' | ||
| - | ^ Aspect | + | The SSO effect works because both WebA and WebB trust the same ServerSSO. When WebB |
| - | | Client type | Public (no secret) | + | redirects the user to ServerSSO for login and the SSO session cookie |
| - | | PKCE | Required | + | ServerSSO skips the login page and issues an authorization code immediately. |
| - | | Token storage | + | |
| - | | Token exchange | + | |
| - | | API calls | Browser → API (Bearer JWT) | Web server → API (Bearer JWT) | | + | |
| - | | Session management | + | |
| - | | Logout complexity | + | |
| - | | Security surface | + | |
| - | | Back-channel logout | + | |
| - | > **Security advantage | + | ---- |
| - | > XSS attacks cannot steal access_tokens or refresh_tokens. The browser only ever holds | + | |
| - | > a session cookie (HttpOnly | + | ===== What Makes This Different ===== |
| + | |||
| + | ^ Aspect | ||
| + | | Number | ||
| + | | client_id / client_secret | ||
| + | | Session stores | ||
| + | | id_token audience | ||
| + | | Browser cookies | ||
| + | | Cross-app login | N/A | Login to WebA → WebB login is seamless | ||
| + | | Cross-app logout | ||
| + | | Token scopes | ||
| ---- | ---- | ||
| Line 92: | Line 92: | ||
| ===== Components ===== | ===== Components ===== | ||
| - | ==== Web Application (Traditional / Server-Rendered) | + | ==== Browser |
| - | ^ Property | + | ^ Property |
| - | | Role | + | | Role |
| - | | Client Type | Confidential | + | | Cookies |
| - | | Auth Method | + | | Cookies (WebB) |
| - | | Token Storage | + | | Cookies (SSO) |
| - | | Session Cookie | + | | Sees | HTML pages only — never tokens |
| - | | Base URL | '' | + | |
| - | | Redirect URI | '' | + | |
| - | | Stack examples | + | |
| - | ==== ServerSSO (Identity Provider / Authorization Server) | + | ==== WebA ==== |
| - | ^ Property | + | ^ Property |
| - | | Role | OIDC Provider (OP) / Authorization Server | + | | Role | OIDC Relying Party — Confidential Client |
| - | | Standard | + | | client_id |
| - | | Token Format | + | | client_secret |
| - | | Base URL | '' | + | | Base URL | '' |
| - | | Discovery URL | + | | Redirect URI | '' |
| - | | JWKS URL | '' | + | | Session Store |
| - | | Token Endpoint | + | | Session Cookie |
| + | | Scopes requested | '' | ||
| - | ==== ServerA (Resource Server / Microservice) | + | ==== WebB ==== |
| - | ^ Property | + | ^ Property |
| - | | Role | + | | Role |
| - | | Called by | + | | client_id |
| - | | Validates | + | | client_secret |
| - | | Required Scope | '' | + | | Base URL | '' |
| - | | Base URL | '' | + | | Redirect URI | '' |
| - | | Network | + | | Session Store |
| + | | Session Cookie | ||
| + | | Scopes requested | '' | ||
| - | ==== ServerB (Resource Server / Microservice) | + | ==== ServerSSO |
| - | ^ Property | + | ^ Property |
| - | | Role | + | | Role |
| - | | Called by | + | | Standard |
| - | | Validates | + | | Token format |
| - | | Required Scope | '' | + | | Base URL | '' |
| - | | Base URL | '' | + | | Discovery URL |
| + | | JWKS URL | '' | ||
| + | | SSO Session TTL | 8 hours (inactivity); | ||
| + | | Registered clients | web-a-001, web-b-001 (and others) | ||
| ---- | ---- | ||
| - | ===== OIDC Authorization Code Flow — Confidential Client | + | ===== RSA Key Usage — Private Key vs Public Key ===== |
| - | ==== Flow Overview | + | ==== The Asymmetric Key Pair ==== |
| + | |||
| + | ServerSSO uses **RS256** (RSA + SHA-256), an asymmetric signing algorithm. | ||
| + | This means two mathematically linked keys exist with strictly separate roles: | ||
| < | < | ||
| - | 1. User visits protected page → Web App detects no session | + | ┌─────────────────────────────────────────────────────────────────────────────┐ |
| - | 2. Web App redirects browser → ServerSSO /authorize | + | │ RSA KEY PAIR — WHO HOLDS WHAT │ |
| - | 3. | + | │ │ |
| - | 4. ServerSSO redirects browser → Web App / | + | │ |
| - | 5. Web App backend calls ServerSSO / | + | │ |
| - | 6. | + | │ |
| - | 7. Web App stores tokens in server-side session | + | │ |
| - | 8. Web App sets session cookie on browser | + | │ |
| - | 9. Web App calls ServerA/ | + | │ |
| - | 10. ServerA/B validate | + | │ |
| - | 11. Web App renders response | + | │ |
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | └─────────────────────────────────────────────────────────────────────────────┘ | ||
| </ | </ | ||
| - | ==== client_secret Authentication | + | ==== Fundamental Rule ==== |
| - | When the Web App exchanges | + | > **SIGN with PRIVATE KEY → VERIFY with PUBLIC KEY** |
| - | using its '' | + | > |
| + | > Only ServerSSO can create valid JWT signatures (it alone holds the private key). | ||
| + | > Anyone with the public key (WebA, WebB, or any other party) can verify those signatures | ||
| + | > but can NEVER forge a new one. | ||
| + | |||
| + | ==== Key Usage Master Table ==== | ||
| + | |||
| + | ^ Component | ||
| + | | ServerSSO | ||
| + | | ServerSSO | ||
| + | | ServerSSO | ||
| + | | ServerSSO | ||
| + | | WebA | 🔓 Public | ||
| + | | WebA | 🔓 Public | ||
| + | | WebB | 🔓 Public | ||
| + | | WebB | 🔓 Public | ||
| + | |||
| + | > **Note on access_token: | ||
| + | > server-side and forward | ||
| + | > access_token's claims (e.g. to check roles before calling an API), they would also | ||
| + | > use the public | ||
| + | |||
| + | ==== Where the Private Key Lives ==== | ||
| < | < | ||
| - | Method 1 — HTTP Basic Auth (client_secret_basic): | + | ServerSSO key storage options |
| - | Authorization: | + | |
| - | Method | + | 1. Hardware Security Module (HSM) |
| - | | + | → Key material never exists in software memory |
| + | → Signing operation performed inside HSM hardware | ||
| + | → Best for production | ||
| + | |||
| + | 2. Cloud KMS (AWS KMS, GCP Cloud KMS, Azure Key Vault) | ||
| + | → Private key stored in managed key store | ||
| + | → Signing done via API call to KMS | ||
| + | → Key never exported to disk | ||
| + | |||
| + | 3. Encrypted key file (minimum acceptable) | ||
| + | → PEM file encrypted with passphrase | ||
| + | → Loaded into process memory at startup | ||
| + | → Passphrase injected via secrets manager at deploy time | ||
| + | → Never committed to source control | ||
| + | |||
| + | ❌ NEVER: | ||
| + | → Hardcode in source code | ||
| + | → Store in environment variable in plaintext | ||
| + | → Check into git | ||
| + | → Log to any output | ||
| + | → Transmit over the network | ||
| </ | </ | ||
| - | The SSO server trusts this client to: | + | ==== Where the Public Key Travels ==== |
| - | * Exchange codes directly (no PKCE required, though it can be added) | + | |
| - | * Store and use refresh_tokens securely | + | |
| - | * Not expose tokens to end-users | + | |
| - | ---- | + | < |
| + | Public key distribution path: | ||
| - | ===== JWT & JWKS Explained ===== | + | ServerSSO private key |
| + | │ | ||
| + | │ RSA key generation (one-time) | ||
| + | ▼ | ||
| + | ServerSSO public key ←── stored alongside private key | ||
| + | │ | ||
| + | │ Exposed at JWKS endpoint (intentionally public) | ||
| + | ▼ | ||
| + | GET https:// | ||
| + | │ | ||
| + | | ||
| + | │ | ||
| + | | ||
| - | ==== JWT Structure | + | The public key contains ONLY: |
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | |||
| + | The public key does NOT contain: | ||
| + | " | ||
| + | " | ||
| + | </ | ||
| + | |||
| + | ==== Step-by-Step Key Operations During | ||
| < | < | ||
| - | A JWT = header.payload.signature (all base64url-encoded) | + | STEP 1 — JWT Creation (ServerSSO, private key) |
| + | ────────────────────────────────────────────── | ||
| + | ServerSSO assembles: | ||
| + | | ||
| + | | ||
| - | Decoded Header: | + | signing_input = base64url(header) + " |
| + | |||
| + | 🔐 signature = RSA_PKCS1v15_SIGN( | ||
| + | key = private_key, | ||
| + | hash = SHA256(signing_input) | ||
| + | ) | ||
| + | |||
| + | JWT = signing_input + " | ||
| + | |||
| + | STEP 2 — JWT Transmission | ||
| + | ────────────────────────────────────────────── | ||
| + | ServerSSO → WebA/WebB: JWT returned in /token response (HTTPS) | ||
| + | WebA/WebB stores JWT in Redis session — browser never sees it | ||
| + | WebA/WebB sends JWT to resource APIs as Bearer token (HTTPS) | ||
| + | |||
| + | STEP 3 — JWT Verification (WebA or WebB, public key) | ||
| + | ────────────────────────────────────────────────────── | ||
| + | Receiver (WebA/WebB) performs: | ||
| + | |||
| + | 1. Split JWT into header, payload, signature | ||
| + | 2. Decode header → extract " | ||
| + | 3. Fetch public key from JWKS cache matching kid | ||
| + | 4. Reconstruct signing_input = header + " | ||
| + | |||
| + | 🔓 valid = RSA_PKCS1v15_VERIFY( | ||
| + | key = public_key, | ||
| + | hash = SHA256(signing_input), | ||
| + | signature = base64url_decode(signature_part) | ||
| + | ) | ||
| + | |||
| + | 5. If valid == true → signature is authentic → trust payload claims | ||
| + | 6. Validate iss, aud, exp, nbf, iat, nonce, scope | ||
| + | </ | ||
| + | |||
| + | ==== Key Rotation ==== | ||
| + | |||
| + | RSA keys should be rotated periodically (every 90 days recommended). | ||
| + | ServerSSO supports multiple active keys in JWKS simultaneously during rotation: | ||
| + | |||
| + | < | ||
| + | During key rotation, JWKS contains TWO keys: | ||
| + | |||
| + | GET / | ||
| { | { | ||
| - | " | + | |
| - | "typ": "JWT", | + | { |
| - | " | + | " |
| + | " | ||
| + | "n": "old-modulus...", | ||
| + | }, | ||
| + | { | ||
| + | | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | ] | ||
| } | } | ||
| - | Decoded Payload | + | Rotation process: |
| + | 1. Generate new RSA key pair on ServerSSO | ||
| + | 2. Add new public key to JWKS (both keys now published) | ||
| + | 3. Start signing NEW tokens with new private key | ||
| + | 4. Old tokens (signed with old key) still verifiable via old public key in JWKS | ||
| + | 5. Wait for all old tokens to expire (max access_token | ||
| + | 6. Remove old public key from JWKS | ||
| + | 7. Decommission old private key | ||
| + | |||
| + | WebA and WebB handle this automatically: | ||
| + | - If JWT header.kid not found in cache → re-fetch JWKS → find new key → verify | ||
| + | - No restart, no config change required | ||
| + | </ | ||
| + | |||
| + | ==== Summary: Who Uses Which Key and Why ==== | ||
| + | |||
| + | < | ||
| + | ╔══════════════╦════════════════╦══════════════════════════════════╦══════════════════════════╗ | ||
| + | ║ Component | ||
| + | ╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣ | ||
| + | ║ ServerSSO | ||
| + | ║ ║ ║ (RSA-SHA256 signature) | ||
| + | ║ ║ ║ ║ logout_token | ||
| + | ╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣ | ||
| + | ║ ServerSSO | ||
| + | ║ ║ ║ for consumers to fetch | ||
| + | ╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣ | ||
| + | ║ WebA ║ 🔓 PUBLIC KEY ║ Verifies id_token signature | ||
| + | ║ (fetched | ||
| + | ║ and cached)║ | ||
| + | ╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣ | ||
| + | ║ WebB ║ 🔓 PUBLIC KEY ║ Verifies id_token signature | ||
| + | ║ (fetched | ||
| + | ║ and cached)║ | ||
| + | ╚══════════════╩════════════════╩══════════════════════════════════╩══════════════════════════╝ | ||
| + | |||
| + | KEY RULE: ServerSSO is the ONLY component that ever touches the private key. | ||
| + | WebA and WebB ONLY ever hold and use the public key. | ||
| + | The private key NEVER travels over a network. | ||
| + | The public key is INTENTIONALLY distributed to all verifiers. | ||
| + | </ | ||
| + | |||
| + | ---- | ||
| + | |||
| + | ===== SSO Session vs Application Session ===== | ||
| + | |||
| + | Understanding the difference between these three layers is essential: | ||
| + | |||
| + | < | ||
| + | ┌─────────────────────────────────────────────────────────────────────┐ | ||
| + | │ LAYER 1 — SSO Session (ServerSSO) | ||
| + | │ │ | ||
| + | │ Created when: User successfully authenticates at ServerSSO | ||
| + | │ Lives at: | ||
| + | │ Identified by: sso_session cookie (domain: sso.example.com) | ||
| + | │ TTL: 8 hours inactivity / 24 hours absolute | ||
| + | │ Contains: | ||
| + | │ Purpose: | ||
| + | │ │ | ||
| + | │ ServerSSO session record: | ||
| + | │ { │ | ||
| + | │ sso_session_id: | ||
| + | │ sub: " | ||
| + | │ email: | ||
| + | │ authenticated_clients: | ||
| + | │ auth_time: | ||
| + | │ last_activity: | ||
| + | │ } │ | ||
| + | └─────────────────────────────────────────────────────────────────────┘ | ||
| + | |||
| + | ┌─────────────────────────────────────────────────────────────────────┐ | ||
| + | │ LAYER 2 — WebA Application Session | ||
| + | │ │ | ||
| + | │ Created when: WebA completes OIDC token exchange | ||
| + | │ Lives at: | ||
| + | │ Identified by: session_a cookie (domain: web-a.example.com) | ||
| + | │ TTL: 8 hours │ | ||
| + | │ Contains: | ||
| + | │ Purpose: | ||
| + | │ │ | ||
| + | │ WebA session record: | ||
| + | │ { │ | ||
| + | │ session_id: | ||
| + | │ sub: " | ||
| + | │ email: | ||
| + | │ access_token: | ||
| + | │ access_token_exp: | ||
| + | │ refresh_token: | ||
| + | │ id_token: | ||
| + | │ sso_session_id: | ||
| + | │ created_at: | ||
| + | │ } │ | ||
| + | └─────────────────────────────────────────────────────────────────────┘ | ||
| + | |||
| + | ┌─────────────────────────────────────────────────────────────────────┐ | ||
| + | │ LAYER 3 — WebB Application Session | ||
| + | │ │ | ||
| + | │ Created when: WebB completes its own OIDC token exchange | ||
| + | │ Lives at: | ||
| + | │ Identified by: session_b cookie (domain: web-b.example.com) | ||
| + | │ TTL: 8 hours │ | ||
| + | │ Contains: | ||
| + | │ Purpose: | ||
| + | │ │ | ||
| + | │ WebB session record: | ||
| + | │ { │ | ||
| + | │ session_id: | ||
| + | │ sub: " | ||
| + | │ access_token: | ||
| + | │ access_token_exp: | ||
| + | │ refresh_token: | ||
| + | │ sso_session_id: | ||
| + | │ created_at: | ||
| + | │ } │ | ||
| + | └─────────────────────────────────────────────────────────────────────┘ | ||
| + | </ | ||
| + | |||
| + | > **Key insight:** WebA and WebB each maintain completely independent sessions and tokens. | ||
| + | > They share only the '' | ||
| + | > WebA's tokens cannot be used to call WebB's APIs and vice versa. | ||
| + | |||
| + | ---- | ||
| + | |||
| + | ===== JWT & JWKS in Multi-App SSO ===== | ||
| + | |||
| + | ==== Different Tokens for Different Apps ==== | ||
| + | |||
| + | When a user logs into WebA and WebB, ServerSSO issues **different JWTs** to each. | ||
| + | Both are signed with the **same RSA private key** but carry different audience and scope claims: | ||
| + | |||
| + | < | ||
| + | JWT issued to WebA (via web-a-001 client): | ||
| + | 🔐 SIGNED with ServerSSO RSA PRIVATE KEY (kid: " | ||
| { | { | ||
| " | " | ||
| " | " | ||
| - | " | + | " |
| " | " | ||
| " | " | ||
| - | | + | " |
| - | | + | |
| " | " | ||
| - | | + | " |
| - | | + | " |
| - | " | + | |
| - | " | + | |
| } | } | ||
| - | Decoded Payload | + | JWT issued to WebB (via web-b-001 client): |
| + | 🔐 SIGNED with ServerSSO RSA PRIVATE KEY (kid: " | ||
| { | { | ||
| " | " | ||
| " | " | ||
| - | " | + | " |
| - | " | + | " |
| - | " | + | " |
| - | "nonce": "random-nonce-xyz", | + | "jti": |
| " | " | ||
| - | "name": | + | "scope": |
| + | " | ||
| } | } | ||
| + | |||
| + | Verification by WebA or WebB: | ||
| + | 🔓 VERIFY with ServerSSO RSA PUBLIC KEY (fetched from JWKS, cached) | ||
| + | Both apps use the same public key from the same JWKS endpoint. | ||
| </ | </ | ||
| - | ==== JWKS Endpoint | + | Both JWTs are signed with the **same RSA private key** on ServerSSO and verified against |
| + | the **same JWKS endpoint**. The separation is in '' | ||
| + | |||
| + | ==== JWKS Shared by All Clients | ||
| <code json> | <code json> | ||
| // GET https:// | // GET https:// | ||
| - | // Response cached | + | // Used by WebA, WebB, and any resource servers |
| { | { | ||
| " | " | ||
| Line 236: | Line 520: | ||
| </ | </ | ||
| - | ==== JWT Validation on Resource Servers | + | ==== id_token Audience Scoping |
| < | < | ||
| - | Resource Server (ServerA or ServerB) validates each incoming JWT: | + | id_token for WebA login: |
| + | " | ||
| - | 1. Parse JWT header → extract " | + | id_token for WebB login: |
| - | 2. Look up JWKS cache → find key where key.kid == header.kid | + | " |
| - | 3. If not found in cache → re-fetch /jwks.json → update cache → retry | + | |
| - | 4. Verify RSA signature using the public key | + | |
| - | 5. Check: payload.iss == "https:// | + | |
| - | 6. Check: payload.aud includes this server' | + | |
| - | 7. Check: payload.exp > current_unix_timestamp | + | |
| - | 8. Check: payload.nbf <= current_unix_timestamp (± clock_skew) | + | |
| - | 9. Check: payload.iat is not in the future (± clock_skew) | + | |
| - | 10. Check: payload.scope includes required scope ("api:serverA" | + | |
| - | 11. Optionally check: payload.jti not in revocation list | + | |
| - | 12. Extract sub, email, roles for business logic | + | |
| - | </ | + | |
| - | > **Critical: | + | This prevents id_token |
| - | > JWT validation is entirely offline using the cached RSA public key from JWKS. | + | </code> |
| - | > The SSO server is only contacted for JWKS key rotation (every ~1 hour or on unknown kid). | + | |
| ---- | ---- | ||
| - | ===== Session Management | + | ===== Scenario 1: Unauthenticated User — Visits WebA then WebB ===== |
| - | ==== Server-Side Session Store ==== | + | ==== Overview |
| - | The Web App maintains a session | + | Alice has no session |
| - | The browser | + | then navigates to WebB. She should |
| - | < | + | This scenario has two parts: |
| - | Browser Cookie: | + | |
| - | | + | |
| - | + | ||
| - | Server-Side Session Record | + | |
| - | { | + | |
| - | | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | </ | + | |
| - | + | ||
| - | ==== Session vs Token Lifecycle ==== | + | |
| - | + | ||
| - | < | + | |
| - | Timeline: | + | |
| - | + | ||
| - | ├── User logs in ──────────────────────────────────────────────────────▶ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ Web session expires: | + | |
| - | │ SSO session expires: | + | |
| - | │ | + | |
| - | └── User logs out ─────────────────────────────────────────────────────▶ | + | |
| - | 1. Web App deletes server-side session | + | |
| - | 2. Clears session cookie | + | |
| - | 3. Calls SSO /logout (terminates SSO session) | + | |
| - | 4. SSO triggers backchannel logout to all registered clients | + | |
| - | </ | + | |
| ---- | ---- | ||
| - | ===== Scenario 1: Unauthenticated User ===== | + | ==== PART A — First Login via WebA ==== |
| - | ==== Overview ==== | + | === Step A1 — Browser Requests Protected Page on WebA === |
| - | + | ||
| - | Alice opens her browser and navigates to '' | + | |
| - | She has no active session. The system must authenticate her and then serve the page. | + | |
| - | + | ||
| - | ---- | + | |
| - | + | ||
| - | ==== Step 1 — Browser Requests Protected Page ==== | + | |
| < | < | ||
| - | Browser → Web App: | + | Browser → WebA: |
| - | GET https:// | + | GET https://web-a.example.com/ |
| - | Cookie: (none / no valid session cookie) | + | Cookie: (none) |
| </ | </ | ||
| - | ---- | + | === Step A2 — WebA Detects No Session === |
| - | + | ||
| - | ==== Step 2 — Web App Detects No Session | + | |
| <code python> | <code python> | ||
| - | # Web App middleware | + | # WebA auth middleware |
| - | def require_auth(view_func): | + | def require_auth_weba(request): |
| - | | + | session_id = request.COOKIES.get(' |
| - | | + | session |
| - | session | + | |
| - | | + | |
| - | | + | # Generate CSRF state and id_token nonce |
| - | | + | state = generate_secure_random(32) |
| + | nonce = generate_secure_random(32) | ||
| + | |||
| + | # Store temporarily (5 min TTL) for callback validation | ||
| + | redis.setex(f" | ||
| + | | ||
| + | | ||
| + | })) | ||
| - | | + | |
| - | | + | client_id |
| - | | + | redirect_uri = ' |
| + | scope | ||
| + | | ||
| + | | ||
| + | ) | ||
| </ | </ | ||
| - | **What happens:** No session found (or session expired). The Web App: | + | === Step A3 — WebA Redirects Browser to ServerSSO /authorize === |
| - | - Generates a random '' | + | |
| - | - Generates a random '' | + | |
| - | - Stores both in a short-lived pre-auth cookie or server-side temp store | + | |
| - | - Builds the /authorize URL | + | |
| - | - Returns HTTP 302 to the browser | + | |
| - | + | ||
| - | ---- | + | |
| - | + | ||
| - | ==== Step 3 — Web App Redirects Browser to ServerSSO / | + | |
| < | < | ||
| - | Web App → Browser: | + | WebA → Browser: |
| HTTP 302 | HTTP 302 | ||
| Location: https:// | Location: https:// | ||
| ? | ? | ||
| - | & | + | & |
| - | & | + | & |
| - | & | + | & |
| - | &state=csrf-token-f3a8b2 | + | &state=state-weba-f3a8b2c1 |
| - | &nonce=replay-token-9d4e1c | + | &nonce=nonce-weba-9d4e1c7a |
| </ | </ | ||
| - | **Parameters: | + | === Step A4 — Browser Follows Redirect to ServerSSO === |
| - | + | ||
| - | ^ Parameter | + | |
| - | | response_type | '' | + | |
| - | | client_id | + | |
| - | | redirect_uri | + | |
| - | | scope | '' | + | |
| - | | state | Random CSRF token | Prevents CSRF on callback | + | |
| - | | nonce | Random value | Embedded in id_token to prevent replay | + | |
| - | + | ||
| - | > **Note:** No PKCE parameters. The confidential client authenticates via '' | + | |
| - | > at the token endpoint instead. PKCE can be added as defense-in-depth. | + | |
| - | + | ||
| - | ---- | + | |
| - | + | ||
| - | ==== Step 4 — Browser Follows Redirect to ServerSSO | + | |
| < | < | ||
| Browser → ServerSSO: | Browser → ServerSSO: | ||
| - | GET https:// | + | GET https:// |
| - | Cookie: (no SSO session | + | Cookie: (no sso_session |
| </ | </ | ||
| - | ServerSSO | + | ServerSSO: no SSO session |
| - | ---- | + | === Step A5 — ServerSSO Presents Login Page === |
| - | + | ||
| - | ==== Step 5 — ServerSSO Presents Login Page ==== | + | |
| < | < | ||
| ServerSSO → Browser: | ServerSSO → Browser: | ||
| - | HTTP 200 | + | HTTP 200 Content-Type: |
| - | | + | |
| - | Set-Cookie: sso_pre_session=temp123; | + | |
| - | | + | <form method=" |
| - | | + | <input name=" |
| - | <input name=" | + | <input name=" |
| - | <input name=" | + | < |
| - | < | + | </ |
| - | </form> | + | |
| - | </html> | + | |
| </ | </ | ||
| - | ---- | + | === Step A6 — Alice Submits Credentials === |
| - | + | ||
| - | ==== Step 6 — Alice Submits Credentials | + | |
| < | < | ||
| Browser → ServerSSO: | Browser → ServerSSO: | ||
| POST https:// | POST https:// | ||
| - | Content-Type: | ||
| - | Cookie: sso_pre_session=temp123 | ||
| - | |||
| username=alice%40example.com& | username=alice%40example.com& | ||
| </ | </ | ||
| - | **ServerSSO | + | ServerSSO |
| - | - Validates credentials against user store (LDAP, database, etc.) | + | - Creates SSO session '' |
| - | - If MFA configured: presents second factor challenge | + | - Records '' |
| - | - On success: creates SSO session, sets SSO session cookie | + | - Generates authorization code '' |
| - | < | + | === Step A7 — ServerSSO |
| - | ServerSSO → Browser: | + | |
| - | HTTP 302 | + | |
| - | Set-Cookie: sso_session=SESSION-XYZ; | + | |
| - | </ | + | |
| - | + | ||
| - | > **Note:** The SSO session cookie uses '' | + | |
| - | > in cross-origin redirects from the Web App domain to the SSO domain. | + | |
| - | + | ||
| - | ---- | + | |
| - | + | ||
| - | ==== Step 7 — ServerSSO | + | |
| < | < | ||
| ServerSSO → Browser: | ServerSSO → Browser: | ||
| HTTP 302 | HTTP 302 | ||
| - | | + | |
| - | ?code=SplxlOBeZQQYbYS6WxSbIA | + | |
| - | &state=csrf-token-f3a8b2 | + | |
| - | + | | |
| - | ServerSSO stores internally: | + | |
| - | | + | |
| - | client_id: " | + | |
| - | redirect_uri: | + | |
| - | | + | |
| - | nonce: | + | |
| - | | + | |
| - | expires_at: | + | |
| - | } | + | |
| </ | </ | ||
| - | ---- | + | > **The SSO session cookie is now set on the SSO domain.** |
| + | > The browser will automatically send it on all future requests to '' | ||
| - | ==== Step 8 — Browser Follows Redirect to Web App Callback ==== | + | === Step A8 — Browser Follows Redirect to WebA / |
| < | < | ||
| - | Browser → Web App: | + | Browser → WebA: |
| - | GET https:// | + | GET https://web-a.example.com/ |
| - | ?code=SplxlOBeZQQYbYS6WxSbIA | + | ?code=CODE-A-111 |
| - | &state=csrf-token-f3a8b2 | + | &state=state-weba-f3a8b2c1 |
| - | Cookie: | + | Cookie: |
| </ | </ | ||
| - | ---- | + | === Step A9 — WebA Validates state and Exchanges Code === |
| - | + | ||
| - | ==== Step 9 — Web App Validates state (CSRF Check) ==== | + | |
| <code python> | <code python> | ||
| - | # Web App callback handler | + | def callback_weba(request): |
| - | def auth_callback(request): | + | code = request.GET[' |
| - | code = request.GET.get(' | + | state = request.GET[' |
| - | state = request.GET.get(' | + | |
| - | # Retrieve stored | + | # Retrieve stored pre-auth |
| - | | + | |
| - | if not state or state != stored_state: | + | if not preauth: |
| - | raise SecurityError(" | + | raise SecurityError(" |
| - | | + | |
| - | | + | |
| - | # Proceed to token exchange | + | # Exchange code for tokens — SERVER-TO-SERVER call |
| - | | + | |
| - | </code> | + | code |
| + | | ||
| + | client_secret = WEBA_CLIENT_SECRET, | ||
| + | redirect_uri | ||
| + | ) | ||
| - | **What is validated: | + | # Validate id_token |
| - | | + | |
| - | - '' | + | token = tokens['id_token'], |
| + | audience = 'web-a-001', | ||
| + | nonce = preauth_data['nonce'] | ||
| + | ) | ||
| - | ---- | + | # Create WebA session |
| + | session_id = create_session_weba(tokens, | ||
| - | ==== Step 10 — Web App Backend Exchanges Code for Tokens | + | response |
| + | response.set_cookie(' | ||
| + | httponly=True, secure=True, | ||
| + | samesite=' | ||
| + | return response | ||
| + | </ | ||
| - | This call is **server-to-server** — the browser is NOT involved. | + | === Step A10 — WebA Exchanges Code at ServerSSO (Server-to-Server) === |
| < | < | ||
| - | Web App Backend → ServerSSO: | + | WebA Backend → ServerSSO: |
| POST https:// | POST https:// | ||
| + | Authorization: | ||
| Content-Type: | Content-Type: | ||
| - | Authorization: | ||
| grant_type=authorization_code | grant_type=authorization_code | ||
| - | &code=SplxlOBeZQQYbYS6WxSbIA | + | &code=CODE-A-111 |
| - | & | + | & |
| </ | </ | ||
| - | **ServerSSO | + | ServerSSO |
| - | - Authorization header contains valid '' | + | - '' |
| - | - '' | + | - '' |
| - | - '' | + | - '' |
| - | - '' | + | |
| - | ---- | + | === Step A11 — ServerSSO Returns Tokens to WebA === |
| - | ==== Step 11 — ServerSSO | + | > 🔐 **PRIVATE KEY USED HERE — ServerSSO |
| + | > '' | ||
| <code json> | <code json> | ||
| - | ServerSSO → Web App Backend: | + | ServerSSO → WebA Backend: |
| HTTP 200 | HTTP 200 | ||
| - | Content-Type: | ||
| - | |||
| { | { | ||
| - | " | + | " |
| " | " | ||
| " | " | ||
| - | " | + | " |
| " | " | ||
| - | " | + | " |
| } | } | ||
| + | |||
| + | JWT header (both tokens): | ||
| + | Signature computed with: 🔐 ServerSSO RSA PRIVATE KEY | ||
| </ | </ | ||
| - | ---- | + | ServerSSO also updates its SSO session: |
| + | < | ||
| + | SSO session "SSO-XYZ-789": | ||
| + | authenticated_clients: | ||
| + | </ | ||
| - | ==== Step 12 — Web App Validates id_token and Stores Session | + | === Step A12 — WebA Stores Session |
| - | <code python> | + | > 🔓 **PUBLIC KEY USED HERE — WebA fetches JWKS and verifies |
| - | # Validate | + | > WebA calls '' |
| - | id_token_payload = verify_jwt( | + | > matching ''kid: "key-2024-01"' |
| - | token = token_response['id_token' | + | > If verification passes, WebA trusts the claims (sub, email, nonce, |
| - | issuer | + | |
| - | | + | |
| - | | + | |
| - | algorithm = ' | + | |
| - | jwks_uri | + | |
| - | ) | + | |
| - | # Create server-side session | + | < |
| - | session_id = generate_secure_random_id() | + | WebA stores in Redis: |
| - | session_store.set(session_id, | + | Key: " |
| - | | + | |
| - | | + | |
| - | | + | email: |
| - | 'access_token': | + | access_token: |
| - | | + | access_token_exp: |
| - | | + | refresh_token: |
| - | | + | sso_session_id: |
| - | 'created_at': time.time() | + | created_at: |
| - | }, ttl=28800) | + | } |
| + | |||
| + | WebA → Browser: | ||
| + | HTTP 302 | ||
| + | Set-Cookie: session_a=SESS-A-abc123; | ||
| + | HttpOnly; Secure; SameSite=Lax; | ||
| + | Location: https:// | ||
| + | </ | ||
| + | |||
| + | === Step A13 — Browser Loads WebA Dashboard === | ||
| + | |||
| + | < | ||
| + | Browser → WebA: | ||
| + | GET https:// | ||
| + | Cookie: session_a=SESS-A-abc123 | ||
| + | |||
| + | WebA → Browser: | ||
| + | HTTP 200 | ||
| + | </ | ||
| + | |||
| + | WebA may call its own backend resources using the stored '' | ||
| + | receives rendered HTML. | ||
| + | |||
| + | **State after Part A:** | ||
| + | |||
| + | < | ||
| + | Browser cookies: | ||
| + | sso_session=SSO-XYZ-789 | ||
| + | | ||
| + | [NO session_b yet] | ||
| + | |||
| + | SSO session " | ||
| + | authenticated_clients: | ||
| + | |||
| + | WebA session: exists with JWT-A | ||
| + | WebB session: does NOT exist | ||
| </ | </ | ||
| ---- | ---- | ||
| - | ==== Step 13 — Web App Sets Session Cookie | + | ==== PART B — Seamless SSO Login to WebB ==== |
| + | |||
| + | Alice opens a new tab and navigates | ||
| + | |||
| + | === Step B1 — Browser Requests Protected Page on WebB === | ||
| < | < | ||
| - | Web App → Browser: | + | Browser |
| - | | + | |
| - | Location: | + | Cookie: |
| - | | + | |
| </ | </ | ||
| - | > **The browser now has only a session_id cookie | + | Note: '' |
| - | > All sensitive material | + | |
| - | ---- | + | === Step B2 — WebB Detects No Session === |
| - | ==== Step 14 — Browser Requests Dashboard | + | <code python> |
| + | # WebB auth middleware — identical pattern to WebA | ||
| + | def require_auth_webb(request): | ||
| + | session_id | ||
| + | session | ||
| + | |||
| + | if not session: | ||
| + | state = generate_secure_random(32) | ||
| + | nonce = generate_secure_random(32) | ||
| + | redis.setex(f" | ||
| + | ' | ||
| + | ' | ||
| + | })) | ||
| + | return redirect_to_sso_authorize( | ||
| + | client_id | ||
| + | redirect_uri = ' | ||
| + | scope = ' | ||
| + | state = state, | ||
| + | nonce = nonce | ||
| + | ) | ||
| + | </ | ||
| + | |||
| + | === Step B3 — WebB Redirects Browser to ServerSSO / | ||
| < | < | ||
| - | Browser | + | WebB → Browser: |
| - | | + | |
| - | | + | Location: |
| + | ? | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | &nonce=nonce-webb-4b8a3d6e | ||
| </ | </ | ||
| - | ---- | + | === Step B4 — Browser Follows Redirect to ServerSSO (WITH SSO Cookie) === |
| - | ==== Step 15 — Web App Calls ServerA (Server-to-Server, | + | < |
| + | Browser → ServerSSO: | ||
| + | GET https:// | ||
| + | Cookie: sso_session=SSO-XYZ-789 | ||
| + | </ | ||
| + | |||
| + | **This is the SSO magic moment.** | ||
| + | |||
| + | === Step B5 — ServerSSO Finds Active SSO Session — SKIPS Login Page === | ||
| <code python> | <code python> | ||
| - | # Web App loads session, retrieves access_token | + | # ServerSSO /authorize handler |
| - | session | + | def handle_authorize(request): |
| - | access_token | + | sso_session_id |
| + | | ||
| - | # Web App calls ServerA backend-to-backend | + | if sso_session and not is_expired(sso_session): |
| - | response | + | |
| - | 'https://api-a.example.com/ | + | # No login page needed — user already authenticated |
| - | | + | |
| - | 'Authorization': f'Bearer {access_token}', | + | # Check if re-authentication required (max_age, prompt, etc.) |
| - | 'X-Request-ID': generate_request_id() | + | # For normal flow: skip login entirely |
| - | } | + | |
| - | ) | + | # Generate new authorization code for web-b-001 |
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | nonce = request.params['nonce'] | ||
| + | ) | ||
| + | |||
| + | # Update SSO session | ||
| + | | ||
| + | sso_store.update(sso_session_id, | ||
| + | |||
| + | # Redirect immediately — no login page | ||
| + | return redirect(f" | ||
| + | |||
| + | else: | ||
| + | # No SSO session → show login page | ||
| + | return show_login_page(request) | ||
| </ | </ | ||
| - | **This is a server-to-server call — the browser is NOT involved.** | + | < |
| + | ServerSSO → Browser: | ||
| + | HTTP 302 | ||
| + | Location: https://web-b.example.com/ | ||
| + | ?code=CODE-B-222 | ||
| + | & | ||
| + | (NO login page was shown — completely transparent to Alice) | ||
| + | </ | ||
| - | ---- | + | === Step B6 — Browser Follows Redirect to WebB /callback === |
| - | ==== Step 16 — ServerA | + | < |
| + | Browser → WebB: | ||
| + | GET https:// | ||
| + | ?code=CODE-B-222 | ||
| + | & | ||
| + | Cookie: (no session_b — but that's fine, we have the code) | ||
| + | </ | ||
| + | |||
| + | === Step B7 — WebB Validates | ||
| <code python> | <code python> | ||
| - | # ServerA middleware | + | def callback_webb(request): |
| - | def validate_bearer_token(request): | + | |
| - | | + | |
| - | | + | |
| - | return Response(401, | + | |
| - | | + | |
| + | if not preauth: | ||
| + | raise SecurityError(" | ||
| - | | + | |
| - | header | + | |
| - | | + | |
| - | pub_key = find_key(jwks, | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | client_secret = WEBB_CLIENT_SECRET, |
| - | if not pub_key: | + | redirect_uri |
| - | return Response(401, | + | ) |
| - | | + | |
| - | payload | + | |
| - | | + | audience |
| - | | + | |
| - | 'algorithm': | + | ) |
| - | | + | |
| - | | + | |
| - | if ' | + | |
| - | return Response(403, | + | |
| - | | + | |
| - | | + | |
| + | httponly=True, | ||
| + | samesite=' | ||
| + | return response | ||
| </ | </ | ||
| - | **ServerSSO | + | === Step B8 — WebB Exchanges Code at ServerSSO |
| - | using the cached RSA public key. | + | |
| - | ---- | + | < |
| + | WebB Backend → ServerSSO: | ||
| + | POST https:// | ||
| + | Authorization: | ||
| + | |||
| + | grant_type=authorization_code | ||
| + | & | ||
| + | & | ||
| + | </ | ||
| + | |||
| + | === Step B9 — ServerSSO Returns WebB-Specific Tokens === | ||
| - | ==== Step 17 — ServerA Returns Data to Web App ==== | + | > 🔐 **PRIVATE KEY USED HERE — ServerSSO signs new JWTs for WebB with the same RSA private key** |
| + | > JWT-B is a brand-new token with different '' | ||
| + | > by the same private key (identified by '' | ||
| <code json> | <code json> | ||
| - | ServerA | + | ServerSSO |
| HTTP 200 | HTTP 200 | ||
| { | { | ||
| - | "items": | + | "access_token": |
| - | "user": "alice@example.com" | + | "token_type": |
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| } | } | ||
| + | |||
| + | JWT header (both tokens): | ||
| + | Signature computed with: 🔐 ServerSSO RSA PRIVATE KEY (same key as JWT-A) | ||
| </ | </ | ||
| - | ---- | + | Note: '' |
| + | '' | ||
| - | ==== Step 18 — Web App Calls ServerB (Same Pattern) ==== | + | === Step B10 — WebB Creates Session, Sets Cookie, Redirects |
| - | The Web App calls ServerB with the same access_token: | + | > 🔓 **PUBLIC KEY USED HERE — WebB verifies id_token signature** |
| + | > WebB fetches JWKS (or uses its own cache), finds '' | ||
| + | > verifies | ||
| + | > and '' | ||
| + | > both share the same JWKS endpoint. | ||
| < | < | ||
| - | Web App Backend | + | WebB stores in Redis: |
| - | | + | Key: " |
| - | | + | Value: { |
| + | sub: " | ||
| + | email: | ||
| + | access_token: | ||
| + | access_token_exp: | ||
| + | refresh_token: | ||
| + | sso_session_id: | ||
| + | created_at: | ||
| + | } | ||
| + | |||
| + | WebB → Browser: | ||
| + | | ||
| + | Set-Cookie: session_b=SESS-B-def456; | ||
| + | HttpOnly; Secure; SameSite=Lax; | ||
| + | | ||
| </ | </ | ||
| - | ServerB performs the same JWKS-based JWT validation, checking: | + | === Step B11 — Browser Loads WebB Reports Page === |
| - | - '' | + | |
| - | - '' | + | |
| - | ---- | + | < |
| + | Browser → WebB: | ||
| + | GET https://web-b.example.com/ | ||
| + | Cookie: session_b=SESS-B-def456 | ||
| - | ==== Step 19 — Web App Renders Final HTML Response ==== | + | WebB → Browser: |
| + | HTTP 200 (HTML reports page) | ||
| + | </ | ||
| - | <code | + | Alice is now logged into WebB without ever having entered her credentials a second time. |
| - | Web App → Browser: | + | |
| - | HTTP 200 | + | |
| - | Content-Type: | + | |
| - | < | + | **Final state after Scenario 1:** |
| - | < | + | |
| - | < | + | |
| - | < | + | |
| - | <!-- Data from ServerA and ServerB merged into page --> | + | |
| - | </ | + | |
| - | </ | + | |
| - | </ | + | |
| - | The browser receives only rendered HTML. **No tokens appear in the browser.** | + | < |
| + | Browser cookies: | ||
| + | sso_session=SSO-XYZ-789 | ||
| + | session_a=SESS-A-abc123 | ||
| + | session_b=SESS-B-def456 | ||
| - | ---- | + | SSO session "SSO-XYZ-789": |
| + | authenticated_clients: | ||
| + | |||
| + | WebA session SESS-A: exists, JWT-A (aud=resource-a, | ||
| + | WebB session SESS-B: exists, JWT-B (aud=resource-b, | ||
| + | </ | ||
| ==== Scenario 1 — Complete Flow Summary ==== | ==== Scenario 1 — Complete Flow Summary ==== | ||
| < | < | ||
| - | Browser | + | Browser |
| - | │ | + | │ |
| - | │─ GET /dash ──▶│ | + | │ PART A — First login (via WebA) │ |
| - | │ | + | │─ GET /dash ─▶│ |
| - | │◀─ 302 /auth ─│ | + | │ │ no session_a |
| - | │ | + | │◀─ 302 ──────│ │ |
| - | │─ GET /authorize ───────────────▶│ | + | │─ GET / |
| - | │◀─ | + | |
| - | │─ POST /login ──────────────────▶│ | + | │◀─ login page ──────────────────────────────│ |
| - | │◀─ SSO session cookie | + | │─ POST /login ──────────────────────────────▶│ |
| - | │◀─ 302 / | + | │◀─ |
| - | │ | + | │─ GET / |
| - | │─ GET / | + | │ │─ POST / |
| - | │ | + | │ │ (web-a-001 |
| - | │ | + | │ │◀─ {JWT-A, id_token, refresh} ──│ |
| - | │ | + | |
| - | │ | + | |
| - | │◀─ | + | │─ GET /dash ─▶│ |
| - | │ │ │ │ | + | │◀─ |
| - | │─ GET /dash ──▶│ | + | │ │ |
| - | │ | + | │ PART B — Seamless SSO login (WebB) |
| - | │ │─ GET /api ──────────────────────▶│ │ | + | │─ GET /reports ────────────▶│ |
| - | │ │ (Bearer JWT, s2s) | + | │ │ |
| - | │ | + | │◀─ 302 ─────────────────────│ |
| - | │ | + | │─ |
| - | │ | + | |
| - | │ | + | │ │ |
| - | │ | + | │◀─ 302 /callback? |
| - | │ | + | │─ GET / |
| - | │◀─ 200 HTML ──│ | + | │ │ │─ POST / |
| + | │ | ||
| + | │ │ | ||
| + | │ | ||
| + | | ||
| + | │─ GET / | ||
| + | │◀─ 200 HTML ────────────────│ | ||
| </ | </ | ||
| ---- | ---- | ||
| - | ===== Scenario 2: Authenticated User ===== | + | ===== Scenario 2: Authenticated User — Returns to WebA, Opens WebB ===== |
| ==== Overview ==== | ==== Overview ==== | ||
| - | Alice already has an active session | + | Alice already has valid sessions on both WebA and WebB (from Scenario 1 or a previous |
| - | pages, triggering calls to ServerA and ServerB. | + | This scenario covers |
| - | * **2A** — Session | + | * **2A** — Returns to WebA with a valid session |
| - | * **2B** — Session valid but access_token | + | * **2B** — WebA's token has expired |
| - | * **2C** — Session | + | * **2C** — WebA's session has expired |
| + | * **2D** — Opens WebB simultaneously — each app manages its session independently | ||
| ---- | ---- | ||
| - | ==== Scenario 2A — Session Active, Token Valid ==== | + | ==== Scenario 2A — Both Sessions |
| - | === Step 1 — Browser Requests Page with Session Cookie | + | === Accessing WebA === |
| < | < | ||
| - | Browser → Web App: | + | Browser → WebA: |
| - | GET https:// | + | GET https://web-a.example.com/ |
| - | Cookie: | + | Cookie: |
| - | </ | + | |
| - | === Step 2 — Web App Validates Session === | + | WebA middleware: |
| + | session | ||
| + | → valid, not expired | ||
| + | → access_token not expired (exp > now + 60s) | ||
| + | → serve request directly | ||
| - | <code python> | + | WebA → Browser: |
| - | def require_auth(request): | + | HTTP 200 (HTML) |
| - | | + | |
| - | session | + | |
| - | + | ||
| - | if not session: | + | |
| - | return redirect_to_login(request) | + | |
| - | + | ||
| - | # Check access_token expiry (with 60s buffer) | + | |
| - | if session[' | + | |
| - | session = refresh_access_token(session) | + | |
| - | + | ||
| - | request.user | + | |
| - | request.token = session[' | + | |
| - | # Continue to handler | + | |
| </ | </ | ||
| - | === Step 3 — Web App Calls ServerA and ServerB | + | === Accessing WebB at the Same Time === |
| < | < | ||
| - | Web App → ServerA: GET /api/ | + | Browser |
| - | Web App → ServerB: | + | |
| - | </ | + | Cookie: session_b=SESS-B-def456 |
| + | (session_a is NOT sent — different domain) | ||
| - | Both servers validate JWT offline via JWKS. The same JWT is reused for the duration of its validity. | + | WebB middleware: |
| + | session = redis.get(" | ||
| + | → valid, not expired | ||
| + | → access_token not expired | ||
| + | → serve request directly | ||
| - | === Step 4 — Web App Renders Page === | + | WebB → Browser: |
| + | HTTP 200 (HTML) | ||
| + | </ | ||
| - | No SSO interaction. | + | Both apps serve their pages without any SSO interaction. |
| - | + | completely independent — WebA' | |
| - | < | + | |
| - | Browser | + | |
| - | │ | + | |
| - | │─ GET / | + | |
| - | │ (session cookie) | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │◀─ 200 HTML ──│ | + | |
| - | </ | + | |
| ---- | ---- | ||
| - | ==== Scenario 2B — Session Valid, Access | + | ==== Scenario 2B — WebA Token Expired |
| - | === Step 1 — Web App Detects Expired Token === | + | === Step 1 — WebA Detects Expired Token === |
| <code python> | <code python> | ||
| - | # Session is valid but access_token is expired | + | def get_valid_token_for_weba(session): |
| - | if session[' | + | if session[' |
| - | new_tokens = refresh_access_token(session[' | + | # Token expired or expiring soon — refresh silently |
| - | if new_tokens: | + | |
| - | # Update session with new tokens | + | |
| - | | + | |
| - | session[' | + | session[' |
| - | session[' | + | session[' |
| - | | + | session[' |
| - | else: | + | redis.set(f" |
| - | # Refresh failed → force re-login | + | |
| - | return | + | |
| + | # Refresh failed → force re-login | ||
| + | return | ||
| + | |||
| + | return session[' | ||
| </ | </ | ||
| - | === Step 2 — Web App Calls ServerSSO /token with Refresh Token (Server-to-Server) === | + | === Step 2 — WebA Calls ServerSSO /token with Refresh Token (Server-to-Server) === |
| - | + | ||
| - | This call is entirely transparent to the user. No redirect, no interruption. | + | |
| < | < | ||
| - | Web App Backend → ServerSSO: | + | WebA Backend → ServerSSO: |
| POST https:// | POST https:// | ||
| - | | + | Authorization: |
| - | | + | |
| grant_type=refresh_token | grant_type=refresh_token | ||
| - | & | + | & |
| </ | </ | ||
| === Step 3 — ServerSSO Returns New Tokens === | === Step 3 — ServerSSO Returns New Tokens === | ||
| + | |||
| + | > 🔐 **PRIVATE KEY USED HERE — ServerSSO signs fresh JWTs with the RSA private key** | ||
| + | > The new access_token is a freshly minted JWT with updated '' | ||
| + | > signed again with the private key. The '' | ||
| + | > (unless key rotation just happened). | ||
| <code json> | <code json> | ||
| { | { | ||
| - | " | + | " |
| - | " | + | |
| " | " | ||
| - | " | + | " |
| - | " | + | " |
| } | } | ||
| - | </ | ||
| - | === Step 4 — Web App Updates Session Transparently === | + | Signature computed with: 🔐 ServerSSO RSA PRIVATE KEY |
| - | + | ||
| - | <code python> | + | |
| - | # Update session — user is completely unaware this happened | + | |
| - | session_store.update(session_id, | + | |
| - | ' | + | |
| - | ' | + | |
| - | ' | + | |
| - | }) | + | |
| - | # Continue to serve the original request | + | |
| </ | </ | ||
| - | === Step 5 — Web App Continues | + | **This is completely invisible to the user and to WebB.** |
| + | WebB's session and tokens are unaffected. | ||
| + | |||
| + | === Step 4 — WebA Continues | ||
| < | < | ||
| - | Browser | + | Browser |
| - | │ | + | │ |
| - | │─ GET /reports─▶│ | + | │─ GET /dash ─▶│ |
| - | │ | + | │ │ token expired│ │ |
| - | │ token expired│─ POST / | + | │ │─ POST /token (refresh, s2s) ──▶│ |
| - | │ | + | │ │◀─ new JWT-A ───────────────────│ |
| - | │ | + | │ │ update session |
| - | │ | + | │◀─ 200 HTML ─│ |
| - | │ | + | │ |
| - | │ | + | |
| - | | + | │ │ │ token valid │ |
| - | │◀─ 200 HTML ──│ | + | │◀─ |
| </ | </ | ||
| - | |||
| - | The user experiences zero interruption. The token refresh is completely transparent. | ||
| ---- | ---- | ||
| - | ==== Scenario 2C — Session Expired (Re-authentication Required) ==== | + | ==== Scenario 2C — WebA Session Expired (Re-auth via SSO Session) ==== |
| - | === Step 1 — Web App Finds Expired / Missing | + | === Step 1 — WebA Finds No Valid Session === |
| - | < | + | < |
| - | def require_auth(request): | + | Browser → WebA: |
| - | | + | GET https:// |
| - | | + | |
| - | # Session expired or doesn' | + | WebA: session |
| - | if not session | + | WebA: redirect |
| - | # Save intended URL for post-login | + | |
| - | original_url = request.build_absolute_uri() | + | |
| - | request.session[' | + | |
| - | return redirect_to_oidc_login(request) | + | |
| </ | </ | ||
| - | === Step 2 — Web App Redirects to SSO === | + | === Step 2 — Browser Arrives at SSO with Valid SSO Session Cookie |
| - | Same as Scenario 1 Step 3. But this time: | + | < |
| + | Browser → ServerSSO: | ||
| + | GET https:// | ||
| + | Cookie: sso_session=SSO-XYZ-789 | ||
| + | </ | ||
| - | * If the SSO session is **still active** (e.g., user logged in via another app) → SSO skips login page and immediately returns an authorization code | + | === Step 3 — ServerSSO Issues Code Without Login Page === |
| - | * If the SSO session also expired → SSO shows the login page again | + | |
| < | < | ||
| - | SSO session | + | ServerSSO: |
| - | Browser | + | ServerSSO → Browser: |
| - | | + | |
| - | | + | |
| - | + | ||
| - | SSO session also expired: | + | |
| - | Browser → ServerSSO: GET / | + | |
| - | ServerSSO sees no valid session | + | |
| - | ServerSSO → Browser: login page | + | |
| - | Alice logs in again → proceeds normally | + | |
| </ | </ | ||
| - | This is the **SSO effect** | + | === Step 4 — WebA Completes Token Exchange, Creates New Session === |
| - | she only needs to log in once. Re-authentication in any individual app is seamless | + | |
| - | as long as the SSO session is still active. | + | |
| - | === Step 3 — New Session Created, Original URL Restored === | + | < |
| + | WebA exchanges code → gets new JWT-A → creates new SESS-A-new → sets new session_a cookie | ||
| + | Browser receives new session_a cookie and is redirected to / | ||
| + | User never saw a login prompt. | ||
| + | </ | ||
| - | < | + | < |
| - | # After successful | + | Browser |
| - | # Redirect to original URL the user wanted | + | │ │ │ │ |
| - | original_url = request.session.pop(' | + | │─ GET /dash ─▶│ |
| - | return redirect(original_url) | + | │ │ SESS-A expired |
| + | │◀─ 302 ──────│ | ||
| + | │─ GET /authorize ───────────────────────────▶│ | ||
| + | │ │ │ sso_session ✅ │ | ||
| + | │◀─ 302 / | ||
| + | │─ GET /callback ─────────────▶ | ||
| + | │ │─ POST /token ──────────────────▶│ | ||
| + | │ │◀─ new tokens ──────────────────│ | ||
| + | │ │ create new SESS-A | ||
| + | | ||
| + | | ||
| + | | ||
| </ | </ | ||
| + | |||
| + | > WebB is completely unaware of this. Its session and tokens are unaffected. | ||
| ---- | ---- | ||
| - | ==== Scenario | + | ==== Scenario |
| + | |||
| + | WebB's access token expiry is tracked independently. Refreshing WebB's token has no | ||
| + | effect on WebA and requires no interaction from WebA's session. | ||
| < | < | ||
| - | Browser | + | Browser |
| - | | + | │ │ │ │ |
| - | │ [2A: Session and token valid] | + | │─ GET /rpts ────────────────▶│ |
| - | │─ GET /page ──▶│ │ | + | │ │ │ token expired |
| - | | + | │ │ │─ POST /token ──▶│ |
| - | | + | |
| - | │◀─ 200 HTML ──│ | + | │ │ │◀─ |
| - | │ | + | │ │ │ update SESS-B |
| - | │ [2B: Session valid, | + | │◀─ |
| - | │─ GET /page ──▶│ | + | │ │ │ |
| - | │ │─ POST /token ──▶│ | + | │─ GET /dash ─▶│ |
| - | │ │ (refresh, s2s) │ | + | │ │ token valid |
| - | | + | │◀─ |
| - | │ | + | |
| - | │ │◀─ | + | |
| - | | + | |
| - | │ | + | |
| - | │ [2C: Session expired] | + | |
| - | │─ GET /page ──▶│ | + | |
| - | │ │ no session | + | |
| - | │◀─ | + | |
| - | │─ GET / | + | |
| - | │ SSO session still active | + | |
| - | │◀─ 302 / | + | |
| - | │─ GET /callback? | + | |
| - | │ | + | |
| - | | + | |
| - | │◀─ 302 /page + session cookie ──│ | + | |
| - | │─ GET /page ──▶│ │ | + | |
| - | | + | |
| - | │ │◀─ | + | |
| - | │◀─ 200 HTML ──│ │ | + | |
| </ | </ | ||
| + | |||
| + | Each application independently manages its own token lifecycle. | ||
| ---- | ---- | ||
| Line 988: | Line 1308: | ||
| ===== Sequence Diagrams ===== | ===== Sequence Diagrams ===== | ||
| - | ==== Full Authorization Code Flow (Confidential Client) | + | ==== Master |
| < | < | ||
| - | Step-by-step: | + | |
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | 10. WebA → WebA : | ||
| + | 11. WebA → ServerSSO | ||
| + | 12. ServerSSO | ||
| + | 13. ServerSSO | ||
| + | 14. ServerSSO | ||
| + | 15. WebA → WebA : | ||
| + | 16. WebA → WebA : | ||
| + | 17. WebA → Redis : store SESS-A { JWT-A, refresh-A, sso_sid=SSO-XYZ } | ||
| + | 18. WebA → Browser | ||
| + | 19. Browser | ||
| + | 20. WebA → Browser | ||
| - | 1. Browser | + | ── Alice now opens WebB ── |
| - | 2. | + | |
| - | 3. | + | 21. Browser |
| - | 4. Browser | + | 22. |
| - | 5. ServerSSO | + | 23. |
| - | | + | 24. Browser |
| - | | + | 25. ServerSSO |
| - | 8. ServerSSO | + | 26. ServerSSO |
| - | 9. Browser | + | 27. Browser |
| - | 10. | + | 28. |
| - | 11. | + | 29. |
| - | 12. ServerSSO | + | 30. ServerSSO |
| - | 13. ServerSSO | + | 31. ServerSSO |
| - | 14. | + | 32. |
| - | 15. | + | 33. WebB → WebB : validate id_token-B claims |
| - | 16. | + | 34. |
| - | 17. Browser | + | 35. |
| - | 18. | + | 36. Browser |
| - | 19. Web App → ServerA | + | 37. |
| - | 20. ServerA | + | |
| - | 21. ServerA | + | KEY USAGE SUMMARY FOR THIS FLOW: |
| - | 22. Web App → ServerB | + | |
| - | 23. ServerB | + | |
| - | 24. ServerB | + | |
| - | 25. Web App → Browser | + | |
| </ | </ | ||
| Line 1024: | Line 1361: | ||
| ===== API Contracts ===== | ===== API Contracts ===== | ||
| - | ==== ServerSSO | + | ==== ServerSSO |
| - | + | ||
| - | === GET / | + | |
| <code json> | <code json> | ||
| + | // GET https:// | ||
| { | { | ||
| " | " | ||
| " | " | ||
| - | " | + | " |
| - | " | + | " |
| - | " | + | " |
| - | " | + | " |
| " | " | ||
| " | " | ||
| - | " | + | " |
| - | "subject_types_supported": ["public"], | + | "token_endpoint_auth_methods_supported": |
| - | " | + | " |
| - | " | + | " |
| - | "token_endpoint_auth_methods_supported": | + | "claims_supported": |
| - | " | + | |
| } | } | ||
| </ | </ | ||
| - | === POST /token — Authorization Code === | + | ==== POST /token — Authorization Code ==== |
| - | **Request (client_secret_basic):** | + | **WebA request:** |
| < | < | ||
| - | POST / | + | POST /token |
| - | Host: sso.example.com | + | Authorization: Basic base64(web-a-001:SECRET-A) |
| - | Content-Type: application/ | + | |
| - | Authorization: | + | |
| grant_type=authorization_code | grant_type=authorization_code | ||
| - | &code=SplxlOBeZQQYbYS6WxSbIA | + | &code=CODE-A-111 |
| - | & | + | & |
| </ | </ | ||
| - | **Response:** | + | **WebB request:** |
| - | <code json> | + | |
| - | { | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | </ | + | |
| - | + | ||
| - | === POST /token — Refresh Token === | + | |
| < | < | ||
| - | POST / | + | POST /token |
| - | Authorization: | + | Authorization: |
| - | grant_type=refresh_token | + | grant_type=authorization_code |
| - | &refresh_token=8xLOxBtZp8QyNcZpA3Rx | + | &code=CODE-B-222 |
| + | & | ||
| </ | </ | ||
| - | === POST / | + | ==== POST / |
| - | ServerSSO | + | ServerSSO |
| < | < | ||
| - | POST https:// | + | POST https://web-a.example.com/ |
| + | POST https:// | ||
| Content-Type: | Content-Type: | ||
| - | |||
| logout_token=eyJhbGciOiJSUzI1NiJ9... | logout_token=eyJhbGciOiJSUzI1NiJ9... | ||
| </ | </ | ||
| Line 1097: | Line 1419: | ||
| <code json> | <code json> | ||
| { | { | ||
| - | " | + | " |
| - | " | + | " |
| - | " | + | " |
| - | " | + | " |
| - | " | + | "sub": |
| - | "sid": "sso-session-xyz", | + | " |
| - | " | + | " |
| - | | + | |
| - | | + | |
| } | } | ||
| </ | </ | ||
| + | |||
| + | ==== WebA & WebB Backchannel Logout Handler ==== | ||
| + | |||
| + | > 🔓 **PUBLIC KEY USED HERE — WebA and WebB verify the logout_token signature before acting on it** | ||
| + | > This is critical: without signature verification, | ||
| + | > and force-terminate user sessions. The public key guarantees the logout_token genuinely | ||
| + | > came from ServerSSO. | ||
| <code python> | <code python> | ||
| - | # Web App backchannel logout | + | # Identical |
| - | def backchannel_logout(request): | + | def backchannel_logout_weba(request): |
| logout_token = request.POST.get(' | logout_token = request.POST.get(' | ||
| - | payload = verify_logout_token(logout_token) | ||
| - | | ||
| - | sso_sid = payload.get(' | ||
| - | sub = payload.get(' | ||
| - | | ||
| - | # Delete all web sessions associated with this SSO session | ||
| - | session_store.delete_by_sso_session(sso_sid) | ||
| - | | ||
| - | return Response(200) | ||
| - | </ | ||
| - | ==== Error Responses ==== | + | # 🔓 Validate signature via JWKS (public key) |
| + | payload | ||
| + | token | ||
| + | audience | ||
| + | issuer | ||
| + | jwks_uri | ||
| + | ) | ||
| - | ^ HTTP ^ error ^ Description | + | sso_sid = payload[' |
| - | | 400 | invalid_request | + | |
| - | | 400 | invalid_grant | + | |
| - | | 401 | invalid_client | + | |
| - | | 403 | insufficient_scope | + | |
| - | | 400 | unsupported_grant_type | grant_type not recognized | + | |
| - | ==== ServerA & ServerB Protected Endpoint Contract ==== | + | # Find and delete all WebA sessions linked to this SSO session |
| + | session_ids | ||
| + | for sid in session_ids: | ||
| + | redis.delete(f" | ||
| + | redis.delete(f" | ||
| - | **Request: | + | return HttpResponse(status=200) |
| - | < | + | |
| - | GET /api/data HTTP/1.1 | + | |
| - | Host: api-a.example.com | + | |
| - | Authorization: | + | |
| - | X-Request-ID: | + | |
| </ | </ | ||
| - | |||
| - | **Error Responses: | ||
| - | |||
| - | ^ HTTP ^ WWW-Authenticate header | ||
| - | | 401 | Bearer error=" | ||
| - | | 401 | Bearer error=" | ||
| - | | 401 | Bearer error=" | ||
| - | | 403 | Bearer error=" | ||
| - | | 403 | Bearer error=" | ||
| ---- | ---- | ||
| - | ===== Server-Side JWT Validation | + | ===== Logout Flows ===== |
| - | ==== Complete Validation Middleware (Python) | + | ==== User Logs Out from WebA ==== |
| - | <code python> | + | When Alice clicks logout in WebA, the logout must propagate to: |
| - | import jwt | + | - WebA's own session |
| - | import requests | + | - The SSO session on ServerSSO |
| - | import time | + | - WebB's session (via backchannel logout) |
| - | from functools import lru_cache | + | |
| - | JWKS_URL | + | < |
| - | ISSUER | + | Step 1: Browser → WebA: |
| - | SERVER_A_AUDIENCE = "https://api-a.example.com" | + | |
| - | CACHE_TTL | + | |
| - | _jwks_cache = {' | + | Step 2: WebA: |
| + | a. Retrieve id_token and sso_session_id from SESS-A | ||
| + | b. Delete SESS-A from Redis | ||
| + | c. Revoke refresh_token (optional — call /revoke endpoint) | ||
| - | def get_public_key(kid): | + | Step 3: WebA → Browser: |
| - | | + | HTTP 302 |
| - | | + | Set-Cookie: session_a=; Max-Age=0 |
| + | Location: https:// | ||
| + | | ||
| + | & | ||
| + | | ||
| - | # Check cache | + | Step 4: Browser → ServerSSO: |
| - | if kid in _jwks_cache[' | + | GET / |
| - | time.time() - _jwks_cache[' | + | |
| - | | + | |
| - | # Refresh JWKS | + | Step 5: ServerSSO: |
| - | | + | a. Validates id_token_hint |
| - | jwks = response.json() | + | b. Terminates SSO session SSO-XYZ-789 |
| + | c. Clears sso_session cookie | ||
| + | d. Looks up authenticated_clients: | ||
| + | e. Sends backchannel logout to each registered client | ||
| - | _jwks_cache[' | + | Step 6: ServerSSO → WebA (backchannel): |
| - | | + | POST https:// |
| + | (logout_token with aud=web-a-001, sid=SSO-XYZ-789) | ||
| + | WebA: already deleted — ack 200 | ||
| - | for key_data in jwks[' | + | Step 7: ServerSSO → WebB (backchannel): |
| - | | + | POST https:// |
| - | | + | |
| + | WebB: delete SESS-B from Redis ← Alice is now logged out of WebB too! | ||
| - | return _jwks_cache[' | + | Step 8: ServerSSO → Browser: |
| + | HTTP 302 | ||
| + | Set-Cookie: sso_session=; | ||
| + | Location: https:// | ||
| - | def validate_token(token, | + | Step 9: Browser → WebA: |
| - | """ | + | GET https:// |
| - | # Decode header only (no verification) to get kid | + | (session_a already cleared) |
| - | unverified_header = jwt.get_unverified_header(token) | + | |
| - | kid = unverified_header.get(' | + | |
| - | public_key = get_public_key(kid) | + | Step 10: WebA → Browser: |
| - | if not public_key: | + | HTTP 200 "You have been logged out" |
| - | raise ValueError(f" | + | |
| - | + | ||
| - | # Full verification | + | |
| - | payload = jwt.decode( | + | |
| - | token, | + | |
| - | public_key, | + | |
| - | algorithms=["RS256"], | + | |
| - | issuer=ISSUER, | + | |
| - | audience=required_audience, | + | |
| - | options={" | + | |
| - | ) | + | |
| - | + | ||
| - | # Scope check | + | |
| - | scopes = payload.get(' | + | |
| - | if required_scope not in scopes: | + | |
| - | raise PermissionError(f" | + | |
| - | + | ||
| - | return payload | + | |
| </ | </ | ||
| - | ==== Complete Validation Middleware (Java/ | + | **Result:** Alice is logged out of WebA, WebB, and the SSO server in a single logout action. |
| - | < | + | < |
| - | @Component | + | Browser |
| - | public class JwtValidationFilter extends OncePerRequestFilter { | + | |
| - | + | | |
| - | private static final String ISSUER | + | |
| - | | + | |
| - | | + | |
| - | + | | |
| - | private final JwkSet jwkSet; | + | |
| - | | + | |
| - | + | | |
| - | @Override | + | |
| - | protected void doFilterInternal(HttpServletRequest req, | + | |
| - | HttpServletResponse res, | + | |
| - | FilterChain chain) | + | |
| - | throws ServletException, | + | |
| - | + | ||
| - | String authHeader = req.getHeader(" | + | |
| - | if (authHeader == null || !authHeader.startsWith(" | + | |
| - | res.sendError(401, | + | |
| - | return; | + | |
| - | } | + | |
| - | + | ||
| - | String token = authHeader.substring(7); | + | |
| - | + | ||
| - | try { | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | + | ||
| - | // 2. Get key from cache (fetch from JWKS if unknown) | + | |
| - | | + | |
| - | + | ||
| - | | + | |
| - | | + | |
| - | if (!signedJWT.verify(verifier)) { | + | |
| - | res.sendError(401, | + | |
| - | return; | + | |
| - | } | + | |
| - | + | ||
| - | | + | |
| - | JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); | + | |
| - | validateClaims(claims); | + | |
| - | + | ||
| - | // 5. Inject user into request context | + | |
| - | SecurityContextHolder.getContext() | + | |
| - | .setAuthentication(buildAuth(claims)); | + | |
| - | + | ||
| - | chain.doFilter(req, | + | |
| - | + | ||
| - | } catch (Exception e) { | + | |
| - | res.sendError(401, | + | |
| - | } | + | |
| - | } | + | |
| - | + | ||
| - | private void validateClaims(JWTClaimsSet claims) throws Exception { | + | |
| - | if (!ISSUER.equals(claims.getIssuer())) | + | |
| - | throw new Exception(" | + | |
| - | if (!claims.getAudience().contains(AUDIENCE)) | + | |
| - | throw new Exception(" | + | |
| - | if (claims.getExpirationTime().before(new Date())) | + | |
| - | throw new Exception(" | + | |
| - | if (!claims.getStringClaim(" | + | |
| - | throw new Exception(" | + | |
| - | } | + | |
| - | } | + | |
| </ | </ | ||
| - | ---- | + | ==== Single-App Logout (WebA Only — Without Global SSO Logout) ==== |
| - | ===== Logout Flows ===== | + | If WebA wants to log out locally without terminating the SSO session: |
| - | + | ||
| - | ==== Standard Logout (User-Initiated) ==== | + | |
| < | < | ||
| - | 1. User clicks | + | 1. Delete SESS-A from Redis |
| + | 2. Clear session_a cookie | ||
| + | 3. Redirect to local "logged out from WebA" | ||
| + | 4. Do NOT call SSO /logout endpoint | ||
| - | 2. | + | Result: Alice is logged out of WebA only. |
| - | POST https://web.example.com/logout | + | SSO session and WebB session remain active. |
| - | Cookie: session_id=abc123xyz | + | If Alice returns to WebA, seamless re-auth via SSO session will occur. |
| + | </code> | ||
| - | 3. Web App: | + | This is sometimes called "local logout" |
| - | a. Retrieve id_token from session | + | |
| - | b. Delete server-side session from session store | + | |
| - | c. Revoke refresh_token at SSO (optional but recommended) | + | |
| - | 4. Web App → Browser: | + | ---- |
| - | HTTP 302 | + | |
| - | Set-Cookie: session_id=; | + | |
| - | Location: https:// | + | |
| - | ? | + | |
| - | & | + | |
| - | & | + | |
| - | 5. Browser → ServerSSO: | + | ===== Security Considerations ===== |
| - | GET / | + | |
| - | 6. ServerSSO: | + | ==== Multi-App Specific Security Rules ==== |
| - | a. Validates id_token_hint | + | |
| - | b. Identifies SSO session | + | |
| - | c. Terminates SSO session | + | |
| - | d. Clears SSO session cookie | + | |
| - | e. Triggers backchannel logout to all registered clients | + | |
| - | f. Redirects to post_logout_redirect_uri | + | |
| - | + | ||
| - | 7. Browser → Web App: | + | |
| - | GET https:// | + | |
| - | (session cookie already cleared) | + | |
| - | + | ||
| - | 8. Web App → Browser: | + | |
| - | 200 "You have been logged out" | + | |
| - | </ | + | |
| - | ==== Backchannel Logout | + | ^ Rule ^ Reason |
| + | | Each app has its own client_secret | Compromise of one app does not expose others | ||
| + | | id_token audience isolation | ||
| + | | JWT audience isolation | ||
| + | | Separate session stores | ||
| + | | state scoped per app | state generated by WebA is meaningless to WebB | | ||
| + | | Backchannel logout required | ||
| + | | sso_session_id stored in each session | Enables backchannel logout to find and delete sessions | | ||
| - | When a user logs out from another application sharing the same SSO: | + | ==== Cookie Isolation Between Apps ==== |
| < | < | ||
| - | ServerSSO → Web App Backend: | + | Domain scoping prevents cross-app cookie access: |
| - | POST https:// | + | |
| - | (logout_token JWT identifying sub + sid) | + | |
| - | Web App: | + | session_a: |
| - | | + | session_b: |
| - | | + | sso_session: |
| - | - Deletes those sessions from session store | + | |
| - | | + | |
| - | Result: User is silently logged out of the Web App even though | + | Even if WebA is compromised, |
| - | they only explicitly logged out from a different application. | + | Even if WebB is compromised, |
| </ | </ | ||
| - | |||
| - | ---- | ||
| - | |||
| - | ===== Security Considerations ===== | ||
| - | |||
| - | ==== Cookie Security Requirements ==== | ||
| - | |||
| - | ^ Attribute | ||
| - | | HttpOnly | ||
| - | | Secure | ||
| - | | SameSite | ||
| - | | Path | / | Cookie sent to all app paths | | ||
| - | | Max-Age / Expires | 8 hours | Session timeout | ||
| - | | Domain | ||
| - | |||
| - | ==== client_secret Security ==== | ||
| - | |||
| - | ^ Requirement | ||
| - | | Never hardcode in source code | Use environment variables / secret manager | ||
| - | | Never log client_secret | ||
| - | | Rotate periodically | ||
| - | | Store in secrets manager | ||
| - | | Use TLS for all /token calls | Enforce HTTPS on SSO token endpoint | ||
| ==== Security Checklist ==== | ==== Security Checklist ==== | ||
| - | ^ # ^ Control | + | ^ # ^ Control |
| - | | 1 | state parameter validation | + | | 1 | Separate client credentials |
| - | | 2 | nonce in id_token | + | | 2 | state validation per app |
| - | | 3 | Exact redirect_uri match | SSO rejects partial or wildcard URIs | | + | | 3 | nonce validation per app |
| - | | 4 | client_secret confidentiality | + | | 4 | id_token audience check | Verify aud == own client_id, not any other app' |
| - | | 5 | Session cookie flags | HttpOnly + Secure + SameSite=Lax mandatory | + | | 5 | Cookie domain scoping |
| - | | 6 | Short access_token TTL | 15 minutes max; refresh transparently | + | | 6 | Separate session stores |
| - | | 7 | Refresh token rotation | + | | 7 | Backchannel logout implemented |
| - | | 8 | Server-side session store | Use Redis or DB; never JWT-only | + | | 8 | sso_session_id in app sessions |
| - | | 9 | JWKS caching | + | | 9 | JWKS shared but keys validated |
| - | | 10| Audience validation | + | | 10| Refresh token per app | WebA's refresh_token cannot be used by WebB | |
| - | | 11| Backchannel logout | + | | 11| HTTPS on all domains |
| - | | 12| HTTPS everywhere | + | | 12| SameSite=None on SSO cookie |
| ==== Attack Mitigations ==== | ==== Attack Mitigations ==== | ||
| - | ^ Attack | + | ^ Attack |
| - | | CSRF on / | + | | JWT-A used against WebB's API | aud claim mismatch → rejected by WebB's resource |
| - | | Token theft via XSS | + | | id_token from WebA replayed at WebB | aud=web-a-001 → WebB rejects |
| - | | Session fixation | + | | Stolen session_a used at WebB | Domain-scoped cookie; WebB only reads session_b |
| - | | Code interception | + | | CSRF on WebA callback |
| - | | Refresh token theft | + | | Logout only from one app |
| - | | Man-in-the-middle | + | | Partial |
| - | | id_token replay | + | | SSO session |
| - | | Forged JWT | RS256 signature verified against JWKS public key | + | |
| - | | Backchannel | + | |
| - | | Session after logout | + | |
| ---- | ---- | ||
| Line 1412: | Line 1618: | ||
| ===== Configuration Reference ===== | ===== Configuration Reference ===== | ||
| - | ==== ServerSSO Client | + | ==== ServerSSO Client |
| <code yaml> | <code yaml> | ||
| clients: | clients: | ||
| - | | + | |
| - | client_secret: | + | |
| + | client_secret: | ||
| client_type: | client_type: | ||
| - | | + | |
| redirect_uris: | redirect_uris: | ||
| - | - " | + | - " |
| - | | + | |
| post_logout_redirect_uris: | post_logout_redirect_uris: | ||
| - | - " | + | - " |
| - | | + | backchannel_logout_uri: |
| - | backchannel_logout_uri: | + | |
| backchannel_logout_session_required: | backchannel_logout_session_required: | ||
| - | | + | allowed_scopes: |
| - | | + | |
| - | - openid | + | |
| - | - profile | + | |
| - | + | ||
| - | - api:serverA | + | |
| - | - api: | + | |
| - | | + | |
| token_endpoint_auth_method: | token_endpoint_auth_method: | ||
| - | | + | access_token_ttl: |
| - | | + | refresh_token_ttl: |
| - | refresh_token_ttl: | + | |
| - | | + | - client_id: |
| + | client_secret: | ||
| + | client_type: | ||
| + | | ||
| + | redirect_uris: | ||
| + | - " | ||
| + | post_logout_redirect_uris: | ||
| + | - " | ||
| + | backchannel_logout_uri: | ||
| + | backchannel_logout_session_required: | ||
| + | allowed_scopes: | ||
| + | token_endpoint_auth_method: | ||
| + | access_token_ttl: | ||
| + | refresh_token_ttl: | ||
| </ | </ | ||
| - | ==== Web Application | + | ==== WebA Configuration ==== |
| <code yaml> | <code yaml> | ||
| oidc: | oidc: | ||
| authority: | authority: | ||
| - | client_id: | + | client_id: |
| - | client_secret: | + | client_secret: |
| - | redirect_uri: | + | redirect_uri: |
| - | scopes: | + | scopes: |
| - | - openid | + | |
| - | - profile | + | |
| - | + | ||
| - | - api:serverA | + | |
| - | - api: | + | |
| - | | + | |
| token_endpoint_auth_method: | token_endpoint_auth_method: | ||
| session: | session: | ||
| - | store: | + | store: |
| - | redis_url: | + | redis_url: |
| - | ttl_seconds: | + | key_prefix: |
| - | cookie_name: | + | ttl_seconds: |
| - | | + | cookie_name: |
| + | | ||
| + | cookie_secure: | ||
| cookie_httponly: | cookie_httponly: | ||
| cookie_samesite: | cookie_samesite: | ||
| + | </ | ||
| - | apis: | + | ==== WebB Configuration ==== |
| - | | + | |
| - | | + | <code yaml> |
| + | oidc: | ||
| + | | ||
| + | | ||
| + | client_secret: | ||
| + | redirect_uri: | ||
| + | scopes: | ||
| + | token_endpoint_auth_method: | ||
| + | |||
| + | session: | ||
| + | store: | ||
| + | redis_url: | ||
| + | key_prefix: | ||
| + | ttl_seconds: | ||
| + | cookie_name: | ||
| + | cookie_domain: | ||
| + | cookie_secure: | ||
| + | cookie_httponly: | ||
| + | cookie_samesite: | ||
| </ | </ | ||
| - | ==== ServerA & ServerB | + | ==== JWT Validation Configuration |
| <code yaml> | <code yaml> | ||
| - | # ServerA | + | # WebA validates JWT-A issued to resource-a |
| - | jwt: | + | jwt_validation_weba: |
| - | issuer: | + | issuer: |
| - | audience: | + | audience: |
| - | algorithms: | + | algorithms: |
| - | jwks_uri: | + | jwks_uri: |
| - | jwks_cache_ttl: | + | jwks_cache_ttl: |
| - | required_scope: | + | required_scope: |
| - | clock_skew: | + | clock_skew: |
| - | # ServerB | + | # WebB validates JWT-B issued to resource-b |
| - | jwt: | + | jwt_validation_webb: |
| - | issuer: | + | issuer: |
| - | audience: | + | audience: |
| - | algorithms: | + | algorithms: |
| - | jwks_uri: | + | jwks_uri: |
| jwks_cache_ttl: | jwks_cache_ttl: | ||
| - | required_scope: | + | required_scope: |
| - | clock_skew: | + | clock_skew: |
| </ | </ | ||
| Line 1503: | Line 1729: | ||
| < | < | ||
| - | ╔══════════════════════════════════════════════════════════════════════╗ | + | ╔═════════════════════════════════════════════════════════════════════════╗ |
| - | ║ OIDC TRADITIONAL WEB — QUICK REFERENCE | + | ║ OIDC MULTI-APP SSO — QUICK REFERENCE |
| - | ╠══════════════════════════════════════════════════════════════════════╣ | + | ╠═════════════════════════════════════════════════════════════════════════╣ |
| - | ║ CLIENT TYPE | + | ║ RSA KEY USAGE — WHO USES WHAT ║ |
| - | ║ Confidential — has client_secret | + | ║ ║ |
| - | ║ No PKCE required | + | ║ 🔐 PRIVATE KEY → ServerSSO ONLY ║ |
| - | ╠══════════════════════════════════════════════════════════════════════╣ | + | ║ Used to SIGN: access_token, |
| - | ║ TOKEN STORAGE | + | ║ |
| - | ║ access_token | + | ║ NEVER shared, NEVER leaves ServerSSO |
| - | ║ refresh_token | + | ║ ║ |
| - | ║ | + | ║ 🔓 PUBLIC KEY |
| - | ║ Browser holds | + | ║ Used to VERIFY: id_token signature |
| - | ╠══════════════════════════════════════════════════════════════════════╣ | + | ║ |
| - | ║ TOKEN LIFETIMES | + | ║ |
| - | ║ | + | ║ |
| - | ║ id_token | + | ║ kid " |
| - | ║ refresh_token | + | ╠═════════════════════════════════════════════════════════════════════════╣ |
| - | ║ auth code 60 sec (single use) | + | ║ WHEN EACH KEY IS USED (step reference) ║ |
| - | ║ web session | + | ║ Scenario 1, Step A11 → 🔐 SSO signs JWT-A + id_token-A |
| - | ╠══════════════════════════════════════════════════════════════════════╣ | + | ║ Scenario 1, Step A12 → 🔓 WebA verifies id_token-A |
| - | ║ CRITICAL VALIDATIONS | + | ║ |
| - | ║ /callback: state == stored state (CSRF check) ║ | + | ║ Scenario 1, Step B10 → 🔓 WebB verifies |
| - | ║ id_token: | + | ║ Scenario 2B, Step 3 → 🔐 SSO signs new JWT-A (refresh) ║ |
| - | ║ id_token: aud == client_id | + | ║ |
| - | ║ | + | ╠═════════════════════════════════════════════════════════════════════════╣ |
| - | ║ JWT: | + | ║ THREE-LAYER COOKIE MODEL |
| - | ║ JWT: exp > now() | + | ║ sso_session |
| - | ║ JWT: scope includes required API scope | + | ║ session_a |
| - | ╠══════════════════════════════════════════════════════════════════════╣ | + | ║ session_b |
| - | ║ FLOW TYPES | + | ║ Browser never holds tokens — only session |
| - | ║ Login: /authorize → login → code → POST / | + | ╠═════════════════════════════════════════════════════════════════════════╣ |
| - | ║ Refresh: POST /token (grant=refresh_token, s2s, transparent) | + | ║ SSO EFFECT — HOW IT WORKS ║ |
| - | ║ Logout: | + | ║ 1. WebA redirects → /authorize |
| - | ╠══════════════════════════════════════════════════════════════════════╣ | + | ║ 2. User logs in → sso_session cookie set on sso.example.com |
| - | ║ ENDPOINTS (ServerSSO) | + | ║ 3. WebB redirects → / |
| - | ║ / | + | ║ 4. ServerSSO issues code immediately, |
| - | ║ / | + | ╠═════════════════════════════════════════════════════════════════════════╣ |
| - | ║ /.well-known/ | + | ║ TOKEN ISOLATION |
| - | ║ / | + | ║ JWT-A: aud=resource-a, scope=api: |
| - | ║ / | + | ║ JWT-B: aud=resource-b, scope=api: |
| - | ╠══════════════════════════════════════════════════════════════════════╣ | + | ║ id_token-A: aud=web-a-001 → WebA accepts; WebB rejects |
| - | ║ KEY SECURITY PROPERTIES | + | ║ id_token-B: aud=web-b-001 → WebB accepts; WebA rejects |
| - | ║ XSS cannot steal tokens | + | ║ Both signed by same 🔐 private key; both verified by same 🔓 pub key ║ |
| - | ║ CSRF protected by state (verified | + | ╠═════════════════════════════════════════════════════════════════════════╣ |
| - | ║ Replay protected by nonce (verified in id_token) | + | ║ CLIENT CREDENTIALS (CONFIDENTIAL) |
| - | ║ Code single-use + 60s TTL + client_secret required to exchange | + | ║ WebA: client_id=web-a-001, |
| - | ║ JWKS offline validation | + | ║ WebB: client_id=web-b-001, client_secret=SECRET-B (separate) |
| - | ╚══════════════════════════════════════════════════════════════════════╝ | + | ║ Stored server-side only — never in browser |
| + | ╠═════════════════════════════════════════════════════════════════════════╣ | ||
| + | ║ SESSION INDEPENDENCE | ||
| + | ║ WebA token refresh | ||
| + | ║ WebA session expiry | ||
| + | ║ Each app tracks its own access_token_exp and refresh_token | ||
| + | ╠═════════════════════════════════════════════════════════════════════════╣ | ||
| + | ║ GLOBAL LOGOUT CHAIN ║ | ||
| + | ║ User logs out WebA → WebA calls SSO /logout | ||
| + | ║ → SSO signs logout_token | ||
| + | ║ | ||
| + | ║ | ||
| + | ╠═════════════════════════════════════════════════════════════════════════╣ | ||
| + | ║ TOKEN LIFETIMES | ||
| + | ║ access_token | ||
| + | ║ id_token | ||
| + | ║ refresh_token | ||
| + | ║ auth code 60 sec (single use, per app) | ||
| + | ║ web session | ||
| + | ║ sso session | ||
| + | ╚═════════════════════════════════════════════════════════════════════════╝ | ||
| </ | </ | ||
| Line 1557: | Line 1803: | ||
| //Document maintained by: Platform Security Team// | //Document maintained by: Platform Security Team// | ||
| //Format: DokuWiki// | //Format: DokuWiki// | ||
| - | //Standard: OpenID Connect Core 1.0 · RFC 6749 (OAuth 2.0) · RFC 7519 (JWT) · RFC 7517 (JWK) · OpenID Connect Back-Channel Logout 1.0// | + | //Version: 2.0 — Added RSA Key Usage section (private key / public key per component and step)// |
| + | //Standard: OpenID Connect Core 1.0 · OpenID Connect Back-Channel Logout 1.0 · RFC 6749 · RFC 7519 · RFC 7517// | ||
security/sso-web.1781590552.txt.gz · Last modified: by phong2018
