# Ephemera — Authentication on AWS (Cognito + aws CLI)

> Self-executing Markdown. The cloud is the source of truth; this file is intent + write-back ledger + audit trail.
> **Second binding of the `auth` intent** — sibling of [`auth.firebase.md`](./auth.firebase.md), same acceptance contract.
>
> **Provides** `identity(${USER_POOL_ID})` — a configured identity service: enabled sign-in methods + the ability to mint
> and **verify** user tokens. With `IDENTITY_POOL=auth-only` it **also Provides** `aws-credential-broker(${IDENTITY_POOL_ID})`
> — signed-in users can exchange their token for **scoped AWS credentials** (this is what powers per-user object-store
> prefixes in a future `storage.aws.md`; Firebase has no equivalent — a binding asymmetry in AWS's favor).
> **Requires** only an authenticated AWS account (borrowed — never created/deleted here). No console steps expected:
> unlike the Firebase sibling's base-tier bootstrap, Cognito is believed **fully CLI-able** (to be proven at dogfood).

## 🤖 Director prompt

Observe before acting; verify each step; stop at 🔴/💥 for human go; write realized values back into Live State.
Unlike the Firebase sibling (config-only), this plan **creates real resources** (user pool, app client, optionally an
identity pool + IAM role) — so the **TAGS** movement applies, and teardown deletes what create made. Every resource is
a **pure function of the resolved knobs** + a deterministic name, so re-runs discover-and-reuse, never duplicate.

> **Status: DOGFOODED LIVE 2026-07-01 (full lifecycle, ~$0).** Ran end-to-end against a throwaway stack
> (`ephemera-auth-dev` pool + client + auth-only identity pool + authRole, us-west-2, essentials tier — created,
> verified, torn down): the **acceptance contract PASSED** — email/password user minted, `initiate-auth` →
> `get-user` resolved the user (ID-token `sub` == sign-up `UserSub`), garbage token rejected, absent-Google
> negative held, all three created resources tagged; teardown guard → delete → absence-verify all clean.
> **Headline: NO console gate anywhere — fully CLI-able** (the believed asymmetry vs the Firebase sibling's
> console bootstrap, now proven). Dogfood findings folded below:
> 1. **Run the blocks under `bash`.** zsh does not word-split unquoted expansions — `$UP_ARGS` / `$IDPS`
>    arrive as one argument and the CLI errors `Unknown options` (bit live on first run).
> 2. **The IAM/broker gotcha fired, with a diagnostic twist:** ambient credentials can be session-shaped with
>    **zero `AWS_*` env vars** (keychain/credential-process brokered) — regional + STS calls succeed while every
>    IAM call throws `InvalidClientTokenId`. **ALL IAM calls in this plan need the broker's `--no-session`** —
>    §4b create/reconcile *and* teardown's probe, guard lists, delete, and absence-verify — not just create-role.
> 3. `admin-confirm-sign-up` → `initiate-auth` works on an **unverified** email (the acceptance path is safe).
> 4. In email-alias pools `get-user` returns the **sub** as `Username` — identity is proven by the sub match,
>    not the email string.
> Still unrun: `SIGN_IN_GOOGLE` (needs an OAuth client), `CLIENT_SECRET=on` (the SECRET_HASH path), and §4c's
> propagation retry (set-roles passed on the first try this run).

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

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

- **`aws` CLI v2, authenticated.** *Why:* every step is an `aws cognito-idp` / `cognito-identity` / `iam` call.
  *Have it?* `aws sts get-caller-identity` succeeds.
- **A region** (`AWS_REGION`). Cognito is regional; tokens are issued by
  `https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}` — consumers verify against that issuer's JWKS.
- **If your credentials come from a broker (aws-vault / SSO):** **every `aws iam …` call in this plan** (§4b's
  create/reconcile AND teardown's probe, guard lists, delete, verify) needs the broker's **no-session mode**
  (`aws-vault exec <profile> --no-session -- aws iam …`) — GetSessionToken creds can't call IAM (EPHEMERA.md
  gotcha, **proven live 2026-07-01**: ambient creds were session-shaped with zero `AWS_*` env vars — STS and
  Cognito succeeded while IAM threw `InvalidClientTokenId`; diagnose via the IAM call, not the env). Regional
  Cognito calls run fine on session creds.
- **Run the code blocks under `bash`** (`bash <<'…'` if your interactive shell is zsh): the plans use POSIX
  word-splitting of unquoted variables (`$UP_ARGS`, `$IDPS`), which zsh does not perform (proven live — the
  create errors `Unknown options` under zsh). **Any bash ≥3.2 works** — 3.2 is only the compatibility *floor*
  (so a stock Mac runs plans with zero installs); a modern bash (Homebrew's 5.x) is strictly better.
- **(Google sign-in only) an OAuth client** (id + secret) from the Google Cloud **Credentials** console — same
  prerequisite as the Firebase sibling's §2; federated Google login can't be minted headlessly. Email/password
  needs **no** external secret.
- **`openssl` + `python3`** — only for the acceptance step (SECRET_HASH when `CLIENT_SECRET=on`; JWT payload decode).

## Intent

Stand up **user authentication** on AWS Cognito by creating a user pool + app client whose configuration is a pure
function of the knobs, so an app can authenticate users and a backend can **verify** the resulting tokens. Identical
*intent* to [`auth.firebase.md`](./auth.firebase.md) — only the binding differs. Optional extras this provider adds:
**Google federation** (an IdP on the pool) and an **identity pool** (token → scoped AWS credentials, the seam a future
`storage.aws.md` consumes for per-user prefixes).

**Acceptance contract** (defined in [`auth.firebase.md`](./auth.firebase.md); every `auth` binding must pass it):
1. the **enabled sign-in methods** reflect the resolved knobs (config reports them on — and the *off* ones absent)
2. a user can be **minted and its token verified** (sign a user up → the token resolves to that user) — *positive*
3. an **invalid / tampered token is rejected** — *negative*

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Sign-in identifier | `email` / `username` | `email` | `USERNAME_MODE` | §1 `--username-attributes` |
| 2 | Google sign-in | `on` / `off` | `off` | `SIGN_IN_GOOGLE` | §2 (🔴 needs OAuth client); §3 client IdP list |
| 3 | App client secret | `off` / `on` | `off` | `CLIENT_SECRET` | §3 `--generate-secret`; §5 SECRET_HASH |
| 4 | AWS-credential broker | `none` / `auth-only` | `none` | `IDENTITY_POOL` | §4 entirely (pool + IAM role + wiring) |
| 5 | Pool feature tier | `essentials` / `lite` | `essentials` | `POOL_TIER` | §1 `--user-pool-tier` (billing axis) |
| 6 | Base name | text — identifier | `ephemera-auth` | `AUTH_NAME` | every resource name (`${AUTH_NAME}-${ENV}-…`) |
| 7 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | name postfix + `Environment` tag |
| — | *(Google only)* OAuth client id | text | — | `GOOGLE_CLIENT_ID` | §2 |
| — | *(Google only)* OAuth client secret | text (secret) | — | `GOOGLE_CLIENT_SECRET` | §2 (keep out of logs/VCS) |

```yaml
# → written into Live State once resolved (the deterministic input to every step below)
resolved_inputs:
  username_mode:  email       # email | username
  sign_in_google: off         # on | off  (needs an OAuth client)
  client_secret:  off         # off | on
  identity_pool:  none        # none | auth-only
  pool_tier:      essentials  # essentials | lite
  auth_name:      ephemera-auth
  env:            dev
```

> **Determinism.** `create-user-pool` / `create-user-pool-client` / `create-identity-pool` **mint a new resource every
> call** (non-idempotent). Identity is restored by deterministic **names** (`${AUTH_NAME}-${ENV}[-…]`) + observe-before-
> act: every 🟢 lists by name first, reuses on a hit, creates only on a miss. The Google IdP POST is the same shape as
> the sibling's (`describe` first, create only on a miss). Same answers ⇒ same resources.
> ⚠ *Caveat:* the list calls cap at the API max (`--max-results 60`) and do **not** auto-consume `NextToken` — on an
> account with >60 pools/identity-pools in the region, loop the observe on `NextToken` or discovery can miss (apply
> would then mint a duplicate; teardown would no-op). Fine at fleet scale; named so it's a decision.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
                             # dogfood); torn down both times; nothing live now
last_action:   teardown — 2nd cycle (storage dogfood): guard first REFUSED while storage's access-levels policy
               was attached (interlock proven), passed after storage detached; stack then deleted (💥 go: Mark)
last_verified: 2026-07-01 dogfoods (throwaway, us-west-2, essentials, ~$0) — contract PASSED end-to-end; re-apply
               is a clean no-op-then-extend (discover-or-create held); the broker issued real scoped credentials
               that storage.aws.md's per-user prefixes enforced (own allowed / foreign denied). Google +
               client-secret branches still unrun; §4c retry never needed (set-roles passed first try, twice).

resolved_inputs:            # as run 2026-07-01 (🔴 go recorded: Mark, create + teardown approved up front)
  username_mode:  email
  sign_in_google: off
  client_secret:  off
  identity_pool:  auth-only
  pool_tier:      essentials
  auth_name:      ephemera-auth
  env:            dev
realized:                    # cleared by teardown — dogfood values existed ~30 min, region us-west-2
  AWS_REGION:       —
  USER_POOL_ID:     —          # §1
  CLIENT_ID:        —          # §3
  IDENTITY_POOL_ID: —          # §4 (iff identity_pool=auth-only)
  AUTH_ROLE_ARN:    —          # §4 (iff identity_pool=auth-only)
  GOOGLE_IDP:       —          # §2 (iff sign_in_google=on): "Google" once attached
```

| ✔ check                           | expected                                        | observed (2026-07-01 dogfood) | result |
|-----------------------------------|-------------------------------------------------|----------|--------|
| enabled methods = knobs           | pool/client config reports the `on` set          | email on, case-insensitive; flows incl. USER_PASSWORD_AUTH; secret off | PASS |
| absent method stays absent (neg)  | `sign_in_google=off` ⇒ no Google IdP on the pool | `google: absent (matches knob)` | PASS |
| mint + verify token → user        | sign-up → initiate-auth → get-user resolves user | get-user resolved; ID-token sub == UserSub | PASS |
| invalid token rejected (negative) | garbage access token → NotAuthorizedException    | rejected (call failed as expected) | PASS |
| tags present                      | pool + identity pool + role carry `ManagedBy=ephemera` | all three: ephemera | PASS |
| broker wired (auth-only)          | idpool unauth=false; authenticated role set      | false; authRole ARN wired | PASS |
| teardown leaves nothing           | pool/idpool/role all absent after 💥             | ResourceNotFound / NoSuchEntity / list-miss | PASS |

## TAGS — provenance & cost tags

This binding **creates taggable resources** (the Firebase sibling creates none — that asymmetry cuts the other way
here). Tag **on create**, only what this plan creates: user pool + identity pool take a **JSON map**
(`--user-pool-tags` / `--identity-pool-tags` → `tags_map`); the IAM role takes a **Key/Value list** (`--tags` →
`tags_kv`). Renderers inline in §0 (canonical source + tests: `scripts/tags.sh`).

## 0. Variables

```bash
set -euo pipefail
export AWS_REGION="${AWS_REGION:-us-west-2}"
export ENV="${ENV:-dev}"
export AUTH_NAME="${AUTH_NAME:-ephemera-auth}"
export USERNAME_MODE="${USERNAME_MODE:-email}"     # email | username
export SIGN_IN_GOOGLE="${SIGN_IN_GOOGLE:-off}"     # on | off (needs OAuth client below)
export CLIENT_SECRET="${CLIENT_SECRET:-off}"       # off | on
export IDENTITY_POOL="${IDENTITY_POOL:-none}"      # none | auth-only
export POOL_TIER="${POOL_TIER:-essentials}"        # essentials | lite  → ESSENTIALS | LITE
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)

POOL_NAME="${AUTH_NAME}-${ENV}"
CLIENT_NAME="${AUTH_NAME}-${ENV}-client"
IDPOOL_NAME="${AUTH_NAME}_${ENV}_idpool"           # deterministic; underscores match identity-pool naming norms
AUTH_ROLE_NAME="${AUTH_NAME}-${ENV}-authRole"
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"

# ── TAGS — resolved once, applied everywhere (canonical: scripts/tags.sh) ──
PLAN_SOURCE="auth.aws.md"
PLAN_VERSION="2026-07-01"
TAG_COST_CENTER="${TAG_COST_CENTER:-}"; TAG_OWNER="${TAG_OWNER:-}"
TAGS="$(printf '%s\n' "ManagedBy=ephemera" "Source=${PLAN_SOURCE}" "PlanVersion=${PLAN_VERSION}" \
  "CostCenter=${TAG_COST_CENTER}" "Owner=${TAG_OWNER}" "Environment=${ENV}")"
_tags_list() { printf '%s\n' "$TAGS" "$@" | awk '
  { eq=index($0,"="); if(eq==0) next; k=substr($0,1,eq-1); v=substr($0,eq+1); if(v=="") next;
    val[k]=v; if(!(k in seen)){ order[++n]=k; seen[k]=1 } }
  END { for(i=1;i<=n;i++) print order[i]"="val[order[i]] }'; }
tags_kv()  { _tags_list "$@" | while IFS='=' read -r k v; do printf 'Key=%s,Value=%s ' "$k" "$v"; done; }
tags_map() { printf '{%s}' "$(_tags_list "$@" | while IFS='=' read -r k v; do printf '"%s":"%s",' "$k" "$v"; done | sed 's/,$//')"; }
```

## Dependency frontier

```
user-pool 🟢 ──┬─ SIGN_IN_GOOGLE=on ─> 🔴 OAuth client ─> §2 Google IdP 🟡 ─┐
               │                                                            ├─> §3 app client 🟢 ─┬─> §5 ✔ acceptance
               └─ off ──────────────────────────────────────────────────────┘                     │
IDENTITY_POOL=auth-only:  §3 client ─> §4a identity-pool 🟢 ─> §4b IAM authRole 🟢 ─> §4c set-roles 🟡 ┘
```

Non-negotiable edges: the **Google IdP must exist before the client** (§3's `SupportedIdentityProviders` references it
by name — creating the client first would force a clobber-prone update). The **identity pool must exist before the IAM
role** (the role's trust policy conditions on the pool id — the chicken-and-egg edge), and `set-roles` needs both.
Teardown reverses this.

## 1. User pool  🔴🟢  *(discover-or-create)*

> 🔴 rides the create: a user pool is a persistent identity store with a **billing axis** (`essentials` is AWS's
> default tier — free to ~10k MAU then per-MAU; `lite` is the legacy-shaped cheaper tier). Confirm tier + go once;
> the re-run path is gate-free (discovery hits).

```bash
# observe — deterministic identity is the name
USER_POOL_ID="$(aws cognito-idp list-user-pools --region "$AWS_REGION" --max-results 60 \
  --query "UserPools[?Name=='${POOL_NAME}'].Id | [0]" --output text)"
if [ "$USER_POOL_ID" = "None" ] || [ -z "$USER_POOL_ID" ]; then
  # 🔴🟢 create (tags ride the create — atomic, no untagged window)
  UP_ARGS=""; [ "$USERNAME_MODE" = email ] && UP_ARGS="--username-attributes email --auto-verified-attributes email"
  USER_POOL_ID="$(aws cognito-idp create-user-pool --region "$AWS_REGION" \
    --pool-name "$POOL_NAME" \
    $UP_ARGS \
    --username-configuration CaseSensitive=false \
    --policies 'PasswordPolicy={MinimumLength=8,RequireUppercase=false,RequireLowercase=false,RequireNumbers=false,RequireSymbols=false}' \
    --user-pool-tier "$(printf '%s' "$POOL_TIER" | tr a-z A-Z)" \
    --user-pool-tags "$(tags_map)" \
    --query 'UserPool.Id' --output text)"
fi
echo "USER_POOL_ID=${USER_POOL_ID}"
```
```bash
# ✔ pool exists and reflects the knobs (email-as-username, case-insensitive)
# ⚠ UsernameAttributes are IMMUTABLE post-create — a reused pool that mismatches USERNAME_MODE cannot be
#   reconciled in place; the only path is recreate (teardown 💥 → apply). This verify is what surfaces it.
aws cognito-idp describe-user-pool --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
  --query 'UserPool.{name:Name,usernameAttrs:UsernameAttributes,caseSensitive:UsernameConfiguration.CaseSensitive,tier:UserPoolTier}'
```
> → Live State: `USER_POOL_ID`, `AWS_REGION`; `status: creating`.

## 2. Google federation — iff `SIGN_IN_GOOGLE=on`  🔴🟡  *(observe-first; skip entirely when off)*

> 🔴 needs the OAuth client id + secret (human-minted in the Google Cloud console — same seam as the Firebase
> sibling's §2). Attaching an IdP is **non-idempotent** (`DuplicateProviderException` on a re-POST) → describe first.

```bash
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 aws cognito-idp describe-identity-provider --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
       --provider-name Google >/dev/null 2>&1; then
    echo "Google IdP already attached — reuse (update-identity-provider to rotate the secret)"
  else
    aws cognito-idp create-identity-provider --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
      --provider-name Google --provider-type Google \
      --provider-details client_id="${GOOGLE_CLIENT_ID}",client_secret="${GOOGLE_CLIENT_SECRET}",authorize_scopes="openid email profile" \
      --attribute-mapping email=email,username=sub
  fi
fi
```
```bash
# ✔ on ⇒ Google listed; off ⇒ Google absent (the contract's negative side) — asserted, not eyeballed
GOT=absent
if aws cognito-idp list-identity-providers --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
     --query 'Providers[].ProviderName' --output text | grep -qw Google; then GOT=attached; fi
WANT=absent; [ "$SIGN_IN_GOOGLE" = on ] && WANT=attached
[ "$GOT" = "$WANT" ] && echo "google: ${GOT} (matches knob)" || { echo "google: ${GOT}, knob wants ${WANT}"; exit 1; }
```
> → Live State: `GOOGLE_IDP`.

## 3. App client  🟢  *(discover-or-create)*

```bash
# observe by name
CLIENT_ID="$(aws cognito-idp list-user-pool-clients --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" --max-results 60 \
  --query "UserPoolClients[?ClientName=='${CLIENT_NAME}'].ClientId | [0]" --output text)"
if [ "$CLIENT_ID" = "None" ] || [ -z "$CLIENT_ID" ]; then
  IDPS="COGNITO"; [ "$SIGN_IN_GOOGLE" = on ] && IDPS="COGNITO Google"   # §2 ran first — the frontier edge
  SECRET_FLAG="--no-generate-secret"; [ "$CLIENT_SECRET" = on ] && SECRET_FLAG="--generate-secret"
  CLIENT_ID="$(aws cognito-idp create-user-pool-client --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
    --client-name "$CLIENT_NAME" "$SECRET_FLAG" \
    --explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_USER_SRP_AUTH ALLOW_REFRESH_TOKEN_AUTH \
    --supported-identity-providers $IDPS \
    --refresh-token-validity 30 \
    --query 'UserPoolClient.ClientId' --output text)"
