Skip to content

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

POST /v1/auth/token
{
  "grant_type": "refresh_token",
  "refresh_token": "def456..."
}

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