User Tools

Site Tools


security:sso-spa

Differences

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

Link to this comparison view

security:sso-spa [2026/06/16 06:05] – created phong2018security:sso-spa [2026/06/16 06:07] (current) phong2018
Line 1: Line 1:
-====== OIDC SSO Architecture ======+====== OIDC SSO System — SPA + PKCE + JWT + JWKS ======
  
-===== Overview =====+**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
  
-This system implements Single Sign-On (SSO) using:+----
  
-  * OpenID Connect (OIDC) +===== Table of Contents =====
-  * OAuth2 Authorization Code Flow +
-  * PKCE (Proof Key for Code Exchange) +
-  * JWT Access Token +
-  * JWKS for signature verification+
  
-===== Components =====+  - [[#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]]
  
-| Component | Responsibility | +----
-| SPA | Browser frontend | +
-| ServerA | Business API A | +
-| ServerB | Business API B | +
-| ServerSSO | Identity Provider (IdP) | +
-| JWKS Endpoint | Public keys for JWT verification |+
  
-===== Architecture =====+===== Architecture Overview =====
  
-<code> +This document describes a modern, standards-compliant Single Sign-On (SSO) system using:
-+-------------+ +
-|    User     | +
-+------+------+  +
-       | +
-       v +
-+-------------+ +
-|     SPA     | +
-+------+------+  +
-       | +
-       | +
-       v +
-+------------------+ +
-|    ServerSSO     | +
-|  OIDC Provider   | +
-+--------+---------+ +
-         | +
-         | +
-         v +
-+------------------+ +
-| JWKS Endpoint    | +
-+------------------++
  
-         ^ +  * **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
-|     ServerA      | +
-+------------------++
  
-+------------------+ +<code> 
-|     ServerB      | +┌─────────────────────────────────────────────────────────────────┐ 
-+------------------++│                        SYSTEM TOPOLOGY                          │ 
 +│                                                                 │ 
 +│   ┌──────────┐     ┌──────────────┐     ┌──────────────────┐   │ 
 +│   │   SPA    │────▶│  ServerSSO   │     │    JWKS Endpoint │   │ 
 +│   │(Browser) │     │  (OIDC IdP)  │────▶│  /.well-known/   │   │ 
 +│   └────┬─────┘     └──────────────┘     │  openid-config   │   │ 
 +│        │                                └──────────────────┘   │ 
 +│        │ Bearer JWT                                             │ 
 +│        ├──────────────────────────────▶ ┌──────────────────┐   │ 
 +│        │                                │    ServerA       │   │ 
 +│        │                                │  (Resource API)  │   │ 
 +│        │                                └──────────────────┘   │ 
 +│        │                                                        │ 
 +│        └──────────────────────────────▶ ┌──────────────────┐   │ 
 +│                                         │    ServerB       │   │ 
 +│                                         │  (Resource API)  │   │ 
 +│                                         └──────────────────┘   │ 
 +└─────────────────────────────────────────────────────────────────┘
 </code> </code>
  
-===== Endpoints =====+----
  
-==== ServerSSO ====+===== Components =====
  
-Authorization Endpoint:+==== SPA (Single Page Application) ====
  
-<code> +^ Property        ^ Value                              ^ 
-GET /oauth2/authorize +| Role            | OIDC Relying Party (RP) Client   | 
-</code>+| 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'' |
  
-Token Endpoint:+==== ServerSSO (Identity Provider / Authorization Server) ====
  
-<code> +^ Property        ^ Value                              ^ 
-POST /oauth2/token +| Role            | OIDC Provider (OP) Auth Server   | 
-</code>+| 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'' |
  
-UserInfo Endpoint:+==== ServerA (Resource Server) ====
  
-<code> +^ Property        ^ Value                          ^ 
-GET /userinfo +| Role            | OAuth 2.0 Resource Server      | 
-</code>+| 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''  |
  
-JWKS Endpoint:+---- 
 + 
 +===== 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.
  
 <code> <code>
-GET /.well-known/jwks.json +Step 1 — Generate code_verifier (random 43-128 char string) 
-</code>+         code_verifier = base64url(random_bytes(32))
  
-OIDC Discovery:+Step 2 — Derive code_challenge 
 +         code_challenge = base64url(SHA256(ASCII(code_verifier)))
  
-<code> +Step 3 — Send code_challenge in /authorize request 
-GET /.well-known/openid-configuration+         (keep code_verifier secret in memory) 
 + 
 +Step 4 — Send code_verifier in /token exchange 
 +         ServerSSO verifies: SHA256(code_verifier) == code_challenge
 </code> </code>
  
-===== JWT Example =====+==== Token Types ====
  
-Header+^ 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 |
  
-<code json>+---- 
 + 
 +===== JWT & JWKS Explained ===== 
 + 
 +==== JWT Structure ==== 
 + 
 +A JWT has three base64url-encoded parts separated by dots: 
 + 
 +<code> 
 +header.payload.signature 
 + 
 +Example decoded header:
 { {
   "alg": "RS256",   "alg": "RS256",
-  "kid": "key-2026"+  "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"]
 } }
 </code> </code>
  
-Payload+==== 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.
  
 <code json> <code json>
 +// GET https://sso.example.com/.well-known/jwks.json
 { {
-  "iss": "https://sso.company.com", +  "keys": 
-  "sub": "123456", +    { 
-  "aud": "spa-client", +      "kty": "RSA", 
-  "exp": 1800000000+      "use": "sig", 
-  "iat": 1799990000+      "kid": "key-2024-01", 
-  "scope": "openid profile email"+      "alg": "RS256"
 +      "n":   "0vx7agoebGcQSuuPiLJX..."   ← RSA modulus (public) 
 +      "e":   "AQAB                         ← RSA exponent (public) 
 +    } 
 +  ]
 } }
 </code> </code>
  
