Skip to content

fix(auth): close external browser tab after desktop passkey/OAuth handoff - #1015

Merged
2witstudios merged 4 commits into
masterfrom
pu/browser-close
Apr 14, 2026
Merged

2witstudios merged 4 commits into
masterfrom
pu/browser-close

Conversation

@2witstudios

@2witstudios 2witstudios commented Apr 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Passkey external pages (/auth/passkey-external, /auth/passkey-register-external) now transition to a terminal "complete" state ~600ms after firing the pagespace:// deep link, replacing the eternal "Returning to the desktop app…" spinner. Adds shared HandoffCompleteCard and best-effort window.close().
  • Google + Apple OAuth desktop callbacks no longer 302 directly to pagespace://auth-exchange?code=… (which left the browser tab pending forever). They now return a 200 HTML bridge response built by buildHandoffBridgeHtml, which uses <meta http-equiv="refresh"> to dispatch the deep link (CSP-clean — no inline scripts), shows the same success card, and includes a <noscript> fallback anchor.
  • Per-route CSP override allows inline <style> for the bridge HTML while keeping default-src 'none'. iOS branch left untouched (Capacitor handles deep links differently).

Test plan

  • pnpm --filter web typecheck
  • pnpm --filter web exec vitest run for google/callback, apple/callback, _shared/buildHandoffBridgeHtml, and runPasskey*Ceremony — 149 tests passing
  • Manual: passkey add from desktop settings → external browser shows green-check "Passkey added" terminal state, no spinner
  • Manual: passkey sign-in from desktop → external browser shows "You're signed in" terminal state
  • Manual: Google OAuth sign-in from desktop → external browser flashes the bridge page then shows terminal state, desktop app authenticated
  • Manual: Apple OAuth sign-in from desktop → same as Google
  • Manual: cancel passkey ceremony → existing error UI still renders unchanged

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a completion screen displaying authentication status (signed in or passkey registered) with a window close option
    • Improved desktop OAuth sign-in experience with an intermediate handoff page before redirection
  • Security Improvements

    • Hardened security headers on authentication responses to prevent framing and enforce stricter content policies

…doff

External system-browser tabs sat spinning forever after a successful
passkey ceremony or OAuth sign-in: the passkey pages stayed on the
"Returning to the desktop app…" spinner, and the OAuth callbacks 302'd
straight to a pagespace:// URL the browser couldn't resolve. Both flows
now render a terminal "you can close this window" state — passkey pages
transition to a `complete` React state shortly after firing the deep
link, and OAuth callbacks return an HTML bridge that uses meta-refresh
to dispatch the deep link without inline scripts (CSP-clean).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@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 8:31pm

@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 38 minutes and 19 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 38 minutes and 19 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: f376a15b-eff3-4cc4-9dd6-c63162c9036a

📥 Commits

Reviewing files that changed from the base of the PR and between 456610f and 8e885fe.

📒 Files selected for processing (1)
  • apps/web/src/app/auth/passkey-external/page.tsx
📝 Walkthrough

Walkthrough

Introduces a new "handoff bridge" mechanism that returns HTML with meta refresh redirection instead of direct HTTP redirects for OAuth callbacks. Includes new HTML generation utility, response wrapper with security headers, updated callbacks for Apple and Google OAuth, and completion UI for passkey flows with cleanup handling for unmount scenarios.

Changes

