User Tools

Site Tools


security:sso-web

This is an old revision of the document!


Table of Contents

OIDC SSO System — Traditional Web Application + JWT + JWKS

Document Version: 1.0 Last Updated: 2026-06-16 Scope: Single Sign-On architecture for server-rendered / traditional web applications

        using Authorization Code Flow (confidential client), JWT access tokens,
        JWKS-based verification, and server-side session management.

Table of Contents

Architecture Overview

This document describes a server-side SSO system where:

  • The Web application is a traditional server-rendered app (PHP, Java, Python/Django, Node/Express, .NET, Ruby on Rails, etc.)
  • The web app is a confidential client — it has a client_secret stored securely on the server
  • The browser never sees tokens directly — they are stored server-side in a session
  • ServerA and ServerB are backend microservices/APIs called server-to-server (not directly from browser)
  • JWT validation on all resource servers uses JWKS — no SSO contact per request
┌──────────────────────────────────────────────────────────────────────────┐
│                         SYSTEM TOPOLOGY                                  │
│                                                                          │
│   Browser                                                                │
│  ┌────────┐   HTTP + Session Cookie    ┌─────────────────────────────┐   │
│  │  User  │◀──────────────────────────▶│       Web App               │   │
│  │(Browser│                            │  (Confidential OIDC Client) │   │
│  └────────┘                            │  - Renders HTML pages       │   │
│                                        │  - Holds tokens server-side │   │
│                                        │  - Calls ServerA / ServerB  │   │
│                                        └──────────────┬──────────────┘   │
│                                                       │                  │
│                              ┌────────────────────────┼────────────────┐ │
│                              │                        │                │ │
│                              ▼                        ▼                │ │
│                   ┌──────────────────┐   ┌──────────────────┐         │ │
│                   │    ServerA       │   │    ServerB       │         │ │
│                   │  (Resource API)  │   │  (Resource API)  │         │ │
│                   │  JWT via JWKS    │   │  JWT via JWKS    │         │ │
│                   └──────────────────┘   └──────────────────┘         │ │
│                                                                        │ │
│                              ┌──────────────────────────────────────┐  │ │
│                              │           ServerSSO                  │  │ │
│                              │       (OIDC Identity Provider)       │  │ │
│                              │  /authorize  /token  /jwks.json      │  │ │
│                              └──────────────────────────────────────┘  │ │
└────────────────────────────────────────────────────────────────────────┘

Key Differences from SPA/PKCE

Aspect SPA + PKCE Traditional Web + Confidential Client
Client type Public (no secret) Confidential (has client_secret)
PKCE Required Optional (client_secret replaces it)
Token storage Browser memory / sessionStorage Server-side session (never in browser)
Token exchange Browser calls /token directly Web server backend calls /token
API calls Browser → API (Bearer JWT) Web server → API (Bearer JWT)
Session management JS state + localStorage HTTP session cookie (HttpOnly, Secure)
Logout complexity Clear JS state + redirect Invalidate session + SSO logout
Security surface XSS can steal tokens XSS cannot access tokens (server-side)
Back-channel logout Complex (postMessage) Easy (server receives logout notification)
Security advantage of traditional web apps: Because tokens never reach the browser,
XSS attacks cannot steal access_tokens or refresh_tokens. The browser only ever holds
a session cookie (HttpOnly + Secure + SameSite=Lax).

Components

Web Application (Traditional / Server-Rendered)

Property Value
Role OIDC Relying Party (RP) — Confidential Client
Client Type Confidential (has client_secret)
Auth Method client_secret_basic or client_secret_post
Token Storage Server-side session store (Redis / DB)
Session Cookie HttpOnly, Secure, SameSite=Lax
Base URL https://web.example.com
Redirect URI https://web.example.com/auth/callback
Stack examples PHP/Laravel, Java/Spring, Python/Django, .NET

ServerSSO (Identity Provider / Authorization Server)

Property Value
Role OIDC Provider (OP) / Authorization 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
Token Endpoint https://sso.example.com/token

ServerA (Resource Server / Microservice)

Property Value
Role OAuth 2.0 Resource Server
Called by Web App backend (server-to-server)
Validates JWT Bearer token via JWKS
Required Scope api:serverA
Base URL https://api-a.example.com
Network Internal / private network preferred

ServerB (Resource Server / Microservice)

Property Value
Role OAuth 2.0 Resource Server
Called by Web App backend (server-to-server)
Validates JWT Bearer token via JWKS
Required Scope api:serverB
Base URL https://api-b.example.com

OIDC Authorization Code Flow — Confidential Client

Flow Overview

1.  User visits protected page → Web App detects no session
2.  Web App redirects browser → ServerSSO /authorize
3.  ServerSSO authenticates user (login page, MFA, etc.)
4.  ServerSSO redirects browser → Web App /callback?code=...
5.  Web App backend calls ServerSSO /token (with client_secret)  ← server-to-server
6.  ServerSSO returns access_token, id_token, refresh_token
7.  Web App stores tokens in server-side session
8.  Web App sets session cookie on browser (HttpOnly)
9.  Web App calls ServerA/ServerB with Bearer JWT              ← server-to-server
10. ServerA/B validate JWT via JWKS (offline, no SSO contact)
11. Web App renders response to browser

client_secret Authentication

When the Web App exchanges a code for tokens, it authenticates itself to the SSO server using its client_secret. This is what distinguishes a confidential client from a public client.

Method 1 — HTTP Basic Auth (client_secret_basic):
  Authorization: Basic base64(client_id:client_secret)

Method 2 — POST body (client_secret_post):
  client_id=web-app-001&client_secret=s3cr3t-v4lue...

The SSO server trusts this client to:

  • Exchange codes directly (no PKCE required, though it can be added)
  • Store and use refresh_tokens securely
  • Not expose tokens to end-users

JWT & JWKS Explained

JWT Structure

A JWT = header.payload.signature (all base64url-encoded)

Decoded Header:
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-2024-01"       ← Key ID — matches a key in JWKS
}