fi
echo "CLIENT_ID=${CLIENT_ID}"
```
```bash
# ✔ client reflects the knobs (USER_PASSWORD_AUTH on — §5 depends on it; secret per knob)
aws cognito-idp describe-user-pool-client --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" --client-id "$CLIENT_ID" \
  --query 'UserPoolClient.{flows:ExplicitAuthFlows,idps:SupportedIdentityProviders}'
SECRET_GOT=off
if [ "$(aws cognito-idp describe-user-pool-client --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
        --client-id "$CLIENT_ID" --query 'UserPoolClient.ClientSecret' --output text)" != "None" ]; then SECRET_GOT=on; fi
[ "$SECRET_GOT" = "$CLIENT_SECRET" ] && echo "secret: ${SECRET_GOT} (matches knob)" || { echo "secret: ${SECRET_GOT}, knob wants ${CLIENT_SECRET}"; exit 1; }
```
> → Live State: `CLIENT_ID`; `status: live` (auth is usable from here; §4 is the optional broker).

## 4. Identity pool + credential broker — iff `IDENTITY_POOL=auth-only`  🟢  *(skip entirely when none)*

> Exchanges a verified user-pool token for **scoped AWS credentials** via an IAM role — the seam a future
> `storage.aws.md` consumes for per-user S3 prefixes. The role is created **empty of permissions**: downstream plans
> attach their own policies; this plan only mints the broker. **Order matters:** pool → role (trust conditions on the
> pool id) → set-roles.

```bash
if [ "$IDENTITY_POOL" = "auth-only" ]; then
  # 4a 🟢 identity pool (observe by name; unauthenticated access stays off — the enum's only member is auth-only)
  IDENTITY_POOL_ID="$(aws cognito-identity list-identity-pools --region "$AWS_REGION" --max-results 60 \
    --query "IdentityPools[?IdentityPoolName=='${IDPOOL_NAME}'].IdentityPoolId | [0]" --output text)"
  if [ "$IDENTITY_POOL_ID" = "None" ] || [ -z "$IDENTITY_POOL_ID" ]; then
    IDENTITY_POOL_ID="$(aws cognito-identity create-identity-pool --region "$AWS_REGION" \
      --identity-pool-name "$IDPOOL_NAME" \
      --no-allow-unauthenticated-identities \
      --cognito-identity-providers ProviderName="cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}",ClientId="${CLIENT_ID}",ServerSideTokenCheck=true \
      --identity-pool-tags "$(tags_map)" \
      --query 'IdentityPoolId' --output text)"
  fi

  # 4b 🟢 the authenticated role — trust scoped to THIS identity pool (the chicken-and-egg edge: pool id first)
  #    ⚠ brokered creds (aws-vault/SSO session tokens) can't call IAM — run this one step via `--no-session` (EPHEMERA.md gotcha)
  TRUST="$(printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"%s"},"ForAnyValue:StringLike":{"cognito-identity.amazonaws.com:amr":"authenticated"}}}]}' "$IDENTITY_POOL_ID")"
  AUTH_ROLE_ARN="$(aws iam get-role --role-name "$AUTH_ROLE_NAME" --query 'Role.Arn' --output text 2>/dev/null)" || \
  AUTH_ROLE_ARN="$(aws iam create-role --role-name "$AUTH_ROLE_NAME" \
    --assume-role-policy-document "$TRUST" --tags $(tags_kv) \
    --query 'Role.Arn' --output text)"
  # on reuse: the trust's aud must be THIS pool (a torn-down + recreated pool strands the old aud, and the
  # broker then silently issues nothing) — reconcile in place, the Update section's describe→merge discipline
  aws iam get-role --role-name "$AUTH_ROLE_NAME" --query 'Role.AssumeRolePolicyDocument' --output json \
    | grep -q "$IDENTITY_POOL_ID" \
    || aws iam update-assume-role-policy --role-name "$AUTH_ROLE_NAME" --policy-document "$TRUST"

  # 4c 🟡 wire the role to the pool (declarative upsert — safe to re-run).
  #    Fresh IAM roles propagate eventually (EPHEMERA.md gotcha) — retry briefly instead of failing the run.
  for i in 1 2 3 4 5; do
    if aws cognito-identity set-identity-pool-roles --region "$AWS_REGION" \
         --identity-pool-id "$IDENTITY_POOL_ID" --roles authenticated="$AUTH_ROLE_ARN"; then break; fi
    if [ "$i" = 5 ]; then echo "set-identity-pool-roles kept failing"; exit 1; fi
    echo "fresh role not yet propagated — retry ${i}/5"; sleep 5
  done
