feat(auth): include email in password-reset URL for password-manager pairing#92
Conversation
…pairing Mobile clients (iCloud Keychain on iOS, Google Password Manager on Android) can only update an existing saved credential when they see the account email alongside the new password. Without the email in the reset URL, the app must save a new entry, producing duplicate password-manager records for the same Divine account. Adds an `&email=<url-encoded>` param to the reset URL emitted by both the dev console logger and the SendGrid/SMTP provider. Token remains the sole authenticator for POST /api/auth/reset-password; the email is purely a hint for client-side autofill pairing. Refs divinevideo/divine-mobile#3156 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Paired-PR review from the mobile side (divinevideo/divine-mobile#3156). Contract verification, plus a few non-blocking quality notes. Contract match with mobile: ✅Traced the full round-trip for a tricky input
Matches exactly what the mobile widget test asserts. No compatibility gap. Non-blocking quality notes
One question
If there's an admin-triggered password reset that also surfaces a URL to the end-user's email, it has the same duplicate-in-Keychain problem this PR fixes. If the flow exists, worth the same 1-line treatment; if not, maybe note in the PR body so future readers don't have to re-check. Security framingThe "PII-in-URL trade vs UX cost of duplicate Keychain entries" framing in the PR body is the right call. Reset tokens are short-lived and self-authenticating. Mobile side ships with a graceful fallback (null-guard on UnblockCI is green; |
|
Paired-PR update from the mobile side so the deploy decision isn't blocked on info. Mobile side is now pinned to this contractTwo regression tests on divinevideo/divine-mobile#3243 assert the exact byte shape this PR must produce, and they share a single
Expanded contract verificationExtends my Apr 22 round-trip check beyond
Contract holds across the full input class. Deploy sequencing — both orders are safeMobile has a null-guard on absent
No gating either way — whichever is ready first can ship; the full benefit lands when both are live. Remaining gap (unchanged from Apr 22)The only open items are the two asks from my prior review:
~8 lines total. Pins the backend side of the contract so a future encoder swap or lost |
NotThatKindOfDrLiz
left a comment
There was a problem hiding this comment.
Reviewed the actual diff and the paired mobile discussion. I do not see a blocker in this change itself: it is narrowly scoped to adding a URL-encoded email hint to the emitted password-reset link, while the reset token remains the sole authenticator on the backend. The mobile-side contract review also makes the deploy sequencing risk clear and acceptable.
Two non-blocking follow-ups are now tracked:
- #138 centralize and test password reset URL construction
- #139 make auth/reset page referrer policy explicit for URL-carried email hints
Approving.
…3243) * fix(auth): plumb email into reset-password route for Keychain update ResetPasswordScreen had no AutofillHints.username field co-located with the new-password field inside its AutofillGroup, so iOS Keychain / Google Password Manager filed reset-saves as new credential entries instead of updating the existing one — producing duplicate password-manager entries per account that users then autofilled, failed login, and contacted support about. Plumbs an optional email through the reset deep link: login.divine.video/reset-password?token=T&email=<url-encoded> - PasswordResetListener extracts the email param and forwards it to the internal route; the log line now emits only uri.path, not the full URI (prevents a new PII leak once the URL carries email). - Router passes email from query params into ResetPasswordScreen. - ResetPasswordScreen accepts an optional email; when present, renders a read-only DivineAuthTextField with AutofillHints.username above the new-password field inside the existing AutofillGroup. Graceful fallback when email is absent (old reset emails still in users' inboxes). - Wraps content in Expanded + SingleChildScrollView so the extra field doesn't overflow on small viewports. Paired with divinevideo/keycast#92 which emits the email in the URL. Client ships safely without backend thanks to the null-guard fallback; end-to-end verification (Keychain update vs duplicate) requires both deployed. Device test guide: tasks/pr3156_device_test_guide.md. Closes #3156 * fix(auth): preserve email through top-level reset-password redirect The top-level /reset-password GoRoute redirect rewrote the deep link to the nested /welcome/login-options/reset-password path but forwarded only the token — dropping the email query param. Native deep links via PasswordResetListener already forward email, but that listener is native-only (app_links), so on the Flutter web build at login.divine.video the redirect was the only entry point and the AutofillHints.username field never rendered there. Web users still got duplicate password- manager entries, which is the exact bug #3156 is meant to fix. Mirror the StringBuffer + Uri.encodeQueryComponent logic from PasswordResetListener so both entry points produce identical output for identical input. Backward-compat preserved when email is absent (old reset emails still in users' inboxes). Drive-by: `?? ''` on token avoids the prior `?token=null` string interpolation when the query param is missing. Adds a pure-function regression test at mobile/test/router/reset_password_redirect_test.dart following the pattern in login_flow_redirect_test.dart. Covers: token+email preserved, email absent (backward compat), email empty string, special-char round-trip, and missing-token safety. * refactor(router): share reset-password redirect logic with its test The router-level test was a line-for-line mirror of the inline redirect callback — if one drifted, the other could still pass silently. Extract the rewrite as a @VisibleForTesting top-level function and have both the GoRoute and the test call it directly, so there is exactly one source of truth. Behavior unchanged; all 99 router tests green.
Summary
&email=<url-encoded>to the password-reset URL emitted in both the dev logger and SendGrid/SMTP email paths (api/src/email_service.rs).POST /api/auth/reset-password; the email is only a hint so mobile password managers can pair the new password with the existing saved credential instead of saving a duplicate entry.Why
iCloud Keychain (iOS) and Google Password Manager (Android) will only update an existing saved credential when they see the same account identifier alongside the new password. Without the email on the reset URL, the mobile app (
divine-mobile) has to create a new credential — producing duplicate entries for the same Divine account. Users then hit autofill with the stale entry, fail to sign in, and contact support.Cross-repo dependency: divinevideo/divine-mobile#3156 — client-side fix is ready to ship once this lands.
Security note
Reset tokens are short-lived and are still the only thing that authorizes the reset. Putting the account email in the URL during that window is a conscious trade against the UX cost of duplicate password-manager entries. If there's a standing policy against any PII in URLs for reset flows, flag it and we'll switch to Option B in issue #3156 (a
GET /api/auth/reset-password/inspect?token=XYZendpoint that returns{email}without state change).What is unchanged
POST /api/auth/reset-passwordstill validates the token exactly as before./reset-password).Test plan
cargo build --libon theapicrate passes.cargo clippy --libis clean.api::http::auth::tests::test_*_path_componentsare pre-existing — they fail onmasterwithout a local Postgres (VersionMissing(9)duringsqlx::migrate!) and are unrelated to this change.&email=<encoded>with+,@,.correctly escaped.🤖 Generated with Claude Code