Skip to content

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 when ENV=production.
  • SameSite=None; Partitioned in production (CHIPS, for cross-site Safari compatibility); SameSite=Lax in 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.