Files
accounted/extensions/general/skatteverket/__tests__/oauth-connector.test.ts
T
MattssonandClaude Fable 5 08b1119c7d 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>
2026-09-01 12:11:34 +02:00

135 lines
5.3 KiB
TypeScript

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')
})
})