====== 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)//