* 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>
209 lines
8.3 KiB
TypeScript
209 lines
8.3 KiB
TypeScript
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/,
|
|
)
|
|
})
|
|
})
|