fi
```
```bash
# ✔ broker wired + the negative: unauthenticated access is OFF (guarded — a knob=none drift pass skips, not crashes)
if [ "$IDENTITY_POOL" = "auth-only" ]; then
  aws cognito-identity describe-identity-pool --region "$AWS_REGION" --identity-pool-id "$IDENTITY_POOL_ID" \
    --query '{name:IdentityPoolName,unauthAllowed:AllowUnauthenticatedIdentities}'   # unauthAllowed must be false
  aws cognito-identity get-identity-pool-roles --region "$AWS_REGION" --identity-pool-id "$IDENTITY_POOL_ID" \
    --query 'Roles.authenticated'
fi
```
> → Live State: `IDENTITY_POOL_ID`, `AUTH_ROLE_ARN`.

## 5. Acceptance verify — mint + verify a token  ✔  *(pure CLI, mirrors the sibling's §3)*

```bash
EMAIL="ephemera-acctest-$$@example.com"; PW="Test-$$-passw0rd"
SH=""   # SECRET_HASH — only when the client has a secret: HMAC-SHA256(username+client_id, secret), base64
if [ "$CLIENT_SECRET" = on ]; then
  CS="$(aws cognito-idp describe-user-pool-client --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
        --client-id "$CLIENT_ID" --query 'UserPoolClient.ClientSecret' --output text)"
  # HMAC in python3 with the secret passed via env — keeps it off the process argv (visible in `ps`)
  SH="$(CS="$CS" MSG="${EMAIL}${CLIENT_ID}" python3 -c 'import os,hmac,hashlib,base64;print(base64.b64encode(hmac.new(os.environ["CS"].encode(),os.environ["MSG"].encode(),hashlib.sha256).digest()).decode())')"
