security:sso-mobile
Differences
This shows you the differences between two versions of the page.
| Next revision | Previous revision | ||
| security:sso-mobile [2026/06/16 05:28] – created phong2018 | security:sso-mobile [2026/06/16 07:08] (current) – phong2018 | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| - | ====== SSO Flow Example: Mobile App, Server A, Server B, and SSO (OIDC + JWT + JWKS) ====== | + | ====== |
| - | ===== System Overview ===== | + | **Version: |
| + | **Date:** 2026-06-16 \\ | ||
| + | **Audience: | ||
| - | We have 3 systems: | + | ---- |
| - | Mobile App (iOS / Android) | + | ===== Table of Contents ===== |
| - | Server A: crm.company.com | + | |
| - | Server B: orders.company.com | + | |
| - | SSO / Identity Provider: auth.company.com | + | |
| - | Initial state: | + | - [[# |
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| + | - [[# | ||
| - | < | + | ---- |
| - | (No cookies used in mobile flow) | + | ===== Overview ===== {{anchor: |
| - | ===== Scenario 1: Login via Mobile App ===== | + | This document describes a complete **OpenID Connect (OIDC) Single Sign-On (SSO)** system |
| + | designed for a mobile application that communicates with two backend resource servers. | ||
| - | ===== Step 1: User opens Mobile App ===== | + | The system uses: |
| + | * **PKCE** (Proof Key for Code Exchange) — prevents authorization code interception on mobile | ||
| + | * **JWT** (JSON Web Token) — compact, self-contained token format | ||
| + | * **JWKS** (JSON Web Key Set) — public key endpoint for token signature verification | ||
| + | * **OIDC** — identity layer on top of OAuth 2.0 | ||
| - | < | + | ---- |
| - | App detects user not logged in. | + | ===== Architecture & Components ===== {{anchor: |
| - | ===== Step 2: App opens SSO login (system browser) ===== | + | ==== Component List ==== |
| - | < | + | ^ Component |
| + | | **Mobile | ||
| + | | **ServerSSO** | ||
| + | | **ServerA** | ||
| + | | **ServerB** | ||
| - | Includes: | + | ==== Network Overview ==== |
| - | client_id | + | < |
| - | redirect_uri | + | |
| - | response_type=code | + | │ Mobile App │ ◄──────────────────────────► │ |
| - | PKCE challenge | + | └──────┬──────┘ |
| + | │ └────────┬────────┘ | ||
| + | │ JWT (Bearer Token) | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | | ||
| + | </ | ||
| - | ===== Step 3: User logs in ===== | + | > **Note:** ServerA and ServerB never contact ServerSSO during normal request validation. |
| + | > They verify JWT signatures locally using the public keys fetched from the JWKS endpoint. | ||
| - | User enters: | + | ---- |
| - | username | + | ===== Key Concepts ===== {{anchor: |
| - | password | + | |
| - | MFA (optional) | + | |
| - | SSO creates session: | + | ==== PKCE Flow (Mobile-Specific) ==== |
| - | <code> SSO_SESSION=123 </ | + | PKCE prevents **authorization |
| + | (because redirect URIs on mobile can be hijacked by malicious apps). | ||
| - | SSO sets cookie | + | ^ Step ^ PKCE Parameter |
| + | | 1 | ``code_verifier`` | ||
| + | | 2 | ``code_challenge`` | ||
| + | | 3 | Auth request | ||
| + | | 4 | Token exchange | ||
| - | < | + | ==== JWT Structure ==== |
| - | ===== Step 4: Redirect back to Mobile App (Deep Link) ===== | + | A JWT has three parts separated by dots: ``header.payload.signature`` |
| - | < | + | < |
| + | // Header | ||
| + | { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| - | Browser closes → control returns to app. | + | // Payload (claims) |
| + | { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| - | ===== Step 5: Mobile App exchanges | + | // Signature: RS256(base64url(header) + " |
| + | </code> | ||
| - | < | + | ==== JWKS Endpoint ==== |
| - | Request includes: | + | ServerSSO exposes a public endpoint: |
| - | code=abc | + | <code> |
| - | code_verifier (PKCE) | + | GET https:// |
| - | client_id | + | </ |
| - | SSO returns: | + | Response: |
| - | access_token (JWT) | + | <code json> |
| - | id_token (JWT) | + | { |
| - | refresh_token | + | " |
| + | { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | ] | ||
| + | } | ||
| + | </ | ||
| - | ===== 🔐 JWT SIGNING (SSO - PRIVATE KEY) ===== | + | ServerA and ServerB **cache** this response and use it to verify |
| + | without calling ServerSSO on every request. | ||
| - | Inside SSO: | + | ==== OIDC Discovery Document ==== |
| - | < | + | ServerSSO also exposes: |
| - | SSO: | + | < |
| + | GET https:// | ||
| + | </ | ||
| - | creates ID token | + | This returns all endpoint URLs (authorization, |
| - | creates Access token | + | |
| - | signs both with PRIVATE KEY | + | |
| - | ✔ Only SSO has PRIVATE KEY | + | ---- |
| - | ✔ Never exposed to apps or servers | + | |
| - | ===== 🔐 JWT VERIFICATION | + | ===== Scenario 1 — Not Authenticated |
| - | Server A / B validate token: | + | This scenario covers the **complete first-time login flow** from scratch. |
| - | ==== 1. Fetch JWKS ==== | + | ==== Step-by-Step |
| - | < | + | === Step 1: Mobile App — Generate PKCE Parameters === |
| - | ==== 2. Verify signature ==== | + | The app generates cryptographic values **locally**, |
| - | < | + | < |
| + | code_verifier | ||
| + | e.g. " | ||
| - | ==== 3. Validate claims ==== | + | code_challenge |
| + | e.g. " | ||
| - | exp (expiration) | + | state = base64url( random_bytes(16) ) // CSRF protection |
| - | iss (issuer) | + | nonce = base64url( random_bytes(16) ) // replay protection |
| - | aud (audience) | + | </ |
| - | If valid → accept request | + | The app stores ``code_verifier``, |
| - | ===== Step 6: Store tokens in Mobile App ===== | + | === Step 2: Mobile App → ServerSSO — Authorization Request |
| - | < | + | The app opens the system browser (or in-app browser tab) and navigates to: |
| - | Stored in: | + | < |
| + | GET https:// | ||
| + | ? | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | </ | ||
| - | iOS Keychain | + | ^ Parameter |
| - | Android Keystore | + | | ``response_type`` |
| + | | ``client_id`` | ||
| + | | ``redirect_uri`` | ||
| + | | ``scope`` | ||
| + | | ``state`` | ||
| + | | ``nonce`` | ||
| + | | ``code_challenge`` | ||
| + | | ``code_challenge_method``| ``S256`` | ||
| - | ===== Scenario 2: Mobile App calls Server A ===== | + | === Step 3: ServerSSO — Authenticate the User === |
| - | ===== Step 7: Call Server A ===== | + | ServerSSO presents the **login page** (username/ |
| - | < | + | The user authenticates successfully. ServerSSO: |
| + | - Validates the PKCE challenge parameters | ||
| + | - Creates a server-side session | ||
| + | - Generates a short-lived **authorization | ||
| - | Server A: | + | === Step 4: ServerSSO → Mobile App — Authorization Code Redirect === |
| - | verifies JWT via JWKS | + | ServerSSO redirects the browser to the app's deep link: |
| - | checks signature + claims | + | |
| - | returns data | + | |
| - | ===== Scenario 3: Mobile App calls Server B ===== | + | < |
| + | myapp:// | ||
| + | ?code=SplxlOBeZQQYbYS6WxSbIA | ||
| + | &state=< | ||
| + | </ | ||
| - | ===== Step 8: Call Server B ===== | + | The app: |
| + | - Receives the redirect via the OS URL scheme handler | ||
| + | - **Validates** that ``state`` matches what was stored in Step 1 (CSRF check) | ||
| + | - Extracts the ``code`` | ||
| - | < | + | === Step 5: Mobile App → ServerSSO — Token Exchange (PKCE Verification) === |
| - | Server B: | + | The app makes a **back-channel** (direct HTTPS) POST request: |
| - | verifies JWT via JWKS | + | < |
| - | trusts SSO identity | + | POST https:// |
| - | returns data | + | Content-Type: |
| - | ===== 🔁 Token Refresh Flow ===== | + | grant_type=authorization_code |
| + | &code=SplxlOBeZQQYbYS6WxSbIA | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | </ | ||
| - | When access_token expires: | + | > **Critical:** ``code_verifier`` is sent here for the first time. |
| + | > ServerSSO recomputes SHA256(code_verifier) and compares it to the stored ``code_challenge``. | ||
| + | > If they match, the exchange is authorized. This **proves** the requester is the same | ||
| + | > entity that started the flow — defeating code interception attacks. | ||
| - | < | + | === Step 6: ServerSSO → Mobile App — Token Response === |
| - | SSO returns new access_token. | + | ServerSSO responds with: |
| - | (No user login required again) | + | <code json> |
| + | { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | </ | ||
| - | ===== 🔐 JWT SIGNING | + | ^ 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 | ||
| - | Same as login: | + | 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) | ||
| - | < | + | === Step 7: Mobile App → ServerA — API Request with JWT === |
| - | ✔ Central signing authority (SSO) | + | < |
| + | GET https:// | ||
| + | Authorization: | ||
| + | </ | ||
| - | ===== 🔐 JWT VERIFICATION | + | === Step 8: ServerA — JWT Validation |
| - | Server A / B: | + | ServerA validates the JWT **entirely locally**: |
| - | fetch JWKS | + | < |
| - | verify | + | 1. Parse JWT header → extract " |
| - | validate | + | 2. Look up public key in local JWKS cache by kid |
| + | (If not cached: fetch https:// | ||
| + | 3. Verify RS256 signature | ||
| + | 4. Validate | ||
| + | - exp > now() → not expired | ||
| + | - iss == " | ||
| + | - aud contains " | ||
| + | 5. Extract sub, roles, email from payload | ||
| + | 6. Apply authorization rules | ||
| + | 7. Return API response | ||
| + | </ | ||
| - | No call to SSO per request | + | === Step 9: Mobile App → ServerB — API Request with Same JWT === |
| - | ===== Final State ===== | + | The **same access_token** is reused for ServerB: |
| - | Mobile App stores: | + | < |
| + | GET https:// | ||
| + | Authorization: | ||
| + | </ | ||
| - | < | + | ServerB performs identical |
| + | authentication, | ||
| - | Servers do NOT store sessions. | + | ==== Scenario 1 — Summary Flow ==== |
| - | ===== Key Architecture Insight ===== | + | < |
| + | Mobile App ServerSSO | ||
| + | │ | ||
| + | │─ Generate PKCE ──►│ | ||
| + | │ code_verifier | ||
| + | │ code_challenge | ||
| + | │ | ||
| + | │── GET /authorize ─►│ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │◄── redirect ──────│ | ||
| + | │ ?code=XYZ | ||
| + | │ &state=ABC │ │ │ | ||
| + | │ | ||
| + | │── POST /token ────►│ | ||
| + | │ | ||
| + | │ │ verify: SHA256( | ||
| + | │ | ||
| + | │ | ||
| + | │ | ||
| + | │◄── access_token ──│ | ||
| + | │ id_token | ||
| + | │ refresh_token | ||
| + | │ | ||
| + | │── GET /api/data ──────────────────────►│ | ||
| + | │ | ||
| + | │ | ||
| + | │◄── 200 response ──────────────────────►│ | ||
| + | │ | ||
| + | │── GET /api/res ────────────────────────────────────────►│ | ||
| + | │ | ||
| + | │◄── 200 response ────────────────────────────────────────│ (JWKS cache) | ||
| + | │ | ||
| + | </ | ||
| - | SSO is the ONLY system that: | + | ---- |
| - | uses PRIVATE KEY | + | |
| - | signs JWT tokens | + | |
| - | Applications: | + | ===== Scenario 2 — Already Authenticated |
| - | use PUBLIC KEY (JWKS) | + | |
| - | verify JWT signatures | + | |
| - | do NOT maintain server session per user | + | |
| - | Mobile | + | This scenario covers the case where the user **already has a valid session** or |
| - | stores tokens securely (Keychain/ | + | the app has a **cached refresh_token** and needs a new access_token. |
| - | calls APIs directly | + | |
| - | Cookies: | + | ==== Sub-Scenario 2a: Valid Access Token Still in Memory ==== |
| - | NOT used in mobile flow | + | |
| - | ===== Final Interview One-liner ===== | + | The simplest case: the app already holds a non-expired ``access_token``. |
| - | In mobile SSO (OIDC with PKCE), the app authenticates via system browser, receives an authorization | + | === Step 1: Mobile App — Check Token Expiry === |
| + | |||
| + | Before making any API call, the app checks locally: | ||
| + | |||
| + | < | ||
| + | decoded = JWT.decode(access_token, | ||
| + | now = current_unix_timestamp() | ||
| + | |||
| + | if decoded.exp > now + 30: // 30-second buffer | ||
| + | // Token is still valid — use it directly | ||
| + | proceed_to_step_2() | ||
| + | else: | ||
| + | // Token expired or about to expire — refresh it (Sub-Scenario 2b) | ||
| + | refresh_access_token() | ||
| + | </ | ||
| + | |||
| + | === Step 2: Mobile App → ServerA / ServerB — API Request === | ||
| + | |||
| + | Identical to Scenario 1 Steps 7–9. The app reuses the cached ``access_token``: | ||
| + | |||
| + | < | ||
| + | GET https:// | ||
| + | Authorization: | ||
| + | </ | ||
| + | |||
| + | No interaction | ||
| + | |||
| + | ---- | ||
| + | |||
| + | ==== Sub-Scenario 2b: Access Token Expired — Refresh Flow ==== | ||
| + | |||
| + | The ``access_token`` has expired, but the app holds a valid ``refresh_token``. | ||
| + | |||
| + | === Step 1: Mobile App → ServerSSO — Refresh Token Request === | ||
| + | |||
| + | < | ||
| + | POST https:// | ||
| + | Content-Type: | ||
| + | |||
| + | grant_type=refresh_token | ||
| + | & | ||
| + | & | ||
| + | </ | ||
| + | |||
| + | > **Note:** No PKCE is required for the refresh grant — PKCE was only needed | ||
| + | > for the initial authorization code exchange. | ||
| + | |||
| + | === Step 2: ServerSSO — Validate Refresh Token === | ||
| + | |||
| + | ServerSSO checks: | ||
| + | - Refresh token exists in its database and is not revoked | ||
| + | - Refresh token has not expired (typically 30 days for mobile apps) | ||
| + | - ``client_id`` matches | ||
| + | |||
| + | If valid, ServerSSO **rotates** | ||
| + | |||
| + | === Step 3: ServerSSO → Mobile App — New Tokens === | ||
| + | |||
| + | <code json> | ||
| + | { | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | The app stores the **new** ``refresh_token`` and **new** ``access_token``. | ||
| + | |||
| + | === Step 4: Mobile App → ServerA / ServerB — API Request === | ||
| + | |||
| + | The app proceeds with the freshly issued ``access_token``. | ||
| + | |||
| + | < | ||
| + | GET https:// | ||
| + | Authorization: | ||
| + | </ | ||
| + | |||
| + | ---- | ||
| + | |||
| + | ==== Sub-Scenario 2c: Both Tokens Expired — Re-Authentication ==== | ||
| + | |||
| + | If the ``refresh_token`` has also expired (e.g., user was inactive for 30+ days): | ||
| + | |||
| + | === Step 1: Mobile App — Detect Expired Refresh Token === | ||
| + | |||
| + | The token endpoint returns: | ||
| + | |||
| + | <code json> | ||
| + | HTTP 400 Bad Request | ||
| + | { | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | === Step 2: Check for Existing SSO Session (Silent Auth) === | ||
| + | |||
| + | Before forcing a full re-login, the app attempts **silent authentication**: | ||
| + | |||
| + | < | ||
| + | GET https:// | ||
| + | ? | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | & | ||
| + | </ | ||
| + | |||
| + | * If ServerSSO has a **valid browser/SSO session** (cookie): it returns a new ``code`` immediately — no login prompt → proceed with token exchange (Scenario 1, Steps 5–6) | ||
| + | * If no session exists: ServerSSO returns ``error=login_required`` → app must show login UI → full Scenario 1 from Step 2 | ||
| + | |||
| + | ==== Scenario 2b — Summary Flow ==== | ||
| + | |||
| + | < | ||
| + | Mobile App ServerSSO | ||
| + | │ | ||
| + | │ [access_token expired, refresh_token valid] | ||
| + | │ | ||
| + | │── POST /token ────►│ | ||
| + | │ | ||
| + | │ | ||
| + | │ │ validate RT | ||
| + | │ │ rotate RT | ||
| + | │◄── new tokens | ||
| + | │ access_token | ||
| + | │ refresh_token | ||
| + | │ (rotated) | ||
| + | │ | ||
| + | │── GET /api/data ──────────────────────►│ | ||
| + | │ | ||
| + | │◄── 200 OK ─────────────────────────────│ | ||
| + | │ | ||
| + | │── GET /api/res ────────────────────────────────────────►│ | ||
| + | │ | ||
| + | │◄── 200 OK ──────────────────────────────────────────────│ | ||
| + | </ | ||
| + | |||
| + | ---- | ||
| + | |||
| + | ===== Token Validation with JWKS (ServerA & ServerB Detail) ===== {{anchor: | ||
| + | |||
| + | ==== JWKS Caching Strategy ==== | ||
| + | |||
| + | Resource servers should **not** fetch JWKS on every request. Recommended strategy: | ||
| + | |||
| + | ^ Condition | ||
| + | | Startup | ||
| + | | JWT ``kid`` found in cache | Use cached key — no network | ||
| + | | JWT ``kid`` **not** found in cache | Fetch JWKS again (key rotation may have occurred) | ||
| + | | Cache age > 1 hour | Refresh JWKS in background | ||
| + | | JWKS fetch fails | Use stale cache; log warning; retry with backoff | ||
| + | |||
| + | ==== JWT Validation Algorithm (Pseudocode) ==== | ||
| + | |||
| + | < | ||
| + | function validateJWT(token, audience): | ||
| + | |||
| + | // 1. Split and decode (no verification yet) | ||
| + | [header_b64, | ||
| + | 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(" | ||
| + | |||
| + | // 3. Verify signature | ||
| + | message | ||
| + | if NOT RSA_SHA256_verify(message, | ||
| + | throw InvalidTokenError(" | ||
| + | |||
| + | // 4. Validate standard claims | ||
| + | if payload.exp < now(): | ||
| + | throw InvalidTokenError(" | ||
| + | if payload.iss != " | ||
| + | throw InvalidTokenError(" | ||
| + | if audience NOT IN payload.aud: | ||
| + | throw InvalidTokenError(" | ||
| + | if payload.nbf is set AND payload.nbf > now(): | ||
| + | throw InvalidTokenError(" | ||
| + | |||
| + | // 5. Extract | ||
| + | return { userId: payload.sub, roles: payload.roles, | ||
| + | </ | ||
| + | |||
| + | ==== Key Rotation ==== | ||
| + | |||
| + | When ServerSSO rotates its signing key: | ||
| + | |||
| + | - New JWTs are signed with the **new** key (new ``kid``) | ||
| + | - Old JWTs remain valid until they expire (old key stays in JWKS temporarily) | ||
| + | - ServerA/ | ||
| + | - No downtime or coordination required | ||
| + | |||
| + | ---- | ||
| + | |||
| + | ===== Security Notes ===== {{anchor: | ||
| + | |||
| + | ==== Token Storage on Mobile ==== | ||
| + | |||
| + | ^ Token ^ Recommended Storage | ||
| + | | access_token | ||
| + | | refresh_token | ||
| + | | id_token | ||
| + | |||
| + | ==== PKCE Requirements ==== | ||
| + | |||
| + | * ``code_verifier``: | ||
| + | * Always use ``S256`` (SHA-256) — never ``plain`` (insecure) | ||
| + | * Generate a **new** ``code_verifier`` for every authorization request | ||
| + | * Never reuse or persist ``code_verifier`` across | ||
| + | |||
| + | ==== Redirect URI Security ==== | ||
| + | |||
| + | * Register **exact** redirect URIs in ServerSSO (no wildcards) | ||
| + | * Use **private-use URI schemes** (e.g., ``com.example.myapp:// | ||
| + | * Always validate ``state`` parameter to prevent CSRF | ||
| + | |||
| + | ==== Access Token Lifetime Recommendations ==== | ||
| + | |||
| + | ^ Token ^ Recommended Lifetime ^ Notes ^ | ||
| + | | access_token | ||
| + | | refresh_token | ||
| + | | id_token | ||
| + | |||
| + | ==== HTTPS Enforcement ==== | ||
| + | |||
| + | All endpoints (authorization, | ||
| + | Certificate pinning is recommended for the mobile app in high-security environments. | ||
| + | |||
| + | ---- | ||
| + | |||
| + | ===== Glossary ===== {{anchor: | ||
| + | |||
| + | ^ Term ^ Definition | ||
| + | | OIDC | OpenID Connect — identity layer on top of OAuth 2.0 | | ||
| + | | OAuth 2.0 | Authorization framework; OIDC extends it with identity (id_token) | ||
| + | | PKCE | Proof Key for Code Exchange — RFC 7636; prevents code interception on mobile | ||
| + | | JWT | JSON Web Token — RFC 7519; compact, URL-safe token format | ||
| + | | JWKS | JSON Web Key Set — RFC 7517; public key set exposed by the auth server | ||
| + | | access_token | ||
| + | | id_token | ||
| + | | refresh_token | ||
| + | | code_verifier | ||
| + | | code_challenge | ||
| + | | authorization code | Short-lived, | ||
| + | | kid | Key ID — identifies which key in the JWKS was used to sign a JWT | | ||
| + | | SSO | Single Sign-On — one authentication grants access to multiple services | ||
| + | | Bearer token | HTTP authentication scheme; token is presented as-is in the Authorization header | | ||
| + | | RS256 | RSA Signature with SHA-256; asymmetric signing algorithm for JWTs | | ||
| + | |||
| + | ---- | ||
| + | |||
| + | //Document maintained by the Platform Security Team. Last updated: 2026-06-16// | ||
security/sso-mobile.1781587707.txt.gz · Last modified: by phong2018
