Skip to content

fix(auth): tell the user a reset link is dead BEFORE they choose a password (TIGER-387) - #365

Merged
WebTigers merged 1 commit into
mainfrom
fix/tiger-387-reset-dead-link
Oct 24, 2026
Merged

WebTigers merged 1 commit into
mainfrom
fix/tiger-387-reset-dead-link

Conversation

@WebTigers

Copy link
Copy Markdown
Owner

Found by the new UI probe (PR #364), verified in a real browser, fixed here.

The bug

A password-reset link that had expired, been used, or was malformed rendered a perfectly ordinary "Set a new password" screen. You chose a password, typed it twice, submitted — and only then got "This reset link is invalid or has expired."

The refusal was always correct and safe: fails closed, clear message, nothing written. The defect is that it arrived after the work instead of before it. And the people who hit it are the ones whose link already expired — already locked out, already frustrated.

The error also offered no way forward: "Back to sign in", but no way to request a new link.

The fix

The token is checked on GET. A dead one gets a card that says the link expired and offers a one-click Request a new link → /auth/forgot.

Tiger_Service_Authentication::resetTokenIsLive() is a read-only peek:

  • It never consumes the challenge and never costs an attempt, so it's safe at render time. There's an integration test asserting exactly that — peek twice, then redeem successfully.
  • It checks the challenge only, not the code. A code can't be verified without spending an attempt, and the challenge id is an opaque v4 UUID the link holder already has — so answering leaks nothing the link itself didn't.
  • resetPassword() still re-checks everything at submit. A token can expire between render and submit, so the submit-time gate stays as defence in depth.

Verified on tiger-dev, through the browser

Token Result
bogus / empty dead-link card
live form renders, reset succeeds, signs in at ?pwreset=1
consumed dead-link card

The middle row is the one that matters most — the guard must not break real reset links. The third is the realistic case: an old link still sitting in someone's inbox.

Tests

Two integration tests in AuthenticationTest:

  • a peek doesn't burn the token (peek ×2, then redeem succeeds)
  • unknown / consumed / expired / wrong-type challenges all read as not-live — a email_login challenge must never open the reset screen

Mutation-checked: stubbing resetTokenIsLive() to always return true fails them. Full unit suite green (1195 tests); the reset integration tests pass alongside the three pre-existing ones.

i18n

Three new keys, added to all six shipped locales (en/es/pt/fr/de/hi). This isn't optional here — Zend_Translate is configured with disableNotices and no fallback locale, so a missing key renders the raw key as page text on a non-English page. tlh is left alone, consistent with its existing tracked gap.

Scope

core/controllers/AuthController.php, core/views/scripts/auth/reset.phtml, one new @api service method, six language files, one test file. No schema change, no migration, no change to the submit path.

🤖 Generated with Claude Code

https://claude.ai/code/session_013vdyTeoDrGE4hKxt53TsE8

…ssword (TIGER-387)

A password-reset link that had expired, been used, or was malformed rendered a
perfectly ordinary "Set a new password" screen. The user chose a password, typed
it twice, submitted — and only then learned the link was dead. The refusal was
always correct and safe; it just arrived after the work instead of before it.
The people who hit that are the ones already locked out.

The token is now checked on GET, and a dead one gets a card that says so and
offers a one-click "Request a new link" straight to /auth/forgot.

Tiger_Service_Authentication::resetTokenIsLive() is a READ-ONLY peek: it never
consumes the challenge and never costs an attempt, so it is safe at render time.
It checks the challenge only, not the code — a code cannot be verified without
spending an attempt, and the challenge id is an opaque v4 UUID the link holder
already has, so answering leaks nothing the link did not. resetPassword() still
re-checks everything at submit, because a token can expire in between.

Verified on tiger-dev through the browser, all three branches:
  bogus/empty token -> dead-link card
  live token        -> form renders, reset succeeds, signs in at ?pwreset=1
  consumed token    -> dead-link card  (the realistic case: an old link in the inbox)

Tests: two integration tests, including that a peek does not burn the token and
that unknown / consumed / expired / wrong-type challenges all read as not-live.
Mutation-checked — an always-live stub fails them.

i18n: the three new keys are in all six shipped locales. A missing key would
render the raw key on a non-English page, since Zend_Translate has no fallback
locale configured here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013vdyTeoDrGE4hKxt53TsE8
@WebTigers
WebTigers merged commit 06979cb into main Oct 24, 2026
14 checks passed
@WebTigers
WebTigers deleted the fix/tiger-387-reset-dead-link branch October 24, 2026 17:37
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