fi

# ✔ positive — mint a user, confirm (admin bypass: acceptance only; real users confirm via the emailed code), sign in
USER_SUB="$(aws cognito-idp sign-up --region "$AWS_REGION" --client-id "$CLIENT_ID" \
  --username "$EMAIL" --password "$PW" ${SH:+--secret-hash "$SH"} --query 'UserSub' --output text)"
aws cognito-idp admin-confirm-sign-up --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" --username "$EMAIL"
AUTH_JSON="$(aws cognito-idp initiate-auth --region "$AWS_REGION" --client-id "$CLIENT_ID" \
  --auth-flow USER_PASSWORD_AUTH --auth-parameters "USERNAME=${EMAIL},PASSWORD=${PW}${SH:+,SECRET_HASH=${SH}}")"
ACCESS_TOKEN="$(printf '%s' "$AUTH_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin)["AuthenticationResult"]["AccessToken"])')"
ID_TOKEN="$(printf '%s' "$AUTH_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin)["AuthenticationResult"]["IdToken"])')"

# ✔ the service verifies the token server-side (get-user accepts only a valid, unexpired access token)…
aws cognito-idp get-user --region "$AWS_REGION" --access-token "$ACCESS_TOKEN" --query 'Username' --output text
# …and the ID token's sub is the very user we minted (issuer = https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID})
TOK_SUB="$(python3 -c 'import sys,json,base64;p=sys.argv[1].split(".")[1];p+="="*(-len(p)%4);print(json.loads(base64.urlsafe_b64decode(p))["sub"])' "$ID_TOKEN")"
[ "$TOK_SUB" = "$USER_SUB" ] && echo "verified sub matches: ${TOK_SUB}" || { echo "SUB MISMATCH"; exit 1; }

