Skip to content

feat(auth): redeem magic links in the installed PWA (iOS handoff)#220

Merged
kubo6472 merged 5 commits into
mainfrom
cursor/pwa-magic-link-handoff-4309
May 11, 2026
Merged

feat(auth): redeem magic links in the installed PWA (iOS handoff)#220
kubo6472 merged 5 commits into
mainfrom
cursor/pwa-magic-link-handoff-4309

Conversation

@kubo6472
Copy link
Copy Markdown
Member

@kubo6472 kubo6472 commented May 9, 2026

Summary

iOS does not share cookies or storage between Mobile Safari and a Home Screen web app, so a magic link opened from Mail often logged the user in only in Safari. This change adds a server-backed handoff: on iPhone/iPad outside standalone mode, the raw email token is exchanged for a short-lived code stored in KV; the SPA replaces the URL with /auth/verify?handoff=… so the user can open the same URL in the installed app (or finish in Safari). POST /api/auth/redeem-pwa-handoff issues the normal refresh cookie + JWT in whichever context runs it.

Chromium PWAs can also reuse an existing client for navigations via launch_handler.client_mode: navigate-existing in the web manifest.

API

  • POST /api/auth/magic-pwa-handoff — body { token }; same consume rules as GET /api/auth/verify (including 2FA pending). Full-session path stores { userId } in RATE_LIMIT_KV under auth:pwa-handoff:${code} (10 min TTL) and returns { handoffCode, totpRequired }. If KV is missing, falls back to immediate session (same as GET verify).
  • POST /api/auth/redeem-pwa-handoff — body { code }; one-time redeem → session response + Set-Cookie.

Web

  • pages/auth/verify.vue — iOS non-standalone: POST handoff → navigateTo with handoff query; defer redeem in Safari with copy + “Continue in Safari”; auto-redeem in standalone (display-mode: standalone / navigator.standalone). watch(route.fullPath) so query-only navigations still run the flow.
  • useAuthmagicPwaHandoff, redeemPwaHandoff, exported MagicPwaHandoffResult.
  • utils/strings.ts — copy for the handoff screen.
  • nuxt.config.ts — manifest launch_handler.

Files

  • packages/api/src/auth.ts — shared consumeMagicLinkForUser, issueFullMagicSessionResponse, new handlers.
  • packages/api/src/index.ts — route wiring.
  • packages/web/composables/useAuth.ts, pages/auth/verify.vue, utils/strings.ts, nuxt.config.ts.

Testing

  • npm run typecheck in @vmp/api (pass)
  • npx nuxi prepare in @vmp/web (pass)

Manual verification on a real iPhone (Mail → Safari → Home Screen app) is recommended.

Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • PWA magic-link handoff: generate and redeem one-time handoff codes to enable PWA/iOS Safari sign-in flows.
    • Client support: composable methods to initiate/redeem handoffs, store sessions, and drive verify UI flows.
    • PWA manifest updated to favor routing into installed PWAs.
    • Verify page UI: handoff wait state, copy-to-clipboard, and updated messaging.
  • Refactor

    • Centralized magic-link verification and unified session issuance.
  • Chores

    • Database migration: persistent handoff storage table added.

Review Change Stack

- Share consumeMagicLinkForUser between GET verify and new POST magic-pwa-handoff
- Store one-time handoff codes in RATE_LIMIT_KV; redeem issues refresh cookie + JWT
- iOS non-standalone: exchange email token for handoff URL, defer redeem until Safari or PWA
- Manifest launch_handler navigate-existing for Chromium installed apps
- Centralize verify page strings

Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7a96f66e-a1e1-4027-8591-892d90eb190f

📥 Commits

Reviewing files that changed from the base of the PR and between 58fa93f and 76fcba7.

📒 Files selected for processing (1)
  • packages/api/src/auth.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/api/src/auth.ts

📝 Walkthrough

Walkthrough