Decoded Payload (access_token):
{
  "iss":   "https://sso.example.com",
  "sub":   "user-uid-456",
  "aud":   ["https://api-a.example.com", "https://api-b.example.com"],
  "exp":   1718550000,
  "iat":   1718549100,
  "nbf":   1718549100,
  "jti":   "unique-token-id-abc",
  "email": "alice@example.com",
  "name":  "Alice Martin",
  "scope": "openid profile email api:serverA api:serverB",
  "roles": ["user", "editor"],
  "sid":   "sso-session-xyz"    ← SSO session ID (for backchannel logout)
}

Decoded Payload (id_token):
{
  "iss":   "https://sso.example.com",
  "sub":   "user-uid-456",
  "aud":   "web-app-001",        ← id_token audience = client_id
  "exp":   1718549400,
  "iat":   1718549100,
  "nonce": "random-nonce-xyz",   ← Replay protection for id_token
  "email": "alice@example.com",
  "name":  "Alice Martin"
}

JWKS Endpoint

// GET https://sso.example.com/.well-known/jwks.json
// Response cached by resource servers (1 hour TTL)
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "key-2024-01",
      "alg": "RS256",
      "n":   "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWh...",
      "e":   "AQAB"
    }
  ]
}

JWT Validation on Resource Servers

Resource Server (ServerA or ServerB) validates each incoming JWT:

1.  Parse JWT header → extract "kid"
2.  Look up JWKS cache → find key where key.kid == header.kid
3.  If not found in cache → re-fetch /jwks.json → update cache → retry
4.  Verify RSA signature using the public key
5.  Check: payload.iss == "https://sso.example.com"
6.  Check: payload.aud includes this server's URL
7.  Check: payload.exp > current_unix_timestamp
8.  Check: payload.nbf <= current_unix_timestamp (± clock_skew)
9.  Check: payload.iat is not in the future (± clock_skew)
10. Check: payload.scope includes required scope ("api:serverA" / "api:serverB")
11. Optionally check: payload.jti not in revocation list
12. Extract sub, email, roles for business logic
Critical: ServerA and ServerB NEVER contact ServerSSO during request validation.
JWT validation is entirely offline using the cached RSA public key from JWKS.
The SSO server is only contacted for JWKS key rotation (every ~1 hour or on unknown kid).

Session Management

Server-Side Session Store

The Web App maintains a session store (Redis, database, or encrypted cookie). The browser only sees a session cookie — never the JWT.

Browser Cookie:
  Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/

Server-Side Session Record (Redis / DB):
{
  "session_id":     "abc123xyz",
  "user_id":        "user-uid-456",
  "email":          "alice@example.com",
  "name":           "Alice Martin",
  "roles":          ["user", "editor"],
  "access_token":   "eyJhbGci...",          ← JWT (stored server-side only)
  "access_token_exp": 1718550000,
  "refresh_token":  "8xLOxBtZp8...",
  "id_token":       "eyJhbGci...",
  "sso_session_id": "sso-session-xyz",       ← for backchannel logout
  "created_at":     1718549100,
  "last_seen":      1718549100
}

Session vs Token Lifecycle

Timeline:

  ├── User logs in ──────────────────────────────────────────────────────▶
  │
  │   access_token expires:  +15 min  → Web App refreshes silently
  │   refresh_token expires: +24 hrs  → Web App must re-login user
  │   Web session expires:   +8 hrs   → Configurable inactivity timeout
  │   SSO session expires:   +8 hrs   → Configurable at SSO server
  │
  └── User logs out ─────────────────────────────────────────────────────▶
       1. Web App deletes server-side session
       2. Clears session cookie
       3. Calls SSO /logout (terminates SSO session)
       4. SSO triggers backchannel logout to all registered clients

Scenario 1: Unauthenticated User

Overview

Alice opens her browser and navigates to https://web.example.com/dashboard. She has no active session. The system must authenticate her and then serve the page.


Step 1 — Browser Requests Protected Page

Browser → Web App:
  GET https://web.example.com/dashboard
  Cookie: (none / no valid session cookie)

Step 2 — Web App Detects No Session

# Web App middleware (Python/Django example)
def require_auth(view_func):
    def wrapper(request):
        session_id = request.COOKIES.get('session_id')
        session    = session_store.get(session_id) if session_id else None
 
        if not session or is_expired(session):
            # No valid session → initiate OIDC login
            return initiate_oidc_login(request)
 
        request.user = session['user']
        return view_func(request)
    return wrapper

What happens: No session found (or session expired). The Web App:

  1. Generates a random state value (CSRF protection)
  2. Generates a random nonce value (id_token replay protection)
  3. Stores both in a short-lived pre-auth cookie or server-side temp store
  4. Builds the /authorize URL
  5. Returns HTTP 302 to the browser

Step 3 — Web App Redirects Browser to ServerSSO /authorize

Web App → Browser:
  HTTP 302
  Location: https://sso.example.com/authorize
    ?response_type=code
    &client_id=web-app-001
    &redirect_uri=https://web.example.com/auth/callback
    &scope=openid profile email api:serverA api:serverB
    &state=csrf-token-f3a8b2
    &nonce=replay-token-9d4e1c

Parameters:

Parameter Value Purpose
response_type code Request authorization code
client_id web-app-001 Identifies the Web App
redirect_uri https://web.example.com/auth/callback Where SSO sends the code
scope openid profile email api:serverA api:serverB Requested permissions
state Random CSRF token Prevents CSRF on callback
nonce Random value Embedded in id_token to prevent replay
Note: No PKCE parameters. The confidential client authenticates via client_secret
at the token endpoint instead. PKCE can be added as defense-in-depth.

Step 4 — Browser Follows Redirect to ServerSSO