Cohort / File(s) Summary
Handoff Bridge HTML Generation
apps/web/src/app/api/auth/_shared/buildHandoffBridgeHtml.ts, apps/web/src/app/api/auth/_shared/__tests__/buildHandoffBridgeHtml.test.ts
New module that generates HTML documents with meta refresh redirection and escapes inputs for safe embedding; comprehensive test suite validates HTML structure, security headers presence, entity-encoding of URLs, and absence of inline scripts.
Handoff Bridge Response Wrapper
apps/web/src/app/api/auth/_shared/handoffBridgeResponse.ts
New function wrapping buildHandoffBridgeHtml in a NextResponse with explicit security headers (CSP, no-store cache, frame-deny, no-sniff).
OAuth Callback Updates
apps/web/src/app/api/auth/apple/callback/route.ts, apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts, apps/web/src/app/api/auth/google/callback/route.ts, apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts
Desktop OAuth flows now use buildHandoffBridgeResponse instead of direct redirects; callback tests updated to validate handoff-bridge HTML responses and security headers instead of Location redirects.
Passkey Flow Completion UI
apps/web/src/components/auth/HandoffCompleteCard.tsx, apps/web/src/components/auth/PasskeyRegisterExternalView.tsx
New completion card component with variant-based copy (signed-in vs passkey-added); view component extended to branch on complete state and render card with close-window action.
Passkey Page Logic Updates
apps/web/src/app/auth/passkey-external/page.tsx, apps/web/src/app/auth/passkey-register-external/page.tsx
Added delayed settle timeout and unmount cleanup to prevent post-unmount state updates; completion state triggers window close as best-effort action.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Browser as Browser
    participant Handler as OAuth Handler
    participant MobileApp as Mobile App

    User->>Handler: Click "Sign in with Google/Apple"
    Handler->>Browser: 200 OK + HTML (meta refresh)
    Browser->>Browser: Execute meta refresh
    Browser->>MobileApp: Navigate to pagespace://auth-exchange?code=...&provider=...
    MobileApp->>MobileApp: Process authentication
    Handler->>Browser: Render HandoffCompleteCard
    Browser->>Browser: Show "You're signed in" + Close button
    User->>Browser: Click Close (or auto-close after timeout)
    Browser->>Browser: window.close()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Possibly related PRs

Poem

🐰 A bridge of HTML rises high,
With meta-refresh reaching for the sky,
Deep links flow through handoff's gentle way,
Completion cards sing "Have a great day!"
Close the window, settle the code—
Smoother paths for every mode!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% 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 pull request title accurately and concisely summarizes the main change: closing external browser tabs after desktop passkey/OAuth handoffs, which is the primary objective across all modified files.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/browser-close

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.

Extracts the duplicated buildHandoffBridgeResponse wrapper and
HANDOFF_BRIDGE_CSP constant from the apple and google OAuth
callback routes into _shared/handoffBridgeResponse.ts. Drops the
dead img-src data: directive (no inline <img> is rendered; the
SVG is inline, and default-src 'none' already denies images).

Adds route-level assertions that each callback emits the hardened
security header set (CSP, Cache-Control, X-Frame-Options,
X-Content-Type-Options, Referrer-Policy) so future edits to the
bridge wrapper cannot silently weaken the response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolves conflict in apps/web/src/app/auth/passkey-register-external/page.tsx
by combining both branches:

- Keeps master's PasskeyRegisterExternalView extraction with the
  ALREADY_REGISTERED / CANCELLED error-code rendering paths from
  #1013 / #1014.
- Keeps this branch's `complete` state machine, post-deep-link
  settle timer, best-effort window.close(), and HandoffCompleteCard
  terminal-state rendering.

PasskeyRegisterExternalView is extended with a `complete` kind that
renders <HandoffCompleteCard variant="passkey-added" />, so the page
component can rely on the View for all rendering while still owning
the timer-driven state transitions.

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

Copy link
Copy Markdown
Owner Author

Merged latest master into the branch and resolved a conflict in apps/web/src/app/auth/passkey-register-external/page.tsx. Combined this branch's complete-state settle timer / window.close() / HandoffCompleteCard terminal-state with master's PasskeyRegisterExternalView extraction and the ALREADY_REGISTERED / CANCELLED error-code routing from #1013 and #1014. Extended PasskeyRegisterExternalView with a complete kind that renders <HandoffCompleteCard variant="passkey-added" /> so the page component drives state transitions and the View handles all rendering. Local typecheck clean, 138 tests passing across the affected callback / passkey / shared suites.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/app/auth/passkey-external/page.tsx (1)

35-57: ⚠️ Potential issue | 🟠 Major

Handle rejected ceremony promises to avoid unhandled rejection/stuck UI.

Line 35 starts an async chain without a rejection path. If the ceremony throws/rejects, users can be left without an error state.

Proposed fix
-    void runPasskeyExternalCeremony({
-      deviceId: params.deviceId,
-      deviceName: params.deviceName,
-    }).then((result) => {
-      if (unmounted) return;
-      if (result.ok) {
-        setStatus({ kind: 'redirecting' });
-        window.location.href = result.deepLink;
-        settleTimer = setTimeout(() => {
-          if (unmounted) return;
-          setStatus({ kind: 'complete' });
-          try {
-            window.close();
-          } catch {
-            // Best-effort: many browsers refuse window.close() for tabs
-            // not opened via window.open(). The terminal UI is the fallback.
-          }
-        }, HANDOFF_SETTLE_MS);
-      } else {
-        setStatus({ kind: 'error', message: result.error });
-      }
-    });
+    void runPasskeyExternalCeremony({
+      deviceId: params.deviceId,
+      deviceName: params.deviceName,
+    })
+      .then((result) => {
+        if (unmounted) return;
+        if (result.ok) {
+          setStatus({ kind: 'redirecting' });
+          window.location.href = result.deepLink;
+          settleTimer = setTimeout(() => {
+            if (unmounted) return;
+            setStatus({ kind: 'complete' });
+            try {
+              window.close();
+            } catch {
+              // Best-effort: many browsers refuse window.close() for tabs
+              // not opened via window.open(). The terminal UI is the fallback.
+            }
+          }, HANDOFF_SETTLE_MS);
+        } else {
+          setStatus({ kind: 'error', message: result.error });
+        }
+      })
+      .catch((err: unknown) => {
+        if (unmounted) return;
+        setStatus({
+          kind: 'error',
+          message: err instanceof Error ? err.message : 'Unexpected error',
+        });
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/auth/passkey-external/page.tsx` around lines 35 - 57, The
async call to runPasskeyExternalCeremony is missing a rejection handler, which
can leave the UI stuck; update the invocation (the code calling
runPasskeyExternalCeremony with params.deviceId/deviceName) to handle promise
rejections — either by awaiting it inside an async IIFE with try/catch or by
appending .catch(...) — and in the catch setStatus({ kind: 'error', message: /*
friendly error or err.message */ }), clear any pending settleTimer, and respect
the unmounted check before updating state; ensure the existing success path
(setting redirect/complete and window.close) remains unchanged and still uses
HANDOFF_SETTLE_MS.
🧹 Nitpick comments (3)
apps/web/src/app/api/auth/_shared/handoffBridgeResponse.ts (1)

1-2: Use kebab-case filename for this shared module.

Please rename handoffBridgeResponse.ts to handoff-bridge-response.ts and update import paths accordingly.

As per coding guidelines: Use kebab-case for filenames, except React hooks (camelCase with use prefix), Zustand stores (camelCase with use prefix), and React components (PascalCase).

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

In `@apps/web/src/app/api/auth/_shared/handoffBridgeResponse.ts` around lines 1 -
2, Rename the module file from handoffBridgeResponse.ts to
handoff-bridge-response.ts and update all imports that reference
handoffBridgeResponse to the new kebab-case filename; specifically update any
files importing buildHandoffBridgeHtml or NextResponse from this module to
import from './handoff-bridge-response' (or the correct relative path), and
ensure the exported symbols (e.g., buildHandoffBridgeHtml) remain unchanged so
existing callers continue to work.
apps/web/src/app/api/auth/_shared/__tests__/buildHandoffBridgeHtml.test.ts (1)

1-2: Rename test file to kebab-case for repo consistency.

buildHandoffBridgeHtml.test.ts should follow kebab-case naming (e.g., build-handoff-bridge-html.test.ts) to match repository standards.

As per coding guidelines: Use kebab-case for filenames, except React hooks (camelCase with use prefix), Zustand stores (camelCase with use prefix), and React components (PascalCase).

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

In `@apps/web/src/app/api/auth/_shared/__tests__/buildHandoffBridgeHtml.test.ts`
around lines 1 - 2, Rename the test file from buildHandoffBridgeHtml.test.ts to
kebab-case build-handoff-bridge-html.test.ts and update any imports or
references that point to the old filename (e.g., test runner entries or other
files importing this test). Ensure the test still imports buildHandoffBridgeHtml
from ../buildHandoffBridgeHtml and keeps the existing describe/it/expect usage
intact; run the test suite to verify the renamed file is discovered by Vitest.
apps/web/src/app/api/auth/_shared/buildHandoffBridgeHtml.ts (1)

1-5: Rename this utility file to kebab-case.

buildHandoffBridgeHtml.ts should be renamed to build-handoff-bridge-html.ts to align with repo conventions.

As per coding guidelines: Use kebab-case for filenames, except React hooks (camelCase with use prefix), Zustand stores (camelCase with use prefix), and React components (PascalCase).

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

In `@apps/web/src/app/api/auth/_shared/buildHandoffBridgeHtml.ts` around lines 1 -
5, The file name uses camelCase; rename the module from
buildHandoffBridgeHtml.ts to kebab-case build-handoff-bridge-html.ts and update
all imports/exports accordingly (search for BuildHandoffBridgeHtmlParams, any
exported functions or default export in that file and update their import
paths). Ensure any CI/tsconfig references still resolve after the rename and run
a quick typecheck to catch broken imports.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/web/src/app/auth/passkey-external/page.tsx`:
- Around line 35-57: The async call to runPasskeyExternalCeremony is missing a
rejection handler, which can leave the UI stuck; update the invocation (the code
calling runPasskeyExternalCeremony with params.deviceId/deviceName) to handle
promise rejections — either by awaiting it inside an async IIFE with try/catch
or by appending .catch(...) — and in the catch setStatus({ kind: 'error',
message: /* friendly error or err.message */ }), clear any pending settleTimer,
and respect the unmounted check before updating state; ensure the existing
success path (setting redirect/complete and window.close) remains unchanged and
still uses HANDOFF_SETTLE_MS.

---

Nitpick comments:
In `@apps/web/src/app/api/auth/_shared/__tests__/buildHandoffBridgeHtml.test.ts`:
- Around line 1-2: Rename the test file from buildHandoffBridgeHtml.test.ts to
kebab-case build-handoff-bridge-html.test.ts and update any imports or
references that point to the old filename (e.g., test runner entries or other
files importing this test). Ensure the test still imports buildHandoffBridgeHtml
from ../buildHandoffBridgeHtml and keeps the existing describe/it/expect usage
intact; run the test suite to verify the renamed file is discovered by Vitest.

In `@apps/web/src/app/api/auth/_shared/buildHandoffBridgeHtml.ts`:
- Around line 1-5: The file name uses camelCase; rename the module from
buildHandoffBridgeHtml.ts to kebab-case build-handoff-bridge-html.ts and update
all imports/exports accordingly (search for BuildHandoffBridgeHtmlParams, any
exported functions or default export in that file and update their import
paths). Ensure any CI/tsconfig references still resolve after the rename and run
a quick typecheck to catch broken imports.

In `@apps/web/src/app/api/auth/_shared/handoffBridgeResponse.ts`:
- Around line 1-2: Rename the module file from handoffBridgeResponse.ts to
handoff-bridge-response.ts and update all imports that reference
handoffBridgeResponse to the new kebab-case filename; specifically update any
files importing buildHandoffBridgeHtml or NextResponse from this module to
import from './handoff-bridge-response' (or the correct relative path), and
ensure the exported symbols (e.g., buildHandoffBridgeHtml) remain unchanged so
existing callers continue to work.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 48b4149b-edc9-44d8-82d4-306aa4f8b427

📥 Commits

Reviewing files that changed from the base of the PR and between b28af9c and 456610f.

📒 Files selected for processing (11)
  • apps/web/src/app/api/auth/_shared/__tests__/buildHandoffBridgeHtml.test.ts
  • apps/web/src/app/api/auth/_shared/buildHandoffBridgeHtml.ts
  • apps/web/src/app/api/auth/_shared/handoffBridgeResponse.ts
  • apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/apple/callback/route.ts
  • apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts
  • apps/web/src/app/api/auth/google/callback/route.ts
  • apps/web/src/app/auth/passkey-external/page.tsx
  • apps/web/src/app/auth/passkey-register-external/page.tsx
  • apps/web/src/components/auth/HandoffCompleteCard.tsx
  • apps/web/src/components/auth/PasskeyRegisterExternalView.tsx

The previous invocation used void runPasskeyExternalCeremony(...).then(...)
with no rejection handler, so any thrown error inside the ceremony (network
failure, NotAllowedError, etc.) left the UI stuck on the running spinner.
Add a .catch that respects the unmounted check, clears any pending
settle timer, and surfaces the error message via setStatus, mirroring the
register-external page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@2witstudios
2witstudios merged commit 92b7248 into master Apr 14, 2026
12 checks passed
@2witstudios
2witstudios deleted the pu/browser-close branch April 14, 2026 21:23

This branch was previously deployed

1 inactive deployment
Preview — 8e885fe2 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