====== OIDC SSO System: Mobile App + PKCE + JWT + JWKS ====== **Version:** 1.0 \\ **Date:** 2026-06-16 \\ **Audience:** Backend Engineers, Mobile Developers, Security Architects ---- ===== Table of Contents ===== - [[#overview|Overview]] - [[#architecture|Architecture & Components]] - [[#key-concepts|Key Concepts]] - [[#scenario1|Scenario 1 — Not Authenticated (First Login)]] - [[#scenario2|Scenario 2 — Already Authenticated (SSO Token Reuse)]] - [[#token-validation|Token Validation with JWKS]] - [[#sequence-diagrams|Sequence Diagrams (Text)]] - [[#security-notes|Security Notes]] - [[#glossary|Glossary]] ---- ===== Overview ===== {{anchor:overview}} This document describes a complete **OpenID Connect (OIDC) Single Sign-On (SSO)** system designed for a mobile application that communicates with two backend resource servers. The system uses: * **PKCE** (Proof Key for Code Exchange) — prevents authorization code interception on mobile * **JWT** (JSON Web Token) — compact, self-contained token format * **JWKS** (JSON Web Key Set) — public key endpoint for token signature verification * **OIDC** — identity layer on top of OAuth 2.0 ---- ===== Architecture & Components ===== {{anchor:architecture}} ==== Component List ==== ^ Component ^ Role ^ Typical Stack ^ | **Mobile App** | End-user client. Initiates auth, holds tokens, calls resource servers. | iOS / Android / React Native | | **ServerSSO** | OIDC Authorization Server. Issues tokens, manages sessions, exposes JWKS. | Keycloak / Auth0 / Custom | | **ServerA** | Resource Server A. Protected API. Validates JWT on each request. | Node.js / Spring / Django | | **ServerB** | Resource Server B. Protected API. Validates JWT on each request. | Node.js / Spring / Django | ==== Network Overview ==== ┌─────────────┐ OIDC / PKCE ┌─────────────────┐ │ Mobile App │ ◄──────────────────────────► │ ServerSSO │ └──────┬──────┘ │ (Auth Server) │ │ └────────┬────────┘ │ JWT (Bearer Token) │ JWKS Public Keys ├────────────────────────────────► ┌──────────┴──────────┐ │ │ ServerA │ │ JWT (Bearer Token) │ (Resource API) │ └────────────────────────────────► └─────────────────────┘ ┌─────────────────────┐ │ ServerB │ │ (Resource API) │ └─────────────────────┘ > **Note:** ServerA and ServerB never contact ServerSSO during normal request validation. > They verify JWT signatures locally using the public keys fetched from the JWKS endpoint. ---- ===== Key Concepts ===== {{anchor:key-concepts}} ==== PKCE Flow (Mobile-Specific) ==== PKCE prevents **authorization code interception attacks** that are common on mobile platforms (because redirect URIs on mobile can be hijacked by malicious apps). ^ Step ^ PKCE Parameter ^ Description ^ | 1 | ``code_verifier`` | Random secret (43–128 chars) generated by the Mobile App | | 2 | ``code_challenge`` | SHA-256 hash of code_verifier, base64url-encoded | | 3 | Auth request | App sends ``code_challenge`` + ``code_challenge_method=S256`` | | 4 | Token exchange | App sends original ``code_verifier`` to prove identity | ==== JWT Structure ==== A JWT has three parts separated by dots: ``header.payload.signature`` // Header { "alg": "RS256", "typ": "JWT", "kid": "key-id-001" // key ID — used to look up the right key in JWKS } // Payload (claims) { "iss": "https://sso.example.com", // issuer "sub": "user-uuid-123", // subject (user ID) "aud": ["serverA", "serverB"], // intended audiences "exp": 1718500000, // expiry (Unix timestamp) "iat": 1718496400, // issued at "email": "user@example.com", "roles": ["read", "write"] } // Signature: RS256(base64url(header) + "." + base64url(payload), privateKey) ==== JWKS Endpoint ==== ServerSSO exposes a public endpoint: GET https://sso.example.com/.well-known/jwks.json Response: { "keys": [ { "kty": "RSA", "use": "sig", "kid": "key-id-001", "alg": "RS256", "n": "0vx7agoebGcQ...", "e": "AQAB" } ] } ServerA and ServerB **cache** this response and use it to verify JWT signatures without calling ServerSSO on every request. ==== OIDC Discovery Document ==== ServerSSO also exposes: GET https://sso.example.com/.well-known/openid-configuration This returns all endpoint URLs (authorization, token, userinfo, jwks_uri, etc.). ---- ===== Scenario 1 — Not Authenticated (First Login) ===== {{anchor:scenario1}} This scenario covers the **complete first-time login flow** from scratch. ==== Step-by-Step ==== === Step 1: Mobile App — Generate PKCE Parameters === The app generates cryptographic values **locally**, before any network call: code_verifier = base64url( random_bytes(32) ) e.g. "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code_challenge = base64url( SHA256( code_verifier ) ) e.g. "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" state = base64url( random_bytes(16) ) // CSRF protection nonce = base64url( random_bytes(16) ) // replay protection The app stores ``code_verifier``, ``state``, and ``nonce`` **in memory** (never on disk). === Step 2: Mobile App → ServerSSO — Authorization Request === The app opens the system browser (or in-app browser tab) and navigates to: GET https://sso.example.com/authorize ?response_type=code &client_id=mobile-app-client &redirect_uri=myapp://callback &scope=openid%20profile%20email%20offline_access &state= &nonce= &code_challenge= &code_challenge_method=S256 ^ Parameter ^ Value ^ Purpose ^ | ``response_type`` | ``code`` | Request an authorization code | | ``client_id`` | ``mobile-app-client`` | Identifies this application | | ``redirect_uri`` | ``myapp://callback`` | Deep link back to the app | | ``scope`` | ``openid profile email offline_access`` | Requested permissions | | ``state`` | Random value | CSRF protection | | ``nonce`` | Random value | Replay attack protection | | ``code_challenge`` | SHA256(code_verifier) | PKCE challenge | | ``code_challenge_method``| ``S256`` | Hash algorithm | === Step 3: ServerSSO — Authenticate the User === ServerSSO presents the **login page** (username/password, MFA, social login, etc.). The user authenticates successfully. ServerSSO: - Validates the PKCE challenge parameters - Creates a server-side session - Generates a short-lived **authorization code** (e.g., valid for 60 seconds, single-use) === Step 4: ServerSSO → Mobile App — Authorization Code Redirect === ServerSSO redirects the browser to the app's deep link: myapp://callback ?code=SplxlOBeZQQYbYS6WxSbIA &state= The app: - Receives the redirect via the OS URL scheme handler - **Validates** that ``state`` matches what was stored in Step 1 (CSRF check) - Extracts the ``code`` === Step 5: Mobile App → ServerSSO — Token Exchange (PKCE Verification) === The app makes a **back-channel** (direct HTTPS) POST request: POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=myapp://callback &client_id=mobile-app-client &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk > **Critical:** ``code_verifier`` is sent here for the first time. > ServerSSO recomputes SHA256(code_verifier) and compares it to the stored ``code_challenge``. > If they match, the exchange is authorized. This **proves** the requester is the same > entity that started the flow — defeating code interception attacks. === Step 6: ServerSSO → Mobile App — Token Response === ServerSSO responds with: { "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...", "token_type": "Bearer", "expires_in": 3600, "id_token": "eyJhbGciOiJSUzI1NiJ9...", "refresh_token": "8xLOxBtZp8", "scope": "openid profile email offline_access" } ^ Token ^ Purpose ^ Storage on Device ^ | ``access_token`` | Sent to ServerA / ServerB as Bearer token | Secure memory only | | ``id_token`` | Contains user identity claims (verified by app) | Secure memory only | | ``refresh_token``| Used to get new access_token when expired | Encrypted secure storage | The app **validates the id_token**: - Fetch JWKS from ``https://sso.example.com/.well-known/jwks.json`` - Verify RS256 signature using the public key matching ``kid`` - Check ``iss``, ``aud``, ``exp``, and ``nonce`` (nonce must match Step 1) === Step 7: Mobile App → ServerA — API Request with JWT === GET https://serverA.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ... === Step 8: ServerA — JWT Validation (Local, No SSO Call) === ServerA validates the JWT **entirely locally**: 1. Parse JWT header → extract "kid" = "key-id-001" 2. Look up public key in local JWKS cache by kid (If not cached: fetch https://sso.example.com/.well-known/jwks.json and cache) 3. Verify RS256 signature using the RSA public key 4. Validate claims: - exp > now() → not expired - iss == "https://sso.example.com" → correct issuer - aud contains "serverA" → token is intended for this server 5. Extract sub, roles, email from payload 6. Apply authorization rules 7. Return API response === Step 9: Mobile App → ServerB — API Request with Same JWT === The **same access_token** is reused for ServerB: GET https://serverB.example.com/api/resource Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ... ServerB performs identical JWT validation (Steps 8.1–8.6) — this is the **SSO benefit**: one authentication, multiple resource servers, no re-login required. ==== Scenario 1 — Summary Flow ==== Mobile App ServerSSO ServerA ServerB │ │ │ │ │─ Generate PKCE ──►│ │ │ │ code_verifier │ │ │ │ code_challenge │ │ │ │ │ │ │ │── GET /authorize ─►│ │ │ │ (code_challenge) │ │ │ │ │ │ │ │ │◄── User Login ─────│ │ │ │ (browser) │ │ │ │ │ │ │◄── redirect ──────│ │ │ │ ?code=XYZ │ │ │ │ &state=ABC │ │ │ │ │ │ │ │── POST /token ────►│ │ │ │ code_verifier │ │ │ │ │ verify: SHA256( │ │ │ │ code_verifier) │ │ │ │ == code_challenge │ │ │ │ │ │ │◄── access_token ──│ │ │ │ id_token │ │ │ │ refresh_token │ │ │ │ │ │ │ │── GET /api/data ──────────────────────►│ │ │ Bearer: JWT │ │ validate JWT │ │ │ │ (JWKS cache) │ │◄── 200 response ──────────────────────►│ │ │ │ │ │ │── GET /api/res ────────────────────────────────────────►│ │ Bearer: JWT │ │ │ validate JWT │◄── 200 response ────────────────────────────────────────│ (JWKS cache) │ │ │ │ ---- ===== Scenario 2 — Already Authenticated (SSO Token Reuse) ===== {{anchor:scenario2}} This scenario covers the case where the user **already has a valid session** or the app has a **cached refresh_token** and needs a new access_token. ==== Sub-Scenario 2a: Valid Access Token Still in Memory ==== The simplest case: the app already holds a non-expired ``access_token``. === Step 1: Mobile App — Check Token Expiry === Before making any API call, the app checks locally: decoded = JWT.decode(access_token, verify_signature=false) // decode without verification now = current_unix_timestamp() if decoded.exp > now + 30: // 30-second buffer // Token is still valid — use it directly proceed_to_step_2() else: // Token expired or about to expire — refresh it (Sub-Scenario 2b) refresh_access_token() === Step 2: Mobile App → ServerA / ServerB — API Request === Identical to Scenario 1 Steps 7–9. The app reuses the cached ``access_token``: GET https://serverA.example.com/api/data Authorization: Bearer No interaction with ServerSSO is needed. ✓ ---- ==== Sub-Scenario 2b: Access Token Expired — Refresh Flow ==== The ``access_token`` has expired, but the app holds a valid ``refresh_token``. === Step 1: Mobile App → ServerSSO — Refresh Token Request === POST https://sso.example.com/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=8xLOxBtZp8 &client_id=mobile-app-client > **Note:** No PKCE is required for the refresh grant — PKCE was only needed > for the initial authorization code exchange. === Step 2: ServerSSO — Validate Refresh Token === ServerSSO checks: - Refresh token exists in its database and is not revoked - Refresh token has not expired (typically 30 days for mobile apps) - ``client_id`` matches If valid, ServerSSO **rotates** the refresh token (issues a new one, invalidates the old one). === Step 3: ServerSSO → Mobile App — New Tokens === { "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...(new)", "token_type": "Bearer", "expires_in": 3600, "id_token": "eyJhbGciOiJSUzI1NiJ9...(new)", "refresh_token": "9yMpACuQr9", "scope": "openid profile email offline_access" } The app stores the **new** ``refresh_token`` and **new** ``access_token``. === Step 4: Mobile App → ServerA / ServerB — API Request === The app proceeds with the freshly issued ``access_token``. GET https://serverA.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...(new) ---- ==== Sub-Scenario 2c: Both Tokens Expired — Re-Authentication ==== If the ``refresh_token`` has also expired (e.g., user was inactive for 30+ days): === Step 1: Mobile App — Detect Expired Refresh Token === The token endpoint returns: HTTP 400 Bad Request { "error": "invalid_grant", "error_description": "Refresh token expired or revoked" } === Step 2: Check for Existing SSO Session (Silent Auth) === Before forcing a full re-login, the app attempts **silent authentication**: GET https://sso.example.com/authorize ?response_type=code &client_id=mobile-app-client &redirect_uri=myapp://callback &scope=openid profile email offline_access &prompt=none ← key parameter: no UI shown &code_challenge= &code_challenge_method=S256 &state= * If ServerSSO has a **valid browser/SSO session** (cookie): it returns a new ``code`` immediately — no login prompt → proceed with token exchange (Scenario 1, Steps 5–6) * If no session exists: ServerSSO returns ``error=login_required`` → app must show login UI → full Scenario 1 from Step 2 ==== Scenario 2b — Summary Flow ==== Mobile App ServerSSO ServerA ServerB │ │ │ │ │ [access_token expired, refresh_token valid] │ │ │ │ │ │── POST /token ────►│ │ │ │ grant_type= │ │ │ │ refresh_token │ │ │ │ │ validate RT │ │ │ │ rotate RT │ │ │◄── new tokens ────│ │ │ │ access_token │ │ │ │ refresh_token │ │ │ │ (rotated) │ │ │ │ │ │ │ │── GET /api/data ──────────────────────►│ │ │ Bearer: new JWT │ │ validate JWT │ │◄── 200 OK ─────────────────────────────│ │ │ │ │ │ │── GET /api/res ────────────────────────────────────────►│ │ Bearer: new JWT │ │ │ validate JWT │◄── 200 OK ──────────────────────────────────────────────│ ---- ===== Token Validation with JWKS (ServerA & ServerB Detail) ===== {{anchor:token-validation}} ==== JWKS Caching Strategy ==== Resource servers should **not** fetch JWKS on every request. Recommended strategy: ^ Condition ^ Action ^ | Startup | Fetch JWKS and cache in memory | | JWT ``kid`` found in cache | Use cached key — no network call | | JWT ``kid`` **not** found in cache | Fetch JWKS again (key rotation may have occurred) | | Cache age > 1 hour | Refresh JWKS in background | | JWKS fetch fails | Use stale cache; log warning; retry with backoff | ==== JWT Validation Algorithm (Pseudocode) ==== function validateJWT(token, audience): // 1. Split and decode (no verification yet) [header_b64, payload_b64, signature_b64] = token.split(".") header = base64url_decode(header_b64) payload = base64url_decode(payload_b64) // 2. Look up the signing key kid = header.kid key = jwksCache.get(kid) if key is null: jwksCache.refresh() // re-fetch from ServerSSO key = jwksCache.get(kid) if key is null: throw InvalidTokenError("Unknown kid") // 3. Verify signature message = header_b64 + "." + payload_b64 if NOT RSA_SHA256_verify(message, signature_b64, key.publicKey): throw InvalidTokenError("Bad signature") // 4. Validate standard claims if payload.exp < now(): throw InvalidTokenError("Token expired") if payload.iss != "https://sso.example.com": throw InvalidTokenError("Wrong issuer") if audience NOT IN payload.aud: throw InvalidTokenError("Wrong audience") if payload.nbf is set AND payload.nbf > now(): throw InvalidTokenError("Token not yet valid") // 5. Extract and return user context return { userId: payload.sub, roles: payload.roles, email: payload.email } ==== Key Rotation ==== When ServerSSO rotates its signing key: - New JWTs are signed with the **new** key (new ``kid``) - Old JWTs remain valid until they expire (old key stays in JWKS temporarily) - ServerA/ServerB fetch the new JWKS when they encounter an unknown ``kid`` - No downtime or coordination required ---- ===== Security Notes ===== {{anchor:security-notes}} ==== Token Storage on Mobile ==== ^ Token ^ Recommended Storage ^ Never store in ^ | access_token | In-memory only (lost on app restart) | SharedPreferences (Android) / UserDefaults (iOS) without encryption | | refresh_token | iOS Keychain / Android Keystore (hardware-backed) | Plain files, AsyncStorage (unencrypted) | | id_token | In-memory only (only needed at login) | Anywhere persistent | ==== PKCE Requirements ==== * ``code_verifier``: must be 43–128 characters, using ``[A-Z a-z 0-9 - . _ ~]`` * Always use ``S256`` (SHA-256) — never ``plain`` (insecure) * Generate a **new** ``code_verifier`` for every authorization request * Never reuse or persist ``code_verifier`` across sessions ==== Redirect URI Security ==== * Register **exact** redirect URIs in ServerSSO (no wildcards) * Use **private-use URI schemes** (e.g., ``com.example.myapp://callback``) or HTTPS with Universal Links (iOS) / App Links (Android) * Always validate ``state`` parameter to prevent CSRF ==== Access Token Lifetime Recommendations ==== ^ Token ^ Recommended Lifetime ^ Notes ^ | access_token | 15–60 minutes | Short life limits exposure if intercepted | | refresh_token | 7–30 days | Rotate on each use (refresh token rotation) | | id_token | 15–60 minutes | Same as access_token | ==== HTTPS Enforcement ==== All endpoints (authorization, token, userinfo, JWKS, and resource APIs) **must** use HTTPS. Certificate pinning is recommended for the mobile app in high-security environments. ---- ===== Glossary ===== {{anchor:glossary}} ^ Term ^ Definition ^ | OIDC | OpenID Connect — identity layer on top of OAuth 2.0 | | OAuth 2.0 | Authorization framework; OIDC extends it with identity (id_token) | | PKCE | Proof Key for Code Exchange — RFC 7636; prevents code interception on mobile | | JWT | JSON Web Token — RFC 7519; compact, URL-safe token format | | JWKS | JSON Web Key Set — RFC 7517; public key set exposed by the auth server | | access_token | Short-lived credential presented to resource servers | | id_token | JWT containing user identity claims; consumed by the client only | | refresh_token | Long-lived credential used to obtain new access tokens | | code_verifier | Random secret generated by the client for PKCE | | code_challenge | SHA-256 hash of code_verifier, sent in the authorization request | | authorization code | Short-lived, single-use code exchanged for tokens | | kid | Key ID — identifies which key in the JWKS was used to sign a JWT | | SSO | Single Sign-On — one authentication grants access to multiple services | | Bearer token | HTTP authentication scheme; token is presented as-is in the Authorization header | | RS256 | RSA Signature with SHA-256; asymmetric signing algorithm for JWTs | ---- //Document maintained by the Platform Security Team. Last updated: 2026-06-16//