Files
accounted/extensions/general/skatteverket/lib/connector-mode.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

254 lines
8.8 KiB
TypeScript

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