Skip to content

feat(engine): passkey (WebAuthn) login — core auth provider (HT-75) - #94

Merged
zaridan merged 3 commits into
mainfrom
feat/ht-75-passkey-engine
Jul 19, 2026
Merged

feat(engine): passkey (WebAuthn) login — core auth provider (HT-75)#94
zaridan merged 3 commits into
mainfrom
feat/ht-75-passkey-engine

Conversation

@zaridan

@zaridan zaridan commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Passkey (WebAuthn) login as a second core auth provider on the HT-54 seam (HT-75; spec specs/auth/passkeys.md, merged in #88). Core-free — security hygiene is never paid (catalog §1). Engine + API only; the login/profile UI is design-blocked (separate ticket).

  • Migration 026: webauthn_credentials, webauthn_challenges, webauthn_stepup_tokens (three additive tables, no backfill).
  • Three ceremonies, discriminator-enforced at both token-prefix and DB-ceremony-column layers: registration (gated on fresh ≤5min step-up re-auth — the planted-passkey ATO is impossible), authentication (conditional-UI with challenge re-mint), step-up. rpId/origin config-only (never request-derived; inbox.* UI origin; localhost-only HTTP).
  • Counter clone-detection that actually alerts: signature verification and counter policy cleanly separated (the library's own regression-throw is disabled by design so our FOR UPDATE-locked comparison is the sole authority), sequential AND concurrent regressions rejected-recorded-logged, surfaced through a new /internal/health webauthn-counter-regression check (200→503 pivot, HT-44 pattern).
  • Session mint identical to HT-54; enrollment-notification email; userHandle↔agent_id check; generic 409 no-enumeration; @simplewebauthn/server (MIT).

Review trail — the full auth-critical gauntlet

Sonnet-authored → Codex independent pass: SHIP (verified the challenge-encoding round-trip against library source) → Fable lead-tier review: FIX-FIRST (1 MAJOR — the counter-regression observability shipped non-functional: no health check, the real library threw before our recording ran, tests mocked the throw away; all 8 security-critical lenses verified correct) → fix applied → targeted Codex re-check of the changed recording path: SHIP (confirmed counter:0 weakens no other library check). The two reviewers caught disjoint things — the diversity the tiered rule exists for.

Gates

typecheck 0 · lint 0 · test 0 — 1507 passed. Migration 026 applied to prod ahead of merge (additive, no breaking window). No HT-5 dependency — passkeys are core, not a marketplace module (spec §1 corrected to match catalog §1 / #85 in this branch).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added passkey (WebAuthn) authentication, registration, step-up, and credential management (list, rename, revoke) with admin/self access controls.
    • Exposed WebAuthn as an auth provider and added passkey-specific API routes when a valid domain-based application URL is configured.
  • Bug Fixes
    • Expired/replayed passkey challenges now return a distinct challenge_expired unauthorized response.
    • Added protections against passkey counter regressions and unsafe removal of the final credential.
  • Monitoring
    • Added health reporting and alerting for recent passkey counter regressions.
  • Documentation
    • Updated the passkeys spec draft and classification details.

zaridan and others added 2 commits July 19, 2026 14:37
Implements specs/auth/passkeys.md end to end: migration 026
(webauthn_credentials, webauthn_challenges, webauthn_stepup_tokens),
the WebAuthnStore, the htw./htsu. signed-token pair, the shared
authentication-ceremony verifier (TOCTOU-safe FOR UPDATE counter
policy, two-tier clone/regression detection routed to the HT-44
health check, userHandle cross-check), the WebAuthnAuthProvider
(kind: 'webauthn' on the provider seam), and the full step-up +
registration + credential-management API surface. root.ts wires the
provider only when config.uiBaseUrl resolves to a domain-form
hostname; an IP-literal or unset uiBaseUrl degrades to
webauthn-absent rather than failing the whole engine boot. Engine +
API only — no web/UI (HT-75 is design-blocked; a separate ticket).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fire (HT-75)

Review findings (Codex SHIP, Fable lead-tier FIX-FIRST):

MAJOR — the spec §8 "reject AND alert" signal was non-functional:
- health.ts's runHealthCheck had no webauthn check at all; add
  webauthn-counter-regression, same 24h-growth idiom as
  forged-token-burst, trips the existing 200->503 pivot.
- The real @simplewebauthn verifyAuthenticationResponse throws its own
  counter-regression error using the unlocked pre-read counter, before
  our locked Tier-1/Tier-2 logic ever runs — markCounterRegression was
  reachable only in a narrow concurrent-race window, never the common
  sequential-replay case. Fixed by always passing credential.counter: 0
  to the library (structurally disables its internal throw) and relying
  entirely on our own FOR UPDATE-locked comparison, matching what spec
  §6.2 already describes as the intended split (signature verification
  vs. counter policy as two separate steps).
- The regression test mocked a resolved regressed counter, which the
  real library never produces (it throws) — false-green over a dead
  path. Replaced with a mock that faithfully reproduces the library's
  own throw-on-regression guard against whatever counter we actually
  pass, plus an explicit assertion that we pass counter: 0.
- Added the missing structured console.warn at the regression-detection
  point (spec §8: "the log line is what makes it investigable").

MINOR — src/store/webauthn.ts's module doc named a nonexistent
src/auth/webauthn-service.ts; corrected to webauthn-ceremony.ts.

Also corrects specs/auth/passkeys.md §1/§12: passkeys were already
core (specs/modules/catalog.md §1/§2.2, accepted 2026-07-18; reconciled
into agents-and-auth.md via HT-76/PR #85, merged same day as this
spec's draft.1-3). This spec inherited the pre-reconciliation
"licensed marketplace module, waiting on HT-5" framing rather than the
fix — corrected to draft.4, documentation only, no design change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e1fc0d2f-5748-4a52-b713-fa65687d9cad

📥 Commits

Reviewing files that changed from the base of the PR and between b201501 and 08e05fb.

📒 Files selected for processing (2)
  • src/store/webauthn.test.ts
  • src/store/webauthn.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/store/webauthn.test.ts
  • src/store/webauthn.ts

📝 Walkthrough

Walkthrough

Introduces core WebAuthn/passkey authentication, registration, step-up verification, credential management, persistence, routing, conditional application wiring, notification email construction, and counter-regression health monitoring.

Changes

Passkey foundation

Layer / File(s) Summary
WebAuthn persistence and security primitives
package.json, src/db/*, src/store/webauthn*, src/auth/webauthn-token*, src/auth/webauthn-rp*
Adds WebAuthn tables, credential/challenge/step-up token storage, signed token formats, relying-party resolution, and tests.
Ceremony verification and authentication provider
src/auth/provider.ts, src/auth/webauthn-ceremony*, src/auth/webauthn-provider*, src/auth/webauthn-notify-email.ts
Adds ceremony verification, counter handling, provider behavior, and passkey-added email construction.
Passkey API and route dispatch
src/api/router*, src/api/index.ts, src/api/agents.ts, src/api/webauthn*
Adds WebAuthn routes, authentication, step-up, registration, credential management, challenge-expiry handling, and API tests.
Application wiring and operational monitoring
src/composition/root*, src/composition/health*, src/composition/app.test.ts, specs/auth/passkeys.md
Conditionally enables WebAuthn from uiBaseUrl, adds provider and health-report integration, and updates passkey specification classification.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding passkey/WebAuthn login as a core auth provider.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-75-passkey-engine

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/db/migrate.ts (1)

1432-1446: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider indexing agent_id on webauthn_challenges/webauthn_stepup_tokens.

Both tables have an agent_id FK with ON DELETE CASCADE but no index on that column, unlike webauthn_credentials (which gets webauthn_credentials_agent). Deleting an Agent will force a sequential scan of these tables to cascade. Impact is likely small since both tables self-purge expired rows opportunistically, but adding the indexes costs little and removes the asymmetry.

♻️ Suggested addition
 CREATE INDEX webauthn_challenges_expires ON webauthn_challenges (expires_at);
+CREATE INDEX webauthn_challenges_agent ON webauthn_challenges (agent_id);
 CREATE TABLE webauthn_stepup_tokens (
...
 CREATE INDEX webauthn_stepup_tokens_expires ON webauthn_stepup_tokens (expires_at);
+CREATE INDEX webauthn_stepup_tokens_agent ON webauthn_stepup_tokens (agent_id);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/migrate.ts` around lines 1432 - 1446, Add indexes on the agent_id
columns of webauthn_challenges and webauthn_stepup_tokens, matching the existing
webauthn_credentials_agent indexing pattern while preserving the current table
definitions and expiry indexes.
src/composition/health.test.ts (1)

352-368: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Parameterize the interval instead of string-splicing it into the SQL.

Static analysis (OpenGrep, ast-grep) flags the template-literal-built INSERT as a SQL-injection pattern. regressedAgoHours is currently a typed, test-only number | null, so there's no live exploit path, but building the query this way is still worth avoiding — a bound parameter closes the gap outright and keeps this fixture from becoming a copy-paste template for something less contained.

🛡️ Suggested fix
-      await database.query(
-        `INSERT INTO webauthn_credentials
-           (agent_id, credential_id, public_key, sign_count, backup_eligible, backup_state, name, sign_count_regression_at)
-         VALUES ($1, $2, $3, 10, false, false, 'Key', ${
-           regressedAgoHours === null ? 'NULL' : `now() - interval '${regressedAgoHours} hours'`
-})`,
-        [agent.id, `cred-${Math.random()}`, new Uint8Array([1])],
-      )
+      await database.query(
+        `INSERT INTO webauthn_credentials
+           (agent_id, credential_id, public_key, sign_count, backup_eligible, backup_state, name, sign_count_regression_at)
+         VALUES ($1, $2, $3, 10, false, false, 'Key',
+           CASE WHEN $4::double precision IS NULL THEN NULL
+                ELSE now() - ($4::double precision * interval '1 hour') END)`,
+        [agent.id, `cred-${Math.random()}`, new Uint8Array([1]), regressedAgoHours],
+      )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composition/health.test.ts` around lines 352 - 368, Update
seedRegressedCredential to remove regressedAgoHours from the interpolated INSERT
SQL and bind it as a query parameter, preserving NULL behavior when the value is
null and calculating the requested interval through parameterized SQL for
non-null values.

Source: Linters/SAST tools

specs/auth/passkeys.md (1)

45-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use "plugin exception," not "module exception," for the HT-5/AGPL §7 clause.

This is original (non-quoted) prose introduced in draft.4, so the repo's naming convention applies here: the specific legal clause should be referred to as the "plugin exception," not renamed to "module exception."

✏️ Suggested fix
-anywhere), so this is a documentation-only correction. HT-5 (the AGPL §7
-module exception) gates **in-process third-party modules and external
+anywhere), so this is a documentation-only correction. HT-5 (the AGPL §7
+plugin exception) gates **in-process third-party modules and external
 contributions** — Google SSO, magic-link, and SAML/enterprise SSO remain

As per coding guidelines, "Call extension artifacts Modules, never plugins, except within the legal phrase plugin exception and quoted charter language."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/auth/passkeys.md` around lines 45 - 46, Update the original prose in
the HT-5/AGPL §7 passage to call the legal clause the “plugin exception” instead
of the “module exception”; preserve “Modules” for extension artifacts elsewhere,
except in this legal phrase and quoted charter language.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/store/webauthn.ts`:
- Around line 325-353: Update deleteCredential to lock all webauthn_credentials
rows for the specified agentId before evaluating the last-credential guard,
rather than locking only the target row. Preserve the existing not_found
behavior and ensure the subsequent other_count check runs after the agent-wide
credential lock so concurrent deletes serialize.

---

Nitpick comments:
In `@specs/auth/passkeys.md`:
- Around line 45-46: Update the original prose in the HT-5/AGPL §7 passage to
call the legal clause the “plugin exception” instead of the “module exception”;
preserve “Modules” for extension artifacts elsewhere, except in this legal
phrase and quoted charter language.

In `@src/composition/health.test.ts`:
- Around line 352-368: Update seedRegressedCredential to remove
regressedAgoHours from the interpolated INSERT SQL and bind it as a query
parameter, preserving NULL behavior when the value is null and calculating the
requested interval through parameterized SQL for non-null values.

In `@src/db/migrate.ts`:
- Around line 1432-1446: Add indexes on the agent_id columns of
webauthn_challenges and webauthn_stepup_tokens, matching the existing
webauthn_credentials_agent indexing pattern while preserving the current table
definitions and expiry indexes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 461e5b3b-6719-485a-a28e-d605cfca010b

📥 Commits

Reviewing files that changed from the base of the PR and between df89a4c and b201501.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (28)
  • package.json
  • specs/auth/passkeys.md
  • src/api/agents.ts
  • src/api/index.ts
  • src/api/router.test.ts
  • src/api/router.ts
  • src/api/webauthn.test.ts
  • src/api/webauthn.ts
  • src/auth/provider.ts
  • src/auth/webauthn-ceremony.test.ts
  • src/auth/webauthn-ceremony.ts
  • src/auth/webauthn-notify-email.ts
  • src/auth/webauthn-provider.test.ts
  • src/auth/webauthn-provider.ts
  • src/auth/webauthn-rp.test.ts
  • src/auth/webauthn-rp.ts
  • src/auth/webauthn-token.test.ts
  • src/auth/webauthn-token.ts
  • src/composition/app.test.ts
  • src/composition/health.test.ts
  • src/composition/health.ts
  • src/composition/root.test.ts
  • src/composition/root.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/db/postgres.test.ts
  • src/store/webauthn.test.ts
  • src/store/webauthn.ts

Comment thread src/store/webauthn.ts
…(HT-75)

CodeRabbit finding on PR #94: WebAuthnStore.deleteCredential took
FOR UPDATE on the target credential row only; the "does this Agent
have another credential" count was a separate, unlocked read. Two
concurrent deletes of DIFFERENT credentials for the same passwordless
Agent could each see the other's row as still present, both pass the
guard, and both commit -- leaving the Agent with zero credentials and
no password, locked out.

Fix: SELECT ... WHERE agent_id = $1 FOR UPDATE now locks every
credential row belonging to the Agent (not just the target) before
the guard runs, so a real concurrent Postgres session deleting a
different credential for the same Agent blocks on this same lock and
re-reads current state after the first commits.

Test note: a literal concurrent-call test does not reproduce this
race against PGlite -- verified empirically that PGlite serializes
whole transactions on its single connection (a second db.transaction()
call does not even begin until the first fully commits), so such a
test would pass identically against the old, buggy code and prove
nothing. Matches the identical, already-documented limitation in
src/store/agents.test.ts for the createFirstAdmin advisory-lock guard.
Added instead: an instrumented-Db test proving the lock SQL targets
the Agent's whole credential set (the structural fact real concurrent
Postgres sessions serialize on), and a test proving the guard's
arithmetic is correct against that locked set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@zaridan
zaridan merged commit df3431c into main Jul 19, 2026
5 checks passed
@zaridan
zaridan deleted the feat/ht-75-passkey-engine branch July 19, 2026 22:49
zaridan added a commit that referenced this pull request Jul 19, 2026
…-75) (#96)

* fix(auth): bind step-up challenge consumption to the acting Agent (HT-75)

Found by an independent Codex review of PR #94, after that PR merged.

`verifyAuthenticationCeremony` consumed the `webauthn_challenges` row
immediately after the token's signature/ceremony check, and only then
resolved the credential and applied `requireAgentId`. But `requireAgentId`
was compared against the CREDENTIAL's owner — never against the Agent the
challenge itself was minted for, even though both the signed token and the
DB row already carry that `agentId`.

Consequence: anyone holding a victim's step-up challenge token could spend
it from their own session with any garbage `response`. The consume
succeeded, verification then failed, and the victim's own legitimate verify
came back `challenge_expired`. Not an authentication bypass — the assertion
is still cryptographically verified and the credential-owner check still
holds — but a griefing DoS against a real user's step-up.

Fixed at both layers, matching spec §7's "two independent layers, not
duplicated logic":

- Application: reject when the token's `agentId` is not the acting Agent,
  BEFORE the consume runs.
- Database: `consumeChallenge` takes an optional `expectedAgentId` that
  adds `AND agent_id = $3` to the UPDATE predicate.

The login ceremony is deliberately unaffected: its challenges are minted
with `agent_id IS NULL` (discoverable credential, spec §4.3), so the
binding is conditional on `requireAgentId` rather than unconditional.

Regression test proven to catch the original bug: with both layers
reverted, it fails on the burn assertion (the victim's row is already
consumed). One pre-existing step-up test was minting its challenge with
`agentId: null`, which the new check would have short-circuited — it now
mints for the impersonator so it still exercises the credential-owner
check it is named for.

Gates: typecheck, lint, test:coverage all exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(auth): cover the DB-layer Agent binding; bind registration consume too

Codex review of the previous commit, findings 2 and 5.

Finding 5 (the real one): the step-up ceremony test could not fail if the
new `AND agent_id = $3` clause were deleted. The app-layer check returns
first, so the store's clause was never reached — dead weight no test would
notice losing. Two direct store-level tests added: a step-up row minted for
one Agent is not consumable under another (proven to fail when the clause is
removed), and a login row with `agent_id IS NULL` stays consumable when no
Agent is expected, which guards the SQL branch itself.

This also corrects an overclaim in the previous commit's PR description.
Disabling the app layer and watching the test fail showed the DB clause
firing in THAT configuration; it did not show the committed test covering
the clause. Those are different properties and only the first was verified.

Finding 2: registration's consume omitted the Agent argument, so its binding
rested solely on the app-level check above it. No live bug — that check is
correct — but the DB layer was not defense in depth the way step-up's now
is. Passed through for consistency.

Not addressed, deliberately: `verifyAuthenticationCeremony` still accepts
`ceremony: 'step-up'` with no `requireAgentId`, which would silently disable
the binding. No caller does this. Making it unrepresentable is a type-level
signature change beyond this fix's scope — noted in the PR for TJ.

Gates: typecheck, lint, test:coverage all exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
zaridan added a commit that referenced this pull request Aug 4, 2026
The previous pass keyed on tool names and left the framing they sat in.
"HT-70 review fix:", "(review, 2026-07-31)", "(review fix)" narrate how a
rule was found rather than stating it. 78 sites across specs and source,
not the 49 the first scan reported.

Ticket and PR references are kept as navigable provenance -- "HT-70:",
"(PR #94)", "HT-101 (2026-07-31)". The "review fix" framing around them is
gone.

Also fixed:

- Two lines the previous pass broke: a mangled JSDoc block in migrate.ts
  and a floating comma in conversations.ts.
- Model names that the first sweep missed entirely -- "(Opus)" and "(Opus
  review fix)" in two files.
- A test title used "assistant" for an AI actor; the fixed project term is
  "Assistant".
- Two stale claims in agents-and-auth.md's consolidated changelog. It said
  `agent_mailbox_access` is "schema-only, no behaviour" while section 3.4
  defines managed grants, auto-granting, and grant endpoints; and it said
  "no schema or behaviour in this spec changes" directly after describing
  `webauthn_credentials`. Both now match the sections they describe.

Verified: typecheck, web typecheck, and lint clean. Every source edit is
inside a comment except test titles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zaridan added a commit that referenced this pull request Aug 4, 2026
Source comments named the tooling that prompted a fix -- "CodeRabbit
(Major): claimThreadForDelivery's WHERE clause checked only the lease",
"HT-70 review fix (Codex)", "found by adversarial review" -- and several
recounted what an earlier revision got wrong instead of stating current
behaviour.

Each keeps its technical content and loses the narration. Where a comment
argued against an alternative by describing a past mistake, it now asks the
question directly:

  store/mailboxes.ts    "Why not tell the operator to disconnect and retry?
                         Because that instruction would be false:
                         markDisconnected only sets status ..."
  providers/inbound-email.ts
                        "Why not return a NormalizedInboundEmail? That puts
                         the parse inside the provider ..."
  mail/gmail-reconcile.ts
                        "Why not just ack? The tempting reasoning is 'the
                         holder will advance the cursor' ..."

Ticket and PR references are kept -- "HT-70:", "(PR #94)" -- as navigable
provenance. Vocabulary corrected in three places: "Agent" for a human
support role, "Assistant" for an AI actor, and "module loader" rather than
"plugin loader", since the substrate reserves "plugin" for the legal phrase
"plugin exception".

.coderabbit.yaml is untouched: a tool's own configuration has to name it.

No logic changed. Every edit is inside a comment except eight test titles,
which are descriptive strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant