# Sign in with BW (Pattern B auth)

**Status:** in production
**Lives at:** `/auth/login`, `/auth/callback`, `/auth/logout`, `/api/me`;
`app/services/auth/` (bw_auth kit port, config, session, provision,
middleware), `app/routers/auth.py`, `app/models/user.py`,
`alembic/versions/0002_users.py`; SPA: `frontend/src/lib/api.ts` (401
handling), `frontend/src/components/AppShell.tsx` (UserMenu)
**Spec:** `/srv/system/id-auth/app-auth/` (README → PATTERN-B.md →
CHECKLIST.md; bw_auth.py is the canonical module),
`/srv/apps/work/docs/rebuild/04-architecture.md` §"Sign in with BW",
`02-domain-model.md` §1 (users)

## Summary

Users sign in with their BW (id-auth) account — the server-wide SSO —
via OAuth2 authorization-code + PKCE against `auth.bowden.works`. The
app never sees or stores a password; it verifies an audience-bound,
HMAC-signed id_token and keeps its own short-lived session.

## Why

M1 of the rebuild ladder (09-build-plan, 2026-07-07): retire the
plan's only hard external dependency in week one by integrating the
real Pattern B kit instead of building behind a stub. No password
store, ever (04-architecture).

## Behavior

- **Default-deny.** Every route requires a signed app session except
  the explicit allowlist: `/healthz`, `/auth/login`, `/auth/callback`,
  `/auth/logout`, and `/assets/*` (the SPA's hashed bundles).
  Unauthenticated `/api/*` → 401 JSON (`error_code:
  NOT_AUTHENTICATED`); unauthenticated page routes → 302 into
  `/auth/login?next=<destination>`.
- **The flow.** `/auth/login` parks a PKCE verifier + anti-CSRF state
  in 10-minute HttpOnly cookies and redirects to id-auth's
  `/app-authorize`. `/auth/callback` checks state, redeems the code
  server-to-server (`/app-token`, carries the client secret), verifies
  the id_token (HMAC signature keyed on our client secret, `aud` ==
  our client_id, `exp` in the future), provisions the user, mints the
  session, and redirects to the parked `next` (same-origin paths only).
- **App session.** HttpOnly + Secure + SameSite=Lax cookie
  (`with_session`), HMAC-signed claims (username, email,
  is_super_admin), **12h TTL**. Expiry is silent: the next page hit
  302s back through `/auth/login`, and a visitor whose BW SSO session
  is alive round-trips without seeing a form.
- **Auto-provisioning.** First sight of a BW username creates a
  zero-capability `users` row (no party link, not super admin, empty
  preferences). Re-login is column-scoped: only `email` refreshes;
  `is_super_admin` / `person_party_id` / `preferences` are app-owned
  and never touched by a login.
- **Sign out.** `/auth/logout` clears the app session cookie, then
  redirects to id-auth's `/logout` so the shared BW SSO session is
  revoked too (no silent re-auth on the next visit).
- **SPA.** The header shows the signed-in username (from `/api/me`)
  and a Sign out link. Any 401 on an API fetch triggers a full-page
  navigation to `/auth/login`.
- **Dev stub.** `AUTH_DEV_USER` (judgment #13) resolves a stub
  identity in non-production envs only; with `APP_ENV=production` set
  (the container sets it) the app **refuses to start** if the stub is
  configured.

## Constraints & edge cases

- **Headers never grant identity** (T-AZ-045): the middleware reads
  only the session cookie; `X-Auth-User` & co are ignored everywhere.
- **The id_token is one-shot:** verified in the callback, identity
  extracted, discarded. Never stored, never accepted later.
- The app defines **no** `/login`, `/logout`, `/authorize`, `/_grant`
  root routes (T-AZ-042) — those belong to id-auth's gate snippet;
  the app's only auth surface is `/auth/*` (PATTERN-B §1b).
- Capability changes (super-admin grant/revoke) reach the session at
  the next establishment — bounded by the 12h TTL. Instant revocation
  = rotate the BW client secret (kills every session's signature) or
  wait out the TTL. Revisit if M2's authz engine needs per-request
  freshness.
- id-auth force-logout does not instantly end app sessions (the app's
  session is its own, per the kit); the short TTL bounds the gap.
- `/api/meta` (db status, changelog head) is authenticated; the public
  `/healthz` deliberately reports no db state.
- Open-redirect guard on `next`: same-origin absolute paths only.
- Session signing key derives from `BW_CLIENT_SECRET` unless an
  explicit `SESSION_SECRET` is staged; without either (bare dev) it is
  ephemeral per-process.

## Permissions

Everyone with a BW account granted to `with.bowden.works` can sign in
and gets a zero-capability row. `is_super_admin` is granted by the
owner directly in the DB (an admin surface arrives with the authz
engine, M2+). No capability checks exist yet beyond
authenticated-or-not — M2's authz engine is the real grid.

## Open considerations

- `report_role()` (display-only role mirroring to the BW hub) is in
  the kit port but not called yet — wire it when roles exist (M2+).
- A timed session-refresh (re-validate against id-auth without full
  redirect) could shrink the revocation gap below 12h if needed.
- `person_party_id` gains its FK + unique constraint in M2's schema
  migration (parties lands there).

## Tests

- T-AZ-040 — `tests/test_auth_tokens.py` (verifier: signature/
  audience/expiry/malformed) + `tests/test_auth_flow.py` (callback:
  state mismatch, missing PKCE, forged/wrong-aud/expired tokens ⇒ no
  session)
- T-AZ-041 — `tests/test_auth_allowlist.py` (allowlist exact,
  401 JSON shape, 302 into flow, assets public)
- T-AZ-042 — `tests/test_auth_allowlist.py` (route-table walk)
- T-AZ-043 — `tests/test_auth_dev_stub.py` (production refusal;
  dev resolution)
- T-AZ-045 — `tests/test_auth_allowlist.py` (headers never grant;
  session wins over spoofed header)
- T-AUTH-016 (auto-provision half) — `tests/test_auth_flow.py`
  (first login creates zero-capability row; second login reuses it
  column-scoped)
- Live half of the M1 gate (real rian + adi logins on
  with.bowden.works) is deliberately NOT unit-tested — see
  06-test-plan "Sign-in flow against LIVE id-auth" (the gate IS the
  integration test).

## Changelog

- 2026-07-07 — v0.2.0: shipped (M1). Full code+PKCE flow, default-deny
  middleware, 12h signed session, zero-capability auto-provisioning,
  users table (alembic 0002), /api/me + header UserMenu, AUTH_DEV_USER
  stub with production refusal.
