User Tools

Site Tools


security:sso-mobile

This is an old revision of the document!


Table of Contents

OIDC SSO System — Mobile App + PKCE + JWT + JWKS

Document Version: 1.0 Last Updated: 2026-06-16 Scope: Single Sign-On architecture for a native Mobile Application (iOS / Android)

        using Authorization Code Flow with PKCE, JWT access tokens validated via JWKS
        on ServerA and ServerB. Covers RSA key usage at every component.

Table of Contents

Architecture Overview

┌──────────────────────────────────────────────────────────────────────────────────┐
│                            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     │  │
│   └──────────────────┘  └──────────────────┘  └──────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────────────────┘

Core principle for mobile: The mobile app is a public client — it cannot securely store a client_secret (the app binary can be decompiled). PKCE replaces the client_secret as the proof of legitimacy for the token exchange. The authorization flow opens in the system browser (not a WebView), which provides isolation from the app's process and access to any existing browser SSO session.


Why Mobile is Different

Aspect SPA (Browser) Traditional Web Mobile App
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

  • RFC 8252 — OAuth 2.0 for Native Apps (mandates system browser, forbids WebView for login)
  • 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

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

┌──────────────────────────────────────────────────────────────────────┐
│               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

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

// 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()
}

What happens: No access_token in memory, no refresh_token in Keychain. Full login flow required.


Step 2 — App Generates PKCE Values and state

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.

Step 3 — App Opens System Browser with /authorize URL

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
 

Platform-specific implementation:

// 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()
// Android
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(context, Uri.parse(authorizeURL))
// App re-opened via AndroidManifest intent-filter for myapp://auth/callback
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

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)

ServerSSO checks: no SSO session cookie → must authenticate → show login page.


Step 5 — ServerSSO Presents Login Page in System Browser

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>

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

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

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

ServerSSO processing:

  1. Validates credentials against user store
  2. If MFA configured → challenges second factor in same browser session
  3. On success: creates SSO session, stores code_challenge binding

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
  }

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

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

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

// 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)
}

Step 10 — App Exchanges Code for Tokens (Device → ServerSSO, HTTPS)

Step 11 — ServerSSO Signs and Returns JWT Tokens

🔐 PRIVATE KEY USED HERE — ServerSSO signs both JWTs with RSA private key
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)

Step 12 — App Stores Tokens Securely

// 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
}
// 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
}

Step 13 — App Calls ServerA with Bearer JWT

App → ServerA: GET https://api-a.example.com/api/data Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0... Content-Type: application/json
 

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
# 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

ServerSSO is NOT contacted during JWT validation. Validation is offline cryptographic verification using the cached public key.


Step 15 — ServerA Returns Protected Resource

ServerA → Mobile App:
  HTTP 200  Content-Type: application/json
 
  {
    "data":  [...],
    "user":  "alice@example.com",
    "source": "ServerA"
  }

Step 16 — App Calls ServerB with Same JWT

The same access_token is used for ServerB because:

  1. aud claim includes https://api-b.example.com
  2. scope includes api:serverB
App → ServerB: GET https://api-b.example.com/api/records Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIn0...
 
🔓 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

ServerB → Mobile App:
  HTTP 200
  { "records": [...], "source": "ServerB" }

Scenario 1 — Complete Flow Summary

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} ──────────────────────────────────────────────│

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

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)
}
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} ──────────────────────────────────────────────│

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

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()
    }
}

Step 2 — App Calls ServerSSO /token with Refresh Token (Direct HTTPS)

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.

Step 3 — ServerSSO Validates Refresh Token

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)

Step 4 — ServerSSO Returns New Tokens

🔐 PRIVATE KEY USED HERE — ServerSSO signs fresh JWTs with RSA private key
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

Step 5 — App Updates Storage

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)
}

Step 6 — App Continues with API Calls

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} ─────────────────────────────────│              │

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.

// 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)
        }
    }
}
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} ─────────────────────────────────│

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

ServerSSO → Mobile App:
  HTTP 400
  { "error": "invalid_grant", "error_description": "Refresh token expired or revoked" }

Step 2 — App Clears Stale Tokens and Initiates Login

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()
}

Step 3 — Full Login Flow Initiated

Same as Scenario 1 Steps 2–10, but with a key difference:

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=...

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.

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} ─────────────────────────────────│

Sequence Diagrams

Full PKCE Flow — Mobile (Scenario 1, all steps)

 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)

API Contracts

ServerSSO Endpoints

GET /.well-known/openid-configuration

{
  "issuer":                                "https://sso.example.com",
  "authorization_endpoint":               "https://sso.example.com/authorize",
  "token_endpoint":                        "https://sso.example.com/token",
  "userinfo_endpoint":                     "https://sso.example.com/userinfo",
  "jwks_uri":                              "https://sso.example.com/.well-known/jwks.json",
  "end_session_endpoint":                  "https://sso.example.com/logout",
  "response_types_supported":              ["code"],
  "subject_types_supported":              ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported":                      ["openid","profile","email","api:serverA","api:serverB"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported":      ["S256"],
  "grant_types_supported":                 ["authorization_code", "refresh_token"]
}

GET /authorize — Parameters

Parameter Required Value / Example Notes
response_type code Always code
client_id mobile-app-001 Registered mobile client
redirect_uri
security/sso-mobile.1781592923.txt.gz · Last modified: by phong2018