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:
@@ -339,7 +339,10 @@ function AuthCard() {
|
||||
verifyFired.current = true;
|
||||
api
|
||||
.verifyEmail(verifyToken)
|
||||
.then(() => setVerify("ok"))
|
||||
// "ok_reset": verified, but SEC-10 had dropped the password this account registered with (an
|
||||
// admin approved it before it was verified). Sign-in would answer with the uniform credentials
|
||||
// error, so point the user at the reset flow instead. Same value the legacy ?verify= redirect uses.
|
||||
.then((r) => setVerify(r.password_reset_required ? "ok_reset" : "ok"))
|
||||
.catch(() => setVerify("invalid"));
|
||||
}, [verifyToken]);
|
||||
|
||||
@@ -392,6 +395,13 @@ function AuthCard() {
|
||||
setMode("resend");
|
||||
};
|
||||
|
||||
// Used by the "verified, but your password was revoked" banner — the only way back into that
|
||||
// account is a reset link, so put the user one click from it.
|
||||
const goForgot = () => {
|
||||
reset();
|
||||
setMode("forgot");
|
||||
};
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
@@ -465,6 +475,14 @@ function AuthCard() {
|
||||
|
||||
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verify === "ok_reset" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyOkReset")}{" "}
|
||||
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
|
||||
{t("welcome.auth.verifyOkResetCta")}
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{verify === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.",
|
||||
"resetDone": "Your password has been set — you can sign in now.",
|
||||
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
||||
"verifyOkReset": "Email confirmed. Your account was approved before you confirmed it, so the password you registered with was cleared for security — choose a new one to sign in.",
|
||||
"verifyOkResetCta": "Set a new password",
|
||||
"verifyInvalid": "That verification link is invalid or has expired.",
|
||||
"resendCta": "Send a new link",
|
||||
"resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.",
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.",
|
||||
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
|
||||
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
|
||||
"verifyOkReset": "E-mail megerősítve. A fiókodat még a megerősítés előtt jóváhagyták, ezért biztonsági okból töröltük a regisztrációkor megadott jelszavadat — válassz újat a belépéshez.",
|
||||
"verifyOkResetCta": "Új jelszó beállítása",
|
||||
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
|
||||
"resendCta": "Új link küldése",
|
||||
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.",
|
||||
|
||||
@@ -37,7 +37,10 @@ export const authApi = {
|
||||
{ quiet: true },
|
||||
),
|
||||
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
||||
// `password_reset_required` = SEC-10 dropped the password this account registered with (an admin
|
||||
// approved it before it was verified), so signing in would fail with the uniform credentials error.
|
||||
// The card sends the user to the reset flow instead of leaving them to guess.
|
||||
verifyEmail: (token: string): Promise<{ ok: boolean; password_reset_required?: boolean }> =>
|
||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
||||
// reveals whether the email has a pending account.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -146,10 +146,16 @@ export function readPage(): Page {
|
||||
return isPage(p) ? p : "feed";
|
||||
}
|
||||
|
||||
/** Build a URL (path + query) from the CURRENT location's query with `updates` applied — a null value
|
||||
* deletes that key, any other string sets it. Lets each provider own its own nav key(s) — page / chan /
|
||||
* v / pl / the feed-filter keys — and update the address bar without clobbering the others (read-modify-
|
||||
* write on the live query). The caller decides pushState vs replaceState with the returned string. */
|
||||
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —
|
||||
* a null value deletes that key, any other string sets it. Lets each provider own its own nav key(s) —
|
||||
* page / chan / v / pl / the feed-filter keys — and update the address bar without clobbering the others
|
||||
* (read-modify-write on the live query). The caller decides pushState vs replaceState with the string.
|
||||
*
|
||||
* ⚠️ The FRAGMENT must be carried over. The one-time auth tokens ride it (`#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's this URL before the auth card's
|
||||
* chunk has even loaded. Dropping the hash here silently ate the token: the verification link opened a
|
||||
* bare sign-in page and the account could never be verified. */
|
||||
export function urlWithParams(updates: Record<string, string | null>): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
@@ -157,7 +163,7 @@ export function urlWithParams(updates: Record<string, string | null>): string {
|
||||
else params.set(k, v);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||
return `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`;
|
||||
}
|
||||
|
||||
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
||||
@@ -178,6 +184,8 @@ export function clearParams(...keys: string[]): void {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
// Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…`
|
||||
// token that has not been consumed yet must survive. Welcome's own cleanup is what clears it.
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,4 +48,14 @@ describe("useUrlMirror", () => {
|
||||
renderHook(() => useUrlMirror({ pl: "5" }));
|
||||
expect(window.location.search).toBe("?page=playlists&pl=5");
|
||||
});
|
||||
|
||||
it("leaves an unconsumed auth token in the fragment alone", () => {
|
||||
// This hook's mount effect is what ran before <App> (and therefore before the auth card) had even
|
||||
// loaded, so whatever it writes to the address bar is what the card later reads. It must not eat
|
||||
// the #verify=/#reset= token that the emailed link carries.
|
||||
window.history.replaceState(null, "", "/?page=playlists#verify=tok123");
|
||||
renderHook(() => useUrlMirror({ pl: "5" }));
|
||||
expect(window.location.hash).toBe("#verify=tok123");
|
||||
expect(window.location.search).toBe("?page=playlists&pl=5");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user