# ✔ negative — a garbage token must be rejected
if aws cognito-idp get-user --region "$AWS_REGION" --access-token not-a-real-token >/dev/null 2>&1; then
  echo "FAIL: garbage token accepted"; exit 1
else echo "garbage token rejected"; fi

# cleanup the throwaway acceptance user
aws cognito-idp admin-delete-user --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" --username "$EMAIL" \
  && echo "test user deleted"

# ✔ tags present (drift) — everything this plan created carries ManagedBy=ephemera
# (cognito tags read back via describe-*, not a list-tags call; IAM via list-role-tags)
[ "$(aws cognito-idp describe-user-pool --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
     --query 'UserPool.UserPoolTags.ManagedBy' --output text)" = "ephemera" ] \
  && echo "pool tags ok" || { echo "pool missing ManagedBy=ephemera"; exit 1; }
if [ "$IDENTITY_POOL" = "auth-only" ]; then
  [ "$(aws cognito-identity describe-identity-pool --region "$AWS_REGION" --identity-pool-id "$IDENTITY_POOL_ID" \
       --query 'IdentityPoolTags.ManagedBy' --output text)" = "ephemera" ] \
    && echo "identity-pool tags ok" || { echo "identity pool missing ManagedBy=ephemera"; exit 1; }
  [ "$(aws iam list-role-tags --role-name "$AUTH_ROLE_NAME" \
       --query "Tags[?Key=='ManagedBy'].Value | [0]" --output text)" = "ephemera" ] \
    && echo "authRole tags ok" || { echo "authRole missing ManagedBy=ephemera"; exit 1; }