Browser → ServerSSO:
  GET https://sso.example.com/authorize?response_type=code&client_id=...
  Cookie: (no SSO session cookie)

ServerSSO sees no active SSO session → presents login page.


Step 5 — ServerSSO Presents Login Page

ServerSSO → Browser:
  HTTP 200
  Content-Type: text/html
  Set-Cookie: sso_pre_session=temp123; HttpOnly; Secure; SameSite=Lax

  <html>
    <form method="POST" action="/login">
      <input name="username" type="text" />
      <input name="password" type="password" />
      <button type="submit">Sign In</button>
    </form>
  </html>

Step 6 — Alice Submits Credentials

Browser → ServerSSO:
  POST https://sso.example.com/login
  Content-Type: application/x-www-form-urlencoded
  Cookie: sso_pre_session=temp123

  username=alice%40example.com&password=secret123

ServerSSO processes:

  1. Validates credentials against user store (LDAP, database, etc.)
  2. If MFA configured: presents second factor challenge
  3. On success: creates SSO session, sets SSO session cookie
ServerSSO → Browser:
  HTTP 302
  Set-Cookie: sso_session=SESSION-XYZ; HttpOnly; Secure; SameSite=None; Domain=sso.example.com
Note: The SSO session cookie uses SameSite=None because it must be sent
in cross-origin redirects from the Web App domain to the SSO domain.

Step 7 — ServerSSO Issues Authorization Code and Redirects Back

ServerSSO → Browser:
  HTTP 302
  Location: https://web.example.com/auth/callback
    ?code=SplxlOBeZQQYbYS6WxSbIA
    &state=csrf-token-f3a8b2

ServerSSO stores internally:
  code "SplxlOBeZQQYbYS6WxSbIA" → {
    client_id:    "web-app-001",
    redirect_uri: "https://web.example.com/auth/callback",
    scope:        "openid profile email api:serverA api:serverB",
    nonce:        "replay-token-9d4e1c",
    user_id:      "user-uid-456",
    expires_at:   now + 60s    ← One-time, short-lived
  }

Step 8 — Browser Follows Redirect to Web App Callback

Browser → Web App:
  GET https://web.example.com/auth/callback
    ?code=SplxlOBeZQQYbYS6WxSbIA
    &state=csrf-token-f3a8b2
  Cookie: pre_auth_state=csrf-token-f3a8b2   ← set in Step 2

Step 9 — Web App Validates state (CSRF Check)

# Web App callback handler
def auth_callback(request):
    code  = request.GET.get('code')
    state = request.GET.get('state')
 
    # Retrieve stored state from pre-auth cookie / temp store
    stored_state = request.COOKIES.get('pre_auth_state')
    if not state or state != stored_state:
        raise SecurityError("State mismatch — possible CSRF attack")
 
    # Retrieve nonce for id_token validation later
    nonce = temp_store.get(f"nonce:{stored_state}")
 
    # Proceed to token exchange
    return exchange_code_for_tokens(code, nonce)

What is validated:

  1. state in URL matches state stored in Step 2 → CSRF check ✅
  2. code is present and non-empty

Step 10 — Web App Backend Exchanges Code for Tokens (Server-to-Server)

This call is server-to-server — the browser is NOT involved.

Web App Backend → ServerSSO:
  POST https://sso.example.com/token
  Content-Type: application/x-www-form-urlencoded
  Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl   ← base64(client_id:client_secret)

  grant_type=authorization_code
  &code=SplxlOBeZQQYbYS6WxSbIA
  &redirect_uri=https://web.example.com/auth/callback

ServerSSO validates:

  1. Authorization header contains valid client_id + client_secret
  2. code is valid, not expired, not already used
  3. redirect_uri exactly matches the one used in /authorize
  4. client_id in Authorization header matches the one the code was issued to

Step 11 — ServerSSO Returns JWT Tokens

ServerSSO → Web App Backend:
  HTTP 200
  Content-Type: application/json
 
  {
    "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
    "token_type":    "Bearer",
    "expires_in":    900,
    "refresh_token": "8xLOxBtZp8QyNcZpA3Rx",
    "id_token":      "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
    "scope":         "openid profile email api:serverA api:serverB"
  }

Step 12 — Web App Validates id_token and Stores Session

# Validate id_token
id_token_payload = verify_jwt(
    token     = token_response['id_token'],
    issuer    = 'https://sso.example.com',
    audience  = 'web-app-001',   ← id_token audience = client_id
    nonce     = stored_nonce,    ← must match nonce from Step 3
    algorithm = 'RS256',
    jwks_uri  = 'https://sso.example.com/.well-known/jwks.json'
)
 
# Create server-side session
session_id = generate_secure_random_id()
session_store.set(session_id, {
    'user_id':           id_token_payload['sub'],
    'email':             id_token_payload['email'],
    'name':              id_token_payload['name'],
    'access_token':      token_response['access_token'],
    'access_token_exp':  time.time() + token_response['expires_in'],
    'refresh_token':     token_response['refresh_token'],
    'sso_session_id':    id_token_payload.get('sid'),
    'created_at':        time.time()
}, ttl=28800)  # 8 hours

Web App → Browser:
  HTTP 302
  Location: https://web.example.com/dashboard    ← original requested URL
  Set-Cookie: session_id=abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=28800
The browser now has only a session_id cookie — no JWT, no tokens.
All sensitive material is on the server.

Step 14 — Browser Requests Dashboard (Now Authenticated)

Browser → Web App:
  GET https://web.example.com/dashboard
  Cookie: session_id=abc123xyz

Step 15 — Web App Calls ServerA (Server-to-Server, Bearer JWT)

# Web App loads session, retrieves access_token
session      = session_store.get('abc123xyz')
access_token = session['access_token']
 
# Web App calls ServerA backend-to-backend
response = http_client.get(
    'https://api-a.example.com/api/data',
    headers={
        'Authorization': f'Bearer {access_token}',
        'X-Request-ID':  generate_request_id()
    }
)

