security:sso-mobile
Differences
This shows you the differences between two versions of the page.
| Both sides previous revisionPrevious revisionNext revision | Previous revision | ||
| security:sso-mobile [2026/06/16 07:00] – phong2018 | security:sso-mobile [2026/06/16 07:08] (current) – phong2018 | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| - | ====== OIDC SSO System | + | ====== OIDC SSO System: Mobile App + PKCE + JWT + JWKS ====== |
| - | **Document | + | **Version: |
| - | **Last Updated:** 2026-06-16 | + | **Date:** 2026-06-16 |
| - | **Scope:** Single Sign-On architecture for a native | + | **Audience:** Backend Engineers, |
| - | using Authorization Code Flow with PKCE, JWT 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 | + | - [[# |
| - | - [[#why-mobile-is-different-from-spa-and-traditional-web|Why Mobile is Different]] | + | - [[#architecture|Architecture & Components]] |
| - | - [[# | + | - [[#key-concepts|Key Concepts]] |
| - | - [[#rsa-key-usage-private-key-vs-public-key|RSA Key Usage — Private Key vs Public | + | - [[#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 | + | - [[#token-validation|Token Validation with JWKS]] |
| - | - [[#scenario-1-unauthenticated-user|Scenario 1: Unauthenticated User]] | + | - [[# |
| - | - [[# | + | - [[# |
| - | - [[# | + | - [[#glossary|Glossary]] |
| - | - [[# | + | |
| - | - [[# | + | |
| - | - [[#configuration-reference|Configuration Reference]] | + | |
| - | - [[# | + | |
| ---- | ---- | ||
| - | ===== Architecture | + | ===== Overview ===== {{anchor: |
| - | < | + | This document describes a complete **OpenID Connect |
| - | ┌──────────────────────────────────────────────────────────────────────────────────┐ | + | designed for a mobile application that communicates with two backend resource servers. |
| - | │ SYSTEM TOPOLOGY | + | |
| - | │ │ | + | |
| - | │ ┌──────────────────────────────────────────────────────┐ | + | |
| - | │ │ | + | |
| - | │ │ │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ │ | + | |
| - | │ └──────────────────────────────┼───────────────────────┘ | + | |
| - | │ │ HTTPS (REST API) │ | + | |
| - | │ ┌──────────────────┼────────────────┐ | + | |
| - | │ │ │ │ │ | + | |
| - | │ ▼ ▼ ▼ │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | └──────────────────────────────────────────────────────────────────────────────────┘ | + | |
| - | </ | + | |
| - | **Core principle for mobile:** | + | The system uses: |
| - | The mobile | + | |
| - | (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 | + | * **OIDC** — identity layer on top of OAuth 2.0 |
| - | to any existing browser SSO session. | + | |
| ---- | ---- | ||
| - | ===== Why Mobile is Different | + | ===== Architecture & Components |
| - | ^ Aspect | + | ==== Component List ==== |
| - | | Client type | Public (no secret) | + | |
| - | | PKCE | Required | + | |
| - | | Login UI | Browser redirect | + | |
| - | | Token storage | + | |
| - | | Redirect after login | Browser URL change | + | |
| - | | Background token refresh | Silent iframe / JS | Server-side auto-refresh | + | |
| - | | App lifecycle risk | Tab close loses memory state | Server session persists | + | |
| - | | Network calls | Same origin / CORS | Server-to-server | + | |
| - | | Biometric gate | Not available | + | |
| - | ==== Mobile-Specific Standards ==== | + | ^ Component |
| + | | **Mobile | ||
| + | | **ServerSSO** | ||
| + | | **ServerA** | ||
| + | | **ServerB** | ||
| - | * **RFC 8252** — OAuth 2.0 for Native Apps (mandates system browser, forbids WebView for login) | + | ==== Network |
| - | * **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 | + | |
| - | | Role | OIDC Relying Party — Public Client | + | |
| - | | Platform | + | |
| - | | Client type | Public — NO client_secret | + | |
| - | | Auth method | + | |
| - | | Login browser | + | |
| - | | Redirect scheme | + | |
| - | | | OR App Link/ | + | |
| - | | Token: access | + | |
| - | | Token: refresh | + | |
| - | | Token: id | In-memory only (used once to extract user identity) | + | |
| - | | Scopes requested | + | |
| - | | client_id | + | |
| - | + | ||
| - | ==== ServerSSO ==== | + | |
| - | + | ||
| - | ^ Property | + | |
| - | | Role | OIDC Provider (OP) / Authorization Server | + | |
| - | | Standard | + | |
| - | | Token format | + | |
| - | | Base URL | https:// | + | |
| - | | Discovery URL | https:// | + | |
| - | | JWKS URL | https:// | + | |
| - | | 🔐 Private key | RSA-2048/ | + | |
| - | | 🔓 Public key | Exposed at JWKS endpoint — used by ServerA, ServerB | + | |
| - | + | ||
| - | ==== ServerA (Resource Server) ==== | + | |
| - | + | ||
| - | ^ Property | + | |
| - | | Role | OAuth 2.0 Resource Server / Backend API | | + | |
| - | | Called by | Mobile App directly (device → server HTTPS) | + | |
| - | | JWT validation | + | |
| - | | 🔓 Public key | Fetched from JWKS, cached 1 hour, used to verify JWTs | | + | |
| - | | Required scope | api: | + | |
| - | | Base URL | https:// | + | |
| - | + | ||
| - | ==== ServerB (Resource Server) ==== | + | |
| - | + | ||
| - | ^ Property | + | |
| - | | Role | OAuth 2.0 Resource Server / Backend API | | + | |
| - | | Called by | Mobile App directly (device → server HTTPS) | + | |
| - | | JWT validation | + | |
| - | | 🔓 Public key | Fetched from JWKS, cached 1 hour, used to verify JWTs | | + | |
| - | | Required scope | api: | + | |
| - | | Base URL | https:// | + | |
| - | + | ||
| - | ---- | + | |
| - | + | ||
| - | ===== RSA Key Usage — Private Key vs Public Key ===== | + | |
| - | + | ||
| - | ==== Key Pair Overview ==== | + | |
| < | < | ||
| - | ┌─────────────────────────────────────────────────────────────────────────────┐ | + | |
| - | │ RSA KEY PAIR — MOBILE SYSTEM | + | │ Mobile App │ ◄──────────────────────────► │ |
| - | │ │ | + | └──────┬──────┘ │ (Auth Server) |
| - | │ ┌──────────────────────────────────────────────────────────────────────┐ | + | |
| - | │ | + | |
| - | │ │ │ │ | + | ├────────────────────────────────► ┌──────────┴──────────┐ |
| - | │ │ 🔐 PRIVATE KEY (RSA-2048 or RSA-4096) | + | |
| - | │ │ kid: " | + | |
| - | │ │ Location: HSM / Cloud KMS / encrypted key store │ │ | + | └────────────────────────────────► └─────────────────────┘ |
| - | │ │ Operation: SIGN JWT payloads | + | ┌─────────────────────┐ |
| - | │ | + | |
| - | │ │ NEVER transmitted — never leaves this server | + | |
| - | │ │ │ │ | + | |
| - | │ │ 🔓 PUBLIC KEY (RSA modulus n + exponent e only) | + | |
| - | │ │ kid: " | + | |
| - | │ │ Exposed at: GET / | + | |
| - | │ │ Intentionally public — safe to distribute | + | |
| - | │ └──────────────────────────────────────────────────────────────────────┘ | + | |
| - | │ │ | + | |
| - | │ JWKS endpoint | + | |
| - | │ ┌───────────────────┴────────────────────┐ │ | + | |
| - | │ │ │ │ | + | |
| - | │ ▼ ▼ │ | + | |
| - | │ ┌──────────────────────┐ | + | |
| - | │ | + | |
| - | │ │ 🔓 PUBLIC KEY cache │ │ 🔓 PUBLIC KEY cache │ │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ └──────────────────────┘ | + | |
| - | │ │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ The app received the JWT from ServerSSO (trusted over TLS). │ | + | |
| - | └─────────────────────────────────────────────────────────────────────────────┘ | + | |
| </ | </ | ||
| - | ==== Key Usage Master Table ==== | + | > **Note:** ServerA and ServerB |
| - | + | > They verify | |
| - | ^ Component | + | |
| - | | ServerSSO | + | |
| - | | ServerSSO | + | |
| - | | ServerSSO | + | |
| - | | ServerA | + | |
| - | | ServerB | + | |
| - | | Mobile App | (none) | + | |
| - | + | ||
| - | > **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 | + | |
| - | > cryptographically verify signatures because they receive JWTs from potentially | + | |
| - | > untrusted callers (mobile devices). | + | |
| - | + | ||
| - | ==== Cryptographic Flow ==== | + | |
| - | + | ||
| - | < | + | |
| - | JWT CREATION (ServerSSO | + | |
| - | ────────────────────────────────────── | + | |
| - | 1. Assemble header: | + | |
| - | 2. Assemble payload: { " | + | |
| - | " | + | |
| - | 3. signing_input = base64url(header) + " | + | |
| - | 4. 🔐 signature = RSA_PKCS1v15_SIGN(key=PRIVATE_KEY, | + | |
| - | 5. JWT = signing_input + " | + | |
| - | 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: | + | |
| - | 9. Mobile app does NOT inspect or verify the JWT signature | + | |
| - | + | ||
| - | JWT VERIFICATION (ServerA / ServerB — public | + | |
| - | ────────────────────────────────────────────────── | + | |
| - | 10. Parse JWT → extract header.kid = " | + | |
| - | 11. Lookup JWKS cache → find RSA public key for kid | + | |
| - | (If not in cache → GET / | + | |
| - | 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 | + | |
| - | </ | + | |
| - | + | ||
| - | ==== Key Rotation Strategy ==== | + | |
| - | + | ||
| - | < | + | |
| - | ServerSSO rotates | + | |
| - | + | ||
| - | Phase 1 — Add new key (both keys in JWKS): | + | |
| - | GET / | + | |
| - | { " | + | |
| - | { " | + | |
| - | { " | + | |
| - | ]} | + | |
| - | + | ||
| - | Phase 2 — Sign new JWTs with new private key (kid: " | + | |
| - | → Old JWTs still verifiable via old public key (kid: " | + | |
| - | → 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. | + | |
| - | </ | + | |
| ---- | ---- | ||
| - | ===== OIDC + PKCE Flow for Mobile | + | ===== Key Concepts |
| - | ==== PKCE Generation | + | ==== PKCE Flow (Mobile-Specific) ==== |
| - | On mobile, | + | 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 |
| - | // iOS (Swift) — PKCE generation | + | | 1 | ``code_verifier`` |
| - | import CryptoKit | + | | 2 | ``code_challenge`` |
| - | import Foundation | + | | 3 | Auth request |
| + | | 4 | Token exchange | ||
| - | func generateCodeVerifier() -> String { | + | ==== JWT Structure ==== |
| - | var buffer | + | |
| - | _ = SecRandomCopyBytes(kSecRandomDefault, | + | |
| - | return Data(buffer).base64URLEncodedString() | + | |
| - | } | + | |
| - | func generateCodeChallenge(from verifier: String) -> 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 | + | |
| - | let codeChallenge = generateCodeChallenge(from: | + | |
| - | </ | + | |
| - | - code kotlin | + | <code json> |
| - | // Android (Kotlin) — PKCE generation | + | // Header |
| - | import java.security.MessageDigest | + | { |
| - | import java.security.SecureRandom | + | " |
| - | import android.util.Base64 | + | " |
| - | + | " | |
| - | fun generateCodeVerifier(): String { | + | |
| - | val bytes = ByteArray(32) | + | |
| - | | + | |
| - | return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) | + | |
| } | } | ||
| - | fun generateCodeChallenge(verifier: String): String | + | // Payload |
| - | val bytes = verifier.toByteArray(Charsets.US_ASCII) | + | { |
| - | val digest = MessageDigest.getInstance("SHA-256").digest(bytes) | + | |
| - | | + | "sub": "user-uuid-123", // subject |
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| } | } | ||
| - | // Store codeVerifier in ViewModel memory — ephemeral per login attempt | ||
| - | val codeVerifier | ||
| - | val codeChallenge = generateCodeChallenge(codeVerifier) | ||
| + | // Signature: RS256(base64url(header) + " | ||
| </ | </ | ||
| - | ==== System Browser Requirement (RFC 8252) ==== | + | ==== JWKS Endpoint |
| - | Native apps **MUST NOT** use an embedded WebView for the authorization flow. | + | ServerSSO exposes a public endpoint: |
| < | < | ||
| - | ❌ FORBIDDEN — Embedded WebView (UIWebView | + | GET https:// |
| - | | + | |
| - | The user has no visual guarantee they are on the real SSO login page. | + | |
| - | | + | |
| - | SSO session cookies from other apps are NOT shared with WebView. | + | |
| - | + | ||
| - | ✅ REQUIRED — System Browser / In-App Browser Tab | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | 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 | + | |
| - | | + | |
| - | - App process cannot read credentials or intercept the response | + | |
| - | - Platform-managed security sandbox | + | |
| </ | </ | ||
| - | ==== Custom URI Redirect Scheme ==== | + | Response: |
| - | After login, ServerSSO redirects the browser to a URI that opens the mobile app: | + | < |
| - | + | { | |
| - | < | + | |
| - | Option 1 — Custom URI Scheme (simpler but hijackable on Android): | + | { |
| - | | + | " |
| - | | + | " |
| - | Risk: Another app could register the same scheme (mitigated by state + PKCE) | + | " |
| - | + | " | |
| - | Option 2 — App Links / Universal Links (more secure, recommended): | + | " |
| - | https:// | + | " |
| - | | + | |
| - | iOS: apple-app-site-association file on server | + | |
| - | | + | |
| - | Risk: Only the verified app for that domain can handle the URL | + | |
| - | </ | + | |
| - | + | ||
| - | ---- | + | |
| - | + | ||
| - | ===== Token Storage on Mobile ===== | + | |
| - | + | ||
| - | < | + | |
| - | ┌──────────────────────────────────────────────────────────────────────┐ | + | |
| - | │ TOKEN STORAGE STRATEGY — MOBILE | + | |
| - | │ │ | + | |
| - | │ Token │ Storage Location | + | |
| - | │ ─────────────────────────────────────────────────────────────────── │ | + | |
| - | │ access_token | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ id_token | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ refresh_token | + | |
| - | │ │ Android: EncryptedSharedPrefs / Keystore | + | |
| - | │ │ Flutter: flutter_secure_storage | + | |
| - | │ │ React Native: react-native-keychain | + | |
| - | └──────────────────────────────────────────────────────────────────────┘ | + | |
| - | + | ||
| - | iOS Keychain attributes for refresh_token: | + | |
| - | kSecAttrAccessible: | + | |
| - | kSecAttrService: | + | |
| - | | + | |
| - | → 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 ==== | + | |
| - | + | ||
| - | <code swift> | + | |
| - | // iOS — App startup auth check | + | |
| - | func checkAuthState() { | + | |
| - | // 1. Check memory for access_token | + | |
| - | if let token = AppState.shared.accessToken, | + | |
| - | // → Scenario 2A: token valid, proceed | + | |
| - | return | + | |
| } | } | ||
| - | + | ] | |
| - | // 2. Check Keychain for refresh_token | + | |
| - | if let refreshToken = Keychain.read(key: | + | |
| - | // → Scenario 2B: attempt silent refresh | + | |
| - | silentRefresh(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. | + | ServerA and ServerB |
| - | Full login flow required. | + | without calling ServerSSO on every request. |
| - | ---- | + | ==== OIDC Discovery Document ==== |
| - | ==== Step 2 — App Generates PKCE Values and state ==== | + | ServerSSO also exposes: |
| < | < | ||
| - | Mobile App generates (in memory, NOT persisted to Keychain): | + | GET https:// |
| - | code_verifier | + | </ |
| - | = " | + | |
| - | code_challenge = base64url(SHA256(code_verifier)) | + | This returns all endpoint URLs (authorization, |
| - | = " | + | |
| - | + | ||
| - | state = base64url(SecureRandom(16 bytes)) | + | |
| - | = " | + | |
| - | + | ||
| - | All three stored in ViewModel memory for duration of login flow. | + | |
| - | </ | + | |
| ---- | ---- | ||
| - | ==== Step 3 — App Opens System Browser with /authorize URL ==== | + | ===== Scenario 1 — Not Authenticated (First Login) ===== {{anchor: |
| - | <code | + | This scenario covers the **complete first-time login flow** from scratch. |
| - | Mobile App → System Browser: | + | |
| - | Open URL: https://sso.example.com/ | + | |
| - | ? | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | </ | + | |
| - | **Platform-specific implementation: | + | ==== Step-by-Step ==== |
| - | <code swift> | + | === Step 1: Mobile App — Generate PKCE Parameters === |
| - | // iOS | + | |
| - | let session | + | |
| - | url: authorizeURL, | + | |
| - | callbackURLScheme: | + | |
| - | ) { callbackURL, | + | |
| - | guard let url = callbackURL else { return } | + | |
| - | handleCallback(url: url) | + | |
| - | } | + | |
| - | session.presentationContextProvider | + | |
| - | session.prefersEphemeralWebBrowserSession | + | |
| - | session.start() | + | |
| - | </ | + | |
| - | <code kotlin> | + | The app generates cryptographic values **locally**, before any network call: |
| - | // Android | + | |
| - | val customTabsIntent = CustomTabsIntent.Builder().build() | + | |
| - | customTabsIntent.launchUrl(context, Uri.parse(authorizeURL)) | + | |
| - | // App re-opened via AndroidManifest intent-filter for myapp:// | + | |
| - | </ | + | |
| - | > **prefersEphemeralWebBrowserSession | + | <code> |
| - | > share its cookie jar with Safari, enabling SSO if the user already has an active | + | code_verifier |
| - | > SSO session from another app or from mobile Safari. | + | e.g. " |
| - | ---- | + | code_challenge = base64url( SHA256( code_verifier ) ) |
| + | e.g. " | ||
| - | ==== Step 4 — System Browser Navigates to ServerSSO ==== | + | state |
| - | + | nonce | |
| - | < | + | |
| - | System Browser → ServerSSO: | + | |
| - | GET https://sso.example.com/ | + | |
| - | ? | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | Cookie: (no sso_session — first login) | + | |
| </ | </ | ||
| - | ServerSSO checks: no SSO session cookie → must authenticate → show login page. | + | The app stores ``code_verifier``, |
| - | ---- | + | === 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: |
| < | < | ||
| - | ServerSSO → System Browser: | + | GET https:// |
| - | HTTP 200 Content-Type: | + | ? |
| - | + | & | |
| - | < | + | & |
| - | < | + | & |
| - | <form action=" | + | & |
| - | < | + | & |
| - | < | + | & |
| - | < | + | |
| - | </form> | + | |
| - | </body> | + | |
| - | | + | |
| </ | </ | ||
| - | The mobile app process **cannot see this page or the credentials Alice types**. | + | ^ Parameter |
| - | The system browser has a separate process with its own memory space. | + | | ``response_type`` |
| + | | ``client_id`` | ||
| + | | ``redirect_uri`` | ||
| + | | ``scope`` | ||
| + | | ``state`` | ||
| + | | ``nonce`` | ||
| + | | ``code_challenge`` | ||
| + | | ``code_challenge_method``| ``S256`` | ||
| - | ---- | + | === Step 3: ServerSSO — Authenticate the User === |
| - | ==== Step 6 — Alice Submits Credentials in System Browser ==== | + | ServerSSO presents the **login page** (username/ |
| - | < | + | The user authenticates successfully. |
| - | System Browser → ServerSSO: | + | |
| - | | + | - Creates a server-side session |
| - | | + | |
| - | username=alice%40example.com& | + | === Step 4: ServerSSO → Mobile App — Authorization Code Redirect === |
| - | </ | + | |
| - | **ServerSSO | + | ServerSSO |
| - | - Validates credentials against user store | + | |
| - | - If MFA configured → challenges second factor in same browser | + | |
| - | - On success: creates SSO session, stores code_challenge binding | + | |
| - | + | ||
| - | ---- | + | |
| - | + | ||
| - | ==== Step 7 — ServerSSO Sets SSO Cookie and Redirects with Authorization Code ==== | + | |
| < | < | ||
| - | ServerSSO → System Browser: | + | myapp:// |
| - | HTTP 302 | + | ?code=SplxlOBeZQQYbYS6WxSbIA |
| - | Set-Cookie: sso_session=SSO-MOB-XYZ; | + | &state=< |
| - | HttpOnly; Secure; SameSite=None | + | |
| - | Location: | + | |
| - | ?code=AUTH-CODE-MOBILE-111 | + | |
| - | &state=xyzABC123randomstate | + | |
| - | + | ||
| - | ServerSSO internally stores binding: | + | |
| - | " | + | |
| - | client_id: | + | |
| - | code_challenge: | + | |
| - | challenge_method: | + | |
| - | redirect_uri: | + | |
| - | scope: | + | |
| - | user_id: | + | |
| - | expires_at: | + | |
| - | } | + | |
| </ | </ | ||
| - | 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 |
| + | - **Validates** that ``state`` matches what was stored in Step 1 (CSRF check) | ||
| + | - Extracts | ||
| - | ---- | + | === 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: |
| < | < | ||
| - | System Browser attempts to navigate to: myapp://auth/ | + | POST https://sso.example.com/token |
| + | Content-Type: | ||
| - | iOS: | + | grant_type=authorization_code |
| - | → calls completion handler with callbackURL | + | & |
| - | → system browser session ends automatically | + | & |
| - | + | & | |
| - | Android: OS resolves intent for scheme " | + | & |
| - | Activity registered in AndroidManifest.xml for this URI scheme | + | |
| - | → Activity.onNewIntent(intent) fires with the callback URI | + | |
| </ | </ | ||
| - | The **authorization code** is now inside | + | > **Critical:** ``code_verifier`` |
| - | 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 | ||
| + | > 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: |
| - | < | + | < |
| - | // iOS callback handler | + | { |
| - | func handleCallback(url: | + | " |
| - | let components = URLComponents(url: url, resolvingAgainstBaseURL: | + | " |
| - | let params | + | "expires_in": 3600, |
| - | $0[$1.name] = $1.value | + | "id_token": |
| - | } ?? [:] | + | |
| - | + | " | |
| - | guard | + | |
| - | let code = params["code"], | + | |
| - | let state = params["state"], | + | |
| - | state == AppState.shared.pendingState | + | |
| - | else { | + | |
| - | // State mismatch → abort → possible CSRF / redirect hijack | + | |
| - | handleAuthError("state_mismatch") | + | |
| - | return | + | |
| - | } | + | |
| - | + | ||
| - | // Retrieve PKCE verifier from ViewModel memory | + | |
| - | let codeVerifier = AppState.shared.pendingCodeVerifier | + | |
| - | | + | |
| - | + | ||
| - | // Exchange code for tokens | + | |
| - | exchangeCodeForTokens(code: code, codeVerifier: codeVerifier) | + | |
| } | } | ||
| </ | </ | ||
| - | ---- | + | ^ Token ^ Purpose |
| + | | ``access_token`` | Sent to ServerA / ServerB as Bearer token | Secure memory only | | ||
| + | | ``id_token`` | ||
| + | | ``refresh_token``| Used to get new access_token when expired | ||
| - | ==== Step 10 — App Exchanges Code for Tokens (Device → ServerSSO, HTTPS) ==== | + | The app **validates the id_token**: |
| + | - Fetch JWKS from ``https:// | ||
| + | - 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** | + | === Step 7: Mobile App → ServerA |
| - | 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/ |
| - | POST https://sso.example.com/ | + | Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ... |
| - | Content-Type: | + | |
| - | + | ||
| - | grant_type=authorization_code | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | & | + | |
| - | + | ||
| - | Note: NO client_secret — this is a public client. | + | |
| - | The code_verifier takes its place as proof of legitimacy. | + | |
| </ | </ | ||
| - | **ServerSSO PKCE verification:** | + | === Step 8: ServerA — JWT Validation (Local, No SSO Call) === |
| - | <code | + | ServerA |
| - | ServerSSO | + | |
| - | 1. code " | + | |
| - | 2. client_id " | + | |
| - | 3. redirect_uri "myapp:// | + | |
| - | 4. SHA256(code_verifier) == stored code_challenge | + | |
| - | | + | |
| - | 5. Invalidate code (single-use) | + | |
| - | </ | + | |
| - | ---- | + | < |
| - | + | 1. Parse JWT header | |
| - | ==== Step 11 — ServerSSO Signs and Returns JWT Tokens ==== | + | 2. Look up public key in local JWKS cache by kid |
| - | + | (If not cached: fetch https:// | |
| - | > 🔐 **PRIVATE KEY USED HERE — ServerSSO signs both JWTs with RSA private key** | + | 3. Verify RS256 signature using the RSA public key |
| - | + | 4. Validate claims: | |
| - | < | + | - exp > now() → not expired |
| - | ServerSSO → Mobile App: | + | - iss == " |
| - | HTTP 200 Content-Type: | + | - aud contains |
| - | + | 5. Extract sub, roles, email from payload | |
| - | { | + | 6. Apply authorization rules |
| - | " | + | 7. Return API response |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | + | ||
| - | JWT header | + | |
| - | { | + | |
| - | " | + | |
| - | | + | |
| - | " | + | |
| - | } | + | |
| - | + | ||
| - | access_token payload: | + | |
| - | { | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | + | ||
| - | id_token payload: | + | |
| - | { | + | |
| - | "iss": | + | |
| - | " | + | |
| - | "aud": "mobile-app-001", | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | + | ||
| - | All signatures computed with: 🔐 ServerSSO RSA PRIVATE KEY (kid: key-2024-01) | + | |
| </ | </ | ||
| - | ---- | + | === Step 9: Mobile App → ServerB — API Request with Same JWT === |
| - | ==== Step 12 — App Stores Tokens Securely ==== | + | The **same access_token** is reused for ServerB: |
| - | < | + | < |
| - | // iOS — token storage after successful exchange | + | GET https://serverB.example.com/api/resource |
| - | func storeTokens(tokenResponse: TokenResponse) { | + | Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS1pZC0wMDEifQ... |
| - | | + | |
| - | AppState.shared.accessToken | + | |
| - | AppState.shared.accessTokenExp = Date().addingTimeInterval( | + | |
| - | | + | |
| - | + | ||
| - | | + | |
| - | | + | |
| - | key: " | + | |
| - | value: tokenResponse.refreshToken, | + | |
| - | accessibility: | + | |
| - | ) | + | |
| - | + | ||
| - | // id_token → memory for user info extraction, then discard | + | |
| - | let userInfo = parseIdToken(tokenResponse.idToken) | + | |
| - | AppState.shared.userEmail = userInfo.email | + | |
| - | AppState.shared.userName | + | |
| - | // id_token NOT persisted — not needed after user info extracted | + | |
| - | } | + | |
| </ | </ | ||
| - | <code kotlin> | + | ServerB performs identical JWT validation |
| - | // Android — token storage | + | authentication, |
| - | fun storeTokens(tokenResponse: | + | |
| - | // access_token → ViewModel memory | + | |
| - | viewModel.accessToken | + | |
| - | viewModel.accessTokenExp = System.currentTimeMillis() + | + | |
| - | (tokenResponse.expiresIn * 1000L) | + | |
| - | // refresh_token → EncryptedSharedPreferences (Android Keystore-backed) | + | ==== Scenario 1 — Summary Flow ==== |
| - | val prefs = EncryptedSharedPreferences.create( | + | |
| - | " | + | |
| - | EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, | + | |
| - | EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM | + | |
| - | ) | + | |
| - | prefs.edit().putString(" | + | |
| - | // id_token → extract user info then discard | + | < |
| - | | + | Mobile App ServerSSO |
| - | | + | │ |
| - | } | + | │─ Generate PKCE ──►│ |
| + | │ code_verifier | ||
| + | │ code_challenge | ||
| + | │ | ||
| + | │── GET /authorize ─►│ | ||
| + | | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │◄── redirect ──────│ | ||
| + | │ ?code=XYZ | ||
| + | │ & | ||
| + | │ | ||
| + | │── POST /token ────►│ | ||
| + | │ | ||
| + | │ │ verify: SHA256( │ │ | ||
| + | │ | ||
| + | | ||
| + | | ||
| + | │◄── access_token ──│ | ||
| + | │ id_token | ||
| + | │ refresh_token | ||
| + | │ | ||
| + | │── GET /api/data ──────────────────────►│ | ||
| + | │ | ||
| + | │ | ||
| + | │◄── 200 response ──────────────────────►│ | ||
| + | │ | ||
| + | │── GET /api/res ────────────────────────────────────────►│ | ||
| + | │ | ||
| + | │◄── 200 response ────────────────────────────────────────│ (JWKS cache) | ||
| + | │ | ||
| </ | </ | ||
| ---- | ---- | ||
| - | ==== Step 13 — App Calls ServerA with Bearer JWT ==== | + | ===== Scenario 2 — Already Authenticated (SSO Token Reuse) ===== {{anchor: |
| - | <code | + | This scenario covers the case where the user **already has a valid session** or |
| - | Mobile App → ServerA: | + | the app has a **cached refresh_token** and needs a new access_token. |
| - | GET https:// | + | |
| - | Authorization: | + | |
| - | Content-Type: | + | |
| - | </ | + | |
| - | 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: |
| - | < | + | < |
| - | # ServerA | + | decoded = JWT.decode(access_token, verify_signature=false) // decode without verification |
| - | def validate_bearer_jwt(request): | + | now = current_unix_timestamp() |
| - | auth = request.headers.get(' | + | |
| - | if not auth.startswith(' | + | |
| - | return error_response(401, | + | |
| - | token = auth[7:] | + | if decoded.exp > now + 30: |
| - | + | // Token is still valid — use it directly | |
| - | # 1. Decode header (no verification) to get kid | + | |
| - | header = decode_jwt_header_unverified(token) | + | else: |
| - | kid = header.get(' | + | |
| - | + | | |
| - | # 2. Look up public key in JWKS cache | + | |
| - | public_key = jwks_cache.get(kid) | + | |
| - | | + | |
| - | # Unknown kid → refresh JWKS from ServerSSO | + | |
| - | jwks_cache.refresh(' | + | |
| - | public_key = jwks_cache.get(kid) | + | |
| - | if not public_key: | + | |
| - | return error_response(401, | + | |
| - | + | ||
| - | | + | |
| - | try: | + | |
| - | payload = jwt.decode( | + | |
| - | token, | + | |
| - | public_key, | + | |
| - | algorithms=[' | + | |
| - | issuer=' | + | |
| - | audience=' | + | |
| - | options={' | + | |
| - | ) | + | |
| - | | + | |
| - | return error_response(401, ' | + | |
| - | | + | |
| - | return error_response(401, | + | |
| - | | + | |
| - | return error_response(403, ' | + | |
| - | + | ||
| - | | + | |
| - | if ' | + | |
| - | return error_response(403, | + | |
| - | + | ||
| - | # 5. Inject user context — no ServerSSO contact needed | + | |
| - | request.user = { | + | |
| - | ' | + | |
| - | ' | + | |
| - | ' | + | |
| - | } | + | |
| - | # Continue to handler | + | |
| </ | </ | ||
| - | **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: | + | |
| - | + | ||
| - | { | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | </ | + | |
| - | + | ||
| - | ---- | + | |
| - | + | ||
| - | ==== Step 16 — App Calls ServerB with Same JWT ==== | + | |
| - | + | ||
| - | The **same** | + | |
| - | - aud claim includes https:// | + | |
| - | - scope includes api: | + | |
| - | + | ||
| - | <code | + | |
| - | Mobile App → ServerB: | + | |
| - | GET https:// | + | |
| - | Authorization: | + | |
| - | </ | + | |
| - | + | ||
| - | > 🔓 **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 | + | |
| - | { " | + | |
| - | </ | + | |
| - | + | ||
| - | ==== Scenario 1 — Complete Flow Summary ==== | + | |
| < | < | ||
| - | Mobile App System Browser | + | GET https://serverA.example.com/api/data |
| - | │ | + | Authorization: |
| - | │ check storage │ | + | |
| - | │ (empty) | + | |
| - | │ gen PKCE+state│ | + | |
| - | │ | + | |
| - | │─ open browser ▶ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │◀─ callback URL (code+state) ──│ | + | |
| - | │ validate state | + | |
| - | │─ POST /token ─────────────────▶ | + | |
| - | │ (code + code_verifier) | + | |
| - | │ 🔐 SSO signs JWTs with | + | |
| - | │ | + | |
| - | │◀─ {access_token(JWT), | + | |
| - | │ refresh_token, | + | |
| - | │ store: access→memory, | + | |
| - | │ | + | |
| - | │ | + | |
| - | │─ GET /api ────────────────────────────────────▶ | + | |
| - | │ Bearer JWT | + | |
| - | │ | + | |
| - | │◀─ 200 {data} ─────────────────────────────────│ | + | |
| - | | + | |
| - | │─ GET /api ────────────────────────────────────────────────▶│ | + | |
| - | │ | + | |
| - | │ | + | |
| - | │◀─ 200 {data} ──────────────────────────────────────────────│ | + | |
| </ | </ | ||
| - | ---- | + | No interaction with ServerSSO is needed. ✓ |
| - | + | ||
| - | ===== 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 | + | ==== Sub-Scenario |
| - | <code swift> | + | The ``access_token`` has expired, but 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 1: Mobile App → ServerSSO — Refresh Token Request === |
| - | if let token = getValidToken() { | + | |
| - | callServerA(bearerToken: token) | + | |
| - | callServerB(bearerToken: | + | |
| - | } | + | |
| - | </ | + | |
| < | < | ||
| - | Mobile App System Browser | + | POST https://sso.example.com/ |
| - | │ | + | Content-Type: |
| - | │ access_token | + | |
| - | │ in memory ✅ │ | + | |
| - | │ | + | |
| - | │─ GET /api ────────────────────────────────────▶ | + | |
| - | │ Bearer JWT | + | |
| - | │◀─ 200 {data} ─────────────────────────────────│ | + | |
| - | │ | + | |
| - | │─ GET /api ─────────────────────────────────────────────────▶│ | + | |
| - | │ Bearer JWT | + | |
| - | │◀─ 200 {data} ──────────────────────────────────────────────│ | + | |
| - | </code> | + | |
| - | Zero SSO contact. ServerA and ServerB verify JWTs offline. | + | grant_type=refresh_token |
| - | + | &refresh_token=8xLOxBtZp8 | |
| - | ---- | + | & |
| - | + | ||
| - | ==== Scenario 2B — App Killed and Relaunched (Refresh from Keychain) ==== | + | |
| - | + | ||
| - | When the OS kills the app (memory pressure, user swipe-close), | + | |
| - | The refresh_token | + | |
| - | + | ||
| - | === 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: | + | |
| - | silentRefresh(refreshToken: | + | |
| - | } else { | + | |
| - | // No refresh token → full login (Scenario 1) | + | |
| - | initiateLogin() | + | |
| - | } | + | |
| - | } | + | |
| </ | </ | ||
| - | === 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. | ||
| - | < | + | === Step 2: ServerSSO |
| - | Mobile App → ServerSSO: | + | |
| - | POST https:// | + | |
| - | Content-Type: | + | |
| - | grant_type=refresh_token | ||
| - | & | ||
| - | & | ||
| - | |||
| - | 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: | ServerSSO checks: | ||
| - | - refresh_token | + | - 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 | + | |
| - | If SSO session was terminated | + | If valid, ServerSSO **rotates** the refresh token (issues a new one, invalidates the old one). |
| - | → Returns error: invalid_grant | + | |
| - | → Mobile app must initiate full login (Scenario 1) | + | |
| - | </ | + | |
| - | === Step 4 — ServerSSO Returns | + | === 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: | + | { |
| - | | + | " |
| - | { | + | " |
| - | | + | " |
| - | " | + | "id_token": |
| - | " | + | "refresh_token": " |
| - | "refresh_token": "rtMOB-xyz-new-rotated-token", | + | " |
| - | " | + | } |
| - | } | + | |
| - | + | ||
| - | Signature: 🔐 ServerSSO RSA PRIVATE KEY | + | |
| </ | </ | ||
| - | === Step 5 — App Updates Storage === | + | The app stores the **new** ``refresh_token`` and **new** ``access_token``. |
| - | <code swift> | + | === Step 4: Mobile App → ServerA |
| - | func handleRefreshSuccess(tokenResponse: TokenResponse) { | + | |
| - | | + | |
| - | AppState.shared.accessToken | + | |
| - | AppState.shared.accessTokenExp | + | |
| - | | + | |
| - | + | ||
| - | // New refresh_token → Keychain (overwrites old — rotation) | + | |
| - | Keychain.write(key: | + | |
| - | | + | |
| - | } | + | |
| - | </ | + | |
| - | === Step 6 — App Continues | + | The app proceeds |
| < | < | ||
| - | Mobile App System Browser | + | GET https://serverA.example.com/api/data |
| - | │ | + | Authorization: |
| - | │ app relaunched│ | + | |
| - | │ memory empty │ | + | |
| - | │ read Keychain ✅ │ | + | |
| - | │─ POST /token ────────────────▶ | + | |
| - | │ (refresh_token) | + | |
| - | │ 🔐 SSO signs new JWT | + | |
| - | │◀─ new {access_token, | + | |
| - | │ update memory + Keychain | + | |
| - | │ | + | |
| - | │─ GET /api ────────────────────────────────────▶ | + | |
| - | | + | |
| - | │◀─ 200 {data} ─────────────────────────────────│ | + | |
| </ | </ | ||
| ---- | ---- | ||
| - | ==== Scenario | + | ==== Sub-Scenario |
| - | The app is running in the foreground but the 15-minute access_token | + | If the ``refresh_token`` |
| - | This is handled transparently, typically by an HTTP interceptor. | + | |
| - | <code swift> | + | === Step 1: Mobile App — Detect Expired Refresh Token === |
| - | // iOS — Alamofire/ | + | |
| - | class TokenRefreshInterceptor: RequestInterceptor { | + | |
| - | func adapt(_ request: URLRequest, ...) async throws -> URLRequest { | + | The token endpoint returns: |
| - | var req = request | + | |
| - | let token = await getOrRefreshToken() | + | |
| - | req.setValue(" | + | |
| - | 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 | + | |
| - | │ | + | |
| - | │─ GET /api ────────────────────────────────────▶ | + | |
| - | │ expired JWT │ | + | |
| - | │◀─ 401 ────────────────────────────────────────│ | + | |
| - | │ interceptor fires │ │ | + | |
| - | │─ POST / | + | |
| - | │ (refresh_token, | + | |
| - | │ 🔐 SSO signs new JWT | + | |
| - | │◀─ new tokens ─────────────────│ | + | |
| - | │─ GET /api (retry) ────────────────────────────▶ | + | |
| - | │ new Bearer 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 === | + | |
| <code json> | <code json> | ||
| - | ServerSSO → Mobile App: | + | HTTP 400 Bad Request |
| - | | + | { |
| - | | + | " |
| - | </ | + | |
| - | + | ||
| - | === Step 2 — App Clears Stale Tokens and Initiates Login === | + | |
| - | + | ||
| - | <code swift> | + | |
| - | func handleRefreshFailure(error: | + | |
| - | // Clear stale tokens | + | |
| - | AppState.shared.accessToken = nil | + | |
| - | Keychain.delete(key: | + | |
| - | + | ||
| - | // 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 | + | === Step 2: Check for Existing SSO Session (Silent Auth) === |
| - | Same as Scenario 1 Steps 2–10, but with a key difference: | + | Before forcing |
| < | < | ||
| - | Mobile App → System Browser: open / | + | GET https:// |
| - | + | | |
| - | System Browser → ServerSSO: | + | |
| - | | + | & |
| - | | + | & |
| - | + | & | |
| - | ServerSSO: SSO session found and valid | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| </ | </ | ||
| - | Alice taps the app, the system | + | * If ServerSSO has a **valid |
| - | **She never sees a login form.** This is the SSO effect on mobile. | + | |
| - | === 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. | + | |
| < | < | ||
| - | Mobile App | + | Mobile App ServerSSO |
| - | │ | + | │ |
| - | │ refresh | + | │ |
| - | │ clear Keychain│ | + | │ |
| - | │ gen PKCE+state│ | + | │── POST /token ────►│ │ |
| - | │─ open browser ▶ │ | + | │ grant_type= |
| - | │ | + | │ refresh_token |
| - | | + | │ |
| - | │ | + | │ │ rotate RT |
| - | │ | + | │◄── |
| - | │◀─ callback ───│ | + | │ |
| - | │ | + | │ |
| - | │─ | + | │ |
| - | │ | + | │ |
| - | │ 🔐 SSO signs new JWTs │ │ | + | │── GET /api/ |
| - | │◀─ new tokens | + | │ Bearer: new JWT |
| - | │ | + | │◄── 200 OK ─────────────────────────────│ |
| - | │─ GET /api ────────────────────────────────────▶ | + | │ │ |
| - | │ | + | │── GET /api/res ────────────────────────────────────────►│ |
| - | │◀─ 200 {data} | + | │ |
| + | │◄── 200 OK ──────────────────────────────────────────────│ | ||
| </ | </ | ||
| ---- | ---- | ||
| - | ===== Sequence Diagrams | + | ===== Token Validation with JWKS (ServerA & ServerB Detail) |
| - | ==== Full PKCE Flow — Mobile (Scenario 1, all steps) | + | ==== JWKS Caching Strategy |
| - | < | + | Resource servers should **not** fetch JWKS on every request. Recommended strategy: |
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | | + | |
| - | 10. ServerSSO | + | |
| - | 11. Sys Browser | + | |
| - | 12. Mobile App → Mobile App : validate state == pending_state | + | |
| - | 13. Mobile App → Mobile App : retrieve code_verifier from ViewModel memory | + | |
| - | 14. Mobile App → ServerSSO | + | |
| - | 15. ServerSSO | + | |
| - | 16. ServerSSO | + | |
| - | 17. ServerSSO | + | |
| - | 18. ServerSSO | + | |
| - | 19. Mobile App → Mobile App : store access_token → memory | + | |
| - | 20. Mobile App → Keychain | + | |
| - | 21. Mobile App → ServerA | + | |
| - | 22. ServerA | + | |
| - | (if not cached: GET / | + | |
| - | 23. ServerA | + | |
| - | 24. ServerA | + | |
| - | 25. ServerA | + | |
| - | 26. Mobile App → ServerB | + | |
| - | 27. ServerB | + | |
| - | 28. ServerB | + | |
| - | 29. ServerB | + | |
| - | 30. | + | |
| - | KEY USAGE SUMMARY: | + | ^ Condition |
| - | Steps 16–17 | + | | Startup |
| - | Steps 23, 28 → 🔓 RSA PUBLIC KEY from JWKS | + | | 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 ===== | + | < |
| + | function validateJWT(token, | ||
| - | ==== ServerSSO | + | // 1. Split and decode (no verification yet) |
| + | [header_b64, | ||
| + | header | ||
| + | payload | ||
| + | |||
| + | // 2. Look up the signing key | ||
| + | kid = header.kid | ||
| + | key = jwksCache.get(kid) | ||
| + | if key is null: | ||
| + | jwksCache.refresh() | ||
| + | key = jwksCache.get(kid) | ||
| + | if key is null: | ||
| + | throw InvalidTokenError(" | ||
| - | === GET /.well-known/ | + | |
| + | message | ||
| + | if NOT RSA_SHA256_verify(message, | ||
| + | throw InvalidTokenError(" | ||
| - | <code json> | + | |
| - | { | + | |
| - | " | + | throw InvalidTokenError("Token expired") |
| - | | + | |
| - | " | + | throw InvalidTokenError("Wrong issuer") |
| - | "userinfo_endpoint": | + | |
| - | | + | throw InvalidTokenError("Wrong audience") |
| - | " | + | |
| - | " | + | throw InvalidTokenError("Token not yet valid") |
| - | "subject_types_supported": [" | + | |
| - | | + | |
| - | "scopes_supported": [" | + | |
| - | | + | |
| - | "code_challenge_methods_supported": [" | + | |
| - | " | + | |
| - | } | + | |
| - | </ | + | |
| - | === GET /authorize — Parameters === | + | // 5. Extract and return user context |
| - | + | | |
| - | ^ Parameter | + | |
| - | | response_type | + | |
| - | | client_id | + | |
| - | | redirect_uri | + | |
| - | | scope | ✅ | openid profile email api:serverA api:serverB | Space-separated | + | |
| - | | state | ✅ | Random 128-bit value | CSRF protection | + | |
| - | | code_challenge | + | |
| - | | code_challenge_method | ✅ | S256 | plain not accepted | + | |
| - | | nonce | ⚠️ rec | Random value | Replay protection on id_token | + | |
| - | + | ||
| - | === POST /token — Authorization Code === | + | |
| - | + | ||
| - | ^ Parameter | + | |
| - | | grant_type | + | |
| - | | code | ✅ | Authorization code from /authorize | + | |
| - | | redirect_uri | + | |
| - | | client_id | + | |
| - | | code_verifier | + | |
| - | + | ||
| - | No client_secret — public client. | + | |
| - | + | ||
| - | === POST /token — Refresh Token === | + | |
| - | + | ||
| - | ^ Parameter | + | |
| - | | grant_type | + | |
| - | | refresh_token | + | |
| - | | client_id | + | |
| - | + | ||
| - | === Error Responses === | + | |
| - | + | ||
| - | ^ HTTP ^ error ^ Description | + | |
| - | | 400 | invalid_request | + | |
| - | | 400 | invalid_grant | + | |
| - | | 400 | invalid_client | + | |
| - | | 400 | invalid_pkce | + | |
| - | | 400 | unsupported_grant_type | grant_type not recognized | + | |
| - | + | ||
| - | ==== ServerA & ServerB — Protected Endpoint Contract ==== | + | |
| - | + | ||
| - | **Request:** | + | |
| - | < | + | |
| - | GET /api/data HTTP/1.1 | + | |
| - | Host: api-a.example.com | + | |
| - | Authorization: | + | |
| </ | </ | ||
| - | **Error Responses: | + | ==== Key Rotation ==== |
| - | ^ HTTP ^ WWW-Authenticate header | + | When ServerSSO rotates its signing key: |
| - | | 401 | Bearer error=" | + | |
| - | | 401 | Bearer error=" | + | |
| - | | 401 | Bearer error=" | + | |
| - | | 401 | Bearer error=" | + | |
| - | | 403 | Bearer error=" | + | |
| - | | 403 | Bearer error=" | + | |
| - | ---- | + | |
| - | + | - Old JWTs remain valid until they expire | |
| - | ===== Security Considerations ===== | + | - ServerA/ServerB |
| - | + | - No downtime | |
| - | ==== Mobile-Specific Threats and Mitigations ==== | + | |
| - | + | ||
| - | ^ Threat | + | |
| - | | Code interception (redirect hijack) | Malicious app registers same URI scheme | PKCE: stolen code useless without code_verifier; | + | |
| - | | Access token theft | Memory scraping on rooted device | + | |
| - | | Refresh token theft | App data backup / ADB extraction | + | |
| - | | WebView credential interception | + | |
| - | | Man-in-the-middle | + | |
| - | | Replay attack | + | |
| - | | Forged JWT | Attacker crafts fake JWT | RSA RS256 signature; ServerA/B verify against JWKS public | + | |
| - | | Jailbreak / root | OS-level keystore bypass | + | |
| - | | SSO session hijack | + | |
| - | | client_secret exposure | + | |
| - | + | ||
| - | ==== PKCE Security Properties ==== | + | |
| - | + | ||
| - | < | + | |
| - | Without PKCE (Authorization Code only — Public Client): | + | |
| - | + | ||
| - | | + | |
| - | myapp:// | + | |
| - | + | ||
| - | EVIL-APP calls POST /token with stolen code: | + | |
| - | grant_type=authorization_code | + | |
| - | & | + | |
| - | & | + | |
| - | → ServerSSO issues tokens ← ATTACK SUCCEEDS ❌ | + | |
| - | + | ||
| - | With PKCE (S256): | + | |
| - | + | ||
| - | EVIL-APP intercepts code but does NOT have code_verifier | + | |
| - | POST /token with STOLEN-CODE but WRONG code_verifier | + | |
| - | → ServerSSO: SHA256(wrong_verifier) ≠ stored code_challenge | + | |
| - | → Error: invalid_grant ← ATTACK BLOCKED ✅ | + | |
| - | + | ||
| - | | + | |
| - | It stays in the legitimate app's memory and is only sent directly to | + | |
| - | the token endpoint over TLS. | + | |
| - | </ | + | |
| - | + | ||
| - | ==== Security Checklist ==== | + | |
| - | + | ||
| - | ^ # ^ Control | + | |
| - | | 1 | PKCE S256 enforced | + | |
| - | | 2 | System browser only | No WebView for authentication (RFC 8252) | | + | |
| - | | 3 | state validation | + | |
| - | | 4 | Redirect URI exact match | SSO refuses partial/ | + | |
| - | | 5 | access_token in memory only | Never persist to disk; lost on app kill is acceptable | + | |
| - | | 6 | refresh_token in secure storage | + | |
| - | | 7 | Short access_token TTL | 15 minutes maximum | + | |
| - | | 8 | Refresh token rotation | + | |
| - | | 9 | Audience validation | + | |
| - | | 10| Scope validation | + | |
| - | | 11| JWKS caching with TTL | 1 hour; refetch only on unknown kid | | + | |
| - | | 12| Certificate pinning (optional) | + | |
| - | | 13| Biometric gate for refresh_token | + | |
| - | | 14| Jailbreak detection | + | |
| - | | 15| No noneOf algorithm | + | |
| ---- | ---- | ||
| - | ===== Configuration Reference | + | ===== Security Notes ===== {{anchor: |
| - | ==== ServerSSO — Mobile | + | ==== Token Storage on Mobile ==== |
| - | <code yaml> | + | ^ Token ^ Recommended Storage |
| - | clients: | + | | access_token |
| - | - client_id: | + | | refresh_token |
| - | | + | | id_token |
| - | | + | |
| - | redirect_uris: | + | ==== PKCE Requirements ==== |
| - | - " | + | |
| - | - " | + | |
| - | post_logout_redirect_uris: | + | * ``code_verifier``: must be 43–128 characters, using ``[A-Z a-z 0-9 - . _ ~]`` |
| - | - " | + | * Always use ``S256`` (SHA-256) — never ``plain`` (insecure) |
| + | * Generate a **new** ``code_verifier`` for every authorization request | ||
| + | * Never reuse or persist ``code_verifier`` across sessions | ||
| - | allowed_scopes: | + | ==== Redirect URI Security ==== |
| - | - openid | + | |
| - | - profile | + | |
| - | + | ||
| - | - api: | + | |
| - | - api:serverB | + | |
| - | token_endpoint_auth_method: | + | |
| - | | + | * Use **private-use URI schemes** (e.g., ``com.example.myapp:// |
| - | | + | * Always validate ``state`` parameter to prevent CSRF |
| - | access_token_ttl: | + | ==== Access Token Lifetime Recommendations ==== |
| - | refresh_token_ttl: | + | |
| - | id_token_ttl: | + | |
| - | auth_code_ttl: | + | |
| - | </ | + | |
| - | ==== ServerA JWT Validation ==== | + | ^ Token ^ Recommended Lifetime ^ Notes ^ |
| + | | access_token | ||
| + | | refresh_token | ||
| + | | id_token | ||
| - | <code yaml> | + | ==== HTTPS Enforcement ==== |
| - | jwt: | + | |
| - | issuer: | + | |
| - | audience: | + | |
| - | algorithms: | + | |
| - | jwks_uri: | + | |
| - | jwks_cache_ttl: | + | |
| - | required_scope: | + | |
| - | clock_skew: | + | |
| - | reject_alg_none: | + | |
| - | </ | + | |
| - | ==== ServerB JWT Validation ==== | + | All endpoints |
| - | + | Certificate pinning is recommended for the mobile app in high-security environments. | |
| - | <code yaml> | + | |
| - | jwt: | + | |
| - | issuer: | + | |
| - | audience: | + | |
| - | algorithms: | + | |
| - | jwks_uri: | + | |
| - | jwks_cache_ttl: | + | |
| - | required_scope: | + | |
| - | clock_skew: | + | |
| - | reject_alg_none: | + | |
| - | </ | + | |
| - | + | ||
| - | ==== Mobile App — iOS Configuration ==== | + | |
| - | + | ||
| - | <code swift> | + | |
| - | // OIDCConfig.swift | + | |
| - | struct OIDCConfig { | + | |
| - | static let issuer | + | |
| - | static let clientID | + | |
| - | static let redirectURI | + | |
| - | static let scopes | + | |
| - | + | ||
| - | static let serverABaseURL = " | + | |
| - | | + | |
| - | + | ||
| - | // Keychain | + | |
| - | static let keychainService | + | |
| - | static let refreshTokenKey | + | |
| - | static let keychainAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | + | |
| - | } | + | |
| - | </ | + | |
| - | + | ||
| - | ==== Mobile App — Android Configuration ==== | + | |
| - | + | ||
| - | <code kotlin> | + | |
| - | // AuthConfig.kt | + | |
| - | object AuthConfig { | + | |
| - | const val ISSUER | + | |
| - | const val CLIENT_ID | + | |
| - | const val REDIRECT_URI = " | + | |
| - | val SCOPES | + | |
| - | + | ||
| - | const val SERVER_A_BASE_URL = " | + | |
| - | const val SERVER_B_BASE_URL = " | + | |
| - | + | ||
| - | // EncryptedSharedPreferences key | + | |
| - | const val PREFS_FILE | + | |
| - | const val REFRESH_TOKEN_KEY | + | |
| - | } | + | |
| - | </ | + | |
| ---- | ---- | ||
| - | ===== Quick Reference Card ===== | + | ===== Glossary |
| - | < | + | ^ Term ^ Definition |
| - | ╔══════════════════════════════════════════════════════════════════════════╗ | + | | OIDC | OpenID Connect |
| - | ║ | + | | OAuth 2.0 | Authorization framework; OIDC extends it with identity |
| - | ╠══════════════════════════════════════════════════════════════════════════╣ | + | | PKCE |
| - | ║ RSA KEY USAGE ║ | + | | JWT | JSON Web Token — RFC 7519; compact, URL-safe |
| - | ║ 🔐 PRIVATE KEY → ServerSSO ONLY ║ | + | | JWKS | JSON Web Key Set — RFC 7517; public |
| - | ║ | + | | access_token |
| - | ║ | + | | id_token |
| - | ║ | + | | refresh_token |
| - | ║ ║ | + | | code_verifier |
| - | ║ 🔓 PUBLIC KEY → ServerA + ServerB | + | | code_challenge |
| - | ║ | + | | authorization |
| - | ║ | + | | kid | Key ID — identifies which key in the JWKS was used to sign a JWT | |
| - | ║ | + | | SSO | Single Sign-On — one authentication grants access |
| - | ║ ║ | + | | 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 | + | |
| - | ║ code_challenge = base64url(SHA256(code_verifier)) | + | |
| - | ║ method = S256 (plain rejected) | + | |
| - | ║ Verifier sent to /token endpoint — never in redirect URI ║ | + | |
| - | ╠══════════════════════════════════════════════════════════════════════════╣ | + | |
| - | ║ CLIENT TYPE: PUBLIC | + | |
| - | ║ PKCE replaces client_secret for public | + | |
| - | ║ /token call: grant_type + code + code_verifier + client_id only ║ | + | |
| - | ╠══════════════════════════════════════════════════════════════════════════╣ | + | |
| - | ║ TOKEN STORAGE | + | |
| - | ║ | + | |
| - | ║ refresh_token → iOS Keychain / Android EncryptedSharedPrefs | + | |
| - | ║ | + | |
| - | ║ Browser holds → sso_session cookie (system browser, not app) ║ | + | |
| - | ╠══════════════════════════════════════════════════════════════════════════╣ | + | |
| - | ║ SYSTEM BROWSER (RFC 8252) ║ | + | |
| - | ║ iOS: | + | |
| - | ║ 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 | + | |
| - | ║ access_token expired + refresh in Keychain | + | |
| - | ║ | + | |
| - | ║ No tokens | + | |
| - | ╠══════════════════════════════════════════════════════════════════════════╣ | + | |
| - | ║ TOKEN LIFETIMES | + | |
| - | ║ access_token | + | |
| - | ║ id_token | + | |
| - | ║ refresh_token | + | |
| - | ║ auth code 60 sec (single use; PKCE-bound) | + | |
| - | ║ SSO session | + | |
| - | ╠══════════════════════════════════════════════════════════════════════════╣ | + | |
| - | ║ KEY SECURITY PROPERTIES | + | |
| - | ║ Stolen auth code | + | |
| - | ║ Stolen access JWT → expires | + | |
| - | ║ Stolen refresh | + | |
| - | ║ Forged | + | |
| - | ║ No client_secret | + | |
| - | ╚══════════════════════════════════════════════════════════════════════════╝ | + | |
| - | </ | + | |
| ---- | ---- | ||
| - | //Document maintained by: Platform Security Team// | + | //Document maintained by the Platform Security Team. Last updated: 2026-06-16// |
| - | //Format: DokuWiki// | + | |
| - | //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.1781593206.txt.gz · Last modified: by phong2018
