domain: expired/invalid password-reset token surfaces as 500 Unknown instead of link-expired #54
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
rosa/vernier#54
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found during a code review of the
domaincrate.Location:
crates/domain/src/services.rs:380(reset_password)Severity: Correctness / UX
Problem
reset_passwordwrapsConfirmation::verify_nouserfailures in.with_context(...)?, convertingConfirmationError::Expiredand verifier-mismatch intoResetPasswordError::Unknownvia#[from] anyhow::Error. The dedicatedConfirmationExpired/ConfirmationVerifierMismatchvariants are never constructed, and the web layer rendersUnknownas a 500.Failure scenario
A user clicks a reset link more than 30 minutes after requesting it and gets a generic 500 page instead of a 400 link-expired message, with no way to distinguish an expired link from a real fault.
Suggested fix
Map
verify_nousererrors to the existing dedicated variants instead of funnelling through anyhow context, and render them as 4xx.Verified during triage by code inspection: the reset flow wraps
Confirmation::verify_nouserfailures in an anyhow context, which funnels every verification failure intoResetPasswordError::Unknown. The dedicatedConfirmationExpiredandConfirmationVerifierMismatchvariants are constructed nowhere in the codebase, while the web error mapping already routes them to the invalid-confirmation (4xx) response — the plumbing exists on both ends and has simply never been connected.Agent Brief
Category: bug
Summary: Map password-reset token verification failures to their dedicated error variants so users see a link-expired message instead of a 500
Current behavior:
reset_passwordcallsConfirmation::verify_nouserand wraps any failure in an anyhow context, convertingConfirmationError::Expiredand the verifier-mismatch case intoResetPasswordError::Unknown. The web layer rendersUnknownas an internal error (500). The dedicatedResetPasswordError::ConfirmationExpiredandResetPasswordError::ConfirmationVerifierMismatchvariants exist and the web layer already maps them to the invalid-confirmation response, but nothing ever constructs them. A user clicking a reset link after the 30-minute window gets a generic 500 page.Desired behavior:
Verification failures map to their specific variants: an expired confirmation becomes
ConfirmationExpired, a verifier mismatch becomesConfirmationVerifierMismatch, and each renders through the existing invalid-confirmation (4xx) path with its user-facing message. Only genuine faults (repository errors, hashing failures) remainUnknown/500. No web-layer mapping changes should be needed — theFrom<ResetPasswordError> for AppErrorarms already exist.Key interfaces:
ConfirmationError→ResetPasswordErrorconversion inreset_password— replace the anyhow-context funnel with a per-variant mapping (aFromimpl or explicitmap_err).get_confirmation_by_token(used to render the reset form) for consistency: the form-rendering path should treat an expired/mismatched token the same way the submission path does, so the user doesn't see a form they can't submit.Acceptance criteria:
ResetPasswordError::ConfirmationExpiredand renders as the invalid-confirmation response (4xx), not a 500.ResetPasswordError::ConfirmationVerifierMismatchand renders as 4xx.ConfirmationErrorvariant reachable fromverify_nouser.Unknown(500).cimise task passes.Out of scope:
confirm_email_address) — it returnsConfirmationErrordirectly and is a separate surface.