Authentication¶
Overview¶
All auth uses raw Google OAuth 2.0. There is no third-party auth service. FTL signs its own JWTs using RS256.
Google token verification¶
The handler calls auth.NewGoogleVerifier(cfg.GoogleClientID) on startup. FetchKeys fetches the JWKS from https://www.googleapis.com/oauth2/v3/certs and stores them in a map[kid]*rsa.PublicKey. The goroutine StartRefresh re-fetches every 6 hours.
VerifyIDToken(tokenStr, expectedNonce) in internal/auth/google.go:
1. Parses the JWT header to extract kid.
2. Looks up the RSA public key by kid.
3. Validates signature, audience (GOOGLE_CLIENT_ID), issuer (accounts.google.com), and expiry.
4. Checks email_verified == true.
5. If expectedNonce != "", checks the nonce claim matches.
Login flow (existing user)¶
sequenceDiagram
participant Client
participant API as api-server
participant Google
participant Redis
participant PG as Postgres
Client->>API: POST /api/auth/google {idToken, nonce}
API->>Google: Verify idToken signature (local JWKS)
Google-->>API: Claims (sub, email, nonce)
API->>Redis: SETNX google:nonce:{nonce} 1 EX 300
Redis-->>API: OK (first use)
API->>PG: SELECT user WHERE google_sub = sub
PG-->>API: User row
API->>Client: 200 + Set-Cookie (ftl_access, ftl_refresh) + {user, isNew:false}
Decision: The Google token is verified before the nonce is consumed. This ensures a failed verification leaves the nonce unclaimed so the user can retry immediately without waiting for the 5-minute TTL.
Registration flow (new user)¶
sequenceDiagram
participant Client
participant API as api-server
participant Redis
participant PG as Postgres
participant ACS as Azure Email
Client->>API: POST /api/auth/google {idToken, nonce}
API->>Redis: SETNX google:nonce:{nonce} 1 EX 300
API->>Redis: Issue OTP (6-digit, 60s cooldown)
API->>ACS: Send verification email (async goroutine)
API->>Client: 422 {needsProfile:true, registrationTicket, email, ...}
Client->>API: POST /api/auth/verify-otp {registrationTicket, otp}
API->>Redis: Verify OTP code; set regotp:verified:{sub}
API->>Client: 200 {verified:true}
Client->>API: POST /api/auth/google/complete {registrationTicket, firstName, ...}
API->>Redis: Check regotp:verified:{sub}
API->>PG: INSERT user + wallet (10K starting balance)
API->>Redis: HSET wallet:{userId} balance 10000
API->>Client: 200 + Set-Cookie + {user, isNew:true, ...}
Registration ticket: HMAC-SHA256 signed by REG_TICKET_SECRET with a REG_TICKET_TTL expiry (default 5 min). Carries sub, email, name, picture. The frontend passes it back at each step so the server never trusts raw claims from the client.
OTP: 6 digits, stored in Redis at otp:{sub}, valid for 5 minutes, max 3 verify attempts, 60-second resend cooldown. AuthGoogleComplete checks regotp:verified:{sub} and rejects if not set.
JWT types¶
All tokens use RS256 with issuer ftl-api.
| Type constant | Cookie | Default expiry | Claims |
|---|---|---|---|
TypAccess ("access") |
ftl_access |
JWT_ACCESS_EXPIRY (15m) |
userId, districtId, role, typ |
TypRefresh ("refresh") |
ftl_refresh |
JWT_REFRESH_EXPIRY (168h / 7d) |
userId, typ |
TypWS ("ws") |
Query param ?token= |
JWT_WS_EXPIRY (60s) |
userId, typ |
The JWTValidator.Validate(tokenStr, expectedTyp) call in internal/auth/jwt.go enforces the typ claim, preventing access tokens from being used as WebSocket tokens.
Cookies¶
Set by auth.SetAccessCookie and auth.SetRefreshCookie:
HttpOnly: true— no JavaScript access.Secure: cfg.CookieSecure— set only whenENV=production.SameSite=None; Partitionedin production (CHIPS, for cross-site Safari compatibility);SameSite=Laxin local dev.Domain: COOKIE_DOMAIN(empty in dev, set to the apex domain in prod).
Token refresh¶
POST /api/auth/refresh accepts the ftl_refresh cookie. It validates the refresh token against the JTI blocklist, fetches the user from Postgres, revokes the old refresh JTI, and issues a new access+refresh pair.
Logout¶
POST /api/auth/logout (public route — works even with an expired access cookie). Revokes both JTIs via auth.Blocklist (Redis SET jti:{id} 1 EX <remaining-ttl>). Clears both cookies unconditionally.
JWTAuth middleware¶
middleware.JWTAuth in internal/middleware/:
1. Extracts the ftl_access cookie.
2. Calls JWTValidator.Validate(token, "access").
3. Checks the JTI against the auth.Blocklist.
4. Sets c.Locals("userId") and c.Locals("role").
middleware.JWTAuthOptional follows the same path but returns a zero-value instead of 401 on failure (used for GET /api/me).
middleware.AdminOnly reads c.Locals("role") and returns 403 unless it equals "admin". It must run after JWTAuth.
Admin promotion¶
handler.BootstrapAdminsFromEnv reads ADMIN_EMAILS at startup and promotes any pre-registered users. PromoteIfAdminEmail runs on every login for the same list. Admins get role=admin in their JWT claims on next token issue.