fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user (#2373)
* 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>
This commit is contained in:
@@ -1636,6 +1636,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen.
|
||||
[2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable".
|
||||
[2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler.
|
||||
[2026-09-07] Skatteverket OAuth moved from six per-company extension_data keys to one oauth_flows row per flow (migration 20260907120000) with a two-hop brand handoff modelled on PR #2305: hop 1 on the registered callback host consumes the state and 302s an encrypted handoff to the recorded app/brand origin, hop 2 there requires the initiating user's session before the exchange. The hosted no-session exception from PR #2155 is gone (it made the initiator check a no-op on every hosted connect). Rejected: extending provider_otc (its consent FK is NOT NULL and points at migration consents) and a purge cron (the row set is tiny; /authorize sweeps expired rows). In-flight connects at deploy time fail with a state error and are restarted; the legacy cookie-user and optional-PKCE fallbacks were deleted with them.
|
||||
[2026-09-07] WooCommerce activation gate: the wc-auth callback now only STAGES verified keys on the pending row; activation additionally requires browser_confirmed_at from the session-bound return leg, enforced by a DB CHECK (20260907143000) rather than an application guard, so 'active implies keys plus a recorded confirmation' holds by construction and a connection can never go live headless. Skeptic review established the limit of that guarantee and it is recorded here on purpose: the second signal comes from the INITIATING user, so it does not stop a store admin from approving a link someone else generated (wc-auth delivers keys server-to-server and identifies no approver); that needs a proof of store control from the initiator, tracked as a follow-up. Rejected the proposed separate pending-attempt table and handshake token: the pending row and oauth_state already are that state. Expiry: 15-minute TTL on the return leg plus a sweep at the start of the nightly orders cron; NOT on the callback leg, because WooCommerce treats any non-200 as a failed handshake (deletes the key, store-side error page, no redirect) and would strand slow approvals. Prod audit before the migration found no active row still carrying a state; one hand-inserted demo row (active, no keys, sync off) is parked as error by the migration because it could never sync. Returning the browser to the initiating brand origin is a separate change.
|
||||
[2026-09-07] Björn Lundén connect: Lundify activation redirect reuses the OAuth state row (provider_otc) and the callback, with the User-Key arriving as publicKey and the state as extra; the manual User-Key field stays as a folded fallback: BL issued the activation key on 2026-09-07, the redirect removes the GUID copy-paste that failed every real customer, and reusing the atomic state consumption plus initiator binding keeps the same tenant boundary as Fortnox/Visma instead of a second callback with its own checks. BL's ON/OFF backend callback and settings URL are not built: they need a registered URL at BL and an orgNr-to-company mapping, filed as follow-up.
|
||||
[2026-09-07] Auth-link hosts resolve against the brands table only; NEXT_PUBLIC_WHITELABEL_DOMAINS removed (supersedes 2026-08-18): the env var was a second copy of brands.domain compiled into the browser, so every brand needed four registrations (row, env var, GoTrue allowlist, redeploy) and two partners shipped with it stale (canonical-branded reset mails). Password reset moved to POST /api/auth/password-reset so the server resolves the host; invite, email change and signup share the same resolver, which also trusts this deployment's own VERCEL_URL/VERCEL_BRANCH_URL so previews keep working. A drift check between the copies was rejected: it would be a fifth thing to maintain. GoTrue's redirect allowlist stays as the backstop; hosted carries the wildcards https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/** there (config, not code; GoTrue matches the full URL with query, and * stops at . and /) so only bring-your-own-domain partners need a manual entry. A failed brands lookup refuses with 503 (BrandLookupFailedError) instead of a canonical fallback: a canonical link is a wrong-brand mail for a white-label user, which is the bug this replaces.
|
||||
@@ -1645,4 +1646,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-07] Line breaks in line descriptions are collapsed by every single-line consumer (Peppol cbc:Name, accrual voucher text) and additionally in the SIE writer's quoted-text escaper: SIE is one record per line by spec, so the writer guards the format regardless of where the text came from.
|
||||
[2026-09-07] Enable Banking callbacks return to the initiating white-label host by recording the allowlisted request origin on the pending row and replaying the callback there, instead of extending the provider_otc handoff from PR #2305: the brand host already holds the session, so a /login?next=<callback> bounce on that host forwards straight back into the callback with cookies, needing one nullable column and no encrypted payload, no second table and no cron. The provider redirect URI stays canonical, so nothing changes in the Enable Banking console. Stripe, Gmail and cloud backup have the same shape but no partner-domain users yet; tracked as a follow-up issue rather than built speculatively.
|
||||
[2026-09-07] Stripe checkout and portal return URLs resolve through the existing resolveRequestAppOrigin allowlist (NEXT_PUBLIC_WHITELABEL_DOMAINS), not a DB brand lookup: it is the same trust boundary invites and email-change links already use, so one allowlist governs every host we redirect a browser to. Return paths stay fixed literals; no caller-supplied URL is accepted. Session-expiry and company-switch handling were left alone: the middleware already bounces to /login on the same host with the path preserved, and the webhook keys on company_id metadata.
|
||||
[2026-09-07] PR #2373 review: the Skatteverket callback now peeks the flow row for its initiator and binds the session BEFORE consuming state or handoff (Superagent P2: a DELETE-before-auth let a signed-out or wrong-user arrival burn a live consent). A session-less arrival is sent to /login on the initiating origin and resumes into the same URL; a wrong user is refused with the row left claimable. This reverses the earlier 'no login resume' decision for this flow; the consume stays the atomic gate, the peek only decides who may attempt it. Handoff TTL raised from two to five minutes to fit a sign-in.
|
||||
[2026-09-07] BankID confirmation mail and the Send Email hook resolve their link host through lib/domains/trusted-app-origin instead of the raw forwarded host / GoTrue's redirect_to: the BankID mail is the one auth link GoTrue's redirect allowlist never sees (built here, sent via Resend), and the hook's signature proves the sender, not the destination, while the GoTrue allowlist is a hand-configured glob. Unknown, lookalike, credential-bearing, non-default-port and malformed destinations collapse to the canonical /auth/callback with no next path; a registered brand host over http is upgraded to https. Brand sender identity is resolved from the RESOLVED host so mail branding and link destination always agree. A brands-table read failure refuses (BankID: step resolve_origin, signup rolls back; hook: 500 so Supabase retries) rather than mailing a canonical link to a white-label user. Dropped from the audit's plan 7 as already in place after #2376: signup route, HTTPS enforcement, credential/port checks, recovery/invite/email-change coverage.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// The route is exercised through the extension registration; the connector
|
||||
// seam and the paywall gate are mocked so the test pins ONLY the /authorize
|
||||
// wiring: what is stored, and where the browser is sent.
|
||||
// seam, the paywall gate and the flow store are mocked so the test pins ONLY
|
||||
// the /authorize wiring: what is recorded, and where the browser is sent.
|
||||
const { mockConnectorMode, mockStartAuth } = vi.hoisted(() => ({
|
||||
mockConnectorMode: vi.fn(),
|
||||
mockStartAuth: vi.fn(),
|
||||
@@ -27,6 +27,28 @@ vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
requireCapability: vi.fn(async () => null),
|
||||
}))
|
||||
|
||||
const { mockCreateFlow, mockPurge, mockResolveOrigin, mockNewId } = vi.hoisted(() => ({
|
||||
mockCreateFlow: vi.fn(),
|
||||
mockPurge: vi.fn(),
|
||||
mockResolveOrigin: vi.fn(),
|
||||
mockNewId: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/auth/oauth-flows', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/oauth-flows')>()
|
||||
return {
|
||||
...actual,
|
||||
createOAuthFlow: mockCreateFlow,
|
||||
purgeExpiredOAuthFlows: mockPurge,
|
||||
resolveOAuthOrigin: mockResolveOrigin,
|
||||
newOAuthFlowId: mockNewId,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(() => ({ tag: 'service' })),
|
||||
}))
|
||||
|
||||
import { skatteverketExtension } from '../index'
|
||||
import { buildAuthorizeUrl } from '../lib/oauth'
|
||||
|
||||
@@ -38,33 +60,21 @@ function authorizeRoute() {
|
||||
return route!
|
||||
}
|
||||
|
||||
function makeCtx() {
|
||||
const stored: Record<string, string> = {}
|
||||
const cleared: string[] = []
|
||||
return {
|
||||
stored,
|
||||
cleared,
|
||||
ctx: {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
supabase: {} as any,
|
||||
settings: {
|
||||
set: vi.fn(async (key: string, value: string) => {
|
||||
stored[key] = value
|
||||
}),
|
||||
clear: vi.fn(async (key: string) => {
|
||||
cleared.push(key)
|
||||
}),
|
||||
get: vi.fn(async () => null),
|
||||
},
|
||||
} as any,
|
||||
}
|
||||
}
|
||||
const ctx = {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
supabase: {} as any,
|
||||
settings: { set: vi.fn(), clear: vi.fn(), get: vi.fn(async () => null) },
|
||||
} as any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://instans.example.se'
|
||||
delete process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL
|
||||
mockNewId.mockReturnValue('state-fixed')
|
||||
mockResolveOrigin.mockResolvedValue('https://instans.example.se')
|
||||
mockPurge.mockResolvedValue(undefined)
|
||||
mockCreateFlow.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('skatteverket /authorize: connector mode', () => {
|
||||
@@ -80,8 +90,7 @@ describe('skatteverket /authorize: connector mode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('starts the consent through the broker and stores its redirect_uri + connector_state', async () => {
|
||||
const { ctx, stored } = makeCtx()
|
||||
it('starts the consent through the broker and records its redirect_uri + connector_state on the flow', async () => {
|
||||
const res = await authorizeRoute().handler(
|
||||
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
||||
ctx,
|
||||
@@ -99,23 +108,33 @@ describe('skatteverket /authorize: connector mode', () => {
|
||||
// The instance's own callback: where the hosted SKV callback bounces
|
||||
// the browser back to.
|
||||
returnUrl: 'https://instans.example.se/api/extensions/ext/skatteverket/callback',
|
||||
state: stored.oauth_state,
|
||||
state: 'state-fixed',
|
||||
codeChallenge: 'pkce-c',
|
||||
},
|
||||
)
|
||||
// The BROKER's redirect_uri (what SKV saw) is what the token exchange
|
||||
// must repeat, so it replaces the locally computed one.
|
||||
expect(stored.oauth_redirect_uri).toBe(
|
||||
'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
||||
expect(mockCreateFlow).toHaveBeenCalledWith(
|
||||
{ tag: 'service' },
|
||||
{
|
||||
id: 'state-fixed',
|
||||
kind: 'skatteverket',
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
origin: 'https://instans.example.se',
|
||||
redirectUri: 'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
||||
codeVerifier: 'pkce-v',
|
||||
connectorState: 'signed-cs',
|
||||
returnTo: null,
|
||||
},
|
||||
)
|
||||
expect(stored.oauth_connector_state).toBe('signed-cs')
|
||||
expect(stored.oauth_code_verifier).toBe('pkce-v')
|
||||
expect(buildAuthorizeUrl).not.toHaveBeenCalled()
|
||||
// Nothing goes through extension settings any more.
|
||||
expect(ctx.settings.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 502 with operator guidance when the broker refuses, storing no flow state', async () => {
|
||||
it('answers 502 with operator guidance when the broker refuses, recording no flow', async () => {
|
||||
mockStartAuth.mockRejectedValueOnce(new Error('Connector authorize-url failed (403): quota'))
|
||||
const { ctx, stored } = makeCtx()
|
||||
const res = await authorizeRoute().handler(
|
||||
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
||||
ctx,
|
||||
@@ -124,27 +143,65 @@ describe('skatteverket /authorize: connector mode', () => {
|
||||
expect(res.status).toBe(502)
|
||||
const body = await (res as Response).json()
|
||||
expect(body.error).toMatch(/GNUBOK_CONNECTOR_KEY/)
|
||||
expect(Object.keys(stored)).toHaveLength(0)
|
||||
expect(mockCreateFlow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skatteverket /authorize: direct mode', () => {
|
||||
it('builds the authorize URL locally and clears any stale connector state', async () => {
|
||||
beforeEach(() => {
|
||||
mockConnectorMode.mockReturnValue(null)
|
||||
const { ctx, stored, cleared } = makeCtx()
|
||||
})
|
||||
|
||||
it('builds the authorize URL locally and records the validated origin, return path and PKCE verifier', async () => {
|
||||
process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL = 'https://oauth.example'
|
||||
mockResolveOrigin.mockResolvedValue('https://brand.example')
|
||||
|
||||
const res = await authorizeRoute().handler(
|
||||
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
||||
new Request('https://brand.example/api/extensions/ext/skatteverket/authorize?return_to=%2Fsettings%2Ftax'),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(307)
|
||||
expect(res.headers.get('location')).toBe('https://skv.test/authorize?direct=1')
|
||||
expect(mockStartAuth).not.toHaveBeenCalled()
|
||||
expect(stored.oauth_redirect_uri).toBe(
|
||||
'https://instans.example.se/api/extensions/ext/skatteverket/callback',
|
||||
expect(buildAuthorizeUrl).toHaveBeenCalledWith(
|
||||
'https://oauth.example/api/extensions/ext/skatteverket/callback',
|
||||
'state-fixed',
|
||||
{ codeChallenge: 'pkce-c' },
|
||||
)
|
||||
// A row surviving from a connector-era flow must not leak into a direct
|
||||
// exchange.
|
||||
expect(cleared).toContain('oauth_connector_state')
|
||||
expect(mockCreateFlow).toHaveBeenCalledWith(
|
||||
{ tag: 'service' },
|
||||
expect.objectContaining({
|
||||
id: 'state-fixed',
|
||||
origin: 'https://brand.example',
|
||||
redirectUri: 'https://oauth.example/api/extensions/ext/skatteverket/callback',
|
||||
codeVerifier: 'pkce-v',
|
||||
connectorState: null,
|
||||
returnTo: '/settings/tax',
|
||||
}),
|
||||
)
|
||||
// Expired rows are swept on the way in, best-effort.
|
||||
expect(mockPurge).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops a return_to that is not an in-app path', async () => {
|
||||
await authorizeRoute().handler(
|
||||
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize?return_to=//evil.example'),
|
||||
ctx,
|
||||
)
|
||||
expect(mockCreateFlow).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ returnTo: null }),
|
||||
)
|
||||
})
|
||||
|
||||
it('still starts the flow when the purge fails', async () => {
|
||||
mockPurge.mockRejectedValueOnce(new Error('purge boom'))
|
||||
const res = await authorizeRoute().handler(
|
||||
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(307)
|
||||
expect(mockCreateFlow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,76 +36,108 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: mockCreateServiceClient,
|
||||
}))
|
||||
|
||||
// The flow store is exercised in lib/auth/__tests__/oauth-flows.test.ts and
|
||||
// tests/pg/oauth-flows.pg.test.ts; here it is a seam so these tests pin the
|
||||
// callback's routing, binding and delivery only. Host and origin resolution
|
||||
// stay real (with the brands table mocked) because the hop decision is the
|
||||
// thing under test.
|
||||
const {
|
||||
mockPeekState,
|
||||
mockPeekHandoff,
|
||||
mockConsumeState,
|
||||
mockConsumeHandoff,
|
||||
mockMintHandoff,
|
||||
mockResolveBrandByHost,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPeekState: vi.fn(),
|
||||
mockPeekHandoff: vi.fn(),
|
||||
mockConsumeState: vi.fn(),
|
||||
mockConsumeHandoff: vi.fn(),
|
||||
mockMintHandoff: vi.fn(),
|
||||
mockResolveBrandByHost: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/branding/resolve', () => ({
|
||||
resolveBrandByHost: mockResolveBrandByHost,
|
||||
// requireFlowInitiator's login redirect (unused by this callback, which
|
||||
// answers its own error page) resolves hosts through the same table.
|
||||
resolveBrandResultByHost: async (host: string) => ({
|
||||
brand: await mockResolveBrandByHost(host),
|
||||
lookupFailed: false,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/auth/oauth-flows', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/oauth-flows')>()
|
||||
return {
|
||||
...actual,
|
||||
peekOAuthFlowState: mockPeekState,
|
||||
peekOAuthFlowHandoff: mockPeekHandoff,
|
||||
consumeOAuthFlowState: mockConsumeState,
|
||||
consumeOAuthFlowHandoff: mockConsumeHandoff,
|
||||
mintOAuthFlowHandoff: mockMintHandoff,
|
||||
createOAuthFlow: vi.fn(),
|
||||
purgeExpiredOAuthFlows: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { after } from 'next/server'
|
||||
import { skatteverketExtension } from '../index'
|
||||
import { exchangeCodeForTokens } from '../lib/oauth'
|
||||
import { storeTokens } from '../lib/token-store'
|
||||
import { runPostConnectRefresh } from '../lib/post-connect-refresh'
|
||||
import type { OAuthFlow, OAuthFlowHandoff } from '@/lib/auth/oauth-flows'
|
||||
|
||||
const mockExchange = vi.mocked(exchangeCodeForTokens)
|
||||
const mockStoreTokens = vi.mocked(storeTokens)
|
||||
const mockRefresh = vi.mocked(runPostConnectRefresh)
|
||||
|
||||
const STATE = 'state-1'
|
||||
const HANDOFF = 'handoff-1'
|
||||
const OAUTH_HOST = 'https://oauth.example'
|
||||
const APP = 'https://app.example'
|
||||
const BRAND = 'https://brand.example'
|
||||
|
||||
/**
|
||||
* Service-client mock covering the callback's extension_data access:
|
||||
* awaiting the query chain directly returns the oauth_state row listing
|
||||
* (the company resolution), .maybeSingle() returns the per-key setting for
|
||||
* whichever key the chain last filtered on, and the post-exchange cleanup
|
||||
* delete resolves via .in().
|
||||
*/
|
||||
function makeServiceSupabase(
|
||||
overrides: Record<string, string | null> = {},
|
||||
options: { isMember?: boolean } = {},
|
||||
) {
|
||||
const values: Record<string, string | null> = {
|
||||
oauth_state: STATE,
|
||||
oauth_user_id: 'user-1',
|
||||
oauth_redirect_uri: 'https://app.example/api/extensions/ext/skatteverket/callback',
|
||||
oauth_code_verifier: 'verifier-1',
|
||||
oauth_return_to: '/settings/tax',
|
||||
function flowOn(origin: string, overrides: Partial<OAuthFlow> = {}): OAuthFlow {
|
||||
return {
|
||||
id: STATE,
|
||||
kind: 'skatteverket',
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
origin,
|
||||
redirectUri: `${OAUTH_HOST}/api/extensions/ext/skatteverket/callback`,
|
||||
codeVerifier: 'verifier-1',
|
||||
connectorState: null,
|
||||
returnTo: '/settings/tax',
|
||||
...overrides,
|
||||
}
|
||||
const isMember = options.isMember ?? true
|
||||
const gte = vi.fn()
|
||||
const inCalls: string[][] = []
|
||||
const from = vi.fn((table: string) => {
|
||||
let key: string | null = null
|
||||
const chain: any = {
|
||||
select: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
eq: vi.fn((col: string, val: string) => {
|
||||
if (col === 'key') key = val
|
||||
return chain
|
||||
}),
|
||||
gte: gte.mockImplementation(() => chain),
|
||||
in: vi.fn((_col: string, keys: string[]) => {
|
||||
inCalls.push(keys)
|
||||
return Promise.resolve({ error: null })
|
||||
}),
|
||||
maybeSingle: vi.fn(async () => {
|
||||
if (table === 'company_members') {
|
||||
return { data: isMember ? { user_id: values.oauth_user_id ?? 'user-1' } : null }
|
||||
}
|
||||
return {
|
||||
data: key !== null && values[key] != null ? { value: values[key] } : null,
|
||||
}
|
||||
}),
|
||||
then: (resolve: any, reject: any) => {
|
||||
const rows =
|
||||
values.oauth_state != null
|
||||
? [{ company_id: 'company-1', value: values.oauth_state }]
|
||||
: []
|
||||
return Promise.resolve({ data: rows }).then(resolve, reject)
|
||||
},
|
||||
}
|
||||
return chain
|
||||
})
|
||||
return { from, gte, inCalls }
|
||||
}
|
||||
|
||||
/** Cookie-bound client: only consulted by the legacy session fallback. */
|
||||
function handoffOn(origin: string, overrides: Partial<OAuthFlowHandoff> = {}): OAuthFlowHandoff {
|
||||
return { ...flowOn(origin), providerCode: 'abc', providerError: null, ...overrides }
|
||||
}
|
||||
|
||||
/** A live state row: peek sees its identity, consume returns the flow. */
|
||||
function stateIs(flow: OAuthFlow) {
|
||||
mockPeekState.mockResolvedValue({ userId: flow.userId, companyId: flow.companyId, origin: flow.origin })
|
||||
mockConsumeState.mockResolvedValue(flow)
|
||||
}
|
||||
|
||||
/** A live handoff row: peek sees its identity, consume returns the result. */
|
||||
function handoffIs(handoff: OAuthFlowHandoff) {
|
||||
mockPeekHandoff.mockResolvedValue({ userId: handoff.userId, companyId: handoff.companyId, origin: handoff.origin })
|
||||
mockConsumeHandoff.mockResolvedValue(handoff)
|
||||
}
|
||||
|
||||
/** Service client: only the membership check runs through it here. */
|
||||
function makeServiceSupabase(options: { isMember?: boolean } = {}) {
|
||||
const isMember = options.isMember ?? true
|
||||
const chain: any = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn(async () => ({ data: isMember ? { user_id: 'user-1' } : null })),
|
||||
}
|
||||
return { from: vi.fn(() => chain) }
|
||||
}
|
||||
|
||||
function makeCookieClient(userId: string | null) {
|
||||
return {
|
||||
auth: {
|
||||
@@ -123,26 +155,49 @@ function callbackRoute() {
|
||||
return route!
|
||||
}
|
||||
|
||||
function callbackRequest(params: string) {
|
||||
return new Request(
|
||||
`https://app.example/api/extensions/ext/skatteverket/callback?${params}`,
|
||||
)
|
||||
function callbackRequest(origin: string, params: string) {
|
||||
return new Request(`${origin}/api/extensions/ext/skatteverket/callback?${params}`)
|
||||
}
|
||||
|
||||
async function expectErrorPage(
|
||||
response: Response,
|
||||
containing?: string,
|
||||
options: { closesTab?: boolean } = {},
|
||||
) {
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-error')
|
||||
// Once the flow is known the message reaches the opener for certain, so
|
||||
// the tab stays open and the reason stays diagnosable. Before the flow is
|
||||
// known the target is a guess: the tab closes so a brand opener that never
|
||||
// hears the message is still reset by its closed-tab watcher.
|
||||
if (options.closesTab) expect(html).toContain('window.close()')
|
||||
else expect(html).not.toContain('window.close()')
|
||||
if (containing) expect(html).toContain(containing)
|
||||
return html
|
||||
}
|
||||
|
||||
describe('skatteverket OAuth callback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Hosted shape: the OAuth redirect_uri is pinned to a different host than
|
||||
// the app (app.gnubok.se vs app.accounted.se), so the callback cannot
|
||||
// expect the app's session cookies. The same-origin tests below override.
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example')
|
||||
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://oauth.example')
|
||||
// the app (app.gnubok.se vs app.accounted.se), so hop 1 lands on a host
|
||||
// that never carries the app's session cookies.
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP)
|
||||
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', OAUTH_HOST)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockCreateServiceClient.mockReturnValue(makeServiceSupabase() as any)
|
||||
// No session cookies by default: the callback is served on the pinned
|
||||
// OAuth host (app.gnubok.se), where the user-facing app's session does
|
||||
// not exist. Every happy-path test doubles as a cookie-free proof.
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient(null) as any)
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient('user-1') as any)
|
||||
mockResolveBrandByHost.mockImplementation(async (host: string) =>
|
||||
host === 'brand.example' ? { domain: 'brand.example' } : null,
|
||||
)
|
||||
mockPeekState.mockResolvedValue(null)
|
||||
mockPeekHandoff.mockResolvedValue(null)
|
||||
mockConsumeState.mockResolvedValue(null)
|
||||
mockConsumeHandoff.mockResolvedValue(null)
|
||||
mockMintHandoff.mockResolvedValue('handoff-minted')
|
||||
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
|
||||
mockExchange.mockResolvedValue({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
@@ -157,231 +212,202 @@ describe('skatteverket OAuth callback', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('responds with the success page WITHOUT awaiting the post-connect refresh', async () => {
|
||||
// A refresh that never settles: if the handler regressed to awaiting it,
|
||||
// this test would hang into the vitest timeout instead of passing.
|
||||
let refreshStarted = false
|
||||
mockRefresh.mockImplementation(() => {
|
||||
refreshStarted = true
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-success')
|
||||
expect(html).toContain('window.close()')
|
||||
|
||||
expect(mockExchange).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
'https://app.example/api/extensions/ext/skatteverket/callback',
|
||||
'verifier-1',
|
||||
undefined,
|
||||
)
|
||||
expect(mockStoreTokens).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.objectContaining({ access_token: 'at' }),
|
||||
'company-1',
|
||||
)
|
||||
// The stored oauth_user_id resolved the user. The cookie session is
|
||||
// consulted only to catch a DIFFERENT signed-in user; on the pinned OAuth
|
||||
// host it is empty, and that must not block the flow.
|
||||
expect(mockCreateClient).toHaveBeenCalledTimes(1)
|
||||
// The state lookup must be recency-bounded: an old state row (leaked or
|
||||
// phished authorize URL) must not stay completable indefinitely.
|
||||
const service = mockCreateServiceClient.mock.results[0]!.value
|
||||
expect(service.gte).toHaveBeenCalledWith('updated_at', expect.any(String))
|
||||
// The refresh was started eagerly and handed to after() so it survives
|
||||
// past the response; it must not gate the response itself.
|
||||
expect(refreshStarted).toBe(true)
|
||||
expect(vi.mocked(after)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still succeeds when after() is unavailable (outside a request scope)', async () => {
|
||||
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 })
|
||||
vi.mocked(after).mockImplementation(() => {
|
||||
throw new Error('after called outside request scope')
|
||||
})
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('skatteverket-oauth-success')
|
||||
})
|
||||
|
||||
it('falls back to the session cookie for flows started before oauth_user_id shipped', async () => {
|
||||
mockCreateServiceClient.mockReturnValue(
|
||||
makeServiceSupabase({ oauth_user_id: null }) as any,
|
||||
)
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient('legacy-user') as any)
|
||||
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 })
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('skatteverket-oauth-success')
|
||||
expect(mockStoreTokens).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'legacy-user',
|
||||
expect.objectContaining({ access_token: 'at' }),
|
||||
'company-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the error page when neither a stored user id nor a session exists', async () => {
|
||||
mockCreateServiceClient.mockReturnValue(
|
||||
makeServiceSupabase({ oauth_user_id: null }) as any,
|
||||
)
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-error')
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
expect(mockRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the error page on a state (CSRF) mismatch without exchanging the code', async () => {
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest('code=abc&state=wrong-state'),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-error')
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
expect(mockRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the error page when the token exchange fails', async () => {
|
||||
mockExchange.mockRejectedValueOnce(new Error('exchange boom'))
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-error')
|
||||
expect(mockRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up the ephemeral state rows (incl. oauth_user_id) when the exchange fails', async () => {
|
||||
const service = makeServiceSupabase()
|
||||
mockCreateServiceClient.mockReturnValue(service as any)
|
||||
mockExchange.mockRejectedValueOnce(new Error('exchange boom'))
|
||||
|
||||
await callbackRoute().handler(callbackRequest(`code=abc&state=${STATE}`))
|
||||
|
||||
// The failure path must delete the same ephemeral keys the success
|
||||
// path does: oauth_user_id holds a user identity and must not be
|
||||
// retained past the flow. (#1090)
|
||||
expect(service.inCalls).toHaveLength(1)
|
||||
expect(service.inCalls[0]).toEqual(
|
||||
expect.arrayContaining(['oauth_state', 'oauth_user_id', 'oauth_code_verifier']),
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a stored connector_state into the exchange and cleans it up (connector-mode instance)', async () => {
|
||||
const service = makeServiceSupabase({ oauth_connector_state: 'signed-cs' })
|
||||
mockCreateServiceClient.mockReturnValue(service as any)
|
||||
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockExchange).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
'https://app.example/api/extensions/ext/skatteverket/callback',
|
||||
'verifier-1',
|
||||
'signed-cs',
|
||||
)
|
||||
// The one-shot connector state must be deleted with the other flow rows.
|
||||
expect(service.inCalls[0]).toEqual(expect.arrayContaining(['oauth_connector_state']))
|
||||
})
|
||||
|
||||
it('falls back to the bounced connector_state query param when no row was stored', async () => {
|
||||
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}&connector_state=bounced-cs`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockExchange).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
'https://app.example/api/extensions/ext/skatteverket/callback',
|
||||
'verifier-1',
|
||||
'bounced-cs',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects the flow when the stored user is no longer a member of the company', async () => {
|
||||
mockCreateServiceClient.mockReturnValue(
|
||||
makeServiceSupabase({}, { isMember: false }) as any,
|
||||
)
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-error')
|
||||
// Rejected before the exchange so the one-shot code is not burned,
|
||||
// and no token write is attempted. (#1091)
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
expect(mockStoreTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The state row names who started the flow; the tokens are stored for that
|
||||
// user with the service client. The browser finishing the flow must be that
|
||||
// user whenever a session can be read at all.
|
||||
describe('binding the completion to the initiator', () => {
|
||||
it('finalises when the signed-in user is the one who started the flow', async () => {
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient('user-1') as any)
|
||||
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
|
||||
describe('hop 1 on the registered OAuth host', () => {
|
||||
it('consumes the state, stashes the code and redirects to the initiating brand origin', async () => {
|
||||
stateIs(flowOn(BRAND))
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
callbackRequest(OAUTH_HOST, `code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
const location = new URL(response.headers.get('location') as string)
|
||||
expect(location.origin).toBe(BRAND)
|
||||
expect(location.pathname).toBe('/api/extensions/ext/skatteverket/callback')
|
||||
expect(location.searchParams.get('handoff')).toBe('handoff-minted')
|
||||
// Provider credentials never enter the hop-2 URL.
|
||||
expect(location.searchParams.has('code')).toBe(false)
|
||||
expect(location.searchParams.has('state')).toBe(false)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
|
||||
expect(mockConsumeState).toHaveBeenCalledWith(expect.anything(), STATE, 'skatteverket')
|
||||
expect(mockMintHandoff).toHaveBeenCalledWith(expect.anything(), flowOn(BRAND), { providerCode: 'abc' })
|
||||
// No session can exist here, so none is read, and nothing is exchanged.
|
||||
expect(mockCreateClient).not.toHaveBeenCalled()
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
expect(mockStoreTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands a plain app user to the app origin the same way', async () => {
|
||||
stateIs(flowOn(APP))
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(OAUTH_HOST, `code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
expect(new URL(response.headers.get('location') as string).origin).toBe(APP)
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('carries a provider denial through the handoff instead of answering on the wrong origin', async () => {
|
||||
stateIs(flowOn(BRAND))
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(OAUTH_HOST, `error=access_denied&error_description=Avbrutet&state=${STATE}`),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(302)
|
||||
expect(new URL(response.headers.get('location') as string).origin).toBe(BRAND)
|
||||
expect(mockMintHandoff).toHaveBeenCalledWith(expect.anything(), flowOn(BRAND), { providerError: 'Avbrutet' })
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers the callback page, not a framework error, when the handoff cannot be minted', async () => {
|
||||
stateIs(flowOn(BRAND))
|
||||
mockMintHandoff.mockRejectedValueOnce(new Error('Failed to mint OAuth handoff: boom'))
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(OAUTH_HOST, `code=abc&state=${STATE}`),
|
||||
)
|
||||
|
||||
// The flow is known by now, so the page targets the initiating origin
|
||||
// and stays open with the reason on screen.
|
||||
const html = await expectErrorPage(response, 'tekniskt fel')
|
||||
expect(html).toContain(JSON.stringify(BRAND))
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers the error page for an unknown, expired or replayed state without minting anything', async () => {
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(OAUTH_HOST, 'code=abc&state=wrong-state'),
|
||||
)
|
||||
|
||||
const html = await expectErrorPage(response, 'ogiltig eller förbrukad state', { closesTab: true })
|
||||
// Nothing better than the canonical app origin is known for a rejected
|
||||
// state; the request's own host is never trusted.
|
||||
expect(html).toContain(JSON.stringify(APP))
|
||||
expect(mockConsumeState).not.toHaveBeenCalled()
|
||||
expect(mockMintHandoff).not.toHaveBeenCalled()
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers the error page when neither code nor error nor state is present', async () => {
|
||||
const response = await callbackRoute().handler(callbackRequest(OAUTH_HOST, 'state=only'))
|
||||
await expectErrorPage(response, 'Saknar auktoriseringskod', { closesTab: true })
|
||||
expect(mockConsumeState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hop 2 on the initiating origin', () => {
|
||||
it('binds to the initiator, then claims the handoff for this origin and exchanges the stashed code', async () => {
|
||||
handoffIs(handoffOn(BRAND, { connectorState: 'signed-cs' }))
|
||||
// A refresh that never settles: if the handler regressed to awaiting
|
||||
// it, this test would hang into the vitest timeout instead of passing.
|
||||
let refreshStarted = false
|
||||
mockRefresh.mockImplementation(() => {
|
||||
refreshStarted = true
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('skatteverket-oauth-success')
|
||||
expect(mockExchange).toHaveBeenCalledTimes(1)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-success')
|
||||
expect(html).toContain('window.close()')
|
||||
// Both the postMessage target and the no-opener fallback are the
|
||||
// origin the flow started on, so the opener tab actually hears it.
|
||||
expect(html).toContain(`postMessage({ type: 'skatteverket-oauth-success' }, ${JSON.stringify(BRAND)})`)
|
||||
expect(html).toContain(JSON.stringify(`${BRAND}/settings/tax?skv_connected=true`))
|
||||
expect(html).not.toContain(APP)
|
||||
|
||||
expect(mockPeekHandoff).toHaveBeenCalledWith(expect.anything(), HANDOFF, BRAND, 'skatteverket')
|
||||
expect(mockConsumeHandoff).toHaveBeenCalledWith(expect.anything(), HANDOFF, BRAND, 'skatteverket')
|
||||
// The identity check ran before the row was spent.
|
||||
expect(mockCreateClient.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockConsumeHandoff.mock.invocationCallOrder[0]!,
|
||||
)
|
||||
// The exchange repeats what SKV saw: the registered redirect_uri, the
|
||||
// PKCE verifier and the connector state, all from the row.
|
||||
expect(mockExchange).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
`${OAUTH_HOST}/api/extensions/ext/skatteverket/callback`,
|
||||
'verifier-1',
|
||||
'signed-cs',
|
||||
)
|
||||
expect(mockStoreTokens).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.objectContaining({ access_token: 'at' }),
|
||||
'company-1',
|
||||
)
|
||||
expect(refreshStarted).toBe(true)
|
||||
expect(vi.mocked(after)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses a completion by a different signed-in user, on any host', async () => {
|
||||
// The victim (user-2) was lured into approving user-1's consent.
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient('user-2') as any)
|
||||
it('still succeeds when after() is unavailable (outside a request scope)', async () => {
|
||||
handoffIs(handoffOn(BRAND))
|
||||
vi.mocked(after).mockImplementation(() => {
|
||||
throw new Error('after called outside request scope')
|
||||
})
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('skatteverket-oauth-error')
|
||||
expect(html).toContain('annat användarkonto')
|
||||
expect(await response.text()).toContain('skatteverket-oauth-success')
|
||||
})
|
||||
|
||||
it('shows the provider denial on the initiating origin', async () => {
|
||||
handoffIs(handoffOn(BRAND, { providerCode: null, providerError: 'Avbrutet' }))
|
||||
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
const html = await expectErrorPage(response, 'Avbrutet')
|
||||
expect(html).toContain(JSON.stringify(BRAND))
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an unknown, expired, replayed or wrong-origin handoff', async () => {
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
await expectErrorPage(response, 'ogiltig eller förbrukad state', { closesTab: true })
|
||||
expect(mockCreateClient).not.toHaveBeenCalled()
|
||||
expect(mockConsumeHandoff).not.toHaveBeenCalled()
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends a session-less arrival to login on the initiating origin, leaving the handoff claimable', async () => {
|
||||
handoffIs(handoffOn(BRAND))
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient(null) as any)
|
||||
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = new URL(response.headers.get('location') as string)
|
||||
expect(location.origin).toBe(BRAND)
|
||||
expect(location.pathname).toBe('/login')
|
||||
// Signing in re-runs this exact URL: the row was only peeked, not spent.
|
||||
expect(location.searchParams.get('next')).toBe(
|
||||
`/api/extensions/ext/skatteverket/callback?handoff=${HANDOFF}`,
|
||||
)
|
||||
expect(mockConsumeHandoff).not.toHaveBeenCalled()
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
expect(mockStoreTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a different signed-in user without burning the flow for its initiator', async () => {
|
||||
// The victim (user-2) was lured into approving user-1's consent.
|
||||
handoffIs(handoffOn(BRAND))
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient('user-2') as any)
|
||||
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
const html = await expectErrorPage(response, 'annat användarkonto')
|
||||
// Delivered to the initiating origin the row names, and the row is not
|
||||
// consumed: user-1 can still finish, user-2 cannot burn it.
|
||||
expect(html).toContain(JSON.stringify(BRAND))
|
||||
expect(mockConsumeHandoff).not.toHaveBeenCalled()
|
||||
// Refused before the exchange: the one-shot code is not burned and no
|
||||
// token is written under the initiator's id.
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
@@ -389,38 +415,126 @@ describe('skatteverket OAuth callback', () => {
|
||||
expect(mockRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends a session-less completion to login when the OAuth host IS the app host', async () => {
|
||||
// Self-hosted shape (or the hosted pin removed): the initiator's cookies
|
||||
// do arrive here, so an empty session means someone else is finishing it.
|
||||
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://app.example')
|
||||
it('answers the error page when the handoff is claimed by a concurrent delivery after the identity check', async () => {
|
||||
mockPeekHandoff.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', origin: BRAND })
|
||||
mockConsumeHandoff.mockResolvedValue(null)
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = new URL(response.headers.get('location') as string)
|
||||
expect(location.origin).toBe('https://app.example')
|
||||
expect(location.pathname).toBe('/login')
|
||||
// The state row is untouched until the exchange, so signing in and
|
||||
// re-running this exact callback completes the flow for its initiator.
|
||||
expect(location.searchParams.get('next')).toBe(
|
||||
`/api/extensions/ext/skatteverket/callback?code=abc&state=${STATE}`,
|
||||
)
|
||||
await expectErrorPage(response, 'ogiltig eller förbrukad state', { closesTab: true })
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a revoked member before the handoff is spent', async () => {
|
||||
handoffIs(handoffOn(BRAND))
|
||||
mockCreateServiceClient.mockReturnValue(makeServiceSupabase({ isMember: false }) as any)
|
||||
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
await expectErrorPage(response, 'Behörighet saknas')
|
||||
// Rejected before the consume and the exchange: neither the handoff
|
||||
// nor the one-shot code is burned. (#1091)
|
||||
expect(mockConsumeHandoff).not.toHaveBeenCalled()
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
expect(mockStoreTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps tolerating a missing session on the pinned OAuth host (no cookies can arrive)', async () => {
|
||||
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
|
||||
it('answers the error page on the initiating origin when the token exchange fails', async () => {
|
||||
handoffIs(handoffOn(BRAND))
|
||||
mockExchange.mockRejectedValueOnce(new Error('exchange boom'))
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(`code=abc&state=${STATE}`),
|
||||
)
|
||||
const response = await callbackRoute().handler(callbackRequest(BRAND, `handoff=${HANDOFF}`))
|
||||
|
||||
const html = await expectErrorPage(response, 'exchange boom')
|
||||
expect(html).toContain(JSON.stringify(`${BRAND}/settings/tax?skv_error=exchange%20boom`))
|
||||
expect(mockRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('single hop when the callback host is the app host (self-hosted)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', APP)
|
||||
})
|
||||
|
||||
it('binds and exchanges directly without a handoff', async () => {
|
||||
stateIs(flowOn(APP, { redirectUri: `${APP}/api/extensions/ext/skatteverket/callback` }))
|
||||
|
||||
const response = await callbackRoute().handler(callbackRequest(APP, `code=abc&state=${STATE}`))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('skatteverket-oauth-success')
|
||||
expect(mockExchange).toHaveBeenCalledTimes(1)
|
||||
expect(mockMintHandoff).not.toHaveBeenCalled()
|
||||
expect(mockExchange).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
`${APP}/api/extensions/ext/skatteverket/callback`,
|
||||
'verifier-1',
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the provider denial directly', async () => {
|
||||
stateIs(flowOn(APP))
|
||||
|
||||
const response = await callbackRoute().handler(
|
||||
callbackRequest(APP, `error=access_denied&error_description=Avbrutet&state=${STATE}`),
|
||||
)
|
||||
|
||||
await expectErrorPage(response, 'Avbrutet')
|
||||
expect(mockMintHandoff).not.toHaveBeenCalled()
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finishes behind a proxy that rewrites Host and drops x-forwarded-proto', async () => {
|
||||
// nginx defaults: Next sees http://127.0.0.1:3000 on every hop while
|
||||
// /authorize recorded the configured https app origin. Hop 1 compares
|
||||
// hosts (mismatch, so it hands off to the public origin) and hop 2
|
||||
// resolves the internal host back to the app origin for the claim.
|
||||
stateIs(flowOn(APP))
|
||||
const hop1 = await callbackRoute().handler(callbackRequest('http://127.0.0.1:3000', `code=abc&state=${STATE}`))
|
||||
expect(hop1.status).toBe(302)
|
||||
expect(new URL(hop1.headers.get('location') as string).origin).toBe(APP)
|
||||
|
||||
handoffIs(handoffOn(APP))
|
||||
const hop2 = await callbackRoute().handler(callbackRequest('http://127.0.0.1:3000', `handoff=${HANDOFF}`))
|
||||
expect(hop2.status).toBe(200)
|
||||
expect(await hop2.text()).toContain('skatteverket-oauth-success')
|
||||
expect(mockConsumeHandoff).toHaveBeenCalledWith(expect.anything(), HANDOFF, APP, 'skatteverket')
|
||||
})
|
||||
|
||||
it('treats a proxy-reported http scheme on the app host as the same hop', async () => {
|
||||
stateIs(flowOn(APP))
|
||||
const response = await callbackRoute().handler(callbackRequest('http://app.example', `code=abc&state=${STATE}`))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toContain('skatteverket-oauth-success')
|
||||
expect(mockMintHandoff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends a session-less completion to login before spending the state', async () => {
|
||||
stateIs(flowOn(APP))
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient(null) as any)
|
||||
|
||||
const response = await callbackRoute().handler(callbackRequest(APP, `code=abc&state=${STATE}`))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = new URL(response.headers.get('location') as string)
|
||||
expect(location.origin).toBe(APP)
|
||||
expect(location.pathname).toBe('/login')
|
||||
expect(location.searchParams.get('next')).toBe(
|
||||
`/api/extensions/ext/skatteverket/callback?code=abc&state=${STATE}`,
|
||||
)
|
||||
expect(mockConsumeState).not.toHaveBeenCalled()
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a different signed-in user here too, leaving the state for its initiator', async () => {
|
||||
stateIs(flowOn(APP))
|
||||
mockCreateClient.mockResolvedValue(makeCookieClient('user-2') as any)
|
||||
|
||||
const response = await callbackRoute().handler(callbackRequest(APP, `code=abc&state=${STATE}`))
|
||||
|
||||
await expectErrorPage(response, 'annat användarkonto')
|
||||
expect(mockConsumeState).not.toHaveBeenCalled()
|
||||
expect(mockExchange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -431,7 +545,7 @@ describe('skatteverket OAuth callback', () => {
|
||||
describe('skatteverket OAuth callback: connector branch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.example'
|
||||
process.env.NEXT_PUBLIC_APP_URL = APP
|
||||
process.env.CONNECTOR_STATE_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
@@ -439,7 +553,7 @@ describe('skatteverket OAuth callback: connector branch', () => {
|
||||
const { signConnectorState } = await import('@/lib/connect/hosted/state')
|
||||
const signed = signConnectorState({ kid: 'k1', svc: 'skv', ret: 'https://bokforing.example.se/skv/cb', st: 'inst-state', cref: 'company-1' })
|
||||
const route = callbackRoute()
|
||||
const res = await route.handler(callbackRequest(`code=auth-code&state=${encodeURIComponent(signed)}`))
|
||||
const res = await route.handler(callbackRequest(APP, `code=auth-code&state=${encodeURIComponent(signed)}`))
|
||||
expect(res.status).toBe(307)
|
||||
const loc = new URL(res.headers.get('location') as string)
|
||||
expect(loc.origin + loc.pathname).toBe('https://bokforing.example.se/skv/cb')
|
||||
@@ -447,16 +561,17 @@ describe('skatteverket OAuth callback: connector branch', () => {
|
||||
expect(loc.searchParams.get('state')).toBe('inst-state')
|
||||
expect(loc.searchParams.get('connector_state')).toBe(signed)
|
||||
expect(exchangeCodeForTokens).not.toHaveBeenCalled()
|
||||
expect(mockPeekState).not.toHaveBeenCalled()
|
||||
expect(mockConsumeState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a connector state for the wrong service', async () => {
|
||||
const { signConnectorState } = await import('@/lib/connect/hosted/state')
|
||||
const signed = signConnectorState({ kid: 'k1', svc: 'bank', ret: 'https://bokforing.example.se/cb', st: 's', cref: 'c' })
|
||||
const route = callbackRoute()
|
||||
const res = await route.handler(callbackRequest(`code=c&state=${encodeURIComponent(signed)}`))
|
||||
const res = await route.handler(callbackRequest(APP, `code=c&state=${encodeURIComponent(signed)}`))
|
||||
expect(res.status).toBe(307)
|
||||
expect(new URL(res.headers.get('location') as string).searchParams.get('connector_error')).toBe('wrong_service')
|
||||
expect(exchangeCodeForTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,19 @@ import {
|
||||
requireFlowInitiator,
|
||||
FLOW_INITIATOR_MISMATCH_MESSAGE,
|
||||
} from '@/lib/auth/oauth-flow-binding'
|
||||
import {
|
||||
consumeOAuthFlowHandoff,
|
||||
consumeOAuthFlowState,
|
||||
createOAuthFlow,
|
||||
mintOAuthFlowHandoff,
|
||||
newOAuthFlowId,
|
||||
peekOAuthFlowHandoff,
|
||||
peekOAuthFlowState,
|
||||
purgeExpiredOAuthFlows,
|
||||
requestMatchesOrigin,
|
||||
resolveOAuthOrigin,
|
||||
type OAuthFlow,
|
||||
} from '@/lib/auth/oauth-flows'
|
||||
import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store'
|
||||
import { skvRequest, skvRequestWithAuth, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
|
||||
import { writeSkatteverketAudit } from './lib/audit'
|
||||
@@ -84,6 +97,18 @@ import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('skatteverket')
|
||||
|
||||
// Every state or handoff failure on the unauthenticated callback gets this
|
||||
// one message: unknown, forged, expired, replayed, wrong origin. Telling the
|
||||
// caller which one would make the route an oracle for live flows.
|
||||
const SKV_STATE_REJECTED_MESSAGE =
|
||||
'Anslutningen kunde inte verifieras (ogiltig eller förbrukad state). Stäng fliken och försök ansluta igen.'
|
||||
|
||||
// Answered only when the session vanished between the identity check and
|
||||
// the consume (a race, not the normal path: a session-less arrival is sent
|
||||
// to login BEFORE the flow is spent and resumes from there).
|
||||
const SKV_SESSION_MISSING_MESSAGE =
|
||||
'Sessionen har gått ut. Stäng fliken, logga in och försök ansluta igen.'
|
||||
|
||||
// Body for POST /skattekonto/transaktioner/bokfor-batch. Capped at 200 ids:
|
||||
// a full year of skattekonto events fits comfortably, and the sequential
|
||||
// draft+commit loop stays well inside the dispatcher's time budget.
|
||||
@@ -312,7 +337,9 @@ export const skatteverketExtension: Extension = {
|
||||
apiRoutes: [
|
||||
// ── OAuth: Start authorization ──────────────────────────────────
|
||||
// Builds the Skatteverket OAuth2 authorize URL and redirects the user
|
||||
// to BankID login. Stores state token in extension settings for CSRF validation.
|
||||
// to BankID login. The flow is recorded as one oauth_flows row keyed by
|
||||
// the state (lib/auth/oauth-flows.ts): who started it, on which origin,
|
||||
// and what the token exchange must repeat.
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/authorize',
|
||||
@@ -323,7 +350,7 @@ export const skatteverketExtension: Extension = {
|
||||
const blocked = await requireSkvCapability(ctx)
|
||||
if (blocked) return blocked
|
||||
|
||||
const state = crypto.randomUUID()
|
||||
const state = newOAuthFlowId()
|
||||
let redirectUri = `${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
|
||||
|
||||
// Optional: where to send the user after the BankID round-trip.
|
||||
@@ -335,6 +362,11 @@ export const skatteverketExtension: Extension = {
|
||||
? requestedReturn
|
||||
: null
|
||||
|
||||
// The origin the user is on (app or a validated white-label brand
|
||||
// domain). The callback finishes the flow there: that is where the
|
||||
// opener tab lives and where the session cookies are.
|
||||
const origin = await resolveOAuthOrigin(request)
|
||||
|
||||
// Generate PKCE pair: verifier persisted server-side, challenge sent
|
||||
// to SKV. Some SKV per-flow client configurations issue revoked-on-use
|
||||
// tokens unless PKCE is present, so we always send it.
|
||||
@@ -378,18 +410,26 @@ export const skatteverketExtension: Extension = {
|
||||
})
|
||||
}
|
||||
|
||||
// Store state for CSRF validation in callback. The user id is stored
|
||||
// alongside it because the callback runs on the OAuth host (see
|
||||
// getSkvOauthBaseUrl), where the browser carries no session cookies
|
||||
// once the user-facing app lives on its own domain.
|
||||
await ctx.settings.set('oauth_state', state)
|
||||
await ctx.settings.set('oauth_user_id', ctx.userId)
|
||||
await ctx.settings.set('oauth_redirect_uri', redirectUri)
|
||||
await ctx.settings.set('oauth_code_verifier', pkce.verifier)
|
||||
if (connectorState) await ctx.settings.set('oauth_connector_state', connectorState)
|
||||
else await ctx.settings.clear('oauth_connector_state')
|
||||
if (returnTo) await ctx.settings.set('oauth_return_to', returnTo)
|
||||
else await ctx.settings.clear('oauth_return_to')
|
||||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||
const db = createServiceClient()
|
||||
// Expired rows go here rather than in a cron: the set is tiny and
|
||||
// every connect attempt is a fine moment to sweep it. Best-effort.
|
||||
try {
|
||||
await purgeExpiredOAuthFlows(db)
|
||||
} catch (err) {
|
||||
log.warn('oauth flow purge failed', { error: (err as Error).message })
|
||||
}
|
||||
await createOAuthFlow(db, {
|
||||
id: state,
|
||||
kind: 'skatteverket',
|
||||
companyId: ctx.companyId,
|
||||
userId: ctx.userId,
|
||||
origin,
|
||||
redirectUri,
|
||||
codeVerifier: pkce.verifier,
|
||||
connectorState,
|
||||
returnTo,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(authorizeUrl)
|
||||
},
|
||||
@@ -398,8 +438,9 @@ export const skatteverketExtension: Extension = {
|
||||
// ── OAuth: Callback ─────────────────────────────────────────────
|
||||
// Receives the auth code from Skatteverket after BankID login.
|
||||
// Exchanges code for tokens immediately (5-minute code expiry).
|
||||
// skipAuth: true; browser redirect from Skatteverket. We handle
|
||||
// user identification via the stored state token + Supabase session.
|
||||
// skipAuth: true; browser redirect from Skatteverket. The flow is
|
||||
// resolved from the oauth_flows row the state names, and the completion
|
||||
// is bound to that row's user via the session on the initiating origin.
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/callback',
|
||||
@@ -407,9 +448,13 @@ export const skatteverketExtension: Extension = {
|
||||
handler: async (request: Request) => {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
let code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
const handoffId = url.searchParams.get('handoff')
|
||||
// Where the opener tab lives. The canonical app origin until the flow
|
||||
// row says otherwise; never derived from the request itself.
|
||||
let responseOrigin = new URL(appUrl).origin
|
||||
|
||||
// Connector branch: a self-hosted instance started this SKV consent
|
||||
// through the /api/connect/skv broker, which registered OUR redirect
|
||||
@@ -417,7 +462,7 @@ export const skatteverketExtension: Extension = {
|
||||
// (the instance does, through the broker's /oauth/token): just bounce
|
||||
// the browser back to the instance with the code + its original
|
||||
// state, so no per-instance redirect uri is registered at SKV.
|
||||
if (isConnectorState(state)) {
|
||||
if (!handoffId && isConnectorState(state)) {
|
||||
const verified = verifyConnectorState(state as string)
|
||||
if (!verified.ok || verified.payload.svc !== 'skv') {
|
||||
return NextResponse.redirect(`${appUrl}/?connector_error=${encodeURIComponent(verified.ok ? 'wrong_service' : verified.reason)}`)
|
||||
@@ -432,8 +477,9 @@ export const skatteverketExtension: Extension = {
|
||||
return NextResponse.redirect(ret.toString())
|
||||
}
|
||||
|
||||
// Injection-safety invariants: appUrl comes from NEXT_PUBLIC_APP_URL
|
||||
// (deployment configuration, never user input), and jsLiteral
|
||||
// Injection-safety invariants: responseOrigin is either deployment
|
||||
// configuration (NEXT_PUBLIC_APP_URL) or a server-validated origin
|
||||
// written by /authorize, never callback input, and jsLiteral
|
||||
// JSON-encodes and escapes `<` so embedded values cannot break out of
|
||||
// the script context. The per-response CSP nonce below is defense in
|
||||
// depth on top of that: even injected markup could never execute.
|
||||
@@ -448,23 +494,25 @@ export const skatteverketExtension: Extension = {
|
||||
'Content-Security-Policy':
|
||||
`default-src 'none'; script-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'`,
|
||||
'Cache-Control': 'no-store',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
})
|
||||
|
||||
// Build an HTML response that detects whether we're running inside an
|
||||
// OAuth popup. If `window.opener` exists, post a message back to the
|
||||
// parent and close the popup. Otherwise fall back to a plain redirect
|
||||
// (preserves the legacy non-popup connect flow). The fallback uses
|
||||
// location.replace so this callback URL (whose code and state are
|
||||
// consumed) drops out of history: navigating Back from the landing
|
||||
// page must not re-run the callback into a guaranteed CSRF error.
|
||||
// (preserves the legacy non-popup connect flow). Both go to the
|
||||
// origin the flow started on. The fallback uses location.replace so
|
||||
// this callback URL (whose code and state are consumed) drops out of
|
||||
// history: navigating Back from the landing page must not re-run the
|
||||
// callback into a guaranteed state error.
|
||||
const respondWithSuccess = (fallbackPath: string) => {
|
||||
const nonce = crypto.randomUUID()
|
||||
const html = `<!DOCTYPE html><html><body><script nonce="${nonce}">
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'skatteverket-oauth-success' }, ${jsLiteral(appUrl)});
|
||||
window.opener.postMessage({ type: 'skatteverket-oauth-success' }, ${jsLiteral(responseOrigin)});
|
||||
window.close();
|
||||
} else {
|
||||
window.location.replace(${jsLiteral(`${appUrl}${fallbackPath}`)});
|
||||
window.location.replace(${jsLiteral(`${responseOrigin}${fallbackPath}`)});
|
||||
}
|
||||
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
|
||||
return new Response(html, {
|
||||
@@ -473,7 +521,18 @@ export const skatteverketExtension: Extension = {
|
||||
})
|
||||
}
|
||||
|
||||
const respondWithError = (reason: string, fallbackPath: string) => {
|
||||
// Once the flow row is known the message goes to the opener's own
|
||||
// origin and is guaranteed to arrive, so the tab stays open on error:
|
||||
// the reason stays on screen and cannot become an invisible "nothing
|
||||
// happens". Before the row is known (unknown, expired or replayed
|
||||
// state or handoff) the target is a guess, and a brand opener would
|
||||
// never hear it: then the tab closes so the panels' closed-tab
|
||||
// watcher resets them instead of leaving Connect disabled forever.
|
||||
const respondWithError = (
|
||||
reason: string,
|
||||
fallbackPath: string,
|
||||
options: { closeTab?: boolean } = {},
|
||||
) => {
|
||||
const nonce = crypto.randomUUID()
|
||||
const escapedReason = reason
|
||||
.replace(/&/g, '&')
|
||||
@@ -481,194 +540,200 @@ export const skatteverketExtension: Extension = {
|
||||
.replace(/>/g, '>')
|
||||
const html = `<!DOCTYPE html><html><body><script nonce="${nonce}">
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'skatteverket-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
|
||||
window.close();
|
||||
window.opener.postMessage({ type: 'skatteverket-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(responseOrigin)});
|
||||
${options.closeTab ? 'window.close();' : ''}
|
||||
} else {
|
||||
window.location.replace(${jsLiteral(`${appUrl}${fallbackPath}`)});
|
||||
window.location.replace(${jsLiteral(`${responseOrigin}${fallbackPath}`)});
|
||||
}
|
||||
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
|
||||
</script><p>Anslutningen misslyckades: ${escapedReason}</p><p>Du kan stänga denna flik.</p></body></html>`
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: responseHeaders(nonce),
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const desc = url.searchParams.get('error_description') || 'Okänt fel'
|
||||
return respondWithError(
|
||||
desc,
|
||||
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(desc)}`,
|
||||
)
|
||||
const defaultErrorPath = (msg: string) =>
|
||||
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}`
|
||||
|
||||
if (!handoffId && ((!code && !error) || !state)) {
|
||||
return respondWithError('Saknar auktoriseringskod', defaultErrorPath('Saknar auktoriseringskod'), { closeTab: true })
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return respondWithError(
|
||||
'Saknar auktoriseringskod',
|
||||
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Saknar auktoriseringskod')}`,
|
||||
)
|
||||
}
|
||||
|
||||
// This callback is served on the OAuth host (see getSkvOauthBaseUrl),
|
||||
// where the browser has no session cookies once the user-facing app
|
||||
// lives on its own domain. The flow is resolved entirely from the
|
||||
// state token: /authorize stored state, user id, redirect_uri and
|
||||
// PKCE verifier keyed on company_id, and the state value is an
|
||||
// unguessable single-use UUID, so the bare lookup by value doubles
|
||||
// as the CSRF check.
|
||||
const { createClient, createServiceClient } = await import('@/lib/supabase/server')
|
||||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||
const db = createServiceClient()
|
||||
|
||||
// States are single-use and short-lived: the recency bound both
|
||||
// caps how long a leaked/phished authorize URL stays completable
|
||||
// (the row expires ten minutes after /authorize refreshed it) and
|
||||
// keeps the row set far below PostgREST's silent 1000-row cap.
|
||||
// value is jsonb, so equality is matched in JS rather than in the
|
||||
// PostgREST filter, where JSON serialization rules would apply.
|
||||
const stateCutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
const { data: stateRows, error: stateError } = await db
|
||||
.from('extension_data')
|
||||
.select('company_id, value')
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', 'oauth_state')
|
||||
.gte('updated_at', stateCutoff)
|
||||
|
||||
if (stateError) {
|
||||
log.error('oauth state lookup failed', stateError)
|
||||
return respondWithError(
|
||||
'Ett tekniskt fel uppstod. Försök igen.',
|
||||
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ett tekniskt fel uppstod')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const stateMatch = (stateRows ?? []).find((row) => row.value === state)
|
||||
if (!stateMatch) {
|
||||
return respondWithError(
|
||||
'Ogiltig state-parameter (CSRF)',
|
||||
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}`,
|
||||
)
|
||||
}
|
||||
const companyId = stateMatch.company_id as string
|
||||
|
||||
const readSetting = async (key: string): Promise<string | null> => {
|
||||
const { data } = await db
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', key)
|
||||
.maybeSingle()
|
||||
return (data?.value as string | null) ?? null
|
||||
}
|
||||
|
||||
// Flows that started before oauth_user_id shipped ran on the same
|
||||
// domain as the app and still carry session cookies; fall back to
|
||||
// those so in-flight connects survive the deploy boundary.
|
||||
const storedUserId = await readSetting('oauth_user_id')
|
||||
let userId = storedUserId
|
||||
if (storedUserId) {
|
||||
// The state row names the user who started the flow; the tokens
|
||||
// below are stored for that user with the service client. Bind the
|
||||
// completion to that user's own session so a victim lured into
|
||||
// approving a Skatteverket consent someone else started cannot have
|
||||
// their BankID-authorised access stored under that someone.
|
||||
//
|
||||
// Hosted, this callback is served on the pinned OAuth host
|
||||
// (getSkvOauthBaseUrl, app.gnubok.se) where the app's session
|
||||
// cookies never arrive: a missing session proves nothing there and
|
||||
// the single-use state + membership check stay the guard. Where the
|
||||
// OAuth host IS the app host (self-hosted, or the pin removed) the
|
||||
// initiator's cookies do arrive, so no session means the initiator
|
||||
// is not the one finishing the flow. A session for a DIFFERENT user
|
||||
// is refused on every host.
|
||||
const initiator = await requireFlowInitiator(request, storedUserId, {
|
||||
// Resolve the flow. Hop 2 arrives with a handoff id; hop 1 (or the
|
||||
// only hop, when the callback host is the app host) with the
|
||||
// provider's state. Both consumes are atomic and single-use, and
|
||||
// every failure (unknown, forged, expired, replayed, wrong origin) is
|
||||
// the same answer: this route is unauthenticated and must not be an
|
||||
// oracle. The handoff is claimed for the validated origin this
|
||||
// request arrived on: decided by host with the scheme from
|
||||
// configuration, so a proxy that drops x-forwarded-proto cannot make
|
||||
// hop 2 disagree with what /authorize recorded.
|
||||
//
|
||||
// On the hop that finishes the flow (it has the initiating origin's
|
||||
// cookies), the completing browser is bound to the initiator BEFORE
|
||||
// the one-shot row is spent: the row names who started the flow, and
|
||||
// the tokens below are stored for that user with the service client.
|
||||
// A session-less arrival goes to login and resumes into this very
|
||||
// URL; a different user is refused and the row stays claimable for
|
||||
// the initiator; only then is the row consumed. A victim lured into
|
||||
// approving a consent someone else started thus never has their
|
||||
// BankID-authorised access stored under that someone, and nobody
|
||||
// can burn a live flow by merely reaching its URL signed out.
|
||||
//
|
||||
// Membership is checked here as well, before the consume: the
|
||||
// service-role token write below has no RLS backstop, membership
|
||||
// can be revoked between /authorize and this callback (#1091), and
|
||||
// a revoked initiator must not burn the one-shot provider code on
|
||||
// the way to being refused.
|
||||
const bindInitiator = async (identity: { userId: string; companyId: string; origin: string }) => {
|
||||
responseOrigin = identity.origin
|
||||
const initiator = await requireFlowInitiator(request, identity.userId, {
|
||||
flow: 'skatteverket.callback',
|
||||
returnOrigin: identity.origin,
|
||||
})
|
||||
if (!initiator.ok) {
|
||||
const sessionExpected =
|
||||
new URL(getSkvOauthBaseUrl()).origin === new URL(appUrl).origin
|
||||
if (initiator.reason === 'mismatch') {
|
||||
return respondWithError(
|
||||
if (initiator.reason === 'no_session') return { userId: null, response: initiator.response }
|
||||
return {
|
||||
userId: null,
|
||||
response: respondWithError(
|
||||
FLOW_INITIATOR_MISMATCH_MESSAGE,
|
||||
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(FLOW_INITIATOR_MISMATCH_MESSAGE)}`,
|
||||
)
|
||||
}
|
||||
if (sessionExpected) {
|
||||
// The state row is untouched until the exchange, so signing in
|
||||
// and re-running this callback (the helper's /login?next=...)
|
||||
// completes the flow for its initiator.
|
||||
return initiator.response
|
||||
defaultErrorPath(FLOW_INITIATOR_MISMATCH_MESSAGE),
|
||||
),
|
||||
}
|
||||
}
|
||||
const { data: membership } = await db
|
||||
.from('company_members')
|
||||
.select('user_id')
|
||||
.eq('company_id', identity.companyId)
|
||||
.eq('user_id', initiator.userId)
|
||||
.maybeSingle()
|
||||
if (!membership) {
|
||||
return {
|
||||
userId: null,
|
||||
response: respondWithError(
|
||||
'Behörighet saknas för företaget',
|
||||
defaultErrorPath('Behörighet saknas för företaget'),
|
||||
),
|
||||
}
|
||||
}
|
||||
return { userId: initiator.userId, response: null }
|
||||
}
|
||||
|
||||
let flow: OAuthFlow
|
||||
let providerError: string | null = null
|
||||
let boundUserId: string | null = null
|
||||
if (handoffId) {
|
||||
const arrivedOn = await resolveOAuthOrigin(request)
|
||||
const identity = await peekOAuthFlowHandoff(db, handoffId, arrivedOn, 'skatteverket')
|
||||
if (!identity) {
|
||||
return respondWithError(SKV_STATE_REJECTED_MESSAGE, defaultErrorPath(SKV_STATE_REJECTED_MESSAGE), { closeTab: true })
|
||||
}
|
||||
const bound = await bindInitiator(identity)
|
||||
if (bound.response) return bound.response
|
||||
boundUserId = bound.userId
|
||||
const handoff = await consumeOAuthFlowHandoff(db, handoffId, arrivedOn, 'skatteverket')
|
||||
if (!handoff) {
|
||||
return respondWithError(SKV_STATE_REJECTED_MESSAGE, defaultErrorPath(SKV_STATE_REJECTED_MESSAGE), { closeTab: true })
|
||||
}
|
||||
flow = handoff
|
||||
code = handoff.providerCode
|
||||
providerError = handoff.providerError
|
||||
} else {
|
||||
const cookieClient = await createClient()
|
||||
const { data: { user } } = await cookieClient.auth.getUser()
|
||||
userId = user?.id ?? null
|
||||
}
|
||||
if (!userId) {
|
||||
return respondWithError(
|
||||
'Sessionen har gått ut. Stäng fliken och försök ansluta igen.',
|
||||
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Sessionen har gått ut')}`,
|
||||
)
|
||||
const identity = await peekOAuthFlowState(db, state as string, 'skatteverket')
|
||||
if (!identity) {
|
||||
return respondWithError(SKV_STATE_REJECTED_MESSAGE, defaultErrorPath(SKV_STATE_REJECTED_MESSAGE), { closeTab: true })
|
||||
}
|
||||
// Single hop (the callback host is the initiating host): bind here.
|
||||
// Hop 1 on the registered host has no session to bind; hop 2 does.
|
||||
if (requestMatchesOrigin(request, identity.origin)) {
|
||||
const bound = await bindInitiator(identity)
|
||||
if (bound.response) return bound.response
|
||||
boundUserId = bound.userId
|
||||
}
|
||||
const consumed = await consumeOAuthFlowState(db, state as string, 'skatteverket')
|
||||
if (!consumed) {
|
||||
return respondWithError(SKV_STATE_REJECTED_MESSAGE, defaultErrorPath(SKV_STATE_REJECTED_MESSAGE), { closeTab: true })
|
||||
}
|
||||
flow = consumed
|
||||
if (error) providerError = url.searchParams.get('error_description') || 'Okänt fel'
|
||||
}
|
||||
responseOrigin = flow.origin
|
||||
|
||||
// Defense in depth for the service-role write below: the stored
|
||||
// user must still be a member of the company that initiated the
|
||||
// flow (membership can be revoked between /authorize and this
|
||||
// callback, and RLS no longer backstops the write). Checked before
|
||||
// the exchange so a rejected flow does not burn the one-shot
|
||||
// authorization code. (#1091)
|
||||
const { data: membership } = await db
|
||||
.from('company_members')
|
||||
.select('user_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
if (!membership) {
|
||||
return respondWithError(
|
||||
'Behörighet saknas för företaget',
|
||||
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Behörighet saknas för företaget')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const redirectUri = (await readSetting('oauth_redirect_uri')) ||
|
||||
`${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
|
||||
|
||||
// Retrieve the PKCE verifier stored in /authorize. Optional only for
|
||||
// backward compatibility with in-flight flows that started before the
|
||||
// PKCE rollout: once those drain, this can be made required.
|
||||
const codeVerifier = (await readSetting('oauth_code_verifier')) || undefined
|
||||
|
||||
// Connector mode: the broker's signed state, stored by /authorize
|
||||
// (authoritative) with the hosted callback's bounced query param as
|
||||
// fallback for a row written before the store landed. Required by the
|
||||
// broker's token exchange; undefined on the direct path.
|
||||
const connectorState =
|
||||
(await readSetting('oauth_connector_state')) ||
|
||||
url.searchParams.get('connector_state') ||
|
||||
undefined
|
||||
|
||||
// Optional in-app destination set by /authorize?return_to=...
|
||||
const returnTo = await readSetting('oauth_return_to')
|
||||
const successPath = returnTo
|
||||
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_connected=true`
|
||||
const successPath = flow.returnTo
|
||||
? `${flow.returnTo}${flow.returnTo.includes('?') ? '&' : '?'}skv_connected=true`
|
||||
: `/reports?tab=vat-declaration&skv_connected=true`
|
||||
const errorPath = (msg: string) =>
|
||||
returnTo
|
||||
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_error=${encodeURIComponent(msg)}`
|
||||
: `/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}`
|
||||
flow.returnTo
|
||||
? `${flow.returnTo}${flow.returnTo.includes('?') ? '&' : '?'}skv_error=${encodeURIComponent(msg)}`
|
||||
: defaultErrorPath(msg)
|
||||
|
||||
// Hop 1 on the registered callback host: no session for the
|
||||
// initiating origin can exist here. Stash the provider's result on
|
||||
// the row (encrypted, under a separate handoff id) and send the
|
||||
// browser to the origin the flow started on. Provider credentials
|
||||
// never enter this URL. Compared by host only: see requestHost.
|
||||
if (!handoffId && !requestMatchesOrigin(request, flow.origin)) {
|
||||
let nextHandoff: string
|
||||
try {
|
||||
nextHandoff = await mintOAuthFlowHandoff(
|
||||
db,
|
||||
flow,
|
||||
providerError !== null ? { providerError } : { providerCode: code as string },
|
||||
)
|
||||
} catch (err) {
|
||||
// The state is spent; the user restarts. The response must still
|
||||
// be the callback page, so the opener hears it and resets.
|
||||
log.error('oauth handoff mint failed', err as Error, { companyId: flow.companyId })
|
||||
return respondWithError(
|
||||
'Ett tekniskt fel uppstod. Försök igen.',
|
||||
errorPath('Ett tekniskt fel uppstod'),
|
||||
)
|
||||
}
|
||||
const target = new URL('/api/extensions/ext/skatteverket/callback', flow.origin)
|
||||
target.searchParams.set('handoff', nextHandoff)
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: target.toString(),
|
||||
'Cache-Control': 'no-store',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Bound above, before the consume. Re-checked here only because the
|
||||
// row was read twice: the session that passed the identity check is
|
||||
// the one that must own the tokens.
|
||||
if (!boundUserId || boundUserId !== flow.userId) {
|
||||
log.error('oauth callback bound user diverged from the consumed flow', {
|
||||
companyId: flow.companyId,
|
||||
})
|
||||
return respondWithError(SKV_SESSION_MISSING_MESSAGE, errorPath(SKV_SESSION_MISSING_MESSAGE))
|
||||
}
|
||||
const userId = boundUserId
|
||||
const companyId = flow.companyId
|
||||
|
||||
if (providerError !== null) {
|
||||
return respondWithError(providerError, errorPath(providerError))
|
||||
}
|
||||
if (!code) {
|
||||
return respondWithError(SKV_STATE_REJECTED_MESSAGE, errorPath(SKV_STATE_REJECTED_MESSAGE))
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier, connectorState)
|
||||
// The exchange must repeat the redirect_uri SKV saw and the PKCE
|
||||
// verifier /authorize generated; both come from the row.
|
||||
const tokens = await exchangeCodeForTokens(
|
||||
code,
|
||||
flow.redirectUri,
|
||||
flow.codeVerifier ?? undefined,
|
||||
flow.connectorState ?? undefined,
|
||||
)
|
||||
await storeTokens(db, userId, tokens, companyId)
|
||||
|
||||
// Clean up CSRF state + the one-shot user id/return_to/PKCE verifier.
|
||||
await db
|
||||
.from('extension_data')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.in('key', ['oauth_state', 'oauth_user_id', 'oauth_return_to', 'oauth_code_verifier', 'oauth_connector_state'])
|
||||
|
||||
// Refresh Skatteverket-derived data AFTER the response is sent.
|
||||
// Right-after-consent is still the one reliable window for a
|
||||
// personal-token fetch (SKV per-flow tokens live ~65 minutes), but
|
||||
@@ -697,20 +762,6 @@ export const skatteverketExtension: Extension = {
|
||||
return respondWithSuccess(successPath)
|
||||
} catch (err) {
|
||||
console.error('[skatteverket] Token exchange failed:', err)
|
||||
// The ephemeral flow rows must not outlive the flow: oauth_user_id
|
||||
// in particular holds a user identity and serves no purpose once
|
||||
// the exchange has failed (#1090). Best-effort: a cleanup failure
|
||||
// must not mask the exchange error shown to the user.
|
||||
try {
|
||||
await db
|
||||
.from('extension_data')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.in('key', ['oauth_state', 'oauth_user_id', 'oauth_return_to', 'oauth_code_verifier', 'oauth_connector_state'])
|
||||
} catch (cleanupErr) {
|
||||
log.error('oauth state cleanup after failed exchange failed', cleanupErr, { companyId })
|
||||
}
|
||||
// BankID auth codes expire after 5 minutes. Surface timeouts distinctly
|
||||
// so the user retries quickly instead of exhausting the code window.
|
||||
const message = err instanceof TimeoutError
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const { mockResolveBrandByHost } = vi.hoisted(() => ({ mockResolveBrandByHost: vi.fn() }))
|
||||
vi.mock('@/lib/branding/resolve', () => ({ resolveBrandByHost: mockResolveBrandByHost }))
|
||||
|
||||
import {
|
||||
consumeOAuthFlowHandoff,
|
||||
consumeOAuthFlowState,
|
||||
createOAuthFlow,
|
||||
mintOAuthFlowHandoff,
|
||||
newOAuthFlowId,
|
||||
peekOAuthFlowHandoff,
|
||||
peekOAuthFlowState,
|
||||
purgeExpiredOAuthFlows,
|
||||
requestHost,
|
||||
requestMatchesOrigin,
|
||||
resolveOAuthOrigin,
|
||||
type OAuthFlow,
|
||||
} from '../oauth-flows'
|
||||
import { decryptOAuthFlowValue, encryptOAuthFlowValue } from '../oauth-flow-crypto'
|
||||
|
||||
/**
|
||||
* Chainable Supabase mock that records every builder call. Awaiting the
|
||||
* chain (insert / delete without select) resolves `terminal`; maybeSingle()
|
||||
* resolves it too.
|
||||
*/
|
||||
function makeDb(terminal: { data?: any; error?: any } = { data: null, error: null }) {
|
||||
const calls: Array<[string, unknown[]]> = []
|
||||
const chain: any = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop: string) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: any, reject: any) => Promise.resolve(terminal).then(resolve, reject)
|
||||
}
|
||||
if (prop === 'maybeSingle') return vi.fn(async () => terminal)
|
||||
return (...args: unknown[]) => {
|
||||
calls.push([prop, args])
|
||||
return chain
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
const db = { from: vi.fn(() => chain) }
|
||||
const call = (name: string) => calls.find(([n]) => n === name)?.[1]
|
||||
const payload = (name: string) => call(name)?.[0] as Record<string, any>
|
||||
return { db: db as any, calls, call, payload }
|
||||
}
|
||||
|
||||
const FLOW: OAuthFlow = {
|
||||
id: 'state-1',
|
||||
kind: 'skatteverket',
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
origin: 'https://brand.example',
|
||||
redirectUri: 'https://oauth.example/cb',
|
||||
codeVerifier: 'verifier-1',
|
||||
connectorState: null,
|
||||
returnTo: '/settings/tax',
|
||||
}
|
||||
|
||||
function rowFor(flow: OAuthFlow, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: flow.id,
|
||||
kind: flow.kind,
|
||||
company_id: flow.companyId,
|
||||
user_id: flow.userId,
|
||||
origin: flow.origin,
|
||||
redirect_uri: flow.redirectUri,
|
||||
code_verifier:
|
||||
flow.codeVerifier === null
|
||||
? null
|
||||
: encryptOAuthFlowValue(
|
||||
flow.codeVerifier,
|
||||
JSON.stringify([flow.id, flow.userId, flow.origin, 'code_verifier']),
|
||||
),
|
||||
connector_state: flow.connectorState,
|
||||
return_to: flow.returnTo,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'test-service-role-secret')
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example')
|
||||
mockResolveBrandByHost.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('oauth-flow-crypto', () => {
|
||||
it('round-trips under the same context and refuses another', () => {
|
||||
const ct = encryptOAuthFlowValue('secret', 'ctx-a')
|
||||
expect(ct.startsWith('v1:')).toBe(true)
|
||||
expect(ct).not.toContain('secret')
|
||||
expect(decryptOAuthFlowValue(ct, 'ctx-a')).toBe('secret')
|
||||
expect(() => decryptOAuthFlowValue(ct, 'ctx-b')).toThrow()
|
||||
})
|
||||
|
||||
it('never accepts a plaintext value as a decryption', () => {
|
||||
expect(() => decryptOAuthFlowValue('secret', 'ctx')).toThrow(/ciphertext/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestHost', () => {
|
||||
it('prefers the Host header over the reconstructed URL and normalises it', () => {
|
||||
const req = new Request('https://internal.example/cb', { headers: { host: 'Brand.Example.' } })
|
||||
expect(requestHost(req)).toBe('brand.example')
|
||||
})
|
||||
|
||||
it('falls back to the URL host for a malformed Host header', () => {
|
||||
const req = new Request('https://app.example/cb', { headers: { host: 'evil.example/path' } })
|
||||
expect(requestHost(req)).toBe('app.example')
|
||||
})
|
||||
|
||||
it('matches an origin by host regardless of the scheme the proxy reported', () => {
|
||||
// A TLS-terminating proxy that drops x-forwarded-proto makes Next
|
||||
// reconstruct request.url as http://; the host is what identifies the hop.
|
||||
const req = new Request('http://app.example/cb')
|
||||
expect(requestMatchesOrigin(req, 'https://app.example')).toBe(true)
|
||||
expect(requestMatchesOrigin(req, 'https://oauth.example')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveOAuthOrigin', () => {
|
||||
it('returns the app origin for the app host without a brand lookup, whatever scheme the proxy reported', async () => {
|
||||
expect(await resolveOAuthOrigin(new Request('https://app.example/x'))).toBe('https://app.example')
|
||||
expect(await resolveOAuthOrigin(new Request('http://app.example/x'))).toBe('https://app.example')
|
||||
expect(await resolveOAuthOrigin(new Request('http://APP.example./x'))).toBe('https://app.example')
|
||||
expect(mockResolveBrandByHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an internal proxy upstream host as the app origin', async () => {
|
||||
// nginx defaults: Host rewritten to the upstream address, no proto header.
|
||||
expect(await resolveOAuthOrigin(new Request('http://127.0.0.1:3000/x'))).toBe('https://app.example')
|
||||
expect(mockResolveBrandByHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a brand origin only when the brands table resolves the exact host', async () => {
|
||||
mockResolveBrandByHost.mockResolvedValue({ domain: 'brand.example' })
|
||||
expect(await resolveOAuthOrigin(new Request('https://brand.example/x'))).toBe('https://brand.example')
|
||||
|
||||
mockResolveBrandByHost.mockResolvedValue({ domain: 'other.example' })
|
||||
expect(await resolveOAuthOrigin(new Request('https://brand.example/x'))).toBe('https://app.example')
|
||||
|
||||
mockResolveBrandByHost.mockResolvedValue(null)
|
||||
expect(await resolveOAuthOrigin(new Request('https://stranger.example/x'))).toBe('https://app.example')
|
||||
})
|
||||
|
||||
it('always answers a known brand as HTTPS and never a non-default port', async () => {
|
||||
mockResolveBrandByHost.mockResolvedValue({ domain: 'brand.example' })
|
||||
expect(await resolveOAuthOrigin(new Request('http://brand.example/x'))).toBe('https://brand.example')
|
||||
expect(await resolveOAuthOrigin(new Request('https://brand.example:8443/x'))).toBe('https://app.example')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createOAuthFlow', () => {
|
||||
it('stores the verifier encrypted and bound to the row identity', async () => {
|
||||
const { db, payload } = makeDb({ error: null })
|
||||
await createOAuthFlow(db, {
|
||||
id: 'state-1',
|
||||
kind: 'skatteverket',
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
origin: 'https://brand.example',
|
||||
redirectUri: 'https://oauth.example/cb',
|
||||
codeVerifier: 'verifier-1',
|
||||
connectorState: 'cs',
|
||||
returnTo: '/settings/tax',
|
||||
})
|
||||
const row = payload('insert')
|
||||
expect(row).toMatchObject({
|
||||
id: 'state-1',
|
||||
kind: 'skatteverket',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
origin: 'https://brand.example',
|
||||
redirect_uri: 'https://oauth.example/cb',
|
||||
connector_state: 'cs',
|
||||
return_to: '/settings/tax',
|
||||
})
|
||||
expect(row.code_verifier).not.toContain('verifier-1')
|
||||
expect(
|
||||
decryptOAuthFlowValue(
|
||||
row.code_verifier,
|
||||
JSON.stringify(['state-1', 'user-1', 'https://brand.example', 'code_verifier']),
|
||||
),
|
||||
).toBe('verifier-1')
|
||||
const ttl = new Date(row.expires_at).getTime() - Date.now()
|
||||
expect(ttl).toBeGreaterThan(9 * 60 * 1000)
|
||||
expect(ttl).toBeLessThanOrEqual(10 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('throws when the insert fails', async () => {
|
||||
const { db } = makeDb({ error: { message: 'boom' } })
|
||||
await expect(
|
||||
createOAuthFlow(db, {
|
||||
id: 's',
|
||||
kind: 'skatteverket',
|
||||
companyId: 'c',
|
||||
userId: 'u',
|
||||
origin: 'https://app.example',
|
||||
redirectUri: 'https://app.example/cb',
|
||||
}),
|
||||
).rejects.toThrow(/boom/)
|
||||
})
|
||||
|
||||
it('mints unguessable ids', () => {
|
||||
const a = newOAuthFlowId()
|
||||
expect(a).toHaveLength(43)
|
||||
expect(newOAuthFlowId()).not.toBe(a)
|
||||
})
|
||||
})
|
||||
|
||||
describe('consumeOAuthFlowState', () => {
|
||||
it('consumes with the whole check in the predicate and returns the decrypted flow', async () => {
|
||||
const { db, calls, call } = makeDb({ data: rowFor(FLOW), error: null })
|
||||
const flow = await consumeOAuthFlowState(db, 'state-1', 'skatteverket')
|
||||
expect(flow).toEqual(FLOW)
|
||||
expect(call('update')?.[0]).toMatchObject({ used_at: expect.any(String) })
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['eq', ['id', 'state-1']],
|
||||
['eq', ['kind', 'skatteverket']],
|
||||
['is', ['used_at', null]],
|
||||
['gt', ['expires_at', expect.any(String)]],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for no row, a query error, and an unreadable verifier', async () => {
|
||||
expect(await consumeOAuthFlowState(makeDb({ data: null, error: null }).db, 's', 'skatteverket')).toBeNull()
|
||||
expect(
|
||||
await consumeOAuthFlowState(makeDb({ data: null, error: { message: 'x' } }).db, 's', 'skatteverket'),
|
||||
).toBeNull()
|
||||
const tampered = rowFor(FLOW, { code_verifier: 'v1:not-really' })
|
||||
expect(await consumeOAuthFlowState(makeDb({ data: tampered, error: null }).db, 's', 'skatteverket')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mintOAuthFlowHandoff / consumeOAuthFlowHandoff', () => {
|
||||
it('stashes the provider code encrypted under a fresh handoff id, only onto a consumed row', async () => {
|
||||
const { db, calls, payload } = makeDb({ data: { id: 'state-1' }, error: null })
|
||||
const handoffId = await mintOAuthFlowHandoff(db, FLOW, { providerCode: 'code-1' })
|
||||
expect(handoffId).not.toBe(FLOW.id)
|
||||
const row = payload('update')
|
||||
expect(row.handoff_id).toBe(handoffId)
|
||||
expect(row.handoff_error).toBeNull()
|
||||
expect(row.handoff_code).not.toContain('code-1')
|
||||
expect(
|
||||
decryptOAuthFlowValue(
|
||||
row.handoff_code,
|
||||
JSON.stringify(['state-1', handoffId, 'user-1', 'https://brand.example', 'handoff_code']),
|
||||
),
|
||||
).toBe('code-1')
|
||||
const ttl = new Date(row.handoff_expires_at).getTime() - Date.now()
|
||||
expect(ttl).toBeGreaterThan(4 * 60 * 1000)
|
||||
expect(ttl).toBeLessThanOrEqual(5 * 60 * 1000)
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['eq', ['id', 'state-1']],
|
||||
['is', ['handoff_id', null]],
|
||||
['not', ['used_at', 'is', null]],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the row was already handed off', async () => {
|
||||
const { db } = makeDb({ data: null, error: null })
|
||||
await expect(mintOAuthFlowHandoff(db, FLOW, { providerError: 'nej' })).rejects.toThrow(/handed off/)
|
||||
})
|
||||
|
||||
it('claims the handoff bound to the origin and returns the decrypted result', async () => {
|
||||
const handoffId = 'handoff-1'
|
||||
const ctx = (col: string) => JSON.stringify(['state-1', handoffId, 'user-1', 'https://brand.example', col])
|
||||
const row = rowFor(FLOW, {
|
||||
handoff_code: encryptOAuthFlowValue('code-1', ctx('handoff_code')),
|
||||
handoff_error: null,
|
||||
})
|
||||
const { db, calls } = makeDb({ data: row, error: null })
|
||||
const result = await consumeOAuthFlowHandoff(db, handoffId, 'https://brand.example', 'skatteverket')
|
||||
expect(result).toEqual({ ...FLOW, providerCode: 'code-1', providerError: null })
|
||||
expect(calls[0]?.[0]).toBe('delete')
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['eq', ['handoff_id', handoffId]],
|
||||
['eq', ['origin', 'https://brand.example']],
|
||||
['eq', ['kind', 'skatteverket']],
|
||||
['gt', ['handoff_expires_at', expect.any(String)]],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for a missing row and for a tampered payload', async () => {
|
||||
expect(
|
||||
await consumeOAuthFlowHandoff(makeDb({ data: null, error: null }).db, 'h', 'https://brand.example', 'skatteverket'),
|
||||
).toBeNull()
|
||||
const row = rowFor(FLOW, { handoff_code: 'v1:garbage', handoff_error: null })
|
||||
expect(
|
||||
await consumeOAuthFlowHandoff(makeDb({ data: row, error: null }).db, 'h', 'https://brand.example', 'skatteverket'),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('peekOAuthFlowState / peekOAuthFlowHandoff', () => {
|
||||
it('reads a live state identity without writing, with the same liveness predicate as the consume', async () => {
|
||||
const { db, calls } = makeDb({ data: { user_id: 'user-1', company_id: 'company-1', origin: 'https://brand.example' }, error: null })
|
||||
expect(await peekOAuthFlowState(db, 'state-1', 'skatteverket')).toEqual({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
origin: 'https://brand.example',
|
||||
})
|
||||
expect(calls[0]?.[0]).toBe('select')
|
||||
expect(calls.map(([n]) => n)).not.toContain('update')
|
||||
expect(calls.map(([n]) => n)).not.toContain('delete')
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['eq', ['id', 'state-1']],
|
||||
['eq', ['kind', 'skatteverket']],
|
||||
['is', ['used_at', null]],
|
||||
['gt', ['expires_at', expect.any(String)]],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a live handoff identity bound to the origin, without writing', async () => {
|
||||
const { db, calls } = makeDb({ data: { user_id: 'user-1', company_id: 'company-1', origin: 'https://brand.example' }, error: null })
|
||||
expect(await peekOAuthFlowHandoff(db, 'handoff-1', 'https://brand.example', 'skatteverket')).toEqual({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
origin: 'https://brand.example',
|
||||
})
|
||||
expect(calls[0]?.[0]).toBe('select')
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['eq', ['handoff_id', 'handoff-1']],
|
||||
['eq', ['origin', 'https://brand.example']],
|
||||
['eq', ['kind', 'skatteverket']],
|
||||
['gt', ['handoff_expires_at', expect.any(String)]],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for no row or a query error', async () => {
|
||||
expect(await peekOAuthFlowState(makeDb({ data: null, error: null }).db, 's', 'skatteverket')).toBeNull()
|
||||
expect(
|
||||
await peekOAuthFlowHandoff(makeDb({ data: null, error: { message: 'x' } }).db, 'h', 'https://brand.example', 'skatteverket'),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('purgeExpiredOAuthFlows', () => {
|
||||
it('deletes only rows nothing can consume any more', async () => {
|
||||
const { db, calls } = makeDb({ error: null })
|
||||
await purgeExpiredOAuthFlows(db)
|
||||
expect(calls[0]?.[0]).toBe('delete')
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
['lt', ['expires_at', expect.any(String)]],
|
||||
['or', [expect.stringMatching(/^handoff_expires_at\.is\.null,handoff_expires_at\.lt\./)]],
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* At-rest encryption for the secrets an oauth_flows row holds between
|
||||
* /authorize and /callback: the PKCE verifier, and during the two-minute
|
||||
* brand handoff the provider's authorization code or error text.
|
||||
*
|
||||
* Same construction as the provider OAuth handoff (PR #2305): AES-256-GCM
|
||||
* under a purpose-scoped derivation of the server-only service-role secret,
|
||||
* which every deployment already has, with the row identity as additional
|
||||
* authenticated data so a ciphertext cannot be moved between rows or
|
||||
* columns. Plaintext or unreadable values never decrypt successfully.
|
||||
*/
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!secret) throw new Error('SUPABASE_SERVICE_ROLE_KEY is required')
|
||||
return createHash('sha256').update('oauth-flows:v1:' + secret).digest()
|
||||
}
|
||||
|
||||
export function encryptOAuthFlowValue(plaintext: string, context: string): string {
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
|
||||
cipher.setAAD(Buffer.from(context))
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
return 'v1:' + Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url')
|
||||
}
|
||||
|
||||
export function decryptOAuthFlowValue(ciphertext: string, context: string): string {
|
||||
if (!ciphertext.startsWith('v1:')) throw new Error('Invalid OAuth flow ciphertext')
|
||||
const combined = Buffer.from(ciphertext.slice(3), 'base64url')
|
||||
const decipher = createDecipheriv('aes-256-gcm', getKey(), combined.subarray(0, 12))
|
||||
decipher.setAAD(Buffer.from(context))
|
||||
decipher.setAuthTag(combined.subarray(12, 28))
|
||||
return Buffer.concat([decipher.update(combined.subarray(28)), decipher.final()]).toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { resolveBrandByHost } from '@/lib/branding/resolve'
|
||||
import { decryptOAuthFlowValue, encryptOAuthFlowValue } from './oauth-flow-crypto'
|
||||
|
||||
/**
|
||||
* Server-side state for a browser-driven OAuth flow that must finish on the
|
||||
* origin it started from, for the user who started it (table oauth_flows,
|
||||
* migration 20260907120000).
|
||||
*
|
||||
* The `state` handed to the provider is the row id: a random token that
|
||||
* encodes nothing. The row records who started the flow (user + company),
|
||||
* where (the validated app or brand origin), and what the token exchange
|
||||
* must repeat (redirect URI, PKCE verifier, connector state).
|
||||
*
|
||||
* Hosted, the provider redirects to a registered callback host that carries
|
||||
* no app session cookies. The callback there consumes the state (hop 1),
|
||||
* stashes the provider's code or error encrypted on the row under a separate
|
||||
* handoff id, and redirects to the recorded origin. The callback on that
|
||||
* origin consumes the handoff (hop 2), has the initiator's cookies, and can
|
||||
* bind the completion to the initiating user before exchanging the code.
|
||||
*
|
||||
* Every consume is a single UPDATE or DELETE whose WHERE clause is the whole
|
||||
* check: a replayed or concurrent callback loses the row-lock race and gets
|
||||
* null, with no read-then-write window. Every failure mode (unknown, forged,
|
||||
* expired, consumed, wrong origin, unreadable ciphertext) is the same null:
|
||||
* the callbacks are unauthenticated and must not be an oracle.
|
||||
*/
|
||||
|
||||
export type OAuthFlowKind = 'skatteverket'
|
||||
|
||||
export interface OAuthFlow {
|
||||
id: string
|
||||
kind: OAuthFlowKind
|
||||
companyId: string
|
||||
userId: string
|
||||
origin: string
|
||||
redirectUri: string
|
||||
codeVerifier: string | null
|
||||
connectorState: string | null
|
||||
returnTo: string | null
|
||||
}
|
||||
|
||||
export interface OAuthFlowHandoff extends OAuthFlow {
|
||||
providerCode: string | null
|
||||
providerError: string | null
|
||||
}
|
||||
|
||||
/** How long an authorize URL stays completable. */
|
||||
export const OAUTH_FLOW_TTL_SECONDS = 10 * 60
|
||||
/**
|
||||
* How long hop 1's stash stays claimable by hop 2. Long enough for the
|
||||
* initiator to sign in again when hop 2 finds no session (the login page
|
||||
* forwards straight back into the callback); the provider code inside it
|
||||
* expires on the provider's side anyway.
|
||||
*/
|
||||
export const OAUTH_FLOW_HANDOFF_TTL_SECONDS = 5 * 60
|
||||
|
||||
// Column set every consume returns; decrypted into an OAuthFlow below.
|
||||
const FLOW_COLUMNS =
|
||||
'id, kind, company_id, user_id, origin, redirect_uri, code_verifier, connector_state, return_to'
|
||||
|
||||
type FlowRow = {
|
||||
id: string
|
||||
kind: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
origin: string
|
||||
redirect_uri: string
|
||||
code_verifier: string | null
|
||||
connector_state: string | null
|
||||
return_to: string | null
|
||||
}
|
||||
|
||||
export function newOAuthFlowId(): string {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
function verifierContext(row: { id: string; user_id: string; origin: string }): string {
|
||||
return JSON.stringify([row.id, row.user_id, row.origin, 'code_verifier'])
|
||||
}
|
||||
|
||||
function handoffContext(
|
||||
row: { id: string; user_id: string; origin: string },
|
||||
handoffId: string,
|
||||
column: 'handoff_code' | 'handoff_error',
|
||||
): string {
|
||||
return JSON.stringify([row.id, handoffId, row.user_id, row.origin, column])
|
||||
}
|
||||
|
||||
function rowToFlow(row: FlowRow): OAuthFlow {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as OAuthFlowKind,
|
||||
companyId: row.company_id,
|
||||
userId: row.user_id,
|
||||
origin: row.origin,
|
||||
redirectUri: row.redirect_uri,
|
||||
codeVerifier: row.code_verifier === null ? null : decryptOAuthFlowValue(row.code_verifier, verifierContext(row)),
|
||||
connectorState: row.connector_state,
|
||||
returnTo: row.return_to,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.trim().toLowerCase().replace(/\.$/, '').replace(/\.(?=:\d+$)/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The host the browser addressed, from the Host header (what the browser
|
||||
* typed) rather than the URL Next.js reconstructed. Only the host: the
|
||||
* scheme of request.url comes from x-forwarded-proto, which a self-hoster's
|
||||
* reverse proxy may not send, and must never decide whether two hops are on
|
||||
* the same origin. Anything that is not a bare host falls back to the URL.
|
||||
*/
|
||||
export function requestHost(request: Request): string {
|
||||
const url = new URL(request.url)
|
||||
const host = request.headers.get('host') ?? url.host
|
||||
try {
|
||||
const candidate = new URL(`https://${host}`)
|
||||
if (
|
||||
candidate.host !== host.toLowerCase() ||
|
||||
candidate.pathname !== '/' ||
|
||||
candidate.search ||
|
||||
candidate.hash
|
||||
) {
|
||||
return normalizeHost(url.host)
|
||||
}
|
||||
return normalizeHost(candidate.host)
|
||||
} catch {
|
||||
return normalizeHost(url.host)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the request was addressed to the host of `origin`. */
|
||||
export function requestMatchesOrigin(request: Request, origin: string): boolean {
|
||||
return requestHost(request) === normalizeHost(new URL(origin).host)
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin a flow started on (or, on hop 2, arrived on), validated: the
|
||||
* canonical app origin, or an HTTPS brand domain that resolves in the brands
|
||||
* table. Decided by host alone with the scheme taken from configuration, so
|
||||
* a proxy that forwards Host without x-forwarded-proto still resolves to the
|
||||
* same origin on every hop. Anything else (an unknown host, a non-default
|
||||
* port) is treated as the app origin, so a forged Host header can never make
|
||||
* the callback hand a code to a stranger.
|
||||
*/
|
||||
export async function resolveOAuthOrigin(request: Request): Promise<string> {
|
||||
const appOrigin = new URL(process.env.NEXT_PUBLIC_APP_URL || request.url).origin
|
||||
const host = requestHost(request)
|
||||
if (host === normalizeHost(new URL(appOrigin).host)) return appOrigin
|
||||
if (host.includes(':')) return appOrigin
|
||||
const brand = await resolveBrandByHost(host)
|
||||
return brand && normalizeHost(new URL(`https://${brand.domain}`).host) === host
|
||||
? `https://${host}`
|
||||
: appOrigin
|
||||
}
|
||||
|
||||
export interface CreateOAuthFlowInput {
|
||||
id: string
|
||||
kind: OAuthFlowKind
|
||||
companyId: string
|
||||
userId: string
|
||||
origin: string
|
||||
redirectUri: string
|
||||
codeVerifier?: string | null
|
||||
connectorState?: string | null
|
||||
returnTo?: string | null
|
||||
ttlSeconds?: number
|
||||
}
|
||||
|
||||
/** Insert the flow row. `id` is the state the caller sends to the provider. */
|
||||
export async function createOAuthFlow(db: SupabaseClient, input: CreateOAuthFlowInput): Promise<void> {
|
||||
const ttl = input.ttlSeconds ?? OAUTH_FLOW_TTL_SECONDS
|
||||
const identity = { id: input.id, user_id: input.userId, origin: input.origin }
|
||||
const { error } = await db.from('oauth_flows').insert({
|
||||
id: input.id,
|
||||
kind: input.kind,
|
||||
company_id: input.companyId,
|
||||
user_id: input.userId,
|
||||
origin: input.origin,
|
||||
redirect_uri: input.redirectUri,
|
||||
code_verifier:
|
||||
input.codeVerifier == null ? null : encryptOAuthFlowValue(input.codeVerifier, verifierContext(identity)),
|
||||
connector_state: input.connectorState ?? null,
|
||||
return_to: input.returnTo ?? null,
|
||||
expires_at: new Date(Date.now() + ttl * 1000).toISOString(),
|
||||
})
|
||||
if (error) throw new Error(`Failed to create OAuth flow: ${error.message}`)
|
||||
}
|
||||
|
||||
/** Who a flow belongs to and where it must finish, without consuming it. */
|
||||
export interface OAuthFlowIdentity {
|
||||
userId: string
|
||||
companyId: string
|
||||
origin: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a live state's identity without consuming it, so the callback can
|
||||
* bind the completing browser to the initiator BEFORE the one-shot state is
|
||||
* spent: a session-less arrival is sent to login and resumes, a wrong user
|
||||
* is refused, and the true initiator can still finish. Never trusted for
|
||||
* anything but that identity check; the consume below is the gate.
|
||||
*/
|
||||
export async function peekOAuthFlowState(
|
||||
db: SupabaseClient,
|
||||
state: string,
|
||||
kind: OAuthFlowKind,
|
||||
): Promise<OAuthFlowIdentity | null> {
|
||||
const { data, error } = await db
|
||||
.from('oauth_flows')
|
||||
.select('user_id, company_id, origin')
|
||||
.eq('id', state)
|
||||
.eq('kind', kind)
|
||||
.is('used_at', null)
|
||||
.gt('expires_at', new Date().toISOString())
|
||||
.maybeSingle()
|
||||
if (error || !data) return null
|
||||
return {
|
||||
userId: data.user_id as string,
|
||||
companyId: data.company_id as string,
|
||||
origin: data.origin as string,
|
||||
}
|
||||
}
|
||||
|
||||
/** Same as peekOAuthFlowState, for a live handoff bound to `origin`. */
|
||||
export async function peekOAuthFlowHandoff(
|
||||
db: SupabaseClient,
|
||||
handoffId: string,
|
||||
origin: string,
|
||||
kind: OAuthFlowKind,
|
||||
): Promise<OAuthFlowIdentity | null> {
|
||||
const { data, error } = await db
|
||||
.from('oauth_flows')
|
||||
.select('user_id, company_id, origin')
|
||||
.eq('handoff_id', handoffId)
|
||||
.eq('origin', origin)
|
||||
.eq('kind', kind)
|
||||
.gt('handoff_expires_at', new Date().toISOString())
|
||||
.maybeSingle()
|
||||
if (error || !data) return null
|
||||
return {
|
||||
userId: data.user_id as string,
|
||||
companyId: data.company_id as string,
|
||||
origin: data.origin as string,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the state once. The UPDATE's WHERE clause is the entire check:
|
||||
* unconsumed, unexpired, of the expected kind. Null for every failure.
|
||||
*/
|
||||
export async function consumeOAuthFlowState(
|
||||
db: SupabaseClient,
|
||||
state: string,
|
||||
kind: OAuthFlowKind,
|
||||
): Promise<OAuthFlow | null> {
|
||||
const now = new Date().toISOString()
|
||||
const { data, error } = await db
|
||||
.from('oauth_flows')
|
||||
.update({ used_at: now })
|
||||
.eq('id', state)
|
||||
.eq('kind', kind)
|
||||
.is('used_at', null)
|
||||
.gt('expires_at', now)
|
||||
.select(FLOW_COLUMNS)
|
||||
.maybeSingle()
|
||||
if (error || !data) return null
|
||||
try {
|
||||
return rowToFlow(data as FlowRow)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hop 1: stash the provider's result on the consumed row under a fresh
|
||||
* handoff id, encrypted and bound to the row, the handoff id and the
|
||||
* destination origin. Returns the handoff id to put in the hop-2 URL.
|
||||
*/
|
||||
export async function mintOAuthFlowHandoff(
|
||||
db: SupabaseClient,
|
||||
flow: OAuthFlow,
|
||||
result: { providerCode: string; providerError?: never } | { providerCode?: never; providerError: string },
|
||||
): Promise<string> {
|
||||
const handoffId = newOAuthFlowId()
|
||||
const identity = { id: flow.id, user_id: flow.userId, origin: flow.origin }
|
||||
const { data, error } = await db
|
||||
.from('oauth_flows')
|
||||
.update({
|
||||
handoff_id: handoffId,
|
||||
handoff_code:
|
||||
result.providerCode === undefined
|
||||
? null
|
||||
: encryptOAuthFlowValue(result.providerCode, handoffContext(identity, handoffId, 'handoff_code')),
|
||||
handoff_error:
|
||||
result.providerError === undefined
|
||||
? null
|
||||
: encryptOAuthFlowValue(result.providerError, handoffContext(identity, handoffId, 'handoff_error')),
|
||||
handoff_expires_at: new Date(Date.now() + OAUTH_FLOW_HANDOFF_TTL_SECONDS * 1000).toISOString(),
|
||||
})
|
||||
.eq('id', flow.id)
|
||||
.is('handoff_id', null)
|
||||
.not('used_at', 'is', null)
|
||||
.select('id')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Failed to mint OAuth handoff: ${error.message}`)
|
||||
if (!data) throw new Error('Failed to mint OAuth handoff: flow already handed off')
|
||||
return handoffId
|
||||
}
|
||||
|
||||
/**
|
||||
* Hop 2: claim the handoff once, only from the origin it was minted for.
|
||||
* DELETE RETURNING, so the held provider code leaves the database the moment
|
||||
* it is read. Null for every failure, including unreadable ciphertext.
|
||||
*/
|
||||
export async function consumeOAuthFlowHandoff(
|
||||
db: SupabaseClient,
|
||||
handoffId: string,
|
||||
origin: string,
|
||||
kind: OAuthFlowKind,
|
||||
): Promise<OAuthFlowHandoff | null> {
|
||||
const { data, error } = await db
|
||||
.from('oauth_flows')
|
||||
.delete()
|
||||
.eq('handoff_id', handoffId)
|
||||
.eq('origin', origin)
|
||||
.eq('kind', kind)
|
||||
.gt('handoff_expires_at', new Date().toISOString())
|
||||
.select(`${FLOW_COLUMNS}, handoff_code, handoff_error`)
|
||||
.maybeSingle()
|
||||
if (error || !data) return null
|
||||
const row = data as FlowRow & { handoff_code: string | null; handoff_error: string | null }
|
||||
try {
|
||||
const identity = { id: row.id, user_id: row.user_id, origin: row.origin }
|
||||
return {
|
||||
...rowToFlow(row),
|
||||
providerCode:
|
||||
row.handoff_code === null
|
||||
? null
|
||||
: decryptOAuthFlowValue(row.handoff_code, handoffContext(identity, handoffId, 'handoff_code')),
|
||||
providerError:
|
||||
row.handoff_error === null
|
||||
? null
|
||||
: decryptOAuthFlowValue(row.handoff_error, handoffContext(identity, handoffId, 'handoff_error')),
|
||||
}
|
||||
} catch {
|
||||
// The row is already gone. Tampered or unreadable credentials get the
|
||||
// same answer as an unknown or expired handoff.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop rows nothing can consume any more: an hour past state expiry, and
|
||||
* for rows that were handed off, an hour past handoff expiry too. Called
|
||||
* best-effort from /authorize; the row set is tiny and self-limiting.
|
||||
*/
|
||||
export async function purgeExpiredOAuthFlows(db: SupabaseClient): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString()
|
||||
const { error } = await db
|
||||
.from('oauth_flows')
|
||||
.delete()
|
||||
.lt('expires_at', cutoff)
|
||||
.or(`handoff_expires_at.is.null,handoff_expires_at.lt.${cutoff}`)
|
||||
if (error) throw new Error(`Failed to purge OAuth flows: ${error.message}`)
|
||||
}
|
||||
@@ -1228,6 +1228,8 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
mcp_tasks: 'MCP task handles: transient tool-call state with a 1-hour TTL',
|
||||
metered_events: 'billing telemetry',
|
||||
notice_dismissals: 'per-user UI notice dismissal state, not räkenskapsinformation',
|
||||
oauth_flows:
|
||||
'in-flight browser OAuth flow state (migration 20260907120000): state, origin, encrypted PKCE verifier and handoff; consumed by the callback, not räkenskapsinformation',
|
||||
notification_log: 'notification dedup log',
|
||||
operations: 'staged-operation workflow state',
|
||||
payment_match_log: 'derived matching log',
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Origins the Skatteverket OAuth popup may post back from.
|
||||
*
|
||||
* The OAuth callback is served from the host pinned by
|
||||
* NEXT_PUBLIC_SKV_OAUTH_BASE_URL: the redirect_uri registered with
|
||||
* Skatteverket in Utvecklarportalen, kept on the legacy app.gnubok.se
|
||||
* domain after the user-facing app moved to app.accounted.se. The panels
|
||||
* that open the popup therefore accept postMessage events from that origin
|
||||
* in addition to their own.
|
||||
* The provider redirects to the host pinned by NEXT_PUBLIC_SKV_OAUTH_BASE_URL
|
||||
* (the redirect_uri registered with Skatteverket in Utvecklarportalen, kept
|
||||
* on the legacy app.gnubok.se domain after the user-facing app moved to
|
||||
* app.accounted.se). Since the oauth_flows handoff (lib/auth/oauth-flows.ts)
|
||||
* that host only forwards the browser to the origin the flow started on,
|
||||
* and the success/error page posts from there: the opener's own origin.
|
||||
* The pinned host stays accepted for flows in flight across the deploy.
|
||||
*
|
||||
* Origin alone is never sufficient: callers must also verify that
|
||||
* event.source is the popup window they themselves opened.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- 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';
|
||||
@@ -0,0 +1,165 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getClient, getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
/**
|
||||
* Migration 20260907120000_oauth_flows.sql: the one-row-per-flow table behind
|
||||
* lib/auth/oauth-flows.ts. Locks in the three things the application relies
|
||||
* on and cannot prove with mocks:
|
||||
*
|
||||
* - the table is service-role only (RLS enabled, browser roles revoked);
|
||||
* - consuming the state and consuming the handoff are single statements
|
||||
* whose WHERE clause carries the whole check, so two concurrent
|
||||
* deliveries yield exactly one winner;
|
||||
* - the handoff can only be claimed from the origin it was minted for.
|
||||
*/
|
||||
|
||||
async function insertFlow(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
origin?: string
|
||||
expiresInSeconds?: number
|
||||
}): Promise<string> {
|
||||
const id = randomBytes(32).toString('base64url')
|
||||
await getPool().query(
|
||||
`INSERT INTO public.oauth_flows
|
||||
(id, kind, company_id, user_id, origin, redirect_uri, expires_at)
|
||||
VALUES ($1, 'skatteverket', $2, $3, $4, 'https://oauth.testbrand.example/cb',
|
||||
now() + make_interval(secs => $5))`,
|
||||
[id, params.companyId, params.userId, params.origin ?? 'https://app.testbrand.example', params.expiresInSeconds ?? 600],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
// Mirrors consumeOAuthFlowState's PostgREST statement.
|
||||
const CONSUME_STATE = `
|
||||
UPDATE public.oauth_flows SET used_at = now()
|
||||
WHERE id = $1 AND kind = 'skatteverket' AND used_at IS NULL AND expires_at > now()
|
||||
RETURNING id`
|
||||
|
||||
// Mirrors consumeOAuthFlowHandoff's PostgREST statement.
|
||||
const CONSUME_HANDOFF = `
|
||||
DELETE FROM public.oauth_flows
|
||||
WHERE handoff_id = $1 AND origin = $2 AND kind = 'skatteverket'
|
||||
AND handoff_expires_at > now()
|
||||
RETURNING id, handoff_code`
|
||||
|
||||
async function mintHandoff(id: string, expiresInSeconds = 120): Promise<string> {
|
||||
const handoffId = randomBytes(32).toString('base64url')
|
||||
await getPool().query(
|
||||
`UPDATE public.oauth_flows
|
||||
SET handoff_id = $2, handoff_code = 'v1:ciphertext',
|
||||
handoff_expires_at = now() + make_interval(secs => $3)
|
||||
WHERE id = $1`,
|
||||
[id, handoffId, expiresInSeconds],
|
||||
)
|
||||
return handoffId
|
||||
}
|
||||
|
||||
describe('oauth_flows (pg)', () => {
|
||||
async function expectDenied(role: 'anon' | 'authenticated', sql: string) {
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SET LOCAL ROLE ${role}`)
|
||||
await expect(client.query(sql)).rejects.toThrow(/permission denied/i)
|
||||
} finally {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
it('denies the browser-facing roles entirely', async () => {
|
||||
await expectDenied('anon', 'SELECT * FROM public.oauth_flows LIMIT 1')
|
||||
await expectDenied('authenticated', 'SELECT * FROM public.oauth_flows LIMIT 1')
|
||||
await expectDenied(
|
||||
'authenticated',
|
||||
`INSERT INTO public.oauth_flows (id, kind, company_id, user_id, origin, redirect_uri, expires_at)
|
||||
VALUES ('x', 'skatteverket', gen_random_uuid(), gen_random_uuid(), 'https://a', 'https://b', now())`,
|
||||
)
|
||||
})
|
||||
|
||||
it('lets exactly one of two concurrent deliveries consume the state', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const id = await insertFlow({ companyId, userId })
|
||||
|
||||
const a = await getClient()
|
||||
const b = await getClient()
|
||||
try {
|
||||
const [ra, rb] = await Promise.all([
|
||||
a.query(CONSUME_STATE, [id]),
|
||||
b.query(CONSUME_STATE, [id]),
|
||||
])
|
||||
expect(ra.rowCount! + rb.rowCount!).toBe(1)
|
||||
} finally {
|
||||
a.release()
|
||||
b.release()
|
||||
}
|
||||
|
||||
// And nothing after that: the state is spent.
|
||||
const again = await getPool().query(CONSUME_STATE, [id])
|
||||
expect(again.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses an expired state', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const id = await insertFlow({ companyId, userId, expiresInSeconds: -1 })
|
||||
const res = await getPool().query(CONSUME_STATE, [id])
|
||||
expect(res.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
it('claims the handoff once, and only from the recorded origin', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const id = await insertFlow({ companyId, userId, origin: 'https://brand.testbrand.example' })
|
||||
await getPool().query(CONSUME_STATE, [id])
|
||||
const handoffId = await mintHandoff(id)
|
||||
|
||||
// Wrong origin: nothing, and the row is still there for the right one.
|
||||
const wrong = await getPool().query(CONSUME_HANDOFF, [handoffId, 'https://app.testbrand.example'])
|
||||
expect(wrong.rowCount).toBe(0)
|
||||
|
||||
const a = await getClient()
|
||||
const b = await getClient()
|
||||
try {
|
||||
const [ra, rb] = await Promise.all([
|
||||
a.query(CONSUME_HANDOFF, [handoffId, 'https://brand.testbrand.example']),
|
||||
b.query(CONSUME_HANDOFF, [handoffId, 'https://brand.testbrand.example']),
|
||||
])
|
||||
expect(ra.rowCount! + rb.rowCount!).toBe(1)
|
||||
const winner = ra.rowCount === 1 ? ra : rb
|
||||
expect(winner.rows[0]!.handoff_code).toBe('v1:ciphertext')
|
||||
} finally {
|
||||
a.release()
|
||||
b.release()
|
||||
}
|
||||
|
||||
// DELETE RETURNING: the held code left the database with the claim.
|
||||
const gone = await getPool().query('SELECT 1 FROM public.oauth_flows WHERE id = $1', [id])
|
||||
expect(gone.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses an expired handoff', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const id = await insertFlow({ companyId, userId, origin: 'https://brand.testbrand.example' })
|
||||
await getPool().query(CONSUME_STATE, [id])
|
||||
const handoffId = await mintHandoff(id, -1)
|
||||
const res = await getPool().query(CONSUME_HANDOFF, [handoffId, 'https://brand.testbrand.example'])
|
||||
expect(res.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a handoff written onto an unconsumed state', async () => {
|
||||
// The shape constraint: a handoff only exists for a state hop 1 consumed,
|
||||
// so a stray write can never make an unconsumed state claimable twice.
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const id = await insertFlow({ companyId, userId })
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.oauth_flows
|
||||
SET handoff_id = 'h', handoff_code = 'v1:x', handoff_expires_at = now() + interval '2 minutes'
|
||||
WHERE id = $1`,
|
||||
[id],
|
||||
),
|
||||
).rejects.toThrow(/oauth_flows_handoff_shape/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user