Auth Flow¶
Session Cookies¶
The backend sets two HttpOnly cookies on successful authentication:
| Cookie | Lifetime | Purpose |
|---|---|---|
ftl_access |
Short (minutes) | Access token for all API requests |
ftl_refresh |
7 days | Refresh token; used to mint a new access token |
The frontend never reads cookies directly. It sends credentials: 'include' on all fetch calls so cookies travel automatically.
Google Identity Services (GIS) Flow¶
Implemented in ftl-frontend/src/hooks/useGoogleAuth.ts.
Nonce¶
A nonce is minted per login attempt using crypto.randomUUID() and stored in nonceRef. It is passed to GIS initialize() and sent alongside the idToken to the backend. The backend stores the nonce in Redis with SETNX after verification, burning it for 5 minutes. This prevents token replay attacks.
rotateNonce() mints a fresh UUID and re-initializes GIS (calling google.accounts.id.cancel() first to dispose the cached credential). Register.tsx calls rotateNonce() after every failed callback — validation error, 401, consent not ticked, etc. — so the next sign-in attempt carries a nonce that has not been seen by the backend.
crypto.randomUUID() → nonceRef
GIS initialize({ client_id, nonce, callback })
User clicks button → callback({ credential: idToken })
→ onToken(idToken, nonceRef.current)
→ api.authGoogle(idToken, nonce)
→ POST /api/auth/google
The client ID is read from import.meta.env.VITE_GOOGLE_CLIENT_ID at build time. If the env var is missing, GIS is disabled and a console warning is emitted.
3-Step Registration (Register.tsx)¶
flowchart TD
A[User clicks Sign in with Google] --> B[GIS callback: idToken + nonce]
B --> C[POST /api/auth/google]
C -- existing user --> D[Session cookies set]
D --> E[Navigate to /stadium or flag gate]
C -- new user, needsEmailOTP --> F[Step 2: OTP verification]
C -- new user, no OTP --> G[Step 3: Profile form]
F -- 6-digit code correct --> G
G --> H[POST /api/auth/google/complete]
H --> I[Session cookies set]
I --> E
Step 1 — Google OAuth¶
POST /api/auth/google returns:
- Existing user → session cookies set, navigate away.
- New user, needsEmailOTP: true → show OTP step (step 2).
- New user, no OTP required → skip to profile form (step 3).
The response also carries registrationTicket (opaque token linking the OAuth identity to the profile step), email, name, givenName, familyName, picture.
Step 2 — Email OTP Verification¶
POST /api/auth/verify-otp with { registrationTicket, otp }. A 401 response carries { error: 'invalid_code', attemptsLeft: number } so the UI can show "N attempts remaining" inline. POST /api/auth/resend-otp re-sends the code; a 429 response means the 60-second cooldown has not expired.
Step 3 — Profile Form¶
POST /api/auth/google/complete with:
| Field | Type |
|---|---|
registrationTicket |
string |
firstName |
string |
lastName |
string |
username |
string |
phone |
string |
age |
number |
districtId |
string |
referralCode |
string (optional) |
inviteId |
string (optional) |
On success, session cookies are set and the user is navigated to the post-auth path.
Username availability is checked on debounce via POST /users/check-username. Referral code validity is checked via GET /public/referral/validate?code=… (unauthenticated endpoint).
Multi-Tab Safety¶
Chrome cookies are origin-scoped, not tab-scoped. Logging in as a second user in another tab overwrites the ftl_access cookie for all tabs of that origin.
The fix: every time a tab becomes visible or focused, App.tsx re-runs hydrateFromMe() (calls GET /api/me). If the returned user.id differs from the stored one, setAuth() atomically wipes all per-user state before populating the new user's data.
Dev Auth¶
When VITE_DEV_AUTH=true (local dev only), hydrateFromMe() falls back to POST /api/auth/dev if /api/me returns no user. This creates a seeded test user locally without going through the Google OAuth round-trip.