fix(auth): land stock email-change links on the status page and stop retries voiding pending mails (#2199)
* fix(auth): land stock email-change links on the status page and stop retries voiding pending mails A secure email change needs one click in each mailbox. Stock GoTrue links verify on the GoTrue host and return to /auth/callback through redirect_to with ?message= (first click), ?error= (dead link) or ?code= (completing click); none carries a token_hash, so the callback bounced every one of them to /login with no message. Users read that as a failure and pressed "Byt" again, and because the claims fast path carries no new_email, the route re-issued both tokens on every press and voided the links they were about to click. - /api/account/email stamps flow=email_change on emailRedirectTo and reads pending state from GoTrue when the session claims lack it, so a repeat request inside the 30-minute window is a no-op instead of a re-send. - /auth/callback routes flow=email_change redirects to /auth/email-change?status=partial|done|failed; hook-style token_hash links keep using the existing verifyOtp branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LMFybWJqw8vScQiEDwKXGi * fix(auth): let signed-in stock email-change redirects through the proxy and treat a minted code as done Skeptic findings on e5639fb43: - The proxy bounced authenticated /auth/callback requests to / unless they carried type=email_change. Stock GoTrue links return with only the flow=email_change marker, so the new status branch was unreachable from the signed-in browser the change usually starts in. Exempt the marker too. - A completing click opened in a browser without the PKCE verifier (phone mail app) failed the code exchange and, with no session to inspect, was reported as a failed change although GoTrue had already flipped the address. A code is only minted after that verify, so report done. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LMFybWJqw8vScQiEDwKXGi * fix(auth): gate email-change requests with an atomic per-user claim CodeRabbit on PR #2199: the pending-state read from GoTrue is not atomic, so two concurrent POST /api/account/email calls (two tabs, a retried fetch) could both see nothing pending and both re-issue the confirmation tokens, voiding each other's mails. Migration 20260903083000 adds email_change_requests (one row per auth user, RLS with no policies) and two SECURITY DEFINER RPCs: claim_email_change_request(p_email, p_window_seconds) is a single INSERT ... ON CONFLICT DO UPDATE whose row lock serialises concurrent claimers, so exactly one caller per address per window wins; a different address always wins; release_email_change_request drops the claim when GoTrue refuses the change so the user can retry. The route claims right before updateUser, answers resent:false when the claim is held, releases on GoTrue failure, and falls through to GoTrue if the RPC itself errors. pg-real test covers sequential, windowed, concurrent, per-user, release and RLS behaviour. Applied to staging with the same version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LMFybWJqw8vScQiEDwKXGi --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
51b68afc87
commit
828628d882
@@ -0,0 +1,99 @@
|
||||
-- Atomic per-user claim for login-email change requests.
|
||||
--
|
||||
-- POST /api/account/email must not re-issue GoTrue's confirmation tokens
|
||||
-- while a change to the same address is still fresh: every re-issue voids
|
||||
-- the links the user is about to click (the "link invalid" loop of
|
||||
-- 2026-09-02). The route reads GoTrue's pending state first, but two
|
||||
-- concurrent requests (a double submit from two tabs, or a retried fetch)
|
||||
-- can both read "nothing pending" and both call updateUser. This table is
|
||||
-- the cross-instance gate: one row per user, claimed with a single
|
||||
-- INSERT ... ON CONFLICT DO UPDATE whose row lock serialises concurrent
|
||||
-- claimers, so exactly one caller proceeds per address per window.
|
||||
--
|
||||
-- Account-level (no company_id): the login e-mail belongs to the auth user.
|
||||
-- Not räkenskapsinformation; classified as infrastructure in
|
||||
-- lib/reports/full-archive-export.ts. Rows go with the user (ON DELETE
|
||||
-- CASCADE) and hold only the address the user typed.
|
||||
|
||||
CREATE TABLE public.email_change_requests (
|
||||
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
target_email text NOT NULL,
|
||||
claimed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.email_change_requests IS
|
||||
'Per-user claim for an in-flight login-email change. Written only through claim_email_change_request / release_email_change_request; gates POST /api/account/email so concurrent requests cannot re-issue confirmation tokens.';
|
||||
|
||||
-- No policies on purpose: nothing reads or writes this table except the two
|
||||
-- SECURITY DEFINER functions below, which key every statement on auth.uid().
|
||||
ALTER TABLE public.email_change_requests ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Returns true when the caller now holds the claim for p_email and may call
|
||||
-- GoTrue; false when another request claimed the same address less than
|
||||
-- p_window_seconds ago (the caller must answer "already pending" and send
|
||||
-- nothing). A different address always wins the claim: the user changed
|
||||
-- their mind, and GoTrue restarts the change for the new address anyway.
|
||||
CREATE OR REPLACE FUNCTION public.claim_email_change_request(
|
||||
p_email text,
|
||||
p_window_seconds integer
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid := auth.uid();
|
||||
v_won boolean;
|
||||
BEGIN
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'claim_email_change_request requires an authenticated user'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF p_email IS NULL OR length(btrim(p_email)) = 0 THEN
|
||||
RAISE EXCEPTION 'claim_email_change_request requires an address'
|
||||
USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_window_seconds IS NULL OR p_window_seconds <= 0 THEN
|
||||
RAISE EXCEPTION 'claim_email_change_request requires a positive window'
|
||||
USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.email_change_requests (user_id, target_email, claimed_at)
|
||||
VALUES (v_uid, lower(btrim(p_email)), now())
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET target_email = EXCLUDED.target_email,
|
||||
claimed_at = now()
|
||||
WHERE public.email_change_requests.target_email <> EXCLUDED.target_email
|
||||
OR public.email_change_requests.claimed_at
|
||||
< now() - make_interval(secs => p_window_seconds)
|
||||
RETURNING true INTO v_won;
|
||||
|
||||
RETURN COALESCE(v_won, false);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Drops the caller's claim so a failed GoTrue call (AAL2 refusal, address
|
||||
-- already registered, network) does not lock the user out of retrying for
|
||||
-- the whole window.
|
||||
CREATE OR REPLACE FUNCTION public.release_email_change_request()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'release_email_change_request requires an authenticated user'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
DELETE FROM public.email_change_requests WHERE user_id = auth.uid();
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.claim_email_change_request(text, integer) FROM PUBLIC, anon;
|
||||
REVOKE ALL ON FUNCTION public.release_email_change_request() FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.claim_email_change_request(text, integer) TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.release_email_change_request() TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user