# Ephemera — Authentication on Firebase (GCP + firebase CLI / Identity Platform)

> Self-executing Markdown. The cloud is the source of truth; this file is intent + write-back ledger + audit trail.
> **First binding of a new `auth` intent** — user authentication as a declarative, composable capability.
>
> **Provides** `identity(${GCP_PROJECT})` — a configured auth service: enabled sign-in methods + the ability to mint
> and **verify** user ID tokens. An app plan that needs logins **Requires** it (e.g. a site from
> [`web.gcp-firebase.md`](./web.gcp-firebase.md) + this = a site with accounts).
> **Requires** a Firebase-enabled GCP project (borrowed — same seam as [`web.gcp-firebase.md`](./web.gcp-firebase.md);
> **and** the one-time per-account Firebase activation, see **What you need**). Needs **nothing** else for the
> credential-free methods (email/password, anonymous); Google sign-in additionally needs an OAuth client (§2, manual).

## 🤖 Director prompt

Observe before acting; verify each step; stop at 🔴/💥 for human go; write realized values back into Live State.
Auth here is **configuration, not a server** — you toggle sign-in methods on the project and prove a token can be
minted + verified. Config is a **pure function of the resolved knobs**, so a re-run is idempotent.

> **Status: DOGFOODED LIVE 2026-06-30 (email/password contract passed).** Ran end-to-end against a throwaway project
> (`ephemera-auth-0630`, created + deleted, **~$0** — base Firebase Auth is free): the acceptance contract PASSED —
> minted an email/password user, **verified its ID token → uid**, a **garbage token → 400**, and a **disabled method
> (anonymous) → `ADMIN_ONLY_OPERATION`** (proving enabled-methods reflect config). `apps:sdkconfig` surfaced the Web
> API key; the `signUp`/`:lookup`/`:delete` REST flow works as written.
> **THE dogfood surprise (the thesis, a third time):** on the **base (free) tier, enabling Auth is CONSOLE-GATED** —
> until you click **"Get started" + enable a provider** in the Firebase console once, the project has **no Identity
> Platform config** and both the Admin `config` API *and* REST `signUp` return **`CONFIGURATION_NOT_FOUND`**. And
> **`firebase deploy --only auth` is incomplete in firebase-cli 15.22.3** — it creates a Default Web App but does
> **not** enable providers for REST use (so it is *not* the primary path). **Once the console initializes the config,
> the Admin `config` GET/PATCH work (200)** — so bootstrap is console, but ongoing config + teardown are API-drivable.
> §1/§2 below reflect that reality. **Still unrun:** Google federation (§2, needs an OAuth client).

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## What you need, and why  *(read if Firebase is new to you)*

- **`firebase-tools` CLI** + **`gcloud`**, both authenticated. *Why:* §2 uses `firebase` (declarative block) and/or
  the Identity Toolkit Admin API with a `gcloud` access token; §3 uses `curl`. *Have it?* `firebase projects:list` and
  `gcloud auth print-access-token` both succeed.
- **A Firebase-enabled GCP project** (`GCP_PROJECT`) — **borrowed**, never created/deleted here (the platform seam).
- 🔴 **One-time, per-account: Firebase must be activated for your Google account first.** *Dogfood finding
  (see [`web.gcp-firebase.md`](./web.gcp-firebase.md)):* a never-used-Firebase account 403s on CLI Firebase enablement
  even as owner — do the one-time console activation at <https://console.firebase.google.com> (add the project + accept
  terms) once, then the CLI works forever. *Have it?* `firebase projects:list` shows any project ⇒ activated.
- **(Google sign-in only) an OAuth client** (id + secret) from the Google Cloud **Credentials** console for this
  project. *Why:* federated Google login needs an OAuth 2.0 client; that secret can't be minted headlessly. Email/
  password and anonymous need **no** external secret.

## Intent

Configure **user authentication** on a Firebase project by **declaratively enabling sign-in methods**, so an app can
authenticate users and a backend can **verify** the resulting ID tokens. Identical *intent* to what AWS Cognito or
Auth0 provide — only the binding differs (a future `auth.aws.md` mirrors this contract). `SIGN_IN_*` knobs decide
which methods are on; each is an independent declarative toggle.

**Acceptance contract** (the same test any `auth` binding must pass — defined here so a future `auth.aws.md`/Cognito
sibling mirrors it):
1. the **enabled sign-in methods** reflect the resolved knobs (config reports them on)
2. a user can be **minted and its ID token verified** (sign a user up → the token resolves to that `uid`) — *positive*
3. an **invalid / tampered token is rejected** — *negative*

