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:
Mattsson
2026-09-01 12:11:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent ee22c9c7b7
commit 08b1119c7d
15 changed files with 1501 additions and 25 deletions
@@ -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')
})
})
+54 -8
View File
@@ -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 })
}
+129 -13
View File
@@ -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({