-===== PKCE =====+==== JWT Validation Steps (Resource Servers) ====
  
-==== SPA Generates ==== +Each resource server independently validates every incoming JWT:
- +
-Code Verifier+
  
 <code> <code>
-X7sJ8M9nK2Q...+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
 </code> </code>
  
-Code Challenge+> **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.
  
-<code> +----
-BASE64URL( +
- SHA256(code_verifier) +
-+
-</code>+
  
-Example:+===== Scenario 1Unauthenticated User =====
  
-<code> +==== Overview ====
-code_challenge=AbCdEf123 +
-</code>+
  
-===== Scenario 1: User NOT Authenticated =====+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 User Opens SPA ====+==== Step-by-Step Flow ====
  
-<code> +=== Step 1 — SPA Detects No Session === 
-https://app.company.com+ 
 +<code javascript
 +// SPA startup check 
 +function checkSession() { 
 +  const accessToken = sessionStorage.getItem('access_token'); // or in-memory 
 +  if (!accessToken || isExpired(accessToken)) { 
 +    initiateLogin(); 
 +  } 
 +}
 </code> </code>
  
-SPA checks:+**What happens:** The SPA checks memory/storage for a valid access_token. None found → triggers login.
  
-<code> +----
-access_token exists ? +
-</code>+
  
-Result:+=== Step 2 — PKCEGenerate code_verifier & code_challenge ===
  
-<code> +<code javascript
-No+// 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);
 </code> </code>
  
-==== Step 2 - SPA Creates PKCE =====+**What happens:** Two values are created: 
 +  * ''code_verifier'' — kept secret in sessionStorage 
 +  * ''code_challenge'' — will be sent to SSO (SHA256 hash of verifier)
  
-Generate:+---- 
 + 
 +=== Step 3 — SPA Redirects Browser to ServerSSO /authorize ===
  
 <code> <code>
-code_verifier +GET https://sso.example.com/authorize 
-code_challenge +  ?response_type=code 
-state +  &client_id=spa-client-001 
-nonce+  &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
 </code> </code>
  
-Store:+**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.
  
 <code> <code>
-sessionStorage+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
 </code> </code>
  
-==== Step 3 - Redirect to SSO =====+If this is the first SSO login or MFA is required, the SSO server handles it transparently.
  
-SPA redirects browser:+---- 
 + 
 +=== Step 5 — ServerSSO Returns Authorization Code ===
  
 <code> <code>
-GET /oauth2/authorize +ServerSSO ──▶ Browser: 
- ?response_type=code +  HTTP 302 
- &client_id=spa-client +  Location: https://app.example.com/callback 
- &redirect_uri=https://app.company.com/callback +    ?code=SplxlOBeZQQYbYS6WxSbIA 
- &scope=openid profile email +    &state=random-csrf-token-abc123
- &state=abc123 +
- &nonce=xyz123 +
- &code_challenge=ABC +
- &code_challenge_method=S256+
 </code> </code>
  
-==== Step 4 User Not Logged In =====+**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
  
-ServerSSO checks session:+----
  
-<code> +=== Step 6 — SPA Validates state and Extracts Code === 
-SSO Session Exists?+ 
 +<code javascript
 +// 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
 </code> </code>
  
-Result:+---- 
 + 
 +=== Step 7 — SPA Exchanges Code for Tokens ===
  
 <code> <code>
-No+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
 </code> </code>
  
-Show login page.+**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 5 User Login =====+----
  
-User enters:+=== Step 8 — ServerSSO Returns JWT Tokens ===
  
-<code> +<code json
-username +HTTP 200 OK 
-password+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" 
 +}
 </code> </code>
  