fi
```
> → Live State: fill the verify rows, set `last_verified`, `status: live`.

## Update (idempotent reconcile)  🟡

- ⚠ **`update-user-pool` / `update-user-pool-client` are clobber-prone:** attributes you *omit* are reset to their
  defaults, not left alone. Never PATCH blind — `describe` first, merge the change into the full current config,
  send the whole thing. (This is the AWS analog of the sibling's `updateMask` discipline, inverted.)
- Toggle `SIGN_IN_GOOGLE` on → run §2 then re-create-or-update the client's `SupportedIdentityProviders` (describe →
  merge → `update-user-pool-client`). Off → detach: remove `Google` from the client first, then `delete-identity-provider`.
- Rotate the Google OAuth secret → `update-identity-provider --provider-details …` (in place; §2's observe branch).
- Toggle `IDENTITY_POOL` → §4 is self-contained: apply it, or run its teardown lines.
- Change `USERNAME_MODE` → **recreate, not reconcile**: Cognito username attributes are immutable post-create.
  Teardown 💥 then re-apply. (§1's reuse path adopts an existing pool as-is; §1's verify surfaces the mismatch.)
- Re-running §§1–4 with unchanged knobs is a no-op by construction (observe-first discovery).

## Teardown — observe-first, resumable  💥

> 💥 Human go. **Deleting the user pool deletes every user record in it** — that is the blast radius; say it before
> asking go. Removes only what this plan created; the AWS account is borrowed and untouched. Observe → act → re-observe;
> a crash mid-teardown is fine.

```bash
# observe — re-discover by deterministic name (works even if Live State was stranded mid-apply)
USER_POOL_ID="$(aws cognito-idp list-user-pools --region "$AWS_REGION" --max-results 60 \
  --query "UserPools[?Name=='${POOL_NAME}'].Id | [0]" --output text)"
