UCC Passkey Auth — Replacing basic-auth on the operator dashboard with WebAuthn
- Version
- v7.8.1
- Date
- 2026-05-07
- Tier
- light
Two-PR cross-repo ship that replaces shared HTTP basic-auth on /control-room/* with WebAuthn passkeys. Per-operator identity, per-device credentials, server-side session allowlist (instant revoke), append-only audit log synced daily into FT2 framework-health. Migration is reversible at every step via UCC_AUTH_MODE env switch (basic / passkey / both). 5 screens · 5 API routes · 22 new files in fitme-story · 19/19 unit tests pass · tsc clean · next build clean. Second feature shipped via the v7.8.1 protocol (Mechanism C session attribution + isolated worktree from Phase 1 + Tier 2.2 logging + Mechanism A coverage telemetry). Structurally closes the asymmetry where iOS users had passkeys but operators had a shared phishable password.
How to read this case studyT1/T2/T3 · ledger · kill criterion▾
- T1Instrumented
- Numbers come from a machine-generated ledger or commit. Reproducible. Highest reader trust.
- T2Declared
- Numbers stated by a structured declaration (PRD, plan, frontmatter) but not directly measured.
- T3Narrative
- Estimates and observations from session memory. Useful for context; not citable as evidence.
- Ledger
- Where to verify the claim — a file path, GitHub issue, or backlog entry. Anything labelled
ledger:is the audit trail. - Kill criterion
- The pre-registered threshold under which this work would have been killed mid-flight. Not fired = work shipped without hitting the threshold.
- Deferred
- Items intentionally not closed in this version. Each cites the ledger that tracks remaining work.
Visual aid · key numbers at a glance
Default · no specialised visual declared- K1 — Registration ceremony fails on > 5% of attempted devices in week 1 → fall back to UCC_AUTH_MODE=basic.
- K2 — any counter_replay event = hard stop. Investigate before any further sign-ins.
- K3 — Vercel function p50 on /api/auth/* > 500 ms sustained 24 h → fall back.
Why this exists
The Unified Control Center shipped on 2026-05-06 [T1] gating /control-room/* behind shared HTTP basic-auth. T2.5 — "passkey replacement for basic-auth" — was explicitly deferred because the Astro→Next.js migration was already a 22-PR effort across two repos.
That deferral surfaced a structural asymmetry: the iOS app shipped passkeys for end users on 2026-05-01 via auth-polish-v2 [T1]. The operator surface — which has access to every framework readout, ship event, ledger, and case study — was gated by a phishable shared credential. Operator security weaker than user security is exactly the wrong ratio.
This feature closes that asymmetry one day after the v7.8.1 framework feature that introduced the protocol it followed.
What landed
fitme-story side — the actual gate (PR #55)
Five screens, five API routes (Node.js runtime), four reusable components, one Vercel Cron route, and one extended proxy.ts switch. The gate matrix:
UCC_AUTH_MODE | Behavior |
|---|---|
basic (default; pre-cutover) | Existing basic-auth path; no behavior change |
both (cutover window) | Accept either basic-auth OR passkey session cookie; log which path was used |
passkey (steady state) | Require iron-session cookie; redirect-to-sign-in on miss (NOT 401) |
The cookie is iron-session AES-GCM-sealed (HttpOnly + Secure + SameSite=Strict, scoped to /control-room, 24h hard cap with 12h sliding refresh). Server-side ucc:session:<sid> allowlist in Upstash Redis enables instant revoke — flipping revokedAt on a credential bulk-clears every session for that operator on the next request.
FT2 side — cross-repo audit-log sync (PR #248)
Daily GitHub Actions cron (05:17 UTC) pulls a Vercel Blob populated by the fitme-story Vercel Cron (05:13 UTC) and commits it to .claude/logs/ucc-auth-events.jsonl. The cross-repo workflow is repo-variable gated — absence of UCC_AUDIT_BLOB_URL is a no-op, not a failure. This matches the migration plan's reversibility property at the infrastructure layer.
Defenses
- Phishing → WebAuthn binds assertion to RP-ID (
fitme-story.vercel.app); browser refuses to sign for any other origin. - Replay → Per-ceremony challenge in Redis (60s TTL, single-use) + CAS counter check. Hardware authenticators that always report
counter=0explicitly handled. - Session theft → HttpOnly + Secure + SameSite=Strict + server-side allowlist. Any compromised cookie is one revoke away from invalidation.
- PII in audit log → SHA-256 hashes for credential IDs + session IDs; IPv4 truncated to /24, IPv6 to /48; UA stripped to family. Tests assert no raw value appears in the JSONL output.
How this got shipped — the v7.8.1 protocol
Second feature shipped via the v7.8 protocol (after framework-v7-8.1 itself, the same-day morning ship). The protocol mandates:
- Isolated worktree from Phase 1 onward (Mode C of
BRANCH_ISOLATION_VIOLATION) - Mechanism C session attribution via
.claude/active-featurelockfile - Tier 2.2 contemporaneous logging on every phase transition
- v4.X preflight gates (
/ux preflight+/design preflight) before Phase 4 - v4.X pre-merge-review gates (
/ux pre-merge-review+/design pre-merge-review) before Phase 7 FEATURE_CLOSURE_COMPLETENESSgate at Phase 8 (7 frontmatter fields + Q7 kill_criteria_resolution + Q6 PR parity)
Compliance ledger on this ship: ✓ ✓ ✓ ✓ ✓ ✓ — the kill_criteria_resolution field is set to Pending — week-1 telemetry gate per the documented forward-only path.
Honest disclosures
- One bug caught + fixed during Phase 5.
iron-session.unsealDatareturns{}on garbage input rather than throwing. MyunsealSessionwrapper would have returned an empty SessionPayload-shaped object (truthy, defensive checks pass). Caught by the "returns null on garbage input" test. Fix: explicit field-presence check on all 4 payload fields. The test asserts the bug stays dead. - 4 modules refactored to lazy env reads. Static reads at module-load time caused test isolation issues (env-var override after import had no effect because imports hoist). Refactored to read at call-time. This is also the right production behavior.
- Figma node IDs are absent. Per
state.json.figma_build_status: "deferred_to_prompt". The fitme-story dashboard has no Figma-file mapping by design — built code-first during the UCC migration. Portable build prompt atdocs/prompts/ui/2026-05-07-ucc-passkey-auth-design-build.mdis the v4.X documented escape hatch. kill_criteria_resolutionis "pending". Time-deferred resolution (T+7d post-cutover). Frontmatter records the criteria + a placeholder; actual update lands in §99 after the cutover window.- Patterns ported, not extracted.
auth-polish-v2's capability-detection + consent-sheet + inline-error-banner + mock-fixture-test patterns are mirrored web-side rather than extracted into a shared lib. Consolidation can happen if a third auth surface emerges.
Cross-references
- Source case study:
docs/case-studies/ucc-passkey-auth-case-study.md - Predecessor: Unified Control Center — produced the T2.5 deferral
- Sibling: Auth Polish v2 — iOS-side passkey patterns ported here
- Protocol: Framework v7.8.1 — Branch Isolation + Closure Completeness — the v7.8 protocol gated this ship