6. Authentication¶
How clients and users authenticate: OAuth grant types, token structure, and JWKS validation. A sequence diagram accompanies each grant below.
6.1 Overview¶
Identity Platform uses OAuth 2.0 with multiple grant types for unified authentication.
Token Endpoint: POST /v1/auth/token
JWKS Endpoint: GET /.well-known/jwks.json
Diagram 1.2 — Authentication Model Overview
flowchart TB
subgraph Grant Types
OTP[OTP Grant<br/>Mobile + OTP]
PWD[Password Grant<br/>Email + Password]
GOO[Google Grant<br/>ID Token]
APL[Apple Grant<br/>ID Token]
REF[Refresh Grant<br/>Refresh Token]
CC[Client Credentials<br/>client_id + secret]
end
subgraph Token Endpoint
TE[POST /v1/auth/token]
end
subgraph Tenant Config Check
TC{Check tenant config:<br/>auth_methods enabled?}
end
subgraph Tokens Issued
AT[Access Token JWT<br/>24h user / 1h backend]
RT[Refresh Token<br/>30 days]
end
subgraph Compliance
AUDIT[Audit Log:<br/>token_issued<br/>login_attempt]
end
subgraph Validation
JWKS[/.well-known/jwks.json]
ENV[Envoy Gateway]
IDP[Identity Platform]
end
OTP --> TE
PWD --> TE
GOO --> TE
APL --> TE
REF --> TE
CC --> TE
TE --> TC
TC -->|Allowed| AT
TC -->|Not allowed| REJECT[401 Unauthorized]
TE --> RT
TE --> AUDIT
AT -->|User JWT| ENV
AT -->|Backend JWT| IDP
ENV -->|Fetch Keys| JWKS
ENV -->|Validate Locally| ENV
IDP -->|Validate| IDP
6.2 Grant Types¶
| Grant Type | Caller | Purpose |
|---|---|---|
client_credentials |
Backend systems | API access to Identity Platform |
otp |
End users (mobile) | Phone-based signup/login |
password |
End users (mobile) | Email/password login |
google |
End users (mobile) | Google social login |
apple |
End users (mobile) | Apple social login |
refresh_token |
End users (mobile) | Token refresh |
Note: Personal scanners authenticate via mTLS (client certificate), not OAuth. Certificates are issued during device pairing. See Section 9.
6.3 Token Structure¶
Token Response (User Grants):
{
"access_token": "eyJ...",
"refresh_token": "def456...",
"token_type": "Bearer",
"expires_in": 86400,
"user_id": "user_123",
"is_new_user": false
}
Token Expiry:
| Token | Expiry | Purpose |
|---|---|---|
| Access token | 24 hours | API access |
| Refresh token | 30 days | Get new access token |
| Backend token | 1 hour | Backend-to-backend |
6.4 OAuth Client Management¶
6.4.1 Client Entity¶
Schema (client_id, tenant_id, name, grant_types, status, secret_hash, created_at, created_by, last_rotated_at, revoked_at) is defined in §5.3.
6.4.2 Secret Model¶
Each client has one secret at a time. The plaintext secret is returned exactly once at creation (or regeneration). The server stores only the bcrypt hash.
- If the secret is lost, the admin must regenerate — this immediately invalidates the old secret.
- Services using the old secret will lose access until updated with the new one.
6.4.3 Client Scope¶
Clients are tenant-scoped. All clients within a tenant have equal permissions — they can call any API endpoint available to the client_credentials grant. The tenant_id is embedded in the issued access token and enforced on every request.
A tenant can have multiple clients — one per integrating application (e.g., "Wallet Backend", "Analytics Pipeline", "Mobile BFF"). This allows independent credential lifecycle per service.
6.4.4 Client Lifecycle¶
Create: Tenant Admin creates client → client_id + secret (shown once)
Regenerate: Tenant Admin regenerates secret → old secret dies, new one shown once
Revoke: Tenant Admin revokes client → status: revoked, no new tokens
Existing tokens issued by a revoked client remain valid until their natural expiry (max 1 hour for backend tokens). See Section 14.1 for client management endpoints.
Diagram 8.1 — Create OAuth Client
sequenceDiagram
autonumber
participant Admin as Tenant Admin
participant Console as Web Console
participant Identity as Identity Platform
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
Admin->>Console: Click "Generate Credentials"
Console-->>Admin: Show form:<br/>Client name, grant types
Admin->>Console: Fill form, click Create
Console->>Identity: POST /v1/clients<br/>Authorization: Console session<br/>{name: "Wallet Backend",<br/>grant_types: ["client_credentials"]}
Identity->>Identity: Validate admin role<br/>(must be Tenant Admin)
Identity->>Identity: Extract tenant_id from session
Identity->>Identity: Generate client_id (UUID)
Identity->>Identity: Generate client_secret<br/>(random 256-bit, base64url)
Identity->>Identity: Hash secret (bcrypt)
Identity->>DB: Store client record<br/>(client_id, tenant_id, name,<br/>grant_types, secret_hash,<br/>status: active, created_by)
Identity->>Audit: Log: client.created<br/>(client_id, tenant_id,<br/>actor: admin, name)
Identity-->>Console: {client_id: "cli_abc123",<br/>client_secret: "sk_live_...",<br/>name: "Wallet Backend",<br/>status: "active"}
Note over Console: Secret shown ONCE —<br/>plaintext never stored on server
Console-->>Admin: Show secret reveal modal<br/>(copy button, one-time view)
Admin->>Console: Copy secret, dismiss modal
Console->>Console: Secret cleared from UI<br/>Table shows new client<br/>(secret_hash only on server)
Diagram 8.2 — Regenerate Client Secret
sequenceDiagram
autonumber
participant Admin as Tenant Admin
participant Console as Web Console
participant Identity as Identity Platform
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
Admin->>Console: Click "Regenerate Secret"<br/>on client "Wallet Backend"
Console-->>Admin: Confirm dialog:<br/>"This will invalidate the<br/>current secret immediately.<br/>Any service using it will<br/>lose access. Continue?"
Admin->>Console: Confirm
Console->>Identity: POST /v1/clients/{client_id}/regenerate<br/>Authorization: Console session
Identity->>Identity: Validate admin role
Identity->>Identity: Extract tenant_id from session
Identity->>DB: Get client, verify tenant match
alt Client not found or wrong tenant
DB-->>Identity: Not found
Identity-->>Console: 404 Not Found
else Client found
DB-->>Identity: Client record
Identity->>Identity: Generate new client_secret<br/>(random 256-bit, base64url)
Identity->>Identity: Hash new secret (bcrypt)
Identity->>DB: Replace secret_hash<br/>Update last_rotated_at
Identity->>Audit: Log: client.secret_regenerated<br/>(client_id, tenant_id,<br/>actor: admin)
Identity-->>Console: {client_id: "cli_abc123",<br/>client_secret: "sk_live_new...",<br/>regenerated_at: "..."}
Note over Console: New secret shown ONCE<br/>Old secret dead immediately
Console-->>Admin: Show new secret<br/>(copy button, one-time view)
Admin->>Console: Copy secret, dismiss
Console->>Console: Secret cleared from UI
end
Note over Admin: Deploy new secret to<br/>backend services
Diagram 8.3 — Revoke Client
sequenceDiagram
autonumber
participant Admin as Tenant Admin
participant Console as Web Console
participant Identity as Identity Platform
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
Admin->>Console: Click "Revoke" on client<br/>"Analytics Pipeline"
Console-->>Admin: Confirm dialog:<br/>"This will permanently revoke<br/>this client. New token requests<br/>will be rejected. Existing tokens<br/>expire within 1 hour. Continue?"
Admin->>Console: Confirm
Console->>Identity: DELETE /v1/clients/{client_id}<br/>Authorization: Console session
Identity->>Identity: Validate admin role
Identity->>Identity: Extract tenant_id from session
Identity->>DB: Get client, verify tenant match
alt Client not found or wrong tenant
DB-->>Identity: Not found
Identity-->>Console: 404 Not Found
else Client found
DB-->>Identity: Client record
Identity->>DB: Update client<br/>(status: revoked,<br/>revoked_at, revoked_by)
Identity->>Audit: Log: client.revoked<br/>(client_id, tenant_id,<br/>actor: admin)
Identity-->>Console: {client_id: "cli_abc123",<br/>status: "revoked",<br/>revoked_at: "..."}
Console->>Console: Update table<br/>(show revoked badge)
Console-->>Admin: "Client revoked"
Note over Identity: Existing tokens issued by this<br/>client remain valid until expiry<br/>(max 1 hour). No new tokens<br/>can be issued.
end
6.5 Webhook Signing Secrets¶
Webhook endpoints have their own signing secret, separate from OAuth client secrets and with an independent lifecycle. The full create-once / bcrypt-hash / regenerate spec and HMAC verification are documented with the webhook capability — see §12.3. The webhook_endpoints schema is in §5.3.
6.6 Client Credentials Grant (Backend-to-Backend)¶
POST /v1/auth/token
{
"grant_type": "client_credentials",
"client_id": "wallet",
"client_secret": "<secret>"
}
Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600
}
Diagram 2.6 — Backend-to-Backend (Client Credentials)
sequenceDiagram
autonumber
participant Wallet as Wallet Backend
participant Identity as Identity Platform
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
Note over Wallet: On startup / token refresh
Wallet->>Identity: POST /v1/auth/token<br/>{grant_type: "client_credentials",<br/>client_id: "wallet",<br/>client_secret: "secret123"}
Identity->>DB: Validate client credentials
alt Invalid credentials
DB-->>Identity: Client not found / secret mismatch
Identity->>Audit: Log: client_auth_failed<br/>(client_id: wallet, reason: invalid_credentials)
Identity-->>Wallet: 401 Unauthorized
else Valid credentials
DB-->>Identity: Client valid, tenant_id: wallet
Identity->>Identity: Generate JWT (1h expiry)<br/>Claims: {client_id, tenant_id, scope}
Identity->>Audit: Log: token_issued<br/>(client_id: wallet, grant_type: client_credentials)
Identity-->>Wallet: {access_token, expires_in: 3600}
Wallet->>Wallet: Cache token, set refresh timer
end
Note over Wallet: Making API calls
Wallet->>Identity: GET /v1/users/{user_id}<br/>Authorization: Bearer <access_token>
Identity->>Identity: Validate JWT
Identity->>Identity: Extract tenant_id from claims
Identity-->>Wallet: {user_id, mobile, palm_enrolled,<br/>kyc_status, consent_status}
6.7 OTP Grant (Phone-based Auth)¶
1. POST /v1/auth/otp/send {mobile: "+966..."}
2. User receives SMS
3. POST /v1/auth/token {grant_type: "otp", mobile: "+966...", otp: "123456"}
4. Response: {access_token, refresh_token, user_id, is_new_user}
Diagram 2.1 — Mobile Signup (OTP)
sequenceDiagram
autonumber
participant User
participant App as Mobile App
participant Identity as Identity Platform
participant SMS as SMS Gateway
participant DB as PostgreSQL
participant Redis
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
User->>App: Enter mobile number
App->>Identity: POST /v1/auth/otp/send<br/>{mobile: "+966..."}
Identity->>DB: Get tenant config
DB-->>Identity: {auth_methods: ["otp", ...]}
alt OTP not enabled for tenant
Identity-->>App: 400 Bad Request<br/>"OTP auth not enabled"
else OTP enabled
Identity->>Identity: Generate 6-digit OTP
Identity->>Redis: Store OTP (TTL: 5 min)
Identity->>SMS: Send OTP to mobile
SMS-->>User: SMS with OTP code
Identity-->>App: {otp_id, expires_in: 300}
end
User->>App: Enter OTP code
App->>Identity: POST /v1/auth/token<br/>{grant_type: "otp", mobile, otp}
Identity->>Redis: Validate OTP
alt OTP invalid
Redis-->>Identity: Not found / expired
Identity->>Audit: Log: login_attempt<br/>(status: failed, reason: invalid_otp)
Identity-->>App: 401 Unauthorized
else OTP valid
Redis-->>Identity: OTP valid
Identity->>Redis: Delete OTP (one-time use)
Identity->>DB: Find user by mobile
alt User exists
DB-->>Identity: User found
Identity->>Audit: Log: login_attempt<br/>(status: success, user_id)
else New user
Identity->>DB: Create user
DB-->>Identity: user_id created
Identity->>Audit: Log: user_created<br/>(user_id, method: otp)
Identity->>Identity: Publish UserCreated event
opt Webhook subscribed
Identity->>Identity: Send webhook: user.created<br/>{user_id, method: "otp"}
end
end
Identity->>Identity: Generate JWT (access_token)
Identity->>Identity: Generate refresh_token
Identity->>Redis: Store refresh_token
Identity->>Audit: Log: token_issued<br/>(user_id, grant_type: otp)
Identity-->>App: {access_token, refresh_token,<br/>user_id, is_new_user}
end
App->>App: Store tokens securely
App-->>User: Welcome / Home screen
6.8 Password Grant (Email/Password Auth)¶
Registration:
POST /v1/auth/register
{
"email": "user@example.com",
"password": "SecurePass123!",
"name": "John Doe"
}
Login:
POST /v1/auth/token
{
"grant_type": "password",
"email": "user@example.com",
"password": "SecurePass123!"
}
Diagram 2.2 — Email/Password Registration
sequenceDiagram
autonumber
participant User
participant App as Mobile App
participant Identity as Identity Platform
participant DB as PostgreSQL
participant Redis
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
User->>App: Enter email, password, name
App->>Identity: POST /v1/auth/register<br/>{email, password, name}
Identity->>DB: Get tenant config
DB-->>Identity: {auth_methods: ["password", ...]}
alt Password auth not enabled
Identity-->>App: 400 Bad Request<br/>"Password auth not enabled"
else Password auth enabled
Identity->>DB: Check if email exists
alt Email exists
DB-->>Identity: User found
Identity->>Audit: Log: login_attempt<br/>(status: failed, method: registration,<br/>reason: email_exists)
Identity-->>App: 409 Conflict<br/>"Email already registered"
else Email available
Identity->>Identity: Validate password policy
Identity->>Identity: Hash password (bcrypt)
Identity->>DB: Create user with<br/>email, password_hash, profile
DB-->>Identity: user_id created
Identity->>Audit: Log: user_created<br/>(user_id, method: password)
Identity->>Identity: Publish UserCreated event
opt Webhook subscribed
Identity->>Identity: Send webhook: user.created<br/>{user_id, method: "password"}
end
Identity->>Identity: Generate JWT (access_token)
Identity->>Identity: Generate refresh_token
Identity->>Redis: Store refresh_token
Identity->>Audit: Log: token_issued<br/>(user_id, grant_type: password)
Identity-->>App: {access_token, refresh_token,<br/>user_id, is_new_user: true}
App->>App: Store tokens securely
App-->>User: Welcome / Home screen
end
end
6.9 Social Login (Google/Apple)¶
1. Mobile app → SDK → user authenticates
2. SDK returns ID token
3. POST /v1/auth/token {grant_type: "google"|"apple", id_token: "eyJ..."}
4. Identity validates with provider public keys
5. Response: {access_token, refresh_token, user_id, is_new_user}
Diagram 2.3 — Social Login (Google/Apple)
sequenceDiagram
autonumber
participant User
participant App as Mobile App
participant SDK as Google/Apple SDK
participant Provider as Google/Apple Servers
participant Identity as Identity Platform
participant DB as PostgreSQL
participant Redis
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
User->>App: Tap "Sign in with Google"
App->>SDK: Initiate OAuth flow
SDK->>Provider: Authorization request
Provider-->>User: Consent screen
User->>Provider: Grant consent
Provider-->>SDK: Authorization code
SDK->>Provider: Exchange for ID token
Provider-->>SDK: ID token (JWT)
SDK-->>App: ID token
App->>Identity: POST /v1/auth/token<br/>{grant_type: "google", id_token}
Identity->>DB: Get tenant config
DB-->>Identity: {auth_methods: ["google", ...],<br/>auto_link_enabled: true}
alt Google auth not enabled
Identity-->>App: 400 Bad Request<br/>"Google auth not enabled"
else Google auth enabled
Identity->>Provider: GET /.well-known/jwks.json
Provider-->>Identity: Public keys
Identity->>Identity: Validate ID token signature
Identity->>Identity: Extract claims<br/>(sub, email, name)
Identity->>DB: Find user by google_id (sub)
alt User exists by social ID
DB-->>Identity: User found
Identity->>Audit: Log: login_attempt<br/>(status: success, method: google)
else Check by email (auto-link)
Identity->>DB: Find user by verified email
alt Email matches existing user
DB-->>Identity: User found
Identity->>DB: Link google_id to user
Identity->>Audit: Log: identity_linked<br/>(user_id, type: google)
opt Webhook subscribed
Identity->>Identity: Send webhook: user.identity_linked<br/>{user_id, identity_type: "google"}
end
else New user
Identity->>DB: Create user with<br/>google_id, email, profile
DB-->>Identity: user_id created
Identity->>Audit: Log: user_created<br/>(user_id, method: google)
Identity->>Identity: Publish UserCreated event
opt Webhook subscribed
Identity->>Identity: Send webhook: user.created<br/>{user_id, method: "google"}
end
end
end
Identity->>Identity: Generate JWT (access_token)
Identity->>Identity: Generate refresh_token
Identity->>Redis: Store refresh_token
Identity->>Audit: Log: token_issued<br/>(user_id, grant_type: google)
Identity-->>App: {access_token, refresh_token,<br/>user_id, is_new_user}
end
App->>App: Store tokens securely
App-->>User: Welcome / Home screen
6.10 Refresh Token Grant¶
Refresh Token Rotation: Each refresh returns a new refresh token. Old one is invalidated.
Diagram 2.4 — Token Refresh
sequenceDiagram
autonumber
participant App as Mobile App
participant Identity as Identity Platform
participant Redis
participant DB as PostgreSQL
participant Audit as Audit Log
Note over Identity, Audit: All audit logging is conditional<br/>on tenant audit_enabled setting
Note over App: Access token expired or expiring soon
App->>Identity: POST /v1/auth/token<br/>{grant_type: "refresh_token",<br/>refresh_token: "abc123"}
Identity->>Redis: Validate refresh_token
alt Token valid
Redis-->>Identity: Token data (user_id, issued_at)
Identity->>Redis: Delete old refresh_token
Identity->>DB: Get user (validate still active)
alt User suspended/deleted
DB-->>Identity: User not active
Identity->>Audit: Log: token_refresh_failed<br/>(user_id, reason: user_inactive)
Identity-->>App: 401 Unauthorized
else User active
DB-->>Identity: User active
Identity->>Identity: Generate new access_token
Identity->>Identity: Generate new refresh_token
Identity->>Redis: Store new refresh_token
Identity->>Audit: Log: token_issued<br/>(user_id, grant_type: refresh_token)
Identity-->>App: {access_token, refresh_token,<br/>expires_in: 86400}
App->>App: Replace stored tokens
end
else Token invalid/expired
Redis-->>Identity: Not found
Identity->>Audit: Log: token_refresh_failed<br/>(reason: invalid_token)
Identity-->>App: 401 Unauthorized<br/>"Invalid refresh token"
App->>App: Clear tokens
App-->>App: Redirect to login
end
6.11 Account Auto-Linking¶
Linking Rules:
| Identifier | Behavior |
|---|---|
| Email (verified) | Link if existing user has same verified email |
| Phone (verified) | Link if existing user has same verified phone |
| Apple/Google ID | Link if existing user has same social ID |
Account auto-linking is always enabled. There is no setting to disable it. (Not to be confused with a linked service — §10 — or the broker; see the §2 disambiguation note.)
6.12 JWKS for Token Validation¶
Endpoint: GET /.well-known/jwks.json
Envoy validates user session JWTs without calling Identity Platform. The same JWKS is used by linked-service products to verify the broker's identity_assertion (§10).
Diagram 2.5 — Mobile → Envoy → Vertical (JWKS)
sequenceDiagram
autonumber
participant App as Mobile App
participant Envoy as Envoy Gateway
participant JWKS as Identity JWKS Endpoint
participant Wallet as Wallet Backend
Note over Envoy: On startup / cache refresh
Envoy->>JWKS: GET /.well-known/jwks.json
JWKS-->>Envoy: {keys: [{kty, kid, n, e, ...}]}
Envoy->>Envoy: Cache public keys
App->>Envoy: GET /wallet/balance<br/>Authorization: Bearer <JWT>
Envoy->>Envoy: Extract JWT from header
Envoy->>Envoy: Parse JWT header (kid)
Envoy->>Envoy: Find matching public key
Envoy->>Envoy: Validate signature (RS256)
Envoy->>Envoy: Check expiry (exp claim)
Envoy->>Envoy: Extract user_id, tenant_id from claims
alt JWT valid
Envoy->>Wallet: GET /balance<br/>X-User-ID: user_123<br/>X-Tenant-ID: wallet
Wallet->>Wallet: Process request for user_123
Wallet-->>Envoy: {balance: 500.00}
Envoy-->>App: {balance: 500.00}
else JWT invalid/expired
Envoy-->>App: 401 Unauthorized
end
6.13 Auth Summary¶
| Caller | Target | Mechanism | Validated by |
|---|---|---|---|
| Mobile App | Vertical (via Envoy) | User session JWT | Envoy (JWKS) |
| Vertical Backend | Identity | OAuth client_credentials | Identity |
| InvestGlass | Identity | OAuth client_credentials | Identity |
| POS / Gate / Kiosk | Identity | mTLS / device cert | Identity |
| Personal Scanner | Identity | mTLS / device cert | Identity |