-ServerSSO validates credentials.+**SPA stores tokens:**
  
-==== Step 6 Create SSO Session =====+<code javascript> 
 +// In-memory (most secure for access_token) 
 +window.__auth 
 +  accessToken:  tokenResponse.access_token, 
 +  idToken:      tokenResponse.id_token, 
 +  expiresAt:    Date.now() + (tokenResponse.expires_in * 1000) 
 +};
  
-ServerSSO creates:+// HttpOnly cookie for refresh_token (set by SSO server, or BFF pattern) 
 +// If SPA-onlysessionStorage with strict CSP 
 +</code> 
 + 
 +---- 
 + 
 +=== Step 9 — SPA Calls ServerA with Bearer Token ===
  
 <code> <code>
-SSO Cookie+GET https://api-a.example.com/api/data 
 +Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
 </code> </code>
  
-Example:+----
  
-<code> +=== Step 10 — ServerA Validates JWT (JWKS) === 
-Set-Cookie+ 
-SSO_SESSION=abc123 +<code javascript
-HttpOnly +// ServerA middleware (Node.js example) 
-Secure +async function validateJWT(req, res, next) { 
-SameSite=None+  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(); 
 +}
 </code> </code>
  
-==== Step 7 - Authorization Code =====+**ServerSSO is NOT contacted during this step.** 
 +ServerA validates the JWT entirely using the cached JWKS public key.
  
-ServerSSO generates:+----
  
-<code> +=== Step 11 — ServerA Returns Protected Resource === 
-authorization_code+ 
 +<code json
 +HTTP 200 OK 
 +
 +  "data": "Protected content from ServerA", 
 +  "user": "alice@example.com" 
 +}
 </code> </code>
  
-Redirect:+==== Scenario 1 — Complete Flow Summary ====
  
 <code> <code>
-https://app.company.com/callback +SPA             ServerSSO            ServerA           ServerB 
- ?code=AUTH_CODE + │                  │                   │                 │ 
- &state=abc123+ │─ 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 <JWT>         │                 │ 
 + │                  │  validate JWT     │                 │ 
 + │                  │  (JWKS, offline)  │                 │ 
 + │◀─ 200 { data } ─────────────────────│                 │ 
 + │                  │                   │                 │ 
 + │─ GET /api/data ─────────────────────────────────────▶│ 
 + │  Authorization: Bearer <JWT>                          │ 
 + │                  │  validate JWT (JWKS, offline)      │ 
 + │◀─ 200 { data } ──────────────────────────────────────│
 </code> </code>
  
-==== Step 8 SPA Validates State =====+----
  
-SPA verifies:+===== Scenario 2Authenticated User =====
  
-<code> +==== Overview ====
-returned state == stored state +
-</code>+
  
-==== Step 9 - Exchange Code =====+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.
  
-SPA calls:+This scenario has two sub-cases: 
 +  - **2A** — Access token still valid → used directly 
 +  - **2B** — Access token expired → refreshed silently using refresh_token
  
-<code> +----
-POST /oauth2/token +
-</code>+
  
-Request:+==== Scenario 2A — Access Token Still Valid ====
  
