User Tools

Site Tools


security:sso-mobile

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Both sides previous revisionPrevious revision
Next revision
Previous revision
security:sso-mobile [2026/06/16 06:57] phong2018security:sso-mobile [2026/06/16 07:08] (current) phong2018
Line 1: Line 1:
-====== OIDC SSO System — Mobile App + PKCE + JWT + JWKS ======+====== OIDC SSO SystemMobile App + PKCE + JWT + JWKS ======
  
-**Document Version:** 1.0 +**Version:** 1.0 \\ 
-**Last Updated:** 2026-06-16 +**Date:** 2026-06-16 \\ 
-**Scope:** Single Sign-On architecture for a native Mobile Application (iOS / Android) +**Audience:** Backend Engineers, Mobile DevelopersSecurity Architects
-          using Authorization Code Flow with PKCEJWT access tokens validated via JWKS +
-          on ServerA and ServerB. Covers RSA key usage at every component.+
  
 ---- ----
Line 11: Line 9:
 ===== Table of Contents ===== ===== Table of Contents =====
  
-  - [[#architecture-overview|Architecture Overview]] +  - [[#overview|Overview]] 
-  - [[#why-mobile-is-different-from-spa-and-traditional-web|Why Mobile is Different]] +  - [[#architecture|Architecture & Components]] 
-  - [[#components|Components]] +  - [[#key-concepts|Key Concepts]] 
-  - [[#rsa-key-usage-private-key-vs-public-key|RSA Key Usage — Private Key vs Public Key]] +  - [[#scenario1|Scenario 1 — Not Authenticated (First Login)]] 
-  - [[#oidc-pkce-flow-for-mobile|OIDC + PKCE Flow for Mobile]] +  - [[#scenario2|Scenario 2 — Already Authenticated (SSO Token Reuse)]] 
-  - [[#token-storage-on-mobile|Token Storage on Mobile]] +  - [[#token-validation|Token Validation with JWKS]] 
-  - [[#scenario-1-unauthenticated-user|Scenario 1: Unauthenticated User]] +  - [[#sequence-diagrams|Sequence Diagrams (Text)]] 
-  - [[#scenario-2-authenticated-user|Scenario 2: Authenticated User]] +  - [[#security-notes|Security Notes]] 
-  - [[#sequence-diagrams|Sequence Diagrams]] +  - [[#glossary|Glossary]]
-  - [[#api-contracts|API Contracts]] +
-  - [[#security-considerations|Security Considerations]] +
-  - [[#configuration-reference|Configuration Reference]] +
-  - [[#quick-reference-card|Quick Reference Card]]+
  
 ---- ----
  
-===== Architecture Overview =====+===== Overview ===== {{anchor:overview}}
  
-<code> +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.
-│                            SYSTEM TOPOLOGY                                       │ +
-│                                                                                  │ +
-│  ┌──────────────────────────────────────────────────────┐                        │ +
-│  │                 Mobile Device                        │                        │ +
-│  │                                                      │                        │ +
-│  │   ┌────────────────────────────────────────────┐    │                        │ +
-│  │   │          Native Mobile App                 │    │                        │ +
-│  │   │   (iOS / Android — Public OIDC Client    │    │                        │ +
-│  │   │                                            │    │                        │ +
-│  │   │  Token Storage (Secure Enclave / Keystore):│    │                        │ +
-│  │   │  ┌──────────────────────────────────────┐  │    │                        │ +
-│  │   │  │ access_token  → Memory only          │  │    │                        │ +
-│  │   │  │ refresh_token → Secure storage       │  │    │                        │ +
-│  │   │  │ id_token      → Memory only          │  │    │                        │ +
-│  │   │  └──────────────────────────────────────┘  │    │                        │ +
-│  │   └───────────┬──────────────┬─────────────────┘    │                        │ +
-│  │               │              │                       │                        │ +
-│  │   ┌───────────▼──────────┐   │ Custom URI / ASB      │                        │ +
-│  │   │  System Browser /    │   │ redirect scheme       │                        │ +
-│  │   │  In-App Browser Tab  │   │                       │                        │ +
-│  │   │  (ASWebAuthentication│   │                       │                        │ +
-│  │   │   Session / CCT    │   │                       │                        │ +
-│  │   └──────────────────────┘   │                       │                        │ +
-│  └──────────────────────────────┼───────────────────────┘                        │ +
-│                                 │ HTTPS (REST API)                               │ +
-│              ┌──────────────────┼────────────────┐                               │ +
-│              │                  │                │                               │ +
-│              ▼                  ▼                ▼                               │ +
-│   ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────────────────┐  │ +
-│   │    ServerA       │  │    ServerB       │  │        ServerSSO             │  │ +
-│   │  (Resource API)  │  │  (Resource API)  │  │   (OIDC Identity Provider)   │  │ +
-│   │                  │  │                  │  │                              │  │ +
-│   │  🔓 Public Key   │  │  🔓 Public Key   │  │  🔐 Private Key (sign JWTs)  │  │ +
-│   │  (JWKS cache)    │  │  (JWKS cache)    │  │  🔓 Public Key (JWKS endpt)  │  │ +
-│   │  JWT validation  │  │  JWT validation  │  │  /authorize /token /jwks     │  │ +
-│   └──────────────────┘  └──────────────────┘  └──────────────────────────────┘  │ +
-└──────────────────────────────────────────────────────────────────────────────────┘ +
-</code>+
  
-**Core principle for mobile:** +The system uses: 
-The mobile app is a **public client** — it cannot securely store a ''client_secret'' +  * **PKCE** (Proof Key for Code Exchange) — prevents authorization code interception on mobile 
-(the app binary can be decompiled). PKCE replaces the ''client_secret'' as the proof +  * **JWT** (JSON Web Token— compact, self-contained token format 
-of legitimacy for the token exchange. The authorization flow opens in the **system +  * **JWKS** (JSON Web Key Set— public key endpoint for token signature verification 
-browser** (not a WebView), which provides isolation from the app's process and access +  * **OIDC** — identity layer on top of OAuth 2.0
-to any existing browser SSO session.+
  
 ---- ----
  
-===== Why Mobile is Different =====+===== Architecture & Components ===== {{anchor:architecture}}
  
-^ Aspect                   ^ SPA (Browser)                    ^ Traditional Web                   ^ Mobile App                              ^ +==== Component List ====
-| Client type              | Public (no secret)               | Confidential (has secret)         | Public (no secret — binary is exposed) +
-| PKCE                     | Required                         | Optional                          | **Required** (RFC 8252 mandates it)     | +
-| Login UI                 | Browser redirect                 | Server-rendered page              | **System browser / ASWebAuthSession**   | +
-| Token storage            | Memory / sessionStorage          | Server-side Redis/DB              | **Secure Enclave / Android Keystore**   | +
-| Redirect after login     | Browser URL change               | HTTP 302 redirect                 | **Custom URI scheme / App Link**        | +
-| Background token refresh | Silent iframe / JS               | Server-side auto-refresh          | **Refresh token in secure storage**     | +
-| App lifecycle risk       | Tab close loses memory state     | Server session persists           | **App to background loses memory only** | +
-| Network calls            | Same origin / CORS               | Server-to-server                  | **Direct HTTPS from device**            | +
-| Biometric gate           | Not available                    | Not available                     | **Can gate token access with biometric**|+
  
-==== Mobile-Specific Standards ====+^ 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  |
  
-  * **RFC 8252** — OAuth 2.0 for Native Apps (mandates system browser, forbids WebView for login) +==== Network Overview ====
-  * **RFC 7636** — PKCE (Proof Key for Code Exchange) +
-  * **RFC 6749** — OAuth 2.0 Authorization Framework +
-  * **OpenID Connect Core 1.0** — Identity layer on top of OAuth 2.0 +
- +
----- +
- +
-===== Components ===== +
- +
-==== Mobile App ==== +
- +
-^ Property           ^ Value                                                    ^ +
-| Role               | OIDC Relying Party — Public Client                       | +
-| Platform           | iOS (Swift/ObjC) / Android (Kotlin/Java) / React Native +
-| Client type        | Public — NO client_secret                            | +
-| Auth method        | PKCE with S256 (mandatory)                               | +
-| Login browser      | ASWebAuthenticationSession (iOS) / Chrome Custom Tab (Android) | +
-| Redirect scheme    | Custom URI: 'myapp://auth/callback'                    | +
-|                    | OR App Link/Universal Link: https://app.example.com/callback | +
-| Token: access      | In-memory only (lost on app terminate)                   | +
-| Token: refresh     | iOS Keychain / Android Keystore (encrypted secure storage)| +
-| Token: id          | In-memory only (used once to extract user identity)      | +
-| Scopes requested   | openid profile email api:serverA api:serverB         | +
-| client_id          | mobile-app-001                                       | +
- +
-==== ServerSSO ==== +
- +
-^ 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        | +
-| 🔐 Private key     | RSA-2048/4096 — signs all JWTs — never leaves ServerSSO +
-| 🔓 Public key      | Exposed at JWKS endpoint — used by ServerA, ServerB      | +
- +
-==== ServerA (Resource Server) ==== +
- +
-^ Property           ^ Value                                                    ^ +
-| Role               | OAuth 2.0 Resource Server / Backend API                  | +
-| Called by          | Mobile App directly (device → server HTTPS)              | +
-| JWT validation     | Offline — RSA public key from JWKS cache                 | +
-| 🔓 Public key      | Fetched from JWKS, cached 1 hour, used to verify JWTs    | +
-| Required scope     | ''api:serverA''                                          | +
-| Base URL           | ''https://api-a.example.com''                           | +
- +
-==== ServerB (Resource Server) ==== +
- +
-^ Property           ^ Value                                                    ^ +
-| Role               | OAuth 2.0 Resource Server / Backend API                  | +
-| Called by          | Mobile App directly (device → server HTTPS)              | +
-| JWT validation     | Offline — RSA public key from JWKS cache                 | +
-| 🔓 Public key      | Fetched from JWKS, cached 1 hour, used to verify JWTs    | +
-| Required scope     | ''api:serverB''                                          | +
-| Base URL           | ''https://api-b.example.com''                           | +
- +
----- +
- +
-===== RSA Key Usage — Private Key vs Public Key ===== +
- +
-==== Key Pair Overview ====+
  
 <code> <code>
-┌─────────────────────────────────────────────────────────────────────────────┐ +  ┌─────────────┐         OIDC / PKCE          ┌─────────────────┐ 
-│                   RSA KEY PAIR — MOBILE SYSTEM                              │ +  │  Mobile App │ ◄──────────────────────────► │   ServerSSO     │ 
-│                                                                             │ +  └────────────┘                               │  (Auth Server)  │ 
-│  ┌──────────────────────────────────────────────────────────────────────┐   │ +         │                                      └──────────────── 
-│  │                        ServerSSO                                     │   │ +         │  JWT (Bearer Token)                           │  JWKS Public Keys 
-│  │                                                                      │   │ +         ────────────────────────────────►  ┌──────────────────── 
-│  │   🔐 PRIVATE KEY (RSA-2048 or RSA-4096)                             │   │ +         │                                   │      ServerA        │ 
-│  │      kid: "key-2024-01"                                             │   │ +         │  JWT (Bearer Token)               │   (Resource API   │ 
-│  │      Location: HSM / Cloud KMS / encrypted key store                │   │ +         └────────────────────────────────►  └─────────────────────┘ 
-│  │      Operation: SIGN JWT payloads (RS256)                           │   │ +                                             ┌─────────────────────┐ 
-│  │      Tokens signed: access_token, id_token                          │   │ +                                             │      ServerB        │ 
-│  │      NEVER transmitted — never leaves this server                   │   │ +                                             │   (Resource API   │ 
-│  │                                                                      │   │ +                                             └─────────────────────┘
-│  │   🔓 PUBLIC KEY (RSA modulus n + exponent e only)                   │   │ +
-│  │      kid: "key-2024-01"                                             │   │ +
-│  │      Exposed at: GET /sso.example.com/.well-known/jwks.json         │   │ +
-│  │      Intentionally public — safe to distribute                      │   │ +
-│  └──────────────────────────────────────────────────────────────────────┘   │ +
-│                                  │                                           │ +
-│                   JWKS endpoint  │  (HTTP GET, cacheable                  │ +
-│              ┌───────────────────────────────────────┐                     │ +
-│              │                                        │                     │ +
-│              ▼                                        ▼                     │ +
-│   ┌──────────────────────┐              ─────────────────────┐            │ +
-│   │      ServerA         │              │      ServerB         │            │ +
-│   │  🔓 PUBLIC KEY cache │              │  🔓 PUBLIC KEY cache │            │ +
-│   │  (TTL: 1 hour      │              │  (TTL: 1 hour)       │            │ +
-│   │  Operation: VERIFY   │              │  Operation: VERIFY   │            │ +
-│   │  every incoming JWT  │              │  every incoming JWT  │            │ +
-│   ──────────────────────┘              └──────────────────────┘            │ +
-│                                                                             │ +
-│   Mobile App: does NOT hold any RSA key.                                    │ +
-│   Mobile App sends the JWT as a Bearer token — it does not verify it.      │ +
-│   The app received the JWT from ServerSSO (trusted over TLS).              │ +
-└─────────────────────────────────────────────────────────────────────────────┘+
 </code> </code>
  
-==== Key Usage Master Table ==== +> **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.
-^ Component    ^ Key Type   ^ Key Identifier  ^ Operation                   ^ Applied To                        ^ When                              ^ +
-| ServerSSO    | 🔐 Private | key-2024-01     | **SIGN** (RS256)            | access_token JWT                  | Every POST /token response        | +
-| ServerSSO    | 🔐 Private | key-2024-01     | **SIGN** (RS256)            | id_token JWT                      | Every POST /token response        | +
-| ServerSSO    | 🔓 Public  | key-2024-01     | **PUBLISH** via JWKS        | RSA n + e components              | GET /.well-known/jwks.json        | +
-| ServerA      | 🔓 Public  | key-2024-01     | **VERIFY** signature        | Incoming Bearer JWT               | Every API request from mobile app | +
-| ServerB      | 🔓 Public  | key-2024-01     | **VERIFY** signature        | Incoming Bearer JWT               | Every API request from mobile app | +
-| Mobile App   | (none)     | —               | No key held or used         | Forwards JWT as Bearer token      | Always                            | +
- +
-> **Mobile apps do NOT verify JWT signatures themselves.** +
-> The mobile app receives the JWT from ServerSSO over a TLS-protected connection. +
-> TLS provides the transport-level guarantee. Only ServerA and ServerB need to +
-> cryptographically verify signatures because they receive JWTs from potentially +
-> untrusted callers (mobile devices). +
- +
-==== Cryptographic Flow ==== +
- +
-<code> +
-JWT CREATION (ServerSSO — private key): +
-────────────────────────────────────── +
-1Assemble header:  { "alg": "RS256", "kid": "key-2024-01", "typ": "JWT" } +
-2. Assemble payload: { "sub": "user-uid-456", "iss": "https://sso.example.com", +
-                       "aud": ["https://api-a.example.com"], "exp": ..., ... } +
-3. signing_input = base64url(header) + "." + base64url(payload) +
-4. 🔐 signature = RSA_PKCS1v15_SIGN(key=PRIVATE_KEY, data=SHA256(signing_input)) +
-5. JWT = signing_input + "." + base64url(signature) +
-6. JWT sent to mobile app via /token response (HTTPS) +
- +
-JWT TRANSMISSION (Mobile App — no key): +
-────────────────────────────────────── +
-7. Mobile app stores JWT in memory +
-8. Mobile app sends: Authorization: Bearer <JWTto ServerA or ServerB +
-9. Mobile app does NOT inspect or verify the JWT signature +
- +
-JWT VERIFICATION (ServerA / ServerB — public key): +
-────────────────────────────────────────────────── +
-10. Parse JWT → extract header.kid = "key-2024-01" +
-11. Lookup JWKS cache → find RSA public key for kid +
-    (If not in cache → GET /sso.example.com/.well-known/jwks.json → cache) +
-12. signing_input = first two parts of JWT (header.payload) +
-13. 🔓 valid = RSA_PKCS1v15_VERIFY( +
-          key       = PUBLIC_KEY, +
-          data      = SHA256(signing_input), +
-          signature = base64url_decode(jwt_signature_part) +
-        ) +
-14. If valid == false → 401 Unauthorized +
-15. If valid == true  → validate iss, aud, exp, nbf, scope → proceed +
-</code> +
- +
-==== Key Rotation Strategy ==== +
- +
-<code> +
-ServerSSO rotates keys every 90 days without service interruption: +
- +
-Phase 1 — Add new key (both keys in JWKS): +
-  GET /.well-known/jwks.json → +
-  { "keys":+
-      { "kid": "key-2024-01", ... },   ← old, still valid +
-      { "kid": "key-2025-01", ... }    ← new, now used for signing +
-  ]} +
- +
-Phase 2 — Sign new JWTs with new private key (kid: "key-2025-01"+
-  → Old JWTs still verifiable via old public key (kid: "key-2024-01"+
-  → ServerA / ServerB auto-discover new key on unknown kid +
- +
-Phase 3 — Wait for all old tokens to expire (≤ 15 min TTL) +
- +
-Phase 4 — Remove old key from JWKS +
-  → Only new key published +
- +
-Mobile apps are unaffected — they don't cache keys. +
-ServerA / ServerB refetch JWKS automatically on unknown kid. +
-</code>+
  
 ---- ----
  
-===== OIDC + PKCE Flow for Mobile =====+===== Key Concepts ===== {{anchor:key-concepts}}
  
-==== PKCE Generation (Mobile-Specific) ====+==== PKCE Flow (Mobile-Specific) ====
  
-On mobile, PKCE values must be generated using the platform's cryptographically +PKCE prevents **authorization code interception attacks** that are common on mobile platforms 
-secure random number generator — never Math.random() or equivalent:+(because redirect URIs on mobile can be hijacked by malicious apps).
  
-<code swift> +^ Step ^ PKCE Parameter        ^ Description                                              ^ 
-// iOS (Swift— PKCE generation +| 1    | ``code_verifier``      | Random secret (43–128 charsgenerated by the Mobile App | 
-import CryptoKit +| 2    | ``code_challenge``     | SHA-256 hash of code_verifier, base64url-encoded          | 
-import Foundation+| 3    | Auth request           | App sends ``code_challenge`` + ``code_challenge_method=S256`` | 
 +| 4    | Token exchange         | App sends original ``code_verifier`` to prove identity    |
  
-func generateCodeVerifier() -> String { +==== JWT Structure ====
-    var buffer [UInt8](repeating: 0, count: 32) +
-    _ SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) +
-    return Data(buffer).base64URLEncodedString() +
-}+
  
-func generateCodeChallenge(from verifierString) -> String { +A JWT has three parts separated by dots``header.payload.signature``
-    let data   = Data(verifier.utf8) +
-    let hash   = SHA256.hash(data: data) +
-    return Data(hash).base64URLEncodedString() +
-+
-// Store code_verifier in memory only (NOT in Keychain — it's ephemeral) +
-let codeVerifier  = generateCodeVerifier() +
-let codeChallenge = generateCodeChallenge(from: codeVerifier) +
-</code>+
  
-<code kotlin+<code json
-// Android (Kotlin) — PKCE generation +// Header 
-import java.security.MessageDigest +{ 
-import java.security.SecureRandom +  "alg""RS256", 
-import android.util.Base64 +  "typ": "JWT", 
- +  "kid": "key-id-001"       // key ID — used to look up the right key in JWKS
-fun generateCodeVerifier()String { +
-    val bytes = ByteArray(32) +
-    SecureRandom().nextBytes(bytes) +
-    return Base64.encodeToString(bytesBase64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)+
 } }
  
-fun generateCodeChallenge(verifier: String): String +// Payload (claims) 
-    val bytes  = verifier.toByteArray(Charsets.US_ASCII) +
-    val digest = MessageDigest.getInstance("SHA-256").digest(bytes+  "iss": "https://sso.example.com",     // issuer 
-    return Base64.encodeToString(digestBase64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)+  "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"]
 } }
-// Store codeVerifier in ViewModel memory — ephemeral per login attempt 
-val codeVerifier  = generateCodeVerifier() 
-val codeChallenge = generateCodeChallenge(codeVerifier) 
-</code> 
  
-==== System Browser Requirement (RFC 8252) ==== +// SignatureRS256(base64url(header+ "." + base64url(payload), privateKey)
- +
-Native apps **MUST NOT** use an embedded WebView for the authorization flow. +
- +
-<code> +
-❌ FORBIDDEN — Embedded WebView (UIWebView WKWebView android.webkit.WebView) +
-   ReasonThe app can intercept credentials typed into the WebView. +
-           The user has no visual guarantee they are on the real SSO login page. +
-           Certificates cannot be independently verified by the user. +
-           SSO session cookies from other apps are NOT shared with WebView. +
- +
-✅ REQUIRED — System Browser / In-App Browser Tab +
-   iOS:     ASWebAuthenticationSession  (SFSafariViewController as fallback) +
-   Android: Chrome Custom Tab           (or the default browser as fallback) +
-   Flutter: flutter_appauth plugin +
-   React Native: react-native-app-auth plugin +
- +
-Benefits of system browser: +
-  - User can inspect the URL bar and certificate — knows they are on sso.example.com +
-  - Existing SSO browser session from other apps IS shared (same cookie store) +
-  - App process cannot read credentials or intercept the response +
-  - Platform-managed security sandbox+
 </code> </code>
  
-==== Custom URI Redirect Scheme ====+==== JWKS Endpoint ====
  
-After login, ServerSSO redirects the browser to URI that opens the mobile app:+ServerSSO exposes public endpoint:
  
 <code> <code>
-Option 1 — Custom URI Scheme (simpler but hijackable on Android): +GET https://sso.example.com/.well-known/jwks.json
-  myapp://auth/callback?code=...&state=... +
-  Registered in: iOS Info.plist / Android AndroidManifest.xml +
-  Risk: Another app could register the same scheme (mitigated by state + PKCE) +
- +
-Option 2 — App Links / Universal Links (more secure, recommended): +
-  https://app.example.com/callback?code=...&state=... +
-  Requires: HTTPS domain verification (Apple / Google) +
-  iOS:     apple-app-site-association file on server +
-  Android: .well-known/assetlinks.json on server +
-  Risk: Only the verified app for that domain can handle the URL+
 </code> </code>
  
-----+Response:
  
-===== Token Storage on Mobile ===== +<code json
- +{ 
-<code> +  "keys": [ 
-┌──────────────────────────────────────────────────────────────────────┐ +    { 
-│               TOKEN STORAGE STRATEGY — MOBILE                        │ +      "kty""RSA", 
-│                                                                      │ +      "use""sig", 
-│  Token          │ Storage Location          │ Survives App Kill?     │ +      "kid""key-id-001", 
-│  ─────────────────────────────────────────────────────────────────── │ +      "alg": "RS256", 
-│  access_token   │ In-memory (ViewModel/     │ ❌ No — must refresh   │ +      "n":   "0vx7agoebGcQ..."
-│                 │  AppState)                │    or re-login         │ +      "e"  "AQAB"
-│                 │                           │                        │ +
-│  id_token       │ In-memory (used once to   │ ❌ No — not needed     │ +
-│                 │  extract user identity)   │    after first login   │ +
-│                 │                           │                        │ +
-│  refresh_token  │ iOSKeychain Services    │ ✅ Yes (Keychain       │ +
-│                 │ AndroidEncryptedSharedPrefs / Keystore            │ +
-│                 │ Flutterflutter_secure_storage                     │ +
-│                 │ React Native: react-native-keychain                 │ +
-└──────────────────────────────────────────────────────────────────────┘ +
- +
-iOS Keychain attributes for refresh_token: +
-  kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +
-  kSecAttrService:    "com.example.myapp.auth+
-  kSecAttrAccount:    "refresh_token" +
-  → Token encrypted by iOS Secure Enclave key +
-  → Not accessible if device is locked (after first boot) +
-  → Not backed up to iCloud (ThisDeviceOnly) +
-  → Not accessible on jailbroken devices (with Secure Enclave) +
- +
-Android EncryptedSharedPreferences: +
-  MasterKey.Builder → AES256_GCM +
-  EncryptedSharedPreferences.create(...) +
-  → Backed by Android Keystore (hardware-backed on modern devices) +
-  → setUserAuthenticationRequired(true) for biometric gate (optional) +
-  → Not exportable from device +
-</code> +
- +
----- +
- +
-===== Scenario 1: Unauthenticated User ===== +
- +
-==== Overview ==== +
- +
-Alice installs the mobile app and opens it for the first time. She has no tokens stored +
-anywhere. She must authenticate and then access protected APIs on ServerA and ServerB. +
- +
----- +
- +
-==== Step 1 — App StartsChecks Secure Storage ==== +
- +
-<code swift> +
-// iOS — App startup auth check +
-func checkAuthState() { +
-    // 1. Check memory for access_token +
-    if let token = AppState.shared.accessToken, !token.isExpired { +
-        // → Scenario 2Atoken valid, proceed +
-        return+
     }     }
- +  ]
-    // 2. Check Keychain for refresh_token +
-    if let refreshToken = Keychain.read(key: "refresh_token") { +
-        // → Scenario 2B: attempt silent refresh +
-        silentRefresh(refreshToken: refreshToken) +
-        return +
-    } +
- +
-    // 3. No tokens anywhere → must login +
-    // → Scenario 1: initiate full OIDC login +
-    initiateLogin()+
 } }
 </code> </code>
  
-**What happens:** No access_token in memory, no refresh_token in Keychain. +ServerA and ServerB **cache** this response and use it to verify JWT signatures 
-Full login flow required.+without calling ServerSSO on every request.
  
-----+==== OIDC Discovery Document ====
  
-==== Step 2 — App Generates PKCE Values and state ====+ServerSSO also exposes:
  
 <code> <code>
-Mobile App generates (in memory, NOT persisted to Keychain): +GET https://sso.example.com/.well-known/openid-configuration 
-  code_verifier  = base64url(SecureRandom(32 bytes)) +</code>
-                 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"+
  
-  code_challenge = base64url(SHA256(code_verifier)) +This returns all endpoint URLs (authorization, token, userinfo, jwks_uri, etc.).
-                 = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" +
- +
-  state          = base64url(SecureRandom(16 bytes)) +
-                 = "xyzABC123randomstate" +
- +
-  All three stored in ViewModel memory for duration of login flow. +
-</code>+
  
 ---- ----
  
-==== Step 3 — App Opens System Browser with /authorize URL ====+===== Scenario 1 — Not Authenticated (First Login) ===== {{anchor:scenario1}}
  
-<code +This scenario covers the **complete first-time login flow** from scratch.
-Mobile App → System Browser: +
-  Open URL: https://sso.example.com/authorize +
-    ?response_type=code +
-    &client_id=mobile-app-001 +
-    &redirect_uri=myapp%3A%2F%2Fauth%2Fcallback +
-    &scope=openid%20profile%20email%20api%3AserverA%20api%3AserverB +
-    &state=xyzABC123randomstate +
-    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM +
-    &code_challenge_method=S256 +
-</code>+
  
-**Platform-specific implementation:**+==== Step-by-Step ====
  
-<code swift> +=== Step 1Mobile App — Generate PKCE Parameters ===
-// iOS +
-let session ASWebAuthenticationSession( +
-    url: authorizeURL, +
-    callbackURLScheme: "myapp" +
-) { callbackURL, error in +
-    guard let url callbackURL else { return } +
-    handleCallback(urlurl) +
-+
-session.presentationContextProvider self +
-session.prefersEphemeralWebBrowserSession false  // share SSO session +
-session.start() +
-</code>+
  
-<code kotlin> +The app generates cryptographic values **locally**before any network call:
-// Android +
-val customTabsIntent = CustomTabsIntent.Builder().build() +
-customTabsIntent.launchUrl(contextUri.parse(authorizeURL)) +
-// App re-opened via AndroidManifest intent-filter for myapp://auth/callback +
-</code>+
  
-**prefersEphemeralWebBrowserSession false** (iOS): allows the system browser to +<code> 
-> share its cookie jar with Safari, enabling SSO if the user already has an active +code_verifier  base64urlrandom_bytes(32) 
-> SSO session from another app or from mobile Safari.+                 e.g"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  
-----+code_challenge = base64url( SHA256( code_verifier ) ) 
 +                 e.g. "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
  
-==== Step 4 — System Browser Navigates to ServerSSO ==== +state          base64url( random_bytes(16) )   // CSRF protection 
- +nonce          base64url( random_bytes(16) )   // replay protection
-<code> +
-System Browser → ServerSSO: +
-  GET https://sso.example.com/authorize +
-    ?response_type=code +
-    &client_id=mobile-app-001 +
-    &redirect_uri=myapp://auth/callback +
-    &scope=openid profile email api:serverA api:serverB +
-    &state=xyzABC123randomstate +
-    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM +
-    &code_challenge_method=S256 +
-  Cookie: (no sso_session — first login)+
 </code> </code>
  
-ServerSSO checks: no SSO session cookie → must authenticate → show login page.+The app stores ``code_verifier``, ``state``, and ``nonce`` **in memory** (never on disk).
  
-----+=== Step 2: Mobile App → ServerSSO — Authorization Request ===
  
-==== Step 5 — ServerSSO Presents Login Page in System Browser ====+The app opens the system browser (or in-app browser tab) and navigates to:
  
 <code> <code>
-ServerSSO → System Browser: +GET https://sso.example.com/authorize 
-  HTTP 200  Content-Type: text/html +  ?response_type=code 
- +  &client_id=mobile-app-client 
-  <html> +  &redirect_uri=myapp://callback 
-    <body> +  &scope=openid%20profile%20email%20offline_access 
-      <form action="https://sso.example.com/login" method="POST"> +  &state=<state
-        <input name="username" type="text" placeholder="Email" /> +  &nonce=<nonce
-        <input name="password" type="password" placeholder="Password" /> +  &code_challenge=<code_challenge
-        <button type="submit">Sign In</button+  &code_challenge_method=S256
-      </form+
-    </body+
-  </html>+
 </code> </code>
  
-The mobile app process **cannot see this page or the credentials Alice types**. +^ Parameter               ^ Value                     ^ Purpose                        ^ 
-The system browser has a separate process with its own memory space.+| ``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 ===
  
-==== Step 6 — Alice Submits Credentials in System Browser ====+ServerSSO presents the **login page** (username/password, MFA, social login, etc.).
  
-<code> +The user authenticates successfully. ServerSSO: 
-System Browser → ServerSSO: +  - Validates the PKCE challenge parameters 
-  POST https://sso.example.com/login +  - Creates a server-side session 
-  Content-Type: application/x-www-form-urlencoded+  Generates a short-lived **authorization code** (e.g., valid for 60 seconds, single-use)
  
-  username=alice%40example.com&password=secret123 +=== Step 4: ServerSSO → Mobile App — Authorization Code Redirect ===
-</code>+
  
-**ServerSSO processing:** +ServerSSO redirects the browser to the app's deep link:
-  - Validates credentials against user store +
-  - If MFA configured → challenges second factor in same browser session +
-  - On successcreates SSO session, stores ''code_challenge'' binding +
- +
----- +
- +
-==== Step 7 — ServerSSO Sets SSO Cookie and Redirects with Authorization Code ====+
  
 <code> <code>
-ServerSSO → System Browser: +myapp://callback 
-  HTTP 302 +  ?code=SplxlOBeZQQYbYS6WxSbIA 
-  Set-Cookie: sso_session=SSO-MOB-XYZ; Domain=sso.example.com; +  &state=<original_state>
-              HttpOnly; Secure; SameSite=None +
-  Location: myapp://auth/callback +
-    ?code=AUTH-CODE-MOBILE-111 +
-    &state=xyzABC123randomstate +
- +
-ServerSSO internally stores binding: +
-  "AUTH-CODE-MOBILE-111" → { +
-    client_id:      "mobile-app-001", +
-    code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", +
-    challenge_method: "S256", +
-    redirect_uri:   "myapp://auth/callback", +
-    scope:          "openid profile email api:serverA api:serverB", +
-    user_id:        "user-uid-456", +
-    expires_at:     now + 60s  ← single-use, 60 second TTL +
-  }+
 </code> </code>
  
-The SSO session cookie is set on the **system browser**, not on the app. +The app: 
-This enables future SSO for other apps that also use the system browser.+  - 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) ===
  
-==== Step 8 — System Browser Follows Redirect, OS Hands Control to App ====+The app makes a **back-channel** (direct HTTPS) POST request:
  
 <code> <code>
-System Browser attempts to navigate to: myapp://auth/callback?code=...&state=...+POST https://sso.example.com/token 
 +Content-Type: application/x-www-form-urlencoded
  
-iOS ASWebAuthenticationSession detects the callback scheme "myapp" +grant_type=authorization_code 
-      → calls completion handler with callbackURL +&code=SplxlOBeZQQYbYS6WxSbIA 
-      → system browser session ends automatically +&redirect_uri=myapp://callback 
- +&client_id=mobile-app-client 
-Android: OS resolves intent for scheme "myapp" → Android routes to app's +&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
-         Activity registered in AndroidManifest.xml for this URI scheme +
-         → Activity.onNewIntent(intent) fires with the callback URI+
 </code> </code>
  
-The **authorization code** is now inside the mobile appIt is **not** a token — +**Critical:** ``code_verifier`` is sent here for the first time. 
-it must be exchanged immediately (60 second window).+> 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 ===
  
-==== Step 9 — App Extracts and Validates code and state ====+ServerSSO responds with:
  
-<code swift+<code json
-// iOS callback handler +
-func handleCallback(url: URL) +  "access_token" "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...", 
-    let components = URLComponents(urlurl, resolvingAgainstBaseURL: false) +  "token_type":    "Bearer", 
-    let params     = components?.queryItems?.reduce(into: [String:String]()) { +  "expires_in":    3600
-        $0[$1.name] = $1.value +  "id_token":      "eyJhbGciOiJSUzI1NiJ9...", 
-    } ?? [:+  "refresh_token""8xLOxBtZp8", 
- +  "scope"        "openid profile email offline_access"
-    guard +
-        let code  = params["code"]+
-        let state = params["state"]+
-        state == AppState.shared.pendingState   // ← CSRF check +
-    else { +
-        // State mismatch → abort → possible CSRF / redirect hijack +
-        handleAuthError("state_mismatch"+
-        return +
-    } +
- +
-    // Retrieve PKCE verifier from ViewModel memory +
-    let codeVerifier = AppState.shared.pendingCodeVerifier +
-    AppState.shared.clearPendingAuth()  // clean up +
- +
-    // Exchange code for tokens +
-    exchangeCodeForTokens(codecodecodeVerifiercodeVerifier)+
 } }
 </code> </code>
  
-----+^ 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 |
  
-==== Step 10 — App Exchanges Code for Tokens (Device → ServerSSOHTTPS====+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)
  
-The mobile app calls the token endpoint **directly** — this is device-to-server, +=== Step 7: Mobile App → ServerA — API Request with JWT ===
-not proxied through any intermediate. The ''code_verifier'' proves this exchange +
-comes from the same party that initiated the authorization request.+
  
-<code +<code> 
-Mobile App → ServerSSO: +GET https://serverA.example.com/api/data 
-  POST https://sso.example.com/token +AuthorizationBearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...
-  Content-Type: application/x-www-form-urlencoded +
- +
-  grant_type=authorization_code +
-  &code=AUTH-CODE-MOBILE-111 +
-  &redirect_uri=myapp%3A%2F%2Fauth%2Fcallback +
-  &client_id=mobile-app-001 +
-  &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk +
- +
-NoteNO client_secret — this is a public client. +
-      The code_verifier takes its place as proof of legitimacy.+
 </code> </code>
  
-**ServerSSO PKCE verification:**+=== Step 8ServerA — JWT Validation (Local, No SSO Call) ===
  
-<code +ServerA validates the JWT **entirely locally**:
-ServerSSO validates+
-  1. code "AUTH-CODE-MOBILE-111" exists and is not expired (< 60s) +
-  2. client_id "mobile-app-001" matches the code's bound client +
-  3. redirect_uri "myapp://auth/callback" matches exactly +
-  4. SHA256(code_verifier) == stored code_challenge   ← PKCE check +
-     SHA256("dBjftJeZ4CVP-mB92K27...") == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" ✅ +
-  5. Invalidate code (single-use) +
-</code>+
  
----- +<code> 
- +1Parse JWT header → extract "kid" "key-id-001" 
-==== Step 11 — ServerSSO Signs and Returns JWT Tokens ==== +2. Look up public key in local JWKS cache by kid 
- +   (If not cachedfetch https://sso.example.com/.well-known/jwks.json and cache) 
-> 🔐 **PRIVATE KEY USED HERE — ServerSSO signs both JWTs with RSA private key** +3Verify RS256 signature using the RSA public key 
- +4. Validate claims
-<code json+   - exp  > now()             → not expired 
-ServerSSO → Mobile App: +   - iss == "https://sso.example.com"  → correct issuer 
-  HTTP 200  Content-Type: application/json +   - aud contains "serverA  → token is intended for this server 
- +5. Extract subrolesemail from payload 
-  { +6. Apply authorization rules 
-    "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...", +7Return API response
-    "token_type":    "Bearer", +
-    "expires_in":    900, +
-    "refresh_token": "rtMOB-abc-refresh-secure-token", +
-    "id_token":      "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...", +
-    "scope":         "openid profile email api:serverA api:serverB" +
-  } +
- +
-JWT header (both tokens): +
-  { +
-    "alg": "RS256", +
-    "kid""key-2024-01",   ← identifies which public key to use for verification +
-    "typ""JWT" +
-  } +
- +
-access_token payload: +
-  { +
-    "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":   "jwt-mobile-unique-id-xyz", +
-    "email": "alice@example.com", +
-    "scope": "openid profile email api:serverA api:serverB", +
-    "roles": ["user"+
-  } +
- +
-id_token payload: +
-  { +
-    "iss":   "https://sso.example.com", +
-    "sub":   "user-uid-456", +
-    "aud":   "mobile-app-001",   ← audience = client_id for id_token +
-    "exp":   1718549400, +
-    "iat":   1718549100+
-    "nonce": "nonce-mob-4f8c",   ← if nonce was included in /authorize +
-    "email": "alice@example.com", +
-    "name":  "Alice Martin" +
-  } +
- +
-All signatures computed with:  🔐 ServerSSO RSA PRIVATE KEY (kid: key-2024-01)+
 </code> </code>
  
-----+=== Step 9: Mobile App → ServerB — API Request with Same JWT ===
  
-==== Step 12 — App Stores Tokens Securely ====+The **same access_token** is reused for ServerB:
  
-<code swift+<code> 
-// iOS — token storage after successful exchange +GET https://serverB.example.com/api/resource 
-func storeTokens(tokenResponseTokenResponse) { +AuthorizationBearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...
-    // access_token → memory ONLY (ViewModel / AppState) +
-    AppState.shared.accessToken   = tokenResponse.accessToken +
-    AppState.shared.accessTokenExp = Date().addingTimeInterval( +
-                                         TimeInterval(tokenResponse.expiresIn)) +
- +
-    // refresh_token → Keychain (persists across app kills) +
-    Keychain.write( +
-        key  "refresh_token", +
-        value: tokenResponse.refreshToken, +
-        accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +
-    ) +
- +
-    // id_token → memory for user info extraction, then discard +
-    let userInfo = parseIdToken(tokenResponse.idToken) +
-    AppState.shared.userEmail = userInfo.email +
-    AppState.shared.userName  = userInfo.name +
-    // id_token NOT persisted — not needed after user info extracted +
-}+
 </code> </code>
  
-<code kotlin> +ServerB performs identical JWT validation (Steps 8.1–8.6— this is the **SSO benefit**: one 
-// Android — token storage +authentication, multiple resource servers, no re-login required.
-fun storeTokens(tokenResponse: TokenResponse) { +
-    // access_token → ViewModel memory +
-    viewModel.accessToken    = tokenResponse.accessToken +
-    viewModel.accessTokenExp = System.currentTimeMillis(+ +
-                               (tokenResponse.expiresIn * 1000L)+
  
-    // refresh_token → EncryptedSharedPreferences (Android Keystore-backed) +==== Scenario 1 — Summary Flow ====
-    val prefs EncryptedSharedPreferences.create( +
-        "auth_prefs", masterKey, context, +
-        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, +
-        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM +
-    ) +
-    prefs.edit().putString("refresh_token", tokenResponse.refreshToken).apply()+
  
-    // id_token → extract user info then discard +<code> 
-    val claims parseIdToken(tokenResponse.idToken+Mobile App          ServerSSO            ServerA           ServerB 
-    viewModel.userEmail claims.email +    │                   │                    │                 │ 
-}+    │─ 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) 
 +    │                   │                    │                 │
 </code> </code>
  
 ---- ----
  
-==== Step 13 — App Calls ServerA with Bearer JWT ====+===== Scenario 2 — Already Authenticated (SSO Token Reuse) ===== {{anchor:scenario2}}
  
-<code +This scenario covers the case where the user **already has a valid session** or 
-Mobile App → ServerA: +the app has **cached refresh_token** and needs a new access_token.
-  GET https://api-a.example.com/api/data +
-  Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0... +
-  Content-Type: application/json +
-</code>+
  
-This is a **direct device-to-server HTTPS call** — no proxy, no intermediate. +==== Sub-Scenario 2a: Valid Access Token Still in Memory ====
-The JWT travels inside the TLS-encrypted HTTP request.+
  
-----+The simplest case: the app already holds a non-expired ``access_token``.
  
-==== Step 14 — ServerA Validates JWT via JWKS (Offline) ====+=== Step 1: Mobile App — Check Token Expiry ===
  
-> 🔓 **PUBLIC KEY USED HERE — ServerA verifies JWT signature using cached RSA public key**+Before making any API call, the app checks locally:
  
-<code python+<code> 
-# ServerA JWT validation middleware +decoded = JWT.decode(access_tokenverify_signature=false // decode without verification 
-def validate_bearer_jwt(request): +now     = current_unix_timestamp()
-    auth = request.headers.get('Authorization'''+
-    if not auth.startswith('Bearer '): +
-        return error_response(401, 'missing_token')+
  
-    token = auth[7:] +if decoded.exp > now + 30  // 30-second buffer 
- +    // Token is still valid — use it directly 
-    # 1. Decode header (no verification) to get kid +    proceed_to_step_2() 
-    header = decode_jwt_header_unverified(token) +else
-    kid    = header.get('kid'+    // Token expired or about to expire — refresh it (Sub-Scenario 2b
- +    refresh_access_token()
-    # 2. Look up public key in JWKS cache +
-    public_key = jwks_cache.get(kid) +
-    if not public_key: +
-        # Unknown kid → refresh JWKS from ServerSSO +
-        jwks_cache.refresh('https://sso.example.com/.well-known/jwks.json'+
-        public_key = jwks_cache.get(kid) +
-        if not public_key: +
-            return error_response(401, 'unknown_signing_key'+
- +
-    # 3. 🔓 Verify RSA signature using public key +
-    try: +
-        payload = jwt.decode( +
-            token, +
-            public_key, +
-            algorithms=['RS256'], +
-            issuer='https://sso.example.com', +
-            audience='https://api-a.example.com', +
-            options={'require': ['exp', 'iat', 'nbf', 'iss', 'aud', 'sub']} +
-        ) +
-    except jwt.ExpiredSignatureError: +
-        return error_response(401, 'token_expired'+
-    except jwt.InvalidSignatureError: +
-        return error_response(401, 'invalid_signature') +
-    except jwt.InvalidAudienceError: +
-        return error_response(403, 'invalid_audience') +
- +
-    # 4. Verify required scope +
-    if 'api:serverA' not in payload.get('scope', '').split(): +
-        return error_response(403, 'insufficient_scope'+
- +
-    # 5. Inject user context — no ServerSSO contact needed +
-    request.user = { +
-        'sub':   payload['sub'], +
-        'email': payload.get('email'), +
-        'roles': payload.get('roles', []) +
-    } +
-    # Continue to handler+
 </code> </code>
  
-**ServerSSO is NOT contacted during JWT validation.** +=== Step 2: Mobile App → ServerA / ServerB — API Request ===
-Validation is offline cryptographic verification using the cached public key.+
  
----- +Identical to Scenario 1 Steps 7–9. The app reuses the cached ``access_token``:
- +
-==== Step 15 — ServerA Returns Protected Resource ==== +
- +
-<code json> +
-ServerA → Mobile App: +
-  HTTP 200  Content-Type: application/json +
- +
-  { +
-    "data":  [...], +
-    "user":  "alice@example.com", +
-    "source": "ServerA" +
-  } +
-</code> +
- +
----- +
- +
-==== Step 16 — App Calls ServerB with Same JWT ==== +
- +
-The **same** access_token is used for ServerB because: +
-  - ''aud'' claim includes ''https://api-b.example.com'' +
-  - ''scope'' includes ''api:serverB'' +
- +
-<code +
-Mobile App → ServerB: +
-  GET https://api-b.example.com/api/records +
-  Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0... +
-</code> +
- +
-> 🔓 **PUBLIC KEY USED HERE — ServerB independently verifies the same JWT** +
-> ServerB has its own JWKS cache. Both servers share the same public key +
-> (from the same JWKS endpoint) but operate completely independently. +
- +
----- +
- +
-==== Step 17 — ServerB Returns Protected Resource ==== +
- +
-<code json> +
-ServerB → Mobile App: +
-  HTTP 200 +
-  { "records": [...], "source": "ServerB"+
-</code> +
- +
-==== Scenario 1 — Complete Flow Summary ====+
  
 <code> <code>
-Mobile App    System Browser    ServerSSO       ServerA        ServerB +GET https://serverA.example.com/api/data 
-    │               │               │               │              │ +Authorization: Bearer <cached_access_token>
-    │ check storage │               │               │              │ +
-    │ (empty)       │               │               │              │ +
-    │ gen PKCE+state│               │               │              │ +
-    │               │               │               │              │ +
-    │─ open browser ▶               │               │              │ +
-    │               │─ GET /authorize ─────────────▶│              │ +
-    │               │  (no sso cookie) │             │              │ +
-    │               │◀─ login page ────│             │              │ +
-    │               │─ POST /login ────▶             │              │ +
-    │               │◀─ sso cookie + 302 myapp://────│             │ +
-    │               │               │               │              │ +
-    │◀─ callback URL (code+state) ──│               │              │ +
-    │  validate state               │               │              │ +
-    │─ POST /token ─────────────────▶               │              │ +
-    │  (code + code_verifier)       │               │              │ +
-    │  🔐 SSO signs JWTs with       │               │              │ +
-    │     private key               │               │              │ +
-    │◀─ {access_token(JWT),         │               │              │ +
-    │    refresh_token, id_token} ──│               │              │ +
-    │  store: access→memory,        │               │              │ +
-    │         refresh→Keychain      │               │              │ +
-    │               │               │               │              │ +
-    │─ GET /api ────────────────────────────────────▶              │ +
-    │  Bearer JWT   │               │  🔓 verify JWT               │ +
-    │               │               │  (public key)  │              │ +
-    │◀─ 200 {data} ─────────────────────────────────│              │ +
-    │               │               │               │              │ +
-    │─ GET /api ────────────────────────────────────────────────▶│ +
-    │  Bearer JWT   │               │               │  🔓 verify JWT│ +
-    │               │               │               │  (public key) │ +
-    │◀─ 200 {data} ──────────────────────────────────────────────│+
 </code> </code>
  
----- +No interaction with ServerSSO is needed
- +
-===== Scenario 2: Authenticated User ===== +
- +
-==== Overview ==== +
- +
-Alice returns to the appThis covers four sub-cases: +
- +
-  * **2A** — App in foreground; access_token in memory and still valid +
-  * **2B** — App was killed and relaunched; refresh_token in Keychain; silent refresh +
-  * **2C** — App in foreground; access_token expired; silent background refresh +
-  * **2D** — Refresh token expired; seamless re-auth via SSO session in system browser+
  
 ---- ----
  
-==== Scenario 2A — App Foreground, Access Token Valid ====+==== Sub-Scenario 2b: Access Token Expired — Refresh Flow ====
  
-<code swift> +The ``access_token`` has expiredbut the app holds a valid ``refresh_token``.
-func getValidToken() -> String? { +
-    guard +
-        let token = AppState.shared.accessToken, +
-        AppState.shared.accessTokenExp > Date().addingTimeInterval(60) // 60s buffer +
-    else { return nil } +
-    return token +
-}+
  
-// Token valid → call APIs directly, no SSO interaction +=== Step 1Mobile App → ServerSSO — Refresh Token Request ===
-if let token getValidToken() { +
-    callServerA(bearerTokentoken) +
-    callServerB(bearerToken: token) +
-+
-</code>+
  
 <code> <code>
-Mobile App    System Browser    ServerSSO       ServerA        ServerB +POST https://sso.example.com/token 
-    │               │               │               │              │ +Content-Type: application/x-www-form-urlencoded
-    │ access_token  │               │               │              │ +
-    │ in memory ✅  │               │               │              │ +
-    │               │               │               │              │ +
-    │─ GET /api ────────────────────────────────────▶              │ +
-    │  Bearer JWT   │               │  🔓 verify JWT               │ +
-    │◀─ 200 {data} ─────────────────────────────────│              │ +
-    │               │               │               │              │ +
-    │─ GET /api ─────────────────────────────────────────────────▶│ +
-    │  Bearer JWT   │               │               │  🔓 verify JWT│ +
-    │◀─ 200 {data} ──────────────────────────────────────────────│ +
-</code>+
  
-Zero SSO contact. ServerA and ServerB verify JWTs offline. +grant_type=refresh_token 
- +&refresh_token=8xLOxBtZp8 
----- +&client_id=mobile-app-client
- +
-==== Scenario 2B — App Killed and Relaunched (Refresh from Keychain) ===+
- +
-When the OS kills the app (memory pressure, user swipe-close), in-memory tokens are lost. +
-The refresh_token survives in the Keychain. +
- +
-=== Step 1 — App Launches, Finds No In-Memory Token === +
- +
-<code swift> +
-func appDidLaunch() { +
-    // Memory is empty — app was killed +
-    AppState.shared.accessToken = nil +
- +
-    // Check Keychain for refresh_token +
-    if let refreshToken = Keychain.read(key: "refresh_token") { +
-        silentRefresh(refreshToken: refreshToken) +
-    } else { +
-        // No refresh token → full login (Scenario 1) +
-        initiateLogin() +
-    } +
-}+
 </code> </code>
  
-=== Step 2 — App Calls ServerSSO /token with Refresh Token (Direct HTTPS) ===+> **Note:** No PKCE is required for the refresh grant — PKCE was only needed 
 +> for the initial authorization code exchange.
  
-<code> +=== Step 2: ServerSSO — Validate Refresh Token ===
-Mobile App → ServerSSO+
-  POST https://sso.example.com/token +
-  Content-Type: application/x-www-form-urlencoded+
  
-  grant_type=refresh_token 
-  &refresh_token=rtMOB-abc-refresh-secure-token 
-  &client_id=mobile-app-001 
- 
-  Note: NO client_secret — public client. 
-        No PKCE needed for refresh — refresh_token itself is the credential. 
-</code> 
- 
-=== Step 3 — ServerSSO Validates Refresh Token === 
- 
-<code> 
 ServerSSO checks: ServerSSO checks:
-  - refresh_token is valid and not revoked +  - Refresh token exists in its database and is not revoked 
-  - refresh_token is not expired (24h TTL+  - Refresh token has not expired (typically 30 days for mobile apps
-  - SSO session associated with this token is still active +  - ``client_id`` matches
-  - client_id matches the one the token was issued to+
  
-If SSO session was terminated (e.g. password changedglobal logout)+If valid, ServerSSO **rotates** the refresh token (issues a new oneinvalidates the old one).
-  → Returns error: invalid_grant +
-  → Mobile app must initiate full login (Scenario 1) +
-</code>+
  
-=== Step — ServerSSO Returns New Tokens === +=== Step 3: ServerSSO → Mobile App — New Tokens ===
- +
-> 🔐 **PRIVATE KEY USED HERE — ServerSSO signs fresh JWTs with RSA private key**+
  
 <code json> <code json>
-ServerSSO → Mobile App: +{ 
-  HTTP 200 +  "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...(new)", 
-  { +  "token_type":    "Bearer", 
-    "access_token":  "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...(new)", +  "expires_in":    3600
-    "token_type":    "Bearer", +  "id_token":      "eyJhbGciOiJSUzI1NiJ9...(new)", 
-    "expires_in":    900+  "refresh_token": "9yMpACuQr9", 
-    "refresh_token": "rtMOB-xyz-new-rotated-token",   ← old refresh_token invalidated +  "scope":         "openid profile email offline_access
-    "scope":         "openid profile email api:serverA api:serverB+}
-  } +
- +
-  Signature: 🔐 ServerSSO RSA PRIVATE KEY+
 </code> </code>
  
-=== Step 5 — App Updates Storage ===+The app stores the **new** ``refresh_token`` and **new** ``access_token``.
  
-<code swift> +=== Step 4Mobile App → ServerA ServerB — API Request ===
-func handleRefreshSuccess(tokenResponseTokenResponse) { +
-    // New access_token → memory +
-    AppState.shared.accessToken    tokenResponse.accessToken +
-    AppState.shared.accessTokenExp Date().addingTimeInterval( +
-                                         TimeInterval(tokenResponse.expiresIn)) +
- +
-    // New refresh_token → Keychain (overwrites old — rotation) +
-    Keychain.write(key: "refresh_token", value: tokenResponse.refreshToken, +
-                   accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) +
-+
-</code>+
  
-=== Step 6 — App Continues with API Calls ===+The app proceeds with the freshly issued ``access_token``.
  
 <code> <code>
-Mobile App    System Browser    ServerSSO       ServerA        ServerB +GET https://serverA.example.com/api/data 
-    │               │               │               │              │ +Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ...(new)
-    │ app relaunched│               │               │              │ +
-    │ memory empty  │               │               │              │ +
-    │ read Keychain ✅              │               │              │ +
-    │─ POST /token  ────────────────▶               │              │ +
-    │  (refresh_token)              │               │              │ +
-    │  🔐 SSO signs new JWT         │               │              │ +
-    │◀─ new {access_token, ...} ────│               │              │ +
-    │  update memory + Keychain     │               │              │ +
-    │               │               │               │              │ +
-    │─ GET /api ────────────────────────────────────▶              │ +
-    │  new Bearer JWT               │  🔓 verify JWT               │ +
-    │◀─ 200 {data} ─────────────────────────────────│              │+
 </code> </code>
  
 ---- ----
  
-==== Scenario 2C — App Foreground, Access Token Expired ====+==== Sub-Scenario 2c: Both Tokens Expired — Re-Authentication ====
  
-The app is running in the foreground but the 15-minute access_token has expired. +If the ``refresh_token`` has also expired (e.g., user was inactive for 30+ days):
-This is handled transparentlytypically by an HTTP interceptor.+
  
-<code swift> +=== Step 1Mobile App — Detect Expired Refresh Token ===
-// iOS — Alamofire/URLSession interceptor pattern +
-class TokenRefreshInterceptorRequestInterceptor {+
  
-    func adapt(_ request: URLRequest, ...) async throws -> URLRequest { +The token endpoint returns:
-        var req = request +
-        let token = await getOrRefreshToken()  // refresh if needed +
-        req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization"+
-        return req +
-    } +
- +
-    func retry(_ request: Request, for session: Session, +
-               dueTo error: Error, completion: @escaping ...) { +
-        if case .responseValidationFailed(let reason) = error, +
-           case .unacceptableStatusCode(let code) = reason, code == 401 { +
-            // Token rejected → refresh and retry once +
-            Task { +
-                await silentRefresh() +
-                completion(.retry) +
-            } +
-        } else { +
-            completion(.doNotRetry) +
-        } +
-    } +
-+
-</code> +
- +
-<code> +
-Mobile App    System Browser    ServerSSO       ServerA +
-    │               │               │               │ +
-    │─ GET /api ────────────────────────────────────▶ +
-    │  expired JWT  │               │  🔓 verify: exp < now +
-    │◀─ 401 ────────────────────────────────────────│ +
-    │  interceptor fires            │               │ +
-    │─ POST /token  ────────────────▶               │ +
-    │  (refresh_token, s background)│               │ +
-    │  🔐 SSO signs new JWT         │               │ +
-    │◀─ new tokens ─────────────────│               │ +
-    │─ GET /api (retry) ────────────────────────────▶ +
-    │  new Bearer JWT               │  🔓 verify JWT ✅ +
-    │◀─ 200 {data} ─────────────────────────────────│ +
-</code> +
- +
-The user experiences no interruption — the retry is transparent. +
- +
----- +
- +
-==== Scenario 2D — Refresh Token Expired, Re-auth via SSO Session ==== +
- +
-The refresh_token has a 24-hour TTL. After 24 hours without app use, it expires. +
-The app must re-authenticate — but if the user has an active SSO session in the +
-system browser (e.g., from using another app earlier that day), the login is seamless. +
- +
-=== Step 1 — Refresh Failsinvalid_grant ===+
  
 <code json> <code json>
-ServerSSO → Mobile App: +HTTP 400 Bad Request 
-  HTTP 400 +{ 
-  "error": "invalid_grant", "error_description": "Refresh token expired or revoked" +  "error": "invalid_grant", 
-</code> +  "error_description": "Refresh token expired or revoked"
- +
-=== Step 2 — App Clears Stale Tokens and Initiates Login === +
- +
-<code swift> +
-func handleRefreshFailure(error: OAuthError) { +
-    // Clear stale tokens +
-    AppState.shared.accessToken = nil +
-    Keychain.delete(key: "refresh_token"+
- +
-    // Initiate full login flow +
-    // BUT: system browser MAY have active SSO session from: +
-    //  - Another app that uses the same SSO +
-    //  - Mobile Safari where user was previously logged in +
-    initiateLogin()+
 } }
 </code> </code>
  
-=== Step 3 — Full Login Flow Initiated ===+=== Step 2: Check for Existing SSO Session (Silent Auth) ===
  
-Same as Scenario 1 Steps 2–10, but with key difference:+Before forcing full re-login, the app attempts **silent authentication**:
  
 <code> <code>
-Mobile App → System Browseropen /authorize (new PKCE + state) +GET https://sso.example.com/authorize 
- +  ?response_type=code 
-System Browser → ServerSSO: +  &client_id=mobile-app-client 
-  GET /authorize +  &redirect_uri=myapp://callback 
-  Cookie: sso_session=SSO-MOB-XYZ   ← SSO session still active in browser! +  &scope=openid profile email offline_access 
- +  &prompt=none                          ← key parameterno UI shown 
-ServerSSOSSO session found and valid +  &code_challenge=<new_code_challenge> 
-  → Skip login page entirely +  &code_challenge_method=S256 
-  → Issue new authorization code immediately +  &state=<new_state>
-  → Redirect back to myapp://auth/callback?code=...+
 </code> </code>
  
-Alice taps the app, the system browser flashes briefly and returns immediately. +  * If ServerSSO has a **valid browser/SSO session** (cookie): it returns new ``code`` immediately — no login prompt → proceed with token exchange (Scenario 1, Steps 5–6) 
-**She never sees a login form.*This is the SSO effect on mobile.+  If no session exists: ServerSSO returns ``error=login_required`` → app must show login UI → full Scenario 1 from Step 2
  
-=== Step 4 — App Receives New Code and Completes Token Exchange === +==== Scenario 2b — Summary Flow ====
- +
-Normal PKCE token exchange (Steps 9–12 from Scenario 1). +
-New access_token and refresh_token issued and stored.+
  
 <code> <code>
-Mobile App    System Browser    ServerSSO       ServerA +Mobile App          ServerSSO            ServerA           ServerB 
-    │               │               │               │ +    │                   │                    │                 │ 
-    │ refresh expired               │               │ +    │  [access_token expired, refresh_token valid]             │ 
-    │ clear Keychain│               │               │ +    │                   │                    │                 │ 
-    │ gen PKCE+state│               │               │ +    │── POST /token ────►│                    │                 │ 
-    │─ open browser ▶               │               │ +    │   grant_type=      │                    │                 │ 
-    │               │─ GET /authorize ─────────────▶ +    │   refresh_token    │                    │                 │ 
-    │               │  Cookie: sso_session ✅        │ +    │                   │ validate RT         │                 │ 
-    │               │  SSO session valid → skip login│ +    │                   │ rotate RT           │                 │ 
-    │               ◀─ 302 myapp://callback?code ──│ +    │◄── new tokens ────│                    │                 │ 
-    │◀─ callback ───│               │               │ +    │    access_token    │                    │                 │ 
-    │  validate state               │               │ +    │    refresh_token   │                    │                 │ 
-    │─ POST /token ─────────────────▶               │ +    │    (rotated)       │                    │                 │ 
-    │  (code + code_verifier)       │                +    │                   │                    │                 │ 
-    │  🔐 SSO signs new JWTs        │               │ +    │── GET /api/data ──────────────────────►│                 │ 
-    │─ new tokens ─────────────────│               │ +    │   Bearer: new JWT  │                    │ validate JWT    │ 
-    │  store: memory + Keychain     │               │ +    │◄── 200 OK ─────────────────────────────│                 │ 
-    │─ GET /api ──────────────────────────────────── +    │                   │                    │                 │ 
-    │  new Bearer JWT               │  🔓 verify JWT │ +    │─ GET /api/res ────────────────────────────────────────►│ 
-    │─ 200 {data} ─────────────────────────────────│+    │   Bearer: new JWT  │                    │                 │ validate JWT 
 +    │◄── 200 OK ──────────────────────────────────────────────│
 </code> </code>
  
 ---- ----
  
-===== Sequence Diagrams =====+===== Token Validation with JWKS (ServerA & ServerB Detail) ===== {{anchor:token-validation}}
  
-==== Full PKCE Flow — Mobile (Scenario 1, all steps) ====+==== JWKS Caching Strategy ====
  
-<code> +Resource servers should **not** fetch JWKS on every requestRecommended strategy:
- 1.  Mobile App   → Mobile App    :  check memory → no access_token +
- 2.  Mobile App   → Keychain      :  read refresh_token → empty (first login) +
- 3.  Mobile App   → Mobile App    :  generate code_verifier, code_challenge (S256), state +
- 4.  Mobile App   → System Browser:  open ASWebAuthSession / Chrome Custom Tab +
- 5.  Sys Browser  → ServerSSO     :  GET /authorize?client_id=mobile-app-001&code_challenge=... +
- 6.  ServerSSO    → Sys Browser   :  200 login page  (no sso_session cookie) +
- 7.  Alice        → Sys Browser   :  type credentials +
- 8.  Sys Browser  → ServerSSO     :  POST /login (username + password) +
- 9.  ServerSSO    → ServerSSO     :  validate credentials; create SSO session; bind code→challenge +
-10.  ServerSSO    → Sys Browser   :  Set-Cookie: sso_session + 302 myapp://callback?code=CODE +
-11.  Sys Browser  → Mobile App    :  deliver callback URL (ASWebAuthSession completion / Intent) +
-12.  Mobile App   → Mobile App    :  validate state == pending_state  (CSRF check) +
-13.  Mobile App   → Mobile App    :  retrieve code_verifier from ViewModel memory +
-14.  Mobile App   → ServerSSO     :  POST /token (code=CODE, code_verifier=VERIFIER, client_id) +
-15.  ServerSSO    → ServerSSO     :  verify SHA256(code_verifier) == stored code_challenge ✅ +
-16.  ServerSSO    → ServerSSO     :  🔐 SIGN access_token JWT with RSA PRIVATE KEY +
-17.  ServerSSO    → ServerSSO     :  🔐 SIGN id_token JWT with RSA PRIVATE KEY +
-18.  ServerSSO    → Mobile App    :  { access_token, id_token, refresh_token, expires_in } +
-19.  Mobile App   → Mobile App    :  store access_token → memory +
-20.  Mobile App   → Keychain      :  store refresh_token → secure storage +
-21.  Mobile App   → ServerA       :  GET /api  Authorization: Bearer access_token +
-22.  ServerA      → JWKS Cache    :  lookup public key for kid="key-2024-01" +
-                                      (if not cached: GET /sso.example.com/.well-known/jwks.json) +
-23.  ServerA      → ServerA       :  🔓 VERIFY JWT signature with RSA PUBLIC KEY +
-24.  ServerA      → ServerA       :  validate iss, aud, exp, scope = api:serverA +
-25.  ServerA      → Mobile App    :  200 { data } +
-26.  Mobile App   → ServerB       :  GET /api  Authorization: Bearer access_token (same token) +
-27.  ServerB      → JWKS Cache    :  lookup public key (own cache, may already have it) +
-28.  ServerB      → ServerB       :  🔓 VERIFY JWT signature with RSA PUBLIC KEY +
-29.  ServerB      → ServerB       :  validate iss, aud, exp, scope = api:serverB +
-30 ServerB      → Mobile App     200 { data }+
  
-KEY USAGE SUMMARY: +^ Condition                          ^ Action                                               ^ 
-  Steps 16–17  →  🔐 ServerSSO RSA PRIVATE KEY   (signing) +| Startup                            | Fetch JWKS and cache in memory                       | 
-  Steps 23, 28 →  🔓 RSA PUBLIC KEY from JWKS     (verification by ServerA / ServerB+| JWT ``kid`` found in cache         | Use cached key — no network call                     | 
-</code>+| 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) ====
  
-===== API Contracts =====+<code> 
 +function validateJWT(token, audience):
  
-==== ServerSSO Endpoints ====+  // 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")
  
-=== GET /.well-known/openid-configuration ===+  // 3Verify signature 
 +  message  header_b64 + "." + payload_b64 
 +  if NOT RSA_SHA256_verify(message, signature_b64, key.publicKey): 
 +    throw InvalidTokenError("Bad signature")
  
-<code json> +  // 4Validate standard claims 
-+  if payload.exp < now()
-  "issuer":                                "https://sso.example.com", +    throw InvalidTokenError("Token expired") 
-  "authorization_endpoint":               "https://sso.example.com/authorize", +  if payload.iss != "https://sso.example.com": 
-  "token_endpoint"                       "https://sso.example.com/token", +    throw InvalidTokenError("Wrong issuer") 
-  "userinfo_endpoint":                     "https://sso.example.com/userinfo", +  if audience NOT IN payload.aud
-  "jwks_uri":                              "https://sso.example.com/.well-known/jwks.json", +    throw InvalidTokenError("Wrong audience") 
-  "end_session_endpoint":                  "https://sso.example.com/logout", +  if payload.nbf is set AND payload.nbf > now()
-  "response_types_supported":              ["code"], +    throw InvalidTokenError("Token not yet valid")
-  "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"], +
-  "grant_types_supported":                 ["authorization_code", "refresh_token"+
-+
-</code>+
  
-=== GET /authorize — Parameters === +  // 5Extract and return user context 
- +  return { userIdpayload.sub, rolespayload.roles, email: payload.email }
-^ Parameter             ^ Required ^ Value / Example                          ^ Notes                           ^ +
-| response_type         | ✅       | ''code''                                 | Always ''code''                 | +
-| client_id             | ✅       | ''mobile-app-001''                       | Registered mobile client        | +
-| redirect_uri          | ✅       | ''myapp://auth/callback''                | Exact match required            | +
-| scope                 | ✅       | ''openid profile email api:serverA api:serverB'' | Space-separated      | +
-| state                 | ✅       | Random 128-bit value                     | CSRF protection                 | +
-| code_challenge        | ✅       | ''base64url(SHA256(code_verifier))''     | PKCE required                   | +
-| code_challenge_method | ✅       | ''S256''                                 | ''plain'' not accepted          | +
-| nonce                 | ⚠️ rec  | Random value                             | Replay protection on id_token   +
- +
-=== POST /token — Authorization Code === +
- +
-^ Parameter      ^ Required ^ Value                                     ^ +
-| grant_type     | ✅       | ''authorization_code''                    | +
-| code           | ✅       | Authorization code from /authorize        | +
-| redirect_uri   | ✅       | Same URI used in /authorize               | +
-| client_id      | ✅       | ''mobile-app-001''                        | +
-| code_verifier  | ✅       | Original PKCE verifier (not the hash)     | +
- +
-No ''client_secret'' — public client+
- +
-=== POST /token — Refresh Token === +
- +
-^ Parameter      ^ Required ^ Value                                     ^ +
-| grant_type     | ✅       | ''refresh_token''                         | +
-| refresh_token  | ✅       | Stored refresh token                      | +
-| client_id      | ✅       | ''mobile-app-001''                        | +
- +
-=== Error Responses === +
- +
-^ HTTP ^ error                  ^ Description                                       ^ +
-| 400  | invalid_request        | Missing parameter or malformed request            | +
-| 400  | invalid_grant          | Code or refresh token invalid / expired           | +
-| 400  | invalid_client         | Unknown client_id                                 | +
-| 400  | invalid_pkce           | code_verifier does not match code_challenge       | +
-| 400  | unsupported_grant_type | grant_type not recognized                         | +
- +
-==== ServerA & ServerB — Protected Endpoint Contract ==== +
- +
-**Request:** +
-<code> +
-GET /api/data HTTP/1.+
-Hostapi-a.example.com +
-Authorization: Bearer {access_token_jwt}+
 </code> </code>
  
-**Error Responses:**+==== Key Rotation ====
  
-^ HTTP ^ WWW-Authenticate header                ^ Meaning                              ^ +When ServerSSO rotates its signing key:
-| 401  | ''Bearer error="missing_token"''       | No Authorization header present      | +
-| 401  | ''Bearer error="invalid_token"''       | JWT malformed or signature invalid   | +
-| 401  | ''Bearer error="token_expired"''       | JWT exp claim in the past            | +
-| 401  | ''Bearer error="unknown_key"''         | kid not found in JWKS                | +
-| 403  | ''Bearer error="insufficient_scope"''  | Required scope missing in JWT        | +
-| 403  | ''Bearer error="invalid_audience"''    | aud claim does not match this server |+
  
----- +  New JWTs are signed with the **new** key (new ``kid``
- +  - Old JWTs remain valid until they expire (old key stays in JWKS temporarily
-===== Security Considerations ===== +  - ServerA/ServerB fetch the new JWKS when they encounter an unknown ``kid`` 
- +  No downtime or coordination required
-==== Mobile-Specific Threats and Mitigations ==== +
- +
-^ Threat                           ^ Attack Vector                             ^ Mitigation                                                   ^ +
-| Code interception (redirect hijack) | Malicious app registers same URI scheme | PKCE: stolen code useless without code_verifier; use App Links over custom schemes | +
-| Access token theft               | Memory scraping on rooted device          | Short TTL (15 min); in-memory only; no disk persistence       | +
-| Refresh token theft              | App data backup / ADB extraction          | Keychain (iOS) / Keystore (Android); ''ThisDeviceOnly''; disable backup | +
-| WebView credential interception  | App reads WebView DOM / network           | Use system browser (ASWebAuth / CCT) — RFC 8252 mandatory     | +
-| Man-in-the-middle                | Rogue WiFi / SSL interception             | Certificate pinning (optional); TLS 1.2+; HSTS                | +
-| Replay attack                    | Reuse captured JWT                        | Short TTL; ''jti'' claim; ''exp'' check at every validation   | +
-| Forged JWT                       | Attacker crafts fake JWT                  | RSA RS256 signature; ServerA/B verify against JWKS public key +
-| Jailbreak / root                 | OS-level keystore bypass                  | Runtime jailbreak detection; ''SecureEnclave''-bound keys     | +
-| SSO session hijack               | Steal sso_session cookie from browser     | HttpOnly; Secure; system browser process isolation            | +
-| client_secret exposure           | App binary decompilation                  | Public client — no client_secret; PKCE used instead           | +
- +
-==== PKCE Security Properties ==== +
- +
-<code> +
-Without PKCE (Authorization Code only — Public Client)+
- +
-  Attacker registers malicious app with same URI scheme: +
-    myapp://auth/callback → EVIL-APP intercepts code +
- +
-  EVIL-APP calls POST /token with stolen code: +
-    grant_type=authorization_code +
-    &code=STOLEN-CODE +
-    &client_id=mobile-app-001  ← attacker knows this (it's public) +
-    → ServerSSO issues tokens ← ATTACK SUCCEEDS ❌ +
- +
-With PKCE (S256): +
- +
-  EVIL-APP intercepts code but does NOT have code_verifier (in legitimate app memory)+
-    POST /token with STOLEN-CODE but WRONG code_verifier +
-    → ServerSSO: SHA256(wrong_verifier) ≠ stored code_challenge +
-    → Error: invalid_grant ← ATTACK BLOCKED ✅ +
- +
-  The code_verifier never traverses the redirect channel. +
-  It stays in the legitimate app's memory and is only sent directly to +
-  the token endpoint over TLS. +
-</code> +
- +
-==== Security Checklist ==== +
- +
-^ # ^ Control                             ^ Description                                                       ^ +
-| 1 | PKCE S256 enforced                  | Server rejects requests without code_challenge or with ''plain''+
-| 2 | System browser only                 | No WebView for authentication (RFC 8252)                         | +
-| 3 | state validation                    | Validate state == pending_state on every callback                | +
-| 4 | Redirect URI exact match            | SSO refuses partial/wildcard redirect URIs                       | +
-| 5 | access_token in memory only         | Never persist to disk; lost on app kill is acceptable            | +
-| 6 | refresh_token in secure storage     | Keychain (iOS) / Keystore (Android); ThisDeviceOnly              | +
-| 7 | Short access_token TTL              | 15 minutes maximum                                               | +
-| 8 | Refresh token rotation              | New token per use; old token invalidated server-side             | +
-| 9 | Audience validation                 ServerA checks aud; ServerB checks aud independently            | +
-| 10| Scope validation                    | Each server checks required scope in JWT                         | +
-| 11| JWKS caching with TTL               | 1 hour; refetch only on unknown kid                              | +
-| 12| Certificate pinning (optional)      | Pin SSO and API server certificates for high-security apps       | +
-| 13| Biometric gate for refresh_token    | Require biometric before reading Keychain (optional, high-risk) | +
-| 14| Jailbreak detection                 | Warn or block on rooted/jailbroken devices                       | +
-| 15| No ''noneOf'' algorithm             | Server rejects JWTs with ''alg: none''                           |+
  
 ---- ----
  
-===== Configuration Reference =====+===== Security Notes ===== {{anchor:security-notes}}
  
-==== ServerSSO — Mobile Client Registration ====+==== Token Storage on Mobile ====
  
-<code yaml> +^ Token           ^ Recommended Storage                                      ^ Never store in         ^ 
-clients: +| access_token    | In-memory only (lost on app restart)                     | SharedPreferences (Android) / UserDefaults (iOS) without encryption | 
-  - client_id:     "mobile-app-001" +| refresh_token   | iOS Keychain / Android Keystore (hardware-backed)         | Plain files, AsyncStorage (unencrypted)                             | 
-    client_type:   public              # No client_secret +| id_token        | In-memory only (only needed at login)                     | Anywhere persistent                                                  |
-    display_name:  "My Mobile App"+
  
-    redirect_uris: +==== PKCE Requirements ====
-      - "myapp://auth/callback"         # Custom URI scheme +
-      - "https://app.example.com/callback"  # Universal Link / App Link (preferred)+
  
-    post_logout_redirect_uris+  * ``code_verifier``must be 43–128 characters, using ``[A-Z a-z 0-9 - . _ ~]`` 
-      "myapp://auth/logout"+  * 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
  
-    allowed_scopes: +==== Redirect URI Security ====
-      - openid +
-      - profile +
-      - email +
-      - api:serverA +
-      - api:serverB+
  
-    token_endpoint_auth_method:   none   # Public client — no secret +  * Register **exact** redirect URIs in ServerSSO (no wildcards) 
-    code_challenge_method       S256   # PKCE required; plain rejected +  * Use **private-use URI schemes** (e.g., ``com.example.myapp://callback``) or HTTPS with Universal Links (iOS) / App Links (Android) 
-    pkce_required:                true+  * Always validate ``state`` parameter to prevent CSRF
  
-    access_token_ttl:   900      # 15 minutes +==== Access Token Lifetime Recommendations ====
-    refresh_token_ttl:  86400    # 24 hours +
-    id_token_ttl:       300      # 5 minutes +
-    auth_code_ttl:      60       # 60 seconds (single use) +
-</code>+
  
-==== ServerA JWT Validation ====+^ 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                        |
  
-<code yaml> +==== HTTPS Enforcement ====
-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 +
-  required_scope: "api:serverA" +
-  clock_skew:     30 +
-  reject_alg_none: true +
-</code>+
  
-==== ServerB JWT Validation ==== +All endpoints (authorizationtokenuserinfoJWKSand resource APIs) **must** use HTTPS
- +Certificate pinning is recommended for the mobile app in high-security environments.
-<code yaml> +
-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 +
-  reject_alg_none: true +
-</code> +
- +
-==== Mobile App — iOS Configuration ==== +
- +
-<code swift> +
-// OIDCConfig.swift +
-struct OIDCConfig { +
-    static let issuer       = URL(string: "https://sso.example.com")! +
-    static let clientID     = "mobile-app-001" +
-    static let redirectURI  = URL(string: "myapp://auth/callback")! +
-    static let scopes       = ["openid""profile""email""api:serverA""api:serverB"+
- +
-    static let serverABaseURL = "https://api-a.example.com" +
-    static let serverBBaseURL = "https://api-b.example.com" +
- +
-    // Keychain +
-    static let keychainService    = "com.example.myapp.auth" +
-    static let refreshTokenKey    = "refresh_token" +
-    static let keychainAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +
-+
-</code> +
- +
-==== Mobile App — Android Configuration ==== +
- +
-<code kotlin> +
-// AuthConfig.kt +
-object AuthConfig { +
-    const val ISSUER       = "https://sso.example.com" +
-    const val CLIENT_ID    = "mobile-app-001" +
-    const val REDIRECT_URI = "myapp://auth/callback" +
-    val SCOPES             = listOf("openid", "profile", "email", "api:serverA", "api:serverB"+
- +
-    const val SERVER_A_BASE_URL = "https://api-a.example.com" +
-    const val SERVER_B_BASE_URL = "https://api-b.example.com" +
- +
-    // EncryptedSharedPreferences key +
-    const val PREFS_FILE         = "auth_prefs" +
-    const val REFRESH_TOKEN_KEY  = "refresh_token" +
-+
-</code>+
  
 ---- ----
  
-===== Quick Reference Card =====+===== Glossary ===== {{anchor:glossary}}
  
-<code> +^ Term             ^ Definition                                                                  ^ 
-╔══════════════════════════════════════════════════════════════════════════╗ +OIDC             | OpenID Connect — identity layer on top of OAuth 2.0                         | 
-║            OIDC MOBILE APP + PKCE — QUICK REFERENCE                    ║ +| 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  | 
-║ RSA KEY USAGE                                                           ║ +| JWT              | JSON Web Token — RFC 7519; compact, URL-safe token format                    | 
-║  🔐 PRIVATE KEY → ServerSSO ONLY                                       ║ +| JWKS             | JSON Web Key Set — RFC 7517; public key set exposed by the auth server        | 
-║     Signs: access_token, id_token                                      ║ +access_token     | Short-lived credential presented to resource servers                          | 
-║     Location: HSM / KMS — never leaves ServerSSO                       ║ +id_token         | JWT containing user identity claims; consumed by the client only              | 
-║     Steps: Scenario 1 Step 11 (sign), Scenario 2B Step 4 (sign)        ║ +refresh_token    | Long-lived credential used to obtain new access tokens                        | 
-║                                                                         ║ +| code_verifier    | Random secret generated by the client for PKCE                                | 
-║  🔓 PUBLIC KEY → ServerA + ServerB (fetched from JWKS, cached 1hr    ║ +| code_challenge   | SHA-256 hash of code_verifier, sent in the authorization request              | 
-║     Verifies: every incoming Bearer JWT                                 ║ +| authorization code | Short-lived, single-use code exchanged for tokens                          | 
-║     Fetched: GET /sso.example.com/.well-known/jwks.json                ║ +| kid              | Key ID — identifies which key in the JWKS was used to sign a JWT              | 
-║     Steps: Scenario 1 Step 14 (ServerA), Step 16 (ServerB)             ║ +| 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 | 
-║  Mobile App: holds NO RSA key — forwards JWT as Bearer token only      ║ +| RS256            | RSA Signature with SHA-256; asymmetric signing algorithm for JWTs             |
-╠══════════════════════════════════════════════════════════════════════════╣ +
-║ PKCE                                                                    ║ +
-║  code_verifier  = base64url(SecureRandom(32 bytes))  ← memory only     ║ +
-║  code_challenge = base64url(SHA256(code_verifier))   ← sent to SSO     ║ +
-║  method = S256  (plain rejected)                                        ║ +
-║  Verifier sent to /token endpoint — never in redirect URI              ║ +
-╠══════════════════════════════════════════════════════════════════════════╣ +
-║ CLIENT TYPE: PUBLIC — no client_secret                                  ║ +
-║  PKCE replaces client_secret for public clients                        ║ +
-║  /token call: grant_type + code + code_verifier + client_id only       ║ +
-╠══════════════════════════════════════════════════════════════════════════╣ +
-║ TOKEN STORAGE                                                           ║ +
-║  access_token  → Memory ONLY (ViewModel / AppState)                    ║ +
-║  refresh_token → iOS Keychain / Android EncryptedSharedPrefs           ║ +
-║  id_token      → Memory (extract user info once then discard)          ║ +
-║  Browser holds → sso_session cookie (system browser, not app)          ║ +
-╠══════════════════════════════════════════════════════════════════════════╣ +
-║ SYSTEM BROWSER (RFC 8252)                                               ║ +
-║  iOS:     ASWebAuthenticationSession                                    ║ +
-║  Android: Chrome Custom Tab                                             ║ +
-║  Shares SSO session with other apps using same system browser           ║ +
-║  App cannot read credentials — process isolation                        ║ +
-╠══════════════════════════════════════════════════════════════════════════╣ +
-║ SCENARIO DECISION TREE                                                  ║ +
-║  access_token in memory + not expired  →  2A: use directly             ║ +
-║  access_token expired + refresh in Keychain  →  2B/2C: silent refresh  ║ +
-║  refresh_token expired / invalid_grant →  2D: re-auth via system browser║ +
-║  No tokens anywhere  →  Scenario 1: full login                          ║ +
-╠══════════════════════════════════════════════════════════════════════════╣ +
-║ TOKEN LIFETIMES                                                          ║ +
-║  access_token   15 min   (memory; lost on app kill → refresh)           ║ +
-║  id_token        5 min   (memory; discard after user info extracted)     +
-║  refresh_token  24 hrs   (Keychain; rotated on each use)                ║ +
-║  auth code      60 sec   (single use; PKCE-bound)                       ║ +
-║  SSO session     8 hrs   (system browser cookie store)                  ║ +
-╠══════════════════════════════════════════════════════════════════════════╣ +
-║ KEY SECURITY PROPERTIES                                                  ║ +
-║  Stolen auth code  →  useless (PKCE: no verifier = no tokens)           ║ +
-║  Stolen access JWT →  expires in 15 min; no refresh issued with it      ║ +
-║  Stolen refresh    →  rotation detects reuse; device binding optional   ║ +
-║  Forged JWT        →  impossible without RSA private key (ServerSSO)    ║ +
-║  No client_secret  →  none to steal from binary / decompilation         ║ +
-╚══════════════════════════════════════════════════════════════════════════╝ +
-</code>+
  
 ---- ----
  
-//Document maintained byPlatform Security Team// +//Document maintained by the Platform Security Team. Last updated2026-06-16//
-//FormatDokuWiki// +
-//Standard: OpenID Connect Core 1.0 · RFC 8252 (OAuth 2.0 for Native Apps) · RFC 7636 (PKCE) · RFC 7519 (JWT) · RFC 7517 (JWK)//+
security/sso-mobile.1781593028.txt.gz · Last modified: by phong2018