Skip to content

feat(auth): passkey register handoff Redis module - #1010

Merged
2witstudios merged 3 commits into
masterfrom
pu/passkey-add-handoff-module
Apr 14, 2026
Merged

2witstudios merged 3 commits into
masterfrom
pu/passkey-add-handoff-module

Conversation

@2witstudios

Copy link
Copy Markdown
Owner

Summary

Epic: tasks/passkey-add-desktop-fix.md — sub-task Server Register Handoff Token Module (task 1 of 4).

Adds packages/lib/src/auth/passkey-register-handoff.ts: a Redis-backed, short-lived, one-time token that lets the authenticated Electron renderer hand the user's identity to the system browser so the external passkey-add ceremony can authorize without a PageSpace session cookie. Foundation only — no routes, no callers, no UI wire-up. Those are the other three epic sub-tasks running in parallel worktrees.

Mirrors auth/exchange-codes exactly:

  • 32-byte base64url plaintext token
  • SHA-256 hashed key at rest (auth:passkey-register-handoff:<hash>)
  • 300s TTL
  • Atomic Lua GET + DEL on consume (no race on double-use)
  • Fail-closed on create when Redis is unavailable (throws, both prod and dev)
  • Log-and-return-null on peek/consume when Redis is unavailable

peek (non-destructive) is the new piece vs. exchange-codes: the external options endpoint needs to validate the token without deleting it, since the verify endpoint must still find it.

TDD commits

  • RED: 4a90d57a — test(auth): RED passkey-register-handoff module
  • GREEN: 98174e20 — feat(auth): passkey-register-handoff Redis module

Epic requirement checklist

  • ✅ create: generates 32-byte base64url token, SHA-256 hashed key, stores { userId, createdAt } with 300s TTL
  • ✅ peek: non-destructive GET, returns data or null, never throws on bad input
  • ✅ consume: atomic Lua GET+DEL, one-time use, returns data or null
  • ✅ Create + peek round-trip returns { userId }
  • ✅ Create + consume round-trip returns { userId } and deletes the key
  • ✅ Peek does NOT delete (verified by consuming afterwards)
  • ✅ Second consume on the same token returns null
  • ✅ Unknown / empty / malformed token → peek and consume return null without throwing
  • ✅ Redis unavailable in production → create throws, peek/consume return null
  • ✅ Redis unavailable in development → create throws, peek/consume return null
  • ✅ Re-exported from packages/lib/src/auth/index.ts barrel
  • ✅ No existing files modified except the auth barrel
  • ✅ No any types; no new Redis key prefix, TTL, or hashing scheme invented

Test plan

  • pnpm --filter @pagespace/lib test passkey-register-handoff — 19/19 passing
  • pnpm --filter @pagespace/lib typecheck — clean
  • CI green on PR

🤖 Generated with Claude Code

2witstudios and others added 2 commits April 14, 2026 09:56
Failing tests for the passkey register handoff Redis module covering
create/peek/consume semantics, one-time-use atomicity, malformed input,
and Redis-unavailable fail modes. Next commit implements the module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Redis-backed one-time handoff tokens that let an authenticated desktop
user hand their identity to the system browser for the passkey add
ceremony. Mirrors exchange-codes: 32-byte base64url tokens, SHA-256
hashed at rest, 300s TTL, atomic Lua GET+DEL on consume. Adds peek()
so the external options endpoint can validate without invalidating
before the verify step. Fail-closed on create, log-and-null on reads.

Foundation for the passkey-add-desktop-fix epic; no routes or callers
wired up in this commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 44 minutes and 44 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 44 minutes and 44 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8570227f-68b4-444a-a924-dfa1cf9efbb0

📥 Commits

Reviewing files that changed from the base of the PR and between 372f2b7 and 7739678.

📒 Files selected for processing (3)
  • packages/lib/src/auth/__tests__/passkey-register-handoff.test.ts
  • packages/lib/src/auth/index.ts
  • packages/lib/src/auth/passkey-register-handoff.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/passkey-add-handoff-module

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@vercel

