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:
co-authored by
Claude Fable 5.1
parent
cb962fae88
commit
f047c3d7d1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user