From df7dabe26af42c009f2e62718620a9d5e89d049e Mon Sep 17 00:00:00 2001 From: Sauer Date: Tue, 3 Nov 2026 16:15:51 +0000 Subject: [PATCH 1/4] fix(web): token refreshes no longer remount signed-in pages (web 0.62.1) RequireAuth keyed its "owes a second factor" check by the access token, so every token change (the hourly refresh, or passing the authenticator code) reset it to unknown and showed "Loading..." in place of the page, unmounting it. Turning on two-factor sign-in lost the recovery codes it had just made that way. The check is now keyed by the user and keeps its last answer while it checks again. Co-Authored-By: Claude Opus 5.5 --- apps/web/components/auth/mfa-challenge.tsx | 23 +++++++++++----------- apps/web/components/auth/require-auth.tsx | 5 ++++- apps/web/lib/changelog.ts | 12 ++++++++++- apps/web/package.json | 2 +- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/apps/web/components/auth/mfa-challenge.tsx b/apps/web/components/auth/mfa-challenge.tsx index 419a1991..f097a530 100644 --- a/apps/web/components/auth/mfa-challenge.tsx +++ b/apps/web/components/auth/mfa-challenge.tsx @@ -11,33 +11,34 @@ import { createClient } from "@/lib/supabase/client"; /** * Whether the signed-in session still owes its second factor: the account * has an authenticator app and this session signed in without its code. - * Null while it is being worked out (from the session itself, no request). + * Null only until the first answer for this person. When the session's token + * changes (a refresh, or passing the code), it is checked again while the + * last answer stays, so a token refresh never unmounts the page behind it. */ -export function useNeedsSecondFactor(sessionKey: string | null): boolean | null { - // Keyed by the session, so a new session reads as unknown until checked. - const [state, setState] = useState<{ key: string; needs: boolean } | null>(null); +export function useNeedsSecondFactor(userId: string | null, token: string | null): boolean | null { + const [state, setState] = useState<{ userId: string; needs: boolean } | null>(null); useEffect(() => { - if (!sessionKey) return; + if (!userId || !token) return; let cancelled = false; void createClient() .auth.mfa.getAuthenticatorAssuranceLevel() .then(({ data }) => { if (!cancelled) { setState({ - key: sessionKey, + userId, needs: data?.nextLevel === "aal2" && data.currentLevel !== "aal2", }); } }) .catch(() => { - if (!cancelled) setState({ key: sessionKey, needs: false }); + if (!cancelled) setState({ userId, needs: false }); }); return () => { cancelled = true; }; - }, [sessionKey]); - if (!sessionKey) return false; - return state?.key === sessionKey ? state.needs : null; + }, [userId, token]); + if (!userId || !token) return false; + return state?.userId === userId ? state.needs : null; } /** Asks for the authenticator app's code after a password or link sign-in. */ @@ -227,7 +228,7 @@ export function MfaChallenge({ onVerified }: { onVerified: () => void }) { */ export function MfaGate({ children }: { children: React.ReactNode }) { const { session } = useAuth(); - const needs = useNeedsSecondFactor(session?.access_token ?? null); + const needs = useNeedsSecondFactor(session?.user.id ?? null, session?.access_token ?? null); const [verified, setVerified] = useState(false); if (session && needs === null) return null; if (needs && !verified) return setVerified(true)} />; diff --git a/apps/web/components/auth/require-auth.tsx b/apps/web/components/auth/require-auth.tsx index 0c22a85f..fc827950 100644 --- a/apps/web/components/auth/require-auth.tsx +++ b/apps/web/components/auth/require-auth.tsx @@ -18,7 +18,10 @@ function RequireAuthInner({ children }: { children: React.ReactNode }) { const router = useRouter(); // An account with an authenticator app finishes signing in here; the API // refuses its session until it has (lib/mfa.ts). - const needsSecondFactor = useNeedsSecondFactor(session?.access_token ?? null); + const needsSecondFactor = useNeedsSecondFactor( + session?.user.id ?? null, + session?.access_token ?? null + ); const [verified, setVerified] = useState(false); useEffect(() => { diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 903445d4..4b51ad0c 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -17,7 +17,7 @@ export const TRACK_LABELS: Record = { mcp: "MCP", }; -export const APP_VERSION = "0.62.0"; +export const APP_VERSION = "0.62.1"; export const CLI_VERSION = "1.8.0"; export const SDK_VERSION = "1.21.0"; export const PYTHON_SDK_VERSION = "0.5.0"; @@ -26,6 +26,16 @@ export const MCP_VERSION = "1.17.0"; export const CHANGELOG: ChangelogEntry[] = [ // ─── Web App ──────────────────────────────────────────────────────── + { + version: "0.62.1", + date: "2026-11-03", + title: "Recovery Codes Show When Two-Factor Is Turned On", + track: "web", + items: [ + "Turning on two-factor sign-in shows your ten recovery codes again. They were made but the page reloaded before showing them; make new ones under Account > Security if you never saw yours", + 'Signed-in pages no longer flash "Loading" and start over when your session renews in the background, about once an hour', + ], + }, { version: "0.62.0", date: "2026-11-03", diff --git a/apps/web/package.json b/apps/web/package.json index 5787ad34..8ff5fc68 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.62.0", + "version": "0.62.1", "private": true, "scripts": { "dev": "next dev", From 735450b0c83566fc1f6286d1faf5798014317c1a Mon Sep 17 00:00:00 2001 From: Sauer Date: Tue, 3 Nov 2026 16:21:48 +0000 Subject: [PATCH 2/4] fix(web): two-factor sign-in carries across a site switch Switching between webhooks.cc and mailhooks.cc signs in with a fresh magic-link session, which GoTrue rates aal1 and has no way to raise, so an account with an authenticator had to enter its code on every switch. When the session the switch started from had passed the second factor (checked when the grant is made and again when the ticket is issued), finish lists the new session in mfa_trusted_sessions (migration 00088), and every aal2 check accepts it: the session routes, ops, device approval, recovery codes, and session_mfa_ok() for RLS. The browser's code prompt asks the server before it shows. A row goes with its session, so signing out ends it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../api/account/mfa/recovery-codes/route.ts | 5 +- apps/web/app/api/account/mfa/status/route.ts | 26 ++++++++ .../app/api/auth/device-authorize/route.ts | 4 +- apps/web/app/api/site-switch/route.ts | 4 +- apps/web/app/auth/switch/finish/route.ts | 6 ++ apps/web/app/auth/switch/issue/route.ts | 10 +++ apps/web/components/auth/mfa-challenge.tsx | 20 ++++-- apps/web/lib/api-auth.ts | 4 +- apps/web/lib/changelog.ts | 3 +- apps/web/lib/mfa-session.ts | 61 +++++++++++++++++++ apps/web/lib/site-switch.ts | 4 ++ apps/web/lib/staff.ts | 5 +- apps/web/lib/supabase/database.ts | 7 +++ .../web/tests/integration/support-ops.test.ts | 51 ++++++++++++++++ supabase/migrations/00088_mfa_site_switch.sql | 55 +++++++++++++++++ 16 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 apps/web/app/api/account/mfa/status/route.ts create mode 100644 apps/web/lib/mfa-session.ts create mode 100644 supabase/migrations/00088_mfa_site_switch.sql diff --git a/AGENTS.md b/AGENTS.md index a3c4bb2d..f2bd0fd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ Decisions worth knowing, with the reasons: - **Forwarding is a queue the web app drains.** An endpoint sends to up to 10 rows of `endpoint_destinations` (migration 00063), each with its own kinds, filter, format, templates, throttle, delay and secret. `capture_webhook()` queues a delivery row (table `email_deliveries`, which holds HTTP requests too since migration 00058; the old names stay because a rename would need compatibility views across a deploy) in the capture's own transaction for each destination that takes the kind, so a captured request is never lost before it is queued; the delay is the first `next_attempt_at`. Filters are evaluated by the worker, not in SQL, because they share the rule evaluator (`packages/sdk/src/rules.ts`) with the dashboard's preview; a turned-down delivery is settled as `skipped`, and `bypass_filter` marks a hand redelivery to one destination. The throttle is taken when queueing for a destination without a filter (no row at all for the rest) and by the worker once the filter passed for one with a filter, so turned-down requests never use up the slot. Destination URLs on the receiver's own host are refused when saved and when sent: a delivery would be captured and forwarded again, by up to 10 destinations each time. The endpoint's old `forward_*` columns and `notification_url` still read and write through `legacy_role` (`forward`, `notify`) destinations, so the API and older SDKs keep working; notifications are chat destinations (throttle 1 s, no retries) and the receiver no longer sends any. The worker runs in the web app, not the receiver (`lib/forwarding/worker.ts`, started from `instrumentation.ts`), because the email JSON is built by one function (`buildEmailJson()` in `packages/sdk/src/email/json.ts`) shared with the dashboard's JSON tab; a Rust copy would drift, and the code extraction relies on regex lookbehind Rust's `regex` lacks. Formats: an HTTP request goes out as received (`lib/forwarding/relay.ts`: the same method, the stored headers minus hop-by-hop ones, the exact bytes, `requests.query_raw`, plus `webhooks-cc-*` metadata headers signed with the forwarding secret; never `webhook-*`, which Standard Webhooks senders use themselves), an email as signed JSON, either kind as the owner's JSON template (`{{ placeholders }}` filled from the request, signed like the JSON format) or as a chat message when the URL is a Slack or Discord webhook (`lib/forwarding/chat.ts`, pinned by `lib/forwarding/chat-vectors.json`). Claims are leased and re-checked under `FOR UPDATE SKIP LOCKED`, so several processes can run it and a crash only delays a retry. Deliveries go through the notify-proxy Worker when `NOTIFY_PROXY_URL` is set (`X-Proxy-Mode: forward` for JSON, `relay` for requests as received), so deploy the Worker before a web release that depends on a change to it; without the proxy the web app applies the receiver's SSRF rules and pins DNS. - **One rule language and one placeholder syntax.** Response rules (which answer a sender gets) and forwarding filters share the conditions in `packages/sdk/src/rules.ts`; mock responses and forwarded payloads share the `{{ path | filter }}` placeholders in `packages/sdk/src/placeholders.ts`. The receiver evaluates rules and fills in mock responses in Rust (`handlers/rules.rs`, `placeholders.rs`, `values.rs`), the web app with the SDK; both run `packages/sdk/conformance/rules.json` and `placeholders.json`, so change them together and regenerate the vectors. Regular expressions are RE2 on both sides (the `regex` crate, `re2js` in `lib/rules.ts`), never JavaScript's `RegExp`: they run against whatever anyone sends, and RE2 cannot backtrack. Placeholders only fill in values, with no loops or conditions (rules choose), so rendering always finishes, and an unknown first key is sent as written so old bodies never change. - **One app serves two sites.** webhooks.cc and mailhooks.cc (the mail-first front door) are the same Next app, accounts, endpoints and billing (`lib/site.ts`). The site comes from the Host: `proxy.ts` resolves it against the two configured origins only (`NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_MAILHOOKS_URL`, a build argument; unset means one site), passes it on in `x-whk-site`, lets mailhooks.cc answer only its own paths (the rest 308 to webhooks.cc) and keeps `app/mailhooks` off webhooks.cc; `next.config.ts` serves `app/mailhooks` at the mail host's root with host rewrites, so those pages stay static, and only the app's own layouts read the site from the request. Sessions stay per host (Supabase cookies have no domain), so the header's site switcher carries a session over (`lib/site-switch.ts`): the signed-in page asks `POST /api/site-switch` with its bearer token for a one-minute grant naming the account; `/auth/switch/start`, on the site switched to, sets a nonce cookie (`__Host-` over https); `/auth/switch/issue`, on the site switched from, makes a ticket only for the account the grant names and the session cookie there holds, sealing a GoTrue magic-link token (admin `generateLink`, nothing sent; for an existing user GoTrue stores it as the recovery token, so a switch cancels a pending password-reset link and holds off another for GoTrue's minute) with the nonce's hash for two minutes; `/auth/switch/finish` checks the cookie against the ticket and verifies the token, and uses up a ticket it refuses. The grant means no other page can have a ticket made for someone by sending their browser to issue; the nonce means a ticket signs no one in from another browser. An endpoint's `site` (migration 00078: `webhooks`, `mailhooks` or `both`) only decides which dashboard lists it, never what it captures; the dashboard sends the site it creates on, and existing endpoints and those made through the API without one are `both`. Everything that hands a browser back returns to the site it started on: `/auth/confirm` sends a mailhooks.cc link opened on webhooks.cc (GoTrue's site URL) to mailhooks.cc before verifying, `resolveRedirectBase` only ever answers one of the two origins, checkouts and invites return to the request's site (`returnOrigin`), and emails sent by background jobs link to `users.home_site`, the site the person last signed in on. The OAuth server, agent identities and the MCP server keep webhooks.cc as their issuer on both. Locally, run the two sites as `webhooks.localhost` and `mailhooks.localhost`: Next turns a redirect to its own `localhost` origin into a relative one, which loops between the sites. -- **Staff, support tickets and the operations center** (migration 00087). Staff are the auth user ids in `staff`, inserted by hand with psql, never in a migration and never matched by email; a trigger on `users` keeps a staff account on Business without a subscription, so a revoked subscription or the period job never moves it. `/ops` (webhooks.cc only) and every `/api/ops` route answer 404 to anyone else, accept a session only, and need two-factor sign-in (`aal2`, `lib/staff.ts`); any account with an authenticator app has its aal1 sessions refused everywhere (`lib/mfa.ts`), and `RequireAuth` (or `MfaGate` on pages that sign in on their own) asks for the code. GoTrue has no recovery codes, so the app keeps hashed ones in `mfa_recovery_codes` (`lib/mfa-recovery.ts`): a code cannot raise a session to aal2, so redeeming one removes the account's authenticators instead, as does the staff reset in `/ops`, and the owner is emailed either way. Tickets (`/support` on both sites) belong to the person who opened them, never their team; clients have no access to the tables, and the server never returns notes to users. Staff see an account's metadata freely but its captured contents only through a read-only view session opened from a ticket whose owner gave access (until 7 days after it closes, withdrawable), and each look is written to `support_access_log`, which the user reads under Account, Support access; there is deliberately no way to sign in as a user or write as one. Emails to users carry staff replies in full but never the user's own words; alerts to `SUPPORT_ALERT_EMAIL` carry no ticket contents. The overview reads `ops_daily_metrics`, which the web app fills hourly (`lib/ops/metrics.ts`), leaving out staff and `users.internal` accounts, never counting `requests` on page load. +- **Staff, support tickets and the operations center** (migration 00087). Staff are the auth user ids in `staff`, inserted by hand with psql, never in a migration and never matched by email; a trigger on `users` keeps a staff account on Business without a subscription, so a revoked subscription or the period job never moves it. `/ops` (webhooks.cc only) and every `/api/ops` route answer 404 to anyone else, accept a session only, and need two-factor sign-in (`aal2`, `lib/staff.ts`); any account with an authenticator app has its aal1 sessions refused everywhere (`lib/mfa.ts`), and `RequireAuth` (or `MfaGate` on pages that sign in on their own) asks for the code. GoTrue has no recovery codes, so the app keeps hashed ones in `mfa_recovery_codes` (`lib/mfa-recovery.ts`): a code cannot raise a session to aal2, so redeeming one removes the account's authenticators instead, as does the staff reset in `/ops`, and the owner is emailed either way. A site switch signs in with a fresh magic-link session, which GoTrue rates aal1 and cannot raise, so when the session it started from had passed the second factor the switch lists the new session in `mfa_trusted_sessions` (migration 00088), and every aal2 check accepts it (`lib/mfa-session.ts`, `session_mfa_ok()`); the row goes with its session. Tickets (`/support` on both sites) belong to the person who opened them, never their team; clients have no access to the tables, and the server never returns notes to users. Staff see an account's metadata freely but its captured contents only through a read-only view session opened from a ticket whose owner gave access (until 7 days after it closes, withdrawable), and each look is written to `support_access_log`, which the user reads under Account, Support access; there is deliberately no way to sign in as a user or write as one. Emails to users carry staff replies in full but never the user's own words; alerts to `SUPPORT_ALERT_EMAIL` carry no ticket contents. The overview reads `ops_daily_metrics`, which the web app fills hourly (`lib/ops/metrics.ts`), leaving out staff and `users.internal` accounts, never counting `requests` on page load. - **Guest endpoint creation is bot-gated.** `POST /api/go/endpoint` returns 403 for crawler or missing user agents, and the landing page only auto-creates an endpoint after a human input signal. Browsers with `navigator.webdriver` get a manual create button, so Playwright and agents must click it and tests must send a browser user agent. - **Site totals survive deletes.** `site_stats.total_webhooks` is `sum(endpoints.request_count) + deleted_webhooks`; an AFTER DELETE trigger on `endpoints` (migration 00038) moves the counts of deleted rows into `deleted_webhooks`. Do not add another accumulate step to any delete path. - **State changes leave an audit trail; webhook traffic leaves only counts.** Routes that change account, team, billing, API key, or endpoint state call `auditUserAction()` from `lib/audit.ts`, and the Polar webhook calls `auditPolarEvent()`; both write `audit_events` (kept a year) and print one `[audit]` line, and never throw. Add the call when you add such a route. Password changes never pass through a route (the browser talks to GoTrue directly), so a trigger on `auth.users` records them instead (migration 00061), with no `[audit]` line. Individual webhooks are never logged: `capture_webhook()` upserts per-endpoint daily counts into `endpoint_daily_stats`, which outlives request retention and endpoint deletion. diff --git a/apps/web/app/api/account/mfa/recovery-codes/route.ts b/apps/web/app/api/account/mfa/recovery-codes/route.ts index 86fa374e..4daa2512 100644 --- a/apps/web/app/api/account/mfa/recovery-codes/route.ts +++ b/apps/web/app/api/account/mfa/recovery-codes/route.ts @@ -1,6 +1,7 @@ import { authenticateSessionRequest, extractBearerToken } from "@/lib/api-auth"; import { auditUserAction } from "@/lib/audit"; -import { hasVerifiedFactor, sessionAal } from "@/lib/mfa"; +import { hasVerifiedFactor } from "@/lib/mfa"; +import { passedSecondFactor } from "@/lib/mfa-session"; import { remainingRecoveryCodes, replaceRecoveryCodes } from "@/lib/mfa-recovery"; import { reportError } from "@/lib/observability"; import { checkRateLimitByKey } from "@/lib/rate-limit"; @@ -38,7 +39,7 @@ export async function POST(request: Request) { const { data: { user }, } = await createAdminClient().auth.getUser(token); - if (!user || !hasVerifiedFactor(user) || sessionAal(token) !== "aal2") { + if (!user || !hasVerifiedFactor(user) || !(await passedSecondFactor(user, token))) { return Response.json( { error: "Turn on two-factor sign-in first, then make recovery codes." }, { status: 409 } diff --git a/apps/web/app/api/account/mfa/status/route.ts b/apps/web/app/api/account/mfa/status/route.ts new file mode 100644 index 00000000..336a4daf --- /dev/null +++ b/apps/web/app/api/account/mfa/status/route.ts @@ -0,0 +1,26 @@ +import { extractBearerToken } from "@/lib/api-auth"; +import { owesSecondFactor } from "@/lib/mfa-session"; +import { createAdminClient } from "@/lib/supabase/admin"; + +/** + * Whether this session still owes the authenticator code. The browser's own + * reading (GoTrue's assurance level) does not know about sessions a site + * switch carried the second factor to (migration 00088), so the code prompt + * asks here before it shows. Takes a session that owes the code, which every + * other session route refuses. + */ +export async function GET(request: Request) { + const token = extractBearerToken(request); + if (!token || token.startsWith("whcc_")) { + return Response.json({ error: "Sign in first." }, { status: 401 }); + } + const { + data: { user }, + error, + } = await createAdminClient().auth.getUser(token); + if (error || !user) return Response.json({ error: "Sign in first." }, { status: 401 }); + return Response.json( + { owesCode: await owesSecondFactor(user, token) }, + { headers: { "Cache-Control": "no-store" } } + ); +} diff --git a/apps/web/app/api/auth/device-authorize/route.ts b/apps/web/app/api/auth/device-authorize/route.ts index 3a433e36..bc09040b 100644 --- a/apps/web/app/api/auth/device-authorize/route.ts +++ b/apps/web/app/api/auth/device-authorize/route.ts @@ -3,7 +3,7 @@ import { parseJsonBody } from "@/lib/request-validation"; import { createClient } from "@/lib/supabase/server"; import { authorizeDeviceCodeForUser } from "@/lib/supabase/device-auth"; import { reportError } from "@/lib/observability"; -import { needsSecondFactor } from "@/lib/mfa"; +import { owesSecondFactor } from "@/lib/mfa-session"; export async function POST(request: Request) { const rateLimited = await checkRateLimit(request, "device-authorize", 10); @@ -32,7 +32,7 @@ export async function POST(request: Request) { const { data: { session }, } = await supabase.auth.getSession(); - if (!session || needsSecondFactor(user, session.access_token)) { + if (!session || (await owesSecondFactor(user, session.access_token))) { return Response.json({ error: "Enter your two-factor code first." }, { status: 401 }); } diff --git a/apps/web/app/api/site-switch/route.ts b/apps/web/app/api/site-switch/route.ts index f224ccf3..0274cb81 100644 --- a/apps/web/app/api/site-switch/route.ts +++ b/apps/web/app/api/site-switch/route.ts @@ -1,4 +1,5 @@ -import { authenticateSessionRequest } from "@/lib/api-auth"; +import { authenticateSessionRequest, extractBearerToken } from "@/lib/api-auth"; +import { passedSecondFactor } from "@/lib/mfa-session"; import { sanitizeNextPath } from "@/lib/auth-redirect"; import { parseJsonBody } from "@/lib/request-validation"; import { checkRateLimitByKeyWithInfo } from "@/lib/rate-limit"; @@ -47,6 +48,7 @@ export async function POST(request: Request) { userId: auth.userId, expires: Date.now() + GRANT_LIFETIME_MS, consent: parseCarriedConsent(body.consent), + secondFactor: await passedSecondFactor({ id: auth.userId }, extractBearerToken(request)!), }) ); start.searchParams.set("next", next); diff --git a/apps/web/app/auth/switch/finish/route.ts b/apps/web/app/auth/switch/finish/route.ts index f2ac4fdd..3015985b 100644 --- a/apps/web/app/auth/switch/finish/route.ts +++ b/apps/web/app/auth/switch/finish/route.ts @@ -15,6 +15,7 @@ import { siteForHost } from "@/lib/site"; import { createAdminClient } from "@/lib/supabase/admin"; import { rememberHomeSite } from "@/lib/supabase/home-site"; import { createClient } from "@/lib/supabase/server"; +import { trustSession } from "@/lib/mfa-session"; /** * Uses up the token of a ticket this browser may not have: a session made @@ -79,6 +80,11 @@ export async function GET(request: Request) { token_hash: ticket.tokenHash, }); if (error || !data?.user) return done(failed); + // The session this switch started from had passed the second factor, so + // this one counts as having passed it too (migration 00088). + if (ticket.secondFactor === true && data.session) { + await trustSession(data.session.access_token, data.user.id); + } await rememberHomeSite(data.user.id, here); await auditUserAction(request, data.user.id, { diff --git a/apps/web/app/auth/switch/issue/route.ts b/apps/web/app/auth/switch/issue/route.ts index ba3d0e03..e224dfb1 100644 --- a/apps/web/app/auth/switch/issue/route.ts +++ b/apps/web/app/auth/switch/issue/route.ts @@ -13,6 +13,7 @@ import { import { otherSite, siteForHost, siteOrigin, twoSites } from "@/lib/site"; import { createAdminClient } from "@/lib/supabase/admin"; import { createClient } from "@/lib/supabase/server"; +import { passedSecondFactor } from "@/lib/mfa-session"; /** Tickets per account per hour: far above any person's, well below a script's. */ const SWITCHES_PER_HOUR = 30; @@ -71,6 +72,14 @@ export async function GET(request: Request) { // address: nothing to carry over. if (data.user?.id !== user.id) throw new Error("generateLink answered for another account"); + // The factor carries only when this browser's session here passed it too. + const { + data: { session }, + } = await supabase.auth.getSession(); + const secondFactor = + grant.secondFactor === true && + !!session && + (await passedSecondFactor(user, session.access_token)); const ticket = sealTicket({ tokenHash, userId: user.id, @@ -79,6 +88,7 @@ export async function GET(request: Request) { next, expires: Date.now() + TICKET_LIFETIME_MS, consent: grant.consent, + secondFactor, }); await auditUserAction(request, user.id, { action: "account.site_switch", diff --git a/apps/web/components/auth/mfa-challenge.tsx b/apps/web/components/auth/mfa-challenge.tsx index f097a530..d7c9fbdc 100644 --- a/apps/web/components/auth/mfa-challenge.tsx +++ b/apps/web/components/auth/mfa-challenge.tsx @@ -22,13 +22,21 @@ export function useNeedsSecondFactor(userId: string | null, token: string | null let cancelled = false; void createClient() .auth.mfa.getAuthenticatorAssuranceLevel() - .then(({ data }) => { - if (!cancelled) { - setState({ - userId, - needs: data?.nextLevel === "aal2" && data.currentLevel !== "aal2", - }); + .then(async ({ data }) => { + let needs = data?.nextLevel === "aal2" && data.currentLevel !== "aal2"; + // A session a site switch carried the factor to reads aal1 here; + // the server knows better (migration 00088). + if (needs) { + try { + const response = await fetch("/api/account/mfa/status", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) needs = ((await response.json()) as { owesCode: boolean }).owesCode; + } catch { + // Ask for the code when the server cannot be reached. + } } + if (!cancelled) setState({ userId, needs }); }) .catch(() => { if (!cancelled) setState({ userId, needs: false }); diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts index 5852b76a..6d224fbd 100644 --- a/apps/web/lib/api-auth.ts +++ b/apps/web/lib/api-auth.ts @@ -8,7 +8,7 @@ import { createAdminClient } from "./supabase/admin"; import { validateApiKeyWithMetadata } from "./supabase/api-keys"; import { markAgentTokenRequest } from "./agent/token-requests"; import { reportError } from "@/lib/observability"; -import { needsSecondFactor } from "@/lib/mfa"; +import { owesSecondFactor } from "@/lib/mfa-session"; export type { UserPlan } from "./entitlements"; import type { UserPlan } from "./entitlements"; @@ -54,7 +54,7 @@ async function validateSupabaseSessionWithPlan( // An account with two-factor sign-in on needs a session that passed it: // a password alone (aal1) is not enough for any route. - if (needsSecondFactor(user, accessToken)) { + if (await owesSecondFactor(user, accessToken)) { return null; } diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 4b51ad0c..00d073a7 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -29,11 +29,12 @@ export const CHANGELOG: ChangelogEntry[] = [ { version: "0.62.1", date: "2026-11-03", - title: "Recovery Codes Show When Two-Factor Is Turned On", + title: "Two-Factor Fixes: Recovery Codes and Site Switching", track: "web", items: [ "Turning on two-factor sign-in shows your ten recovery codes again. They were made but the page reloaded before showing them; make new ones under Account > Security if you never saw yours", 'Signed-in pages no longer flash "Loading" and start over when your session renews in the background, about once an hour', + "Switching between webhooks.cc and mailhooks.cc no longer asks for your two-factor code again: a session that passed it carries that over to the other site", ], }, { diff --git a/apps/web/lib/mfa-session.ts b/apps/web/lib/mfa-session.ts new file mode 100644 index 00000000..1ec8a53a --- /dev/null +++ b/apps/web/lib/mfa-session.ts @@ -0,0 +1,61 @@ +import { createAdminClient } from "@/lib/supabase/admin"; +import { reportError } from "@/lib/observability"; +import { hasVerifiedFactor, jwtClaims, sessionAal } from "@/lib/mfa"; + +/** + * Whether a session counts as having passed the second factor: aal2 from + * GoTrue, or a session a site switch carried the factor to (migration + * 00088). GoTrue cannot raise a session to aal2 from the server, so a switch + * between webhooks.cc and mailhooks.cc lists the new session's id in + * mfa_trusted_sessions when the session it started from had passed it. + * Server only; the token must already be validated (getUser). + */ + +function sessionIdOf(token: string): string | null { + const id = jwtClaims(token)?.session_id; + return typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id) ? id : null; +} + +export async function isTrustedSession(token: string, userId: string): Promise { + const sessionId = sessionIdOf(token); + if (!sessionId) return false; + const { data, error } = await createAdminClient() + .from("mfa_trusted_sessions") + .select("session_id") + .eq("session_id", sessionId) + .eq("user_id", userId) + .maybeSingle(); + if (error) { + // A database without migration 00088 has no carried sessions. + if (!/mfa_trusted_sessions/.test(error.message)) { + reportError("[mfa] trusted session lookup failed", error.message); + } + return false; + } + return data !== null; +} + +/** The session passed the second factor, here or before a site switch. */ +export async function passedSecondFactor(user: { id: string }, token: string): Promise { + if (sessionAal(token) === "aal2") return true; + return isTrustedSession(token, user.id); +} + +/** The account has an authenticator and this session has not passed it. */ +export async function owesSecondFactor( + user: { id: string; factors?: { status?: string }[] | null }, + token: string +): Promise { + if (!hasVerifiedFactor(user)) return false; + return !(await passedSecondFactor(user, token)); +} + +/** Lists a session made by a site switch as having passed the second factor. */ +export async function trustSession(token: string, userId: string): Promise { + const sessionId = sessionIdOf(token); + if (!sessionId) return; + const { error } = await createAdminClient() + .from("mfa_trusted_sessions") + .upsert({ session_id: sessionId, user_id: userId }, { onConflict: "session_id" }); + if (error) reportError("[mfa] carrying the second factor across a switch failed", error.message); +} diff --git a/apps/web/lib/site-switch.ts b/apps/web/lib/site-switch.ts index 235fd3a4..d2f7be88 100644 --- a/apps/web/lib/site-switch.ts +++ b/apps/web/lib/site-switch.ts @@ -108,6 +108,8 @@ export interface SwitchGrant { userId: string; expires: number; consent?: CarriedConsent; + /** The asking session had passed the second factor (migration 00088). */ + secondFactor?: boolean; } export interface SwitchTicket { @@ -123,6 +125,8 @@ export interface SwitchTicket { next: string; expires: number; consent?: CarriedConsent; + /** Both sessions it came from had passed the second factor: the new one counts as having passed it. */ + secondFactor?: boolean; } const CONSENT_COOKIE_NAME = "whk_consent_carry"; diff --git a/apps/web/lib/staff.ts b/apps/web/lib/staff.ts index 8e6e823b..9e6cf76e 100644 --- a/apps/web/lib/staff.ts +++ b/apps/web/lib/staff.ts @@ -1,5 +1,6 @@ import { extractBearerToken } from "@/lib/api-auth"; -import { hasVerifiedFactor, sessionAal } from "@/lib/mfa"; +import { hasVerifiedFactor } from "@/lib/mfa"; +import { passedSecondFactor } from "@/lib/mfa-session"; import { checkRateLimitByKey } from "@/lib/rate-limit"; import { createAdminClient } from "@/lib/supabase/admin"; import { reportError } from "@/lib/observability"; @@ -51,7 +52,7 @@ export async function staffSession(request: Request): Promise { expect(count).toBe(0); }); }); + +describe("second factor carried by a site switch", () => { + it("counts a trusted aal1 session as having passed it, until it signs out", async () => { + const email = `test-support-switch-${ts}@webhooks-test.local`; + const password = "TestPassword123!"; + const { data: created } = await admin.auth.admin.createUser({ + email, + password, + email_confirm: true, + }); + const userId = created.user!.id; + userIds.push(userId); + const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const fresh = () => + createClient(process.env.SUPABASE_URL!, anon, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + // An authenticator on the account. + const setup = fresh(); + await setup.auth.signInWithPassword({ email, password }); + const { data: factor } = await setup.auth.mfa.enroll({ factorType: "totp" }); + await setup.auth.mfa.challengeAndVerify({ + factorId: factor!.id, + code: totp(factor!.totp.secret), + }); + + // A new password-only session, as a switch's magic link makes. + const other = fresh(); + const { data: signedIn } = await other.auth.signInWithPassword({ email, password }); + const token = signedIn.session!.access_token; + const { data: user } = await admin.auth.getUser(token); + const ownRow = async () => (await other.from("users").select("id").eq("id", userId)).data ?? []; + + expect(await owesSecondFactor(user.user!, token)).toBe(true); + expect(await ownRow()).toHaveLength(0); + + await trustSession(token, userId); + expect(await owesSecondFactor(user.user!, token)).toBe(false); + expect(await ownRow()).toHaveLength(1); + + // Signing out ends the session and its trust with it. + await other.auth.signOut(); + const { count } = await admin + .from("mfa_trusted_sessions") + .select("session_id", { count: "exact", head: true }) + .eq("user_id", userId); + expect(count).toBe(0); + }); +}); diff --git a/supabase/migrations/00088_mfa_site_switch.sql b/supabase/migrations/00088_mfa_site_switch.sql new file mode 100644 index 00000000..23dc3c27 --- /dev/null +++ b/supabase/migrations/00088_mfa_site_switch.sql @@ -0,0 +1,55 @@ +-- ============================================================================ +-- 00088: two-factor sign-in carries across a site switch +-- ============================================================================ +-- Switching between webhooks.cc and mailhooks.cc signs the person in on the +-- other site with a fresh magic-link session (lib/site-switch.ts), which +-- GoTrue always rates aal1, so an account with an authenticator had to enter +-- its code again on every switch. GoTrue offers no way to raise a session to +-- aal2 from the server, so the app records it instead: when the session the +-- switch started from had passed the second factor, /auth/switch/finish adds +-- the new session's id here, and every check that wants aal2 also accepts a +-- session listed here (lib/mfa.ts on the server, session_mfa_ok() for RLS). +-- A row goes with its session (signing out deletes it), and only a session +-- that had passed the code can list another, so a password alone still gets +-- nothing. +-- +-- Service-role only. Re-runnable. Apply in autocommit mode. +-- ============================================================================ + +create table if not exists public.mfa_trusted_sessions ( + session_id uuid primary key references auth.sessions(id) on delete cascade, + user_id uuid not null references public.users(id) on delete cascade, + created_at timestamptz not null default now() +); + +create index if not exists mfa_trusted_sessions_user + on public.mfa_trusted_sessions (user_id); + +alter table public.mfa_trusted_sessions enable row level security; +revoke all on table public.mfa_trusted_sessions from public, anon, authenticated; +grant select, insert, delete on table public.mfa_trusted_sessions to service_role; + +-- 00087's check, now also passing a session a switch carried the factor to. +create or replace function public.session_mfa_ok() +returns boolean +language sql +stable +security definer set search_path = '' +as $$ + select auth.uid() is null + or coalesce(auth.jwt() ->> 'aal', 'aal1') = 'aal2' + or not exists ( + select 1 from auth.mfa_factors f + where f.user_id = auth.uid() and f.status = 'verified' + ) + or exists ( + select 1 from public.mfa_trusted_sessions t + where t.session_id = nullif(auth.jwt() ->> 'session_id', '')::uuid + and t.user_id = auth.uid() + ); +$$; + +revoke all on function public.session_mfa_ok() from public; +grant execute on function public.session_mfa_ok() to anon, authenticated, service_role; + +notify pgrst, 'reload schema'; From 001b6a50c3350f86e46848ef45516fed43956230 Mon Sep 17 00:00:00 2001 From: Sauer Date: Tue, 3 Nov 2026 16:29:01 +0000 Subject: [PATCH 3/4] fix(web): the second-factor gate remembers who passed it, not that someone did RequireAuth and MfaGate kept a plain "verified" flag, so if another account signed in from another tab while the page stayed mounted, it could inherit the first account's pass. The flag is now the user id that passed the code. Co-Authored-By: Claude Opus 5.5 --- apps/web/components/auth/mfa-challenge.tsx | 11 ++++++++--- apps/web/components/auth/require-auth.tsx | 8 +++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/web/components/auth/mfa-challenge.tsx b/apps/web/components/auth/mfa-challenge.tsx index d7c9fbdc..54c4b1a3 100644 --- a/apps/web/components/auth/mfa-challenge.tsx +++ b/apps/web/components/auth/mfa-challenge.tsx @@ -236,9 +236,14 @@ export function MfaChallenge({ onVerified }: { onVerified: () => void }) { */ export function MfaGate({ children }: { children: React.ReactNode }) { const { session } = useAuth(); - const needs = useNeedsSecondFactor(session?.user.id ?? null, session?.access_token ?? null); - const [verified, setVerified] = useState(false); + const userId = session?.user.id ?? null; + const needs = useNeedsSecondFactor(userId, session?.access_token ?? null); + // Who passed the code here: another account signed in from another tab + // must pass its own. + const [verifiedUserId, setVerifiedUserId] = useState(null); if (session && needs === null) return null; - if (needs && !verified) return setVerified(true)} />; + if (needs && verifiedUserId !== userId) { + return setVerifiedUserId(userId)} />; + } return <>{children}; } diff --git a/apps/web/components/auth/require-auth.tsx b/apps/web/components/auth/require-auth.tsx index fc827950..0fa40630 100644 --- a/apps/web/components/auth/require-auth.tsx +++ b/apps/web/components/auth/require-auth.tsx @@ -22,7 +22,9 @@ function RequireAuthInner({ children }: { children: React.ReactNode }) { session?.user.id ?? null, session?.access_token ?? null ); - const [verified, setVerified] = useState(false); + // Who passed the code here: another account signed in from another tab + // must pass its own. + const [verifiedUserId, setVerifiedUserId] = useState(null); useEffect(() => { if (!isLoading && !isAuthenticated) { @@ -42,8 +44,8 @@ function RequireAuthInner({ children }: { children: React.ReactNode }) { return null; } - if (needsSecondFactor && !verified) { - return setVerified(true)} />; + if (needsSecondFactor && verifiedUserId !== (session?.user.id ?? null)) { + return setVerifiedUserId(session?.user.id ?? null)} />; } return <>{children}; From 34895ce7df93f02736da0def88232b2d77beaa56 Mon Sep 17 00:00:00 2001 From: Sauer Date: Tue, 3 Nov 2026 16:35:50 +0000 Subject: [PATCH 4/4] fix(web): carried two-factor trust ends when the account's factors change From a review of the site-switch change: - A trigger on auth.mfa_factors drops an account's carried sessions when a factor is removed or newly verified (recovery code, staff reset, unenrolling, a new phone), and removeTwoFactor() drops them too; a carried session also counts only while the account has a factor. - Recording the trust is ON CONFLICT DO NOTHING, which needs only the INSERT grant the migration gives. - The grant asks with the full user, factors included. - The Security page asks for the code once before turning two-factor off in a carried session, which GoTrue still rates aal1. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/site-switch/route.ts | 11 +++- apps/web/components/account/two-factor.tsx | 51 ++++++++++++++++++- apps/web/lib/mfa-recovery.ts | 2 + apps/web/lib/mfa-session.ts | 18 +++++-- .../web/tests/integration/support-ops.test.ts | 20 ++++++-- supabase/migrations/00088_mfa_site_switch.sql | 30 +++++++++-- 6 files changed, 118 insertions(+), 14 deletions(-) diff --git a/apps/web/app/api/site-switch/route.ts b/apps/web/app/api/site-switch/route.ts index 0274cb81..cd1e3731 100644 --- a/apps/web/app/api/site-switch/route.ts +++ b/apps/web/app/api/site-switch/route.ts @@ -1,5 +1,6 @@ import { authenticateSessionRequest, extractBearerToken } from "@/lib/api-auth"; import { passedSecondFactor } from "@/lib/mfa-session"; +import { createAdminClient } from "@/lib/supabase/admin"; import { sanitizeNextPath } from "@/lib/auth-redirect"; import { parseJsonBody } from "@/lib/request-validation"; import { checkRateLimitByKeyWithInfo } from "@/lib/rate-limit"; @@ -11,6 +12,14 @@ import { } from "@/lib/site-switch"; import { SITE_HEADER, otherSite, parseSite, siteOrigin, twoSites } from "@/lib/site"; +/** Whether the asking session passed the second factor (its account's factors included). */ +async function switchingSessionPassed(token: string): Promise { + const { + data: { user }, + } = await createAdminClient().auth.getUser(token); + return !!user && (await passedSecondFactor(user, token)); +} + /** Grants per account per minute: a person clicks a few; a script would ask for many. */ const GRANTS_PER_MINUTE = 10; @@ -48,7 +57,7 @@ export async function POST(request: Request) { userId: auth.userId, expires: Date.now() + GRANT_LIFETIME_MS, consent: parseCarriedConsent(body.consent), - secondFactor: await passedSecondFactor({ id: auth.userId }, extractBearerToken(request)!), + secondFactor: await switchingSessionPassed(extractBearerToken(request)!), }) ); start.searchParams.set("next", next); diff --git a/apps/web/components/account/two-factor.tsx b/apps/web/components/account/two-factor.tsx index 52e4ded3..e3dbe057 100644 --- a/apps/web/components/account/two-factor.tsx +++ b/apps/web/components/account/two-factor.tsx @@ -37,6 +37,8 @@ export function TwoFactor() { const [message, setMessage] = useState(null); const [codes, setCodes] = useState(null); const [remaining, setRemaining] = useState(null); + const [stepUp, setStepUp] = useState(false); + const [stepUpCode, setStepUpCode] = useState(""); const { session } = useAuth(); const token = session?.access_token ?? null; @@ -142,7 +144,32 @@ export function TwoFactor() { if (!verified) return; setBusy(true); setError(null); - const { error: unenrollError } = await createClient().auth.mfa.unenroll({ + const supabase = createClient(); + // GoTrue lets only a session that passed the code remove a factor. A + // session carried over from the other site (migration 00088) has not + // in GoTrue's eyes, so it asks for the code once here first. + const { data: level } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (level?.currentLevel !== "aal2") { + const digits = stepUpCode.replace(/\s+/g, ""); + if (!/^\d{6}$/.test(digits)) { + setBusy(false); + setStepUp(true); + if (stepUp) setError("Enter the 6-digit code from your authenticator app."); + return; + } + const { error: stepError } = await supabase.auth.mfa.challengeAndVerify({ + factorId: verified.id, + code: digits, + }); + if (stepError) { + setBusy(false); + setError("That code did not work. Try the current one."); + return; + } + setStepUp(false); + setStepUpCode(""); + } + const { error: unenrollError } = await supabase.auth.mfa.unenroll({ factorId: verified.id, }); setBusy(false); @@ -249,7 +276,27 @@ export function TwoFactor() { Turn off two-factor sign-in - ) : ( + ) : null} + {verified && stepUp ? ( +
+ + setStepUpCode(event.target.value)} + className="w-32 font-mono tracking-widest" + /> + +
+ ) : null} + {verified ? null : ( diff --git a/apps/web/lib/mfa-recovery.ts b/apps/web/lib/mfa-recovery.ts index 95842cc4..65658467 100644 --- a/apps/web/lib/mfa-recovery.ts +++ b/apps/web/lib/mfa-recovery.ts @@ -79,6 +79,8 @@ export async function removeTwoFactor(userId: string): Promise { .delete() .eq("user_id", userId); if (codesError) throw codesError; + // Also done by a trigger on auth.mfa_factors (migration 00088). + await admin.from("mfa_trusted_sessions").delete().eq("user_id", userId); return removed; } diff --git a/apps/web/lib/mfa-session.ts b/apps/web/lib/mfa-session.ts index 1ec8a53a..8ffd8dc3 100644 --- a/apps/web/lib/mfa-session.ts +++ b/apps/web/lib/mfa-session.ts @@ -35,9 +35,17 @@ export async function isTrustedSession(token: string, userId: string): Promise { +/** + * The session passed the second factor, here or before a site switch. A + * carried session counts only while the account still has an authenticator + * (the database drops the rows when factors change; this is the second lock). + */ +export async function passedSecondFactor( + user: { id: string; factors?: { status?: string }[] | null }, + token: string +): Promise { if (sessionAal(token) === "aal2") return true; + if (!hasVerifiedFactor(user)) return false; return isTrustedSession(token, user.id); } @@ -56,6 +64,10 @@ export async function trustSession(token: string, userId: string): Promise if (!sessionId) return; const { error } = await createAdminClient() .from("mfa_trusted_sessions") - .upsert({ session_id: sessionId, user_id: userId }, { onConflict: "session_id" }); + // ON CONFLICT DO NOTHING: needs only the INSERT grant. + .upsert( + { session_id: sessionId, user_id: userId }, + { onConflict: "session_id", ignoreDuplicates: true } + ); if (error) reportError("[mfa] carrying the second factor across a switch failed", error.message); } diff --git a/apps/web/tests/integration/support-ops.test.ts b/apps/web/tests/integration/support-ops.test.ts index 2de0f475..bcc7ef4d 100644 --- a/apps/web/tests/integration/support-ops.test.ts +++ b/apps/web/tests/integration/support-ops.test.ts @@ -468,12 +468,22 @@ describe("second factor carried by a site switch", () => { expect(await owesSecondFactor(user.user!, token)).toBe(false); expect(await ownRow()).toHaveLength(1); + const trusted = async () => + ( + await admin + .from("mfa_trusted_sessions") + .select("session_id", { count: "exact", head: true }) + .eq("user_id", userId) + ).count; + + // Removing the authenticator takes the carried trust with it. + await admin.auth.admin.mfa.deleteFactor({ id: factor!.id, userId }); + expect(await trusted()).toBe(0); + // Signing out ends the session and its trust with it. + await trustSession(token, userId); + expect(await trusted()).toBe(1); await other.auth.signOut(); - const { count } = await admin - .from("mfa_trusted_sessions") - .select("session_id", { count: "exact", head: true }) - .eq("user_id", userId); - expect(count).toBe(0); + expect(await trusted()).toBe(0); }); }); diff --git a/supabase/migrations/00088_mfa_site_switch.sql b/supabase/migrations/00088_mfa_site_switch.sql index 23dc3c27..a35124f1 100644 --- a/supabase/migrations/00088_mfa_site_switch.sql +++ b/supabase/migrations/00088_mfa_site_switch.sql @@ -9,9 +9,9 @@ -- switch started from had passed the second factor, /auth/switch/finish adds -- the new session's id here, and every check that wants aal2 also accepts a -- session listed here (lib/mfa.ts on the server, session_mfa_ok() for RLS). --- A row goes with its session (signing out deletes it), and only a session --- that had passed the code can list another, so a password alone still gets --- nothing. +-- A row goes with its session (signing out deletes it) and with any change +-- to the account's factors, and only a session that had passed the code can +-- list another, so a password alone still gets nothing. -- -- Service-role only. Re-runnable. Apply in autocommit mode. -- ============================================================================ @@ -29,6 +29,30 @@ alter table public.mfa_trusted_sessions enable row level security; revoke all on table public.mfa_trusted_sessions from public, anon, authenticated; grant select, insert, delete on table public.mfa_trusted_sessions to service_role; +-- The trust is in the factors the account had: when one is removed, or a +-- new one verified (a recovery code, a staff reset, unenrolling, setting up +-- a new phone), every carried session of the account goes back to owing +-- the code, as GoTrue does with its own aal2 sessions. +create or replace function public.mfa_factors_drop_trust() +returns trigger +language plpgsql +security definer set search_path = '' +as $$ +begin + delete from public.mfa_trusted_sessions t + where t.user_id = coalesce(new.user_id, old.user_id); + return null; +end; +$$; + +revoke all on function public.mfa_factors_drop_trust() from public, anon, authenticated; + +drop trigger if exists mfa_factors_drop_trust on auth.mfa_factors; +create trigger mfa_factors_drop_trust + after delete or update of status on auth.mfa_factors + for each row + execute function public.mfa_factors_drop_trust(); + -- 00087's check, now also passing a session a switch carried the factor to. create or replace function public.session_mfa_ok() returns boolean