| 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>
┌─────────────────────────────────────────────────────────────────────────────┐
│ RSA KEY PAIR — MOBILE SYSTEM │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ ServerSSO │ │
│ │ │ │
│ │ 🔐 PRIVATE KEY (RSA-2048 or RSA-4096) │ │
│ │ kid: “key-2024-01” │ │
│ │ Location: HSM / Cloud KMS / encrypted key store │ │
│ │ Operation: SIGN JWT payloads (RS256) │ │
│ │ Tokens signed: access_token, id_token │ │
│ │ NEVER transmitted — never leaves this server │ │
│ │ │ │
│ │ 🔓 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>
==== Key Usage Master Table ====
^ 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):
──────────────────────────────────────
1. Assemble 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 <JWT> to 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 =====
==== PKCE Generation (Mobile-Specific) ====
On mobile, PKCE values must be generated using the platform's cryptographically
secure random number generator — never Math.random() or equivalent:
<code swift>
iOS (Swift) — PKCE generation
import CryptoKit
import Foundation
func generateCodeVerifier() → String {
var buffer = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
return Data(buffer).base64URLEncodedString()
}
func generateCodeChallenge(from verifier: String) → String {
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>
Android (Kotlin) — PKCE generation
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
fun generateCodeVerifier(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
}
fun generateCodeChallenge(verifier: String): String {
val bytes = verifier.toByteArray(Charsets.US_ASCII)
val digest = MessageDigest.getInstance(“SHA-256”).digest(bytes)
return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
}
Store codeVerifier in ViewModel memory — ephemeral per login attempt
val codeVerifier = generateCodeVerifier()
val codeChallenge = generateCodeChallenge(codeVerifier)
</code>
==== System Browser Requirement (RFC 8252) ====
Native apps MUST NOT use an embedded WebView for the authorization flow.
<code>
❌ FORBIDDEN — Embedded WebView (UIWebView / WKWebView / android.webkit.WebView)
Reason: The 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>
==== Custom URI Redirect Scheme ====
After login, ServerSSO redirects the browser to a URI that opens the mobile app:
<code>
Option 1 — Custom URI Scheme (simpler but hijackable on Android):
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>
—-
===== Token Storage on Mobile =====
<code>
┌──────────────────────────────────────────────────────────────────────┐
│ TOKEN STORAGE STRATEGY — MOBILE │
│ │
│ Token │ Storage Location │ Survives App Kill? │
│ ─────────────────────────────────────────────────────────────────── │
│ access_token │ In-memory (ViewModel/ │ ❌ No — must refresh │
│ │ AppState) │ or re-login │
│ │ │ │
│ id_token │ In-memory (used once to │ ❌ No — not needed │
│ │ extract user identity) │ after first login │
│ │ │ │
│ refresh_token │ iOS: Keychain Services │ ✅ Yes (Keychain │
│ │ Android: EncryptedSharedPrefs / Keystore │
│ │ Flutter: flutter_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 Starts, Checks 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 2A: token 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>
What happens: No access_token in memory, no refresh_token in Keychain.
Full login flow required.
—-
==== Step 2 — App Generates PKCE Values and state ====
<code>
Mobile App generates (in memory, NOT persisted to Keychain):
code_verifier = base64url(SecureRandom(32 bytes))
= “dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk”
code_challenge = base64url(SHA256(code_verifier))
= “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 ====
<code
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:
<code swift>
iOS
let session = ASWebAuthenticationSession(
url: authorizeURL,
callbackURLScheme: “myapp”
) { callbackURL, error in
guard let url = callbackURL else { return }
handleCallback(url: url)
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false share SSO session
session.start()
</code>
<code kotlin>
Android
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(context, Uri.parse(authorizeURL))
App re-opened via AndroidManifest intent-filter for myapp:auth/callback
</code>
> prefersEphemeralWebBrowserSession = false (iOS): allows the system browser to
> share its cookie jar with Safari, enabling SSO if the user already has an active
> SSO session from another app or from mobile Safari.
—-
==== Step 4 — System Browser Navigates to ServerSSO ====
<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>
ServerSSO checks: no SSO session cookie → must authenticate → show login page.
—-
==== Step 5 — ServerSSO Presents Login Page in System Browser ====
<code>
ServerSSO → System Browser:
HTTP 200 Content-Type: text/html
<html>
<body>
<form action=“https://sso.example.com/login” method=“POST”>
<input name=“username” type=“text” placeholder=“Email” />
<input name=“password” type=“password” placeholder=“Password” />
<button type=“submit”>Sign In</button>
</form>
</body>
</html>
</code>
The mobile app process cannot see this page or the credentials Alice types.
The system browser has a separate process with its own memory space.
—-
==== Step 6 — Alice Submits Credentials in System Browser ====
<code>
System Browser → ServerSSO:
POST https://sso.example.com/login
Content-Type: application/x-www-form-urlencoded
username=alice%40example.com&password=secret123
</code>
ServerSSO processing:
- Validates credentials against user store
- If MFA configured → challenges second factor in same browser session
- On success: creates SSO session, stores code_challenge binding
—-
==== Step 7 — ServerSSO Sets SSO Cookie and Redirects with Authorization Code ====
<code>
ServerSSO → System Browser:
HTTP 302
Set-Cookie: sso_session=SSO-MOB-XYZ; Domain=sso.example.com;
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>
The SSO session cookie is set on the system browser, not on the app.
This enables future SSO for other apps that also use the system browser.
—-
==== Step 8 — System Browser Follows Redirect, OS Hands Control to App ====
<code>
System Browser attempts to navigate to: myapp:auth/callback?code=…&state=…
iOS: ASWebAuthenticationSession detects the callback scheme “myapp”
→ calls completion handler with callbackURL
→ system browser session ends automatically
Android: OS resolves intent for scheme “myapp” → Android routes to app's
Activity registered in AndroidManifest.xml for this URI scheme
→ Activity.onNewIntent(intent) fires with the callback URI
</code>
The authorization code is now inside the mobile app. It is not a token —
it must be exchanged immediately (60 second window).
—-
==== Step 9 — App Extracts and Validates code and state ====
<code swift>
iOS callback handler
func handleCallback(url: URL) {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let params = components?.queryItems?.reduce(into: [String:String]()) {
$0[$1.name] = $1.value
} ?? [:]
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(code: code, codeVerifier: codeVerifier)
}
</code>
—-
==== Step 10 — App Exchanges Code for Tokens (Device → ServerSSO, HTTPS) ====
The mobile app calls the token endpoint directly — this is device-to-server,
not proxied through any intermediate. The code_verifier proves this exchange
comes from the same party that initiated the authorization request.
<code
Mobile App → ServerSSO:
POST https://sso.example.com/token
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
Note: NO client_secret — this is a public client.
The code_verifier takes its place as proof of legitimacy.
</code>
ServerSSO PKCE verification:
<code
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>
—-
==== Step 11 — ServerSSO Signs and Returns JWT Tokens ====
> 🔐 PRIVATE KEY USED HERE — ServerSSO signs both JWTs with RSA private key
<code json>
ServerSSO → Mobile App:
HTTP 200 Content-Type: application/json
{
“access_token”: “eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0…”,
“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>
—-
==== Step 12 — App Stores Tokens Securely ====
<code swift>
iOS — token storage after successful exchange
func storeTokens(tokenResponse: TokenResponse) {
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 kotlin>
Android — token storage
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)
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
val claims = parseIdToken(tokenResponse.idToken)
viewModel.userEmail = claims.email
}
</code>
—-
==== Step 13 — App Calls ServerA with Bearer JWT ====
<code
Mobile App → ServerA:
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.
The JWT travels inside the TLS-encrypted HTTP request.
—-
==== Step 14 — ServerA Validates JWT via JWKS (Offline) ====
> 🔓 PUBLIC KEY USED HERE — ServerA verifies JWT signature using cached RSA public key
<code python>
# ServerA JWT validation middleware
def validate_bearer_jwt(request):
auth = request.headers.get('Authorization', )
if not auth.startswith('Bearer '):
return error_response(401, 'missing_token')
token = auth[7:]
# 1. Decode header (no verification) to get kid
header = decode_jwt_header_unverified(token)
kid = header.get('kid')
# 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>
ServerSSO is NOT contacted during JWT validation.
Validation is offline cryptographic verification using the cached public key.
—-
==== 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>
Mobile App System Browser ServerSSO ServerA ServerB
│ │ │ │ │
│ 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>
—-
===== Scenario 2: Authenticated User =====
==== Overview ====
Alice returns to the app. This 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 ====
<code swift>
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
if let token = getValidToken() {
callServerA(bearerToken: token)
callServerB(bearerToken: token)
}
</code>
<code>
Mobile App System Browser ServerSSO ServerA ServerB
│ │ │ │ │
│ 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.
—-
==== 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>
=== Step 2 — App Calls ServerSSO /token with Refresh Token (Direct HTTPS) ===
<code>
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:
- refresh_token is valid and not revoked
- refresh_token is not expired (24h TTL)
- SSO session associated with this token is still active
- client_id matches the one the token was issued to
If SSO session was terminated (e.g. password changed, global logout):
→ Returns error: invalid_grant
→ Mobile app must initiate full login (Scenario 1)
</code>
=== Step 4 — ServerSSO Returns New Tokens ===
> 🔐 PRIVATE KEY USED HERE — ServerSSO signs fresh JWTs with RSA private key
<code json>
ServerSSO → Mobile App:
HTTP 200
{
“access_token”: “eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0…(new)”,
“token_type”: “Bearer”,
“expires_in”: 900,
“refresh_token”: “rtMOB-xyz-new-rotated-token”, ← old refresh_token invalidated
“scope”: “openid profile email api:serverA api:serverB”
}
Signature: 🔐 ServerSSO RSA PRIVATE KEY
</code>
=== Step 5 — App Updates Storage ===
<code swift>
func handleRefreshSuccess(tokenResponse: TokenResponse) {
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 ===
<code>
Mobile App System Browser ServerSSO ServerA ServerB
│ │ │ │ │
│ 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>
—-
==== Scenario 2C — App Foreground, Access Token Expired ====
The app is running in the foreground but the 15-minute access_token has expired.
This is handled transparently, typically by an HTTP interceptor.
<code swift>
iOS — Alamofire/URLSession interceptor pattern
class TokenRefreshInterceptor: RequestInterceptor {
func adapt(_ request: URLRequest, …) async throws → URLRequest {
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 Fails: invalid_grant ===
<code json>
ServerSSO → Mobile App:
HTTP 400
{ “error”: “invalid_grant”, “error_description”: “Refresh token expired or revoked” }
</code>
=== 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>
=== Step 3 — Full Login Flow Initiated ===
Same as Scenario 1 Steps 2–10, but with a key difference:
<code>
Mobile App → System Browser: open /authorize (new PKCE + state)
System Browser → ServerSSO:
GET /authorize
Cookie: sso_session=SSO-MOB-XYZ ← SSO session still active in browser!
ServerSSO: SSO session found and valid
→ Skip login page entirely
→ Issue new authorization code immediately
→ Redirect back to myapp:auth/callback?code=…
</code>
Alice taps the app, the system browser flashes briefly and returns immediately.
She never sees a login form. This is the SSO effect on mobile.
=== Step 4 — App Receives New Code and Completes Token Exchange ===
Normal PKCE token exchange (Steps 9–12 from Scenario 1).
New access_token and refresh_token issued and stored.
<code>
Mobile App System Browser ServerSSO ServerA
│ │ │ │
│ refresh expired │ │
│ clear Keychain│ │ │
│ gen PKCE+state│ │ │
│─ open browser ▶ │ │
│ │─ GET /authorize ─────────────▶
│ │ Cookie: sso_session ✅ │
│ │ SSO session valid → skip login│
│ │◀─ 302 myapp:callback?code ──│
│◀─ callback ───│ │ │
│ validate state │ │
│─ POST /token ─────────────────▶ │
│ (code + code_verifier) │ │
│ 🔐 SSO signs new JWTs │ │
│◀─ new tokens ─────────────────│ │
│ store: memory + Keychain │ │
│─ GET /api ────────────────────────────────────▶
│ new Bearer JWT │ 🔓 verify JWT │
│◀─ 200 {data} ─────────────────────────────────│
</code>
—-
===== Sequence Diagrams =====
==== Full PKCE Flow — Mobile (Scenario 1, all steps) ====
<code>
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:
Steps 16–17 → 🔐 ServerSSO RSA PRIVATE KEY (signing)
Steps 23, 28 → 🔓 RSA PUBLIC KEY from JWKS (verification by ServerA / ServerB)
</code>
—-
===== API Contracts =====
==== ServerSSO Endpoints ====
=== GET /.well-known/openid-configuration ===
<code json>
{
“issuer”: “https://sso.example.com”,
“authorization_endpoint”: “https://sso.example.com/authorize”,
“token_endpoint”: “https://sso.example.com/token”,
“userinfo_endpoint”: “https://sso.example.com/userinfo”,
“jwks_uri”: “https://sso.example.com/.well-known/jwks.json”,
“end_session_endpoint”: “https://sso.example.com/logout”,
“response_types_supported”: [“code”],
“subject_types_supported”: [“public”],
“id_token_signing_alg_values_supported”: [“RS256”],
“scopes_supported”: [“openid”,“profile”,“email”,“api:serverA”,“api:serverB”],
“token_endpoint_auth_methods_supported”: [“none”],
“code_challenge_methods_supported”: [“S256”],
“grant_types_supported”: [“authorization_code”, “refresh_token”]
}
</code>
=== GET /authorize — Parameters ===
^ 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 | |