From 5b59f86365f8bb64a35148ca1cfc384878e7bd7c Mon Sep 17 00:00:00 2001 From: Jonah Cohen Date: Mon, 20 Jul 2026 14:56:11 -0500 Subject: [PATCH] Fix reset-token/session interaction: sign-out resurrects newPassword, and an active session skips it entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two symptoms traced to one root cause in the auth React layer: 1. After a successful password reset, logging out bounces back to "create a new password" instead of the sign-in screen. 2. Clicking a fresh reset link while already signed in (e.g. from a previous reset, which signs you in on the new password) silently auto-logs in instead of prompting for a new password. Root cause: useSignInForm read `?bool_reset_token=` from the URL on mount and forced mode="newPassword", but never removed it from the URL — and it only ran inside AuthGate's `fallback` branch, which AuthGate picks purely on session presence, with zero awareness of the token. - (1): the token stayed in the URL forever. Signing out clears the session -> AuthGate re-renders `fallback` -> useSignInForm remounts -> its effect re-reads the still-present token -> forces newPassword again. - (2): AuthGate sees `user` truthy and renders `children` immediately; useSignInForm (and its token-reading effect) never mounts, so the token is silently ignored. Fix: lift the reset-token capture into BoolAuthProvider (an ancestor of both AuthGate and useSignInForm) as `pendingResetToken` on context, consumed and stripped from the URL exactly once via a pure, unit-tested helper (`takeResetTokenFromSearch`). AuthGate now forces `fallback` when a reset token is pending, even over an active session — a reset link is an explicit ask to set a new password, so it must always reach that screen. useSignInForm reads the same context value instead of independently reading the URL, and clears it on successful confirmReset or when the visitor backs out of newPassword mode (so AuthGate stops forcing it). react.tsx is unchanged since the initial 0.1.0 release, so this affects every already-created app on the stable ^0.1.0 range, not just the 0.2.0-next canary line. Co-Authored-By: Claude Opus 4.8 --- src/react.test.tsx | 40 +++++++++++++++++++- src/react.tsx | 91 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/src/react.test.tsx b/src/react.test.tsx index 309ae48..9973bec 100644 --- a/src/react.test.tsx +++ b/src/react.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { renderToString } from "react-dom/server"; import { createBoolClient } from "./client"; -import { AuthGate, BoolAuthProvider, useBoolAuth } from "./react"; +import { AuthGate, BoolAuthProvider, takeResetTokenFromSearch, useBoolAuth } from "./react"; // SSR smoke tests: effects don't run in renderToString, so the provider is in // its initial loading state — enough to pin the gate/hook contract without a @@ -59,3 +59,41 @@ describe("useBoolAuth", () => { ); }); }); + +// Regression coverage for the reported bug: a reset link left the +// `bool_reset_token` param in the URL forever (so signing out landed back on +// the "set a new password" screen), and AuthGate never even looked at the +// token when a session already existed (so a fresh reset link silently +// auto-signed the visitor in instead of prompting for a new password). The +// fix hinges on this pure extraction being correct — the DOM-touching glue in +// BoolAuthProvider (read once, replaceState) isn't exercisable without a +// browser, but this pins the string logic it depends on. +describe("takeResetTokenFromSearch", () => { + test("extracts the token and clears it from an otherwise-empty search", () => { + expect(takeResetTokenFromSearch("?bool_reset_token=abc123")).toEqual({ + token: "abc123", + rest: "", + }); + }); + + test("preserves sibling params, order aside", () => { + const { token, rest } = takeResetTokenFromSearch( + "?utm_source=email&bool_reset_token=abc123&ref=x", + ); + expect(token).toBe("abc123"); + expect(new URLSearchParams(rest).get("bool_reset_token")).toBeNull(); + expect(new URLSearchParams(rest).get("utm_source")).toBe("email"); + expect(new URLSearchParams(rest).get("ref")).toBe("x"); + }); + + test("no token present — rest is unchanged, token is null", () => { + expect(takeResetTokenFromSearch("?foo=bar")).toEqual({ + token: null, + rest: "?foo=bar", + }); + }); + + test("empty search — no token, no rest", () => { + expect(takeResetTokenFromSearch("")).toEqual({ token: null, rest: "" }); + }); +}); diff --git a/src/react.tsx b/src/react.tsx index 1106259..6bbd2bc 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -29,10 +29,35 @@ export type BoolAuthState = { signOut: () => Promise; resetPassword: (email: string) => Promise; confirmReset: (token: string, password: string) => Promise; + /** A `?bool_reset_token=…` was found in the URL on load and hasn't been + * consumed yet. Internal plumbing consumed by AuthGate (to force the reset + * screen even over an existing session) and useSignInForm (to drive the + * newPassword step) — app code doesn't need to read this directly. */ + pendingResetToken: string | null; + /** Drop the pending reset token (after a successful confirmReset, or when + * the visitor backs out of the reset flow) so AuthGate and useSignInForm + * fall back to deciding purely on session state. */ + clearPendingReset: () => void; }; const BoolAuthContext = createContext(null); +/** Extract `bool_reset_token` from a `location.search` string, returning the + * token (or null if absent) and the search string with just that param + * removed — every other param is preserved. Pure string logic so it's + * unit-testable without a DOM; the caller applies `rest` via + * history.replaceState. */ +export function takeResetTokenFromSearch( + search: string, +): { token: string | null; rest: string } { + const params = new URLSearchParams(search); + const token = params.get("bool_reset_token"); + if (token === null) return { token: null, rest: search }; + params.delete("bool_reset_token"); + const rest = params.toString(); + return { token, rest: rest ? `?${rest}` : "" }; +} + export function BoolAuthProvider({ children, client, @@ -45,6 +70,7 @@ export function BoolAuthProvider({ const bool = client ?? getDefaultBoolClient(); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [pendingResetToken, setPendingResetToken] = useState(null); useEffect(() => { // Fires once with the current session (or null), then on every sign in/out. @@ -57,9 +83,30 @@ export function BoolAuthProvider({ return () => data.subscription.unsubscribe(); }, [bool]); + // A reset email links back here with ?bool_reset_token=… — capture it into + // state ONCE and strip it from the URL immediately. Runs at the provider + // (not inside useSignInForm, which only mounts when AuthGate picks the + // fallback branch) so the token is visible to AuthGate too, and stripping it + // here — rather than leaving it in the URL until the reset is confirmed — + // means a later sign-out or hard refresh can never resurrect the + // newPassword screen from a long-stale query param. + useEffect(() => { + if (typeof window === "undefined") return; + const { token, rest } = takeResetTokenFromSearch(window.location.search); + if (!token) return; + setPendingResetToken(token); + const url = new URL(window.location.href); + url.search = rest; + window.history.replaceState(window.history.state, "", url.toString()); + }, []); + const value: BoolAuthState = { user, loading, + pendingResetToken, + clearPendingReset() { + setPendingResetToken(null); + }, async signIn(email, password) { const { data, error } = await bool.auth.signInWithPassword({ email, password }); if (data.user) setUser(data.user); @@ -97,7 +144,11 @@ export function useBoolAuth(): BoolAuthState { } // Renders `children` for a signed-in user, otherwise `fallback` (your login -// screen). Renders nothing while the initial session check is in flight. +// screen). Renders nothing while the initial session check is in flight. A +// pending reset token forces `fallback` even over an existing session — a +// reset link is an explicit ask to set a new password, so it must always +// reach that screen instead of silently landing in the already-signed-in app +// (e.g. a stale session from a previous reset, or a shared device). export function AuthGate({ children, fallback, @@ -105,8 +156,9 @@ export function AuthGate({ children: ReactNode; fallback: ReactNode; }) { - const { user, loading } = useBoolAuth(); + const { user, loading, pendingResetToken } = useBoolAuth(); if (loading) return null; + if (pendingResetToken) return <>{fallback}; return <>{user ? children : fallback}; } @@ -121,23 +173,31 @@ export type SignInMode = "signin" | "signup" | "reset" | "newPassword"; // // export function useSignInForm() { - const { signIn, signUp, signInWithGoogle, resetPassword, confirmReset } = useBoolAuth(); - const [mode, setMode] = useState("signin"); + const { signIn, signUp, signInWithGoogle, resetPassword, confirmReset, pendingResetToken, clearPendingReset } = + useBoolAuth(); + // Lazy-init from the provider's already-captured token (BoolAuthProvider is + // an ancestor and reads/strips the URL on its own mount, which always + // completes before this component can mount — AuthGate renders nothing + // until then). The effect below covers the rare case it lands a tick late. + const [mode, setModeState] = useState(() => + pendingResetToken ? "newPassword" : "signin", + ); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [resetToken, setResetToken] = useState(null); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); - // A reset email links back here with ?bool_reset_token=… — switch to the - // "set a new password" mode when that's present. useEffect(() => { - const token = new URLSearchParams(window.location.search).get("bool_reset_token"); - if (token) { - setResetToken(token); - setMode("newPassword"); - } - }, []); + if (pendingResetToken) setModeState("newPassword"); + }, [pendingResetToken]); + + // Leaving newPassword mode without finishing the reset (e.g. "Back to sign + // in") drops the pending token — otherwise AuthGate would keep forcing this + // screen even after the visitor has navigated away from it. + function setMode(next: SignInMode) { + if (next !== "newPassword" && pendingResetToken) clearPendingReset(); + setModeState(next); + } async function submit(e: FormEvent) { e.preventDefault(); @@ -153,9 +213,10 @@ export function useSignInForm() { } else if (mode === "reset") { await resetPassword(email); setMessage("If that email has an account, a reset link is on its way."); - } else if (mode === "newPassword" && resetToken) { - const { error } = await confirmReset(resetToken, password); + } else if (mode === "newPassword" && pendingResetToken) { + const { error } = await confirmReset(pendingResetToken, password); if (error) setMessage("That reset link is invalid or has expired."); + else clearPendingReset(); } } finally { setBusy(false);