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
@@ -89,6 +89,30 @@ describe('skv data proxy', () => {
expect(fetchMock.mock.calls[0][0]).toBe('https://api.skv/agd/inlamning/v1/underlag')
expect(fetchMock.mock.calls[0][1].body).toBe('<xml/>')
})
it('forwards SKV diagnostic headers (WWW-Authenticate, x-skv-*) but nothing else', async () => {
// The instance's 401 classifier reads WWW-Authenticate (insufficient_scope
// → MISSING_SCOPE) and the x-skv-* family on body-less gateway
// rejections; stripping them blinded connector-mode classification.
fetchMock.mockResolvedValueOnce(new Response('', {
status: 401,
headers: {
'content-type': 'application/json',
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"',
'x-skv-trace': 'abc',
'x-amzn-requestid': 'req-1',
'set-cookie': 'secret=1',
'x-internal-other': 'nope',
},
}))
const res = await GET(req('GET', '/moms/x'))
expect(res.status).toBe(401)
expect(res.headers.get('WWW-Authenticate')).toBe('Bearer error="insufficient_scope", scope="agd"')
expect(res.headers.get('x-skv-trace')).toBe('abc')
expect(res.headers.get('x-amzn-requestid')).toBe('req-1')
expect(res.headers.get('set-cookie')).toBeNull()
expect(res.headers.get('x-internal-other')).toBeNull()
})
it('429 when the budget is exhausted', async () => {
hh.budget.mockResolvedValue({ ok: false, scope: 'hour', retryAfterSec: 3600 })
const res = await GET(req('GET', '/moms/x'))
+14 -1
View File
@@ -106,7 +106,20 @@ async function handle(request: Request, ctx: ConnectorContext): Promise<Response
})
const text = await res.text()
if ([204, 205, 304].includes(res.status)) return new NextResponse(null, { status: res.status })
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' } })
// Forward SKV's diagnostic headers: WWW-Authenticate carries OAuth's
// machine-readable failure reason (the instance's insufficient_scope →
// MISSING_SCOPE classification depends on it), and the x-skv-*/x-amzn-*/
// x-api-* families are the only signal on body-less gateway rejections.
// Nothing secret rides in them; stripping them blinded the instance's
// 401 classifier (skeptic finding on PR6b-2).
const headers: Record<string, string> = { 'Content-Type': res.headers.get('content-type') ?? 'application/json' }
res.headers.forEach((v, k) => {
const lk = k.toLowerCase()
if (lk === 'www-authenticate' || lk.startsWith('x-skv-') || lk.startsWith('x-amzn-') || lk.startsWith('x-api-')) {
headers[k] = v
}
})
return new NextResponse(text, { status: res.status, headers })
} finally {
clearTimeout(timeout)
}