security:sso-spa
Differences
This shows you the differences between two versions of the page.
| security:sso-spa [2026/06/16 06:05] – created phong2018 | security: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 | + | - [[# |
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| - | | 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 |
| - | < | + | This document describes a modern, standards-compliant Single Sign-On (SSO) system using: |
| - | +-------------+ | + | |
| - | | User | | + | |
| - | +------+------+ | + | |
| - | | | + | |
| - | v | + | |
| - | +-------------+ | + | |
| - | | | + | |
| - | +------+------+ | + | |
| - | | | + | |
| - | | | + | |
| - | 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 |
| - | | | + | |
| - | +------------------+ | + | |
| - | +------------------+ | + | < |
| - | | ServerB | + | ┌─────────────────────────────────────────────────────────────────┐ |
| - | +------------------+ | + | │ SYSTEM TOPOLOGY |
| + | │ │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ │ └──────────────────┘ | ||
| + | │ │ Bearer JWT │ | ||
| + | │ ├──────────────────────────────▶ ┌──────────────────┐ | ||
| + | │ │ │ ServerA | ||
| + | │ │ │ (Resource API) │ │ | ||
| + | │ │ └──────────────────┘ | ||
| + | │ │ │ | ||
| + | │ └──────────────────────────────▶ ┌──────────────────┐ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | └─────────────────────────────────────────────────────────────────┘ | ||
| </ | </ | ||
| - | ===== Endpoints ===== | + | ---- |
| - | ==== ServerSSO | + | ===== Components ===== |
| - | Authorization Endpoint: | + | ==== SPA (Single Page Application) ==== |
| - | < | + | ^ Property |
| - | GET /oauth2/authorize | + | | Role | OIDC Relying Party (RP) / Client |
| - | </code> | + | | Client Type | Public (no client_secret) |
| + | | Auth Method | ||
| + | | Token Storage | ||
| + | | Base URL | '' | ||
| + | | Redirect URI | '' | ||
| - | Token Endpoint: | + | ==== ServerSSO (Identity Provider / Authorization Server) ==== |
| - | < | + | ^ Property |
| - | POST /oauth2/token | + | | Role | OIDC Provider (OP) / Auth Server |
| - | </code> | + | | Standard |
| + | | Token Format | ||
| + | | Base URL | '' | ||
| + | | Discovery URL | '' | ||
| + | | JWKS URL | '' | ||
| - | UserInfo Endpoint: | + | ==== ServerA (Resource Server) ==== |
| - | < | + | ^ Property |
| - | GET /userinfo | + | | Role | OAuth 2.0 Resource Server |
| - | </code> | + | | Validates |
| + | | Required Scope | '' | ||
| + | | Base URL | '' | ||
| + | |||
| + | ==== ServerB (Resource Server) ==== | ||
| + | |||
| + | ^ Property | ||
| + | | Role | OAuth 2.0 Resource Server | ||
| + | | Validates | ||
| + | | Required Scope | '' | ||
| + | | Base URL | '' | ||
| - | 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. | ||
| < | < | ||
| - | GET /.well-known/ | + | Step 1 — Generate code_verifier (random 43-128 char string) |
| - | </ | + | code_verifier = base64url(random_bytes(32)) |
| - | OIDC Discovery: | + | Step 2 — Derive code_challenge |
| + | | ||
| - | < | + | Step 3 — Send code_challenge in /authorize request |
| - | GET /.well-known/ | + | (keep code_verifier secret in memory) |
| + | |||
| + | Step 4 — Send code_verifier in /token exchange | ||
| + | | ||
| </ | </ | ||
| - | ===== JWT Example ===== | + | ==== Token Types ==== |
| - | Header | + | ^ Token ^ Lifetime |
| + | | id_token | ||
| + | | access_token | ||
| + | | refresh_token | ||
| - | < | + | ---- |
| + | |||
| + | ===== JWT & JWKS Explained ===== | ||
| + | |||
| + | ==== JWT Structure ==== | ||
| + | |||
| + | A JWT has three base64url-encoded parts separated by dots: | ||
| + | |||
| + | < | ||
| + | header.payload.signature | ||
| + | |||
| + | Example decoded header: | ||
| { | { | ||
| " | " | ||
| - | " | + | |
| + | | ||
| + | } | ||
| + | |||
| + | Example decoded payload: | ||
| + | { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| } | } | ||
| </ | </ | ||
| - | 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 '' | ||
| <code json> | <code json> | ||
| + | // GET https:// | ||
| { | { | ||
| - | "iss": "https:// | + | "keys": |
| - | "sub": "123456", | + | { |
| - | "aud": "spa-client", | + | "kty": |
| - | "exp": | + | "use": "sig", |
| - | "iat": | + | "kid": "key-2024-01", |
| - | "scope": "openid profile email" | + | "alg": |
| + | "n": | ||
| + | "e": | ||
| + | } | ||
| + | ] | ||
| } | } | ||
| </ | </ | ||
| - | ===== PKCE ===== | + | ==== JWT Validation Steps (Resource Servers) |
| - | ==== SPA Generates ==== | + | Each resource server independently validates every incoming JWT: |
| - | + | ||
| - | Code Verifier | + | |
| < | < | ||
| - | X7sJ8M9nK2Q... | + | 1. Parse JWT header → extract kid |
| + | 2. Fetch JWKS (from cache or live) → find key matching kid | ||
| + | 3. | ||
| + | 4. Check iss == " | ||
| + | 5. Check aud includes this server' | ||
| + | 6. Check exp > now() | ||
| + | 7. Check nbf <= now() | ||
| + | 8. Check iat is not in the future | ||
| + | 9. Check required scope present (e.g. " | ||
| + | 10. Extract sub, roles, email for authorization logic | ||
| </ | </ | ||
| - | Code Challenge | + | > **Important: |
| + | > They validate JWTs offline using cached JWKS public keys. | ||
| + | > This makes the system highly scalable and resilient. | ||
| - | < | + | ---- |
| - | BASE64URL( | + | |
| - | | + | |
| - | ) | + | |
| - | </ | + | |
| - | Example: | + | ===== Scenario 1: Unauthenticated User ===== |
| - | < | + | ==== Overview ==== |
| - | code_challenge=AbCdEf123 | + | |
| - | </ | + | |
| - | ===== 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 1 - User Opens SPA ==== | + | ==== Step-by-Step Flow ==== |
| - | < | + | === Step 1 — SPA Detects No Session === |
| - | https://app.company.com | + | |
| + | < | ||
| + | // SPA startup check | ||
| + | function checkSession() { | ||
| + | const accessToken = sessionStorage.getItem(' | ||
| + | if (!accessToken || isExpired(accessToken)) { | ||
| + | initiateLogin(); | ||
| + | } | ||
| + | } | ||
| </ | </ | ||
| - | SPA checks: | + | **What happens:** The SPA checks |
| - | < | + | ---- |
| - | access_token exists ? | + | |
| - | </ | + | |
| - | Result: | + | === Step 2 — PKCE: Generate code_verifier & code_challenge === |
| - | < | + | < |
| - | 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(' | ||
| + | return base64UrlEncode(new Uint8Array(hash)); | ||
| + | } | ||
| + | |||
| + | const code_verifier | ||
| + | const code_challenge = await generateCodeChallenge(code_verifier); | ||
| + | sessionStorage.setItem(' | ||
| </ | </ | ||
| - | ==== Step 2 - SPA Creates PKCE ===== | + | **What happens:** Two values are created: |
| + | * '' | ||
| + | * '' | ||
| - | Generate: | + | ---- |
| + | |||
| + | === Step 3 — SPA Redirects Browser to ServerSSO /authorize === | ||
| < | < | ||
| - | code_verifier | + | GET https:// |
| - | code_challenge | + | ? |
| - | state | + | & |
| - | nonce | + | & |
| + | & | ||
| + | &state=random-csrf-token-abc123 | ||
| + | & | ||
| + | & | ||
| </ | </ | ||
| - | Store: | + | **Parameters:** |
| + | |||
| + | ^ Parameter | ||
| + | | response_type | ||
| + | | client_id | ||
| + | | redirect_uri | ||
| + | | scope | '' | ||
| + | | state | Random string | ||
| + | | code_challenge | ||
| + | | code_challenge_method | ||
| + | |||
| + | ---- | ||
| + | |||
| + | === Step 4 — ServerSSO Authenticates Alice === | ||
| + | |||
| + | ServerSSO presents a login page. Alice enters credentials. | ||
| < | < | ||
| - | sessionStorage | + | Browser ──▶ ServerSSO: GET /authorize (params above) |
| + | ServerSSO ──▶ Browser: 302 → / | ||
| + | Browser ──▶ ServerSSO: POST /login { username, password } | ||
| + | ServerSSO: validates credentials, | ||
| </ | </ | ||
| - | ==== 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 === | ||
| < | < | ||
| - | GET / | + | ServerSSO ──▶ Browser: |
| - | ? | + | HTTP 302 |
| - | & | + | |
| - | & | + | ?code=SplxlOBeZQQYbYS6WxSbIA |
| - | &scope=openid profile email | + | &state=random-csrf-token-abc123 |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| </ | </ | ||
| - | ==== Step 4 - User Not Logged In ===== | + | **What happens: |
| + | * ServerSSO stores a binding: '' | ||
| + | * The code is **single-use** and expires in **60 seconds** | ||
| + | * Browser follows the redirect to the SPA callback URL | ||
| - | ServerSSO checks session: | + | ---- |
| - | < | + | === Step 6 — SPA Validates state and Extracts Code === |
| - | SSO Session Exists? | + | |
| + | < | ||
| + | // SPA /callback handler | ||
| + | const params = new URLSearchParams(window.location.search); | ||
| + | const code = params.get(' | ||
| + | const state = params.get(' | ||
| + | |||
| + | // CSRF check | ||
| + | if (state !== sessionStorage.getItem(' | ||
| + | throw new Error(' | ||
| + | } | ||
| + | |||
| + | // Retrieve PKCE verifier | ||
| + | const code_verifier = sessionStorage.getItem(' | ||
| + | sessionStorage.removeItem(' | ||
| </ | </ | ||
| - | Result: | + | ---- |
| + | |||
| + | === Step 7 — SPA Exchanges Code for Tokens === | ||
| < | < | ||
| - | No | + | POST https:// |
| + | Content-Type: | ||
| + | |||
| + | grant_type=authorization_code | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| </ | </ | ||
| - | Show login page. | + | **ServerSSO verification: |
| + | - Checks code is valid and not expired | ||
| + | - Verifies '' | ||
| + | - Verifies '' | ||
| + | - Issues tokens | ||
| - | ==== Step 5 - User Login ===== | + | ---- |
| - | User enters: | + | === Step 8 — ServerSSO Returns JWT Tokens === |
| - | < | + | < |
| - | username | + | HTTP 200 OK |
| - | password | + | Content-Type: |
| + | |||
| + | { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| </ | </ | ||
| - | ServerSSO validates credentials. | + | **SPA stores tokens:** |
| - | ==== Step 6 - Create SSO Session ===== | + | <code javascript> |
| + | // In-memory (most secure for access_token) | ||
| + | window.__auth | ||
| + | accessToken: | ||
| + | idToken: | ||
| + | expiresAt: | ||
| + | }; | ||
| - | ServerSSO creates: | + | // 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 === | ||
| < | < | ||
| - | SSO Cookie | + | GET https:// |
| + | Authorization: | ||
| </ | </ | ||
| - | Example: | + | ---- |
| - | < | + | === Step 10 — ServerA Validates JWT (JWKS) === |
| - | Set-Cookie: | + | |
| - | SSO_SESSION=abc123 | + | < |
| - | HttpOnly | + | // ServerA middleware (Node.js example) |
| - | Secure | + | async function validateJWT(req, |
| - | SameSite=None | + | const token = req.headers.authorization? |
| + | |||
| + | // 1. Decode header to get kid | ||
| + | const header = JSON.parse(atob(token.split(' | ||
| + | |||
| + | // 2. Fetch JWKS (cached 1 hour, or refetch on unknown kid) | ||
| + | const jwks = await getJWKS(' | ||
| + | const key | ||
| + | |||
| + | // 3. Verify signature + claims | ||
| + | const payload | ||
| + | issuer: | ||
| + | audience: ' | ||
| + | algorithms: [' | ||
| + | }); | ||
| + | |||
| + | // 4. Check required scope | ||
| + | if (!payload.scope.includes(' | ||
| + | return res.status(403).json({ error: ' | ||
| + | } | ||
| + | |||
| + | req.user = payload; | ||
| + | next(); | ||
| + | } | ||
| </ | </ | ||
| - | ==== Step 7 - Authorization Code ===== | + | **ServerSSO is NOT contacted during this step.** |
| + | ServerA validates the JWT entirely using the cached JWKS public key. | ||
| - | ServerSSO generates: | + | ---- |
| - | < | + | === Step 11 — ServerA Returns Protected Resource === |
| - | authorization_code | + | |
| + | < | ||
| + | HTTP 200 OK | ||
| + | { | ||
| + | " | ||
| + | " | ||
| + | } | ||
| </ | </ | ||
| - | Redirect: | + | ==== Scenario 1 — Complete Flow Summary ==== |
| < | < | ||
| - | https://app.company.com/callback | + | SPA |
| - | ? | + | |
| - | & | + | |
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | │◀─ 302 /callback? | ||
| + | │ │ | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| </ | </ | ||
| - | ==== Step 8 - SPA Validates State ===== | + | ---- |
| - | SPA verifies: | + | ===== Scenario 2: Authenticated User ===== |
| - | < | + | ==== Overview ==== |
| - | returned state == stored state | + | |
| - | </ | + | |
| - | ==== 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 | ||
| - | < | + | ---- |
| - | POST / | + | |
| - | </ | + | |
| - | Request: | + | ==== Scenario 2A — Access Token Still Valid ==== |
| - | < | + | === Step 1 — SPA Checks Token in Memory === |
| - | grant_type=authorization_code | + | |
| - | code=AUTH_CODE | + | < |
| - | client_id=spa-client | + | function getValidToken() { |
| - | code_verifier=X7sJ8M9nK2Q... | + | const auth = window.__auth; |
| - | redirect_uri=https: | + | if (auth && auth.expiresAt > Date.now() + 30000) { // 30s buffer |
| + | return auth.accessToken; | ||
| + | } | ||
| + | return null; // Expired → go to 2B | ||
| + | } | ||
| </ | </ | ||
| - | ==== Step 10 - SSO Validates PKCE ===== | + | **What happens:** Token is still valid. No SSO interaction needed. |
| - | ServerSSO: | + | ---- |
| + | |||
| + | === Step 2 — SPA Calls ServerA Directly === | ||
| < | < | ||
| - | SHA256(code_verifier) | + | GET https:// |
| - | == | + | Authorization: |
| - | stored code_challenge | + | |
| </ | </ | ||
| - | 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: | ||
| + | - '' | ||
| + | - '' | ||
| < | < | ||
| - | Issue Tokens | + | GET https:// |
| + | Authorization: | ||
| </ | </ | ||
| - | ==== Step 11 - Token Response ===== | + | 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) { | ||
| + | | ||
| + | | ||
| } | } | ||
| </ | </ | ||
| - | ==== Step 12 - SPA Calls ServerA ===== | + | ---- |
| - | Request: | + | === Step 2 — SPA Sends Refresh Token to ServerSSO === |
| < | < | ||
| - | GET /api/orders | + | POST https://sso.example.com/ |
| + | Content-Type: | ||
| - | Authorization: | + | grant_type=refresh_token |
| - | Bearer JWT | + | & |
| + | & | ||
| </ | </ | ||
| - | ==== Step 13 - ServerA Validates JWT ===== | + | > **No user interaction required.** This is a background HTTP call. |
| - | ServerA reads: | + | ---- |
| - | < | + | === Step 3 — ServerSSO Validates Refresh Token === |
| - | kid | + | |
| - | </ | + | |
| - | 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 '' |
| - | If key not cached: | + | ---- |
| - | < | + | === Step 4 — ServerSSO Issues New Tokens === |
| - | GET / | + | |
| - | </ | + | |
| - | + | ||
| - | Response: | + | |
| <code json> | <code json> | ||
| + | HTTP 200 OK | ||
| { | { | ||
| - | "keys":[ | + | "access_token": |
| - | { | + | "token_type": |
| - | | + | "expires_in": |
| - | "kty":" | + | "refresh_token": "9yMpaBuA3Rx", ← Rotated refresh_token (new one issued) |
| - | "alg":" | + | |
| - | } | + | |
| - | | + | |
| } | } | ||
| </ | </ | ||
| - | ==== 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: | + | < |
| - | + | window.__auth = { | |
| - | < | + | accessToken: |
| - | 200 OK | + | expiresAt: |
| + | }; | ||
| + | // Update stored refresh_token | ||
| + | sessionStorage.setItem(' | ||
| </ | </ | ||
| - | ==== Step 16 - SPA Calls ServerB ===== | + | ---- |
| - | Same JWT: | + | === Step 6 — SPA Continues API Calls === |
| - | < | + | With the new access_token, |
| - | Authorization: | + | |
| - | </ | + | |
| - | ==== 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 | ||
| + | prompt: ' | ||
| + | ...pkceParams | ||
| + | }); | ||
| - | Browser already contains: | + | // Load in hidden iframe |
| + | const iframe = document.createElement(' | ||
| + | iframe.style.display = ' | ||
| + | iframe.src = silentAuthUrl; | ||
| + | document.body.appendChild(iframe); | ||
| + | </ | ||
| + | |||
| + | === Step 2 — ServerSSO Checks SSO Session Cookie === | ||
| < | < | ||
| - | SSO_SESSION cookie | + | GET https:// |
| + | ? | ||
| + | & | ||
| + | & | ||
| + | & | ||
| </ | </ | ||
| - | from previous | + | * If SSO session **valid** → returns code immediately (no login page) |
| + | * If SSO session **invalid** → returns '' | ||
| - | ==== 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: ' | ||
| + | code: params.get(' | ||
| + | state: params.get(' | ||
| + | }, ' | ||
| + | </ | ||
| - | ==== 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 ==== | ||
| < | < | ||
| - | /oauth2/authorize | + | SPA |
| + | | ||
| + | │ [2A: Token valid]│ | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | │ [2B: Token expired] | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | │ [2C: New tab, SSO session active] | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| </ | </ | ||
| - | ==== Step 3 - SSO Checks Session ===== | + | ---- |
| - | ServerSSO finds: | + | ===== Sequence Diagrams ===== |
| + | |||
| + | ==== Full PKCE Authorization Code Flow ==== | ||
| < | < | ||
| - | SSO_SESSION exists | + | Sequence: SPA → SSO → Callback → Token → API |
| + | |||
| + | 1. SPA → (browser) | ||
| + | 2. SPA → ServerSSO | ||
| + | 3. ServerSSO | ||
| + | 4. User → ServerSSO | ||
| + | 5. ServerSSO | ||
| + | 6. ServerSSO | ||
| + | 7. SPA → SPA : validate state, retrieve code_verifier | ||
| + | 8. SPA → ServerSSO | ||
| + | 9. ServerSSO | ||
| + | 10. ServerSSO | ||
| + | 11. SPA → ServerA | ||
| + | 12. ServerA | ||
| + | 13. ServerA | ||
| + | 14. ServerA | ||
| + | 15. SPA → ServerB | ||
| + | 16. ServerB | ||
| + | 17. ServerB | ||
| </ | </ | ||
| - | User already authenticated. | + | ---- |
| - | ==== Step 4 - Skip Login Screen | + | ===== API Contracts |
| - | No username/ | + | ==== ServerSSO Endpoints ==== |
| - | ==== Step 5 - Generate Authorization Code ===== | + | === GET /.well-known/ |
| - | ServerSSO immediately creates: | + | <code json> |
| - | + | { | |
| - | <code> | + | " |
| - | AUTH_CODE | + | " |
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| </ | </ | ||
| - | ==== Step 6 - Redirect Back ===== | + | === POST /token — Authorization Code === |
| + | **Request: | ||
| < | < | ||
| - | https:// | + | POST /token |
| - | ? | + | Content-Type: |
| - | & | + | |
| - | </code> | + | |
| - | ==== Step 7 - Exchange Code ===== | + | grant_type=authorization_code |
| - | + | &code={authorization_code} | |
| - | SPA calls: | + | & |
| - | + | & | |
| - | < | + | & |
| - | POST / | + | |
| </ | </ | ||
| - | with PKCE. | + | **Response: |
| - | + | ||
| - | ==== Step 8 - Receive JWT ===== | + | |
| <code json> | <code json> | ||
| { | { | ||
| - | " | + | " |
| - | " | + | " |
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| } | } | ||
| </ | </ | ||
| - | ==== Step 9 - Access ServerA ===== | + | === POST /token — Refresh Token === |
| + | **Request: | ||
| < | < | ||
| - | Authorization: | + | grant_type=refresh_token |
| + | & | ||
| + | & | ||
| </ | </ | ||
| - | ServerA validates JWT via JWKS. | + | **Error Responses: |
| - | ==== Step 10 - Access ServerB ===== | + | ^ HTTP ^ error ^ Meaning |
| + | | 400 | invalid_request | ||
| + | | 400 | invalid_grant | ||
| + | | 400 | invalid_client | ||
| + | | 403 | insufficient_scope | ||
| - | Same token: | + | ==== ServerA & ServerB Protected Endpoints ==== |
| + | **Request (all protected routes):** | ||
| < | < | ||
| - | Authorization: | + | Authorization: |
| </ | </ | ||
| - | ServerB validates JWT via JWKS. | + | **Error Responses: |
| - | Result: | + | ^ HTTP ^ error ^ Meaning |
| + | | 401 | invalid_token | ||
| + | | 401 | token_expired | ||
| + | | 403 | insufficient_scope | ||
| + | | 403 | invalid_audience | ||
| - | < | + | ---- |
| - | No login page shown. | + | |
| - | True Single Sign-On. | + | ===== Security Considerations ===== |
| + | |||
| + | ==== Token Storage ==== | ||
| + | |||
| + | ^ Location | ||
| + | | Memory (JS var) | ✅ Recommended| ⚠️ Lost on refresh | Low (XSS can't access) | | ||
| + | | sessionStorage | ||
| + | | localStorage | ||
| + | | HttpOnly Cookie | ||
| + | |||
| + | ==== Security Checklist ==== | ||
| + | |||
| + | ^ # ^ Control | ||
| + | | 1 | PKCE S256 | Always use '' | ||
| + | | 2 | state validation | ||
| + | | 3 | Redirect URI exact match | SSO must reject partial/ | ||
| + | | 4 | Short-lived access tokens | ||
| + | | 5 | Refresh token rotation | ||
| + | | 6 | Audience validation | ||
| + | | 7 | JWKS caching with TTL | Cache JWKS for 1 hour; refetch on unknown kid | | ||
| + | | 8 | HTTPS everywhere | ||
| + | | 9 | CSP headers on SPA | Prevent XSS with strict Content-Security-Policy | ||
| + | | 10| Refresh token binding | ||
| + | |||
| + | ==== Attack Mitigations ==== | ||
| + | |||
| + | ^ Attack | ||
| + | | Authorization Code Interception | PKCE: SHA256 binding between code and verifier | ||
| + | | CSRF on / | ||
| + | | Token theft (XSS) | access_token in memory; refresh_token HttpOnly cookie | ||
| + | | Replay attack | ||
| + | | Key compromise | ||
| + | | Session fixation | ||
| + | |||
| + | ---- | ||
| + | |||
| + | ===== Configuration Reference ===== | ||
| + | |||
| + | ==== ServerSSO Configuration ==== | ||
| + | |||
| + | < | ||
| + | sso: | ||
| + | issuer: " | ||
| + | | ||
| + | refresh_token_ttl: | ||
| + | id_token_ttl: | ||
| + | authorization_code_ttl: | ||
| + | |||
| + | signing: | ||
| + | algorithm: RS256 | ||
| + | key_rotation_days: | ||
| + | |||
| + | clients: | ||
| + | | ||
| + | client_type: | ||
| + | redirect_uris: | ||
| + | - " | ||
| + | allowed_scopes: | ||
| + | - openid | ||
| + | - profile | ||
| + | |||
| + | - api: | ||
| + | - api: | ||
| + | pkce_required: | ||
| + | pkce_method: | ||
| </ | </ | ||
| - | ===== Sequence Diagram - Not Authenticated ===== | + | ==== ServerA & ServerB JWT Validation Configuration |
| - | < | + | < |
| - | User | + | jwt: |
| - | | | + | |
| - | | Open SPA | + | |
| - | v | + | |
| - | SPA | + | |
| - | | | + | |
| - | | Redirect | + | |
| - | v | + | |
| - | ServerSSO | + | |
| - | | | + | |
| - | | Login Page | + | |
| - | v | + | |
| - | User | + | |
| - | | | + | |
| - | | Credentials | + | |
| - | v | + | |
| - | ServerSSO | + | |
| - | | | + | |
| - | | Authorization Code | + | |
| - | v | + | |
| - | SPA | + | |
| - | | | + | |
| - | | Token Request + PKCE | + | |
| - | v | + | |
| - | ServerSSO | + | |
| - | | | + | |
| - | | JWT | + | |
| - | v | + | |
| - | SPA | + | |
| - | | | + | |
| - | | Bearer Token | + | |
| - | | + | |
| - | | | + | |
| - | | + | |
| </ | </ | ||
| - | ===== Sequence Diagram - Already Authenticated ===== | + | ==== SPA Environment Configuration |
| - | < | + | < |
| - | User | + | const AUTH_CONFIG = { |
| - | | | + | |
| - | | Open SPA | + | |
| - | v | + | |
| - | SPA | + | |
| - | | | + | |
| - | | Redirect | + | |
| - | v | + | |
| - | ServerSSO | + | apis: { |
| - | | | + | |
| - | | Existing SSO Session | + | |
| - | | | + | } |
| - | | Authorization Code | + | }; |
| - | v | + | |
| - | SPA | + | |
| - | | | + | |
| - | | Exchange Code | + | |
| - | v | + | |
| - | ServerSSO | + | |
| - | | | + | |
| - | | JWT | + | |
| - | v | + | |
| - | SPA | + | |
| - | | | + | |
| - | +-----> ServerA | + | |
| - | | | + | |
| - | +-----> ServerB | + | |
| </ | </ | ||
| - | ===== 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(' |
| + | logoutUrl.searchParams.set(' | ||
| + | logoutUrl.searchParams.set(' | ||
| + | window.location.href = logoutUrl.toString(); | ||
| + | </ | ||
| - | Token Validation: | + | **What ServerSSO does on logout:** |
| - | | + | |
| + | - Revokes all refresh_tokens for the session | ||
| + | - Notifies registered clients via backchannel logout (optional) | ||
| + | - Redirects back to '' | ||
| - | Login Flow: | + | > **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/ | ||
| - | Single Sign-On: | + | ---- |
| - | * Managed by ServerSSO Session Cookie | + | |
| - | Resource Servers: | + | ===== Quick Reference Card ===== |
| - | * ServerA | + | |
| - | * ServerB | + | |
| - | Identity Provider: | + | < |
| - | | + | ╔═══════════════════════════════════════════════════════════════╗ |
| + | ║ OIDC PKCE QUICK REFERENCE | ||
| + | ╠═══════════════════════════════════════════════════════════════╣ | ||
| + | ║ PKCE ║ | ||
| + | ║ | ||
| + | ║ | ||
| + | ║ | ||
| + | ╠═══════════════════════════════════════════════════════════════╣ | ||
| + | ║ ENDPOINTS | ||
| + | ║ / | ||
| + | ║ / | ||
| + | ║ / | ||
| + | ║ / | ||
| + | ║ / | ||
| + | ╠═══════════════════════════════════════════════════════════════╣ | ||
| + | ║ JWT CLAIMS TO VALIDATE | ||
| + | ║ | ||
| + | ║ | ||
| + | ║ | ||
| + | ║ | ||
| + | ║ scope includes required API scope ║ | ||
| + | ╠═══════════════════════════════════════════════════════════════╣ | ||
| + | ║ TOKEN LIFETIMES | ||
| + | ║ | ||
| + | ║ | ||
| + | ║ | ||
| + | ║ 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)// | ||
security/sso-spa.1781589950.txt.gz · Last modified: by phong2018