Refactors magic-link consumption into a reusable helper, adds D1-backed PWA handoff create/redeem endpoints and router wiring, extends the frontend useAuth composable and verify.vue to support handoff flows, updates PWA manifest and localized strings, and adds a migration for pwa_handoffs.

Changes

PWA Magic-Link Handoff Authentication

Layer / File(s) Summary
DB Migration
packages/api/migrations/0018_pwa_handoffs.sql
Creates pwa_handoffs table and index for one-time handoff codes with expiry and used_at tracking.
Auth Constants & Consume Helper
packages/api/src/auth.ts
Adds PWA_MAGIC_HANDOFF_TTL_SEC, MagicLinkConsumeResult, and consumeMagicLinkForUser to hash/validate and atomically consume magic-link tokens, returning invalid / totp_pending / session_ready.
Session Issuance Helper & Verify Refactor
packages/api/src/auth.ts
Adds issueFullMagicSessionResponse and updates handleVerifyMagicLink to use the consume helper and centralized session issuance.
Backend PWA Handoff Endpoints
packages/api/src/auth.ts
Adds handleMagicPwaHandoff (creates hashed D1 handoff record or falls back to full session) and handleRedeemPwaHandoff (atomically marks handoff used, loads user, issues session).
API Router Wiring
packages/api/src/index.ts
Imports PWA handoff handlers and wires POST routes /api/auth/magic-pwa-handoff and /api/auth/redeem-pwa-handoff.
Frontend Type Definitions
packages/web/composables/useAuth.ts
Adds MagicPwaHandoffResult discriminated union describing requiresTwoFactor, handoffCode, or full-session results.
Frontend Auth Composable Methods
packages/web/composables/useAuth.ts
Implements magicPwaHandoff(token) and redeemPwaHandoff(code) which call the new endpoints, parse responses, and store access token & user on full-session results; both exported from useAuth().
PWA Manifest & Strings
packages/web/nuxt.config.ts, packages/web/utils/strings.ts
Adds pwa.manifest.launch_handler.client_mode = 'navigate-existing' and new localized strings for signing-in, handoff title/body, continue-in-Safari, and copy-link messages.
Verification Page Structure & Main Flow
packages/web/pages/auth/verify.vue
Adds handoff_wait UI state, device-detection helpers, query-normalization, copy/finish helpers, and replaces onMounted with a watcher that handles token verification, iOS handoff creation/redemption, TOTP flows, and handoff deferral.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Safari
  participant PWA
  participant API
  participant D1
  participant DB

  User->>Safari: Click magic-link (token in URL)
  Safari->>API: POST /api/auth/magic-pwa-handoff { token }
  API->>API: consumeMagicLinkForUser(token)
  API->>DB: verify/load magic_link_tokens + user
  DB-->>API: invalid / totp_pending / session_ready
  alt totp_pending
    API-->>Safari: { requiresTwoFactor, pendingToken }
  else session_ready & D1 insert OK
    API->>D1: INSERT pwa_handoffs (hashed_code, user_id, expires_at)
    API-->>Safari: { ok: true, handoffCode, totpRequired }
    User->>PWA: open installed PWA and provide code
    PWA->>API: POST /api/auth/redeem-pwa-handoff { code }
    API->>D1: UPDATE pwa_handoffs SET used_at=now() ... RETURNING user_id
    API->>DB: load user by user_id
    API-->>PWA: { accessToken, user } + Set-Cookie refresh
  else session_ready & D1 insert fails
    API->>DB: issueFullMagicSessionResponse(user)
    API-->>Safari: { accessToken, user } + Set-Cookie refresh
  end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • tojemoc/vmp#59: Modifies the same magic-link flow and TOTP pending-token / totp_challenges logic.
  • tojemoc/vmp#36: Touches magic-link authentication and useAuth/verify.vue routing logic.

Poem

