User Tools

Site Tools


security:sso-spa

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

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: codecode_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:

  1. Checks code is valid and not expired
  2. Verifies SHA256(code_verifier) == stored code_challenge ← PKCE check
  3. Verifies redirect_uri matches registered value
  4. 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 <JWT>         │                 │
 │                  │  validate JWT     │                 │
 │                  │  (JWKS, offline)  │                 │
 │◀─ 200 { data } ─────────────────────│                 │
 │                  │                   │                 │
 │─ GET /api/data ─────────────────────────────────────▶│
 │  Authorization: Bearer <JWT>                          │
 │                  │  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:

  1. 2A — Access token still valid → used directly
  2. 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:

  1. aud claim includes https://api-b.example.com
  2. 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:

  1. refresh_token is valid and not revoked
  2. refresh_token is not expired (24h TTL)
  3. 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);
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:

  1. Invalidates SSO session cookie
  2. Revokes all refresh_tokens for the session
  3. Notifies registered clients via backchannel logout (optional)
  4. 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)

security/sso-spa.txt · Last modified: by phong2018