* fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user The Skatteverket OAuth callback answered NEXT_PUBLIC_APP_URL regardless of where the flow started, so on a white-label brand domain the popup's postMessage was dropped and the fallback redirect landed on the wrong origin without a session. On hosted, the initiator check from #2155 was bypassed by design because the registered callback host carries no app cookies, so a lured victim's BankID-authorised tokens could be stored under the user who started the flow. Flow state moves from six per-company extension_data keys to one oauth_flows row per flow (migration 20260907120000), consumed atomically. Hop 1 on the registered OAuth host consumes the state, stashes the provider code or error encrypted under a separate handoff id and 302s to the recorded origin; hop 2 there claims the handoff bound to that origin, requires the initiating user's session, re-checks membership and exchanges the code. Error pages keep the tab open. The self-hosted single-hop and the connector broker branch keep working. The hosted no-session exception, the legacy cookie-user fallback and the optional PKCE verifier are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): decide the callback hop by host, close the tab when the flow is unknown Skeptic findings on #2373. The hop comparison and the handoff claim used the request origin including its scheme, which Next derives from x-forwarded-proto; a self-hosted proxy that forwards Host without it (or rewrites Host to the upstream address) made every connect end in a state error. Hops are now compared by host only, and the handoff is claimed for the validated origin the host resolves to, scheme from configuration. Error pages answered before the flow row is known (unknown, expired or replayed state or handoff) post to a guessed origin that a brand opener never hears; they now close the tab so the panels' closed-tab watcher resets them instead of leaving Connect disabled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * test(skatteverket): mock resolveBrandResultByHost for the merged login-redirect resolver Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): bind the initiator before the flow is spent Superagent P2 on #2373: hop 2 deleted the handoff before the session and membership checks, so a signed-out or wrong-user arrival burned a live consent. The finishing hop now peeks the row for its initiator, binds the completing session to it, and only then consumes atomically. A session-less arrival is sent to /login on the initiating origin and resumes into the same callback URL; a different user is refused with the row left claimable for the initiator. The handoff TTL is five minutes so a sign-in fits. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): check membership before the flow is spent, answer the callback page on a failed mint Second review cycle on #2373. Superagent: the company-membership check ran after the consume, so a revoked initiator burned the provider code on the way to being refused; it now runs inside the pre-consume binding. CodeRabbit: a failed handoff mint escaped as a framework error page the opener never hears; it now answers the callback error page on the initiating origin. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
74 lines
4.0 KiB
SQL
74 lines
4.0 KiB
SQL
-- One row per browser-driven OAuth flow that must finish on the origin it
|
|
-- started from and for the user who started it.
|
|
--
|
|
-- The Skatteverket consent used to live as six per-company keys in
|
|
-- extension_data (oauth_state, oauth_user_id, oauth_redirect_uri,
|
|
-- oauth_code_verifier, oauth_connector_state, oauth_return_to). Per-company
|
|
-- keys meant a second connect overwrote the first mid-flight, the callback
|
|
-- scanned every company's state row to find its own, the state was deleted
|
|
-- only after the token exchange (two deliveries could both find it), and
|
|
-- nothing recorded which origin (app or white-label brand) the flow started
|
|
-- on, so the callback always answered the canonical app origin.
|
|
--
|
|
-- This table is the single source of truth for such a flow. The row id is
|
|
-- the OAuth `state` sent to the provider: an unguessable random token that
|
|
-- encodes nothing. Consuming the state and consuming the handoff are each
|
|
-- one UPDATE / DELETE whose WHERE clause carries the whole check
|
|
-- (unconsumed, unexpired, and for the handoff the destination origin), so a
|
|
-- replayed or concurrent callback loses the row-lock race with no
|
|
-- read-then-write window.
|
|
--
|
|
-- Two-hop flow on hosted: the provider redirects to the registered callback
|
|
-- host (app.gnubok.se), which carries no app session. Hop 1 consumes the
|
|
-- state, encrypts the provider code (or error) into the handoff columns and
|
|
-- 302s to the recorded origin with the separate handoff id. Hop 2 on that
|
|
-- origin consumes the handoff (DELETE RETURNING, bound to the origin),
|
|
-- verifies the completing browser's session is the initiating user, and
|
|
-- exchanges the code. Self-hosted (callback host = app host) is one hop.
|
|
--
|
|
-- Service-role only: written by /authorize and consumed by /callback with the
|
|
-- service client. No user-facing role may read a code verifier or a held
|
|
-- provider code. Not räkenskapsinformation; classified as infrastructure in
|
|
-- lib/reports/full-archive-export.ts. Rows cascade with the company and the
|
|
-- user; expired rows are purged opportunistically by /authorize.
|
|
|
|
CREATE TABLE public.oauth_flows (
|
|
id text PRIMARY KEY,
|
|
kind text NOT NULL,
|
|
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
|
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
origin text NOT NULL,
|
|
redirect_uri text NOT NULL,
|
|
code_verifier text,
|
|
connector_state text,
|
|
return_to text,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz NOT NULL,
|
|
used_at timestamptz,
|
|
handoff_id text UNIQUE,
|
|
handoff_code text,
|
|
handoff_error text,
|
|
handoff_expires_at timestamptz,
|
|
CONSTRAINT oauth_flows_kind_check CHECK (kind IN ('skatteverket')),
|
|
CONSTRAINT oauth_flows_handoff_shape CHECK (
|
|
(handoff_id IS NULL AND handoff_code IS NULL AND handoff_error IS NULL AND handoff_expires_at IS NULL)
|
|
OR (handoff_id IS NOT NULL AND handoff_expires_at IS NOT NULL AND used_at IS NOT NULL
|
|
AND (handoff_code IS NOT NULL OR handoff_error IS NOT NULL))
|
|
)
|
|
);
|
|
|
|
COMMENT ON TABLE public.oauth_flows IS
|
|
'One row per browser OAuth flow (state = id). Written by /authorize, consumed atomically by /callback. code_verifier, handoff_code and handoff_error are AES-256-GCM ciphertext (lib/auth/oauth-flow-crypto.ts). Service-role only.';
|
|
COMMENT ON COLUMN public.oauth_flows.origin IS
|
|
'Validated app or brand origin the flow started on; the callback finishes there and the handoff may only be consumed from there.';
|
|
COMMENT ON COLUMN public.oauth_flows.handoff_id IS
|
|
'Separate random token for hop 2; never the state, so the provider redirect URL alone cannot claim the handoff.';
|
|
|
|
CREATE INDEX idx_oauth_flows_expires_at ON public.oauth_flows (expires_at);
|
|
|
|
-- No policies on purpose: nothing but the service client touches this table.
|
|
ALTER TABLE public.oauth_flows ENABLE ROW LEVEL SECURITY;
|
|
REVOKE ALL ON TABLE public.oauth_flows FROM anon, authenticated;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|