Skip to content

Authentication

All authentication on the platform terminates at one FastAPI service — cashless-auth, deployed at auth.dev2.mycashless.com. It mints JWT access + opaque refresh tokens and runs every login flow. Backend services (cm_v2, pa_v4) do not verify JWTs locally — they round-trip every bearer token through POST /tokens/verify, fronted by a 60-second Redis claims cache so the hot path stays cheap. Frontends and mobile apps hold the token in storage and attach it as Authorization: Bearer <jwt> on every API call. See architecture.md for how cashless-auth fits into the larger service map.

Keycloak / Better Auth

The home page lists Keycloak + Better Auth in the platform stack table. Both are aspirational today — neither is wired into any repo on cashless-v2. Identity, sessions, MFA, and token issuance all live in the cashless-auth FastAPI service described below. The CSRF audit at services/cashless-auth/docs/csrf-audit-2026-05.md confirms there is no cookie-session integration today.

What runs where

Concern Where it lives
Login flows + token issuance services/cashless-auth/auth/routers/
JWT encode / decode auth/tokens.py
Role / permission resolution auth/permissions.py
TOTP MFA (admin) auth/mfa.py + auth/admin_mfa.py
Rate limiting auth/ratelimit.py (slowapi + Redis)
Revocation blocklist Redis — cashless:token:revoked:<jti>, TTL = refresh-token lifetime
Caller-side claims cache cm_v2/auth_client.py + pa_v4/auth_client.py — 60s Redis cache keyed by sha256(token)

Login + authenticated request

sequenceDiagram
    autonumber
    participant C as Client (web / mobile)
    participant A as cashless-auth
    participant R as Redis (auth)
    participant B as Backend (cm_v2 / pa_v4)
    participant Rb as Redis (backend)

    C->>A: POST /auth/login {email,password,role}
    A->>A: argon2id verify + build_claims()
    A-->>C: 200 {access_token, refresh_token, expires_in}

    C->>B: GET /v2/... Authorization: Bearer <jwt>
    B->>Rb: GET cm_v2:auth:claims:sha256(jwt)
    alt cache miss
        B->>A: POST /tokens/verify {token}
        A->>R: EXISTS cashless:token:revoked:<jti>
        A-->>B: 200 {valid:true, claims:{sub,role,...}}
        B->>Rb: SETEX ...sha256(jwt) 60s
    end
    B-->>C: 200 <response>

Login flows

Every flow returns the same TokenResponse shape (access_token, refresh_token, expires_in) unless noted.

Flow Endpoint Limit Issued role(s) Notes
Password POST /auth/login 10/min/IP admin, operator, network_manager Role chosen in body. argon2id hashes. Uniform 401 across all failure branches.
Customer password POST /auth/login/customer 10/min/IP consumer Phone + password.
Phone OTP POST /auth/login/phone + /auth/login/phone/verify 30/min/IP consumer Two-step: request OTP, then verify code.
Firebase POST /auth/login/firebase 30/min/IP consumer Verifies Firebase ID token, finds-or-creates Customer.
Phone-ownership (WhatsApp) POST /verify/whatsapp/{start,check} 5/min + 30/min/IP (no token — verification only) Confirms phone ownership via Meta Graph API. Caller flows back through /auth/login/customer.
Device (POS) POST /auth/login/device 60/min/IP (role of assigned operator) Returns an offline token (type=offline, 72h default). Requires a pre-assigned operator.
Offline token POST /tokens/offline 30/min/IP admin or operator Authenticated mint of an event-scoped offline token.
API key POST /tokens/api-key 10/min/IP (event-client scopes) Admin-gated. Plaintext key returned once; only the SHA-256 hash is persisted.
Refresh POST /auth/refresh 30/min/IP (same as input refresh) Refresh-token rotation — the old JTI is revoked on success.
Logout POST /auth/logout 30/min/IP n/a Adds the refresh JTI to the Redis blocklist.

TBD — Nexmo SMS OTP

auth/routers/verify.py marks DEBT(nexmo-transport): the /verify/nexmo/{start,check} pair via the Vonage SDK is deferred until credentials are provisioned. Phone proof on cardholder flows currently goes through WhatsApp or Firebase ID-token exchange.

Admin MFA

TOTP only, gated to the admin role and pinned to the freshness model in auth/admin_mfa.py.

  1. POST /admin/mfa/setup — generates a TOTP secret + otpauth:// URI for QR provisioning.
  2. POST /admin/mfa/verify — verifies the 6-digit code, sets is_two_factor_authentication_enabled=true, and re-mints the token pair with mfa_verified_at = now().
  3. Routes guarded by Depends(require_admin_mfa_fresh) reject tokens whose mfa_verified_at is older than 8 hours (MFA_FRESHNESS_WINDOW_HOURS) with a structured 403 {"action":"require_mfa","reason":"mfa_stale"|"mfa_never"}.

