---
status: accepted
implemented: false
---

# Sign-in leaves the Event origin: one central auth origin plus a single-use handoff code

> **Decision accepted; the code is implemented, the provisioning is complete, and the flow becomes reachable with the first named Five Across deploy after #1169.** `implemented: false` still holds for the decision as a whole, and deliberately so: until that deploy ships the handoff-mode client, nobody signs in through this path.
>
> **Shipped (#548).** The `authHandoffs` collection with its deny-all rules, and the `mintAuthHandoff` / `exchangeAuthHandoff` callables that mint a single-use, origin-bound, transaction-bound code and consume it transactionally in exchange for a Firebase custom token. The wire contract is [`specs/auth-handoff.md`](../../specs/auth-handoff.md). Two details differ from the sketch below and are recorded there in full: the document is keyed by the code's SHA-256, so the path is `authHandoffs/{codeHash}` rather than `authHandoffs/{code}` — one opaque document per code consumed transactionally, exactly as this ADR requires, but with the value at rest no longer itself redeemable — and the transaction binding is PKCE-shaped, so the Event origin keeps a verifier and publishes only its digest, which is what makes a code read out of a URL insufficient to redeem.
>
> **Shipped (#892, client half of #549).** `resolveSignInStrategy` consumes `VITE_AUTH_MODE`, the Event origin starts the handoff, the central origin completes Google sign-in and mints the code, and the Event origin exchanges it and establishes the local Firebase session.
>
> **Provisioned (#547, completed 2026-09-10).** `auth.fiveacross.app` is a Firebase Auth authorized domain and its `/__/auth/handler` is a registered redirect URI on the Google OAuth web client (verified against Google's authorize endpoint with a negative control); the Five Across deploy service account holds `roles/run.admin`, both callables exist on `fiveacross`, the exact-SA readiness proof forced a successful update on each, and invoker reconciliation is enabled for the deploy target (`skipInvokerReconcile: false`, #1169). **Still pending:** the first named `npm run deploy:fiveacross` after that flip, which ships the handoff-mode client and proves the Hosting/handoff readiness gates end to end. Until it runs, the exact-host `resolveAuthDomain` allowlist and its same-origin path remain the only route by which anyone actually signs in.

> **Amended 2026-08-05 (#599):** the canonical Five Across domain is now `fiveacross.app`, so the central auth origin this ADR plans is `auth.fiveacross.app`, not `auth.fiveacrossbingo.com` (#547 tracks its provisioning). One mechanism step is amended too: #599 as amended removes edge canonicalization—every registered host in service serves in place—so the client does **not** "canonicalise their alias" before starting a handoff. The transaction's validated **target origin is the serving host sign-in began on**, and the handoff returns the player to that entry origin. Everything else below (opaque single-use code, UID + origin + transaction binding, transactional consumption) is unchanged; read `auth.fiveacrossbingo.com` and the `<slug>.vacaybingo.com` examples as historical names.

> **Amended 2026-08-17:** self-service Event creation is the **near-term** goal, which makes everything below near-term blocking work rather than a step that waits behind the next few Events. The one alternative that would delete this ADR outright—collapsing every Event onto a single origin and addressing Events by path instead of by subdomain—is now recorded as considered and rejected in [ADR 0009](0009-event-resolved-from-hostname.md) § Why not one origin with a path prefix: installed-PWA isolation is a property of the origin, so shared storage, a shared quota, and origin-scoped permission grants are ceilings application code cannot raise. Read that section before re-opening this decision, because the path form's attraction is precisely that it makes this ADR unnecessary.
>
> The interim path for Events provisioned before the handoff becomes reachable is narrower than two console entries, and it is worth naming precisely because its shortfall is silent—a half-configured host does not fail the build, it serves the auth-unconfigured screen. Same-origin sign-in on a new host requires **all** of: the hostname present in `FIRST_PARTY_AUTH_HOSTS` (`src/auth-domain.ts`), which is a code edit and a redeploy, or a build that bakes `VITE_FIREBASE_AUTH_DOMAIN` equal to that hostname, since `resolveAuthDomain` otherwise returns the configured domain and the pre-mount same-origin pin then rejects the host; the host registered in Firebase Auth's authorized domains; `https://<host>/__/auth/handler` registered on the Google OAuth web client; and `/__/auth/*` served same-origin on that host. `VITE_AUTH_MODE=same_origin` selects this existing route but does not provide any of those host-specific prerequisites. Because a per-Event code edit and redeploy is precisely what wildcard addressing exists to abolish, this is a bridge for a handful of hand-provisioned Events, not a way to defer this ADR past the first Event nobody pre-registered.

Google validates OAuth redirect URIs **exactly**; there is no wildcard form. An Event addressed at `<slug>.fiveacross.app` therefore can never be its own OAuth callback unless somebody registers it by hand—which is the per-event provisioning the wildcard architecture exists to abolish. Authentication will consequently be centralised on one exact registered origin, `auth.fiveacross.app` (#547 tracks its provisioning; the ADR was written against `auth.fiveacrossbingo.com` and `<slug>.vacaybingo.com`, superseded by #599). A player will get a cryptographically random transaction whose validated target origin is **the serving host sign-in began on**—there is no alias canonicalization step, because #599 as amended never bounces a serving domain off itself—complete Google sign-in at the central origin, receive back only an **opaque single-use handoff code** bound to the authenticated UID, target origin, and transaction, and exchange it over HTTPS for a Firebase session on that entry origin. The code will be a Firestore document (`authHandoffs/{code}`) consumed transactionally by a Cloud Function, which is what makes single-use enforceable—a stateless signed blob cannot be.

**No ID token, refresh token, or Firebase custom token may ever appear in a URL.** Replay, expiry, origin mismatch, unrecognised slugs, and open-redirect targets are all rejected.

## Consequences

- The flow must be verified on mobile Safari, mobile Chrome, an installed PWA, and a desktop tab. Safari's storage partitioning is why the naive "point `authDomain` at the central origin" shortcut is not available—this repo already carries two workarounds for that failure (`firebaseAuthOriginRedirectUrl`, the Vercel `/__/auth/:path*` rewrite).
- `authHandoffs` will be denied to all clients in rules; only the Admin SDK may touch it.
- **`VITE_AUTH_MODE=same_origin` is a deliberate escape hatch, not dead code.** It works only for hostnames registered as exact Firebase Hosting custom domains—which is why the same-origin path is the verification baseline the handoff is proven on top of, and the recovery if sign-in breaks in the field.
- Self-service Event creation cannot ship until this path is proven, because a hostname nobody pre-registered has no other way to authenticate.