This is a server-to-server call — the browser is NOT involved.


Step 16 — ServerA Validates JWT via JWKS (Offline)

# ServerA middleware
def validate_bearer_token(request):
    auth_header = request.headers.get('Authorization', '')
    if not auth_header.startswith('Bearer '):
        return Response(401, {'error': 'missing_token'})
 
    token = auth_header[7:]
 
    # Decode header to get kid
    header  = decode_jwt_header(token)
    jwks    = get_jwks_cached('https://sso.example.com/.well-known/jwks.json')
    pub_key = find_key(jwks, kid=header['kid'])
 
    if not pub_key:
        # Unknown kid → force refresh JWKS cache once
        jwks    = fetch_jwks_live('https://sso.example.com/.well-known/jwks.json')
        pub_key = find_key(jwks, kid=header['kid'])
        if not pub_key:
            return Response(401, {'error': 'unknown_key'})
 
    # Verify signature + standard claims
    payload = verify_jwt(token, pub_key, {
        'issuer':    'https://sso.example.com',
        'audience':  'https://api-a.example.com',
        'algorithm': 'RS256'
    })
 
    # Verify required scope
    if 'api:serverA' not in payload.get('scope', '').split():
        return Response(403, {'error': 'insufficient_scope'})
 
    request.user = payload
    # Continue to handler...

ServerSSO is NOT contacted here. Validation is pure cryptographic verification using the cached RSA public key.


Step 17 — ServerA Returns Data to Web App

ServerA → Web App:
  HTTP 200
  {
    "items": [...],
    "user": "alice@example.com"
  }

Step 18 — Web App Calls ServerB (Same Pattern)

The Web App calls ServerB with the same access_token:

Web App Backend → ServerB:
  GET https://api-b.example.com/api/records
  Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...

ServerB performs the same JWKS-based JWT validation, checking:

  1. scope includes api:serverB

Step 19 — Web App Renders Final HTML Response

App → Browser: HTTP 200 Content-Type: text/html <html
    <body>
      <h1>Dashboard</h1>
      <p>Welcome, Alice Martin</p>
      <!-- Data from ServerA and ServerB merged into page -->
    </body>
  </html>

The browser receives only rendered HTML. No tokens appear in the browser.


Scenario 1 — Complete Flow Summary

Browser        Web App          ServerSSO        ServerA        ServerB
  │               │                 │               │              │
  │─ GET /dash ──▶│                 │               │              │
  │               │ no session      │               │              │
  │◀─ 302 /auth ─│                 │               │              │
  │                                 │               │              │
  │─ GET /authorize ───────────────▶│               │              │
  │◀─ 302 /login ──────────────────│               │              │
  │─ POST /login ──────────────────▶│               │              │
  │◀─ SSO session cookie ───────────│               │              │
  │◀─ 302 /callback?code= ─────────│               │              │
  │                                 │               │              │
  │─ GET /callback?code= ─────────▶│               │              │
  │               │─ POST /token ──▶│               │              │
  │               │  (+ client_secret, s2s)          │              │
  │               │◀─ {tokens} ────│               │              │
  │               │  store session  │               │              │
  │◀─ 302 /dash + session_id cookie │               │              │
  │                                 │               │              │
  │─ GET /dash ──▶│                 │               │              │
  │  (session cookie)               │               │              │
  │               │─ GET /api ──────────────────────▶│              │
  │               │  (Bearer JWT, s2s)               │              │
  │               │  validate JWT (JWKS offline)     │              │
  │               │◀─ {data} ────────────────────────│              │
  │               │─ GET /api ─────────────────────────────────────▶│
  │               │  (Bearer JWT, s2s)                              │
  │               │  validate JWT (JWKS offline)                    │
  │               │◀─ {data} ───────────────────────────────────────│
  │◀─ 200 HTML ──│                 │               │              │

Scenario 2: Authenticated User

Overview

Alice already has an active session from a previous login. She navigates to multiple pages, triggering calls to ServerA and ServerB. This scenario covers:

  • 2A — Session and access_token both valid → serve directly
  • 2B — Session valid but access_token expired → silent refresh
  • 2C — Session expired / invalidated → re-authentication

Scenario 2A — Session Active, Token Valid

Browser → Web App:
  GET https://web.example.com/reports
  Cookie: session_id=abc123xyz

Step 2 — Web App Validates Session

def require_auth(request):
    session_id = request.COOKIES.get('session_id')
    session    = session_store.get(session_id)
 
    if not session:
        return redirect_to_login(request)   # No session
 
    # Check access_token expiry (with 60s buffer)
    if session['access_token_exp'] < time.time() + 60:
        session = refresh_access_token(session)  # → Scenario 2B
 
    request.user  = session
    request.token = session['access_token']
    # Continue to handler

Step 3 — Web App Calls ServerA and ServerB

Web App → ServerA:  GET /api/reports  (Authorization: Bearer JWT)
Web App → ServerB:  GET /api/metrics  (Authorization: Bearer JWT)

Both servers validate JWT offline via JWKS. The same JWT is reused for the duration of its validity.

Step 4 — Web App Renders Page

No SSO interaction. No new tokens. Pure session lookup + backend API calls.

Browser        Web App          ServerSSO        ServerA        ServerB
  │               │                 │               │              │
  │─ GET /reports─▶│                 │               │              │
  │  (session cookie)               │               │              │
  │               │─ GET /api/reports ──────────────▶│              │
  │               │◀─ {data} ────────────────────────│              │
  │               │─ GET /api/metrics ────────────────────────────▶│
  │               │◀─ {data} ───────────────────────────────────────│
  │◀─ 200 HTML ──│                 │               │              │

Scenario 2B — Session Valid, Access Token Expired

Step 1 — Web App Detects Expired Token

