feat(auth): redeem magic links in the installed PWA (iOS handoff)#220
Conversation
- 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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughRefactors 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. ChangesPWA Magic-Link Handoff Authentication
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
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
packages/api/src/auth.tspackages/api/src/index.tspackages/web/composables/useAuth.tspackages/web/nuxt.config.tspackages/web/pages/auth/verify.vuepackages/web/utils/strings.ts
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 2 file(s) based on 3 unresolved review comments. Files modified:
Commit: The changes have been pushed to the Time taken: |
Fixed 2 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/api/src/auth.ts (1)
489-507: ⚖️ Poor tradeoffInconsistent token storage: PWA handoff
codeshould be hashed like magic links and refresh tokensLook at how
magic_link_tokensandrefresh_tokenswork in this same file — the user gets the raw token, but the database only stores the SHA-256 hash (seehashTokenon 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
codestraight intopwa_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 withawait hashToken(code)and hash the incoming code on read inhandleRedeemPwaHandoffbefore 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
📒 Files selected for processing (2)
packages/api/src/auth.tspackages/web/utils/strings.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/web/utils/strings.ts
|
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. |
|
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>
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
packages/api/migrations/0018_pwa_handoffs.sqlpackages/api/src/auth.ts
Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
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-handoffissues 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-existingin the web manifest.API
POST /api/auth/magic-pwa-handoff— body{ token }; same consume rules asGET /api/auth/verify(including 2FA pending). Full-session path stores{ userId }inRATE_LIMIT_KVunderauth: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 →navigateTowithhandoffquery; 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.useAuth—magicPwaHandoff,redeemPwaHandoff, exportedMagicPwaHandoffResult.utils/strings.ts— copy for the handoff screen.nuxt.config.ts— manifestlaunch_handler.Files
packages/api/src/auth.ts— sharedconsumeMagicLinkForUser,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 typecheckin@vmp/api(pass)npx nuxi preparein@vmp/web(pass)Manual verification on a real iPhone (Mail → Safari → Home Screen app) is recommended.
Summary by CodeRabbit
New Features
Refactor
Chores