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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user