Token shapes

JWT signing uses HS256 with a single platform secret (JWT_SECRET_KEY). There is no JWKS endpoint — backends do not verify locally; they call POST /tokens/verify instead.

Property Access token Refresh token Offline token (POS)
type claim access refresh offline
Lifetime ACCESS_TOKEN_EXPIRE_MINUTES (default 15 min) REFRESH_TOKEN_EXPIRE_DAYS (default 30 d) OFFLINE_TOKEN_EXPIRE_HOURS (default 72 h)
Claims sub, role, event_ids, permissions, jti, iat, exp, aud, iss, mfa_verified_at?, device_id? sub, role, jti, iat, exp, aud, iss access claims + event_id, optional device_id
aud / iss cashless-platform / cashless-auth (configurable) same same
Rotation re-issued on every /auth/refresh rotated on /auth/refresh (old JTI revoked) re-issued by /tokens/offline or /auth/login/device
Revocation not directly revocable — wait for exp cashless:token:revoked:<jti> in Redis, TTL = refresh TTL not directly revocable
Storage client memory / localStorage client secure storage device-local

aud and iss are asserted on decode (PyJWT raises InvalidAudienceError / InvalidIssuerError) so a token minted for one cluster is rejected by another. See auth/tokens.py:verify_token.

Roles and permissions

Tokens carry a role claim plus a per-event permissions map of the form { "<event_id>": ["pos:sell", ...], "*": [...] }. The role inventory and its baseline scope set lives in auth/permissions.py.

Role Issued by Baseline scopes (subset)
admin /auth/login (role=admin) → Admin row events:manage, reports:read/write, devices:manage, users:manage, billing:manage
operator /auth/login (role=operator) → User row pos:sell, pos:refund, topup:balance, checkin:nfc/manual/fastpass, ticket:sell/scan
network_manager /auth/login (role=network_manager) events:manage, reports:read, devices:manage, users:manage
consumer /auth/login/customer, /auth/login/phone/verify, /auth/login/firebase, /auth/register* wallet:read, wallet:topup, profile:read/write
device /auth/login/device (offline token) pos:sell, pos:refund, sync:upload/download
event_client /tokens/api-key api:read, api:write
  • Admin.role == "superadmin" resolves to all ADMIN_PERMISSIONS for every event (empty event_ids semantically means "all"). Other admins are scoped to their Admin.event_ids CSV.
  • Operators are bound to a single User.event_id; the permissions JSON column supplies their per-event scope list (falling back to OPERATOR_PERMISSIONS if unset).
  • Per-event scopes travel inside the JWT — guards do not re-query the DB on every request, but the /auth/refresh path does (_rebuild_claims_from_db) so revoked scopes propagate within one access-token lifetime.

Rate limiting

auth/ratelimit.py wires slowapi against Redis (REDIS_URL) and honours X-Forwarded-For for the real client IP behind ingress. Per-route limits are declared with @limiter.limit("N/min"); the figures appear in the flow table above. The limiter sets swallow_errors=True — a Redis blip fails open rather than 500-ing every public endpoint. 429 responses are structured ({"error":"rate_limit_exceeded", ...}) with Retry-After attached.

Auth-claims cache (Wave 6 G4)

Every authenticated request into cm_v2 and pa_v4 would otherwise fan out to cashless-auth POST /tokens/verify. Wave 6 added a caller-side Redis cache that fronts that call. The wiring lives in cm_v2/auth_client.py (mirrored in pa_v4/auth_client.py):

Property Value
Key cm_v2:auth:claims:<sha256(token)> (token is never stored in plaintext)
TTL CLAIMS_CACHE_TTL_SECONDS = 60 seconds
Value The full TokenClaims (sub, role, type, raw) as JSON
Miss → fetch POST /tokens/verify → write-back on valid=true
Invalidation invalidate_token_claims(token) drops the entry; called on revocation hooks
Failure mode Redis fault → log + fall through to live verify (pre-G4 behaviour)

Errors and invalid responses are never cached — a transient outage or revocation cannot extend itself beyond the live request.

Validating a request — caller pattern

The canonical FastAPI integration lives in cm_v2/deps.py (pa_v4/deps.py mirrors it):

  1. require_bearer_token parses Authorization: Bearer <jwt>BearerToken.
  2. verify_token_claims calls AuthClient.verify(token) (cache-aside → POST /tokens/verify). InvalidTokenError401, AuthServiceError503. Never 500.
  3. Domain dependencies (require_admin, ...) load the role-specific row using claims.sub as the primary key (Admin.id, User.id, Customer.id, NetworkManager.id).

Backends never hold JWT_SECRET_KEY and never decode JWTs locally — cashless-auth is the single source of truth. Per-route guards (require_role, require_permission, require_event_access, require_admin_mfa_fresh) layer on top of Claims; see auth/middleware.py.

Where to look next