[ "$USER_POOL_ID" = "None" ] && USER_POOL_ID=""
CLIENT_ID=""; IDENTITY_POOL_ID=""
[ -n "$USER_POOL_ID" ] && CLIENT_ID="$(aws cognito-idp list-user-pool-clients --region "$AWS_REGION" \
  --user-pool-id "$USER_POOL_ID" --max-results 60 \
  --query "UserPoolClients[?ClientName=='${CLIENT_NAME}'].ClientId | [0]" --output text)"
IDENTITY_POOL_ID="$(aws cognito-identity list-identity-pools --region "$AWS_REGION" --max-results 60 \
  --query "IdentityPools[?IdentityPoolName=='${IDPOOL_NAME}'].IdentityPoolId | [0]" --output text)"

# ── GUARD FIRST, before ANY destructive act — and fail CLOSED (an IAM error ≠ "no consumers") ──
# Is a downstream plan still consuming the broker (policies on the authRole)?
ROLE_PROBE="$(aws iam get-role --role-name "$AUTH_ROLE_NAME" --output json 2>&1 || true)"
if printf '%s' "$ROLE_PROBE" | grep -q NoSuchEntity; then ROLE_EXISTS=no
elif printf '%s' "$ROLE_PROBE" | grep -q '"Role"'; then ROLE_EXISTS=yes
else echo "cannot reach IAM (brokered session creds? use --no-session) — refusing to tear down blind"; exit 1; fi
if [ "$ROLE_EXISTS" = yes ]; then
  ATT="$(aws iam list-attached-role-policies --role-name "$AUTH_ROLE_NAME" --query 'AttachedPolicies[].PolicyName' --output text)" \
    || { echo "cannot verify broker consumers — refusing to tear down blind"; exit 1; }
  INL="$(aws iam list-role-policies --role-name "$AUTH_ROLE_NAME" --query 'PolicyNames[]' --output text)" \
    || { echo "cannot verify broker consumers — refusing to tear down blind"; exit 1; }
  if [ -n "$(printf '%s %s' "$ATT" "$INL" | sed 's/None//g' | tr -d '[:space:]')" ]; then
    echo "authRole still carries policies — tear down the consumers first"; exit 1
  fi
fi