> **Note on "no server":** unlike a bucket or a load balancer, there is nothing to *stand up* — Firebase runs the
> identity service; this plan **configures** it (which methods, which authorized domains) and proves it works. The
> "resource" is project configuration, so provenance and teardown are about **config**, not infrastructure (see below).

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Email/password sign-in | `on` / `off` | `on` | `SIGN_IN_EMAIL` | §2 `emailPassword`; §3 mints via this |
| 2 | Anonymous sign-in | `on` / `off` | `off` | `SIGN_IN_ANON` | §2 `anonymous` |
| 3 | Google sign-in | `on` / `off` | `off` | `SIGN_IN_GOOGLE` | §2 `googleSignIn` (🔴 needs OAuth client) |
| 4 | Authorized domains | CSV (hostnames) | `${SITE_ID}.web.app` | `AUTH_DOMAINS` | §2b — where sign-in is allowed to run |
| 5 | GCP project (Firebase-enabled) | text — project id | — | `GCP_PROJECT` | all (borrowed) |
| — | *(Google only)* OAuth client id | text | — | `GOOGLE_CLIENT_ID` | §2 googleSignIn |
| — | *(Google only)* OAuth client secret | text (secret) | — | `GOOGLE_CLIENT_SECRET` | §2 googleSignIn |

```yaml
# → written into Live State once resolved (the deterministic input to every step below)
resolved_inputs:
  sign_in_email:  on          # on | off
  sign_in_anon:   off         # on | off
  sign_in_google: off         # on | off  (needs an OAuth client)
  auth_domains:   —           # CSV; default ${SITE_ID}.web.app
```

> **Determinism.** The enabled-methods set is a **pure function of the knobs**, and the config API is a declarative
> upsert (PATCH the whole `config`, or re-`deploy` the `firebase.json` `auth` block) — re-applying converges, never
> duplicates. Adding a federated IdP (`defaultSupportedIdpConfigs`) is **non-idempotent** (409 if it exists), so §2
> **observes before acting**: list, reuse on a hit, create only on a miss.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   teardown — project ephemera-auth-0630 deleted after the 2026-06-30 dogfood
last_verified: 2026-06-30 dogfood (throwaway project, torn down): email/password contract PASSED — signUp→idToken→lookup resolved the uid, garbage token→400, disabled anonymous→ADMIN_ONLY_OPERATION. Enablement is console-gated (base tier); Google federation unrun.

resolved_inputs:
  sign_in_email:  on
  sign_in_anon:   off
  sign_in_google: off
  auth_domains:   —
realized:
  GCP_PROJECT:   —           # borrowed (not created here)
  WEB_API_KEY:   —           # discovered in §3 (firebase apps:sdkconfig) — needed for the REST acceptance calls
  METHODS_ON:    —           # the sign-in methods this plan enabled (for teardown)
```

| ✔ check                         | expected                                   | observed | result |
|---------------------------------|--------------------------------------------|----------|--------|
| enabled methods = knobs         | the `on` methods report enabled            | —        | —      |
| mint + verify token → uid       | signUp → idToken → lookup resolves the uid | —        | —      |
| invalid token rejected (negative) | a garbage idToken → 400 / not resolved   | —        | —      |

## Tags & provenance (binding asymmetry)

**Firebase Auth config has no per-resource tag/label API** (same shape as Firebase Hosting / Cloudflare). The project
is **borrowed**, so this plan adds **no** labels. Provenance is **structural**: the **enabled-methods set + authorized
domains** are a pure function of this plan's knobs (the config *is* the record), and the plan's `Source` is noted here.
Same portability insight as [`web.gcp-firebase.md`](./web.gcp-firebase.md) / [`web.cloudflare.md`](./web.cloudflare.md).

## 0. Variables

```bash
set -euo pipefail
export GCP_PROJECT="${GCP_PROJECT:?set the Firebase-enabled GCP project id}"
export SIGN_IN_EMAIL="${SIGN_IN_EMAIL:-on}"      # on | off
export SIGN_IN_ANON="${SIGN_IN_ANON:-off}"       # on | off
export SIGN_IN_GOOGLE="${SIGN_IN_GOOGLE:-off}"   # on | off (needs OAuth client below)
export AUTH_DOMAINS="${AUTH_DOMAINS:-}"          # CSV of authorized hostnames; blank => leave Firebase defaults
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID:-}"       # required iff SIGN_IN_GOOGLE=on
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET:-}" # required iff SIGN_IN_GOOGLE=on (secret — keep out of logs/VCS)
export ENV="${ENV:-prod}"
export TAG_SOURCE="auth.firebase.md"             # provenance (no resource tags — see "Tags & provenance")

firebase --version >/dev/null || { echo "install firebase-tools: brew install firebase-cli" >&2; exit 1; }
gcloud auth print-access-token >/dev/null        # §2b/§3 use a gcloud bearer token
```

## Dependency frontier

```
ensure-firebase(project) ─> enable identitytoolkit API ─> 🔴 console "Get started" (bootstrap config, base tier) ─> §2 methods (Admin config API) ─> §3 mint+verify ─> Provides identity(project)
SIGN_IN_GOOGLE? ── on ─> 🔴 OAuth client (id+secret) ─> defaultSupportedIdpConfigs   ·   off ─> skip
```

Bootstrap is a one-time console gate (base tier); everything after is API-driven — no long ordering chain.

## 1. Ensure the Firebase project + enable Identity Platform  🟢  *(discover-or-create)*

```bash
# 🔴 FIRST-TIME-ONLY (per account): if `firebase projects:list` is empty, activate Firebase once in the console
#     (see "What you need") — CLI enablement 403s otherwise (proven live in web.gcp-firebase.md).
firebase projects:list 2>/dev/null | grep -qw "$GCP_PROJECT" \
  || { echo "project not Firebase-enabled — see web.gcp-firebase.md §1 (addfirebase) / the console activation"; exit 1; }

# 🟡 enable the Identity Platform / Identity Toolkit API (idempotent)
gcloud services enable identitytoolkit.googleapis.com --project "$GCP_PROJECT"

# 🔴 ONE-TIME PER PROJECT — bootstrap the Auth config in the console. **Base (free) tier is console-gated** (proven
#    live 2026-06-30): until "Get started" runs, the project has NO Identity Platform config and every Admin/REST
#    call 404s CONFIGURATION_NOT_FOUND. `firebase deploy --only auth` does NOT bootstrap it (only makes a Web App).
TOKEN="$(gcloud auth print-access-token)"
CFG="https://identitytoolkit.googleapis.com/admin/v2/projects/${GCP_PROJECT}/config"
if [ "$(curl -s -o /dev/null -w '%{http_code}' "$CFG" -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}")" = 404 ]; then
  cat <<TXT
🔴 HUMAN STEP — initialize Auth once (no API bootstraps the base-tier config):
  console.firebase.google.com/project/${GCP_PROJECT}/authentication → "Get started" → Sign-in method →
  enable at least one provider (e.g. Email/Password) → Save.  Then re-run — §2 takes over via the Admin config API.
TXT
  exit 1
fi
```
```bash
# ✔ the API is enabled AND the Auth config is bootstrapped (GET → 200, not 404)
gcloud services list --enabled --project "$GCP_PROJECT" --filter='config.name=identitytoolkit.googleapis.com' \
  --format='value(config.name)' | grep -q identitytoolkit && echo "identity platform enabled"
curl -s -o /dev/null -w 'auth_config=%{http_code}\n' "https://identitytoolkit.googleapis.com/admin/v2/projects/${GCP_PROJECT}/config" \
  -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "X-Goog-User-Project: ${GCP_PROJECT}"   # expect 200
```
> → Live State: `GCP_PROJECT`; `status: creating`.

## 2. Configure sign-in methods — from the knobs  🟡

> §1 **bootstrapped** the config (the console `Get started`); this step sets *which* methods + domains via the
> **Identity Toolkit Admin `config` API** — a declarative upsert that works **once the config exists** (proven live
> 2026-06-30). ⚠ **Do not rely on `firebase deploy --only auth`**: in firebase-cli 15.22.3 it creates a Default Web
> App but does **not** enable providers for REST use (dogfood finding). A future `firebase.json` `auth` block
> (`emailPassword`/`anonymous`/`googleSignIn` booleans) is the intended declarative form — revisit when the CLI ships
> it fully.

```bash
TOKEN="$(gcloud auth print-access-token)"
BASE="https://identitytoolkit.googleapis.com/admin/v2/projects/${GCP_PROJECT}"

