Skip to content

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.