fix(auth): stop the address-bar mirror eating emailed auth tokens, and unlock revoked accounts

Two independent bugs made an email+password registration unrecoverable in prod.

The verification link did nothing. urlWithParams/clearParams rebuilt the URL as
pathname+query and dropped the fragment — but the one-time auth tokens ride the
fragment (#verify=/#reset=, SB3), and the providers that mirror nav params
(useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE the lazily-loaded
<App> in main.tsx. Their mount effect replaceState'd the token away before the auth
card's chunk had loaded, so the card read an empty hash: the link opened a bare
sign-in page, no request, no banner. Both helpers now carry the fragment over.

The account was then locked out for good. SEC-10 nulls password_hash when an
unverified account is attested (here: admin approval landing before the user could
verify — which the bug above made impossible). That is indistinguishable from a
Google-only account, and reset eligibility keyed off password_hash alone, so no
reset link was ever issued: no password to sign in with, and no way to get one.
users.password_revoked_at records the revocation, reset eligibility honours it, and
setting a password again (reset-confirm or set-password) clears it. An account that
never had a password stays ineligible on purpose — its owner adds one from Settings.

/auth/verify now also answers password_reset_required so the card can say why the
registered password stopped working, instead of leaving the user at the uniform
"Invalid email or password". Telling the holder of a valid verify token leaks
nothing: they have already proved they own the mailbox.

The 0078 migration backfills the stamp for rows carrying the exact lockout signature
(local, non-demo, no password, unverified), so existing victims recover unaided.
This commit is contained in:
2026-09-02 01:28:45 +02:00
parent 94c62ca699
commit 3ac3d382e7
11 changed files with 282 additions and 14 deletions
+13
View File
@@ -64,6 +64,19 @@ describe("urlWithParams", () => {
setLocation("?page=channels");
expect(urlWithParams({ page: null })).toBe("/app");
});
it("carries the fragment over — the one-time auth tokens ride it", () => {
// Regression (prod, 2026-09-02): the providers that mirror nav params mount ABOVE the lazy <App>,
// so this URL was replaceState'd before the auth card's chunk loaded. Dropping the hash ate the
// #verify= token and the verification link opened a bare sign-in page.
window.history.replaceState(null, "", "/app?page=plex#verify=tok123");
expect(urlWithParams({ v: "show:1" })).toBe("/app?page=plex&v=show%3A1#verify=tok123");
});
it("carries the fragment over even when the query empties out", () => {
window.history.replaceState(null, "", "/app?page=channels#reset=tok456");
expect(urlWithParams({ page: null })).toBe("/app#reset=tok456");
});
});
describe("clearParams", () => {