# built-in methods — declarative upsert per the knobs (a 404 here means §1's console init was skipped)
curl -fsS -X PATCH "${BASE}/config?updateMask=signIn.email.enabled,signIn.email.passwordRequired,signIn.anonymous.enabled" \
  -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}" -H "Content-Type: application/json" \
  -d "{\"signIn\":{\"email\":{\"enabled\":$([ "$SIGN_IN_EMAIL" = on ] && echo true || echo false),\"passwordRequired\":true},\"anonymous\":{\"enabled\":$([ "$SIGN_IN_ANON" = on ] && echo true || echo false)}}}"

# authorized domains (declarative upsert of the whole list)
if [ -n "$AUTH_DOMAINS" ]; then
  DOMS=$(printf '%s' "$AUTH_DOMAINS" | awk -F, '{for(i=1;i<=NF;i++)printf "%s\"%s\"",(i>1?",":""),$i}')
  curl -fsS -X PATCH "${BASE}/config?updateMask=authorizedDomains" \
    -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}" -H "Content-Type: application/json" \
    -d "{\"authorizedDomains\":[${DOMS}]}"
fi

# Google federation (needs an OAuth client) — non-idempotent: 409 if it exists → observe first, then POST
if [ "$SIGN_IN_GOOGLE" = on ]; then
  : "${GOOGLE_CLIENT_ID:?SIGN_IN_GOOGLE=on needs GOOGLE_CLIENT_ID}"; : "${GOOGLE_CLIENT_SECRET:?…needs GOOGLE_CLIENT_SECRET}"
  if curl -fsS "${BASE}/defaultSupportedIdpConfigs" -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}" \
       | grep -q 'google.com'; then
    echo "google.com IdP already configured — reuse (PATCH to update)"
  else
    curl -fsS -X POST "${BASE}/defaultSupportedIdpConfigs?idpId=google.com" \
      -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}" -H "Content-Type: application/json" \
      -d "{\"name\":\"projects/${GCP_PROJECT}/defaultSupportedIdpConfigs/google.com\",\"enabled\":true,\"clientId\":\"${GOOGLE_CLIENT_ID}\",\"clientSecret\":\"${GOOGLE_CLIENT_SECRET}\"}"
  fi
fi
```
```bash
# ✔ enabled methods reflect the knobs (Admin config GET → 200 once §1's console init ran — proven live)
curl -fsS "${BASE}/config" -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}" \
  | python3 -c 'import sys,json;c=json.load(sys.stdin).get("signIn",{});print("email:",c.get("email",{}).get("enabled"),"| anon:",c.get("anonymous",{}).get("enabled"))'
```
> → Live State: `METHODS_ON` (the enabled set), `AUTH_DOMAINS`; `status: live`.

## 3. Acceptance verify — mint + verify a token  ✔  *(Admin-SDK-free, pure REST)*

```bash
# the REST endpoints need the project's Web API key (not the admin bearer token). NOTE: a Firebase *Web* API key is
# PUBLIC BY DESIGN (meant to be embedded in client HTML — security is enforced by Auth + rules, not by hiding it), so
# it is not a secret to guard. `apps:sdkconfig` auto-creates a Default Web App if none exists (proven live).
WEB_API_KEY="$(firebase apps:sdkconfig WEB --project "$GCP_PROJECT" 2>/dev/null \
  | python3 -c 'import sys,re;m=re.search(r"apiKey\"?\s*[:=]\s*\"([^\"]+)\"",sys.stdin.read());print(m.group(1) if m else "")')"
IDT="https://identitytoolkit.googleapis.com/v1/accounts"
EMAIL="ephemera-acctest-$$@example.com"; PW="Test-$$-passw0rd"

# ✔ positive — sign a user up (email/password), get an idToken, then look it up (resolves the uid)
ID_TOKEN="$(curl -fsS -X POST "${IDT}:signUp?key=${WEB_API_KEY}" -H 'Content-Type: application/json' \
  -d "{\"email\":\"${EMAIL}\",\"password\":\"${PW}\",\"returnSecureToken\":true}" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["idToken"])')"
curl -fsS -X POST "${IDT}:lookup?key=${WEB_API_KEY}" -H 'Content-Type: application/json' \
  -d "{\"idToken\":\"${ID_TOKEN}\"}" | python3 -c 'import sys,json;u=json.load(sys.stdin)["users"][0];print("verified uid:",u["localId"],u.get("email"))'

# ✔ negative — a garbage token must be rejected
curl -s -o /dev/null -w 'garbage_token_http=%{http_code}\n' -X POST "${IDT}:lookup?key=${WEB_API_KEY}" \
  -H 'Content-Type: application/json' -d '{"idToken":"not-a-real-token"}'   # expect 400

# cleanup the throwaway acceptance user
curl -fsS -X POST "${IDT}:delete?key=${WEB_API_KEY}" -H 'Content-Type: application/json' \
  -d "{\"idToken\":\"${ID_TOKEN}\"}" >/dev/null && echo "test user deleted"
```
> → Live State: `WEB_API_KEY`, fill the verify rows, set `last_verified`, `status: live`.

## Update (idempotent reconcile)

- Toggle a method (`SIGN_IN_*`) → regenerate §2's `firebase.json` `auth` block (or PATCH §2b `config`) and re-apply;
  the config is a declarative upsert, so re-running converges (no duplicate providers). Re-run §3's acceptance.
- Change `AUTH_DOMAINS` → re-run §2b's `authorizedDomains` PATCH (replaces the whole list).
- Rotate the Google OAuth secret → PATCH `defaultSupportedIdpConfigs/google.com` with the new `clientSecret` (§2b's
  observe-branch reuses the existing IdP; PATCH updates it in place).

## Teardown — observe-first  💥

> 💥 Human go. Removes **only what this plan enabled** — the sign-in-method config. The **borrowed GCP/Firebase project
> is never deleted here.** (User records created via this auth service are app data — bulk-delete them separately if
> required for compliance.)

```bash
TOKEN="$(gcloud auth print-access-token)"; BASE="https://identitytoolkit.googleapis.com/admin/v2/projects/${GCP_PROJECT}"
# disable the federated IdP this plan added (if any) — DELETE is a no-op if absent
[ "$SIGN_IN_GOOGLE" = on ] && curl -fsS -X DELETE "${BASE}/defaultSupportedIdpConfigs/google.com" \
  -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}" || true
# turn built-in methods off (declarative: re-deploy the firebase.json auth block with all-false, or PATCH config)
curl -fsS -X PATCH "${BASE}/config?updateMask=signIn.email.enabled,signIn.anonymous.enabled" \
  -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}" -H "Content-Type: application/json" \
  -d '{"signIn":{"email":{"enabled":false},"anonymous":{"enabled":false}}}'
```
```bash
# ✔ verify teardown — no methods report enabled
curl -fsS "${BASE}/config" -H "Authorization: Bearer ${TOKEN}" -H "X-Goog-User-Project: ${GCP_PROJECT}" \
  | python3 -c 'import sys,json;c=json.load(sys.stdin).get("signIn",{});print("still on?" , c)'
```
> → Live State: `status: gone`, clear `METHODS_ON`.

## Composition — how this plugs into the fleet

`identity(${GCP_PROJECT})` is consumed by an **app**, not another infra plan: e.g. a site from
[`web.gcp-firebase.md`](./web.gcp-firebase.md) whose front-end calls the Firebase Auth client SDK and whose backend
**verifies** ID tokens (the §3 `:lookup` pattern, or the Admin SDK `verifyIdToken`). The two bindings share the **same
borrowed Firebase project**, so `GCP_PROJECT` is the join key. **[`auth.aws.md`](./auth.aws.md)** (Cognito) is the
sibling binding that **mirrors the acceptance contract** above — it additionally offers an AWS-only credential broker
(identity pool: token → scoped AWS credentials), a capability base Firebase Auth has no equivalent of.

## Deliberately not included

- **Phone / SMS sign-in** — needs billing (Blaze) + reCAPTCHA/App Check enrollment; a distinct cost + abuse surface.
  Named so the omission is a decision.
- **Other federated IdPs** (GitHub, Facebook, Apple, generic OIDC/SAML) — the **same `defaultSupportedIdpConfigs`
  pattern** as Google (§2b), one per provider with its own client id/secret; add per need, out of scope here.
- **Identity Platform enterprise tier** — multi-tenancy, SAML/OIDC federation, MFA/TOTP enrollment (`config?updateMask=mfa`).
  A paid upgrade beyond base Firebase Auth; a separate intent if needed.
- **Email-link / magic-link, password-reset & verification email templates** — configurable via the Admin API, but
  they're UX/policy, not the core "can a user authenticate" contract.
- **The app's sign-in UI, session handling, custom claims / RBAC** — application concerns. This plan is the **identity
  config surface**; the app consumes `identity(...)` and owns the login flow + authorization logic.
