Skip to content

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 of firebaseIdToken. The server lazy-creates User + Walker by email instead of by hashed Firebase sub.
  • No App Check token requirement. X-Firebase-AppCheck header is ignored (not required, not validated).
  • JWT signed by NestJS-local key. HS256 (symmetric secret in .env) or RS256 (key pair in backend/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=firebase selects 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

FieldValue
MethodPOST
Path/auth/callback
AuthNone (this IS the auth handshake)
IdempotentYes (repeated calls with same valid token return same internal session)
Rate limit20 req/min/IP, 5 req/min per Firebase sub

Request

Headers

HeaderRequiredNotes
Content-Typeyesapplication/json
X-Firebase-AppCheckyesFirebase App Check token (Play Integrity / DeviceCheck)
Accept-Languageoptionalpl 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"
}
FieldTypeRequiredNotes
firebaseIdTokenstringyesShort-lived (~1h) ID token from Firebase Auth federation broker, contains sub claim from Google/Apple.
displayNamestring (1..40)optionalWalker-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.
regionPreferencestringoptionaleu only at MVP (D-009). Field exists for future expansion.
clientPlatformenumyesios or android
clientVersionstringyessemver+build. Used for forced-upgrade gating.

Server flow

  1. Verify X-Firebase-AppCheck token via Firebase Admin SDK. Reject 401 if invalid.
  2. Verify firebaseIdToken signature against Firebase JWKS. Reject 401 if invalid.
  3. Extract sub claim (opaque external identifier). Reject 401 if absent.
  4. Look up User by firebaseExternalSub = sha256(sub) (hashed at rest).
  5. If User does not exist: a. Create User row in Cloud SQL Warsaw with: email (from token claims if present), displayName (from request body or generated), firebaseExternalSub (hashed), createdAt, lastActiveAt. b. Create Walker row tied to User: starter region = region.plenny, level 0, totalLifetimeSteps 0, availablePoints 0. c. Initialize StreakState (zeros). d. Initialize FactionRep rows for all 5 known factions at neutral tier 0.
  6. Update User.lastActiveAt = now().
  7. Mint internal session JWT signed by KMS key in europe-central2:
    • sub = internal User UUID
    • walkerId = internal Walker UUID
    • exp = now + 24h
    • iss = walkrpg-api
    • aud = walkrpg-mobile
  8. Mint refresh token stored in Cloud SQL (Session table — added to schema by phase 8b extension if needed; closed beta can hold sessions in Redis with 30-day TTL).
  9. 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.
  • displayName profanity 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.