✨ A link, a code, a tiny handoff key,
From Safari to PWA — one-time, you see.
Tokens checked, then used, then closed the gate,
Sessions wake the app; no more waiting fate.
Handoff done — sign in, celebrate!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature: enabling magic link redemption in the installed PWA on iOS via a handoff mechanism.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ 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 cursor/pwa-magic-link-handoff-4309

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.

Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
@kubo6472 kubo6472 marked this pull request as ready for review May 9, 2026 22:01
@kubo6472
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@kubo6472
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot added the codex label May 10, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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 `@packages/api/src/auth.ts`:
- Around line 520-552: The current redeem flow uses kv.get() then kv.delete(),
which is raceable and irreversibly consumes the code before session issuance;
replace this with an atomic consume in a storage that supports conditional
updates (e.g., D1 or a Durable Object). Specifically, remove the
kv.get/kv.delete pair around PWA_MAGIC_HANDOFF_KV_PREFIX + code and instead use
a D1 "handoffs" table (columns: code, payload, used_at) and perform a single
conditional UPDATE or INSERT+UPDATE such as UPDATE handoffs SET used_at =
CURRENT_TIMESTAMP WHERE code = ? AND used_at IS NULL RETURNING payload (or use a
transaction/select-for-update in a Durable Object); if no row is returned,
return the same 401 error, otherwise parse the returned payload and continue to
lookup the user and call issueFullMagicSessionResponse(user, env, db,
corsHeaders). Ensure you only mark the handoff consumed atomically at redeem
time so concurrent requests cannot both succeed and so failures in issuing the
session can be handled (e.g., by not marking used until after successful session
creation or by storing a recovery state) according to your chosen storage
semantics.
- Around line 483-500: The code consumes the magic link via
consumeMagicLinkForUser then attempts kv.put and gives up if the KV write fails,
losing the consumed link; change the flow so that if env.RATE_LIMIT_KV is
present but kv.put throws or rejects, you catch that error and fall back to the
same path used when KV is absent: call getDb(env) and return await
issueFullMagicSessionResponse(phase.user, env, db, corsHeaders). Specifically,
wrap the kv.put(...) and subsequent key creation (generateToken,
PWA_MAGIC_HANDOFF_KV_PREFIX usage) in a try/catch and in the catch block perform
the getDb + issueFullMagicSessionResponse fallback so the user still gets a
session when KV is temporarily unavailable.

In `@packages/web/utils/strings.ts`:
- Around line 39-40: The copy in authVerifyHandoffBody promises the page will
"complete automatically", which is inaccurate; update the string in the
authVerifyHandoffBody constant to remove that promise and replace with neutral
wording such as "the app will attempt to finish signing in" or "you can finish
signing in in the installed app; return to Safari if it doesn't complete", so
the message no longer implies automatic completion and avoids confusing expired
handoffs.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 14a4e9d8-95fc-4623-b14d-e607a51cffe5

📥 Commits

Reviewing files that changed from the base of the PR and between 302167b and 858e0df.

📒 Files selected for processing (6)
  • packages/api/src/auth.ts
  • packages/api/src/index.ts
  • packages/web/composables/useAuth.ts
  • packages/web/nuxt.config.ts
  • packages/web/pages/auth/verify.vue
  • packages/web/utils/strings.ts

Comment thread packages/api/src/auth.ts Outdated
Comment thread packages/api/src/auth.ts Outdated
Comment thread packages/web/utils/strings.ts Outdated
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 2 file(s) based on 3 unresolved review comments.

Files modified:

  • packages/api/src/auth.ts
  • packages/web/utils/strings.ts

Commit: f135569d9efccf656c4a7d7bef6e73371ce8d156

The changes have been pushed to the cursor/pwa-magic-link-handoff-4309 branch.

Time taken: 4m 56s

Fixed 2 file(s) based on 3 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Comment thread packages/api/src/auth.ts Fixed
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 (1)
packages/api/src/auth.ts (1)

489-507: ⚖️ Poor tradeoff

Inconsistent token storage: PWA handoff code should be hashed like magic links and refresh tokens

Look at how magic_link_tokens and refresh_tokens work in this same file — the user gets the raw token, but the database only stores the SHA-256 hash (see hashToken on line 157, used on lines 194 and 246). That way, if someone dumps the database during its 10-minute lifetime, the stolen rows can't be replayed.

But here you're storing the raw code straight into pwa_handoffs.code. The blast radius is small (10-minute TTL, single-use), but it breaks the module's established pattern. To match the rest of the codebase, hash the code on write with await hashToken(code) and hash the incoming code on read in handleRedeemPwaHandoff before the atomic UPDATE.

🤖 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 `@packages/api/src/auth.ts` around lines 489 - 507, The PWA handoff code is
currently stored raw in the pwa_handoffs table; update the insert in the
function that calls generateToken() to store await hashToken(code) instead of
the plain code (keep returning the raw code to the client), and update the
corresponding redeem path in handleRedeemPwaHandoff to hash the incoming code
with await hashToken(code) before running the atomic UPDATE/SELECT so the DB
compare uses the hashed value (mirror how magic_link_tokens and refresh_tokens
use hashToken for storage and verification).
🤖 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 `@packages/api/src/auth.ts`:
- Around line 495-498: Add a new DB migration that creates the pwa_handoffs
table referenced by the INSERT (code, user_id, expires_at) to avoid runtime
errors: define columns code PRIMARY KEY (string), user_id (foreign key to
users.id), expires_at DATETIME, used_at DATETIME DEFAULT NULL, and add an index
on code for fast lookups; ensure the migration also enforces the foreign key
constraint to users and is included in the migration run so the INSERT in
auth.ts (the pwa_handoffs INSERT statement) succeeds.

---

Nitpick comments:
In `@packages/api/src/auth.ts`:
- Around line 489-507: The PWA handoff code is currently stored raw in the
pwa_handoffs table; update the insert in the function that calls generateToken()
to store await hashToken(code) instead of the plain code (keep returning the raw
code to the client), and update the corresponding redeem path in
handleRedeemPwaHandoff to hash the incoming code with await hashToken(code)
before running the atomic UPDATE/SELECT so the DB compare uses the hashed value
(mirror how magic_link_tokens and refresh_tokens use hashToken for storage and
verification).
🪄 Autofix (Beta)

❌ Autofix failed (check again to retry)

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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c6f2dfbc-0b15-4590-bc35-fae5dc28a2d4

📥 Commits

Reviewing files that changed from the base of the PR and between 858e0df and f135569.

📒 Files selected for processing (2)
  • packages/api/src/auth.ts
  • packages/web/utils/strings.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/utils/strings.ts

Comment thread packages/api/src/auth.ts
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

The agent ran but didn't make any changes. The issues may already be fixed or require manual intervention.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

The agent ran but didn't make any changes. The issues may already be fixed or require manual intervention.

Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@packages/api/src/auth.ts`:
- Around line 491-497: The stored expires_at is saved as an ISO string but later
compared to SQLite's datetime('now') which uses a different format; update the
SQL comparisons that check pwa_handoffs.expires_at (the db.prepare calls that
validate/redeem handoff codes around the INSERT and the lookup logic) to wrap
the stored value with datetime(), e.g. use datetime(expires_at) >
datetime('now') or datetime(expires_at) <= datetime('now') as appropriate so
both sides use the same format; keep inserting the ISO string (the INSERT into
pwa_handoffs (code, user_id, expires_at) VALUES ...) but change the SELECT/WHERE
clauses that check expiry to call datetime(expires_at).
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b2c5f41f-7ef2-4731-9eab-3d3cf00a0ba2

📥 Commits

Reviewing files that changed from the base of the PR and between f135569 and 58fa93f.

📒 Files selected for processing (2)
  • packages/api/migrations/0018_pwa_handoffs.sql
  • packages/api/src/auth.ts

Comment thread packages/api/src/auth.ts
Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
@kubo6472
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@kubo6472 kubo6472 merged commit 8bcdee9 into main May 11, 2026
6 checks passed
@kubo6472 kubo6472 deleted the cursor/pwa-magic-link-handoff-4309 branch May 11, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants