====== OIDC SSO System — SPA + PKCE + JWT + JWKS ====== **Document Version:** 1.0 **Last Updated:** 2026-06-16 **Scope:** Single Sign-On architecture with SPA (PKCE), two resource servers, one SSO/IdP server, JWT token validation via JWKS ---- ===== Table of Contents ===== - [[#architecture-overview|Architecture Overview]] - [[#components|Components]] - [[#oidc-pkce-flow-fundamentals|OIDC + PKCE Flow Fundamentals]] - [[#jwt--jwks-explained|JWT & JWKS Explained]] - [[#scenario-1-unauthenticated-user|Scenario 1: Unauthenticated User]] - [[#scenario-2-authenticated-user|Scenario 2: Authenticated User (Token Reuse)]] - [[#sequence-diagrams|Sequence Diagrams]] - [[#api-contracts|API Contracts]] - [[#security-considerations|Security Considerations]] - [[#configuration-reference|Configuration Reference]] ---- ===== Architecture Overview ===== This document describes a modern, standards-compliant Single Sign-On (SSO) system using: * **OpenID Connect (OIDC)** — identity layer on top of OAuth 2.0 * **PKCE** (Proof Key for Code Exchange) — protects SPAs from authorization code interception * **JWT** (JSON Web Token) — compact, self-contained token format * **JWKS** (JSON Web Key Set) — public key endpoint for token verification ┌─────────────────────────────────────────────────────────────────┐ │ SYSTEM TOPOLOGY │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ SPA │────▶│ ServerSSO │ │ JWKS Endpoint │ │ │ │(Browser) │ │ (OIDC IdP) │────▶│ /.well-known/ │ │ │ └────┬─────┘ └──────────────┘ │ openid-config │ │ │ │ └──────────────────┘ │ │ │ Bearer JWT │ │ ├──────────────────────────────▶ ┌──────────────────┐ │ │ │ │ ServerA │ │ │ │ │ (Resource API) │ │ │ │ └──────────────────┘ │ │ │ │ │ └──────────────────────────────▶ ┌──────────────────┐ │ │ │ ServerB │ │ │ │ (Resource API) │ │ │ └──────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ---- ===== Components ===== ==== SPA (Single Page Application) ==== ^ Property ^ Value ^ | Role | OIDC Relying Party (RP) / Client | | Client Type | Public (no client_secret) | | Auth Method | PKCE (S256) | | Token Storage | Memory (access_token), HttpOnly Cookie or sessionStorage (refresh_token) | | Base URL | ''https://app.example.com'' | | Redirect URI | ''https://app.example.com/callback'' | ==== ServerSSO (Identity Provider / Authorization Server) ==== ^ Property ^ Value ^ | Role | OIDC Provider (OP) / Auth Server | | Standard | OpenID Connect Core 1.0 | | Token Format | JWT (RS256) | | Base URL | ''https://sso.example.com'' | | Discovery URL | ''https://sso.example.com/.well-known/openid-configuration'' | | JWKS URL | ''https://sso.example.com/.well-known/jwks.json'' | ==== ServerA (Resource Server) ==== ^ Property ^ Value ^ | Role | OAuth 2.0 Resource Server | | Validates | JWT Bearer token via JWKS | | Required Scope | ''api:serverA'' | | Base URL | ''https://api-a.example.com'' | ==== ServerB (Resource Server) ==== ^ Property ^ Value ^ | Role | OAuth 2.0 Resource Server | | Validates | JWT Bearer token via JWKS | | Required Scope | ''api:serverB'' | | Base URL | ''https://api-b.example.com'' | ---- ===== OIDC + PKCE Flow Fundamentals ===== ==== What is PKCE? ==== PKCE (RFC 7636) prevents authorization code interception attacks in public clients (SPAs, mobile apps). It works by binding the authorization request to the token exchange using a cryptographic challenge. Step 1 — Generate code_verifier (random 43-128 char string) code_verifier = base64url(random_bytes(32)) Step 2 — Derive code_challenge code_challenge = base64url(SHA256(ASCII(code_verifier))) Step 3 — Send code_challenge in /authorize request (keep code_verifier secret in memory) Step 4 — Send code_verifier in /token exchange ServerSSO verifies: SHA256(code_verifier) == code_challenge ==== Token Types ==== ^ Token ^ Lifetime ^ Purpose ^ Storage ^ | id_token | 5 min | User identity (OpenID Connect) | Memory only | | access_token | 15 min | API authorization (Bearer) | Memory only | | refresh_token | 24 hours | Obtain new access_token | HttpOnly Cookie | ---- ===== JWT & JWKS Explained ===== ==== JWT Structure ==== A JWT has three base64url-encoded parts separated by dots: header.payload.signature Example decoded header: { "alg": "RS256", "typ": "JWT", "kid": "key-2024-01" ← Key ID — used to find the right key in JWKS } Example decoded payload: { "iss": "https://sso.example.com", "sub": "user-abc-123", "aud": ["https://api-a.example.com", "https://api-b.example.com"], "exp": 1718550000, "iat": 1718549100, "nbf": 1718549100, "jti": "unique-token-id-xyz", "email": "alice@example.com", "scope": "openid profile email api:serverA api:serverB", "roles": ["user", "editor"] } ==== JWKS Endpoint ==== Resource servers call the JWKS endpoint to get public keys for JWT verification. They cache the response (typically 1 hour) and only re-fetch when an unknown ''kid'' appears. // GET https://sso.example.com/.well-known/jwks.json { "keys": [ { "kty": "RSA", "use": "sig", "kid": "key-2024-01", "alg": "RS256", "n": "0vx7agoebGcQSuuPiLJX...", ← RSA modulus (public) "e": "AQAB" ← RSA exponent (public) } ] } ==== JWT Validation Steps (Resource Servers) ==== Each resource server independently validates every incoming JWT: 1. Parse JWT header → extract kid 2. Fetch JWKS (from cache or live) → find key matching kid 3. Verify signature using RSA public key 4. Check iss == "https://sso.example.com" 5. Check aud includes this server's audience 6. Check exp > now() 7. Check nbf <= now() 8. Check iat is not in the future 9. Check required scope present (e.g. "api:serverA") 10. Extract sub, roles, email for authorization logic > **Important:** Resource servers NEVER contact the SSO server during request processing. > They validate JWTs offline using cached JWKS public keys. > This makes the system highly scalable and resilient. ---- ===== Scenario 1: Unauthenticated User ===== ==== Overview ==== Alice opens the SPA in her browser for the first time (no session, no tokens). She wants to access a protected resource on ServerA. ==== Step-by-Step Flow ==== === Step 1 — SPA Detects No Session === // SPA startup check function checkSession() { const accessToken = sessionStorage.getItem('access_token'); // or in-memory if (!accessToken || isExpired(accessToken)) { initiateLogin(); } } **What happens:** The SPA checks memory/storage for a valid access_token. None found → triggers login. ---- === Step 2 — PKCE: Generate code_verifier & code_challenge === // Generate cryptographically random verifier function generateCodeVerifier() { const array = new Uint8Array(32); crypto.getRandomValues(array); return base64UrlEncode(array); } // Derive challenge using SHA-256 async function generateCodeChallenge(verifier) { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hash = await crypto.subtle.digest('SHA-256', data); return base64UrlEncode(new Uint8Array(hash)); } const code_verifier = generateCodeVerifier(); // Store in sessionStorage const code_challenge = await generateCodeChallenge(code_verifier); sessionStorage.setItem('pkce_verifier', code_verifier); **What happens:** Two values are created: * ''code_verifier'' — kept secret in sessionStorage * ''code_challenge'' — will be sent to SSO (SHA256 hash of verifier) ---- === Step 3 — SPA Redirects Browser to ServerSSO /authorize === GET https://sso.example.com/authorize ?response_type=code &client_id=spa-client-001 &redirect_uri=https://app.example.com/callback &scope=openid profile email api:serverA api:serverB &state=random-csrf-token-abc123 &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 **Parameters:** ^ Parameter ^ Value ^ Purpose ^ | response_type | ''code'' | Request an authorization code | | client_id | ''spa-client-001'' | Identifies the SPA | | redirect_uri | ''https://app.example.com/callback'' | Where to return after login | | scope | ''openid profile email api:serverA api:serverB'' | Requested permissions | | state | Random string | CSRF protection | | code_challenge | SHA256(code_verifier) | PKCE binding | | code_challenge_method | ''S256'' | Hash algorithm | ---- === Step 4 — ServerSSO Authenticates Alice === ServerSSO presents a login page. Alice enters credentials. Browser ──▶ ServerSSO: GET /authorize (params above) ServerSSO ──▶ Browser: 302 → /login?session=xyz Browser ──▶ ServerSSO: POST /login { username, password } ServerSSO: validates credentials, creates SSO session cookie If this is the first SSO login or MFA is required, the SSO server handles it transparently. ---- === Step 5 — ServerSSO Returns Authorization Code === ServerSSO ──▶ Browser: HTTP 302 Location: https://app.example.com/callback ?code=SplxlOBeZQQYbYS6WxSbIA &state=random-csrf-token-abc123 **What happens:** * ServerSSO stores a binding: ''code'' ↔ ''code_challenge'' ↔ requested scopes * The code is **single-use** and expires in **60 seconds** * Browser follows the redirect to the SPA callback URL ---- === Step 6 — SPA Validates state and Extracts Code === // SPA /callback handler const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); // CSRF check if (state !== sessionStorage.getItem('oauth_state')) { throw new Error('State mismatch — possible CSRF attack'); } // Retrieve PKCE verifier const code_verifier = sessionStorage.getItem('pkce_verifier'); sessionStorage.removeItem('pkce_verifier'); // clean up ---- === Step 7 — SPA Exchanges Code for Tokens === POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=https://app.example.com/callback &client_id=spa-client-001 &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk **ServerSSO verification:** - Checks code is valid and not expired - Verifies ''SHA256(code_verifier) == stored code_challenge'' ← PKCE check - Verifies ''redirect_uri'' matches registered value - Issues tokens ---- === Step 8 — ServerSSO Returns JWT Tokens === HTTP 200 OK Content-Type: application/json { "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...", "token_type": "Bearer", "expires_in": 900, "refresh_token": "8xLOxBtZp8", "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...", "scope": "openid profile email api:serverA api:serverB" } **SPA stores tokens:** // In-memory (most secure for access_token) window.__auth = { accessToken: tokenResponse.access_token, idToken: tokenResponse.id_token, expiresAt: Date.now() + (tokenResponse.expires_in * 1000) }; // HttpOnly cookie for refresh_token (set by SSO server, or BFF pattern) // If SPA-only: sessionStorage with strict CSP ---- === Step 9 — SPA Calls ServerA with Bearer Token === GET https://api-a.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0... ---- === Step 10 — ServerA Validates JWT (JWKS) === // ServerA middleware (Node.js example) async function validateJWT(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); // 1. Decode header to get kid const header = JSON.parse(atob(token.split('.')[0])); // 2. Fetch JWKS (cached 1 hour, or refetch on unknown kid) const jwks = await getJWKS('https://sso.example.com/.well-known/jwks.json'); const key = jwks.keys.find(k => k.kid === header.kid); // 3. Verify signature + claims const payload = await verifyJWT(token, key, { issuer: 'https://sso.example.com', audience: 'https://api-a.example.com', algorithms: ['RS256'] }); // 4. Check required scope if (!payload.scope.includes('api:serverA')) { return res.status(403).json({ error: 'insufficient_scope' }); } req.user = payload; next(); } **ServerSSO is NOT contacted during this step.** ServerA validates the JWT entirely using the cached JWKS public key. ---- === Step 11 — ServerA Returns Protected Resource === HTTP 200 OK { "data": "Protected content from ServerA", "user": "alice@example.com" } ==== Scenario 1 — Complete Flow Summary ==== SPA ServerSSO ServerA ServerB │ │ │ │ │─ checkSession ──▶│ │ │ │ (no token) │ │ │ │ │ │ │ │─ generate PKCE ─▶│ │ │ │ │ │ │ │─ GET /authorize ─▶ │ │ │ │ (login page) │ │ │◀─ 302 /login ───│ │ │ │─ POST /login ───▶│ │ │ │◀─ 302 /callback?code= ──────────────│ │ │ │ │ │ │─ POST /token ───▶│ │ │ │ (+ code_verifier) │ │ │◀─ {access_token, id_token, ...} ────│ │ │ │ │ │ │─ GET /api/data ─────────────────────▶│ │ │ Authorization: Bearer │ │ │ │ validate JWT │ │ │ │ (JWKS, offline) │ │ │◀─ 200 { data } ─────────────────────│ │ │ │ │ │ │─ GET /api/data ─────────────────────────────────────▶│ │ Authorization: Bearer │ │ │ validate JWT (JWKS, offline) │ │◀─ 200 { data } ──────────────────────────────────────│ ---- ===== Scenario 2: Authenticated User ===== ==== Overview ==== Alice returns to the SPA. She already has a valid SSO session (SSO session cookie in browser) and a valid access_token in memory. She accesses both ServerA and ServerB. This scenario has two sub-cases: - **2A** — Access token still valid → used directly - **2B** — Access token expired → refreshed silently using refresh_token ---- ==== Scenario 2A — Access Token Still Valid ==== === Step 1 — SPA Checks Token in Memory === function getValidToken() { const auth = window.__auth; if (auth && auth.expiresAt > Date.now() + 30000) { // 30s buffer return auth.accessToken; // Still valid → use directly } return null; // Expired → go to 2B } **What happens:** Token is still valid. No SSO interaction needed. ---- === Step 2 — SPA Calls ServerA Directly === GET https://api-a.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0... ServerA validates JWT offline via JWKS (same as Scenario 1 Step 10). ---- === Step 3 — SPA Calls ServerB with Same Token === The **same** access_token works for ServerB because: - ''aud'' claim includes ''https://api-b.example.com'' - ''scope'' includes ''api:serverB'' GET https://api-b.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0... ServerB independently validates the JWT using its own JWKS cache. ---- ==== Scenario 2B — Access Token Expired (Silent Refresh) ==== === Step 1 — SPA Detects Expired Access Token === const token = getValidToken(); if (!token) { // Token expired → silent refresh using refresh_token await silentRefresh(); } ---- === Step 2 — SPA Sends Refresh Token to ServerSSO === POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=8xLOxBtZp8 &client_id=spa-client-001 > **No user interaction required.** This is a background HTTP call. ---- === Step 3 — ServerSSO Validates Refresh Token === ServerSSO checks: - refresh_token is valid and not revoked - refresh_token is not expired (24h TTL) - SSO session is still active If the SSO session was terminated (logout, password change), refresh fails with ''invalid_grant''. ---- === Step 4 — ServerSSO Issues New Tokens === HTTP 200 OK { "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(new)", "token_type": "Bearer", "expires_in": 900, "refresh_token": "9yMpaBuA3Rx", ← Rotated refresh_token (new one issued) "scope": "openid profile email api:serverA api:serverB" } **Refresh token rotation:** Every refresh issues a NEW refresh_token and invalidates the old one. This limits the damage if a refresh_token is stolen. ---- === Step 5 — SPA Updates Token in Memory === window.__auth = { accessToken: newTokenResponse.access_token, expiresAt: Date.now() + (newTokenResponse.expires_in * 1000) }; // Update stored refresh_token sessionStorage.setItem('refresh_token', newTokenResponse.refresh_token); ---- === Step 6 — SPA Continues API Calls === With the new access_token, the SPA calls ServerA and ServerB as normal. ---- ==== Scenario 2B — SSO Session Already Exists (New Tab) ==== If Alice opens a new browser tab, the SPA has no token in memory (fresh JS context), but the browser has an active SSO session cookie. === Step 1 — SPA Initiates Silent Auth via Hidden iframe === // Attempt silent authorization (prompt=none) const silentAuthUrl = buildAuthUrl({ prompt: 'none', // ← Do NOT show login UI ...pkceParams }); // Load in hidden iframe const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = silentAuthUrl; document.body.appendChild(iframe); === Step 2 — ServerSSO Checks SSO Session Cookie === GET https://sso.example.com/authorize ?response_type=code &client_id=spa-client-001 &prompt=none ← Do not show login UI &...pkce_params... * If SSO session **valid** → returns code immediately (no login page) * If SSO session **invalid** → returns ''error=login_required'' === Step 3 — SPA Receives Code via postMessage === // SPA callback page (loaded in iframe) sends result to parent window.parent.postMessage({ type: 'silent_auth_result', code: params.get('code'), state: params.get('state') }, 'https://app.example.com'); SPA proceeds with token exchange (same as Scenario 1 Steps 7–8). ---- ==== Scenario 2 — Complete Flow Summary ==== SPA ServerSSO ServerA ServerB │ │ │ │ │ [2A: Token valid]│ │ │ │─ GET /api/data ─────────────────────▶│ │ │◀─ 200 { data } ─────────────────────│ │ │ │ │ │ │─ GET /api/data ─────────────────────────────────────▶│ │◀─ 200 { data } ──────────────────────────────────────│ │ │ │ │ │ [2B: Token expired] │ │ │─ POST /token ───▶│ │ │ │ grant=refresh_token │ │ │◀─ new {access_token, refresh_token}─│ │ │ │ │ │ │─ GET /api/data ─────────────────────▶│ │ │◀─ 200 { data } ─────────────────────│ │ │ │ │ │ │ [2C: New tab, SSO session active] │ │ │─ GET /authorize?prompt=none ────────▶│ │ │ (hidden iframe) │ │ │◀─ 302 /callback?code= ──────────────│ │ │─ POST /token ───▶│ │ │ │◀─ {access_token, ...} ──────────────│ │ │─ GET /api/data ─────────────────────▶│ │ │◀─ 200 { data } ─────────────────────│ │ ---- ===== Sequence Diagrams ===== ==== Full PKCE Authorization Code Flow ==== Sequence: SPA → SSO → Callback → Token → API 1. SPA → (browser) : generate code_verifier, code_challenge 2. SPA → ServerSSO : GET /authorize?code_challenge=... 3. ServerSSO → (browser) : 302 to /login 4. User → ServerSSO : POST /login (credentials) 5. ServerSSO → ServerSSO : create SSO session, bind code→challenge 6. ServerSSO → (browser) : 302 to /callback?code=...&state=... 7. SPA → SPA : validate state, retrieve code_verifier 8. SPA → ServerSSO : POST /token (code + code_verifier) 9. ServerSSO → ServerSSO : verify SHA256(code_verifier)==code_challenge 10. ServerSSO → SPA : { access_token, id_token, refresh_token } 11. SPA → ServerA : GET /api (Authorization: Bearer JWT) 12. ServerA → ServerSSO : GET /jwks.json (cached, if needed) 13. ServerA → ServerA : verify JWT signature + claims 14. ServerA → SPA : 200 { data } 15. SPA → ServerB : GET /api (same JWT) 16. ServerB → ServerB : verify JWT (own JWKS cache) 17. ServerB → SPA : 200 { data } ---- ===== API Contracts ===== ==== ServerSSO Endpoints ==== === GET /.well-known/openid-configuration === { "issuer": "https://sso.example.com", "authorization_endpoint": "https://sso.example.com/authorize", "token_endpoint": "https://sso.example.com/token", "userinfo_endpoint": "https://sso.example.com/userinfo", "jwks_uri": "https://sso.example.com/.well-known/jwks.json", "end_session_endpoint": "https://sso.example.com/logout", "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "scopes_supported": ["openid", "profile", "email", "api:serverA", "api:serverB"], "token_endpoint_auth_methods_supported": ["none"], "code_challenge_methods_supported": ["S256"] } === POST /token — Authorization Code === **Request:** POST /token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code={authorization_code} &redirect_uri={registered_redirect_uri} &client_id={client_id} &code_verifier={pkce_verifier} **Response:** { "access_token": "...", "token_type": "Bearer", "expires_in": 900, "refresh_token": "...", "id_token": "...", "scope": "openid profile email api:serverA api:serverB" } === POST /token — Refresh Token === **Request:** grant_type=refresh_token &refresh_token={token} &client_id={client_id} **Error Responses:** ^ HTTP ^ error ^ Meaning ^ | 400 | invalid_request | Missing required parameter | | 400 | invalid_grant | Code/refresh_token invalid or expired | | 400 | invalid_client | Unknown client_id | | 403 | insufficient_scope | Requested scope not permitted | ==== ServerA & ServerB Protected Endpoints ==== **Request (all protected routes):** Authorization: Bearer {access_token} **Error Responses:** ^ HTTP ^ error ^ Meaning ^ | 401 | invalid_token | JWT missing, malformed, or expired | | 401 | token_expired | JWT ''exp'' in the past | | 403 | insufficient_scope | JWT lacks required scope | | 403 | invalid_audience | JWT ''aud'' doesn't include this server| ---- ===== Security Considerations ===== ==== Token Storage ==== ^ Location ^ access_token ^ refresh_token ^ Risk ^ | Memory (JS var) | ✅ Recommended| ⚠️ Lost on refresh | Low (XSS can't access) | | sessionStorage | ⚠️ Acceptable | ⚠️ Acceptable | XSS readable | | localStorage | ❌ Avoid | ❌ Avoid | XSS readable, persistent | | HttpOnly Cookie | ⚠️ CSRF risk | ✅ Recommended | CSRF mitigated by SameSite | ==== Security Checklist ==== ^ # ^ Control ^ Description ^ | 1 | PKCE S256 | Always use ''S256'', never ''plain'' | | 2 | state validation | Verify ''state'' before code exchange (CSRF protection) | | 3 | Redirect URI exact match | SSO must reject partial/wildcard URIs | | 4 | Short-lived access tokens | Max 15 minutes | | 5 | Refresh token rotation | Issue new refresh_token on every use | | 6 | Audience validation | Each server checks ''aud'' claim | | 7 | JWKS caching with TTL | Cache JWKS for 1 hour; refetch on unknown kid | | 8 | HTTPS everywhere | All endpoints use TLS 1.2+ | | 9 | CSP headers on SPA | Prevent XSS with strict Content-Security-Policy | | 10| Refresh token binding | Optionally bind refresh_token to IP/user-agent | ==== Attack Mitigations ==== ^ Attack ^ Mitigation ^ | Authorization Code Interception | PKCE: SHA256 binding between code and verifier | | CSRF on /authorize | state parameter validated by SPA | | Token theft (XSS) | access_token in memory; refresh_token HttpOnly cookie | | Replay attack | jti claim + short expiry; server tracks used jti | | Key compromise | Rotate RSA keys regularly; JWKS kid-based selection | | Session fixation | SSO issues new session ID after successful login | ---- ===== Configuration Reference ===== ==== ServerSSO Configuration ==== sso: issuer: "https://sso.example.com" access_token_ttl: 900 # 15 minutes refresh_token_ttl: 86400 # 24 hours id_token_ttl: 300 # 5 minutes authorization_code_ttl: 60 # 60 seconds signing: algorithm: RS256 key_rotation_days: 90 clients: - client_id: "spa-client-001" client_type: public # No client_secret redirect_uris: - "https://app.example.com/callback" allowed_scopes: - openid - profile - email - api:serverA - api:serverB pkce_required: true pkce_method: S256 ==== ServerA & ServerB JWT Validation Configuration ==== jwt: issuer: "https://sso.example.com" audience: "https://api-a.example.com" # (or api-b for ServerB) algorithms: [RS256] jwks_uri: "https://sso.example.com/.well-known/jwks.json" jwks_cache_ttl: 3600 # 1 hour cache required_scope: "api:serverA" # (or api:serverB) clock_skew_tolerance: 30 # seconds ==== SPA Environment Configuration ==== const AUTH_CONFIG = { authority: 'https://sso.example.com', clientId: 'spa-client-001', redirectUri: 'https://app.example.com/callback', scopes: ['openid', 'profile', 'email', 'api:serverA', 'api:serverB'], responseType: 'code', pkce: true, apis: { serverA: 'https://api-a.example.com', serverB: 'https://api-b.example.com' } }; ---- ===== Logout ===== ==== SPA-initiated Logout ==== // 1. Clear local tokens window.__auth = null; sessionStorage.clear(); // 2. Redirect to SSO end_session_endpoint const logoutUrl = new URL('https://sso.example.com/logout'); logoutUrl.searchParams.set('id_token_hint', idToken); logoutUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com'); window.location.href = logoutUrl.toString(); **What ServerSSO does on logout:** - Invalidates SSO session cookie - Revokes all refresh_tokens for the session - Notifies registered clients via backchannel logout (optional) - Redirects back to ''post_logout_redirect_uri'' > **Note:** Access tokens remain valid until expiry (they are not revoked). > This is by design — JWTs are stateless. For immediate revocation, use > short TTLs (≤15 min) or implement a token introspection/revocation endpoint. ---- ===== Quick Reference Card ===== ╔═══════════════════════════════════════════════════════════════╗ ║ OIDC PKCE QUICK REFERENCE ║ ╠═══════════════════════════════════════════════════════════════╣ ║ PKCE ║ ║ code_verifier = base64url(random 32 bytes) ║ ║ code_challenge = base64url(SHA256(code_verifier)) ║ ║ method = S256 ║ ╠═══════════════════════════════════════════════════════════════╣ ║ ENDPOINTS ║ ║ /authorize → get authorization code ║ ║ /token → exchange code or refresh token ║ ║ /jwks.json → public keys for JWT verification ║ ║ /userinfo → get user profile (with access_token) ║ ║ /logout → end SSO session ║ ╠═══════════════════════════════════════════════════════════════╣ ║ JWT CLAIMS TO VALIDATE ║ ║ iss = ServerSSO issuer URL ║ ║ aud = this server's URL ║ ║ exp > now() ║ ║ nbf ≤ now() ║ ║ scope includes required API scope ║ ╠═══════════════════════════════════════════════════════════════╣ ║ TOKEN LIFETIMES ║ ║ access_token 15 min (in memory) ║ ║ id_token 5 min (in memory) ║ ║ refresh_token 24 hrs (HttpOnly cookie) ║ ║ auth code 60 sec (single use) ║ ╚═══════════════════════════════════════════════════════════════╝ ---- //Document maintained by: Platform Security Team// //Format: DokuWiki// //Standard: OpenID Connect Core 1.0, RFC 7636 (PKCE), RFC 7519 (JWT), RFC 7517 (JWK)//