User Tools

Site Tools


security:sso-web

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Next revision
Previous revision
security:sso-web [2026/06/16 06:15] – created phong2018security: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 (Browser + WebA + WebB + ServerSSO + JWT + JWKS======
  
-**Document Version:** 1.0+**Document Version:** 2.0
 **Last Updated:** 2026-06-16 **Last Updated:** 2026-06-16
-**Scope:** Single Sign-On architecture for server-rendered / traditional web applications +**Changes in v2.0:** Added full RSA Key Usage section; annotated every step in both scenarios 
-          using Authorization Code Flow (confidential client), JWT access tokens, +                    with which key (private / public) is used and by which component. 
-          JWKS-based verification, and server-side session management.+**Scope:** Single Sign-On across two independent traditional web applications (WebA and WebB), 
 +          sharing one ServerSSO. A user authenticates once and gains access to both apps 
 +          without re-entering credentials. JWT access tokens are validated via JWKS.
  
 ---- ----
Line 12: Line 14:
  
   - [[#architecture-overview|Architecture Overview]]   - [[#architecture-overview|Architecture Overview]]
-  - [[#key-differences-from-spa-pkce|Key Differences from SPA/PKCE]]+  - [[#what-makes-this-different|What Makes This Different]]
   - [[#components|Components]]   - [[#components|Components]]
-  - [[#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]] 
-  - [[#scenario-1-unauthenticated-user|Scenario 1: Unauthenticated User]] +  - [[#scenario-1-unauthenticated-user-visits-weba-then-webb|Scenario 1: Unauthenticated User — Visits WebA then WebB]] 
-  - [[#scenario-2-authenticated-user|Scenario 2: Authenticated User (Session Reuse)]]+  - [[#scenario-2-authenticated-user-returns-to-weba-opens-webb|Scenario 2: Authenticated User — Returns to WebA, Opens WebB]]
   - [[#sequence-diagrams|Sequence Diagrams]]   - [[#sequence-diagrams|Sequence Diagrams]]
   - [[#api-contracts|API Contracts]]   - [[#api-contracts|API Contracts]]
-  - [[#server-side-jwt-validation|Server-Side JWT Validation]] 
   - [[#logout-flows|Logout Flows]]   - [[#logout-flows|Logout Flows]]
   - [[#security-considerations|Security Considerations]]   - [[#security-considerations|Security Considerations]]
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/Django, Node/Express, .NET, Ruby on Rails, etc.) 
-  * The web app is a **confidential client** — it has a ''client_secret'' stored securely on the server 
-  * The browser never sees tokens directly — they are stored server-side in a session 
-  * **ServerA** and **ServerB** are backend microservices/APIs called server-to-server (not directly from browser) 
-  * JWT validation on all resource servers uses **JWKS** — no SSO contact per request 
  
 <code> <code>
-┌──────────────────────────────────────────────────────────────────────────┐ +─────────────────────────────────────────────────────────────────────────────────┐ 
-│                         SYSTEM TOPOLOGY                                  │ +│                          SYSTEM TOPOLOGY                                        │ 
-│                                                                          │ +│                                                                                 │ 
-│   Browser                                                                │ +│         User'Browser                                                          │ 
-│  ┌────────┐   HTTP + Session Cookie    ┌─────────────────────────────┐   │ +│  ┌──────────────────────────────┐                                               │ 
-│  │  User  │──────────────────────────▶│       Web App               │   │ +│  │  Tab 1: https://web-a.example│                                               │ 
-│  │(Browser│                            │  (Confidential OIDC Client) │   │ +│  │  Tab 2: https://web-b.example│                                               │ 
-│  └────────┘                            │  - Renders HTML pages       │   │ +│  │                              │                                               │ 
-│                                        │  - Holds tokens server-side │    +│  │  Cookies held by browser:    │                                               │ 
-│                                        │  - Calls ServerA / ServerB  │   │ +│  │  ┌─────────────────────────┐ │                                               │ 
-│                                        └──────────────┬──────────────┘   │ +│  │  │ session_a (web-a domain)│ │  HttpOnly, Secure, SameSite=Lax              │ 
-│                                                       │                  │ +│  │  │ session_b (web-b domain)│ │  HttpOnly, Secure, SameSite=Lax              │ 
-│                              ┌────────────────────────────────────────┐ │ +│  │  │ sso_session (sso domain)│ │  HttpOnly, Secure, SameSite=None             │ 
-│                              │                        │                │ │ +│  │  └─────────────────────────┘ │                                               │ 
-│                              ▼                        ▼                │ │ +│  └─────────────────────────────┘                                               │ 
-│                   ┌──────────────────┐   ┌──────────────────┐         │ │ +│              │ HTTP + cookies                                                    │ 
-│                   │    ServerA       │   │    ServerB       │         │ │ +│    ┌──────────────────┐                                                        │ 
-│                   │  (Resource API)  │   │  (Resource API)  │         │ │ +│    │                   │                                                        │ 
-│                   │  JWT via JWKS    │   │  JWT via JWKS    │         │ │ +│    ▼                   ▼                                                        │ 
-│                   └──────────────────┘   └──────────────────┘         │ │ +│ ──────────────┐  ┌──────────────┐          ┌──────────────────────────────┐   │ 
-│                                                                        │ │ +│ │     WebA     │  │     WebB     │          │         ServerSSO            │   │ 
-│                              ──────────────────────────────────────┐  │ │ +│ │ Confidential │  │ Confidential │          │     (OIDC Identity Provider) │   │ 
-│                              │           ServerSSO                  │  │ │ +│ │ OIDC Client  │  │ OIDC Client  ◀────────▶│  /authorize  /token  /jwks   │   │ 
-│                              │       (OIDC Identity Provider)       │  │ │ +│ │              │  │              │          │  /logout  /backchannel-logout │   │ 
-│                              │  /authorize  /token  /jwks.json      │  │ │ +│ │ Session Store│  │ Session Store│          │                              │   │ 
-│                              └──────────────────────────────────────┘  │ │ +│ │ (Redis/DB)   │  │ (Redis/DB)   │          │  SSO Session Store           │   │ 
-└────────────────────────────────────────────────────────────────────────┘+│ └──────────────┘  └──────────────┘          └──────────────────────────────┘   │ 
 +─────────────────────────────────────────────────────────────────────────────────┘
 </code> </code>
  
-----+**Core SSO Principle:**
  
-===== Key Differences from SPA/PKCE =====+The browser holds **three independent cookies** at steady state: 
 +  - ''sso_session'' — on the SSO domain — proves the user authenticated with the IdP 
 +  - ''session_a'' — on WebA's domain — WebA's own session referencing stored JWT 
 +  - ''session_b'' — on WebB's domain — WebB's own session referencing stored JWT
  
-^ Aspect                   ^ SPA + PKCE                         ^ Traditional Web + Confidential Client   ^ +The SSO effect works because both WebA and WebB trust the same ServerSSO. When WebB 
-| Client type              | Public (no secret)                  | Confidential (has client_secret)        | +redirects the user to ServerSSO for login and the SSO session cookie is already present
-| PKCE                     | Required                            | Optional (client_secret replaces it)    | +ServerSSO skips the login page and issues an authorization code immediately.
-| Token storage            | Browser memory / sessionStorage     | Server-side session (never in browser) +
-| Token exchange           | Browser calls /token directly       | Web server backend calls /token         | +
-| API calls                | Browser → API (Bearer JWT)          | Web server → API (Bearer JWT)           | +
-| Session management       | JS state + localStorage             | HTTP session cookie (HttpOnlySecure) +
-| Logout complexity        | Clear JS state + redirect           | Invalidate session + SSO logout         | +
-| Security surface         | XSS can steal tokens                | XSS cannot access tokens (server-side)  | +
-| Back-channel logout      | Complex (postMessage)               | Easy (server receives logout notification)|+
  
-> **Security advantage of traditional web apps:** Because tokens never reach the browser+---- 
-> XSS attacks cannot steal access_tokens or refresh_tokens. The browser only ever holds + 
-a session cookie (HttpOnly Secure SameSite=Lax).+===== What Makes This Different ===== 
 + 
 +^ Aspect                     ^ Single App (previous doc)            ^ Multi-App SSO (this doc)                    ^ 
 +| Number of OIDC clients     | 1 (Web App)                          | 2 (WebA + WebBindependent clients)        | 
 +| client_id / client_secret  | One pair                             | Separate pair per application               | 
 +| Session stores             | One                                  | Separate per app (different domains)        | 
 +| id_token audience          | ''web-app-001''                      | ''web-a-001'' or ''web-b-001'' respectively | 
 +| Browser cookies            | session cookie (1) SSO cookie (1)  | session_a session_b + SSO cookie (3     | 
 +| Cross-app login            | N/A                                  | Login to WebA → WebB login is seamless      | 
 +| Cross-app logout           | Local + SSO                          | Must propagate to ALL registered clients    | 
 +| Token scopes               | May overlap                          | Each app requests only its own scopes       |
  
 ---- ----
Line 92: Line 92:
 ===== Components ===== ===== Components =====
  
-==== Web Application (Traditional / Server-Rendered) ====+==== Browser ====
  
-^ Property         ^ Value                                           +^ Property         ^ Value                                              
-| Role             OIDC Relying Party (RP) — Confidential Client   +| Role             User agent; holds cookies; follows redirects       
-Client Type      | Confidential (has client_secret               | +Cookies (WebA)   ''session_a''; HttpOnlySecureSameSite=Lax     
-| Auth Method      | client_secret_basic or client_secret_post        | +Cookies (WebB)   | ''session_b''; HttpOnly; Secure; SameSite=Lax     
-| Token Storage    | Server-side session store (Redis / DB)           | +Cookies (SSO)    | ''sso_session''; HttpOnly; Secure; SameSite=None  
-| Session Cookie   | HttpOnlySecureSameSite=Lax                  +Sees             HTML pages only — never tokens                     |
-Base URL         | ''https://web.example.com''                     +
-Redirect URI     | ''https://web.example.com/auth/callback''       +
-Stack examples   PHP/Laravel, Java/Spring, Python/Django, .NET   |+
  
-==== ServerSSO (Identity Provider / Authorization Server) ====+==== WebA ====
  
-^ Property         ^ Value                                           +^ Property         ^ Value                                              
-| Role             | OIDC Provider (OP) / Authorization Server       +| Role             | OIDC Relying Party — Confidential Client           
-Standard         OpenID Connect Core 1.0                         +client_id        ''web-a-001''                                      
-Token Format     JWT (RS256                                    +client_secret    Stored in secrets manager (never in browser      
-| Base URL         | ''https://sso.example.com''                    +| Base URL         | ''https://web-a.example.com''                      
-Discovery URL    | ''https://sso.example.com/.well-known/openid-configuration''+Redirect URI     | ''https://web-a.example.com/auth/callback''        
-JWKS URL         | ''https://sso.example.com/.well-known/jwks.json''+Session Store    Redis (keys prefixed ''weba:'')                    
-Token Endpoint   | ''https://sso.example.com/token''              |+Session Cookie   | ''session_a''; domain ''web-a.example.com''        | 
 +| Scopes requested | ''openid profile email api:resourceA''             |
  
-==== ServerA (Resource Server / Microservice) ====+==== WebB ====
  
-^ Property         ^ Value                               +^ Property         ^ Value                                              
-| Role             OAuth 2.0 Resource Server           | +| Role             OIDC Relying Party — Confidential Client           | 
-Called by        Web App backend (server-to-server)  +client_id        ''web-b-001''                                      
-Validates        JWT Bearer token via JWKS           +client_secret    Stored in secrets manager (never in browser)       
-Required Scope   | ''api:serverA''                     +Base URL         | ''https://web-b.example.com''                      
-Base URL         | ''https://api-a.example.com''       +Redirect URI     | ''https://web-b.example.com/auth/callback''        
-Network          Internal / private network preferred|+Session Store    Redis (keys prefixed ''webb:''                   | 
 +| Session Cookie   | ''session_b''; domain ''web-b.example.com''        | 
 +| Scopes requested | ''openid profile email api:resourceB''             |
  
-==== ServerB (Resource Server / Microservice) ====+==== ServerSSO ====
  
-^ Property         ^ Value                               +^ Property         ^ Value                                              
-| Role             OAuth 2.0 Resource Server           +| Role             OIDC Provider (OP) / Authorization Server          | 
-Called by        Web App backend (server-to-server +| Standard         | OpenID Connect Core 1.0                            
-Validates        JWT Bearer token via JWKS           +Token format     JWT (RS256                                       
-Required Scope   | ''api:serverB''                     +Base URL         ''https://sso.example.com''                       
-Base URL         | ''https://api-b.example.com''       |+Discovery URL    | ''https://sso.example.com/.well-known/openid-configuration''
 +JWKS URL         | ''https://sso.example.com/.well-known/jwks.json'' 
 +| SSO Session TTL  | 8 hours (inactivity); 24 hours (absolute)          | 
 +| 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:
  
 <code> <code>
-1.  User visits protected page → Web App detects no session +┌─────────────────────────────────────────────────────────────────────────────┐ 
-2.  Web App redirects browser → ServerSSO /authorize +│                     RSA KEY PAIR — WHO HOLDS WHAT                          │ 
-3.  ServerSSO authenticates user (login page, MFA, etc.) +│                                                                             │ 
-4.  ServerSSO redirects browser → Web App /callback?code=... +│   ┌───────────────────────────────────────────────────────┐                │ 
-5.  Web App backend calls ServerSSO /token (with client_secret)  ← server-to-server +│   │              ServerSSO                                │                │ 
-6.  ServerSSO returns access_token, id_token, refresh_token +│   │  ┌─────────────────────────────────────────────────┐  │                │ 
-7.  Web App stores tokens in server-side session +│   │  │  🔐 PRIVATE KEY  (RSA-2048 or RSA-4096        │  │                │ 
-8.  Web App sets session cookie on browser (HttpOnly+│   │  │  kid: "key-2024-01"                             │  │                │ 
-9.  Web App calls ServerA/ServerB with Bearer JWT              ← server-to-server +│   │  │  Stored in: HSM / KMS / encrypted key store     │  │                │ 
-10ServerA/B validate JWT via JWKS (offline, no SSO contact+│   │  │  NEVER leaves ServerSSO — never shared          │  │                │ 
-11. Web App renders response to browser+│   │  │  Used to: SIGN JWTs (access_token, id_token,    │  │                │ 
 +│   │  │                       logout_token)             │  │                │ 
 +│   │  └─────────────────────────────────────────────────┘  │                │ 
 +│   │  ┌─────────────────────────────────────────────────┐  │                │ 
 +│   │  │  🔓 PUBLIC KEY  (exposed via JWKS endpoint    │  │                │ 
 +│   │  │  kid: "key-2024-01"                             │  │                │ 
 +│   │  │  Available at: /.well-known/jwks.json           │  │                │ 
 +│   │  │  Anyone can fetch it — it is intentionally      │  │                │ 
 +│   │  │  public (n + e RSA components only)             │  │                │ 
 +│   │  │  Used to: VERIFY JWT signatures                 │  │                │ 
 +│   │  └─────────────────────────────────────────────────┘  │                │ 
 +│   └───────────────────────────────────────────────────────┘                │ 
 +│                                                                             │ 
 +│   ┌────────────────────┐        ┌────────────────────┐                     │ 
 +│   │       WebA         │        │       WebB         │                     │ 
 +│   │  🔓 PUBLIC KEY     │        │  🔓 PUBLIC KEY     │                     │ 
 +│   │  (fetched via JWKS │        │  (fetched via JWKS │                     │ 
 +│   │   and cached     │        │   and cached)      │                     │ 
 +│   │  Used to: VERIFY   │        │  Used to: VERIFY   │                     │ 
 +│   │  id_token sig      │        │  id_token sig      │                     │ 
 +│   │  logout_token sig  │        │  logout_token sig  │                     │ 
 +│   └────────────────────┘        └────────────────────┘                     │ 
 +└─────────────────────────────────────────────────────────────────────────────┘
 </code> </code>
  
-==== client_secret Authentication ====+==== Fundamental Rule ====
  
-When the Web App exchanges code for tokens, it authenticates itself to the SSO server +> **SIGN with PRIVATE KEY → VERIFY with PUBLIC KEY** 
-using its ''client_secret''This is what distinguishes a confidential client from a public client.+
 +> 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 new one. 
 + 
 +==== Key Usage Master Table ==== 
 + 
 +^ Component   ^ Key Type    ^ Key ID        ^ Operation              ^ Token / Object           ^ When                                  ^ 
 +| ServerSSO   | 🔐 Private  | key-2024-01   | **SIGN**               | access_token (JWT)       | Every /token response                 | 
 +| ServerSSO   | 🔐 Private  | key-2024-01   | **SIGN**               | id_token (JWT)           | Every /token response                 | 
 +| ServerSSO   | 🔐 Private  | key-2024-01   | **SIGN**               | logout_token (JWT)       | Every backchannel logout call         | 
 +| ServerSSO   | 🔓 Public   | key-2024-01   | **EXPOSE** via JWKS    | ne (RSA components)    | GET /.well-known/jwks.json (always)   | 
 +| WebA        | 🔓 Public   | key-2024-01   | **VERIFY signature**   | id_token received        | Step A11 — after /token exchange      | 
 +| WebA        | 🔓 Public   | key-2024-01   | **VERIFY signature**   | logout_token received    | Every backchannel logout              | 
 +| WebB        | 🔓 Public   | key-2024-01   | **VERIFY signature**   | id_token received        | Step B9 — after /token exchange       | 
 +| WebB        | 🔓 Public   | key-2024-01   | **VERIFY signature**   | logout_token received    | Every backchannel logout              | 
 + 
 +> **Note on access_token:** In this architecture, WebA and WebB store the access_token 
 +> server-side and forward it to resource APIs. If WebA/WebB also need to inspect the 
 +> access_token's claims (e.g. to check roles before calling an API), they would also 
 +> use the public key to verify itThe public key is fetched once and cached per app. 
 + 
 +==== Where the Private Key Lives ====
  
 <code> <code>
-Method 1 — HTTP Basic Auth (client_secret_basic): +ServerSSO key storage options (most secure first):
-  Authorization: Basic base64(client_id:client_secret)+
  
-Method — POST body (client_secret_post): +1. Hardware Security Module (HSM) 
-  client_id=web-app-001&client_secret=s3cr3t-v4lue...+   → 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 
 + 
 +3Encrypted 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
 </code> </code>
  
-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+
  
-----+<code> 
 +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://sso.example.com/.well-known/jwks.json 
 +       │ 
 +       ├──────────────────────────────▶  WebA caches public key (1 hour TTL) 
 +       │ 
 +       └──────────────────────────────▶  WebB caches public key (1 hour TTL)
  
-==== JWT Structure ====+The public key contains ONLY: 
 +  "n": RSA modulus  (large integer — public component) 
 +  "e": RSA exponent (usually 65537 — public component) 
 +  "kid": key ID 
 +  "alg": RS256 
 +  "use": sig 
 + 
 +The public key does NOT contain: 
 +  "d": private exponent  (this stays on ServerSSO only) 
 +  "p", "q": prime factors (this stays on ServerSSO only) 
 +</code> 
 + 
 +==== Step-by-Step Key Operations During JWT Lifecycle ====
  
 <code> <code>
-JWT header.payload.signature (all base64url-encoded)+STEP 1 — JWT Creation (ServerSSO, private key) 
 +────────────────────────────────────────────── 
 +ServerSSO assembles: 
 +  header  = { "alg": "RS256", "kid": "key-2024-01", "typ": "JWT"
 +  payload = { "sub": "user-uid-456", "iss": "https://sso.example.com", ... }
  
-Decoded Header:+  signing_input = base64url(header) + "." + base64url(payload) 
 + 
 +  🔐 signature = RSA_PKCS1v15_SIGN( 
 +        key   = private_key, 
 +        hash  = SHA256(signing_input) 
 +     ) 
 + 
 +  JWT = signing_input + "." + base64url(signature) 
 + 
 +STEP 2 — JWT Transmission 
 +────────────────────────────────────────────── 
 +ServerSSO → WebA/WebBJWT 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 "kid" = "key-2024-01" 
 +  3. Fetch public key from JWKS cache matching kid 
 +  4. Reconstruct signing_input = header + "." + payload 
 + 
 +  🔓 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 
 +</code> 
 + 
 +==== Key Rotation ==== 
 + 
 +RSA keys should be rotated periodically (every 90 days recommended). 
 +ServerSSO supports multiple active keys in JWKS simultaneously during rotation: 
 + 
 +<code> 
 +During key rotation, JWKS contains TWO keys: 
 + 
 +GET /.well-known/jwks.json
 { {
-  "alg": "RS256", +  "keys":
-  "typ": "JWT", +    { 
-  "kid": "key-2024-01"       ← Key ID — matches a key in JWKS+      "kid": "key-2024-01",   ← OLD key (still valid, being phased out) 
 +      "kty": "RSA", "alg": "RS256", "use": "sig", 
 +      "n": "old-modulus...", "e": "AQAB" 
 +    }, 
 +    { 
 +      "kid": "key-2025-01",   ← NEW key (now used for signing) 
 +      "kty": "RSA", "alg": "RS256", "use": "sig", 
 +      "n": "new-modulus...", "e": "AQAB" 
 +    } 
 +  ]
 } }
  
-Decoded Payload (access_token):+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 TTL = 15 min) 
 +  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 
 +</code> 
 + 
 +==== Summary: Who Uses Which Key and Why ==== 
 + 
 +<code> 
 +╔══════════════╦════════════════╦══════════════════════════════════╦══════════════════════════╗ 
 +║  Component   ║   Key Used     ║         Operation                ║   Tokens Affected        ║ 
 +╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣ 
 +║  ServerSSO   ║ 🔐 PRIVATE KEY ║ Signs JWT payload + header       ║ access_token             ║ 
 +║              ║                ║ (RSA-SHA256 signature)           ║ id_token                 ║ 
 +║              ║                ║                                  ║ logout_token             ║ 
 +╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣ 
 +║  ServerSSO   ║ 🔓 PUBLIC KEY  ║ Publishes n+e in JWKS endpoint   ║ (key material, not token)║ 
 +║              ║                ║ for consumers to fetch           ║                          ║ 
 +╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣ 
 +║  WebA        ║ 🔓 PUBLIC KEY  ║ Verifies id_token signature      ║ id_token (from SSO)      ║ 
 +║  (fetched    ║ (from JWKS)    ║ Verifies logout_token signature  ║ logout_token (from SSO)  ║ 
 +║   and cached)║                ║ Optionally verifies access_token ║ access_token (optional)  ║ 
 +╠══════════════╬════════════════╬══════════════════════════════════╬══════════════════════════╣ 
 +║  WebB        ║ 🔓 PUBLIC KEY  ║ Verifies id_token signature      ║ id_token (from SSO)      ║ 
 +║  (fetched    ║ (from JWKS)    ║ Verifies logout_token signature  ║ logout_token (from SSO)  ║ 
 +║   and cached)║                ║ Optionally verifies access_token ║ access_token (optional)  ║ 
 +╚══════════════╩════════════════╩══════════════════════════════════╩══════════════════════════╝ 
 + 
 +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. 
 +</code> 
 + 
 +---- 
 + 
 +===== SSO Session vs Application Session ===== 
 + 
 +Understanding the difference between these three layers is essential: 
 + 
 +<code> 
 +┌─────────────────────────────────────────────────────────────────────┐ 
 +│  LAYER 1 — SSO Session (ServerSSO)                                  │ 
 +│                                                                     │ 
 +│  Created when: User successfully authenticates at ServerSSO         │ 
 +│  Lives at:     ServerSSO session store                              │ 
 +│  Identified by: sso_session cookie (domain: sso.example.com)        │ 
 +│  TTL:           8 hours inactivity / 24 hours absolute              │ 
 +│  Contains:      user identity, authenticated apps list, MFA status  │ 
 +│  Purpose:       Allows ServerSSO to issue codes WITHOUT re-login    │ 
 +│                                                                     │ 
 +│  ServerSSO session record:                                          │ 
 +│  {                                                                  │ 
 +│    sso_session_id: "SSO-XYZ-789",                                   │ 
 +│    sub:            "user-uid-456",                                  │ 
 +│    email:          "alice@example.com",                             │ 
 +│    authenticated_clients: ["web-a-001", "web-b-001"],               │ 
 +│    auth_time:      1718549100,                                       │ 
 +│    last_activity:  1718549100                                        │ 
 +│  }                                                                  │ 
 +└─────────────────────────────────────────────────────────────────────┘ 
 + 
 +┌─────────────────────────────────────────────────────────────────────┐ 
 +│  LAYER 2 — WebA Application Session                                 │ 
 +│                                                                     │ 
 +│  Created when: WebA completes OIDC token exchange                   │ 
 +│  Lives at:     WebA's Redis session store                           │ 
 +│  Identified by: session_a cookie (domain: web-a.example.com)        │ 
 +│  TTL:           8 hours                                             │ 
 +│  Contains:      user info + JWT tokens (access, refresh, id)        │ 
 +│  Purpose:       WebA's own auth state; independent of WebB          │ 
 +│                                                                     │ 
 +│  WebA session record:                                               │ 
 +│  {                                                                  │ 
 +│    session_id:       "SESS-A-abc123",                               │ 
 +│    sub:              "user-uid-456",                                │ 
 +│    email:            "alice@example.com",                           │ 
 +│    access_token:     "eyJhbGci...",                                 │ 
 +│    access_token_exp: 1718550000,                                    │ 
 +│    refresh_token:    "rtA-xyz...",                                  │ 
 +│    id_token:         "eyJhbGci...",                                 │ 
 +│    sso_session_id:   "SSO-XYZ-789",                                 │ 
 +│    created_at:       1718549100                                     │ 
 +│  }                                                                  │ 
 +└─────────────────────────────────────────────────────────────────────┘ 
 + 
 +┌─────────────────────────────────────────────────────────────────────┐ 
 +│  LAYER 3 — WebB Application Session                                 │ 
 +│                                                                     │ 
 +│  Created when: WebB completes its own OIDC token exchange           │ 
 +│  Lives at:     WebB's Redis session store (separate from WebA!)     │ 
 +│  Identified by: session_b cookie (domain: web-b.example.com)        │ 
 +│  TTL:           8 hours                                             │ 
 +│  Contains:      user info + WebB's own JWT tokens                   │ 
 +│  Purpose:       WebB's own auth state; independent of WebA          │ 
 +│                                                                     │ 
 +│  WebB session record:                                               │ 
 +│  {                                                                  │ 
 +│    session_id:       "SESS-B-def456",                               │ 
 +│    sub:              "user-uid-456",                                │ 
 +│    access_token:     "eyJhbGci...(different JWT for WebB scopes)",  │ 
 +│    access_token_exp: 1718550000,                                    │ 
 +│    refresh_token:    "rtB-uvw...",                                  │ 
 +│    sso_session_id:   "SSO-XYZ-789",     ← same SSO session          │ 
 +│    created_at:       1718549200                                     │ 
 +│  }                                                                  │ 
 +└─────────────────────────────────────────────────────────────────────┘ 
 +</code> 
 + 
 +> **Key insight:** WebA and WebB each maintain completely independent sessions and tokens. 
 +> They share only the ''sso_session_id'' as a reference to the common SSO session. 
 +> 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: 
 + 
 +<code> 
 +JWT issued to WebA (via web-a-001 client): 
 +  🔐 SIGNED with ServerSSO RSA PRIVATE KEY  (kid: "key-2024-01")
 { {
   "iss":   "https://sso.example.com",   "iss":   "https://sso.example.com",
   "sub":   "user-uid-456",   "sub":   "user-uid-456",
-  "aud":   ["https://api-a.example.com", "https://api-b.example.com"],+  "aud":   ["https://resource-a.example.com"],   ← WebA's resource server audience
   "exp":   1718550000,   "exp":   1718550000,
   "iat":   1718549100,   "iat":   1718549100,
-  "nbf":   1718549100, +  "jti":   "jwt-id-aaa-111",
-  "jti":   "unique-token-id-abc",+
   "email": "alice@example.com",   "email": "alice@example.com",
-  "name":  "Alice Martin", +  "scope": "openid profile email api:resourceA",  ← WebA's scopes only 
-  "scope": "openid profile email api:serverA api:serverB", +  "sid":   "SSO-XYZ-789"
-  "roles": ["user", "editor"], +
-  "sid":   "sso-session-xyz   ← SSO session ID (for backchannel logout)+
 } }
  
-Decoded Payload (id_token):+JWT issued to WebB (via web-b-001 client): 
 +  🔐 SIGNED with ServerSSO RSA PRIVATE KEY  (kid: "key-2024-01") ← same private key
 { {
   "iss":   "https://sso.example.com",   "iss":   "https://sso.example.com",
   "sub":   "user-uid-456",   "sub":   "user-uid-456",
-  "aud":   "web-app-001",        ← id_token audience = client_id +  "aud":   ["https://resource-b.example.com"]  ← WebB's resource server audience 
-  "exp":   1718549400+  "exp":   1718550000
-  "iat":   1718549100+  "iat":   1718549200
-  "nonce": "random-nonce-xyz",   ← Replay protection for id_token+  "jti":   "jwt-id-bbb-222",
   "email": "alice@example.com",   "email": "alice@example.com",
-  "name":  "Alice Martin"+  "scope": "openid profile email api:resourceB",  ← WebB's scopes only 
 +  "sid":   "SSO-XYZ-789                         ← same SSO session reference
 } }
 +
 +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.
 </code> </code>
  
-==== 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 ''aud'' and ''scope'' claims. 
 + 
 +==== JWKS Shared by All Clients ====
  
 <code json> <code json>
 // GET https://sso.example.com/.well-known/jwks.json // GET https://sso.example.com/.well-known/jwks.json
-// Response cached by resource servers (1 hour TTL)+// Used by WebA, WebB, and any resource servers to verify JWTs
 { {
   "keys": [   "keys": [
Line 236: Line 520:
 </code> </code>
  
-==== JWT Validation on Resource Servers ====+==== id_token Audience Scoping ====
  
 <code> <code>
-Resource Server (ServerA or ServerB) validates each incoming JWT:+id_token for WebA login: 
 +  "aud": "web-a-001"    ← WebA validates this; WebB must NOT accept it
  
-1.  Parse JWT header → extract "kid" +id_token for WebB login: 
-2.  Look up JWKS cache → find key where key.kid == header.kid +  "aud": "web-b-001   ← WebB validates this; WebA must NOT accept it
-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://sso.example.com" +
-6.  Check: payload.aud includes this server's URL +
-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"api:serverB"+
-11. Optionally check: payload.jti not in revocation list +
-12. Extract sub, email, roles for business logic +
-</code>+
  
-> **Critical:** ServerA and ServerB NEVER contact ServerSSO during request validation. +This prevents id_token from one app being replayed at another app
-> 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 store (Redisdatabaseor encrypted cookie). +Alice has no session anywhere. She opens her browsergoes to WebA, logs in
-The browser only sees a session cookie — never the JWT.+then navigates to WebB. She should only need to enter credentials once.
  
-<code> +This scenario has two parts
-Browser Cookie+  * **Part A** — Alice authenticates via WebA (full login
-  Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/ +  * **Part B** — Alice visits WebB (seamless SSO — no login required)
- +
-Server-Side Session Record (Redis / DB)+
-{ +
-  "session_id":     "abc123xyz", +
-  "user_id":        "user-uid-456", +
-  "email":          "alice@example.com", +
-  "name":           "Alice Martin", +
-  "roles":          ["user", "editor"], +
-  "access_token":   "eyJhbGci...",          ← JWT (stored server-side only) +
-  "access_token_exp": 1718550000, +
-  "refresh_token":  "8xLOxBtZp8...", +
-  "id_token":       "eyJhbGci...", +
-  "sso_session_id": "sso-session-xyz",       ← for backchannel logout +
-  "created_at":     1718549100, +
-  "last_seen":      1718549100 +
-+
-</code> +
- +
-==== Session vs Token Lifecycle ==== +
- +
-<code> +
-Timeline: +
- +
-  ├── User logs in ──────────────────────────────────────────────────────▶ +
-  │ +
-  │   access_token expires:  +15 min  → Web App refreshes silently +
-  │   refresh_token expires: +24 hrs  → Web App must re-login user +
-  │   Web session expires:   +8 hrs   → Configurable inactivity timeout +
-  │   SSO session expires:   +8 hrs   → Configurable at SSO server +
-  │ +
-  └── 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 +
-</code>+
  
 ---- ----
  
-===== 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 ''https://web.example.com/dashboard''+
-She has no active session. The system must authenticate her and then serve the page. +
- +
----- +
- +
-==== Step — Browser Requests Protected Page ====+
  
 <code> <code>
-Browser → Web App+Browser → WebA
-  GET https://web.example.com/dashboard +  GET https://web-a.example.com/dashboard 
-  Cookie: (none / no valid session cookie)+  Cookie: (none)
 </code> </code>
  
----- +=== Step A2 — WebA Detects No Session ===
- +
-==== Step — Web App Detects No Session ====+
  
 <code python> <code python>
-Web App middleware (Python/Django example) +WebA auth middleware 
-def require_auth(view_func): +def require_auth_weba(request): 
-    def wrapper(request): +    session_id = request.COOKIES.get('session_a') 
-        session_id = request.COOKIES.get('session_id') +    session    = redis.get(f"weba:{session_id}") if session_id else None
-        session    = session_store.get(session_id) if session_id else None+
  
-        if not session or is_expired(session): +    if not session
-            # No valid session → initiate OIDC login +        # Generate CSRF state and id_token nonce 
-            return initiate_oidc_login(request)+        state = generate_secure_random(32) 
 +        nonce = generate_secure_random(32) 
 + 
 +        # Store temporarily (5 min TTL) for callback validation 
 +        redis.setex(f"preauth:weba:{state}", 300, json.dumps({ 
 +            'nonce':        nonce, 
 +            'redirect_to':  request.path   # remember original URL 
 +        }))
  
-        request.user session['user'] +        return redirect_to_sso_authorize( 
-        return view_func(request) +            client_id    = 'web-a-001', 
-    return wrapper+            redirect_uri = 'https://web-a.example.com/auth/callback', 
 +            scope        = 'openid profile email api:resourceA', 
 +            state        = state, 
 +            nonce        = nonce 
 +        )
 </code> </code>
  
-**What happens:** No session found (or session expired). The Web App: +=== Step A3 — WebA Redirects Browser to ServerSSO /authorize ===
-  - Generates a random ''state'' value (CSRF protection) +
-  - Generates a random ''nonce'' value (id_token replay protection) +
-  - 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 — Web App Redirects Browser to ServerSSO /authorize ====+
  
 <code> <code>
-Web App → Browser:+WebA → Browser:
   HTTP 302   HTTP 302
   Location: https://sso.example.com/authorize   Location: https://sso.example.com/authorize
     ?response_type=code     ?response_type=code
-    &client_id=web-app-001 +    &client_id=web-a-001 
-    &redirect_uri=https://web.example.com/auth/callback +    &redirect_uri=https://web-a.example.com/auth/callback 
-    &scope=openid profile email api:serverA api:serverB +    &scope=openid%20profile%20email%20api%3AresourceA 
-    &state=csrf-token-f3a8b2 +    &state=state-weba-f3a8b2c1 
-    &nonce=replay-token-9d4e1c+    &nonce=nonce-weba-9d4e1c7a
 </code> </code>
  
-**Parameters:** +=== Step A4 — Browser Follows Redirect to ServerSSO ===
- +
-^ Parameter     ^ Value                                    ^ Purpose                                    ^ +
-| response_type | ''code''                                 | Request authorization code                 | +
-| client_id     | ''web-app-001''                          | Identifies the Web App                     | +
-| redirect_uri  | ''https://web.example.com/auth/callback''| Where SSO sends the code                   | +
-| scope         | ''openid profile email api:serverA api:serverB'' | Requested permissions              | +
-| 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 ''client_secret'' +
-> at the token endpoint instead. PKCE can be added as defense-in-depth. +
- +
----- +
- +
-==== Step — Browser Follows Redirect to ServerSSO ====+
  
 <code> <code>
 Browser → ServerSSO: Browser → ServerSSO:
-  GET https://sso.example.com/authorize?response_type=code&client_id=... +  GET https://sso.example.com/authorize?client_id=web-a-001&... 
-  Cookie: (no SSO session cookie)+  Cookie: (no sso_session cookie)
 </code> </code>
  
-ServerSSO sees no active SSO session → presents login page.+ServerSSOno SSO session found → present login page.
  
----- +=== Step A5 — ServerSSO Presents Login Page ===
- +
-==== Step — ServerSSO Presents Login Page ====+
  
 <code> <code>
 ServerSSO → Browser: ServerSSO → Browser:
-  HTTP 200 +  HTTP 200  Content-Type: text/html
-  Content-Type: text/html +
-  Set-Cookie: sso_pre_session=temp123; HttpOnly; Secure; SameSite=Lax+
  
-  <html> +  <form method="POST" action="https://sso.example.com/login"> 
-    <form method="POST" action="/login"> +    <input name="username" /> 
-      <input name="username" type="text" /> +    <input name="password" type="password" /> 
-      <input name="password" type="password" /> +    <button>Sign In</button> 
-      <button type="submit">Sign In</button> +  </form>
-    </form+
-  </html>+
 </code> </code>
  
----- +=== Step A6 — Alice Submits Credentials ===
- +
-==== Step — Alice Submits Credentials ====+
  
 <code> <code>
 Browser → ServerSSO: Browser → ServerSSO:
   POST https://sso.example.com/login   POST https://sso.example.com/login
-  Content-Type: application/x-www-form-urlencoded 
-  Cookie: sso_pre_session=temp123 
- 
   username=alice%40example.com&password=secret123   username=alice%40example.com&password=secret123
 </code> </code>
  
-**ServerSSO processes:** +ServerSSO validates credentials. On success
-  - Validates credentials against user store (LDAP, database, etc.) +  - Creates SSO session ''SSO-XYZ-789'' 
-  - If MFA configuredpresents second factor challenge +  - Records ''authenticated_clients[]'' (no apps yet) 
-  - On success: creates SSO session, sets SSO session cookie+  - Generates authorization code ''CODE-A-111'' bound to ''web-a-001''
  
-<code> +=== Step A7 — ServerSSO Sets SSO Cookie and Redirects with Code ===
-ServerSSO → Browser: +
-  HTTP 302 +
-  Set-Cookie: sso_session=SESSION-XYZ; HttpOnly; Secure; SameSite=None; Domain=sso.example.com +
-</code> +
- +
-> **Note:** The SSO session cookie uses ''SameSite=None'' because it must be sent +
-> in cross-origin redirects from the Web App domain to the SSO domain. +
- +
----- +
- +
-==== Step — ServerSSO Issues Authorization Code and Redirects Back ====+
  
 <code> <code>
 ServerSSO → Browser: ServerSSO → Browser:
   HTTP 302   HTTP 302
-  Locationhttps://web.example.com/auth/callback +  Set-Cookiesso_session=SSO-XYZ-789; Domain=sso.example.com; 
-    ?code=SplxlOBeZQQYbYS6WxSbIA +              HttpOnly; Secure; SameSite=None; Max-Age=86400 
-    &state=csrf-token-f3a8b2 +  Location: https://web-a.example.com/auth/callback 
- +    ?code=CODE-A-111 
-ServerSSO stores internally: +    &state=state-weba-f3a8b2c1
-  code "SplxlOBeZQQYbYS6WxSbIA" → { +
-    client_id   "web-app-001", +
-    redirect_uri: "https://web.example.com/auth/callback", +
-    scope:        "openid profile email api:serverA api:serverB", +
-    nonce:        "replay-token-9d4e1c", +
-    user_id:      "user-uid-456", +
-    expires_at:   now + 60s    ← One-time, short-lived +
-  }+
 </code> </code>
  
-----+> **The SSO session cookie is now set on the SSO domain.** 
 +> The browser will automatically send it on all future requests to ''sso.example.com''.
  
-==== Step — Browser Follows Redirect to Web App Callback ====+=== Step A8 — Browser Follows Redirect to WebA /callback ===
  
 <code> <code>
-Browser → Web App+Browser → WebA
-  GET https://web.example.com/auth/callback +  GET https://web-a.example.com/auth/callback 
-    ?code=SplxlOBeZQQYbYS6WxSbIA +    ?code=CODE-A-111 
-    &state=csrf-token-f3a8b2 +    &state=state-weba-f3a8b2c1 
-  Cookie: pre_auth_state=csrf-token-f3a8b2   ← set in Step 2+  Cookie: (none — WebA has no session cookie yet)
 </code> </code>
  
----- +=== Step A9 — WebA Validates state and Exchanges Code ===
- +
-==== Step — 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'] 
-    code  = request.GET.get('code') +    state = request.GET['state']
-    state = request.GET.get('state')+
  
-    # Retrieve stored state from pre-auth cookie / temp store +    # Retrieve stored pre-auth data 
-    stored_state request.COOKIES.get('pre_auth_state'+    preauth redis.get(f"preauth:weba:{state}"
-    if not state or state != stored_state+    if not preauth
-        raise SecurityError("State mismatch — possible CSRF attack")+        raise SecurityError("Unknown or expired state")
  
-    # Retrieve nonce for id_token validation later +    preauth_data = json.loads(preauth) 
-    nonce = temp_store.get(f"nonce:{stored_state}")+    redis.delete(f"preauth:weba:{state}"  # one-time use
  
-    # Proceed to token exchange +    # Exchange code for tokens — SERVER-TO-SERVER call 
-    return exchange_code_for_tokens(code, nonce) +    tokens = sso_token_exchange( 
-</code>+        code         code, 
 +        client_id    = 'web-a-001', 
 +        client_secret = WEBA_CLIENT_SECRET, 
 +        redirect_uri  = 'https://web-a.example.com/auth/callback' 
 +    )
  
-**What is validated:** +    # Validate id_token 
-  ''state'' in URL matches ''state'' stored in Step 2 → CSRF check ✅ +    id_claims = validate_id_token( 
-  - ''code'' is present and non-empty+        token    = tokens['id_token'], 
 +        audience = 'web-a-001'
 +        nonce    = preauth_data['nonce'] 
 +    )
  
-----+    # Create WebA session 
 +    session_id = create_session_weba(tokens, id_claims)
  
-==== Step 10 — Web App Backend Exchanges Code for Tokens (Server-to-Server) ====+    response redirect(preauth_data['redirect_to']) 
 +    response.set_cookie('session_a', session_id, 
 +                        httponly=True, secure=True, 
 +                        samesite='Lax', max_age=28800) 
 +    return response 
 +</code>
  
-This call is **server-to-server** — the browser is NOT involved.+=== Step A10 — WebA Exchanges Code at ServerSSO (Server-to-Server) ===
  
 <code> <code>
-Web App Backend → ServerSSO:+WebA Backend → ServerSSO:
   POST https://sso.example.com/token   POST https://sso.example.com/token
 +  Authorization: Basic d2ViLWEtMDAxOlNFQ1JFVC1B   ← base64(web-a-001:SECRET-A)
   Content-Type: application/x-www-form-urlencoded   Content-Type: application/x-www-form-urlencoded
-  Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl   ← base64(client_id:client_secret) 
  
   grant_type=authorization_code   grant_type=authorization_code
-  &code=SplxlOBeZQQYbYS6WxSbIA +  &code=CODE-A-111 
-  &redirect_uri=https://web.example.com/auth/callback+  &redirect_uri=https://web-a.example.com/auth/callback
 </code> </code>
  
-**ServerSSO validates:** +ServerSSO verifies
-  - Authorization header contains valid ''client_id'' + ''client_secret'' +  - ''client_id'' + ''client_secret''  
-  - ''code'' is valid, not expired, not already used +  - ''code'' valid, not expired, not reused ✅ 
-  - ''redirect_uri'' exactly matches the one used in /authorize +  - ''redirect_uri'' matches exactly 
-  - ''client_id'' in Authorization header matches the one the code was issued to+
  
-----+=== Step A11 — ServerSSO Returns Tokens to WebA ===
  
-==== Step 11 — ServerSSO Returns JWT Tokens ====+> 🔐 **PRIVATE KEY USED HERE — ServerSSO signs both JWTs with RSA private key** 
 +> ''kid: "key-2024-01"'' is embedded in each JWT header so verifiers know which public key to use.
  
 <code json> <code json>
-ServerSSO → Web App Backend:+ServerSSO → WebA Backend:
   HTTP 200   HTTP 200
-  Content-Type: application/json 
- 
   {   {
-    "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",+    "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(JWT-A)",
     "token_type":    "Bearer",     "token_type":    "Bearer",
     "expires_in":    900,     "expires_in":    900,
-    "refresh_token": "8xLOxBtZp8QyNcZpA3Rx",+    "refresh_token": "rtA-xyz-refresh-token",
     "id_token":      "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",     "id_token":      "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
-    "scope":         "openid profile email api:serverA api:serverB"+    "scope":         "openid profile email api:resourceA"
   }   }
 +
 +  JWT header (both tokens):  { "alg": "RS256", "kid": "key-2024-01", "typ": "JWT" }
 +  Signature computed with:   🔐 ServerSSO RSA PRIVATE KEY
 </code> </code>
  
-----+ServerSSO also updates its SSO session: 
 +<code> 
 +SSO session "SSO-XYZ-789": 
 +  authenticated_clients: ["web-a-001"  ← WebA added 
 +</code>
  
-==== Step 12 — Web App Validates id_token and Stores Session ====+=== Step A12 — WebA Stores Session and Sets Cookie ===
  
-<code python> +🔓 **PUBLIC KEY USED HERE — WebA fetches JWKS and verifies id_token signature** 
-# Validate id_token +> WebA calls ''GET https://sso.example.com/.well-known/jwks.json'', finds the key 
-id_token_payload = verify_jwt( +> matching ''kid: "key-2024-01"'', then verifies the RSA signature on the id_token. 
-    token     = token_response['id_token'], +> If verification passes, WebA trusts the claims (sub, email, nonce, aud).
-    issuer    = 'https://sso.example.com', +
-    audience 'web-app-001',   ← id_token audience = client_id +
-    nonce     = stored_nonce   ← must match nonce from Step 3 +
-    algorithm = 'RS256', +
-    jwks_uri  = 'https://sso.example.com/.well-known/jwks.json' +
-)+
  
-# Create server-side session +<code> 
-session_id = generate_secure_random_id() +WebA stores in Redis: 
-session_store.set(session_id, +  Key: "weba:SESS-A-abc123" 
-    'user_id'          id_token_payload['sub']+  Value: 
-    'email'            id_token_payload['email']+    sub             "user-uid-456"
-    'name':              id_token_payload['name'], +    email:            "alice@example.com"
-    'access_token'     token_response['access_token']+    access_token:     "JWT-A"             ← signed by 🔐 private key 
-    'access_token_exp' time.time() token_response['expires_in']+    access_token_exp: now 900
-    'refresh_token'    token_response['refresh_token']+    refresh_token:    "rtA-xyz"
-    'sso_session_id':    id_token_payload.get('sid'), +    sso_session_id:   "SSO-XYZ-789", 
-    'created_at':        time.time() +    created_at:       now 
-}, ttl=28800)  # 8 hours+  } 
 + 
 +WebA → Browser: 
 +  HTTP 302 
 +  Set-Cookie: session_a=SESS-A-abc123; Domain=web-a.example.com; 
 +              HttpOnly; Secure; SameSite=Lax; Max-Age=28800 
 +  Location: https://web-a.example.com/dashboard 
 +</code> 
 + 
 +=== Step A13 — Browser Loads WebA Dashboard === 
 + 
 +<code> 
 +Browser → WebA: 
 +  GET https://web-a.example.com/dashboard 
 +  Cookie: session_a=SESS-A-abc123 
 + 
 +WebA → Browser: 
 +  HTTP 200  (HTML dashboard page
 +</code> 
 + 
 +WebA may call its own backend resources using the stored ''JWT-A''The browser only 
 +receives rendered HTML. 
 + 
 +**State after Part A:** 
 + 
 +<code> 
 +Browser cookies: 
 +  sso_session=SSO-XYZ-789  (domain: sso.example.com
 +  session_a=SESS-A-abc123   (domain: web-a.example.com) 
 +  [NO session_b yet] 
 + 
 +SSO session "SSO-XYZ-789": 
 +  authenticated_clients: ["web-a-001"
 + 
 +WebA session: exists with JWT-A 
 +WebB session: does NOT exist
 </code> </code>
  
 ---- ----
  
-==== Step 13 — Web App Sets Session Cookie and Redirects to Original Page ====+==== PART B — Seamless SSO Login to WebB ==== 
 + 
 +Alice opens a new tab and navigates to WebB. 
 + 
 +=== Step B1 — Browser Requests Protected Page on WebB ===
  
 <code> <code>
-Web App → Browser: +Browser → WebB
-  HTTP 302 +  GET https://web-b.example.com/reports 
-  Location: https://web.example.com/dashboard    ← original requested URL +  Cookie: (no session_b cookie — WebB has no session for Alice)
-  Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=28800+
 </code> </code>
  
-> **The browser now has only a session_id cookie — no JWT, no tokens.** +Note: ''session_a'' is NOT sent here — it is scoped to ''web-a.example.com'' only.
-> All sensitive material is on the server.+
  
-----+=== Step B2 — WebB Detects No Session ===
  
-==== Step 14 — Browser Requests Dashboard (Now Authenticated) ====+<code python> 
 +# WebB auth middleware — identical pattern to WebA 
 +def require_auth_webb(request): 
 +    session_id request.COOKIES.get('session_b'
 +    session    redis.get(f"webb:{session_id}") if session_id else None 
 + 
 +    if not session: 
 +        state generate_secure_random(32) 
 +        nonce generate_secure_random(32) 
 +        redis.setex(f"preauth:webb:{state}", 300, json.dumps({ 
 +            'nonce':       nonce, 
 +            'redirect_to': request.path 
 +        })) 
 +        return redirect_to_sso_authorize( 
 +            client_id    'web-b-001', 
 +            redirect_uri = 'https://web-b.example.com/auth/callback', 
 +            scope        = 'openid profile email api:resourceB', 
 +            state        = state, 
 +            nonce        = nonce 
 +        ) 
 +</code> 
 + 
 +=== Step B3 — WebB Redirects Browser to ServerSSO /authorize ===
  
 <code> <code>
-Browser → Web App+WebB → Browser
-  GET https://web.example.com/dashboard +  HTTP 302 
-  Cookiesession_id=abc123xyz+  Location: https://sso.example.com/authorize 
 +    ?response_type=code 
 +    &client_id=web-b-001 
 +    &redirect_uri=https://web-b.example.com/auth/callback 
 +    &scope=openid%20profile%20email%20api%3AresourceB 
 +    &state=state-webb-c7d5e2f1 
 +    &nonce=nonce-webb-4b8a3d6e
 </code> </code>
  
-----+=== Step B4 — Browser Follows Redirect to ServerSSO (WITH SSO Cookie) ===
  
-==== Step 15 — Web App Calls ServerA (Server-to-Server, Bearer JWT) ====+<code> 
 +Browser → ServerSSO: 
 +  GET https://sso.example.com/authorize?client_id=web-b-001&... 
 +  Cookie: sso_session=SSO-XYZ-789    ← SSO cookie from Step A7! 
 +</code> 
 + 
 +**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      session_store.get('abc123xyz') +def handle_authorize(request): 
-access_token session['access_token']+    sso_session_id request.COOKIES.get('sso_session') 
 +    sso_session    sso_store.get(sso_session_id)
  
-Web App calls ServerA backend-to-backend +    if sso_session and not is_expired(sso_session): 
-response http_client.get+        ✅ Valid SSO session found! 
-    'https://api-a.example.com/api/data', +        # No login page needed — user already authenticated 
-    headers={ +         
-        '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 
 +        code generate_code
 +            client_id    'web-b-001', 
 +            sso_session  sso_session, 
 +            scope        = request.params['scope'], 
 +            nonce        = request.params['nonce'
 +        ) 
 +         
 +        # Update SSO session 
 +        sso_session['authenticated_clients'].append('web-b-001'
 +        sso_store.update(sso_session_id, sso_session
 +         
 +        # Redirect immediately — no login page 
 +        return redirect(f"{request.params['redirect_uri']}?code={code}&state={request.params['state']}") 
 +     
 +    else: 
 +        # No SSO session → show login page 
 +        return show_login_page(request)
 </code> </code>
  
-**This is a server-to-server call — the browser is NOT involved.**+<code> 
 +ServerSSO → Browser: 
 +  HTTP 302 
 +  Location: https://web-b.example.com/auth/callback 
 +    ?code=CODE-B-222 
 +    &state=state-webb-c7d5e2f1 
 +  (NO login page was shown — completely transparent to Alice) 
 +</code>
  
-----+=== Step B6 — Browser Follows Redirect to WebB /callback ===
  
-==== Step 16 — ServerA Validates JWT via JWKS (Offline) ====+<code> 
 +Browser → WebB: 
 +  GET https://web-b.example.com/auth/callback 
 +    ?code=CODE-B-222 
 +    &state=state-webb-c7d5e2f1 
 +  Cookie: (no session_b — but that's fine, we have the code) 
 +</code> 
 + 
 +=== Step B7 — WebB Validates state and Exchanges Code ===
  
 <code python> <code python>
-# ServerA middleware +def callback_webb(request): 
-def validate_bearer_token(request): +    code  = request.GET['code'] 
-    auth_header = request.headers.get('Authorization', '') +    state = request.GET['state']
-    if not auth_header.startswith('Bearer '): +
-        return Response(401, {'error': 'missing_token'})+
  
-    token auth_header[7:]+    preauth redis.get(f"preauth:webb:{state}"
 +    if not preauth: 
 +        raise SecurityError("Unknown or expired state")
  
-    # Decode header to get kid +    preauth_data json.loads(preauth
-    header  decode_jwt_header(token+    redis.delete(f"preauth:webb:{state}")
-    jwks    = get_jwks_cached('https://sso.example.com/.well-known/jwks.json'+
-    pub_key = find_key(jwks, kid=header['kid'])+
  
-    if not pub_key: +    tokens = sso_token_exchange( 
-        # Unknown kid → force refresh JWKS cache once +        code          = code, 
-        jwks    fetch_jwks_live('https://sso.example.com/.well-known/jwks.json') +        client_id     = 'web-b-001', 
-        pub_key = find_key(jwks, kid=header['kid']) +        client_secret = WEBB_CLIENT_SECRET,     ← WebB's own secret 
-        if not pub_key: +        redirect_uri  = 'https://web-b.example.com/auth/callback
-            return Response(401, {'error': 'unknown_key'})+    )
  
-    # Verify signature + standard claims +    id_claims validate_id_token
-    payload verify_jwt(token, pub_key, { +        token    = tokens['id_token']
-        'issuer':    'https://sso.example.com', +        audience 'web-b-001',                 ← WebB validates its own id_token 
-        'audience':  'https://api-a.example.com', +        nonce    = preauth_data['nonce'] 
-        'algorithm': 'RS256+    )
-    })+
  
-    # Verify required scope +    session_id = create_session_webb(tokensid_claims)
-    if 'api:serverA' not in payload.get('scope''').split(): +
-        return Response(403, {'error': 'insufficient_scope'})+
  
-    request.user payload +    response redirect(preauth_data['redirect_to']) 
-    # Continue to handler...+    response.set_cookie('session_b', session_id, 
 +                        httponly=True, secure=True, 
 +                        samesite='Lax', max_age=28800) 
 +    return response
 </code> </code>
  
-**ServerSSO is NOT contacted here.** Validation is pure cryptographic verification +=== Step B8 — WebB Exchanges Code at ServerSSO (Server-to-Server) ===
-using the cached RSA public key.+
  
-----+<code> 
 +WebB Backend → ServerSSO: 
 +  POST https://sso.example.com/token 
 +  Authorization: Basic d2ViLWItMDAxOlNFQ1JFVC1C   ← base64(web-b-001:SECRET-B) 
 + 
 +  grant_type=authorization_code 
 +  &code=CODE-B-222 
 +  &redirect_uri=https://web-b.example.com/auth/callback 
 +</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 ''aud'' and ''scope'' from JWT-A, but signed 
 +> by the same private key (identified by ''kid: "key-2024-01"'' in the header).
  
 <code json> <code json>
-ServerA → Web App:+ServerSSO → WebB Backend:
   HTTP 200   HTTP 200
   {   {
-    "items": [...]+    "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(JWT-B)"
-    "user": "alice@example.com"+    "token_type":    "Bearer", 
 +    "expires_in":    900, 
 +    "refresh_token": "rtB-uvw-refresh-token", 
 +    "id_token":      "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...", 
 +    "scope":         "openid profile email api:resourceB"
   }   }
 +
 +  JWT header (both tokens):  { "alg": "RS256", "kid": "key-2024-01", "typ": "JWT" }
 +  Signature computed with:   🔐 ServerSSO RSA PRIVATE KEY  (same key as JWT-A)
 </code> </code>
  
-----+Note: ''JWT-B'' has ''aud: ["https://resource-b.example.com"]'' and 
 +''scope: "api:resourceB"'' — completely different from ''JWT-A''.
  
-==== 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 ''kid: "key-2024-01"'', 
 +> verifies the id_token RSA signature, then checks ''aud == "web-b-001"'' 
 +> and ''nonce == stored nonce''. WebB uses the **same public key** as WebA — 
 +> both share the same JWKS endpoint.
  
 <code> <code>
-Web App Backend → ServerB+WebB stores in Redis: 
-  GET https://api-b.example.com/api/records +  Key: "webb:SESS-B-def456" 
-  AuthorizationBearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...+  Value: { 
 +    sub:              "user-uid-456", 
 +    email:            "alice@example.com", 
 +    access_token:     "JWT-B",              ← signed by 🔐 private key 
 +    access_token_exp: now + 900, 
 +    refresh_token:    "rtB-uvw", 
 +    sso_session_id:   "SSO-XYZ-789",     ← same SSO session 
 +    created_at:       now 
 +  } 
 + 
 +WebB → Browser
 +  HTTP 302 
 +  Set-Cookiesession_b=SESS-B-def456; Domain=web-b.example.com
 +              HttpOnly; Secure; SameSite=Lax; Max-Age=28800 
 +  Locationhttps://web-b.example.com/reports
 </code> </code>
  
-ServerB performs the same JWKS-based JWT validation, checking: +=== Step B11 — Browser Loads WebB Reports Page ===
-  - ''aud'' includes ''https://api-b.example.com'' +
-  - ''scope'' includes ''api:serverB''+
  
-----+<code> 
 +Browser → WebB: 
 +  GET https://web-b.example.com/reports 
 +  Cookie: session_b=SESS-B-def456
  
-==== Step 19 — Web App Renders Final HTML Response ====+WebB → Browser: 
 +  HTTP 200  (HTML reports page) 
 +</code>
  
-<code +Alice is now logged into WebB without ever having entered her credentials a second time.
-Web App → Browser: +
-  HTTP 200 +
-  Content-Type: text/html+
  
-  <html> +**Final state after Scenario 1:**
-    <body> +
-      <h1>Dashboard</h1> +
-      <p>Welcome, Alice Martin</p> +
-      <!-- Data from ServerA and ServerB merged into page --> +
-    </body> +
-  </html> +
-</code>+
  
-The browser receives only rendered HTML**No tokens appear in the browser.**+<code> 
 +Browser cookies: 
 +  sso_session=SSO-XYZ-789  (domain: sso.example.com) 
 +  session_a=SESS-A-abc123   (domain: web-a.example.com) 
 +  session_b=SESS-B-def456   (domain: web-b.example.com)
  
-----+SSO session "SSO-XYZ-789": 
 +  authenticated_clients: ["web-a-001", "web-b-001"
 + 
 +WebA session SESS-A: exists, JWT-A (aud=resource-a, scope=api:resourceA) 
 +WebB session SESS-B: exists, JWT-B (aud=resource-b, scope=api:resourceB) 
 +</code>
  
 ==== Scenario 1 — Complete Flow Summary ==== ==== Scenario 1 — Complete Flow Summary ====
  
 <code> <code>
-Browser        Web App          ServerSSO        ServerA        ServerB +Browser        WebA           WebB           ServerSSO 
-  │               │                 │               │              │ +  │              │              │                 │ 
-  │─ GET /dash ─▶│                 │               │              │ +  │ PART A — First login (via WebA)               │ 
-  │               │ no session      │               │              │ +  │─ GET /dash ─▶│              │                 │ 
-  │◀─ 302 /auth │                 │               │              │ +  │              │ no session_a │                 │ 
-  │                                 │               │              │ +  │◀─ 302 ──────│              │                 │ 
-  │─ GET /authorize ───────────────▶│               │              │ +  │─ GET /authorize ───────────────────────────▶│ 
-  │◀─ 302 /login ──────────────────│               │              │ +  │              │              │  no sso_session  │ 
-  │─ POST /login ──────────────────▶│               │              │ +  │◀─ login page ──────────────────────────────│ 
-  │◀─ SSO session cookie ───────────│               │              │ +  │─ POST /login ──────────────────────────────│ 
-  │◀─ 302 /callback?code────────│               │              │ +  │◀─ sso_session cookie + 302 /callback?code ─│ 
-  │                                 │               │              │ +  │─ GET /callback?code=CODE-A ─▶               │ 
-  │─ GET /callback?code= ─────────▶│               │              │ +  │              │─ POST /token ──────────────────▶│ 
-  │               │─ POST /token ──▶│               │              │ +  │              │  (web-a-001 secret, s2s)      │ 
-  │               │  (+ client_secret, s2s)          │              │ +  │              │◀─ {JWT-A, id_token, refresh} ──│ 
-  │               │◀─ {tokens────│               │              │ +  │              │  create SESS-A                  │ 
-  │               │  store session  │               │              │ +  │◀─ 302 + session_a cookie ───│               │ 
-  │◀─ 302 /dash + session_id cookie │               │              │ +  │─ GET /dash ─▶│              │                 │ 
-  │                                 │               │              │ +  │◀─ 200 HTML ─│              │                 │ 
-  │─ GET /dash ──▶│                 │               │              │ +  │              │              │                 │ 
-  │  (session cookie)               │               │              │ +  │ PART B — Seamless SSO login (WebB)            │ 
-  │               │─ GET /api ──────────────────────▶│              │ +  │─ GET /reports ────────────▶│                 │ 
-  │               │  (Bearer JWT, s2s)               │              │ +  │              │              │ no session_b    │ 
-  │               │  validate JWT (JWKS offline)     │              │ +  │─ 302 ─────────────────────│                 │ 
-  │               │◀─ {data} ────────────────────────│              │ +  │─ GET /authorize ───────────────────────────▶│ 
-  │               │─ GET /api ─────────────────────────────────────▶│ +  │              │              │  sso_session ✅  │ 
-  │               │  (Bearer JWT, s2s)                              │ +  │              │              │  SKIP login!    │ 
-  │               │  validate JWT (JWKS offline)                    │ +  │◀─ 302 /callback?code=CODE-B ───────────────│ 
-  │               │◀─ {data} ───────────────────────────────────────│ +  │─ GET /callback?code=CODE-B ────────────────▶│ 
-  │◀─ 200 HTML ──│                 │               │              +  │              │              │─ POST /token ──▶│ 
 +  │              │              │  (web-b-001 + secret, s2s) 
 +  │              │              │◀─ {JWT-B, ...} ─│ 
 +  │              │              │  create SESS-B  │ 
 +  │◀─ 302 + session_b cookie ──────────────────│ 
 +  │─ GET /reports ────────────▶│                 │ 
 +  │◀─ 200 HTML ────────────────│                 │
 </code> </code>
  
 ---- ----
  
-===== Scenario 2: Authenticated User =====+===== Scenario 2: Authenticated User — Returns to WebA, Opens WebB =====
  
 ==== Overview ==== ==== Overview ====
  
-Alice already has an active session from a previous loginShe navigates to multiple +Alice already has valid sessions on both WebA and WebB (from Scenario 1 or a previous visit)
-pages, triggering calls to ServerA and ServerB. This scenario covers:+This scenario covers what happens when she:
  
-  * **2A** — Session and access_token both valid → serve directly +  * **2A** — Returns to WebA with a valid session and valid token 
-  * **2B** — Session valid but access_token expired → silent refresh +  * **2B** — WebA's token has expired — silently refreshed 
-  * **2C** — Session expired / invalidated → re-authentication+  * **2C** — WebA's session has expired — seamless re-auth via SSO session 
 +  * **2D** — Opens WebB simultaneously — each app manages its session independently
  
 ---- ----
  
-==== Scenario 2A — Session Active, Token Valid ====+==== Scenario 2A — Both Sessions Valid ====
  
-=== Step 1 — Browser Requests Page with Session Cookie ===+=== Accessing WebA ===
  
 <code> <code>
-Browser → Web App+Browser → WebA
-  GET https://web.example.com/reports +  GET https://web-a.example.com/dashboard 
-  Cookie: session_id=abc123xyz +  Cookie: session_a=SESS-A-abc123
-</code>+
  
-=== Step 2 — Web App Validates Session ===+WebA middleware: 
 +  session redis.get("weba:SESS-A-abc123"
 +  → 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_id = request.COOKIES.get('session_id') +
-    session    = session_store.get(session_id) +
- +
-    if not session: +
-        return redirect_to_login(request)   # No session +
- +
-    # Check access_token expiry (with 60s buffer) +
-    if session['access_token_exp'] < time.time() + 60: +
-        session = refresh_access_token(session)  # → Scenario 2B +
- +
-    request.user  = session +
-    request.token = session['access_token'+
-    # Continue to handler+
 </code> </code>
  
-=== Step 3 — Web App Calls ServerA and ServerB ===+=== Accessing WebB at the Same Time ===
  
 <code> <code>
-Web App → ServerA:  GET /api/reports  (AuthorizationBearer JWT) +Browser → WebB: 
-Web App → ServerB:  GET /api/metrics  (Authorization: Bearer JWT) +  GET https://web-b.example.com/reports 
-</code>+  Cookiesession_b=SESS-B-def456    ← WebB's own cookie 
 +  (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("webb:SESS-B-def456"
 +  → valid, not expired 
 +  → access_token not expired 
 +  → serve request directly
  
-=== Step 4 — Web App Renders Page ===+WebB → Browser: 
 +  HTTP 200 (HTML) 
 +</code>
  
-No SSO interaction. No new tokens. Pure session lookup + backend API calls. +Both apps serve their pages without any SSO interaction. The two sessions are 
- +completely independent — WebA'session state does not affect WebB and vice versa.
-<code> +
-Browser        Web App          ServerSSO        ServerA        ServerB +
-  │               │                 │               │              │ +
-  │─ GET /reports─▶│                 │               │              │ +
-  │  (session cookie)               │               │              │ +
-  │               │─ GET /api/reports ──────────────▶│              │ +
-  │               │◀─ {data} ────────────────────────│              │ +
-  │               │─ GET /api/metrics ────────────────────────────▶│ +
-  │               │◀─ {data} ───────────────────────────────────────│ +
-  │◀─ 200 HTML ──│                 │               │              │ +
-</code>+
  
 ---- ----
  
-==== Scenario 2B — Session Valid, Access Token Expired ====+==== Scenario 2B — WebA Token Expired (Silent Server-Side Refresh) ====
  
-=== 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['access_token_exp'] < time.time() + 60: +    if session['access_token_exp'] < time.time() + 60: 
-    new_tokens = refresh_access_token(session['refresh_token']) +        # Token expired or expiring soon — refresh silently 
-    if new_tokens: +        new_tokens = refresh_token_weba(session['refresh_token']) 
-        # Update session with new tokens + 
-        session['access_token'    = new_tokens['access_token'+        if new_tokens: 
-        session['access_token_exp'] = time.time() + new_tokens['expires_in'+            session['access_token'    = new_tokens['access_token'
-        session['refresh_token'   = new_tokens['refresh_token' # Rotated +            session['access_token_exp'] = time.time() + new_tokens['expires_in'
-        session_store.update(session_id, session) +            session['refresh_token'   = new_tokens['refresh_token'
-    else: +            redis.set(f"weba:{session['id']}"json.dumps(session)
-        # Refresh failed → force re-login +            return session['access_token'
-        return redirect_to_login(request)+        else: 
 +            # Refresh failed → force re-login 
 +            return None 
 + 
 +    return session['access_token']
 </code> </code>
  
-=== 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.+
  
 <code> <code>
-Web App Backend → ServerSSO:+WebA Backend → ServerSSO:
   POST https://sso.example.com/token   POST https://sso.example.com/token
-  Content-Type: application/x-www-form-urlencoded +  Authorization: Basic d2ViLWEtMDAxOlNFQ1JFVC1B
-  Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl+
  
   grant_type=refresh_token   grant_type=refresh_token
-  &refresh_token=8xLOxBtZp8QyNcZpA3Rx+  &refresh_token=rtA-xyz-refresh-token
 </code> </code>
  
 === 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 ''iat'' and ''exp'' claims,
 +> signed again with the private key. The ''kid'' in the header is unchanged
 +> (unless key rotation just happened).
  
 <code json> <code json>
 { {
-  "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(new)", +  "access_token":  "eyJhbGci...(new JWT-A)",
-  "token_type":    "Bearer",+
   "expires_in":    900,   "expires_in":    900,
-  "refresh_token": "9yMpaBuA3RxNewToken",   ← Rotated (old one invalidated) +  "refresh_token": "rtA-new-rotated-token",   ← old token invalidated 
-  "scope":         "openid profile email api:serverA api:serverB"+  "scope":         "openid profile email api:resourceA"
 } }
-</code> 
  
-=== 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,+
-    'access_token'    new_tokens['access_token'], +
-    'access_token_exp': time.time() + new_tokens['expires_in'], +
-    'refresh_token':    new_tokens['refresh_token'+
-}) +
-# Continue to serve the original request+
 </code> </code>
  
-=== Step — Web App Continues with New Token ===+**This is completely invisible to the user and to WebB.** 
 +WebB's session and tokens are unaffected. 
 + 
 +=== Step — WebA Continues Serving the Request ===
  
 <code> <code>
-Browser        Web App          ServerSSO        ServerA +Browser        WebA           WebB           ServerSSO 
-  │               │                 │               │ +  │              │              │                 │ 
-  │─ GET /reports─▶│                 │               │ +  │─ GET /dash ─▶│              │                 │ 
-  │  (session OK,  │                 │               │ +  │              │ token expired│                 │ 
-  │   token expired│─ POST /token ──▶│               │ +  │              │─ POST /token (refresh, s2s) ──▶│ 
-  │               │  (refresh, s2s)  │               │ +  │              │◀─ new JWT-A ───────────────────│ 
-  │               │◀─ new tokens ───│               │ +  │              │ update session                  │ 
-  │               │  update session  │               │ +  │◀─ 200 HTML ─│              │                 │ 
-  │               │─ GET /api ───────────────────────▶│ +  │              │              │                 │ 
-  │               │  (new Bearer JWT)│               │ +  │─ GET /rpts ────────────────▶│                 │ 
-  │               │◀─ {data} ────────────────────────│ +  │              │              │ token valid     │ 
-  │◀─ 200 HTML ──│                 │               +  │◀─ 200 HTML ─────────────────│                 │
 </code> </code>
- 
-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 Session ===+=== Step 1 — WebA Finds No Valid Session ===
  
-<code python+<code> 
-def require_auth(request)+Browser → WebA
-    session_id = request.COOKIES.get('session_id') +  GET https://web-a.example.com/dashboard 
-    session    session_store.get(session_id)+  Cookie: session_a=SESS-A-abc123   ← exists but expired in Redis
  
-    # Session expired or doesn't exist +WebA: session lookup returns null (TTL expired) 
-    if not session or session_is_expired(session): +WebA: redirect to SSO /authorize (same as Scenario 1 Step A3)
-        # Save intended URL for post-login redirect +
-        original_url = request.build_absolute_uri() +
-        request.session['post_login_redirect'] = original_url +
-        return redirect_to_oidc_login(request)+
 </code> </code>
  
-=== Step 2 — Web App Redirects to SSO ===+=== Step 2 — Browser Arrives at SSO with Valid SSO Session Cookie ===
  
-Same as Scenario 1 Step 3But this time:+<code> 
 +Browser → ServerSSO: 
 +  GET https://sso.example.com/authorize?client_id=web-a-001&... 
 +  Cookiesso_session=SSO-XYZ-789   ← SSO session is still valid! 
 +</code>
  
-  * 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+
  
 <code> <code>
-SSO session still active (seamless re-auth): +ServerSSO: SSO session valid → skip login → issue code 
-  Browser → ServerSSOGET /authorize +ServerSSO → Browser: 
-  ServerSSO sees valid SSO session cookie +  HTTP 302 
-  ServerSSO → Browser302 /callback?code=...  (no login page shown) +  Location: https://web-a.example.com/auth/callback?code=CODE-A-NEW&state=...
-   +
-SSO session also expired: +
-  Browser → ServerSSO: GET /authorize +
-  ServerSSO sees no valid session +
-  ServerSSO → Browser: login page +
-  Alice logs in again → proceeds normally+
 </code> </code>
  
-This is the **SSO effect** — if Alice has multiple apps sharing the same SSO server, +=== Step 4 — WebA Completes Token ExchangeCreates 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 ===+<code> 
 +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 /dashboard 
 +User never saw a login prompt. 
 +</code>
  
-<code python+<code> 
-# After successful token exchange (same as Scenario 1) +Browser        WebA           WebB           ServerSSO 
-# Redirect to original URL the user wanted +  │              │              │                 │ 
-original_url = request.session.pop('post_login_redirect', '/dashboard') +  │─ GET /dash ─▶│              │                 │ 
-return redirect(original_url)+  │              │ SESS-A expired              │ 
 +  │◀─ 302 ──────│              │                 │ 
 +  │─ GET /authorize ───────────────────────────▶│ 
 +  │              │              │  sso_session ✅  │ 
 +  │◀─ 302 /callback?code ──────────────────────│ 
 +  │─ GET /callback ─────────────▶               │ 
 +  │              │─ POST /token ──────────────────▶│ 
 +  │              │◀─ new tokens ──────────────────│ 
 +  │              │ create new SESS-A              │ 
 +  │◀─ 302 + new session_a cookie│               │ 
 +  │─ GET /dash ─▶│              │                 │ 
 +  │◀─ 200 HTML ─│              │                 │
 </code> </code>
 +
 +> WebB is completely unaware of this. Its session and tokens are unaffected.
  
 ---- ----
  
-==== Scenario — Complete Flow Summary ====+==== Scenario 2D — WebB Token Expired (Independent of WebA) ==== 
 + 
 +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.
  
 <code> <code>
-Browser        Web App          ServerSSO        ServerA        ServerB +Browser        WebA           WebB           ServerSSO 
-  │               │                 │               │              │ +  │              │              │                 │ 
-  │ [2A: Session and token valid]   │               │              │ +  │─ GET /rpts ────────────────▶│                 │ 
-  │─ GET /page ──▶│                 │               │              │ +  │              │              │ token expired   │ 
-  │               │─ GET /api ──────────────────────▶│              │ +  │              │              │─ POST /token ──▶│ 
-  │               │◀─ {data} ────────────────────────│              │ +  │              │              │  (web-b-001 + rtB, s2s) 
-  │◀─ 200 HTML ──│                 │               │              │ +  │              │              │◀─ new JWT-B ────│ 
-  │               │                 │               │              │ +  │              │              │ update SESS-B   │ 
-  │ [2B: Session valid, token expired]              │              │ +  │◀─ 200 HTML ─────────────────│                 │ 
-  │─ GET /page ──▶│                 │               │              │ +  │              │              │                 │ 
-  │               │─ POST /token ──▶│               │              │ +  │─ GET /dash ─▶│              │                 │ 
-  │               │  (refresh, s2s) │               │              │ +  │              │ token valid  │                 │ 
-  │               │◀─ new tokens ───│               │              │ +  │◀─ 200 HTML ─│              │                 │
-  │               │─ GET /api ──────────────────────▶│              │ +
-  │               │◀─ {data} ────────────────────────│              │ +
-  │◀─ 200 HTML ──│                 │               │              │ +
-  │               │                 │               │              │ +
-  │ [2C: Session expired]           │               │              │ +
-  │─ GET /page ──▶│                 │               │              │ +
-  │               │ no session      │               │              │ +
-  │◀─ 302 ───────│                 │               │              │ +
-  │─ GET /authorize ───────────────▶│               │              │ +
-  │  SSO session still active       │               │              │ +
-  │◀─ 302 /callback?code= ─────────│               │              │ +
-  │─ GET /callback?code= ─────────▶│               │              │ +
-  │               │─ POST /token ──▶│               │              │ +
-  │               │◀─ {tokens} ────│               │              │ +
-  │◀─ 302 /page + session cookie ──│               │              │ +
-  │─ GET /page ──▶│                 │               │              │ +
-  │               │─ GET /api ──────────────────────▶│              │ +
-  │               │◀─ {data} ────────────────────────│              │ +
-  │◀─ 200 HTML ──│                 │               │              +
 </code> </code>
 +
 +Each application independently manages its own token lifecycle.
  
 ---- ----
Line 988: Line 1308:
 ===== Sequence Diagrams ===== ===== Sequence Diagrams =====
  
-==== Full Authorization Code Flow (Confidential Client) ====+==== Master Flow: Unauthenticated → WebA → WebB ====
  
 <code> <code>
-Step-by-step:+ 1.  Browser     → WebA         :  GET /page  (no session_a) 
 + 2.  WebA        → WebA         :  generate state-A, nonce-A; store in Redis 
 + 3.  WebA        → Browser      :  302 → /authorize?client_id=web-a-001&state=state-A 
 + 4.  Browser     → ServerSSO    :  GET /authorize  (no sso_session) 
 + 5.  ServerSSO   → Browser      :  200 login page 
 + 6.  Browser     → ServerSSO    :  POST /login (credentials) 
 + 7.  ServerSSO   → ServerSSO    :  validate creds; create SSO session SSO-XYZ 
 + 8.  ServerSSO   → Browser      :  Set-Cookie: sso_session + 302 → WebA/callback?code=CODE-A 
 + 9.  Browser     → WebA         :  GET /callback?code=CODE-A&state=state-A 
 +10.  WebA        → WebA         :  validate state-A == stored; retrieve nonce-A 
 +11.  WebA        → ServerSSO    :  POST /token (code=CODE-A, client_secret-A)  [s2s] 
 +12.  ServerSSO   → ServerSSO    :  verify client, code, redirect_uri 
 +13.  ServerSSO   → ServerSSO    :  🔐 SIGN JWT-A + id_token-A with RSA PRIVATE KEY 
 +14.  ServerSSO   → WebA         :  { JWT-A, id_token-A, refresh-A } 
 +15.  WebA        → WebA         :  🔓 VERIFY id_token-A signature with RSA PUBLIC KEY (JWKS) 
 +16.  WebA        → WebA         :  validate id_token-A claims (aud=web-a-001, nonce=nonce-A) 
 +17.  WebA        → Redis        :  store SESS-A { JWT-A, refresh-A, sso_sid=SSO-XYZ } 
 +18.  WebA        → Browser      :  Set-Cookie: session_a + 302 → /page 
 +19.  Browser     → WebA         :  GET /page  (Cookie: session_a=SESS-A) 
 +20.  WebA        → Browser       200 HTML
  
- 1.  Browser      →  Web App    :  GET /protected-page  (no session cookie+── Alice now opens WebB ── 
- 2.  Web App      →  Web App    :  generate state, nonce; store temporarily + 
- 3.  Web App      →  Browser    :  HTTP 302 → /authorize?...&state=X&nonce=Y +21.  Browser     → WebB         :  GET /page  (no session_b
- 4.  Browser      →  ServerSSO  :  GET /authorize?response_type=code&client_id=... +22.  WebB        → WebB         :  generate state-B, nonce-B; store in Redis 
- 5.  ServerSSO    →  Browser    :  200 login page  (no SSO session+23.  WebB        → Browser      :  302 → /authorize?client_id=web-b-001&state=state-B 
- 6.  Browser      →  ServerSSO  :  POST /login (username + password) +24.  Browser     → ServerSSO    :  GET /authorize  (Cookie: sso_session=SSO-XYZ ✅) 
- 7.  ServerSSO    →  ServerSSO  :  validate credentials, create SSO session +25.  ServerSSO   → ServerSSO    :  SSO session valid → skip login → issue CODE-B 
- 8.  ServerSSO    →  Browser    :  Set-Cookie: sso_session + 302 → /callback?code= +26.  ServerSSO   → Browser      :  302 → WebB/callback?code=CODE-B  (no login page!) 
- 9.  Browser      →  Web App    :  GET /callback?code=AUTH_CODE&state=X +27.  Browser     → WebB         :  GET /callback?code=CODE-B&state=state-B 
-10.  Web App      →  Web App    :  validate state == stored state  (CSRF check) +28.  WebB        → WebB         :  validate state-B == stored; retrieve nonce-B 
-11.  Web App      →  ServerSSO  :  POST /token (code client_secret)  [s2s] +29.  WebB        → ServerSSO    :  POST /token (code=CODE-B, client_secret-B)  [s2s] 
-12.  ServerSSO    →  ServerSSO  :  verify client_secret, validate code +30.  ServerSSO   → ServerSSO    :  🔐 SIGN JWT-B + id_token-B with RSA PRIVATE KEY (same key) 
-13.  ServerSSO    →  Web App    :  { access_token, id_token, refresh_token +31.  ServerSSO   → WebB         :  { JWT-B, id_token-Brefresh-B 
-14.  Web App      →  Web App    :  validate id_token (nonce, aud, exp, sig via JWKS+32.  WebB        → WebB         :  🔓 VERIFY id_token-B signature with RSA PUBLIC KEY (JWKS) 
-15.  Web App      →  SessionDB  :  store { usertokens under session_id +33.  WebB        → WebB         :  validate id_token-B claims (aud=web-b-001nonce=nonce-B
-16.  Web App      →  Browser    :  Set-Cookie: session_id=XYZ + 302 → /page +34.  WebB        → Redis        :  store SESS-B JWT-Brefresh-B, sso_sid=SSO-XYZ 
-17.  Browser      →  Web App    :  GET /page  (Cookie: session_id=XYZ+35.  WebB        → Browser      :  Set-Cookie: session_b + 302 → /page 
-18.  Web App      →  SessionDB  :  get session → retrieve access_token +36.  Browser     → WebB         :  GET /page  (Cookie: session_b=SESS-B
-19.  Web App      →  ServerA     GET /api  (Bearer JWT)  [s2s] +37.  WebB        → Browser      :  200 HTML 
-20.  ServerA      →  JWKS Cache :  verify JWT (RS256offline) + 
-21.  ServerA      →  Web App    :  200 { data } +KEY USAGE SUMMARY FOR THIS FLOW
-22.  Web App      →  ServerB    :  GET /api  (same Bearer JWT [s2s] +  Steps 1330  →  🔐 ServerSSO RSA PRIVATE KEY   (signing
-23.  ServerB      →  JWKS Cache :  verify JWT (RS256offline) +  Steps 1532  →  🔓 RSA PUBLIC KEY from JWKS     (verification)
-24.  ServerB      →  Web App    :  200 { data } +
-25.  Web App      →  Browser    :  200 HTML page (data merged)+
 </code> </code>
  
Line 1024: Line 1361:
 ===== API Contracts ===== ===== API Contracts =====
  
-==== ServerSSO Endpoints ==== +==== ServerSSO Discovery Document ====
- +
-=== GET /.well-known/openid-configuration ===+
  
 <code json> <code json>
 +// GET https://sso.example.com/.well-known/openid-configuration
 { {
   "issuer": "https://sso.example.com",   "issuer": "https://sso.example.com",
   "authorization_endpoint": "https://sso.example.com/authorize",   "authorization_endpoint": "https://sso.example.com/authorize",
-  "token_endpoint": "https://sso.example.com/token", +  "token_endpoint":         "https://sso.example.com/token", 
-  "userinfo_endpoint": "https://sso.example.com/userinfo", +  "userinfo_endpoint":      "https://sso.example.com/userinfo", 
-  "jwks_uri": "https://sso.example.com/.well-known/jwks.json", +  "jwks_uri":               "https://sso.example.com/.well-known/jwks.json", 
-  "end_session_endpoint": "https://sso.example.com/logout",+  "end_session_endpoint":   "https://sso.example.com/logout",
   "backchannel_logout_supported": true,   "backchannel_logout_supported": true,
   "backchannel_logout_session_supported": true,   "backchannel_logout_session_supported": true,
-  "response_types_supported": ["code"], +  "response_types_supported":                ["code"], 
-  "subject_types_supported": ["public"], +  "token_endpoint_auth_methods_supported":   ["client_secret_basic", "client_secret_post"], 
-  "id_token_signing_alg_values_supported": ["RS256"], +  "id_token_signing_alg_values_supported":   ["RS256"], 
-  "scopes_supported": ["openid", "profile", "email", "api:serverA", "api:serverB"], +  "scopes_supported": ["openid","profile","email","api:resourceA","api:resourceB"], 
-  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], +  "claims_supported":  ["sub","email","name","sid","roles"]
-  "claims_supported": ["sub", "email", "name", "roles", "sid"]+
 } }
 </code> </code>
  
-=== POST /token — Authorization Code ===+==== POST /token — Authorization Code ====
  
-**Request (client_secret_basic):**+**WebA request:**
 <code> <code>
-POST /token HTTP/1.1 +POST /token 
-Hostsso.example.com +AuthorizationBasic base64(web-a-001:SECRET-A)
-Content-Typeapplication/x-www-form-urlencoded +
-Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl+
  
 grant_type=authorization_code grant_type=authorization_code
-&code=SplxlOBeZQQYbYS6WxSbIA +&code=CODE-A-111 
-&redirect_uri=https://web.example.com/auth/callback+&redirect_uri=https://web-a.example.com/auth/callback
 </code> </code>
  
-**Response:** +**WebB request:**
-<code json> +
-+
-  "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...", +
-  "token_type":    "Bearer", +
-  "expires_in":    900, +
-  "refresh_token": "8xLOxBtZp8QyNcZpA3Rx", +
-  "id_token":      "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...", +
-  "scope":         "openid profile email api:serverA api:serverB" +
-+
-</code> +
- +
-=== POST /token — Refresh Token === +
 <code> <code>
-POST /token HTTP/1.1 +POST /token 
-Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl+Authorization: Basic base64(web-b-001:SECRET-B)
  
-grant_type=refresh_token +grant_type=authorization_code 
-&refresh_token=8xLOxBtZp8QyNcZpA3Rx+&code=CODE-B-222 
 +&redirect_uri=https://web-b.example.com/auth/callback
 </code> </code>
  
-=== POST /backchannel_logout ===+==== POST /backchannel_logout ====
  
-ServerSSO calls this endpoint on the Web App when a user logs out from any session:+ServerSSO sends this to each registered app on global logout:
  
 <code> <code>
-POST https://web.example.com/auth/backchannel-logout+POST https://web-a.example.com/auth/backchannel-logout 
 +POST https://web-b.example.com/auth/backchannel-logout 
 Content-Type: application/x-www-form-urlencoded Content-Type: application/x-www-form-urlencoded
- 
 logout_token=eyJhbGciOiJSUzI1NiJ9... logout_token=eyJhbGciOiJSUzI1NiJ9...
 </code> </code>
Line 1097: Line 1419:
 <code json> <code json>
 { {
-  "iss": "https://sso.example.com", +  "iss":    "https://sso.example.com", 
-  "sub": "user-uid-456", +  "aud":    "web-a-001",           ← app-specific (different token sent to each app) 
-  "aud": "web-app-001", +  "iat":    1718549100, 
-  "iat": 1718549100, +  "jti":    "logout-jti-unique", 
-  "jti": "unique-logout-jti", +  "sub":    "user-uid-456", 
-  "sid": "sso-session-xyz",      ← SSO session ID → find all web sessions for this SSO session +  "sid":    "SSO-XYZ-789", 
-  "events": { +  "events": { "http://schemas.openid.net/event/backchannel-logout": {} }
-    "http://schemas.openid.net/event/backchannel-logout": {} +
-  }+
 } }
 </code> </code>
 +
 +==== 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, any party could forge a logout request
 +> and force-terminate user sessions. The public key guarantees the logout_token genuinely
 +> came from ServerSSO.
  
 <code python> <code python>
-Web App backchannel logout handler +Identical handler pattern for both apps — only Redis prefix differs 
-def backchannel_logout(request):+def backchannel_logout_weba(request):
     logout_token = request.POST.get('logout_token')     logout_token = request.POST.get('logout_token')
-    payload = verify_logout_token(logout_token)  # validate sig, iss, aud 
-     
-    sso_sid = payload.get('sid') 
-    sub     = payload.get('sub') 
-     
-    # Delete all web sessions associated with this SSO session 
-    session_store.delete_by_sso_session(sso_sid) 
-     
-    return Response(200)  # Must respond 200 to acknowledge 
-</code> 
  
-==== Error Responses ====+    # 🔓 Validate signature via JWKS (public key) 
 +    payload verify_jwt( 
 +        token    logout_token, 
 +        audience 'web-a-001', 
 +        issuer   'https://sso.example.com', 
 +        jwks_uri 'https://sso.example.com/.well-known/jwks.json' 
 +    )
  
-^ HTTP ^ error                ^ Description                                   ^ +    sso_sid = payload['sid']
-| 400  | invalid_request      | Missing or malformed parameter                | +
-| 400  | invalid_grant        | Code or refresh_token invalid / expired       | +
-| 401  | invalid_client       | client_id or client_secret incorrect          | +
-| 403  | insufficient_scope   | Requested scope not allowed for this client +
-| 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 redis.smembers(f"sso_to_sessions:weba:{sso_sid}"
 +    for sid in session_ids: 
 +        redis.delete(f"weba:{sid}"
 +    redis.delete(f"sso_to_sessions:weba:{sso_sid}")
  
-**Request:** +    return HttpResponse(status=200)
-<code> +
-GET /api/data HTTP/1.1 +
-Host: api-a.example.com +
-Authorization: Bearer {access_token_jwt} +
-X-Request-ID: {uuid}+
 </code> </code>
- 
-**Error Responses:** 
- 
-^ HTTP ^ WWW-Authenticate header           ^ Meaning                              ^ 
-| 401  | Bearer error="missing_token"      | No Authorization header              | 
-| 401  | Bearer error="invalid_token"      | JWT malformed or signature invalid   | 
-| 401  | Bearer error="token_expired"      | JWT exp in the past                  | 
-| 403  | Bearer error="insufficient_scope" | Scope check failed                   | 
-| 403  | Bearer error="invalid_audience"   | aud claim doesn't match this server  | 
  
 ---- ----
  
-===== 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    = "https://sso.example.com/.well-known/jwks.json" +<code> 
-ISSUER      = "https://sso.example.com" +Step 1: Browser → WebA
-SERVER_A_AUDIENCE = "https://api-a.example.com" +  POST https://web-a.example.com/logout 
-CACHE_TTL   3600  # 1 hour+  Cookie: session_a=SESS-A-abc123
  
-_jwks_cache = {'keys'{}, 'fetched_at'0}+Step 2WebA: 
 +  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: 
-    """Retrieve RSA public key from JWKS cache, refresh if needed.""" +  HTTP 302 
-    global _jwks_cache+  Set-Cookie: session_a=; Max-Age=0   ← clear cookie 
 +  Location: https://sso.example.com/logout 
 +    ?id_token_hint=eyJhbGci... 
 +    &post_logout_redirect_uri=https://web-a.example.com/logged-out 
 +    &state=random-state
  
-    # Check cache +Step 4: Browser → ServerSSO: 
-    if kid in _jwks_cache['keys'] and \ +  GET /logout?id_token_hint=..
-       time.time() - _jwks_cache['fetched_at'] < CACHE_TTL: +  Cookie: sso_session=SSO-XYZ-789
-        return _jwks_cache['keys'][kid]+
  
-    # Refresh JWKS +Step 5: ServerSSO: 
-    response = requests.get(JWKS_URLtimeout=5) +  aValidates id_token_hint 
-    jwks = response.json()+  b. Terminates SSO session SSO-XYZ-789 
 +  c. Clears sso_session cookie 
 +  d. Looks up authenticated_clients: ["web-a-001""web-b-001"] 
 +  eSends backchannel logout to each registered client
  
-    _jwks_cache['fetched_at'] = time.time() +Step 6: ServerSSO → WebA (backchannel): 
-    _jwks_cache['keys'{}+  POST https://web-a.example.com/auth/backchannel-logout 
 +  (logout_token with aud=web-a-001, sid=SSO-XYZ-789) 
 +  WebA: already deleted — ack 200
  
-    for key_data in jwks['keys']+Step 7: ServerSSO → WebB (backchannel)
-        public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key_data+  POST https://web-b.example.com/auth/backchannel-logout 
-        _jwks_cache['keys'][key_data['kid']] = public_key+  (logout_token with aud=web-b-001, sid=SSO-XYZ-789
 +  WebB: delete SESS-B from Redis ← Alice is now logged out of WebB too!
  
-    return _jwks_cache['keys'].get(kid)+Step 8: ServerSSO → Browser: 
 +  HTTP 302 
 +  Set-Cookie: sso_session=; Max-Age=0   ← SSO cookie cleared 
 +  Location: https://web-a.example.com/logged-out
  
-def validate_token(token, required_audience, required_scope)+Step 9: Browser → WebA
-    """Validate JWT and return payload or raise an exception.""" +  GET https://web-a.example.com/logged-out 
-    # Decode header only (no verification) to get kid +  (session_a already cleared)
-    unverified_header = jwt.get_unverified_header(token) +
-    kid = unverified_header.get('kid')+
  
-    public_key = get_public_key(kid) +Step 10WebA → Browser
-    if not public_key: +  HTTP 200 "You have been logged out"
-        raise ValueError(f"Unknown key id{kid}") +
- +
-    # Full verification +
-    payload = jwt.decode( +
-        token, +
-        public_key, +
-        algorithms=["RS256"], +
-        issuer=ISSUER, +
-        audience=required_audience, +
-        options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub"]} +
-    ) +
- +
-    # Scope check +
-    scopes = payload.get('scope', '').split() +
-    if required_scope not in scopes: +
-        raise PermissionError(f"Missing required scope: {required_scope}"+
- +
-    return payload+
 </code> </code>
  
-==== Complete Validation Middleware (Java/Spring) ====+**Result:** Alice is logged out of WebA, WebB, and the SSO server in a single logout action.
  
-<code java+<code> 
-@Component +Browser        WebA           WebB           ServerSSO 
-public class JwtValidationFilter extends OncePerRequestFilter { +  │              │              │                 │ 
- +  │─ POST /logout▶│              │                 │ 
-    private static final String ISSUER   = "https://sso.example.com"; +  │              │ delete SESS-A│                 │ 
-    private static final String AUDIENCE = "https://api-a.example.com"; +  │◀─ 302 /sso/logout ──────────────────────────│ 
-    private static final String REQUIRED_SCOPE = "api:serverA"; +  │─ GET /sso/logout ──────────────────────────▶│ 
- +  │              │              │  terminate SSO  │ 
-    private final JwkSet jwkSet;    // Cached, refreshed hourly +  │              │              │◀─ POST backchannel-logout (web-b-001
-    private final JwkSetCache cache; +  │              │              │  delete SESS-B  
- +  │              │◀─ POST backchannel-logout (web-a-001
-    @Override +  │              │  (already gone — 200 ack     │ 
-    protected void doFilterInternal(HttpServletRequest req, +  │◀─ 302 /logged-out ─────────────────────────│ 
-                                    HttpServletResponse res, +  │─ GET /logged-out ───▶│      │                 │ 
-                                    FilterChain chain) +  │◀─ 200 HTML ─│              │                 │
-            throws ServletException, IOException { +
- +
-        String authHeader = req.getHeader("Authorization"); +
-        if (authHeader == null || !authHeader.startsWith("Bearer ")) { +
-            res.sendError(401, "Missing Bearer token"); +
-            return; +
-        } +
- +
-        String token = authHeader.substring(7); +
- +
-        try { +
-            // 1. Parse header for kid +
-            SignedJWT signedJWT = SignedJWT.parse(token); +
-            String kid = signedJWT.getHeader().getKeyID(); +
- +
-            // 2. Get key from cache (fetch from JWKS if unknown+
-            RSAKey rsaKey = cache.getKey(kid); +
- +
-            // 3. Verify signature +
-            RSASSAVerifier verifier = new RSASSAVerifier(rsaKey); +
-            if (!signedJWT.verify(verifier)) { +
-                res.sendError(401, "Invalid token signature"); +
-                return; +
-            } +
- +
-            // 4. Validate claims +
-            JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); +
-            validateClaims(claims); +
- +
-            // 5. Inject user into request context +
-            SecurityContextHolder.getContext() +
-                .setAuthentication(buildAuth(claims)); +
- +
-            chain.doFilter(req, res); +
- +
-        } catch (Exception e) { +
-            res.sendError(401, "Token validation failed: " + e.getMessage()); +
-        } +
-    } +
- +
-    private void validateClaims(JWTClaimsSet claims) throws Exception { +
-        if (!ISSUER.equals(claims.getIssuer())) +
-            throw new Exception("Invalid issuer"); +
-        if (!claims.getAudience().contains(AUDIENCE)) +
-            throw new Exception("Invalid audience"); +
-        if (claims.getExpirationTime().before(new Date())) +
-            throw new Exception("Token expired"); +
-        if (!claims.getStringClaim("scope").contains(REQUIRED_SCOPE)) +
-            throw new Exception("Insufficient scope"); +
-    } +
-}+
 </code> </code>
  
-----+==== 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) ====+
  
 <code> <code>
-1.  User clicks "Logoutbutton on Web App+1. Delete SESS-A from Redis 
 +2. Clear session_a cookie 
 +3. Redirect to local "logged out from WebApage 
 +4. Do NOT call SSO /logout endpoint
  
-2 Browser → Web App: +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" vs "federated 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=; Max-Age=0; HttpOnly; Secure +
-      Location: https://sso.example.com/logout +
-        ?id_token_hint=eyJhbGci... +
-        &post_logout_redirect_uri=https://web.example.com/logged-out +
-        &state=random-state-xyz+
  
-5.  Browser → ServerSSO: +===== Security Considerations =====
-      GET /logout?id_token_hint=...+
  
-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://web.example.com/logged-out +
-      (session cookie already cleared) +
- +
-8.  Web App → Browser: +
-      200 "You have been logged out" +
-</code>+
  
-==== Backchannel Logout (SSO-Initiated====+^ Rule                              ^ Reason                                                      ^ 
 +| Each app has its own client_secret | Compromise of one app does not expose others               | 
 +| id_token audience isolation        | WebA's id_token rejected by WebB (aud mismatch           | 
 +| JWT audience isolation             | JWT-A usable only at resource-a; JWT-B only at resource-b 
 +| Separate session stores            | WebA cannot read WebB sessions                             | 
 +| state scoped per app               | state generated by WebA is meaningless to WebB             | 
 +| Backchannel logout required        | Global logout must propagate to all registered clients     | 
 +| 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 ====
  
 <code> <code>
-ServerSSO → Web App Backend: +Domain scoping prevents cross-app cookie access:
-  POST https://web.example.com/auth/backchannel-logout +
-  (logout_token JWT identifying sub + sid)+
  
-Web App: +session_a:  Domain=web-a.example.com  → sent ONLY to web-a.example.com 
-  Validates logout_token signature (JWKS) +session_b:  Domain=web-b.example.com  → sent ONLY to web-b.example.com 
-  Finds all sessions matching sso_session_id (sid claim) +sso_session: Domain=sso.example.com  → sent ONLY to sso.example.com
-  - Deletes those sessions from session store +
-  Returns HTTP 200+
  
-Result: User is silently logged out of the Web App even though +Even if WebA is compromised, it cannot read session_b. 
-        they only explicitly logged out from a different application.+Even if WebB is compromised, it cannot read session_a.
 </code> </code>
- 
----- 
- 
-===== Security Considerations ===== 
- 
-==== Cookie Security Requirements ==== 
- 
-^ Attribute       ^ Value         ^ Reason                                        ^ 
-| HttpOnly        | true          | JavaScript cannot read the session cookie      | 
-| Secure          | true          | Cookie only sent over HTTPS                   | 
-| SameSite        | Lax           | CSRF protection for standard navigation        | 
-| Path            | /             | Cookie sent to all app paths                  | 
-| Max-Age / Expires | 8 hours    | Session timeout                               | 
-| Domain          | web.example.com | Restrict to this app only                  | 
- 
-==== client_secret Security ==== 
- 
-^ Requirement                          ^ Implementation                              ^ 
-| Never hardcode in source code        | Use environment variables / secret manager  | 
-| Never log client_secret              | Filter from all application logs            | 
-| Rotate periodically                  | Minimum annually; immediately if leaked     | 
-| Store in secrets manager             | HashiCorp Vault, AWS Secrets Manager, etc.  | 
-| Use TLS for all /token calls         | Enforce HTTPS on SSO token endpoint         | 
  
 ==== Security Checklist ==== ==== Security Checklist ====
  
-^ # ^ Control                          ^ Description                                              +^ # ^ Control                         ^ Description                                                     
-| 1 | state parameter validation       Always verify state matches stored value (CSRF)          +| 1 | Separate client credentials     Unique client_id + client_secret per application               
-| 2 | nonce in id_token                Verify nonce to prevent id_token replay                  +| 2 | state validation per app        Each app generates and validates its own state                 
-| 3 | Exact redirect_uri match         SSO rejects partial or wildcard URIs                     +| 3 | nonce validation per app        Each app generates and validates its own nonce                 
-| 4 | client_secret confidentiality    Never expose to browser; store in secrets manager        +| 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           session cookies strictly scoped to their app domain            
-| 6 | Short access_token TTL           15 minutes max; refresh transparently                    +| 6 | Separate session stores         No shared session data between WebA and WebB                   
-| 7 | Refresh token rotation           New token per use; old token invalidated                 +| 7 | Backchannel logout implemented  Global logout must propagate across all apps                   
-| 8 | Server-side session store        | Use Redis or DB; never JWT-only sessions                 +| 8 | sso_session_id in app sessions  | Required to correlate for backchannel logout                   
-| 9 | JWKS caching                     1 hour TTLrefetch on unknown kid only                  +| 9 | JWKS shared but keys validated  Both apps use same JWKSaud claim separates token scope       
-| 10| Audience validation              Each server checks aud claim strictly                    | +| 10| Refresh token per app           WebA's refresh_token cannot be used by WebB                    | 
-| 11| Backchannel logout               | Implement to support global SSO logout                   | +| 11| HTTPS on all domains            | TLS 1.2+ on sso, web-a, and web-b                             | 
-| 12| HTTPS everywhere                 | TLS 1.2+ on all endpoints                               |+| 12| SameSite=None on SSO cookie     | Required for cross-origin redirect flows                       |
  
 ==== Attack Mitigations ==== ==== Attack Mitigations ====
  
-^ Attack                       ^ Mitigation                                                   +^ Attack                            ^ Mitigation                                                      
-CSRF on /callback            state parameter validated server-side                        +JWT-A used against WebB's API     aud claim mismatch → rejected by WebB's resource server        
-Token theft via XSS          Tokens never reach browser; session cookie is HttpOnly       | +id_token from WebA replayed at WebB aud=web-a-001 → WebB rejects (expects web-b-001            
-| Session fixation             | New session_id after successful authentication               | +Stolen session_a used at WebB     Domain-scoped cookieWebB only reads session_b               
-| Code interception            | client_secret required for /token; code single-use (60s   +CSRF on WebA callback             state validated; attacker cannot predict state-A              
-Refresh token theft          Server-side storagerotation on use; binding to client      +Logout only from one app          Backchannel logout propagates globally on SSO logout           
-Man-in-the-middle            | Strict TLS everywhere; HSTS headers on Web App               | +Partial logout (SSO active)       Implement max_age or auth_time checks if stricter needed       
-| id_token replay              | nonce claim validated; short id_token TTL                    +| SSO session hijack                | SameSite=None mitigated by HttpOnly + HTTPS + short TTL        |
-Forged JWT                   RS256 signature verified against JWKS public key            +
-Backchannel logout skipped   logout_token signature verified before processing           +
-Session after logout         | Backchannel logout deletes all sessions for SSO session ID   |+
  
 ---- ----
Line 1412: Line 1618:
 ===== Configuration Reference ===== ===== Configuration Reference =====
  
-==== ServerSSO Client Registration ====+==== ServerSSO Client Registrations ====
  
 <code yaml> <code yaml>
 clients: clients:
-  - client_id:     "web-app-001" + 
-    client_secret: "${SSO_CLIENT_SECRET}"    ← from secrets manager+  - client_id:     "web-a-001" 
 +    client_secret: "${WEBA_CLIENT_SECRET}"
     client_type:   confidential     client_type:   confidential
-    +    display_name:  "Web Application A"
     redirect_uris:     redirect_uris:
-      - "https://web.example.com/auth/callback" +      - "https://web-a.example.com/auth/callback"
-    +
     post_logout_redirect_uris:     post_logout_redirect_uris:
-      - "https://web.example.com/logged-out" +      - "https://web-a.example.com/logged-out" 
-     +    backchannel_logout_uri:              "https://web-a.example.com/auth/backchannel-logout"
-    backchannel_logout_uri: "https://web.example.com/auth/backchannel-logout"+
     backchannel_logout_session_required: true     backchannel_logout_session_required: true
-     +    allowed_scopes: [openidprofileemailapi:resourceA]
-    allowed_scopes: +
-      - openid +
-      - profile +
-      - email +
-      - api:serverA +
-      - api:serverB +
-    +
     token_endpoint_auth_method: client_secret_basic     token_endpoint_auth_method: client_secret_basic
-     +    access_token_ttl:  900 
-    access_token_ttl:  900     # 15 minutes +    refresh_token_ttl: 86400 
-    refresh_token_ttl: 86400   # 24 hours + 
-    id_token_ttl:      300     # 5 minutes+  - client_id:     "web-b-001" 
 +    client_secret: "${WEBB_CLIENT_SECRET}" 
 +    client_type:   confidential 
 +    display_name "Web Application B" 
 +    redirect_uris: 
 +      - "https://web-b.example.com/auth/callback" 
 +    post_logout_redirect_uris: 
 +      - "https://web-b.example.com/logged-out" 
 +    backchannel_logout_uri:              "https://web-b.example.com/auth/backchannel-logout" 
 +    backchannel_logout_session_required: true 
 +    allowed_scopes: [openid, profile, email, api:resourceB] 
 +    token_endpoint_auth_method: client_secret_basic 
 +    access_token_ttl:  900 
 +    refresh_token_ttl: 86400
 </code> </code>
  
-==== Web Application Configuration ====+==== WebA Configuration ====
  
 <code yaml> <code yaml>
 oidc: oidc:
   authority:     "https://sso.example.com"   authority:     "https://sso.example.com"
-  client_id:     "web-app-001" +  client_id:     "web-a-001" 
-  client_secret: "${SSO_CLIENT_SECRET}" +  client_secret: "${WEBA_CLIENT_SECRET}" 
-  redirect_uri:  "https://web.example.com/auth/callback" +  redirect_uri:  "https://web-a.example.com/auth/callback" 
-  scopes: +  scopes:        [openidprofileemailapi:resourceA]
-    - openid +
-    - profile +
-    - email +
-    - api:serverA +
-    - api:serverB +
-  +
   token_endpoint_auth_method: client_secret_basic   token_endpoint_auth_method: client_secret_basic
  
 session: session:
-  store:          redis +  store:           redis 
-  redis_url:      "redis://session-redis:6379/0" +  redis_url:       "redis://redis-weba:6379/0
-  ttl_seconds:    28800          # 8 hours +  key_prefix:      "weba:
-  cookie_name:    session_id +  ttl_seconds:     28800 
-  cookie_secure:  true+  cookie_name:     session_a 
 +  cookie_domain  web-a.example.com 
 +  cookie_secure:   true
   cookie_httponly: true   cookie_httponly: true
   cookie_samesite: Lax   cookie_samesite: Lax
 +</code>
  
-apis+==== WebB Configuration ==== 
-  server_a: "https://api-a.example.com" + 
-  server_b: "https://api-b.example.com"+<code yaml> 
 +oidc
 +  authority    "https://sso.example.com" 
 +  client_id    "web-b-001" 
 +  client_secret: "${WEBB_CLIENT_SECRET}" 
 +  redirect_uri:  "https://web-b.example.com/auth/callback" 
 +  scopes:        [openid, profile, email, api:resourceB] 
 +  token_endpoint_auth_method: client_secret_basic 
 + 
 +session: 
 +  store:           redis 
 +  redis_url:       "redis://redis-webb:6379/0" 
 +  key_prefix:      "webb:" 
 +  ttl_seconds:     28800 
 +  cookie_name:     session_b 
 +  cookie_domain:   web-b.example.com 
 +  cookie_secure:   true 
 +  cookie_httponly: true 
 +  cookie_samesite: Lax
 </code> </code>
  
-==== ServerA & ServerB JWT Validation Configuration ====+==== JWT Validation Configuration (Shared Pattern) ====
  
 <code yaml> <code yaml>
-ServerA +WebA validates JWT-A issued to resource-a 
-jwt+jwt_validation_weba
-  issuer:        "https://sso.example.com" +  issuer:         "https://sso.example.com" 
-  audience:      "https://api-a.example.com" +  audience:       "https://resource-a.example.com" 
-  algorithms:    [RS256] +  algorithms:     [RS256] 
-  jwks_uri:      "https://sso.example.com/.well-known/jwks.json" +  jwks_uri:       "https://sso.example.com/.well-known/jwks.json" 
-  jwks_cache_ttl: 3600          # 1 hour +  jwks_cache_ttl: 3600 
-  required_scope: "api:serverA+  required_scope: "api:resourceA
-  clock_skew:    30              # seconds tolerance+  clock_skew:     30
  
-ServerB +WebB validates JWT-B issued to resource-b 
-jwt+jwt_validation_webb
-  issuer:        "https://sso.example.com" +  issuer:         "https://sso.example.com" 
-  audience:      "https://api-b.example.com" +  audience:       "https://resource-b.example.com" 
-  algorithms:    [RS256] +  algorithms:     [RS256] 
-  jwks_uri:      "https://sso.example.com/.well-known/jwks.json"+  jwks_uri:       "https://sso.example.com/.well-known/jwks.json"
   jwks_cache_ttl: 3600   jwks_cache_ttl: 3600
-  required_scope: "api:serverB+  required_scope: "api:resourceB
-  clock_skew:    30+  clock_skew:     30
 </code> </code>
  
Line 1503: Line 1729:
  
 <code> <code>
-╔══════════════════════════════════════════════════════════════════════╗ +═════════════════════════════════════════════════════════════════════════╗ 
-║        OIDC TRADITIONAL WEB — QUICK REFERENCE                       ║ +║          OIDC MULTI-APP SSO — QUICK REFERENCE                          ║ 
-╠══════════════════════════════════════════════════════════════════════╣ +═════════════════════════════════════════════════════════════════════════╣ 
-║ CLIENT TYPE                                                          ║ +║ RSA KEY USAGE — WHO USES WHAT                                           ║ 
-║   Confidential — has client_secret (stored server-side only)        ║ +║                                                                         ║ 
-║   No PKCE required (client_secret authenticates /token call)         ║ +║  🔐 PRIVATE KEY  →  ServerSSO ONLY                                     ║ 
-╠══════════════════════════════════════════════════════════════════════╣ +║     Used to SIGN:  access_token, id_token, logout_token                ║ 
-║ TOKEN STORAGE (server-side only — browser never sees tokens       ║ +║     Stored in:     HSM / KMS / encrypted store on ServerSSO            ║ 
-║   access_token   → server-side session store (Redis/DB)              ║ +║     NEVER shared, NEVER leaves ServerSSO                               ║ 
-║   refresh_token  → server-side session store                         ║ +║                                                                         ║ 
-║   id_token       → server-side session store (validated then stored) ║ +║  🔓 PUBLIC KEY   →  WebA, WebB (fetched from JWKS, cached 1 hr)        ║ 
-║   Browser holds  → HttpOnly session cookie (session_id only        ║ +║     Used to VERIFY: id_token signature (after /token exchange)         ║ 
-╠══════════════════════════════════════════════════════════════════════╣ +║                     logout_token signature (backchannel logout)        ║ 
-║ TOKEN LIFETIMES                                                       ║ +║                     access_token signature (optional, if inspected)    ║ 
-║   access_token   15 min   (server auto-refreshes transparently)      ║ +║     Fetched from: GET /sso.example.com/.well-known/jwks.json           ║ 
-║   id_token        5 min   (validate nonce on receiptthen store   ║ +║     kid "key-2024-01" links JWT header → correct key in JWKS           ║ 
-║   refresh_token  24 hrs   (rotated on each use                     ║ +═════════════════════════════════════════════════════════════════════════╣ 
-║   auth code      60 sec   (single use                              ║ +║ WHEN EACH KEY IS USED (step reference                                 ║ 
-║   web session     8 hrs   (inactivity timeout)                       ║ +║   Scenario 1, Step A11 → 🔐 SSO signs JWT-A + id_token-A              ║ 
-╠══════════════════════════════════════════════════════════════════════╣ +║   Scenario 1, Step A12 → 🔓 WebA verifies id_token-A                  ║ 
-║ CRITICAL VALIDATIONS                                                  ║ +║   Scenario 1, Step B9  → 🔐 SSO signs JWT-B + id_token-B              ║ 
-║   /callback: state == stored state                 (CSRF check     ║ +║   Scenario 1, Step B10 → 🔓 WebB verifies id_token-B                  ║ 
-║   id_token:  nonce == stored nonce                 (replay check)    ║ +║   Scenario 2B,  Step 3 → 🔐 SSO signs new JWT-A (refresh            ║ 
-║   id_token aud  == client_id                     (audience check)  ║ +║   Logout, Step 7       → 🔓 WebB verifies logout_token                ║ 
-║   JWT:       iss  == SSO issuer URL                                  ║ +═════════════════════════════════════════════════════════════════════════╣ 
-║   JWT      aud  includes resource server URL                       ║ +║ THREE-LAYER COOKIE MODEL                                                ║ 
-║   JWT      exp  > now()                                            ║ +║   sso_session  → sso.example.com   (SameSite=NoneHttpOnly, Secure  ║ 
-║   JWT:       scope includes required API scope                        ║ +║   session_a    → web-a.example.com (SameSite=Lax,  HttpOnly, Secure  ║ 
-╠══════════════════════════════════════════════════════════════════════╣ +║   session_b    → web-b.example.com (SameSite=Lax,  HttpOnly, Secure  ║ 
-║ FLOW TYPES                                                            ║ +║   Browser never holds tokens — only session IDs                        ║ 
-║   Login  /authorize → login → code → POST /token (s2s            ║ +═════════════════════════════════════════════════════════════════════════╣ 
-║   RefreshPOST /token (grant=refresh_tokens2s, transparent      ║ +║ SSO EFFECT — HOW IT WORKS                                               ║ 
-║   Logout:  DELETE session → SSO /logout → backchannel notify others  ║ +║   1. WebA redirects → /authorize (no sso_session→ LOGIN PAGE         ║ 
-╠══════════════════════════════════════════════════════════════════════╣ +║   2. User logs in → sso_session cookie set on sso.example.com          ║ 
-║ ENDPOINTS (ServerSSO)                                                 ║ +║   3. WebB redirects → /authorize (WITH sso_session→ NO LOGIN PAGE    ║ 
-║   /authorize             → get authorization code                    ║ +║   4. ServerSSO issues code immediately, redirects to WebB              ║ 
-║   /token                 → exchange code / refresh (s2s only)        ║ +╠═════════════════════════════════════════════════════════════════════════╣ 
-║   /.well-known/jwks.json → RSA public keys (cached by APIs         ║ +║ TOKEN ISOLATION                                                          ║ 
-║   /userinfo              → get user profile (with access_token     ║ +║   JWT-A: aud=resource-a, scope=api:resourceA → usable only at A        ║ 
-║   /logout                → terminate SSO session                     ║ +║   JWT-Baud=resource-b, scope=api:resourceB → usable only at B        ║ 
-╠══════════════════════════════════════════════════════════════════════╣ +║   id_token-A: aud=web-a-001 → WebA accepts; WebB rejects               ║ 
-║ KEY SECURITY PROPERTIES                                               ║ +║   id_token-Baud=web-b-001 → WebB accepts; WebA rejects               ║ 
-║   XSS cannot steal tokens  (stored server-side                     ║ +║   Both signed by same 🔐 private key; both verified by same 🔓 pub key ║ 
-║   CSRF protected by state  (verified on callback                   ║ +═════════════════════════════════════════════════════════════════════════╣ 
-║   Replay protected by nonce (verified in id_token                  ║ +║ CLIENT CREDENTIALS (CONFIDENTIAL)                                        ║ 
-║   Code single-use + 60s TTL + client_secret required to exchange     ║ +║   WebAclient_id=web-a-001, client_secret=SECRET-A (separate        ║ 
-║   JWKS offline validation  (no SSO contact per API request         ║ +║   WebBclient_id=web-b-001client_secret=SECRET-B (separate        ║ 
-╚══════════════════════════════════════════════════════════════════════╝+║   Stored server-side only — never in browser                            ║ 
 +═════════════════════════════════════════════════════════════════════════╣ 
 +║ SESSION INDEPENDENCE                                                     ║ 
 +║   WebA token refresh  → no effect on WebB session                      ║ 
 +║   WebA session expiry → no effect on WebB session                      ║ 
 +║   Each app tracks its own access_token_exp and refresh_token            ║ 
 +╠═════════════════════════════════════════════════════════════════════════╣ 
 +║ GLOBAL LOGOUT CHAIN                                                      ║ 
 +║   User logs out WebA → WebA calls SSO /logout → SSO terminates session ║ 
 +║   → SSO signs logout_token (🔐 private keyfor each registered client ║ 
 +║   → WebA + WebB verify logout_token (🔓 public keythen delete sessions║ 
 +║   → User fully logged out everywhere                                    ║ 
 +═════════════════════════════════════════════════════════════════════════╣ 
 +║ TOKEN LIFETIMES                                                          ║ 
 +║   access_token   15 min  (per app, independently refreshed            ║ 
 +║   id_token        5 min  (validate on receipt; audience-scoped        ║ 
 +║   refresh_token  24 hrs  (per app, rotated on use                     ║ 
 +║   auth code      60 sec  (single use, per app)                          ║ 
 +║   web session     8 hrs  (per app, independent TTL                    ║ 
 +║   sso session     8 hrs  inactivity / 24 hrs absolute                   ║ 
 +═════════════════════════════════════════════════════════════════════════╝
 </code> </code>
  
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