-<code> +=== Step 1 — SPA Checks Token in Memory === 
-grant_type=authorization_code + 
-code=AUTH_CODE +<code javascript
-client_id=spa-client +function getValidToken() { 
-code_verifier=X7sJ8M9nK2Q..+  const auth window.__auth; 
-redirect_uri=https://app.company.com/callback+  if (auth && auth.expiresAt > Date.now() + 30000) { // 30s buffer 
 +    return auth.accessToken; // Still valid → use directly 
 +  } 
 +  return null; // Expired → go to 2B 
 +}
 </code> </code>
  
-==== Step 10 - SSO Validates PKCE =====+**What happens:** Token is still valid. No SSO interaction needed.
  
-ServerSSO:+---- 
 + 
 +=== Step 2 — SPA Calls ServerA Directly ===
  
 <code> <code>
-SHA256(code_verifier) +GET https://api-a.example.com/api/data 
-== +Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
-stored code_challenge+
 </code> </code>
  
-Valid:+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''
  
 <code> <code>
-Issue Tokens+GET https://api-b.example.com/api/data 
 +Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
 </code> </code>
  
-==== Step 11 - Token Response =====+ServerB independently validates the JWT using its own JWKS cache.
  
-<code json+---- 
-{ + 
-  "access_token":"JWT", +==== Scenario 2B — Access Token Expired (Silent Refresh) ==== 
-  "id_token":"JWT", + 
-  "expires_in":3600, +=== Step 1 — SPA Detects Expired Access Token === 
-  "token_type":"Bearer"+ 
 +<code javascript
 +const token = getValidToken(); 
 +if (!token) { 
 +  // Token expired → silent refresh using refresh_token 
 +  await silentRefresh();
 } }
 </code> </code>
  
-==== Step 12 SPA Calls ServerA =====+----
  
-Request:+=== Step 2 — SPA Sends Refresh Token to ServerSSO ===
  
 <code> <code>
-GET /api/orders+POST https://sso.example.com/token 
 +Content-Type: application/x-www-form-urlencoded
  
-Authorization: +grant_type=refresh_token 
-Bearer JWT+&refresh_token=8xLOxBtZp8 
 +&client_id=spa-client-001
 </code> </code>
  
-==== Step 13 - ServerA Validates JWT =====+> **No user interaction required.** This is a background HTTP call.
  
-ServerA reads:+----
  
-<code> +=== Step 3 — ServerSSO Validates Refresh Token ===
-kid +
-</code>+
  
-from JWT header.+ServerSSO checks: 
 +  - refresh_token is valid and not revoked 
 +  - refresh_token is not expired (24h TTL) 
 +  - SSO session is still active
  
-==== Step 14 - ServerA Gets JWKS =====+If the SSO session was terminated (logout, password change), refresh fails with ''invalid_grant''.
  
-If key not cached:+----
  
-<code> +=== Step 4 — ServerSSO Issues New Tokens ===
-GET /.well-known/jwks.json +
-</code> +
- +
-Response:+
  
 <code json> <code json>
 +HTTP 200 OK
 { {
-  "keys":[ +  "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(new)", 
-    { +  "token_type":    "Bearer", 
-      "kid":"key-2026", +  "expires_in":    900
-      "kty":"RSA"+  "refresh_token": "9yMpaBuA3Rx",   ← Rotated refresh_token (new one issued) 
-      "alg":"RS256" +  "scope":         "openid profile email api:serverA api:serverB"
-    } +
-  ]+
 } }
 </code> </code>
  
-==== Step 15 - ServerA Verifies =====+**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.
  
-Verify:+----
  
-  * Signature +=== Step 5 — SPA Updates Token in Memory ===
-  * exp +
-  * iss +
-  * aud+
  
-If valid: +<code javascript
- +window.__auth = { 
-<code> +  accessToken: newTokenResponse.access_token, 
-200 OK+  expiresAt:   Date.now() + (newTokenResponse.expires_in * 1000) 
 +}; 
 +// Update stored refresh_token 
 +sessionStorage.setItem('refresh_token', newTokenResponse.refresh_token);
 </code> </code>
  
-==== Step 16 SPA Calls ServerB =====+----
  
-Same JWT:+=== Step 6 — SPA Continues API Calls ===
  
-<code> +With the new access_token, the SPA calls ServerA and ServerB as normal.
-Authorization: Bearer JWT +
-</code>+
  
-==== Step 17 ServerB Verifies JWT =====+----
  
-Using same JWKS.+==== Scenario 2B — SSO Session Already Exists (New Tab) ====
  
