Skip to content

feat(desktop): allowlist /auth/passkey-register-external for system-browser handoff - #1008

Merged
2witstudios merged 2 commits into
masterfrom
pu/passkey-add-desktop-allowlist
Apr 14, 2026
Merged

2witstudios merged 2 commits into
masterfrom
pu/passkey-add-desktop-allowlist

Conversation

@2witstudios

@2witstudios 2witstudios commented Apr 14, 2026 •

Copy link
Copy Markdown
Owner

Epic sub-task: Electron Open-External Allowlist (tasks/passkey-add-desktop-fix.md).

Extends the auth:open-external IPC allowlist so the Settings → Security add-passkey flow can hand the ceremony off to the system browser the same way sign-in already does (PRs #1001, #1003, #1004). Converts the one-off PASSKEY_EXTERNAL_PATH constant into an ALLOWED_PASSKEY_PATHS set so future passkey flows don't require another one-off edit. Origin matching, OAuth provider branch, and isAppOriginMatch rules are untouched.

TDD trail

  • RED: 59f5089 — failing tests for /auth/passkey-register-external
  • GREEN: f7887b5 — refactor constant → set, update check

Requirement checklist

  • ✅ /auth/passkey-register-external on app origin (https) → allowed
  • ✅ /auth/passkey-register-external on localhost http app origin → allowed
  • ✅ /auth/passkey-external on app origin → still allowed (regression)
  • ✅ /dashboard and other app-origin paths → blocked (regression)
  • ✅ https://accounts.google.com/... → still allowed (regression)
  • ✅ https://appleid.apple.com/... → still allowed (regression)
  • ✅ /auth/passkey-register-external on third-party origin → blocked
  • ✅ Lookalike host pagespace.ai.evil.com → blocked
  • ✅ http:// on non-localhost for either passkey path → blocked

Scope guardrails

  • Only apps/desktop/src/main/ipc-handlers.ts (+ its colocated test) modified.
  • ALLOWED_AUTH_HOSTNAMES and isAppOriginMatch untouched — regression tests cover Google, Apple, lookalike hosts, http-on-non-localhost, and the existing /auth/passkey-external sign-in path to prove it.
  • Deep-link / preload bridge intentionally NOT touched (separate parallel worktree task).

Test plan

  • pnpm --filter desktop typecheck
  • pnpm --filter desktop test — 114/114 passing (21 in ipc-handlers.test.ts, including 5 new /auth/passkey-register-external cases)
  • End-to-end smoke of Settings add-passkey flow — deferred to the follow-up PasskeyManager task in this epic per plan.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Extended passkey-based authentication with support for registration flows.
  • Tests

    • Added unit tests covering passkey authentication paths with origin and protocol validation.

2witstudios and others added 2 commits April 14, 2026 09:56
Add failing tests for /auth/passkey-register-external covering app-origin
(https + localhost), third-party rejection, lookalike-host rejection, and
http-on-non-localhost rejection. Existing /auth/passkey-external and OAuth
provider regression tests continue to pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Refactor the single PASSKEY_EXTERNAL_PATH constant into an
ALLOWED_PASSKEY_PATHS set so both /auth/passkey-external (sign-in
handoff) and /auth/passkey-register-external (Settings add-passkey
handoff) pass the auth:open-external check. Origin matching, OAuth
provider branch, and isAppOriginMatch rules are unchanged.

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

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The auth:open-external IPC handler's allowlist for passkey-related paths has been expanded to include /auth/passkey-register-external in addition to the existing /auth/passkey-external. Implementation logic and corresponding unit tests were updated to reflect this change.

Changes

Cohort / File(s) Summary
Passkey Path Allowlist Expansion
apps/desktop/src/main/ipc-handlers.ts, apps/desktop/src/main/__tests__/ipc-handlers.test.ts
Updated the auth:open-external handler to replace a hard-coded passkey path check with an allowlisted set containing both /auth/passkey-external and /auth/passkey-register-external. Added comprehensive unit tests covering the new path with positive cases (HTTPS app origin and localhost HTTP) and negative cases (third-party origins, non-localhost HTTP).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 A passkey path joins its sibling friend,
Now register externally can also extend,
The allowlist grows with gentle care,
Through tested pathways, secure and fair! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main change: allowing the new /auth/passkey-register-external path in the system-browser handoff allowlist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 pu/passkey-add-desktop-allowlist

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 2:58pm

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/desktop/src/main/ipc-handlers.ts (1)

110-113: Consider centralizing passkey handoff path constants in a shared module.

These path literals now live in desktop and web code, which can drift over time. Moving them to a shared constant source would reduce future allowlist mismatches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/ipc-handlers.ts` around lines 110 - 113,
ALLOWED_PASSKEY_PATHS is defined locally in ipc-handlers.ts causing duplicate
literals across desktop and web; extract these path strings into a shared
constants module (e.g., export a single PASSKEY_HANDOFF_PATHS or
ALLOWED_PASSKEY_PATHS constant) and replace the local ReadonlySet initialization
in ipc-handlers.ts with an import from that shared module, then update the web
code to import the same constant so both consumers reference the single source
of truth; preserve the same type/shape (ReadonlySet<string> or equivalent) and
update any tests or usages that reference the original symbol name to the new
shared export.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/desktop/src/main/ipc-handlers.ts`:
- Around line 110-113: ALLOWED_PASSKEY_PATHS is defined locally in
ipc-handlers.ts causing duplicate literals across desktop and web; extract these
path strings into a shared constants module (e.g., export a single
PASSKEY_HANDOFF_PATHS or ALLOWED_PASSKEY_PATHS constant) and replace the local
ReadonlySet initialization in ipc-handlers.ts with an import from that shared
module, then update the web code to import the same constant so both consumers
reference the single source of truth; preserve the same type/shape
(ReadonlySet<string> or equivalent) and update any tests or usages that
reference the original symbol name to the new shared export.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 48c88125-4526-4f12-92a3-876310eeb84e

📥 Commits

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

📒 Files selected for processing (2)
  • apps/desktop/src/main/__tests__/ipc-handlers.test.ts
  • apps/desktop/src/main/ipc-handlers.ts

@2witstudios

Copy link
Copy Markdown
Owner Author

CodeRabbit nitpick (lines 110–113): Consider centralizing passkey handoff path constants in a shared module.

Declining this refactor for this PR, intentionally:

  1. Scope guardrail. This PR's task explicitly restricts edits to apps/desktop/src/main/ipc-handlers.ts (+ its colocated test). The allowlist extension is deliberately surgical because this branch is one of several parallel worktrees landing the "Passkey Add Desktop Fix" epic.
  2. No duplication exists today. Web-side only references /auth/passkey-external in apps/web/src/components/auth/passkeyExternal.ts:6. /auth/passkey-register-external has no web helper yet — the web route + helper are being added by a separate parallel task. Introducing a shared constant now would require touching web code and packages/lib, which both risks merge conflicts with the parallel PR and publishes a constant referencing a route that doesn't exist on this branch.
  3. Refactor-to-set already made the next addition cheap. The whole point of converting PASSKEY_EXTERNAL_PATH → ALLOWED_PASSKEY_PATHS set is so adding another handoff path is a one-line change inside this one file. Future drift risk is bounded to "desktop must keep its set in sync with whatever route web ships," which is a single grep away.
  4. Better home for centralization is after the parallel PasskeyManager task lands. At that point both consumers exist, the shared constant has a real second caller, and it can be extracted in one PR with both imports updated atomically.

Leaving the structural refactor for a follow-up once the web-side /auth/passkey-register-external route + helper are on master. No other CodeRabbit comments outstanding.

@2witstudios
2witstudios merged commit b2d7b2e into master Apr 14, 2026
5 checks passed
@2witstudios
2witstudios deleted the pu/passkey-add-desktop-allowlist branch April 14, 2026 15:51
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 — f7887b55 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