# 💥 reverse of create: role → identity pool → client → IdP → pool
# 4b — the authRole (guard above proved it carries nothing)
[ "$ROLE_EXISTS" = yes ] && aws iam delete-role --role-name "$AUTH_ROLE_NAME"   # (--no-session under a cred broker)
# 4a — the identity pool (iff discovered)
if [ -n "$IDENTITY_POOL_ID" ] && [ "$IDENTITY_POOL_ID" != "None" ]; then
  aws cognito-identity delete-identity-pool --region "$AWS_REGION" --identity-pool-id "$IDENTITY_POOL_ID"
fi
# 3 — app client (before the IdP it references — honest reverse of the create frontier)
if [ -n "$USER_POOL_ID" ] && [ -n "$CLIENT_ID" ] && [ "$CLIENT_ID" != "None" ]; then
  aws cognito-idp delete-user-pool-client --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" --client-id "$CLIENT_ID"
fi
# 2 — Google IdP (no-op if absent)
if [ -n "$USER_POOL_ID" ]; then
  aws cognito-idp delete-identity-provider --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
    --provider-name Google 2>/dev/null || true
fi
# 1 💥 — the pool itself (ALL USERS GONE). Ownership check first: name-discovery could have adopted a
#   same-named pool this plan never created — only delete what carries our tag. A pool with a hosted
#   domain refuses deletion — none is created here.
if [ -n "$USER_POOL_ID" ]; then
  OWNED="$(aws cognito-idp describe-user-pool --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" \
    --query 'UserPool.UserPoolTags.ManagedBy' --output text)"
  [ "$OWNED" = "ephemera" ] || { echo "pool ${USER_POOL_ID} lacks ManagedBy=ephemera — not ours to delete"; exit 1; }
  aws cognito-idp delete-user-pool --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID"
fi
```
```bash
# ✔ teardown verify — everything reports gone (capture, then grep: pipefail would eat a raw pipeline's result)
if [ -n "${USER_POOL_ID:-}" ]; then
  OUT="$(aws cognito-idp describe-user-pool --region "$AWS_REGION" --user-pool-id "$USER_POOL_ID" 2>&1 || true)"
  printf '%s' "$OUT" | grep -q ResourceNotFound && echo "user pool gone" || { echo "user pool STILL EXISTS"; exit 1; }
else echo "user pool gone (nothing discovered)"; fi
OUT="$(aws iam get-role --role-name "$AUTH_ROLE_NAME" 2>&1 || true)"
printf '%s' "$OUT" | grep -q NoSuchEntity && echo "authRole gone" || { echo "authRole still exists (or IAM unreachable)"; exit 1; }
```
> → Live State: `status: gone`, clear realized ids.

## Composition — how this plugs into the fleet

`identity(${USER_POOL_ID})` is consumed by an **app**: its frontend signs users in (SRP or USER_PASSWORD_AUTH; Google
via the IdP), its backend **verifies** tokens against the issuer JWKS
(`https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}/.well-known/jwks.json`) — or server-side via the §5
`get-user` pattern. `aws-credential-broker(${IDENTITY_POOL_ID})` is consumed by **infra plans**: a future
`storage.aws.md` attaches per-user-prefix S3 policies to `AUTH_ROLE_ARN` (the classic public/protected/private layout),
a future `data-api.aws.md` points AppSync's user-pool auth at this pool. Sibling binding:
[`auth.firebase.md`](./auth.firebase.md) — same acceptance contract, no credential broker (the asymmetry note lives there).

## Deliberately not included

- **MFA** — Cognito supports it natively (`set-user-pool-mfa-config`), but the sibling excludes MFA (enterprise tier),
  and contract parity matters more than surface area. Add per need; named so the omission is a decision.
- **Hosted UI + user-pool domain** — Cognito's managed login pages / OAuth code flow. UX, not the "can a user
  authenticate" contract — and a pool with a domain **refuses deletion** until the domain is removed (teardown coupling).
- **Other federated IdPs** (SAML / OIDC / Facebook / Apple) — the same `create-identity-provider` pattern as Google,
  one per provider with its own credentials; add per need.
- **User pool groups & RBAC** — authorization, an app concern (the realized Amplify source of this plan used an
  `admin` group in its API auth rules — that belongs to the consuming API plan, not the identity plan).
- **Lambda triggers** (pre-sign-up, post-confirmation, custom messages) — a compose seam with a future `service.aws.md`;
  none here so the plan stays pure config-plus-pool.
- **SES-backed email** — Cognito's default mailer is fine at acceptance-test volume; production sender identity is
  [`email.aws.md`](./email.aws.md)'s job (a Requires edge when you need it).
- **Unauthenticated (guest) identities** — the `IDENTITY_POOL` enum stops at `auth-only` on purpose; guest AWS
  credentials are a distinct abuse surface. Widen the enum only with a use case in hand.
- **Advanced security / Plus tier** (risk-based auth, compromised-credential checks) — a billing tier above both
  defaults; a separate decision.