-User accesses both systems without another login.+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.
  
-===== Scenario 2: User Already Authenticated =====+=== Step 1 — SPA Initiates Silent Auth via Hidden iframe ===
  
-==== Initial Condition =====+<code javascript> 
 +// Attempt silent authorization (prompt=none) 
 +const silentAuthUrl buildAuthUrl({ 
 +  prompt: 'none',   // ← Do NOT show login UI 
 +  ...pkceParams 
 +});
  
-Browser already contains:+// Load in hidden iframe 
 +const iframe = document.createElement('iframe'); 
 +iframe.style.display = 'none'; 
 +iframe.src = silentAuthUrl; 
 +document.body.appendChild(iframe); 
 +</code> 
 + 
 +=== Step 2 — ServerSSO Checks SSO Session Cookie ===
  
 <code> <code>
-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...
 </code> </code>
  
-from previous login.+  * If SSO session **valid** → returns code immediately (no login page) 
 +  * If SSO session **invalid** → returns ''error=login_required''
  
-==== Step 1 - User Opens SPA =====+=== Step 3 — SPA Receives Code via postMessage ===
  
-SPA has no access token.+<code javascript> 
 +// 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'); 
 +</code>
  
-==== Step 2 - Redirect to SSO =====+SPA proceeds with token exchange (same as Scenario 1 Steps 7–8).
  
-SPA redirects to:+---- 
 + 
 +==== Scenario 2 — Complete Flow Summary ====
  
 <code> <code>
-/oauth2/authorize+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 } ─────────────────────│                 │
 </code> </code>
  
-==== Step 3 SSO Checks Session =====+----
  
-ServerSSO finds:+===== Sequence Diagrams ===== 
 + 
 +==== Full PKCE Authorization Code Flow ====
  
 <code> <code>
-SSO_SESSION exists+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 }
 </code> </code>
  
-User already authenticated.+----
  
-==== Step 4 - Skip Login Screen =====+===== API Contracts =====
  
-No username/password required.+==== ServerSSO Endpoints ====
  
-==== Step 5 Generate Authorization Code =====+=== GET /.well-known/openid-configuration ===
  
-ServerSSO immediately creates+<code json> 
- +
-<code> +  "issuer""https://sso.example.com", 
-AUTH_CODE+  "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"] 
 +}
 </code> </code>
  
-==== Step 6 - Redirect Back =====+=== POST /token — Authorization Code ===
  
 +**Request:**
 <code> <code>
-https://app.company.com/callback +POST /token 
- ?code=AUTH_CODE +Content-Type: application/x-www-form-urlencoded
- &state=abc123 +
-</code>+
  
-==== Step 7 - Exchange Code ===== +grant_type=authorization_code 
- +&code={authorization_code} 
-SPA calls: +&redirect_uri={registered_redirect_uri} 
- +&client_id={client_id} 
-<code> +&code_verifier={pkce_verifier}
-POST /oauth2/token+
 </code> </code>
  
-with PKCE. +**Response:**
- +
-==== Step 8 - Receive JWT ===== +
 <code json> <code json>
 { {
-  "access_token":"JWT", +  "access_token": "...", 
-  "id_token":"JWT"+  "token_type": "Bearer", 
 +  "expires_in": 900, 
 +  "refresh_token": "...", 
 +  "id_token": "...", 
 +  "scope": "openid profile email api:serverA api:serverB"
 } }
 </code> </code>
  
-==== Step 9 - Access ServerA =====+=== POST /token — Refresh Token ===
  
 +**Request:**
 <code> <code>
-Authorization: Bearer JWT+grant_type=refresh_token 
 +&refresh_token={token} 
 +&client_id={client_id}
 </code> </code>
  
-ServerA validates JWT via JWKS.+**Error Responses:**
  
-==== Step 10 - Access ServerB =====+^ 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         |
  
-Same token:+==== ServerA & ServerB Protected Endpoints ====
  
 +**Request (all protected routes):**
 <code> <code>
-Authorization: Bearer JWT+Authorization: Bearer {access_token}
 </code> </code>
  
-ServerB validates JWT via JWKS.+**Error Responses:**
  
-Result:+^ 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|
  