vercel Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pagespace-master-plan Ready Ready Preview, Comment Apr 14, 2026 3:08pm

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 98174e209b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/lib/src/auth/passkey-register-handoff.ts Outdated
redis.get / redis.eval can reject after the client is acquired
(connection drops, command timeouts). Previously these would bubble
up and break the documented 'never throws' contract on peek/consume,
surfacing as 500s in the external passkey options/verify flows during
transient outages. Wrap both calls in try/catch and return null with
a logged error, matching the fail-closed posture used when the client
itself is unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@2witstudios
2witstudios merged commit 9535c08 into master Apr 14, 2026
12 checks passed
@2witstudios
2witstudios deleted the pu/passkey-add-handoff-module branch April 14, 2026 15:50
2witstudios added a commit that referenced this pull request Apr 14, 2026
Tests for the integration layer that wires the foundation PRs (#1008, #1009,
#1010, #1011) into a working end-to-end flow for adding a passkey from the
Settings → Security panel on desktop. Fails until the routes, ceremony helper,
external page, and PasskeyManager desktop branch land in the next commit.

- handoff mint route: session auth + CSRF + rate limit, mints handoffToken
  via createPasskeyRegisterHandoff and audits auth.token.created
- register/options handoffToken branch: peek (non-destructive), skips session
  auth + CSRF, 401 HANDOFF_INVALID + audit on miss, rate limit bound to
  resolved userId, session path regression
- register verify handoffToken branch: consume (one-time-use), second call
  rejected, rate limit bound to resolved userId, session path regression
- runPasskeyRegisterExternalCeremony: options → startRegistration → verify →
  pagespace://passkey-registered, NotAllowedError → cancelled result
- PasskeyManager desktop branch: update-prompt when bridge missing, POST
  handoff → openExternal with /auth/passkey-register-external URL, no
  in-window startRegistration, onRegistered → mutate('/api/auth/passkey')
  + toast, unsubscribe on unmount, web regression

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2witstudios added a commit that referenced this pull request Apr 14, 2026
Wires the merged foundation PRs (#1008, #1009, #1010, #1011) into a working
end-to-end flow so Electron users can add a passkey from Settings → Security.
Electron's Chromium cannot drive Touch ID / Windows Hello without entitlements
we don't ship, so the ceremony is delegated to the system browser via the
same `shell.openExternal` + `pagespace://` deep-link pattern PR #1001 used for
sign-in.

New:
- POST /api/auth/passkey/register/handoff — authenticated mint endpoint for
  one-time handoff tokens (session auth + CSRF + per-user rate limit).
- apps/web/src/components/auth/runPasskeyRegisterExternalCeremony.ts —
  options → startRegistration → verify → pagespace://passkey-registered.
- apps/web/src/app/auth/passkey-register-external/page.tsx — system-browser
  landing page that runs the ceremony and deep-links back to the desktop app.

Modified in place (mirroring authenticate/route.ts desktopExchange branch):
- register/options/route.ts — handoffToken branch uses peekPasskeyRegisterHandoff
  (non-destructive; verify step still needs the token), skips session + CSRF,
  401 HANDOFF_INVALID on miss, rate limit bound to resolved userId.
- register/route.ts — handoffToken branch uses consumePasskeyRegisterHandoff
  (atomic one-time-use), 401 on miss, same rate limit binding.
- PasskeyManager.tsx — desktop branch in handleRegister: update-prompt guard
  when bridge missing, POST handoff → openExternal with /auth/passkey-register-
  external URL, no in-window startRegistration. Deep-link subscription via
  window.electron.passkey.onRegistered refreshes the passkey list and toasts
  on return. Web path unchanged.

Completes the Passkey Add Desktop Fix epic (tasks/passkey-add-desktop-fix.md).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2witstudios added a commit that referenced this pull request Apr 14, 2026
* test(auth): RED — passkey add-from-settings desktop integration

Tests for the integration layer that wires the foundation PRs (#1008, #1009,
#1010, #1011) into a working end-to-end flow for adding a passkey from the
Settings → Security panel on desktop. Fails until the routes, ceremony helper,
external page, and PasskeyManager desktop branch land in the next commit.

- handoff mint route: session auth + CSRF + rate limit, mints handoffToken
  via createPasskeyRegisterHandoff and audits auth.token.created
- register/options handoffToken branch: peek (non-destructive), skips session
  auth + CSRF, 401 HANDOFF_INVALID + audit on miss, rate limit bound to
  resolved userId, session path regression
- register verify handoffToken branch: consume (one-time-use), second call
  rejected, rate limit bound to resolved userId, session path regression
- runPasskeyRegisterExternalCeremony: options → startRegistration → verify →
  pagespace://passkey-registered, NotAllowedError → cancelled result
- PasskeyManager desktop branch: update-prompt when bridge missing, POST
  handoff → openExternal with /auth/passkey-register-external URL, no
  in-window startRegistration, onRegistered → mutate('/api/auth/passkey')
  + toast, unsubscribe on unmount, web regression

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): GREEN — passkey add-from-settings desktop integration

Wires the merged foundation PRs (#1008, #1009, #1010, #1011) into a working
end-to-end flow so Electron users can add a passkey from Settings → Security.
Electron's Chromium cannot drive Touch ID / Windows Hello without entitlements
we don't ship, so the ceremony is delegated to the system browser via the
same `shell.openExternal` + `pagespace://` deep-link pattern PR #1001 used for
sign-in.

New:
- POST /api/auth/passkey/register/handoff — authenticated mint endpoint for
  one-time handoff tokens (session auth + CSRF + per-user rate limit).
- apps/web/src/components/auth/runPasskeyRegisterExternalCeremony.ts —
  options → startRegistration → verify → pagespace://passkey-registered.
- apps/web/src/app/auth/passkey-register-external/page.tsx — system-browser
  landing page that runs the ceremony and deep-links back to the desktop app.

Modified in place (mirroring authenticate/route.ts desktopExchange branch):
- register/options/route.ts — handoffToken branch uses peekPasskeyRegisterHandoff
  (non-destructive; verify step still needs the token), skips session + CSRF,
  401 HANDOFF_INVALID on miss, rate limit bound to resolved userId.
- register/route.ts — handoffToken branch uses consumePasskeyRegisterHandoff
  (atomic one-time-use), 401 on miss, same rate limit binding.
- PasskeyManager.tsx — desktop branch in handleRegister: update-prompt guard
  when bridge missing, POST handoff → openExternal with /auth/passkey-register-
  external URL, no in-window startRegistration. Deep-link subscription via
  window.electron.passkey.onRegistered refreshes the passkey list and toasts
  on return. Web path unchanged.

Completes the Passkey Add Desktop Fix epic (tasks/passkey-add-desktop-fix.md).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address PR #1012 review feedback — fragment handoff, 400 on parse

Addresses Codex and CodeRabbit review feedback on PR #1012:

**Security — move handoff token out of the query string**
(CodeRabbit Major on passkey-register-external/page.tsx)
- buildPasskeyRegisterExternalUrl now places handoffToken in the URL fragment
  (`#handoffToken=...`), never the query. Fragments never hit the network,
  server access logs, CDN logs, or the Referer header, so the capability
  token no longer leaks anywhere a log scraper or a same-origin fetch could
  see it.
- parsePasskeyRegisterExternalParams takes an optional `hash` argument and
  reads the token from there; deviceId/deviceName remain in the query.
- External page clears the fragment via history.replaceState after reading,
  so the token is not left sitting in the browser address bar.
- Round-trip and missing-field tests updated to cover the new transport.

**Robustness — request body parsing**
(Codex P2 + CodeRabbit Minor)
- register/route.ts wraps `await req.json()` in try/catch and returns a
  controlled 400 on malformed bodies instead of falling to the outer catch
  and emitting internal-error noise to unauthenticated callers.
- register/options/route.ts readOptionalJson normalizes non-plain-object
  parses (null, arrays, primitives) to `{}` so `body.handoffToken` access
  never throws. Regression tests for `'null'` and malformed bodies confirm
  the session-auth path still runs.

**Robustness — unhandled promise on external page**
(Codex P2 on passkey-register-external/page.tsx)
- Effect now attaches a .catch that surfaces the error to the status state
  instead of leaving the spinner stuck if runPasskeyRegisterExternalCeremony
  rejects (e.g. browser offline, fetch throw).

**Cleanup — ceremony helper typing**
(CodeRabbit Nit on runPasskeyRegisterExternalCeremony.ts)
- Derived `RegisterOptionsJSON` from `Parameters<typeof startRegistration>`
  and typed the parsed options directly so startRegistration no longer needs
  a double `as unknown as` cast.

**Test hygiene**
(CodeRabbit Nit on PasskeyManager.desktop.test.tsx)
- Replaced a raw `setTimeout(10)` with a `waitFor` on an observable
  assertion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): tighten Bearer check + surface parse errors in options route

Addresses a second round of review findings on PR #1012:

**CSRF bypass must require the Bearer scheme, not any Authorization header**

The three routes touched by this PR (`register/handoff`, `register/options`,
`register`) previously skipped CSRF whenever `authorization` was present, which
would let a non-Bearer scheme (`Basic`, `Digest`, random garbage) bypass CSRF
even though `authenticateSessionRequest` would reject it upstream. Now each
route uses `getBearerToken(req)` (already the canonical scheme check in
`@/lib/auth`, previously private) and only skips CSRF when the header actually
carries a `Bearer ` prefix. `getBearerToken` is now exported as a named symbol.

Regression tests added to all three route suites: an `Authorization: Basic …`
header with a bad `x-csrf-token` must still return 403.

**`options/route.ts` must surface malformed JSON as 400, not fall through**

`readOptionalJson` previously caught `JSON.parse` errors and returned `{}`,
which meant a syntactically invalid body would silently engage the session
path instead of returning a controlled 400 (mismatched with `register/route.ts`
behavior). The helper no longer catches `SyntaxError`; the caller wraps the
call in a try/catch and returns a 400 `{ error: 'Invalid request body' }`.
Empty body still resolves to `{}` (so callers without a body still reach the
session path), and non-object JSON (`null`, arrays, primitives) still
normalizes to `{}`.

The existing "treats malformed JSON as empty body and falls through" test is
inverted to "returns 400 (not 500, not session fallthrough) when body is
malformed JSON" and now also asserts `authenticateSessionRequest` is never
called.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
Preview — 77396781 Deployed Apr 14, 2026 by vercel[bot]
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