# Session is valid but access_token is expired
if session['access_token_exp'] < time.time() + 60:
    new_tokens = refresh_access_token(session['refresh_token'])
    if new_tokens:
        # Update session with new tokens
        session['access_token']     = new_tokens['access_token']
        session['access_token_exp'] = time.time() + new_tokens['expires_in']
        session['refresh_token']    = new_tokens['refresh_token']  # Rotated
        session_store.update(session_id, session)
    else:
        # Refresh failed → force re-login
        return redirect_to_login(request)

Step 2 — Web App Calls ServerSSO /token with Refresh Token (Server-to-Server)

This call is entirely transparent to the user. No redirect, no interruption.

Web App Backend → ServerSSO:
  POST https://sso.example.com/token
  Content-Type: application/x-www-form-urlencoded
  Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl

  grant_type=refresh_token
  &refresh_token=8xLOxBtZp8QyNcZpA3Rx

Step 3 — ServerSSO Returns New Tokens

{
  "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(new)",
  "token_type":    "Bearer",
  "expires_in":    900,
  "refresh_token": "9yMpaBuA3RxNewToken",   ← Rotated (old one invalidated)
  "scope":         "openid profile email api:serverA api:serverB"
}

Step 4 — Web App Updates Session Transparently

# Update session — user is completely unaware this happened
session_store.update(session_id, {
    'access_token':     new_tokens['access_token'],
    'access_token_exp': time.time() + new_tokens['expires_in'],
    'refresh_token':    new_tokens['refresh_token']
})
# Continue to serve the original request

Step 5 — Web App Continues with New Token

Browser        Web App          ServerSSO        ServerA
  │               │                 │               │
  │─ GET /reports─▶│                 │               │
  │  (session OK,  │                 │               │
  │   token expired│─ POST /token ──▶│               │
  │               │  (refresh, s2s)  │               │
  │               │◀─ new tokens ───│               │
  │               │  update session  │               │
  │               │─ GET /api ───────────────────────▶│
  │               │  (new Bearer JWT)│               │
  │               │◀─ {data} ────────────────────────│
  │◀─ 200 HTML ──│                 │               │

The user experiences zero interruption. The token refresh is completely transparent.


Scenario 2C — Session Expired (Re-authentication Required)

Step 1 — Web App Finds Expired / Missing Session

def require_auth(request):
    session_id = request.COOKIES.get('session_id')
    session    = session_store.get(session_id)
 
    # Session expired or doesn't exist
    if not session or session_is_expired(session):
        # Save intended URL for post-login redirect
        original_url = request.build_absolute_uri()
        request.session['post_login_redirect'] = original_url
        return redirect_to_oidc_login(request)

Step 2 — Web App Redirects to SSO

Same as Scenario 1 Step 3. But this time:

  • If the SSO session is still active (e.g., user logged in via another app) → SSO skips login page and immediately returns an authorization code
  • If the SSO session also expired → SSO shows the login page again
SSO session still active (seamless re-auth):
  Browser → ServerSSO: GET /authorize
  ServerSSO sees valid SSO session cookie
  ServerSSO → Browser: 302 /callback?code=...  (no login page shown)
  
SSO session also expired:
  Browser → ServerSSO: GET /authorize
  ServerSSO sees no valid session
  ServerSSO → Browser: login page
  Alice logs in again → proceeds normally

This is the SSO effect — if Alice has multiple apps sharing the same SSO server, she only needs to log in once. Re-authentication in any individual app is seamless as long as the SSO session is still active.

Step 3 — New Session Created, Original URL Restored

# After successful token exchange (same as Scenario 1)
# Redirect to original URL the user wanted
original_url = request.session.pop('post_login_redirect', '/dashboard')
return redirect(original_url)

Scenario 2 — Complete Flow Summary

Browser        Web App          ServerSSO        ServerA        ServerB
  │               │                 │               │              │
  │ [2A: Session and token valid]   │               │              │
  │─ GET /page ──▶│                 │               │              │
  │               │─ GET /api ──────────────────────▶│              │
  │               │◀─ {data} ────────────────────────│              │
  │◀─ 200 HTML ──│                 │               │              │
  │               │                 │               │              │
  │ [2B: Session valid, token expired]              │              │
  │─ GET /page ──▶│                 │               │              │
  │               │─ POST /token ──▶│               │              │
  │               │  (refresh, s2s) │               │              │
  │               │◀─ new tokens ───│               │              │
  │               │─ GET /api ──────────────────────▶│              │
  │               │◀─ {data} ────────────────────────│              │
  │◀─ 200 HTML ──│                 │               │              │
  │               │                 │               │              │
  │ [2C: Session expired]           │               │              │
  │─ GET /page ──▶│                 │               │              │
  │               │ no session      │               │              │
  │◀─ 302 ───────│                 │               │              │
  │─ GET /authorize ───────────────▶│               │              │
  │  SSO session still active       │               │              │
  │◀─ 302 /callback?code= ─────────│               │              │
  │─ GET /callback?code= ─────────▶│               │              │
  │               │─ POST /token ──▶│               │              │
  │               │◀─ {tokens} ────│               │              │
  │◀─ 302 /page + session cookie ──│               │              │
  │─ GET /page ──▶│                 │               │              │
  │               │─ GET /api ──────────────────────▶│              │
  │               │◀─ {data} ────────────────────────│              │
  │◀─ 200 HTML ──│                 │               │              │

Sequence Diagrams

Full Authorization Code Flow (Confidential Client)

Step-by-step:

 1.  Browser      →  Web App    :  GET /protected-page  (no session cookie)
 2.  Web App      →  Web App    :  generate state, nonce; store temporarily
 3.  Web App      →  Browser    :  HTTP 302 → /authorize?...&state=X&nonce=Y
 4.  Browser      →  ServerSSO  :  GET /authorize?response_type=code&client_id=...
 5.  ServerSSO    →  Browser    :  200 login page  (no SSO session)
 6.  Browser      →  ServerSSO  :  POST /login (username + password)
 7.  ServerSSO    →  ServerSSO  :  validate credentials, create SSO session
 8.  ServerSSO    →  Browser    :  Set-Cookie: sso_session + 302 → /callback?code=
 9.  Browser      →  Web App    :  GET /callback?code=AUTH_CODE&state=X
10.  Web App      →  Web App    :  validate state == stored state  (CSRF check)
11.  Web App      →  ServerSSO  :  POST /token (code + client_secret)  [s2s]
12.  ServerSSO    →  ServerSSO  :  verify client_secret, validate code
13.  ServerSSO    →  Web App    :  { access_token, id_token, refresh_token }
14.  Web App      →  Web App    :  validate id_token (nonce, aud, exp, sig via JWKS)
15.  Web App      →  SessionDB  :  store { user, tokens } under session_id
16.  Web App      →  Browser    :  Set-Cookie: session_id=XYZ + 302 → /page
17.  Browser      →  Web App    :  GET /page  (Cookie: session_id=XYZ)
18.  Web App      →  SessionDB  :  get session → retrieve access_token
19.  Web App      →  ServerA    :  GET /api  (Bearer JWT)  [s2s]
20.  ServerA      →  JWKS Cache :  verify JWT (RS256, offline)
21.  ServerA      →  Web App    :  200 { data }
22.  Web App      →  ServerB    :  GET /api  (same Bearer JWT)  [s2s]
23.  ServerB      →  JWKS Cache :  verify JWT (RS256, offline)
24.  ServerB      →  Web App    :  200 { data }
25.  Web App      →  Browser    :  200 HTML page (data merged)

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",
  "backchannel_logout_supported": true,
  "backchannel_logout_session_supported": true,
  "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": ["client_secret_basic", "client_secret_post"],
  "claims_supported": ["sub", "email", "name", "roles", "sid"]
}

POST /token — Authorization Code

Request (client_secret_basic):

POST /token HTTP/1.1
Host: sso.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://web.example.com/auth/callback

Response:

{
  "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
  "token_type":    "Bearer",
  "expires_in":    900,
  "refresh_token": "8xLOxBtZp8QyNcZpA3Rx",
  "id_token":      "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...",
  "scope":         "openid profile email api:serverA api:serverB"
}

POST /token — Refresh Token

POST /token HTTP/1.1
Authorization: Basic d2ViLWFwcC0wMDE6czNjcjN0LXY0bHVl

grant_type=refresh_token
&refresh_token=8xLOxBtZp8QyNcZpA3Rx

POST /backchannel_logout

ServerSSO calls this endpoint on the Web App when a user logs out from any session:

POST https://web.example.com/auth/backchannel-logout
Content-Type: application/x-www-form-urlencoded

logout_token=eyJhbGciOiJSUzI1NiJ9...

Logout token payload:

{
  "iss": "https://sso.example.com",
  "sub": "user-uid-456",
  "aud": "web-app-001",
  "iat": 1718549100,
  "jti": "unique-logout-jti",
  "sid": "sso-session-xyz",      ← SSO session ID → find all web sessions for this SSO session
  "events": {
    "http://schemas.openid.net/event/backchannel-logout": {}
  }
}
# Web App backchannel logout handler
def backchannel_logout(request):
    logout_token = request.POST.get('logout_token')
    payload = verify_logout_token(logout_token)  # validate sig, iss, aud
 
    sso_sid = payload.get('sid')
    sub     = payload.get('sub')
 
    # Delete all web sessions associated with this SSO session
    session_store.delete_by_sso_session(sso_sid)
 
    return Response(200)  # Must respond 200 to acknowledge

Error Responses

HTTP error Description
400 invalid_request Missing or malformed parameter
400 invalid_grant Code or refresh_token invalid / expired
401 invalid_client client_id or client_secret incorrect
403 insufficient_scope Requested scope not allowed for this client
400 unsupported_grant_type grant_type not recognized

ServerA & ServerB Protected Endpoint Contract

Request:

GET /api/data HTTP/1.1
Host: api-a.example.com
Authorization: Bearer {access_token_jwt}
X-Request-ID: {uuid}

Error Responses:

HTTP WWW-Authenticate header Meaning
401 Bearer error=“missing_token” No Authorization header
401 Bearer error=“invalid_token” JWT malformed or signature invalid
401 Bearer error=“token_expired” JWT exp in the past
403 Bearer error=“insufficient_scope” Scope check failed
403 Bearer error=“invalid_audience” aud claim doesn't match this server

Server-Side JWT Validation

Complete Validation Middleware (Python)

import jwt
import requests
import time
from functools import lru_cache
 
JWKS_URL    = "https://sso.example.com/.well-known/jwks.json"
ISSUER      = "https://sso.example.com"
SERVER_A_AUDIENCE = "https://api-a.example.com"
CACHE_TTL   = 3600  # 1 hour
 
_jwks_cache = {'keys': {}, 'fetched_at': 0}
 
def get_public_key(kid):
    """Retrieve RSA public key from JWKS cache, refresh if needed."""
    global _jwks_cache
 
    # Check cache
    if kid in _jwks_cache['keys'] and \
       time.time() - _jwks_cache['fetched_at'] < CACHE_TTL:
        return _jwks_cache['keys'][kid]
 
    # Refresh JWKS
    response = requests.get(JWKS_URL, timeout=5)
    jwks = response.json()
 
    _jwks_cache['fetched_at'] = time.time()
    _jwks_cache['keys'] = {}
 
    for key_data in jwks['keys']:
        public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key_data)
        _jwks_cache['keys'][key_data['kid']] = public_key
 
    return _jwks_cache['keys'].get(kid)
 