-<code> +---- 
-No login page shown+ 
-True Single Sign-On.+===== 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 ==== 
 + 
 +<code yaml
 +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
 </code> </code>
  
-===== Sequence Diagram - Not Authenticated =====+==== ServerA & ServerB JWT Validation Configuration ====
  
-<code> +<code yaml
-User +jwt: 
- | +  issuer: "https://sso.example.com" 
- | Open SPA +  audience: "https://api-a.example.com"   # (or api-b for ServerB) 
- v +  algorithms: [RS256] 
-SPA +  jwks_uri: "https://sso.example.com/.well-known/jwks.json" 
- | +  jwks_cache_ttl: 3600                     # 1 hour cache 
- | Redirect +  required_scope: "api:serverA"            # (or api:serverB) 
- +  clock_skew_tolerance: 30                 # seconds
-ServerSSO +
- | +
- | Login Page +
- v +
-User +
- | +
- | Credentials +
- v +
-ServerSSO +
- | +
- | Authorization Code +
- v +
-SPA +
- | +
- | Token Request + PKCE +
- v +
-ServerSSO +
- | +
- | JWT +
- v +
-SPA +
- | +
- | Bearer Token +
- +-----> ServerA +
- | +
- +-----> ServerB+
 </code> </code>
  
-===== Sequence Diagram - Already Authenticated =====+==== SPA Environment Configuration ====
  
-<code> +<code javascript
-User +const AUTH_CONFIG = { 
- | +  authority:    'https://sso.example.com', 
- | Open SPA +  clientId:     'spa-client-001', 
- v +  redirectUri:  'https://app.example.com/callback', 
-SPA +  scopes:       ['openid', 'profile', 'email', 'api:serverA', 'api:serverB'], 
- | +  responseType: 'code', 
- | Redirect +  pkce:         true, 
- v +   
-ServerSSO +  apis: { 
- | +    serverA: 'https://api-a.example.com', 
- | Existing SSO Session +    serverB: 'https://api-b.example.com' 
- | +  } 
- | Authorization Code +};
- v +
-SPA +
- | +
- | Exchange Code +
- v +
-ServerSSO +
- | +
- | JWT +
- v +
-SPA +
- | +
- +-----> ServerA +
- | +
- +-----> ServerB+
 </code> </code>
  
-===== Security Best Practices =====+----
  
-  * Always use Authorization Code Flow + PKCE +===== Logout =====
-  * Never use Implicit Flow +
-  * Use RS256 or ES256 +
-  * Validate issuer (iss) +
-  * Validate audience (aud) +
-  * Validate expiration (exp) +
-  * Validate nonce +
-  * Validate state +
-  * Cache JWKS keys +
-  * Use HTTPS everywhere +
-  * Store tokens in memory when possible +
-  * Use short-lived access tokens +
-  * Rotate signing keys regularly+
  
-===== Summary =====+==== SPA-initiated Logout ====
  
-Browser Authentication: +<code javascript> 
-  * SSO Session Cookie+// 1. Clear local tokens 
 +window.__auth = null; 
 +sessionStorage.clear();
  
-API Authorization+// 2. Redirect to SSO end_session_endpoint 
-  * JWT Access Token+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(); 
 +</code>
  
-Token Validation+**What ServerSSO does on logout:** 
-  * JWKS+  - 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''
  
-Login Flow: +> **Note:** Access tokens remain valid until expiry (they are not revoked). 
-  Authorization Code + PKCE+> This is by design — JWTs are stateless. For immediate revocation, use 
 +> short TTLs (≤15 min) or implement a token introspection/revocation endpoint.
  
-Single Sign-On: +----
-  * Managed by ServerSSO Session Cookie+
  
-Resource Servers: +===== Quick Reference Card =====
-  * ServerA +
-  * ServerB+
  
-Identity Provider: +<code> 
-  ServerSSO+╔═══════════════════════════════════════════════════════════════╗ 
 +║              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)                         ║ 
 +╚═══════════════════════════════════════════════════════════════╝
 </code> </code>
 +
 +----
 +
 +//Document maintained by: Platform Security Team//
 +//Format: DokuWiki//
 +//Standard: OpenID Connect Core 1.0, RFC 7636 (PKCE), RFC 7519 (JWT), RFC 7517 (JWK)//
security/sso-spa.1781589950.txt.gz · Last modified: by phong2018