API: POST /auth/callback
POST /auth/callback
Exchange a Firebase ID token (from federation broker — Google or Apple) for an internal NestJS session JWT. Lazy-creates User + Walker rows on first login.
Status: draft contract — implementation in phase 8b.
Related: ADR-0005 (Path B Firebase shim), ops/decisions/2026-05-18-firebase-eu-residency-verification.md.
Test-phase mock mode
Phase 8b ships against the test-phase infrastructure per ADR-0006, not the production target. Differences from the contract below:
- No Firebase ID token verify. The body accepts
{ email, displayName }in lieu offirebaseIdToken. The server lazy-createsUser+Walkerbyemailinstead of by hashed Firebasesub. - No App Check token requirement.
X-Firebase-AppCheckheader is ignored (not required, not validated). - JWT signed by NestJS-local key. HS256 (symmetric secret in
.env) or RS256 (key pair inbackend/keys/) — tech-architect picks at implementation time. Production swap is a single ENV flag flip (AUTH_MODE=firebase). - JWT expiry extended to 7 days (production: 24h) to reduce test-session friction.
- Response envelope is identical.
{ session, walker, isFirstLogin, forcedUpgradeRequired }shape matches production — mobile and curl clients see no shape difference. - ENV flag:
AUTH_MODE=mock(default in.env.example) selects this path.AUTH_MODE=firebaseselects the production path described below, but the production path is not implemented in phase 8b.
The production contract below describes the target state, unfrozen when the production migration is greenlit.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /auth/callback |
| Auth | None (this IS the auth handshake) |
| Idempotent | Yes (repeated calls with same valid token return same internal session) |
| Rate limit | 20 req/min/IP, 5 req/min per Firebase sub |
Request
Headers
| Header | Required | Notes |
|---|---|---|
Content-Type | yes | application/json |
X-Firebase-AppCheck | yes | Firebase App Check token (Play Integrity / DeviceCheck) |
Accept-Language | optional | pl or en — defaults to en, used to localize welcome push |
Body
{ "firebaseIdToken": "eyJhbGc...", "displayName": "Wanderer of Plenny", "regionPreference": "eu", "clientPlatform": "ios" | "android", "clientVersion": "1.0.0+1"}| Field | Type | Required | Notes |
|---|---|---|---|
firebaseIdToken | string | yes | Short-lived (~1h) ID token from Firebase Auth federation broker, contains sub claim from Google/Apple. |
displayName | string (1..40) | optional | Walker-facing display name. If missing or first login: server suggests a generated one (e.g. Wanderer-a1b2c3). Walker can edit later via PATCH /walker/profile. |
regionPreference | string | optional | eu only at MVP (D-009). Field exists for future expansion. |
clientPlatform | enum | yes | ios or android |
clientVersion | string | yes | semver+build. Used for forced-upgrade gating. |
Server flow
- Verify
X-Firebase-AppChecktoken via Firebase Admin SDK. Reject 401 if invalid. - Verify
firebaseIdTokensignature against Firebase JWKS. Reject 401 if invalid. - Extract
subclaim (opaque external identifier). Reject 401 if absent. - Look up
UserbyfirebaseExternalSub = sha256(sub)(hashed at rest). - If User does not exist:
a. Create
Userrow in Cloud SQL Warsaw with:email(from token claims if present),displayName(from request body or generated),firebaseExternalSub(hashed),createdAt,lastActiveAt. b. CreateWalkerrow tied to User: starter region =region.plenny, level 0, totalLifetimeSteps 0, availablePoints 0. c. InitializeStreakState(zeros). d. InitializeFactionReprows for all 5 known factions at neutral tier 0. - Update
User.lastActiveAt = now(). - Mint internal session JWT signed by KMS key in
europe-central2:sub= internal User UUIDwalkerId= internal Walker UUIDexp= now + 24hiss=walkrpg-apiaud=walkrpg-mobile
- Mint refresh token stored in Cloud SQL (
Sessiontable — added to schema by phase 8b extension if needed; closed beta can hold sessions in Redis with 30-day TTL). - Return session payload.
Response
200 OK
{ "session": { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "rt_01HM...", "expiresAt": "2026-05-19T12:00:00Z" }, "walker": { "id": "01HM...", "displayName": "Wanderer-9f3a2c", "level": 0, "currentRegionId": "region.plenny", "totalLifetimeSteps": 0, "availablePoints": 0, "createdAt": "2026-05-18T10:14:22Z" }, "isFirstLogin": true, "forcedUpgradeRequired": false}401 Unauthorized
{ "error": "AUTH_INVALID_TOKEN", "message": "Firebase ID token failed verification.", "details": { "reason": "expired" | "signature_invalid" | "appcheck_invalid" }}403 Forbidden
{ "error": "AUTH_REGION_BLOCKED", "message": "Sign-up from this region is not currently supported.", "details": { "country": "..." }}(Used if D-009 EU-only policy is enforced at signup geo.)
426 Upgrade Required
{ "error": "CLIENT_UPGRADE_REQUIRED", "message": "Please update WalkRPG to continue.", "details": { "minClientVersion": "1.0.5+10" }}429 Too Many Requests
Standard Retry-After header included.
500 Internal Server Error
Generic envelope; no internal details leaked. Cloud Logging captures full trace.
Error envelope (standard for all endpoints)
{ "error": "<UPPER_SNAKE_CODE>", "message": "human-readable", "details": { "...": "..." }, "traceId": "<cloud-trace-id>"}traceId is always returned (correlates to Cloud Logging) and may be quoted by walkers in support requests.
Caching
Cache-Control: no-store, private. Never cache auth responses.
Telemetry
Every call emits a structured log line at info level with: walkerId (if resolved), isFirstLogin, clientPlatform, clientVersion, geoCountry (from Cloud LB metadata), appCheckVerdict. No PII in the log line.
Open follow-ups
- Refresh-token rotation policy: ship single-use refresh tokens at phase 11. Closed beta can reuse refresh token within 30d.
displayNameprofanity filter: phase 11 (narrative-designer + qa-engineer own the wordlist).- Geo-restriction enforcement at sign-up: phase 11 decision — closed beta is EU-soft-restricted by marketing only, not enforced.