def validate_token(token, required_audience, required_scope):
    """Validate JWT and return payload or raise an exception."""
    # Decode header only (no verification) to get kid
    unverified_header = jwt.get_unverified_header(token)
    kid = unverified_header.get('kid')
 
    public_key = get_public_key(kid)
    if not public_key:
        raise ValueError(f"Unknown key id: {kid}")
 
    # Full verification
    payload = jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],
        issuer=ISSUER,
        audience=required_audience,
        options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub"]}
    )
 
    # Scope check
    scopes = payload.get('scope', '').split()
    if required_scope not in scopes:
        raise PermissionError(f"Missing required scope: {required_scope}")
 
    return payload

Complete Validation Middleware (Java/Spring)

@Component
public class JwtValidationFilter extends OncePerRequestFilter {
 
    private static final String ISSUER   = "https://sso.example.com";
    private static final String AUDIENCE = "https://api-a.example.com";
    private static final String REQUIRED_SCOPE = "api:serverA";
 
    private final JwkSet jwkSet;    // Cached, refreshed hourly
    private final JwkSetCache cache;
 
    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain)
            throws ServletException, IOException {
 
        String authHeader = req.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            res.sendError(401, "Missing Bearer token");
            return;
        }
 
        String token = authHeader.substring(7);
 
        try {
            // 1. Parse header for kid
            SignedJWT signedJWT = SignedJWT.parse(token);
            String kid = signedJWT.getHeader().getKeyID();
 
            // 2. Get key from cache (fetch from JWKS if unknown)
            RSAKey rsaKey = cache.getKey(kid);
 
            // 3. Verify signature
            RSASSAVerifier verifier = new RSASSAVerifier(rsaKey);
            if (!signedJWT.verify(verifier)) {
                res.sendError(401, "Invalid token signature");
                return;
            }
 
            // 4. Validate claims
            JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
            validateClaims(claims);
 
            // 5. Inject user into request context
            SecurityContextHolder.getContext()
                .setAuthentication(buildAuth(claims));
 
            chain.doFilter(req, res);
 
        } catch (Exception e) {
            res.sendError(401, "Token validation failed: " + e.getMessage());
        }
    }
 
    private void validateClaims(JWTClaimsSet claims) throws Exception {
        if (!ISSUER.equals(claims.getIssuer()))
            throw new Exception("Invalid issuer");
        if (!claims.getAudience().contains(AUDIENCE))
            throw new Exception("Invalid audience");
        if (claims.getExpirationTime().before(new Date()))
            throw new Exception("Token expired");
        if (!claims.getStringClaim("scope").contains(REQUIRED_SCOPE))
            throw new Exception("Insufficient scope");
    }
}

Logout Flows

Standard Logout (User-Initiated)

1.  User clicks "Logout" button on Web App

2.  Browser → Web App:
      POST https://web.example.com/logout
      Cookie: session_id=abc123xyz

3.  Web App:
      a. Retrieve id_token from session
      b. Delete server-side session from session store
      c. Revoke refresh_token at SSO (optional but recommended)

4.  Web App → Browser:
      HTTP 302
      Set-Cookie: session_id=; Max-Age=0; HttpOnly; Secure
      Location: https://sso.example.com/logout
        ?id_token_hint=eyJhbGci...
        &post_logout_redirect_uri=https://web.example.com/logged-out
        &state=random-state-xyz

5.  Browser → ServerSSO:
      GET /logout?id_token_hint=...

6.  ServerSSO:
      a. Validates id_token_hint
      b. Identifies SSO session
      c. Terminates SSO session
      d. Clears SSO session cookie
      e. Triggers backchannel logout to all registered clients
      f. Redirects to post_logout_redirect_uri

7.  Browser → Web App:
      GET https://web.example.com/logged-out
      (session cookie already cleared)

8.  Web App → Browser:
      200 "You have been logged out"

Backchannel Logout (SSO-Initiated)

When a user logs out from another application sharing the same SSO:

ServerSSO → Web App Backend:
  POST https://web.example.com/auth/backchannel-logout
  (logout_token JWT identifying sub + sid)

Web App:
  - Validates logout_token signature (JWKS)
  - Finds all sessions matching sso_session_id (sid claim)
  - Deletes those sessions from session store
  - Returns HTTP 200

Result: User is silently logged out of the Web App even though
        they only explicitly logged out from a different application.

Security Considerations

Attribute Value Reason
HttpOnly true JavaScript cannot read the session cookie
Secure true Cookie only sent over HTTPS
SameSite Lax CSRF protection for standard navigation
Path / Cookie sent to all app paths
Max-Age / Expires 8 hours Session timeout
Domain web.example.com Restrict to this app only

client_secret Security

Requirement Implementation
Never hardcode in source code Use environment variables / secret manager
Never log client_secret Filter from all application logs
Rotate periodically Minimum annually; immediately if leaked
Store in secrets manager HashiCorp Vault, AWS Secrets Manager, etc.
Use TLS for all /token calls Enforce HTTPS on SSO token endpoint

Security Checklist

# Control Description
1 state parameter validation Always verify state matches stored value (CSRF)
2 nonce in id_token Verify nonce to prevent id_token replay
3 Exact redirect_uri match SSO rejects partial or wildcard URIs
4 client_secret confidentiality Never expose to browser; store in secrets manager
5 Session cookie flags HttpOnly + Secure + SameSite=Lax mandatory
6 Short access_token TTL 15 minutes max; refresh transparently
7 Refresh token rotation New token per use; old token invalidated
8 Server-side session store Use Redis or DB; never JWT-only sessions
9 JWKS caching 1 hour TTL; refetch on unknown kid only
10 Audience validation Each server checks aud claim strictly
11 Backchannel logout Implement to support global SSO logout
12 HTTPS everywhere TLS 1.2+ on all endpoints

Attack Mitigations

