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.
POST /admin/mfa/setup— generates a TOTP secret +otpauth://URI for QR provisioning.POST /admin/mfa/verify— verifies the 6-digit code, setsis_two_factor_authentication_enabled=true, and re-mints the token pair withmfa_verified_at = now().- Routes guarded by
Depends(require_admin_mfa_fresh)reject tokens whosemfa_verified_atis older than 8 hours (MFA_FRESHNESS_WINDOW_HOURS) with a structured403 {"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 allADMIN_PERMISSIONSfor every event (emptyevent_idssemantically means "all"). Other admins are scoped to theirAdmin.event_idsCSV.- Operators are bound to a single
User.event_id; thepermissionsJSON column supplies their per-event scope list (falling back toOPERATOR_PERMISSIONSif unset). - Per-event scopes travel inside the JWT — guards do not re-query
the DB on every request, but the
/auth/refreshpath 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):
require_bearer_tokenparsesAuthorization: Bearer <jwt>→BearerToken.verify_token_claimscallsAuthClient.verify(token)(cache-aside →POST /tokens/verify).InvalidTokenError→401,AuthServiceError→503. Never500.- Domain dependencies (
require_admin, ...) load the role-specific row usingclaims.subas 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¶
-
Swagger / ReDoc for
cashless-auth,cm_v2, andpa_v4. -
Service boundaries and how
cashless-authfits into the platform. -
All deployed hosts on
dev2.mycashless.com. -
Routers, flows, tests. Lives inside the parent repo (not a submodule).