feat(connect): wire the SKV extension through the connector broker + data proxy (PR6b-2) (#2103)
* feat(connect): wire the SKV extension through the connector broker + data proxy (PR6b-2) In connector mode (GNUBOK_CONNECTOR_KEY set, no own SKV credentials) the Skatteverket extension now routes through the hosted connector stack (#1757) instead of calling Skatteverket directly: - skvRequestWithAuth routes to the data proxy: base URL maps to a service segment (moms/skattekonto/agd-inlamning/agd-period), the user's SKV Bearer moves to X-Connector-Upstream-Authorization, the connector key authenticates the proxy, and the gateway Client_Id/Client_Secret are omitted (the proxy adds Arcim's). Connector-layer 4xx bodies (code CONNECTOR_*) are classified before the SKV-shaped 401/403 sniffing so a broker refusal surfaces operator guidance (check GNUBOK_CONNECTOR_KEY), never APIGW/BankID guidance for knobs the instance does not have. - OAuth: /authorize starts the consent via the broker's authorize-url (persisting its redirect_uri + connector_state), the hosted SKV callback bounces the code back to the instance, and exchangeCodeForTokens / refreshAccessToken exchange through the broker's /oauth/token, unwrapping its { data } envelope. Tokens still rest encrypted on the instance; client_id/client_secret never exist there. - Broker refresh 404 CONNECTOR_NOT_OWNED maps to SESSION_EXPIRED (terminal; reconnect fixes); broker 502 stays a raw error so a transient SKV outage never re-arms the reconnect banner (#1155). - getSkatteverketEnvironment() reports 'prod' in connector mode: the upstream env is hosted's, and the instance's unset defaults would show a false Testmiljo badge on real filings. - System (CCG/ombud) auth is deliberately not brokered: hosted-only, stays direct. Hosted and own-credentials self-hosts are byte-identical: every branch gates on skatteverketConnectorMode(), which is null whenever own SKV credentials exist or no connector key is set. Direct-path tests pin that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRfamAKDqvRNwbjr5XD2VS * fix(connect): classify SKV dead-refresh-token dialects broker-side; forward diagnostic headers; connector-aware gateway guidance Skeptic refutation on PR #2103 (found independently by the correctness and compliance skeptics): the broker's /oauth/token catch-all collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the dominant refresh outcome, per-flow tokens live 65 minutes) into the generic 502 CONNECTOR_SKV_TOKEN_FAILED, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing operations consumed as non-recoverable, crons retrying raw forever. - Broker /oauth/token: re-codes those dialects as 401 CONNECTOR_SKV_REFRESH_DEAD, refresh grant only (invalid_grant on the code exchange means an expired one-shot code and keeps the generic 502). The classifier (isSkvDeadRefreshTokenError) uses the same regex set the extension's direct path classifies with. - Instance dead-token classifier maps CONNECTOR_SKV_REFRESH_DEAD to SESSION_EXPIRED alongside 404 CONNECTOR_NOT_OWNED; the generic 502 stays a raw error so a transient SKV outage never re-arms the reconnect banner. - Data proxy: forwards WWW-Authenticate and x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; body-less gateway rejections carry no other signal). - Instance gateway-refusal guidance is connector-aware: a self-host has no SKATTEVERKET_APIGW_CLIENT_ID and no Utvecklarportalen access, so connector mode points at /api/connector/status and support instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRfamAKDqvRNwbjr5XD2VS * fix(connect): reject redirects on the instance's broker OAuth requests CodeRabbit inline finding (CWE-200): the connector-mode authorize-url and token requests followed redirects by default, so a 307/308 would resend the connector key (and code/refresh token) to the redirect target. redirect 'error', matching the broker's own postToken rule; the token response must only ever come from the broker endpoint itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRfamAKDqvRNwbjr5XD2VS --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ee22c9c7b7
commit
08b1119c7d
@@ -1423,6 +1423,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] multi_user seat gate enforced app-side via a NEW gated RPC (resolve_active_company_gated) instead of editing resolve_active_company/current_active_company_id: the zero-arg RPC and the RLS twin also run on self-hosted DBs where the paywall must never bite, and the app picks the gated overload only when isMultiUserEnforced(). RLS convergence rides the existing used_fallback write-back. company_capability_config deliberately does not apply to multi_user (no expiry to hang the 20-day grace on).
|
||||
[2026-09-01] EU reverse-charge packs book directly on 4515/4535 instead of adding a 45xx D / 4598 K basbelopp pair (Anders' literal suggestion): same ruta 20/21 outcome, standard BAS practice for a template that owns the cost account anyway, and a 3-business-line pack would return null from convertLibraryToBookingTemplate and silently vanish from the transaction picker. The 4598 motkonto pattern remains the right tool only where the user's own cost account must be preserved (engine-generated bookings, supplier invoices).
|
||||
[2026-09-01] Floating supplier-invoice underlag gets a standing daily reanchor cron (/api/documents/reanchor/cron) instead of another one-off repair migration: prod case 2026-08-28 (kontantmetod payment verifikat, doc eligible on every static condition, inline anchor silently did nothing, no log line recorded why) is the second time a hand-written sweep (20260727180000, 20260824150000) was needed; the inline anchor is best-effort by design, so the retry belongs in infrastructure. anchorSupplierInvoiceDocument also stops claiming success on a zero-row guarded update and logs its silent bail branches.
|
||||
[2026-09-01] SKV connector instance wiring (PR6b-2): system (CCG/ombud) auth is deliberately NOT brokered; it stays on the direct path and fails SYSTEM_AUTH_FAILED on a credential-less self-host, because the org certificate and ombud grants are hosted-only. getSkatteverketEnvironment() hard-reports 'prod' in connector mode: the upstream env is resolved from HOSTED's config, and the instance's unset base URLs would otherwise show a Testmiljo badge on real filings (reporting hosted's actual env via /api/connector/status is a #2090 follow-up). buildAuthorizeUrl stays direct-only rather than going async: the connector authorize needs to return the broker's redirect_uri + connector_state for persistence, so index.ts branches to startConnectorAuthorization instead of overloading one function. Connector-layer 4xx bodies (code CONNECTOR_*) are classified BEFORE the SKV-shaped 401/403 sniffing so a broker refusal never tells a self-host operator to check SKATTEVERKET_APIGW_CLIENT_ID; broker refresh 404 CONNECTOR_NOT_OWNED maps to SESSION_EXPIRED (terminal, reconnect fixes) while broker 502 stays a raw error (transient SKV outage must not re-arm the reconnect banner, #1155).
|
||||
[2026-09-01] multi_user skeptic fixes: Stripe cancel EXPIRES the multi_user stripe grant instead of deleting it (grace anchor; other grants still deleted per freeze-and-retain); app-side state checks go RPC-first via SECURITY DEFINER company_multi_user_state (capability_grants RLS hides team rows from non-team users, byrå clients would misread as frozen); byra-kind teams get a standing team-scoped multi_user grant via backfill + teams trigger (WL-10 assumption made real; partner billing is out-of-band); PGRST202 on resolution fails OPEN (pre-migration DB has no multi_user rows: gated fallback would freeze all non-owners); /api/v1 got the same dormancy gate as MCP. RLS-level enforcement and the mid-session API fallback write-back window stay v2 follow-ups (documented, same class as pre-existing stale-preference fallback).
|
||||
[2026-09-01] Declined CodeRabbit's UpgradeNote suggestion (PR #1758 follow-up) to append the self-host connector sentence to children instead of replacing them: every caller's children is hosted subscription copy ("... kräver ett abonnemang"), so appending would show subscription wording on a self-host, the exact thing the branch exists to avoid; the "CSV/SIE import stays free" text it cited is a code comment in BankSyncNowButton, not children. Replace-on-self-host stays; a dedicated selfHosted children prop can come when a caller actually needs per-panel reassurance there.
|
||||
[2026-09-01] getConnectorConfig() rebuilds baseUrl as origin + path (userinfo/query/fragment stripped, warn-logged without the raw value): /api/connector/status echoes baseUrl to the operator and the sync/proxy URLs get paths appended, so nothing secret-shaped pasted into GNUBOK_CONNECT_URL may survive; the stripped parts were never meaningful in a base URL. The status route is also Cache-Control: no-store (key prefix + wiring layout out of shared browser caches).
|
||||
[2026-09-01] SKV connector refresh classification fixed broker-side (skeptic refutation on PR #2103, found independently by two skeptics): the broker's /oauth/token catch-all had collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the DOMINANT refresh outcome, per-flow tokens live 65 min) into the generic 502, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing ops consumed as non-recoverable, crons retrying raw forever. The broker now re-codes those dialects (refresh grant only, never the code exchange where invalid_grant means an expired one-shot code) as 401 CONNECTOR_SKV_REFRESH_DEAD, which the instance maps to SESSION_EXPIRED; the generic 502 remains raw so a transient SKV outage still never re-arms the reconnect banner (#1155). Same pass: the data proxy now forwards WWW-Authenticate + x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; nothing secret rides in them), and the instance's gateway-refusal guidance is connector-aware (a self-host has no SKATTEVERKET_APIGW_CLIENT_ID or Utvecklarportalen access: point at /api/connector/status + support instead).
|
||||
|
||||
@@ -89,6 +89,30 @@ describe('skv data proxy', () => {
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://api.skv/agd/inlamning/v1/underlag')
|
||||
expect(fetchMock.mock.calls[0][1].body).toBe('<xml/>')
|
||||
})
|
||||
it('forwards SKV diagnostic headers (WWW-Authenticate, x-skv-*) but nothing else', async () => {
|
||||
// The instance's 401 classifier reads WWW-Authenticate (insufficient_scope
|
||||
// → MISSING_SCOPE) and the x-skv-* family on body-less gateway
|
||||
// rejections; stripping them blinded connector-mode classification.
|
||||
fetchMock.mockResolvedValueOnce(new Response('', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
|
||||
'x-skv-trace': 'abc',
|
||||
'x-amzn-requestid': 'req-1',
|
||||
'set-cookie': 'secret=1',
|
||||
'x-internal-other': 'nope',
|
||||
},
|
||||
}))
|
||||
const res = await GET(req('GET', '/moms/x'))
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.headers.get('WWW-Authenticate')).toBe('Bearer error="insufficient_scope", scope="agd"')
|
||||
expect(res.headers.get('x-skv-trace')).toBe('abc')
|
||||
expect(res.headers.get('x-amzn-requestid')).toBe('req-1')
|
||||
expect(res.headers.get('set-cookie')).toBeNull()
|
||||
expect(res.headers.get('x-internal-other')).toBeNull()
|
||||
})
|
||||
|
||||
it('429 when the budget is exhausted', async () => {
|
||||
hh.budget.mockResolvedValue({ ok: false, scope: 'hour', retryAfterSec: 3600 })
|
||||
const res = await GET(req('GET', '/moms/x'))
|
||||
|
||||
@@ -106,7 +106,20 @@ async function handle(request: Request, ctx: ConnectorContext): Promise<Response
|
||||
})
|
||||
const text = await res.text()
|
||||
if ([204, 205, 304].includes(res.status)) return new NextResponse(null, { status: res.status })
|
||||
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' } })
|
||||
// Forward SKV's diagnostic headers: WWW-Authenticate carries OAuth's
|
||||
// machine-readable failure reason (the instance's insufficient_scope →
|
||||
// MISSING_SCOPE classification depends on it), and the x-skv-*/x-amzn-*/
|
||||
// x-api-* families are the only signal on body-less gateway rejections.
|
||||
// Nothing secret rides in them; stripping them blinded the instance's
|
||||
// 401 classifier (skeptic finding on PR6b-2).
|
||||
const headers: Record<string, string> = { 'Content-Type': res.headers.get('content-type') ?? 'application/json' }
|
||||
res.headers.forEach((v, k) => {
|
||||
const lk = k.toLowerCase()
|
||||
if (lk === 'www-authenticate' || lk.startsWith('x-skv-') || lk.startsWith('x-amzn-') || lk.startsWith('x-api-')) {
|
||||
headers[k] = v
|
||||
}
|
||||
})
|
||||
return new NextResponse(text, { status: res.status, headers })
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,16 @@ const hh = vi.hoisted(() => ({
|
||||
findRefresh: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/connect/hosted/upstream-budget', () => ({ reserveUpstream: (...a: unknown[]) => hh.budget(...a) }))
|
||||
vi.mock('@/lib/connect/upstreams/skatteverket-oauth', () => ({ exchangeSkvCode: (...a: unknown[]) => hh.exchange(...a), refreshSkvToken: (...a: unknown[]) => hh.refresh(...a) }))
|
||||
vi.mock('@/lib/connect/upstreams/skatteverket-oauth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/connect/upstreams/skatteverket-oauth')>()
|
||||
return {
|
||||
exchangeSkvCode: (...a: unknown[]) => hh.exchange(...a),
|
||||
refreshSkvToken: (...a: unknown[]) => hh.refresh(...a),
|
||||
// The dead-dialect classifier stays REAL: these tests pin that the route
|
||||
// re-codes exactly the dialects the classifier recognizes.
|
||||
isSkvDeadRefreshTokenError: actual.isSkvDeadRefreshTokenError,
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/connect/hosted/ledger', () => ({
|
||||
activateByPendingState: (...a: unknown[]) => hh.activate(...a),
|
||||
findPendingByState: (...a: unknown[]) => hh.findPending(...a),
|
||||
@@ -102,4 +111,45 @@ describe('POST /api/connect/skv/oauth/token', () => {
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'authorization_code', code: 'c', redirect_uri: 'https://app.gnubok.se/cb', connector_state: 'ck1.signed' } }))
|
||||
expect(res.status).toBe(502)
|
||||
})
|
||||
|
||||
// SKV per-flow refresh tokens live 65 minutes, so a dead refresh token is
|
||||
// the DOMINANT refresh outcome. The broker must re-code SKV's terminal
|
||||
// dialects distinctly: the instance maps this to SESSION_EXPIRED and shows
|
||||
// its reconnect flow. Collapsing it into the generic 502 stripped every
|
||||
// connector instance of that flow (skeptic refutation on PR6b-2).
|
||||
it('401 CONNECTOR_SKV_REFRESH_DEAD when SKV declares the refresh token dead', async () => {
|
||||
hh.refresh.mockRejectedValue(new Error(
|
||||
'Skatteverket token refresh failed (404): {"error":"id_not_found","error_description":"The refresh token is not found"}',
|
||||
))
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'refresh_token', refresh_token: 'rt' } }))
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_SKV_REFRESH_DEAD')
|
||||
})
|
||||
|
||||
it('401 CONNECTOR_SKV_REFRESH_DEAD on the 400 invalid_grant dialect too', async () => {
|
||||
hh.refresh.mockRejectedValue(new Error(
|
||||
'Skatteverket token refresh failed (400): {"error": "invalid_grant"}',
|
||||
))
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'refresh_token', refresh_token: 'rt' } }))
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_SKV_REFRESH_DEAD')
|
||||
})
|
||||
|
||||
it('keeps transient refresh failures as the generic 502 (never a reconnect signal)', async () => {
|
||||
hh.refresh.mockRejectedValue(new Error('Skatteverket token refresh failed (503): upstream unavailable'))
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'refresh_token', refresh_token: 'rt' } }))
|
||||
expect(res.status).toBe(502)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_SKV_TOKEN_FAILED')
|
||||
})
|
||||
|
||||
it('does NOT re-code a dead-dialect failure on the authorization_code grant', async () => {
|
||||
// An expired one-shot code also answers 400 invalid_grant, but that is
|
||||
// not "refresh token dead": the exchange keeps the generic 502.
|
||||
hh.exchange.mockRejectedValue(new Error(
|
||||
'Skatteverket token exchange failed (400): {"error": "invalid_grant"}',
|
||||
))
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'authorization_code', code: 'c', redirect_uri: 'https://app.gnubok.se/cb', connector_state: 'ck1.signed' } }))
|
||||
expect(res.status).toBe(502)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_SKV_TOKEN_FAILED')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
|
||||
import { exchangeSkvCode, refreshSkvToken, type SkvTokenResponse } from '@/lib/connect/upstreams/skatteverket-oauth'
|
||||
import { exchangeSkvCode, isSkvDeadRefreshTokenError, refreshSkvToken, type SkvTokenResponse } from '@/lib/connect/upstreams/skatteverket-oauth'
|
||||
import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget'
|
||||
import { activateByPendingState, findByRefreshHash, findPendingByState, hashHandle } from '@/lib/connect/hosted/ledger'
|
||||
import { verifyConnectorState } from '@/lib/connect/hosted/state'
|
||||
@@ -140,7 +140,22 @@ export const POST = withConnectorAuth('connect.skv', async (request, ctx) => {
|
||||
}
|
||||
return tokenResponse(tokens)
|
||||
} catch (err) {
|
||||
ctx.log.warn('skv token exchange failed', { err: err instanceof Error ? err.message : String(err) })
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
// Ordinary session expiry must be distinguishable from a transient
|
||||
// failure: SKV's per-flow refresh tokens live 65 minutes, so a dead
|
||||
// refresh token is the DOMINANT outcome here, and collapsing it into the
|
||||
// generic 502 stripped every connector instance of its reconnect flow
|
||||
// (raw 500s, no banner, cron retry spam). The instance maps this code to
|
||||
// SESSION_EXPIRED; everything else stays the opaque 502 so a transient
|
||||
// SKV outage never masquerades as "reconnect needed".
|
||||
if (parsed.data.grant_type === 'refresh_token' && isSkvDeadRefreshTokenError(message)) {
|
||||
ctx.log.info('skv refresh token expired at upstream', { code: 'CONNECTOR_SKV_REFRESH_DEAD' })
|
||||
return NextResponse.json(
|
||||
{ error: 'Skatteverket refresh token is no longer valid; a new BankID consent is required', code: 'CONNECTOR_SKV_REFRESH_DEAD' },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
ctx.log.warn('skv token exchange failed', { err: message })
|
||||
return NextResponse.json({ error: 'Skatteverket token exchange failed', code: 'CONNECTOR_SKV_TOKEN_FAILED' }, { status: 502 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
// Mock the token-store to bypass DB and supply a fresh access token.
|
||||
vi.mock('../lib/token-store', () => ({
|
||||
getTokens: vi.fn(async () => ({
|
||||
access_token: 'user-skv-token',
|
||||
refresh_token: 'user-skv-refresh',
|
||||
expires_at: Date.now() + 60 * 60_000,
|
||||
refresh_count: 0,
|
||||
scope: 'momsdeklaration',
|
||||
})),
|
||||
storeTokens: vi.fn(),
|
||||
deleteTokens: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/oauth', () => ({
|
||||
refreshAccessToken: vi.fn(async () => ({
|
||||
access_token: 'refreshed',
|
||||
refresh_token: 'refreshed-r',
|
||||
expires_at: Date.now() + 60 * 60_000,
|
||||
refresh_count: 1,
|
||||
})),
|
||||
exchangeCodeForTokens: vi.fn(),
|
||||
}))
|
||||
|
||||
import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from '../lib/api-client'
|
||||
|
||||
const fakeSupabase = {} as unknown as Parameters<typeof skvRequest>[0]
|
||||
|
||||
const ENV_VARS = [
|
||||
'SKATTEVERKET_APIGW_CLIENT_ID',
|
||||
'SKATTEVERKET_APIGW_CLIENT_SECRET',
|
||||
'SKATTEVERKET_OAUTH2_CLIENT_ID',
|
||||
'SKATTEVERKET_API_BASE_URL',
|
||||
'SKATTEVERKET_AGD_PERIOD_API_BASE_URL',
|
||||
'GNUBOK_CONNECTOR_KEY',
|
||||
'GNUBOK_CONNECT_URL',
|
||||
]
|
||||
|
||||
/**
|
||||
* Connector-mode env: a self-host with a connector key and NO own SKV
|
||||
* credentials. Env is stubbed in beforeEach, never at module top: a base-URL
|
||||
* const captured at import time would not see module-top stubs.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
for (const v of ENV_VARS) delete process.env[v]
|
||||
process.env.GNUBOK_CONNECTOR_KEY = 'gnubok_ck_test'
|
||||
process.env.GNUBOK_CONNECT_URL = 'https://app.hosted.example'
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const v of ENV_VARS) delete process.env[v]
|
||||
})
|
||||
|
||||
function mockFetchStatus(status: number, body = '', headers?: HeadersInit) {
|
||||
const mock = vi.fn(async () => new Response(body, { status, headers }))
|
||||
global.fetch = mock as unknown as typeof fetch
|
||||
return mock
|
||||
}
|
||||
|
||||
function lastFetchCall(mock: ReturnType<typeof vi.fn>): [string, RequestInit] {
|
||||
return mock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
}
|
||||
|
||||
describe('skvRequestWithAuth: connector mode', () => {
|
||||
it('routes to the data proxy with remapped headers and NO gateway credentials', async () => {
|
||||
const fetchMock = mockFetchStatus(200, '{"ok":true}')
|
||||
|
||||
const res = await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/deklarationer')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const [url, init] = lastFetchCall(fetchMock)
|
||||
// Default (unset) moms base → the moms service segment; the proxy
|
||||
// resolves the real upstream from hosted's env, not this instance's.
|
||||
expect(url).toBe('https://app.hosted.example/api/connect/skv/api/moms/deklarationer')
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers['Authorization']).toBe('Bearer gnubok_ck_test')
|
||||
expect(headers['X-Connector-Upstream-Authorization']).toBe('Bearer user-skv-token')
|
||||
// The gateway credentials are the broker's secret; the instance has none
|
||||
// and must not try to read them (getApiGwClientId would throw).
|
||||
expect(headers['Client_Id']).toBeUndefined()
|
||||
expect(headers['Client_Secret']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps a per-service baseUrl to its proxy segment', async () => {
|
||||
const fetchMock = mockFetchStatus(200, '{}')
|
||||
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/kvittenser', undefined, {
|
||||
baseUrl: 'https://api.test.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1',
|
||||
})
|
||||
|
||||
const [url] = lastFetchCall(fetchMock)
|
||||
expect(url).toBe('https://app.hosted.example/api/connect/skv/api/agd-period/kvittenser')
|
||||
})
|
||||
|
||||
it('mirrors the content type to the upstream content-type header on bodied requests', async () => {
|
||||
const fetchMock = mockFetchStatus(200, '{}')
|
||||
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'POST', '/underlag', '<xml/>', {
|
||||
baseUrl: 'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1',
|
||||
contentType: 'application/xml',
|
||||
})
|
||||
|
||||
const [url, init] = lastFetchCall(fetchMock)
|
||||
expect(url).toBe('https://app.hosted.example/api/connect/skv/api/agd-inlamning/underlag')
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers['Content-Type']).toBe('application/xml')
|
||||
expect(headers['X-Connector-Upstream-Content-Type']).toBe('application/xml')
|
||||
expect(init.body).toBe('<xml/>')
|
||||
})
|
||||
|
||||
it('classifies a broker key refusal as ACCESS_DENIED with connector guidance, not APIGW guidance', async () => {
|
||||
mockFetchStatus(401, '{"error":"Invalid connector key","code":"CONNECTOR_KEY_INVALID"}')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
|
||||
const { message } = e as SkatteverketAuthError
|
||||
// The operator's actual knob, not the hosted APIGW's.
|
||||
expect(message).toMatch(/GNUBOK_CONNECTOR_KEY/)
|
||||
expect(message).not.toMatch(/SKATTEVERKET_APIGW_CLIENT_ID|Utvecklarportalen/)
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies CONNECTOR_NOT_OWNED (ledger no longer vouches) as SESSION_EXPIRED', async () => {
|
||||
mockFetchStatus(404, '{"error":"Unknown Skatteverket connection for this key","code":"CONNECTOR_NOT_OWNED"}')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
|
||||
expect((e as SkatteverketAuthError).message).toMatch(/BankID/)
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies CONNECTOR_RATE_LIMITED as RATE_LIMITED', async () => {
|
||||
mockFetchStatus(429, '{"error":"busy","code":"CONNECTOR_RATE_LIMITED"}')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('RATE_LIMITED')
|
||||
}
|
||||
})
|
||||
|
||||
it('still maps an upstream SKV body passed through the proxy with the normal rules', async () => {
|
||||
// No CONNECTOR_* code → this is Skatteverket answering through the
|
||||
// proxy; the pre-connector classification must keep applying.
|
||||
mockFetchStatus(403, 'Behörighet saknas för aktören')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('BEHORIGHET_SAKNAS')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the passed-through WWW-Authenticate classification: insufficient_scope → MISSING_SCOPE', async () => {
|
||||
// The proxy forwards SKV's diagnostic headers, so the direct path's
|
||||
// scope classification must keep working through the connector.
|
||||
mockFetchStatus(401, '', {
|
||||
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
|
||||
})
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
|
||||
}
|
||||
})
|
||||
|
||||
it('gives connector guidance (never APIGW/Utvecklarportalen) on a body-less passthrough 401', async () => {
|
||||
// An empty 401 through the proxy means the HOSTED gateway config
|
||||
// refused; the operator's instance has no SKATTEVERKET_APIGW_CLIENT_ID
|
||||
// to check, so the direct path's guidance would be a dead end.
|
||||
mockFetchStatus(401, '')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
|
||||
const { message } = e as SkatteverketAuthError
|
||||
expect(message).toMatch(/connectorn/)
|
||||
expect(message).not.toMatch(/SKATTEVERKET_APIGW_CLIENT_ID|Utvecklarportalen/)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives connector guidance on the APIGW scope-contract 403 passthrough', async () => {
|
||||
mockFetchStatus(403, '{"error": "The required scopes are not authorized"}')
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
|
||||
const { message } = e as SkatteverketAuthError
|
||||
expect(message).toMatch(/connectorn/)
|
||||
expect(message).not.toMatch(/Utvecklarportalen/)
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies the broker CONNECTOR_SKV_REFRESH_DEAD dialect as SESSION_EXPIRED', async () => {
|
||||
// The everyday case: SKV per-flow refresh tokens die after 65 minutes;
|
||||
// the broker re-codes SKV's dead-token dialects as 401
|
||||
// CONNECTOR_SKV_REFRESH_DEAD, and the reconnect flow must fire exactly
|
||||
// as it does on the direct path.
|
||||
const { getTokens } = await import('../lib/token-store')
|
||||
const { refreshAccessToken } = await import('../lib/oauth')
|
||||
const expiredTokens = {
|
||||
access_token: 'stale',
|
||||
refresh_token: 'old-refresh',
|
||||
expires_at: Date.now() - 60_000,
|
||||
refresh_count: 1,
|
||||
scope: 'momsdeklaration',
|
||||
}
|
||||
vi.mocked(getTokens)
|
||||
.mockResolvedValueOnce(expiredTokens)
|
||||
.mockResolvedValueOnce(expiredTokens)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
|
||||
new Error(
|
||||
'Skatteverket token refresh failed (401): {"error":"Skatteverket refresh token is no longer valid; a new BankID consent is required","code":"CONNECTOR_SKV_REFRESH_DEAD"}',
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-connector-dead', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
|
||||
expect((e as SkatteverketAuthError).message).toMatch(/Sessionen har gått ut/)
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies the broker refresh dialect (404 CONNECTOR_NOT_OWNED) as SESSION_EXPIRED', async () => {
|
||||
const { getTokens } = await import('../lib/token-store')
|
||||
const { refreshAccessToken } = await import('../lib/oauth')
|
||||
const expiredTokens = {
|
||||
access_token: 'stale',
|
||||
refresh_token: 'dead-refresh',
|
||||
expires_at: Date.now() - 60_000,
|
||||
refresh_count: 1,
|
||||
scope: 'momsdeklaration',
|
||||
}
|
||||
vi.mocked(getTokens)
|
||||
.mockResolvedValueOnce(expiredTokens)
|
||||
.mockResolvedValueOnce(expiredTokens)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
|
||||
new Error(
|
||||
'Skatteverket token refresh failed (404): {"error":"Unknown connection for this key","code":"CONNECTOR_NOT_OWNED"}',
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-connector-404', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
expect((e as SkatteverketAuthError).code).toBe('SESSION_EXPIRED')
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves broker 502s (CONNECTOR_SKV_TOKEN_FAILED) as raw errors, never a reconnect flag', async () => {
|
||||
// A transient SKV outage behind the broker must not tell the user to
|
||||
// reconnect (#1155): only the 404 not-owned dialect is terminal.
|
||||
const { getTokens } = await import('../lib/token-store')
|
||||
const { refreshAccessToken } = await import('../lib/oauth')
|
||||
const expiredTokens = {
|
||||
access_token: 'stale',
|
||||
refresh_token: 'r',
|
||||
expires_at: Date.now() - 60_000,
|
||||
refresh_count: 1,
|
||||
scope: 'momsdeklaration',
|
||||
}
|
||||
vi.mocked(getTokens)
|
||||
.mockResolvedValueOnce(expiredTokens)
|
||||
.mockResolvedValueOnce(expiredTokens)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValueOnce(
|
||||
new Error(
|
||||
'Skatteverket token refresh failed (502): {"error":"Skatteverket token exchange failed","code":"CONNECTOR_SKV_TOKEN_FAILED"}',
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
await skvRequest(fakeSupabase, 'user-connector-502', 'comp-1', 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).not.toBeInstanceOf(SkatteverketAuthError)
|
||||
expect((e as Error).message).toMatch(/502/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('skvRequestWithAuth: connector mode is OFF with own credentials (direct path byte-identical)', () => {
|
||||
it('a connector key does not reroute an own-credentials instance', async () => {
|
||||
// Hosted, and any self-host running its own SKV client: the direct path
|
||||
// must stay exactly as before, connector key or not.
|
||||
process.env.SKATTEVERKET_APIGW_CLIENT_ID = 'gw-id'
|
||||
process.env.SKATTEVERKET_APIGW_CLIENT_SECRET = 'gw-secret'
|
||||
process.env.SKATTEVERKET_API_BASE_URL = 'https://api.test.example/moms'
|
||||
const fetchMock = mockFetchStatus(200, '{"ok":true}')
|
||||
|
||||
await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/deklarationer')
|
||||
|
||||
const [url, init] = lastFetchCall(fetchMock)
|
||||
expect(url).toBe('https://api.test.example/moms/deklarationer')
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers['Authorization']).toBe('Bearer user-skv-token')
|
||||
expect(headers['Client_Id']).toBe('gw-id')
|
||||
expect(headers['Client_Secret']).toBe('gw-secret')
|
||||
expect(headers['skv_client_correlation_id']).toBeDefined()
|
||||
// Nothing connector-shaped leaks to the real upstream.
|
||||
expect(headers['X-Connector-Upstream-Authorization']).toBeUndefined()
|
||||
expect(headers['X-Connector-Upstream-Content-Type']).toBeUndefined()
|
||||
expect(JSON.stringify(headers)).not.toContain('gnubok_ck_')
|
||||
})
|
||||
})
|
||||
|
||||
describe('skvRequestWithAuth: system mode is never brokered', () => {
|
||||
it('unconfigured system auth on a connector self-host fails with SYSTEM_AUTH_FAILED before any fetch', async () => {
|
||||
// System (CCG/ombud) auth is a hosted-only feature: connector mode must
|
||||
// not reroute it, and a credential-less self-host fails cleanly.
|
||||
const fetchMock = mockFetchStatus(200, '{}')
|
||||
try {
|
||||
const { skvRequestWithAuth } = await import('../lib/api-client')
|
||||
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
|
||||
expect.fail('expected throw')
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(SkatteverketAuthError)
|
||||
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSkatteverketEnvironment: connector mode', () => {
|
||||
it("reports 'prod' in connector mode (hosted resolves the upstream, instance defaults would lie 'test')", () => {
|
||||
expect(getSkatteverketEnvironment()).toBe('prod')
|
||||
})
|
||||
|
||||
it('keeps the env-based answer when own credentials disable connector mode', () => {
|
||||
process.env.SKATTEVERKET_APIGW_CLIENT_ID = 'gw-id'
|
||||
expect(getSkatteverketEnvironment()).toBe('test')
|
||||
process.env.SKATTEVERKET_API_BASE_URL = 'https://api.skatteverket.se/momsdeklaration/v1'
|
||||
expect(getSkatteverketEnvironment()).toBe('prod')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
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.
|
||||
const { mockConnectorMode, mockStartAuth } = vi.hoisted(() => ({
|
||||
mockConnectorMode: vi.fn(),
|
||||
mockStartAuth: vi.fn(),
|
||||
}))
|
||||
vi.mock('../lib/connector-mode', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/connector-mode')>()
|
||||
return {
|
||||
...actual,
|
||||
skatteverketConnectorMode: mockConnectorMode,
|
||||
startConnectorAuthorization: mockStartAuth,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../lib/oauth', () => ({
|
||||
buildAuthorizeUrl: vi.fn().mockReturnValue('https://skv.test/authorize?direct=1'),
|
||||
generatePkcePair: vi.fn().mockReturnValue({ verifier: 'pkce-v', challenge: 'pkce-c' }),
|
||||
exchangeCodeForTokens: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
requireCapability: vi.fn(async () => null),
|
||||
}))
|
||||
|
||||
import { skatteverketExtension } from '../index'
|
||||
import { buildAuthorizeUrl } from '../lib/oauth'
|
||||
|
||||
function authorizeRoute() {
|
||||
const route = skatteverketExtension.apiRoutes?.find(
|
||||
(r) => r.method === 'GET' && r.path === '/authorize',
|
||||
)
|
||||
expect(route, 'GET /authorize must be registered').toBeDefined()
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://instans.example.se'
|
||||
delete process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL
|
||||
})
|
||||
|
||||
describe('skatteverket /authorize: connector mode', () => {
|
||||
beforeEach(() => {
|
||||
mockConnectorMode.mockReturnValue({
|
||||
baseUrl: 'https://app.hosted.example/api/connect/skv',
|
||||
key: 'gnubok_ck_test',
|
||||
})
|
||||
mockStartAuth.mockResolvedValue({
|
||||
authorizeUrl: 'https://peroauth2.test.skatteverket.se/oauth2/v1/per/authorize?broker=1',
|
||||
redirectUri: 'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
||||
connectorState: 'signed-cs',
|
||||
})
|
||||
})
|
||||
|
||||
it('starts the consent through the broker and stores its redirect_uri + connector_state', async () => {
|
||||
const { ctx, stored } = makeCtx()
|
||||
const res = await authorizeRoute().handler(
|
||||
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(307)
|
||||
expect(res.headers.get('location')).toBe(
|
||||
'https://peroauth2.test.skatteverket.se/oauth2/v1/per/authorize?broker=1',
|
||||
)
|
||||
|
||||
expect(mockStartAuth).toHaveBeenCalledWith(
|
||||
{ baseUrl: 'https://app.hosted.example/api/connect/skv', key: 'gnubok_ck_test' },
|
||||
{
|
||||
companyRef: 'company-1',
|
||||
// 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,
|
||||
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(stored.oauth_connector_state).toBe('signed-cs')
|
||||
expect(stored.oauth_code_verifier).toBe('pkce-v')
|
||||
expect(buildAuthorizeUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 502 with operator guidance when the broker refuses, storing no flow state', 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,
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skatteverket /authorize: direct mode', () => {
|
||||
it('builds the authorize URL locally and clears any stale connector state', async () => {
|
||||
mockConnectorMode.mockReturnValue(null)
|
||||
const { ctx, stored, cleared } = makeCtx()
|
||||
const res = await authorizeRoute().handler(
|
||||
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
||||
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',
|
||||
)
|
||||
// A row surviving from a connector-era flow must not leak into a direct
|
||||
// exchange.
|
||||
expect(cleared).toContain('oauth_connector_state')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import {
|
||||
baseUrlToService,
|
||||
parseConnectorCode,
|
||||
startConnectorAuthorization,
|
||||
exchangeConnectorCode,
|
||||
refreshConnectorToken,
|
||||
skatteverketConnectorMode,
|
||||
} from '../lib/connector-mode'
|
||||
|
||||
const CONNECTOR = { baseUrl: 'https://app.hosted.example/api/connect/skv', key: 'gnubok_ck_test' }
|
||||
|
||||
const SKV_ENV_VARS = [
|
||||
'SKATTEVERKET_API_BASE_URL',
|
||||
'SKATTEVERKET_SKATTEKONTO_API_BASE_URL',
|
||||
'SKATTEVERKET_AGD_INLAMNING_API_BASE_URL',
|
||||
'SKATTEVERKET_AGD_PERIOD_API_BASE_URL',
|
||||
'SKATTEVERKET_OAUTH2_CLIENT_ID',
|
||||
'SKATTEVERKET_APIGW_CLIENT_ID',
|
||||
'GNUBOK_CONNECTOR_KEY',
|
||||
'GNUBOK_CONNECT_URL',
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
for (const v of SKV_ENV_VARS) delete process.env[v]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const v of SKV_ENV_VARS) delete process.env[v]
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function mockFetchJson(status: number, json: unknown) {
|
||||
const mock = vi.fn(async () => new Response(JSON.stringify(json), { status }))
|
||||
global.fetch = mock as unknown as typeof fetch
|
||||
return mock
|
||||
}
|
||||
|
||||
describe('baseUrlToService', () => {
|
||||
// The proxy's allowlist has exactly these four segments; the map must pin
|
||||
// every default (test AND prod) so an unset-env instance and a
|
||||
// prod-configured one both route to the same service.
|
||||
it.each([
|
||||
['https://api.test.skatteverket.se/momsdeklaration/v1', 'moms'],
|
||||
['https://api.skatteverket.se/momsdeklaration/v1', 'moms'],
|
||||
['https://api.test.skatteverket.se/beskattning/skattekonto/v2', 'skattekonto'],
|
||||
['https://api.skatteverket.se/beskattning/skattekonto/v2', 'skattekonto'],
|
||||
['https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1', 'agd-inlamning'],
|
||||
['https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1', 'agd-inlamning'],
|
||||
['https://api.test.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1', 'agd-period'],
|
||||
['https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1', 'agd-period'],
|
||||
])('maps %s → %s', (base, service) => {
|
||||
expect(baseUrlToService(base)).toBe(service)
|
||||
})
|
||||
|
||||
it('maps an env-overridden base URL to its service by env-var identity', () => {
|
||||
process.env.SKATTEVERKET_AGD_PERIOD_API_BASE_URL = 'https://mock.example/agd-hantera'
|
||||
expect(baseUrlToService('https://mock.example/agd-hantera')).toBe('agd-period')
|
||||
})
|
||||
|
||||
it('ignores a trailing slash', () => {
|
||||
expect(baseUrlToService('https://api.test.skatteverket.se/momsdeklaration/v1/')).toBe('moms')
|
||||
})
|
||||
|
||||
it('throws loudly on an unmapped base instead of proxying to the wrong service', () => {
|
||||
expect(() => baseUrlToService('https://api.test.skatteverket.se/nagot-annat/v1')).toThrow(
|
||||
/Okänd Skatteverket-tjänst/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseConnectorCode', () => {
|
||||
it('reads a CONNECTOR_* code from a broker error body', () => {
|
||||
expect(parseConnectorCode('{"error":"x","code":"CONNECTOR_KEY_INVALID"}')).toBe(
|
||||
'CONNECTOR_KEY_INVALID',
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores non-connector codes and non-JSON bodies', () => {
|
||||
expect(parseConnectorCode('{"error":"invalid_scope","code":"MISSING_SCOPE"}')).toBeNull()
|
||||
expect(parseConnectorCode('The required scopes are not authorized')).toBeNull()
|
||||
expect(parseConnectorCode('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skatteverketConnectorMode (re-export sanity)', () => {
|
||||
it('is null without a key, non-null with a key and no own credentials', () => {
|
||||
expect(skatteverketConnectorMode()).toBeNull()
|
||||
process.env.GNUBOK_CONNECTOR_KEY = 'gnubok_ck_test'
|
||||
process.env.GNUBOK_CONNECT_URL = 'https://app.hosted.example'
|
||||
expect(skatteverketConnectorMode()).toEqual(CONNECTOR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startConnectorAuthorization', () => {
|
||||
it('POSTs the broker contract and unwraps { data }', async () => {
|
||||
const fetchMock = mockFetchJson(200, {
|
||||
data: {
|
||||
authorize_url: 'https://peroauth2.test.skatteverket.se/oauth2/v1/per/authorize?x=1',
|
||||
redirect_uri: 'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
||||
connector_state: 'signed-cs',
|
||||
},
|
||||
})
|
||||
|
||||
const started = await startConnectorAuthorization(CONNECTOR, {
|
||||
companyRef: 'company-1',
|
||||
returnUrl: 'https://instans.example.se/api/extensions/ext/skatteverket/callback',
|
||||
state: 'inst-state',
|
||||
codeChallenge: 'challenge-1234567890',
|
||||
})
|
||||
|
||||
expect(started).toEqual({
|
||||
authorizeUrl: 'https://peroauth2.test.skatteverket.se/oauth2/v1/per/authorize?x=1',
|
||||
redirectUri: 'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
||||
connectorState: 'signed-cs',
|
||||
})
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://app.hosted.example/api/connect/skv/oauth/authorize-url')
|
||||
expect(init.redirect).toBe('error')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer gnubok_ck_test')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
company_ref: 'company-1',
|
||||
return_url: 'https://instans.example.se/api/extensions/ext/skatteverket/callback',
|
||||
state: 'inst-state',
|
||||
code_challenge: 'challenge-1234567890',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws with status + body on a broker refusal (quota, key)', async () => {
|
||||
mockFetchJson(403, { error: 'quota', code: 'CONNECTOR_QUOTA_EXCEEDED' })
|
||||
await expect(
|
||||
startConnectorAuthorization(CONNECTOR, {
|
||||
companyRef: 'c',
|
||||
returnUrl: 'https://i.example/cb',
|
||||
state: 's',
|
||||
codeChallenge: 'challenge-1234567890',
|
||||
}),
|
||||
).rejects.toThrow(/403[\s\S]*CONNECTOR_QUOTA_EXCEEDED/)
|
||||
})
|
||||
|
||||
it('throws on a malformed broker response', async () => {
|
||||
mockFetchJson(200, { data: { authorize_url: 'https://x.example' } })
|
||||
await expect(
|
||||
startConnectorAuthorization(CONNECTOR, {
|
||||
companyRef: 'c',
|
||||
returnUrl: 'https://i.example/cb',
|
||||
state: 's',
|
||||
codeChallenge: 'challenge-1234567890',
|
||||
}),
|
||||
).rejects.toThrow(/oväntat svar/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exchangeConnectorCode / refreshConnectorToken', () => {
|
||||
it('sends the authorization_code grant with connector_state and no client credentials', async () => {
|
||||
const fetchMock = mockFetchJson(200, {
|
||||
data: { access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 'momsdeklaration' },
|
||||
})
|
||||
|
||||
const data = await exchangeConnectorCode(CONNECTOR, {
|
||||
code: 'auth-code',
|
||||
redirectUri: 'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
||||
codeVerifier: 'verifier-1',
|
||||
connectorState: 'signed-cs',
|
||||
})
|
||||
|
||||
expect(data.access_token).toBe('at')
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://app.hosted.example/api/connect/skv/oauth/token')
|
||||
// The token response must come from the broker endpoint itself: a
|
||||
// followed redirect would resend the connector key + code elsewhere.
|
||||
expect(init.redirect).toBe('error')
|
||||
const body = JSON.parse(init.body as string)
|
||||
expect(body).toEqual({
|
||||
grant_type: 'authorization_code',
|
||||
code: 'auth-code',
|
||||
redirect_uri: 'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
||||
code_verifier: 'verifier-1',
|
||||
connector_state: 'signed-cs',
|
||||
})
|
||||
// The whole point of the broker: Arcim's client credentials never appear
|
||||
// in anything the instance sends.
|
||||
expect(init.body as string).not.toMatch(/client_id|client_secret/)
|
||||
})
|
||||
|
||||
it('sends the refresh_token grant and unwraps { data }', async () => {
|
||||
const fetchMock = mockFetchJson(200, {
|
||||
data: { access_token: 'at2', refresh_token: 'rt2', expires_in: 3600, scope: 's' },
|
||||
})
|
||||
|
||||
const data = await refreshConnectorToken(CONNECTOR, 'rt1')
|
||||
expect(data).toEqual({ access_token: 'at2', refresh_token: 'rt2', expires_in: 3600, scope: 's' })
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'rt1',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the broker 404 CONNECTOR_NOT_OWNED dialect in the error message', async () => {
|
||||
// api-client's dead-refresh-token classifier matches on exactly this
|
||||
// shape (status 404 + CONNECTOR_NOT_OWNED) to map it to SESSION_EXPIRED.
|
||||
mockFetchJson(404, { error: 'Unknown connection for this key', code: 'CONNECTOR_NOT_OWNED' })
|
||||
await expect(refreshConnectorToken(CONNECTOR, 'rt-dead')).rejects.toThrow(
|
||||
/refresh failed \(404\)[\s\S]*CONNECTOR_NOT_OWNED/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -168,6 +168,7 @@ describe('skatteverket OAuth callback', () => {
|
||||
'abc',
|
||||
'https://app.example/api/extensions/ext/skatteverket/callback',
|
||||
'verifier-1',
|
||||
undefined,
|
||||
)
|
||||
expect(mockStoreTokens).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -280,6 +281,42 @@ describe('skatteverket OAuth callback', () => {
|
||||
)
|
||||
})
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { exchangeCodeForTokens, refreshAccessToken } from '../lib/oauth'
|
||||
|
||||
const ENV_VARS = [
|
||||
'SKATTEVERKET_OAUTH2_CLIENT_ID',
|
||||
'SKATTEVERKET_OAUTH2_CLIENT_SECRET',
|
||||
'SKATTEVERKET_APIGW_CLIENT_ID',
|
||||
'SKATTEVERKET_OAUTH_BASE_URL',
|
||||
'GNUBOK_CONNECTOR_KEY',
|
||||
'GNUBOK_CONNECT_URL',
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
for (const v of ENV_VARS) delete process.env[v]
|
||||
process.env.GNUBOK_CONNECTOR_KEY = 'gnubok_ck_test'
|
||||
process.env.GNUBOK_CONNECT_URL = 'https://app.hosted.example'
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const v of ENV_VARS) delete process.env[v]
|
||||
})
|
||||
|
||||
function mockFetchJson(status: number, json: unknown) {
|
||||
const mock = vi.fn(async () => new Response(JSON.stringify(json), { status }))
|
||||
global.fetch = mock as unknown as typeof fetch
|
||||
return mock
|
||||
}
|
||||
|
||||
describe('exchangeCodeForTokens: connector mode', () => {
|
||||
it('exchanges through the broker and maps the { data } envelope to SkatteverketTokens', async () => {
|
||||
const before = Date.now()
|
||||
const fetchMock = mockFetchJson(200, {
|
||||
data: { access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 'momsdeklaration ska' },
|
||||
})
|
||||
|
||||
const tokens = await exchangeCodeForTokens(
|
||||
'auth-code',
|
||||
'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
||||
'verifier-1',
|
||||
'signed-cs',
|
||||
)
|
||||
|
||||
expect(tokens.access_token).toBe('at')
|
||||
expect(tokens.refresh_token).toBe('rt')
|
||||
expect(tokens.refresh_count).toBe(0)
|
||||
expect(tokens.scope).toBe('momsdeklaration ska')
|
||||
expect(tokens.expires_at).toBeGreaterThanOrEqual(before + 3600 * 1000)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://app.hosted.example/api/connect/skv/oauth/token')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer gnubok_ck_test')
|
||||
const body = JSON.parse(init.body as string)
|
||||
expect(body.grant_type).toBe('authorization_code')
|
||||
expect(body.connector_state).toBe('signed-cs')
|
||||
expect(body.code_verifier).toBe('verifier-1')
|
||||
// client_id/client_secret must never leave the instance: it has none.
|
||||
expect(init.body as string).not.toMatch(/client_id|client_secret/)
|
||||
})
|
||||
|
||||
it('refuses to exchange without a connector_state (flow predates connector mode)', async () => {
|
||||
const fetchMock = mockFetchJson(200, { data: { access_token: 'at' } })
|
||||
await expect(
|
||||
exchangeCodeForTokens('auth-code', 'https://x.example/cb', 'verifier-1'),
|
||||
).rejects.toThrow(/connector_state saknas/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshAccessToken: connector mode', () => {
|
||||
it('refreshes through the broker, increments refresh_count, unwraps { data }', async () => {
|
||||
const fetchMock = mockFetchJson(200, {
|
||||
data: { access_token: 'at2', refresh_token: 'rt2', expires_in: 3600, scope: 's' },
|
||||
})
|
||||
|
||||
const tokens = await refreshAccessToken('rt1', 3)
|
||||
|
||||
expect(tokens.access_token).toBe('at2')
|
||||
expect(tokens.refresh_token).toBe('rt2')
|
||||
expect(tokens.refresh_count).toBe(4)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://app.hosted.example/api/connect/skv/oauth/token')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'rt1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('direct path unchanged when own credentials disable connector mode', () => {
|
||||
it('exchanges directly against SKV with client credentials in the form body', async () => {
|
||||
process.env.SKATTEVERKET_OAUTH2_CLIENT_ID = 'own-client'
|
||||
process.env.SKATTEVERKET_OAUTH2_CLIENT_SECRET = 'own-secret'
|
||||
const fetchMock = mockFetchJson(200, {
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
expires_in: 3600,
|
||||
scope: 'momsdeklaration',
|
||||
})
|
||||
|
||||
// The trailing connectorState arg is ignored on the direct path: an
|
||||
// own-credentials instance exchanges with SKV even if a stale
|
||||
// oauth_connector_state row survived a credentials change.
|
||||
const tokens = await exchangeCodeForTokens('auth-code', 'https://x.example/cb', 'v1', 'stale-cs')
|
||||
expect(tokens.access_token).toBe('at')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://peroauth2.test.skatteverket.se/oauth2/v1/per/token')
|
||||
const body = init.body as string
|
||||
expect(body).toContain('client_id=own-client')
|
||||
expect(body).toContain('client_secret=own-secret')
|
||||
expect(body).not.toContain('connector_state')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refreshes directly against SKV', async () => {
|
||||
process.env.SKATTEVERKET_OAUTH2_CLIENT_ID = 'own-client'
|
||||
process.env.SKATTEVERKET_OAUTH2_CLIENT_SECRET = 'own-secret'
|
||||
const fetchMock = mockFetchJson(200, {
|
||||
access_token: 'at2',
|
||||
refresh_token: 'rt2',
|
||||
expires_in: 3600,
|
||||
})
|
||||
|
||||
const tokens = await refreshAccessToken('rt1', 0)
|
||||
expect(tokens.refresh_count).toBe(1)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://peroauth2.test.skatteverket.se/oauth2/v1/per/token')
|
||||
expect(init.body as string).toContain('grant_type=refresh_token')
|
||||
expect(init.body as string).toContain('client_id=own-client')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { TimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
|
||||
import { skatteverketConnectorMode, startConnectorAuthorization } from './lib/connector-mode'
|
||||
import { isConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
|
||||
import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store'
|
||||
import { skvRequest, skvRequestWithAuth, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
|
||||
@@ -284,7 +285,7 @@ export const skatteverketExtension: Extension = {
|
||||
if (blocked) return blocked
|
||||
|
||||
const state = crypto.randomUUID()
|
||||
const redirectUri = `${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
|
||||
let redirectUri = `${getSkvOauthBaseUrl()}/api/extensions/ext/skatteverket/callback`
|
||||
|
||||
// Optional: where to send the user after the BankID round-trip.
|
||||
// Allowlisted to internal in-app paths to avoid open-redirect abuse.
|
||||
@@ -300,6 +301,44 @@ export const skatteverketExtension: Extension = {
|
||||
// tokens unless PKCE is present, so we always send it.
|
||||
const pkce = generatePkcePair()
|
||||
|
||||
// Connector mode (self-host with a connector key and no own SKV
|
||||
// client): the broker builds the authorize URL against Arcim's
|
||||
// registered SKV client and OUR hosted redirect_uri; the hosted
|
||||
// callback bounces the browser back to THIS instance's /callback with
|
||||
// the code. The broker's redirect_uri replaces the local one (the
|
||||
// token exchange must repeat the redirect_uri SKV saw), and the
|
||||
// broker's signed connector_state is persisted for that exchange.
|
||||
const connector = skatteverketConnectorMode()
|
||||
let authorizeUrl: string
|
||||
let connectorState: string | null = null
|
||||
if (connector) {
|
||||
try {
|
||||
const started = await startConnectorAuthorization(connector, {
|
||||
companyRef: ctx.companyId,
|
||||
returnUrl: redirectUri,
|
||||
state,
|
||||
codeChallenge: pkce.challenge,
|
||||
})
|
||||
redirectUri = started.redirectUri
|
||||
connectorState = started.connectorState
|
||||
authorizeUrl = started.authorizeUrl
|
||||
} catch (err) {
|
||||
log.error('connector authorize-url failed', err as Error, { companyId: ctx.companyId })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Kunde inte starta Skatteverket-anslutningen via connectorn. ' +
|
||||
'Kontrollera GNUBOK_CONNECTOR_KEY och anslutningsläget på /api/connector/status.',
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
authorizeUrl = buildAuthorizeUrl(redirectUri, state, {
|
||||
codeChallenge: pkce.challenge,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -308,13 +347,11 @@ export const skatteverketExtension: Extension = {
|
||||
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 authorizeUrl = buildAuthorizeUrl(redirectUri, state, {
|
||||
codeChallenge: pkce.challenge,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(authorizeUrl)
|
||||
},
|
||||
},
|
||||
@@ -527,6 +564,15 @@ export const skatteverketExtension: Extension = {
|
||||
// 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
|
||||
@@ -538,7 +584,7 @@ export const skatteverketExtension: Extension = {
|
||||
: `/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}`
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier)
|
||||
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier, connectorState)
|
||||
await storeTokens(db, userId, tokens, companyId)
|
||||
|
||||
// Clean up CSRF state + the one-shot user id/return_to/PKCE verifier.
|
||||
@@ -547,7 +593,7 @@ export const skatteverketExtension: Extension = {
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.in('key', ['oauth_state', 'oauth_user_id', 'oauth_return_to', 'oauth_code_verifier'])
|
||||
.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
|
||||
@@ -587,7 +633,7 @@ export const skatteverketExtension: Extension = {
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.in('key', ['oauth_state', 'oauth_user_id', 'oauth_return_to', 'oauth_code_verifier'])
|
||||
.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 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import crypto from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import {
|
||||
CONNECTOR_UPSTREAM_AUTH_HEADER,
|
||||
CONNECTOR_UPSTREAM_CONTENT_TYPE_HEADER,
|
||||
} from '@/lib/connect/instance/upstreams'
|
||||
import { baseUrlToService, parseConnectorCode, skatteverketConnectorMode } from './connector-mode'
|
||||
import { refreshAccessToken } from './oauth'
|
||||
import { getTokens, storeTokens, deleteTokens } from './token-store'
|
||||
import { getSystemAccessToken, invalidateSystemToken } from './system-auth/token-provider'
|
||||
@@ -14,6 +19,10 @@ import type { SkatteverketTokens } from '../types'
|
||||
* 'system' : Accounted's own Client Credentials token (org certificate),
|
||||
* authorized per company via an ombud grant at Skatteverket.
|
||||
* Used by background reads; carries no user session at all.
|
||||
* NEVER brokered through the connector: the org certificate and
|
||||
* ombud grants are a hosted-only feature, so system mode always
|
||||
* takes the direct path and fails with SYSTEM_AUTH_FAILED on a
|
||||
* credential-less self-host (deliberate, PR6b-2).
|
||||
*/
|
||||
export type SkvAuth =
|
||||
| { mode: 'user'; supabase: SupabaseClient; userId: string; companyId: string }
|
||||
@@ -87,6 +96,13 @@ function isDisabled(): boolean {
|
||||
* filings will hit Skatteverket's production system.
|
||||
*/
|
||||
export function getSkatteverketEnvironment(): 'test' | 'prod' {
|
||||
// Connector mode: the actual upstream environment is resolved from the
|
||||
// HOSTED broker's env, not this instance's (whose base URLs are usually
|
||||
// unset and default to test). Reporting 'test' here would show a Testmiljö
|
||||
// badge on real filings, so report 'prod', the hosted upstream's
|
||||
// environment once connector keys are sold. (Reporting hosted's actual env
|
||||
// through /api/connector/status is a #2090 follow-up.)
|
||||
if (skatteverketConnectorMode()) return 'prod'
|
||||
const baseUrl =
|
||||
process.env.SKATTEVERKET_API_BASE_URL ||
|
||||
process.env.SKATTEVERKET_AGD_INLAMNING_API_BASE_URL ||
|
||||
@@ -202,7 +218,17 @@ async function refreshTokenForUser(
|
||||
(/\b404\b/.test(message) && /id_not_found|refresh token is not found/i.test(message)) ||
|
||||
(/\b400\b/.test(message) &&
|
||||
(/refresh token status is expired/i.test(message) ||
|
||||
/"error"\s*:\s*"invalid_grant"/i.test(message)))
|
||||
/"error"\s*:\s*"invalid_grant"/i.test(message))) ||
|
||||
// Broker dialects (connector mode): CONNECTOR_SKV_REFRESH_DEAD is the
|
||||
// broker's classification of SKV's own dead-token dialects (the
|
||||
// dominant refresh outcome: per-flow refresh tokens live 65 minutes),
|
||||
// and 404 CONNECTOR_NOT_OWNED means the hosted ledger no longer
|
||||
// vouches for this refresh token (rotated away or revoked). Both are
|
||||
// terminal: only a fresh BankID consent recovers. The broker's generic
|
||||
// 502 (CONNECTOR_SKV_TOKEN_FAILED) deliberately stays a raw error: a
|
||||
// transient SKV outage must not flag the row for reconnect (#1155).
|
||||
/CONNECTOR_SKV_REFRESH_DEAD/.test(message) ||
|
||||
(/\b404\b/.test(message) && /CONNECTOR_NOT_OWNED/.test(message))
|
||||
if (deadRefreshToken) {
|
||||
throw new SkatteverketAuthError(
|
||||
'Sessionen har gått ut. Logga in med BankID igen.',
|
||||
@@ -289,6 +315,22 @@ function apigwOrScopeMessage(url: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector-mode variant of the gateway-refusal guidance: the APIGW client
|
||||
* and its subscriptions belong to the HOSTED broker (Arcim), so telling a
|
||||
* self-host operator to check SKATTEVERKET_APIGW_CLIENT_ID or visit
|
||||
* Utvecklarportalen points at knobs their instance does not have. The token
|
||||
* they hold was also minted by the broker, so the only local actions are
|
||||
* checking the connector status and contacting support.
|
||||
*/
|
||||
function connectorGatewayMessage(url: string): string {
|
||||
return (
|
||||
`Skatteverkets API-gateway nekade anropet till tjänsten "${apiHintFromUrl(url)}" via connectorn. ` +
|
||||
'Detta är ett konfigurationsproblem på värdtjänstens sida (gateway-prenumeration eller scope), ' +
|
||||
'inte på din instans: kontakta supporten. Anslutningsläget syns på /api/connector/status.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A genuine token-scope rejection: the stored access token predates a scope
|
||||
* the service now requires, and only a fresh consent can widen it.
|
||||
@@ -367,12 +409,30 @@ export async function skvRequestWithAuth(
|
||||
|
||||
await enforceRateLimit()
|
||||
|
||||
const url = `${options?.baseUrl || getApiBaseUrl()}${path}`
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Client_Id': getApiGwClientId(),
|
||||
'Client_Secret': getApiGwClientSecret(),
|
||||
'skv_client_correlation_id': crypto.randomUUID(),
|
||||
// Connector mode (self-host with GNUBOK_CONNECTOR_KEY and no own SKV
|
||||
// credentials): route through the hosted data proxy. The base URL only
|
||||
// selects the SERVICE segment (the proxy resolves the real upstream from
|
||||
// hosted's env); the user's SKV Bearer moves to the upstream-auth header,
|
||||
// the connector key becomes the proxy auth, and the gateway
|
||||
// Client_Id/Client_Secret are omitted entirely (the proxy adds Arcim's;
|
||||
// this instance has none, which is precisely why it is in connector mode).
|
||||
// System (CCG) auth is deliberately NOT brokered: background ombud reads
|
||||
// are a hosted-only feature and stay on the direct path, where a
|
||||
// credential-less self-host fails with SYSTEM_AUTH_FAILED.
|
||||
const connector = auth.mode === 'user' ? skatteverketConnectorMode() : null
|
||||
const effectiveBase = options?.baseUrl || getApiBaseUrl()
|
||||
let url: string
|
||||
const headers: Record<string, string> = {}
|
||||
if (connector) {
|
||||
url = `${connector.baseUrl}/api/${baseUrlToService(effectiveBase)}${path}`
|
||||
headers['Authorization'] = `Bearer ${connector.key}`
|
||||
headers[CONNECTOR_UPSTREAM_AUTH_HEADER] = `Bearer ${accessToken}`
|
||||
} else {
|
||||
url = `${effectiveBase}${path}`
|
||||
headers['Authorization'] = `Bearer ${accessToken}`
|
||||
headers['Client_Id'] = getApiGwClientId()
|
||||
headers['Client_Secret'] = getApiGwClientSecret()
|
||||
headers['skv_client_correlation_id'] = crypto.randomUUID()
|
||||
}
|
||||
|
||||
// contentType defaults to application/json, which is right for moms +
|
||||
@@ -382,16 +442,60 @@ export async function skvRequestWithAuth(
|
||||
if (body !== undefined) {
|
||||
const contentType = options?.contentType ?? 'application/json'
|
||||
headers['Content-Type'] = contentType
|
||||
if (connector) headers[CONNECTOR_UPSTREAM_CONTENT_TYPE_HEADER] = contentType
|
||||
serializedBody = typeof body === 'string' ? body : JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
let response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: serializedBody,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
// Connector-layer refusals FIRST: a 4xx here can come from the broker
|
||||
// itself, not Skatteverket, and the SKV-shaped sniffing below would then
|
||||
// misdiagnose it (an empty connector 401 would tell the operator to check
|
||||
// SKATTEVERKET_APIGW_CLIENT_ID, which does not exist on their instance).
|
||||
// Bodies without a CONNECTOR_* code are upstream SKV responses passed
|
||||
// through the proxy: re-wrap and fall through to the normal mapping.
|
||||
if (connector && [400, 401, 403, 404, 429].includes(response.status)) {
|
||||
const text = await response.text().catch(() => '')
|
||||
const connectorCode = parseConnectorCode(text)
|
||||
if (connectorCode) {
|
||||
log.warn('connector broker rejected SKV call', {
|
||||
url,
|
||||
statusCode: response.status,
|
||||
code: connectorCode,
|
||||
})
|
||||
if (connectorCode === 'CONNECTOR_RATE_LIMITED') {
|
||||
throw new SkatteverketAuthError(
|
||||
'Skatteverket-connectorn är upptagen. Försök igen om en stund.',
|
||||
'RATE_LIMITED'
|
||||
)
|
||||
}
|
||||
if (connectorCode === 'CONNECTOR_NOT_OWNED') {
|
||||
// The hosted ledger no longer vouches for this token (rotated away
|
||||
// or revoked); only a fresh BankID consent recovers.
|
||||
throw new SkatteverketAuthError(
|
||||
'Sessionen har gått ut. Logga in med BankID igen.',
|
||||
'SESSION_EXPIRED'
|
||||
)
|
||||
}
|
||||
throw new SkatteverketAuthError(
|
||||
`Connectorn nekade anropet (${connectorCode}). Kontrollera instansens ` +
|
||||
'connector-nyckel (GNUBOK_CONNECTOR_KEY) och att abonnemanget omfattar ' +
|
||||
'Skatteverket. Se /api/connector/status för anslutningsläget.',
|
||||
'ACCESS_DENIED'
|
||||
)
|
||||
}
|
||||
response = new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle Skatteverket-specific auth/throttle errors uniformly so callers
|
||||
// can catch a single error type rather than parsing status codes inline.
|
||||
if (response.status === 401) {
|
||||
@@ -455,7 +559,10 @@ export async function skvRequestWithAuth(
|
||||
// cannot fix: SESSION_EXPIRED and MISSING_SCOPE are both reconsent codes,
|
||||
// so either verdict re-arms the banner the user just tried to clear.
|
||||
if (isApigwScopeContractError(text)) {
|
||||
throw new SkatteverketAuthError(apigwOrScopeMessage(url), 'ACCESS_DENIED')
|
||||
throw new SkatteverketAuthError(
|
||||
connector ? connectorGatewayMessage(url) : apigwOrScopeMessage(url),
|
||||
'ACCESS_DENIED'
|
||||
)
|
||||
}
|
||||
|
||||
// OAuth's standard insufficient_scope marker. SKV sometimes emits this
|
||||
@@ -514,10 +621,13 @@ export async function skvRequestWithAuth(
|
||||
// Named the subscription outright: unlike the scope-contract body above,
|
||||
// these shapes (client_id, consumer, subscription) point at the gateway
|
||||
// client alone, so the message must not muddy it with the scope story.
|
||||
// Connector mode: the gateway client is the broker's, not the instance's.
|
||||
throw new SkatteverketAuthError(
|
||||
`Skatteverkets API-gateway nekade anropet till "${apiHintFromUrl(url)}". ` +
|
||||
'Kontrollera att din APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har ' +
|
||||
'prenumeration på denna tjänst i Utvecklarportalen.',
|
||||
connector
|
||||
? connectorGatewayMessage(url)
|
||||
: `Skatteverkets API-gateway nekade anropet till "${apiHintFromUrl(url)}". ` +
|
||||
'Kontrollera att din APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har ' +
|
||||
'prenumeration på denna tjänst i Utvecklarportalen.',
|
||||
'ACCESS_DENIED'
|
||||
)
|
||||
}
|
||||
@@ -531,6 +641,9 @@ export async function skvRequestWithAuth(
|
||||
// "log in again" sends them down a dead end; be explicit about the
|
||||
// likely fix instead.
|
||||
if (!text) {
|
||||
if (connector) {
|
||||
throw new SkatteverketAuthError(connectorGatewayMessage(url), 'ACCESS_DENIED')
|
||||
}
|
||||
const apiHint = apiHintFromUrl(url)
|
||||
throw new SkatteverketAuthError(
|
||||
'Skatteverkets API-gateway nekade anropet utan motivering. ' +
|
||||
@@ -596,7 +709,10 @@ export async function skvRequestWithAuth(
|
||||
// RECONSENT_ERROR_CODES (#1155): a reconnect is only the fix once the
|
||||
// scope actually exists, so it can never be automatic.
|
||||
if (isApigwScopeContractError(text)) {
|
||||
throw new SkatteverketAuthError(apigwOrScopeMessage(url), 'ACCESS_DENIED')
|
||||
throw new SkatteverketAuthError(
|
||||
connector ? connectorGatewayMessage(url) : apigwOrScopeMessage(url),
|
||||
'ACCESS_DENIED'
|
||||
)
|
||||
}
|
||||
// Missing scope on the access token: fires when an existing connection
|
||||
// pre-dates an extension that needed a new scope (the AGI/`agd` rollout
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
skatteverketConnectorMode,
|
||||
type ConnectorUpstream,
|
||||
} from '@/lib/connect/instance/upstreams'
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
OAUTH_TIMEOUT_MS,
|
||||
SKATTEVERKET_EXCHANGE_TIMEOUT_MS,
|
||||
} from '@/lib/http/fetch-with-timeout'
|
||||
|
||||
/**
|
||||
* Instance-side helpers for running the Skatteverket extension through the
|
||||
* hosted connector broker (/api/connect/skv on app.gnubok.se) instead of
|
||||
* against Skatteverket directly.
|
||||
*
|
||||
* Active only when skatteverketConnectorMode() returns non-null: the instance
|
||||
* has GNUBOK_CONNECTOR_KEY set AND no own SKV credentials
|
||||
* (SKATTEVERKET_OAUTH2_CLIENT_ID / SKATTEVERKET_APIGW_CLIENT_ID). Hosted
|
||||
* always has own credentials, so hosted never takes any branch in this file.
|
||||
*
|
||||
* The division of labor (plan WS3 PR5b/PR6b):
|
||||
* - OAuth: the broker builds the authorize URL against Arcim's registered SKV
|
||||
* client and exchanges/refreshes with Arcim's client secret; the TOKENS are
|
||||
* returned to the instance, which stores them encrypted. client_id and
|
||||
* client_secret never exist on the instance.
|
||||
* - Data: the instance sends the user's SKV Bearer as
|
||||
* X-Connector-Upstream-Authorization and its connector key as the regular
|
||||
* Authorization; the proxy adds Arcim's Client_Id/Client_Secret gateway
|
||||
* headers and forwards to the allowlisted backing API.
|
||||
*/
|
||||
|
||||
export { skatteverketConnectorMode }
|
||||
export type { ConnectorUpstream }
|
||||
|
||||
export type SkvConnectorService = 'moms' | 'skattekonto' | 'agd-inlamning' | 'agd-period'
|
||||
|
||||
/**
|
||||
* The env-var identities and defaults per backing API, mirroring the
|
||||
* broker-side allowlist (SKV_API_BASES in lib/connect/upstreams/
|
||||
* skatteverket-oauth.ts) and the per-service clients here (api-client.ts
|
||||
* getApiBaseUrl, skattekonto-client.ts, agi-client.ts). Both test and prod
|
||||
* defaults are listed: in connector mode the instance's own base URLs are
|
||||
* usually unset, so the clients pass their test defaults, and the SERVICE
|
||||
* segment is all the proxy needs (the actual upstream host is resolved from
|
||||
* hosted's env, never from the instance's).
|
||||
*/
|
||||
const SERVICE_BASES: ReadonlyArray<{
|
||||
service: SkvConnectorService
|
||||
envVar: string
|
||||
defaults: readonly string[]
|
||||
}> = [
|
||||
{
|
||||
service: 'moms',
|
||||
envVar: 'SKATTEVERKET_API_BASE_URL',
|
||||
defaults: [
|
||||
'https://api.test.skatteverket.se/momsdeklaration/v1',
|
||||
'https://api.skatteverket.se/momsdeklaration/v1',
|
||||
],
|
||||
},
|
||||
{
|
||||
service: 'skattekonto',
|
||||
envVar: 'SKATTEVERKET_SKATTEKONTO_API_BASE_URL',
|
||||
defaults: [
|
||||
'https://api.test.skatteverket.se/beskattning/skattekonto/v2',
|
||||
'https://api.skatteverket.se/beskattning/skattekonto/v2',
|
||||
],
|
||||
},
|
||||
{
|
||||
service: 'agd-inlamning',
|
||||
envVar: 'SKATTEVERKET_AGD_INLAMNING_API_BASE_URL',
|
||||
defaults: [
|
||||
'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1',
|
||||
'https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1',
|
||||
],
|
||||
},
|
||||
{
|
||||
service: 'agd-period',
|
||||
envVar: 'SKATTEVERKET_AGD_PERIOD_API_BASE_URL',
|
||||
defaults: [
|
||||
'https://api.test.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1',
|
||||
'https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Map a per-service client's base URL to the proxy's service segment.
|
||||
* Throws on an unmapped base: silently proxying to the wrong backing API
|
||||
* would file against the wrong service, so this fails loudly instead.
|
||||
*/
|
||||
export function baseUrlToService(baseUrl: string): SkvConnectorService {
|
||||
const normalized = baseUrl.replace(/\/+$/, '')
|
||||
for (const { service, envVar, defaults } of SERVICE_BASES) {
|
||||
const envValue = process.env[envVar]?.replace(/\/+$/, '')
|
||||
if (envValue && envValue === normalized) return service
|
||||
if (defaults.includes(normalized)) return service
|
||||
}
|
||||
throw new Error(
|
||||
`Okänd Skatteverket-tjänst för connector-läget: ${baseUrl}. ` +
|
||||
'Bastjänsten måste vara en av moms, skattekonto, agd-inlamning, agd-period.',
|
||||
)
|
||||
}
|
||||
|
||||
/** Read a broker error body's connector code, if the body carries one. */
|
||||
export function parseConnectorCode(body: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { code?: unknown }
|
||||
if (typeof parsed?.code === 'string' && parsed.code.startsWith('CONNECTOR_')) {
|
||||
return parsed.code
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: an upstream SKV body passed through the proxy.
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface ConnectorAuthorizationStart {
|
||||
authorizeUrl: string
|
||||
/** The HOSTED redirect_uri registered at SKV; must be sent verbatim in the token exchange. */
|
||||
redirectUri: string
|
||||
/** The broker's signed state; required by the broker's token exchange. */
|
||||
connectorState: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the broker to start a Skatteverket BankID consent. The broker signs a
|
||||
* connector state carrying returnUrl, so the hosted SKV callback bounces the
|
||||
* browser back to this instance with the code; the instance keeps its own
|
||||
* PKCE verifier and later exchanges through the broker.
|
||||
*/
|
||||
export async function startConnectorAuthorization(
|
||||
connector: ConnectorUpstream,
|
||||
args: {
|
||||
companyRef: string
|
||||
returnUrl: string
|
||||
state: string
|
||||
codeChallenge: string
|
||||
scope?: string
|
||||
},
|
||||
): Promise<ConnectorAuthorizationStart> {
|
||||
const response = await fetchWithTimeout(
|
||||
`${connector.baseUrl}/oauth/authorize-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${connector.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// redirect 'error': a followed 307/308 would resend the connector key
|
||||
// to the redirect target (same rule as the broker's own postToken).
|
||||
redirect: 'error',
|
||||
body: JSON.stringify({
|
||||
company_ref: args.companyRef,
|
||||
return_url: args.returnUrl,
|
||||
state: args.state,
|
||||
code_challenge: args.codeChallenge,
|
||||
...(args.scope ? { scope: args.scope } : {}),
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Connector authorize-url' },
|
||||
)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(`Connector authorize-url failed (${response.status}): ${text}`)
|
||||
}
|
||||
const json = (await response.json()) as {
|
||||
data?: { authorize_url?: string; redirect_uri?: string; connector_state?: string }
|
||||
}
|
||||
const data = json?.data
|
||||
if (!data?.authorize_url || !data?.redirect_uri || !data?.connector_state) {
|
||||
throw new Error('Connector authorize-url: oväntat svar från brokern')
|
||||
}
|
||||
return {
|
||||
authorizeUrl: data.authorize_url,
|
||||
redirectUri: data.redirect_uri,
|
||||
connectorState: data.connector_state,
|
||||
}
|
||||
}
|
||||
|
||||
/** The broker's /oauth/token payload (unwrapped from its { data } envelope). */
|
||||
export interface ConnectorTokenData {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
}
|
||||
|
||||
async function connectorTokenRequest(
|
||||
connector: ConnectorUpstream,
|
||||
body: Record<string, string>,
|
||||
description: string,
|
||||
timeoutMs: number,
|
||||
): Promise<ConnectorTokenData> {
|
||||
const response = await fetchWithTimeout(
|
||||
`${connector.baseUrl}/oauth/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${connector.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// redirect 'error': a followed 307/308 would resend the connector key
|
||||
// and the code/refresh token to the redirect target, and the token
|
||||
// response must only ever come from the broker endpoint itself.
|
||||
redirect: 'error',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description },
|
||||
)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
// The message carries status + body so api-client's dead-refresh-token
|
||||
// classifier can match the broker dialect (404 CONNECTOR_NOT_OWNED).
|
||||
throw new Error(`Skatteverket token ${description} failed (${response.status}): ${text}`)
|
||||
}
|
||||
const json = (await response.json()) as { data?: ConnectorTokenData }
|
||||
if (!json?.data?.access_token) {
|
||||
throw new Error(`Skatteverket token ${description}: oväntat svar från brokern`)
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
|
||||
/** Exchange an authorization code through the broker (grant authorization_code). */
|
||||
export function exchangeConnectorCode(
|
||||
connector: ConnectorUpstream,
|
||||
args: { code: string; redirectUri: string; codeVerifier?: string; connectorState: string },
|
||||
): Promise<ConnectorTokenData> {
|
||||
return connectorTokenRequest(
|
||||
connector,
|
||||
{
|
||||
grant_type: 'authorization_code',
|
||||
code: args.code,
|
||||
redirect_uri: args.redirectUri,
|
||||
...(args.codeVerifier ? { code_verifier: args.codeVerifier } : {}),
|
||||
connector_state: args.connectorState,
|
||||
},
|
||||
'exchange',
|
||||
SKATTEVERKET_EXCHANGE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Refresh through the broker (grant refresh_token). */
|
||||
export function refreshConnectorToken(
|
||||
connector: ConnectorUpstream,
|
||||
refreshToken: string,
|
||||
): Promise<ConnectorTokenData> {
|
||||
return connectorTokenRequest(
|
||||
connector,
|
||||
{ grant_type: 'refresh_token', refresh_token: refreshToken },
|
||||
'refresh',
|
||||
OAUTH_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
OAUTH_TIMEOUT_MS,
|
||||
SKATTEVERKET_EXCHANGE_TIMEOUT_MS,
|
||||
} from '@/lib/http/fetch-with-timeout'
|
||||
import {
|
||||
skatteverketConnectorMode,
|
||||
exchangeConnectorCode,
|
||||
refreshConnectorToken,
|
||||
} from './connector-mode'
|
||||
|
||||
/**
|
||||
* Skatteverket OAuth2 helpers for the `per` (BankID) flow.
|
||||
@@ -15,6 +20,15 @@ import {
|
||||
*
|
||||
* The `per` flow is user-facing BankID authentication.
|
||||
* No mTLS required (unlike the `org` flow).
|
||||
*
|
||||
* Connector mode (self-host with GNUBOK_CONNECTOR_KEY and no own SKV
|
||||
* credentials): the token functions route through the hosted broker's
|
||||
* /api/connect/skv/oauth/token instead; client_id/client_secret never leave
|
||||
* the instance because the instance never has them (the broker holds Arcim's
|
||||
* registered client). buildAuthorizeUrl stays direct-only: connector-mode
|
||||
* authorization starts via startConnectorAuthorization (connector-mode.ts),
|
||||
* which returns the broker-built URL plus the connector state and hosted
|
||||
* redirect_uri the caller must persist for the exchange.
|
||||
*/
|
||||
|
||||
const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1/per'
|
||||
@@ -124,7 +138,32 @@ export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
codeVerifier?: string,
|
||||
connectorState?: string,
|
||||
): Promise<SkatteverketTokens> {
|
||||
const connector = skatteverketConnectorMode()
|
||||
if (connector) {
|
||||
if (!connectorState) {
|
||||
// A flow started before connector mode was enabled (or a state row that
|
||||
// lost its connector_state) cannot be exchanged through the broker.
|
||||
throw new Error(
|
||||
'connector_state saknas: anslutningsflödet startades inte via connectorn. Starta om anslutningen.',
|
||||
)
|
||||
}
|
||||
const data = await exchangeConnectorCode(connector, {
|
||||
code,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
connectorState,
|
||||
})
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token ?? null,
|
||||
expires_at: Date.now() + (data.expires_in ?? 3600) * 1000,
|
||||
refresh_count: 0,
|
||||
scope: data.scope ?? DEFAULT_SCOPES,
|
||||
}
|
||||
}
|
||||
|
||||
const base = getOAuthBaseUrl()
|
||||
|
||||
const body = new URLSearchParams({
|
||||
@@ -176,6 +215,25 @@ export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
previousRefreshCount: number
|
||||
): Promise<SkatteverketTokens> {
|
||||
const connector = skatteverketConnectorMode()
|
||||
if (connector) {
|
||||
// Broker refresh rotates the hosted ledger's token hashes. Terminal
|
||||
// failures come back as broker dialects api-client classifies as
|
||||
// SESSION_EXPIRED: 401 CONNECTOR_SKV_REFRESH_DEAD (SKV declared the
|
||||
// refresh token dead: the dominant outcome, per-flow tokens live 65
|
||||
// minutes) and 404 CONNECTOR_NOT_OWNED (the ledger no longer vouches for
|
||||
// it). The generic 502 stays a raw error (transient SKV outage must not
|
||||
// flag the row for reconnect, the #1155 lesson).
|
||||
const data = await refreshConnectorToken(connector, refreshToken)
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token ?? null,
|
||||
expires_at: Date.now() + (data.expires_in ?? 3600) * 1000,
|
||||
refresh_count: previousRefreshCount + 1,
|
||||
scope: data.scope ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const base = getOAuthBaseUrl()
|
||||
|
||||
const body = new URLSearchParams({
|
||||
|
||||
@@ -87,6 +87,27 @@ export async function exchangeSkvCode(code: string, redirectUri: string, codeVer
|
||||
return postToken(body, SKATTEVERKET_EXCHANGE_TIMEOUT_MS, 'Skatteverket token exchange')
|
||||
}
|
||||
|
||||
/**
|
||||
* SKV's terminal dead-refresh-token dialects, matched against the error
|
||||
* message postToken throws (which embeds status + body). The same set the
|
||||
* instance-side extension classifies as SESSION_EXPIRED on the direct path:
|
||||
* 404 id_not_found / "refresh token is not found", 400 "Refresh Token status
|
||||
* is expired", and OAuth2's standard 400 invalid_grant. `per`-flow refresh
|
||||
* tokens live 65 minutes, so this is the DOMINANT refresh outcome, not an
|
||||
* edge case: the broker must distinguish it from transient failures or every
|
||||
* connector instance loses its reconnect flow (skeptic refutation on PR6b-2).
|
||||
* Config-shaped 400s (invalid_client, invalid_scope) deliberately do NOT
|
||||
* match: a reconnect cannot fix those.
|
||||
*/
|
||||
export function isSkvDeadRefreshTokenError(message: string): boolean {
|
||||
return (
|
||||
(/\b404\b/.test(message) && /id_not_found|refresh token is not found/i.test(message)) ||
|
||||
(/\b400\b/.test(message) &&
|
||||
(/refresh token status is expired/i.test(message) ||
|
||||
/"error"\s*:\s*"invalid_grant"/i.test(message)))
|
||||
)
|
||||
}
|
||||
|
||||
export async function refreshSkvToken(refreshToken: string): Promise<SkvTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
|
||||
Reference in New Issue
Block a user