Attack Mitigation
CSRF on /callback state parameter validated server-side
Token theft via XSS Tokens never reach browser; session cookie is HttpOnly
Session fixation New session_id after successful authentication
Code interception client_secret required for /token; code single-use (60s)
Refresh token theft Server-side storage; rotation on use; binding to client
Man-in-the-middle Strict TLS everywhere; HSTS headers on Web App
id_token replay nonce claim validated; short id_token TTL
Forged JWT RS256 signature verified against JWKS public key
Backchannel logout skipped logout_token signature verified before processing
Session after logout Backchannel logout deletes all sessions for SSO session ID

Configuration Reference

ServerSSO Client Registration

clients:
  - client_id:     "web-app-001"
    client_secret: "${SSO_CLIENT_SECRET}"    ← from secrets manager
    client_type:   confidential
    
    redirect_uris:
      - "https://web.example.com/auth/callback"
    
    post_logout_redirect_uris:
      - "https://web.example.com/logged-out"
    
    backchannel_logout_uri: "https://web.example.com/auth/backchannel-logout"
    backchannel_logout_session_required: true
    
    allowed_scopes:
      - openid
      - profile
      - email
      - api:serverA
      - api:serverB
    
    token_endpoint_auth_method: client_secret_basic
    
    access_token_ttl:  900     # 15 minutes
    refresh_token_ttl: 86400   # 24 hours
    id_token_ttl:      300     # 5 minutes

Web Application Configuration

oidc:
  authority:     "https://sso.example.com"
  client_id:     "web-app-001"
  client_secret: "${SSO_CLIENT_SECRET}"
  redirect_uri:  "https://web.example.com/auth/callback"
  scopes:
    - openid
    - profile
    - email
    - api:serverA
    - api:serverB
  
  token_endpoint_auth_method: client_secret_basic

session:
  store:          redis
  redis_url:      "redis://session-redis:6379/0"
  ttl_seconds:    28800          # 8 hours
  cookie_name:    session_id
  cookie_secure:  true
  cookie_httponly: true
  cookie_samesite: Lax

apis:
  server_a: "https://api-a.example.com"
  server_b: "https://api-b.example.com"

ServerA & ServerB JWT Validation Configuration

# ServerA
jwt:
  issuer:        "https://sso.example.com"
  audience:      "https://api-a.example.com"
  algorithms:    [RS256]
  jwks_uri:      "https://sso.example.com/.well-known/jwks.json"
  jwks_cache_ttl: 3600          # 1 hour
  required_scope: "api:serverA"
  clock_skew:    30              # seconds tolerance
 
# ServerB
jwt:
  issuer:        "https://sso.example.com"
  audience:      "https://api-b.example.com"
  algorithms:    [RS256]
  jwks_uri:      "https://sso.example.com/.well-known/jwks.json"
  jwks_cache_ttl: 3600
  required_scope: "api:serverB"
  clock_skew:    30

Quick Reference Card

╔══════════════════════════════════════════════════════════════════════╗
║        OIDC TRADITIONAL WEB — QUICK REFERENCE                       ║
╠══════════════════════════════════════════════════════════════════════╣
║ CLIENT TYPE                                                          ║
║   Confidential — has client_secret (stored server-side only)        ║
║   No PKCE required (client_secret authenticates /token call)         ║
╠══════════════════════════════════════════════════════════════════════╣
║ TOKEN STORAGE (server-side only — browser never sees tokens)        ║
║   access_token   → server-side session store (Redis/DB)              ║
║   refresh_token  → server-side session store                         ║
║   id_token       → server-side session store (validated then stored) ║
║   Browser holds  → HttpOnly session cookie (session_id only)         ║
╠══════════════════════════════════════════════════════════════════════╣
║ TOKEN LIFETIMES                                                       ║
║   access_token   15 min   (server auto-refreshes transparently)      ║
║   id_token        5 min   (validate nonce on receipt, then store)    ║
║   refresh_token  24 hrs   (rotated on each use)                      ║
║   auth code      60 sec   (single use)                               ║
║   web session     8 hrs   (inactivity timeout)                       ║
╠══════════════════════════════════════════════════════════════════════╣
║ CRITICAL VALIDATIONS                                                  ║
║   /callback: state == stored state                 (CSRF check)      ║
║   id_token:  nonce == stored nonce                 (replay check)    ║
║   id_token:  aud  == client_id                     (audience check)  ║
║   JWT:       iss  == SSO issuer URL                                  ║
║   JWT:       aud  includes resource server URL                       ║
║   JWT:       exp  > now()                                            ║
║   JWT:       scope includes required API scope                        ║
╠══════════════════════════════════════════════════════════════════════╣
║ FLOW TYPES                                                            ║
║   Login:   /authorize → login → code → POST /token (s2s)             ║
║   Refresh: POST /token (grant=refresh_token, s2s, transparent)       ║
║   Logout:  DELETE session → SSO /logout → backchannel notify others  ║
╠══════════════════════════════════════════════════════════════════════╣
║ ENDPOINTS (ServerSSO)                                                 ║
║   /authorize             → get authorization code                    ║
║   /token                 → exchange code / refresh (s2s only)        ║
║   /.well-known/jwks.json → RSA public keys (cached by APIs)          ║
║   /userinfo              → get user profile (with access_token)      ║
║   /logout                → terminate SSO session                     ║
╠══════════════════════════════════════════════════════════════════════╣
║ KEY SECURITY PROPERTIES                                               ║
║   XSS cannot steal tokens  (stored server-side)                      ║
║   CSRF protected by state  (verified on callback)                    ║
║   Replay protected by nonce (verified in id_token)                   ║
║   Code single-use + 60s TTL + client_secret required to exchange     ║
║   JWKS offline validation  (no SSO contact per API request)          ║
╚══════════════════════════════════════════════════════════════════════╝

Document maintained by: Platform Security Team Format: DokuWiki Standard: OpenID Connect Core 1.0 · RFC 6749 (OAuth 2.0) · RFC 7519 (JWT) · RFC 7517 (JWK) · OpenID Connect Back-Channel Logout 1.0

security/sso-web.1781590552.txt.gz · Last modified: by phong2018