* 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>
275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { SkatteverketTokens } from '../types'
|
|
import {
|
|
fetchWithTimeout,
|
|
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.
|
|
*
|
|
* Endpoints:
|
|
* Authorize: GET {base}/authorize
|
|
* Token: POST {base}/token
|
|
*
|
|
* 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'
|
|
// `agd` is the AGI (arbetsgivardeklaration) scope. Source: SKV's service
|
|
// description PDF, Tjänstebeskrivning Arbetsgivardeklaration inlämning v1.7,
|
|
// section 4.1.2.2: the 403 "Felaktigt access scope" example shows
|
|
// `"description": "The required scope agd has been requested for that access token."`
|
|
// The other tokens match the path segments of their respective APIs,
|
|
// EXCEPT skattekonto. The scope names there, learned the hard way:
|
|
// - `ska` = the interactive skattekonto REST API (saldo +
|
|
// transaktioner). Requested since the extension's first
|
|
// commit; removed 2026-05-10 by a "remove unused scopes"
|
|
// cleanup (#431 series), which instantly broke skattekonto
|
|
// sync for every token issued after that hour: the API
|
|
// answers 403 "The required scopes are not authorized"
|
|
// without it. Re-added 2026-07-20. Do not "clean up" again.
|
|
// - `skahmst` = a DIFFERENT bulk service (Skattekonto Hämta huvudmäns
|
|
// saldo och transaktioner, file via E-transport for
|
|
// juridiska läsombud; see dev_docs/skatteverket/skahmst).
|
|
// Not what the sync uses, but harmless to request.
|
|
// - `skattekonto` is NOT a real SKV scope name: SKV silently drops it
|
|
// from every grant. Kept only so a future SKV rename in
|
|
// our favor costs nothing.
|
|
//
|
|
// AGI needs TWO scopes, one per backing API, and missing the second one is
|
|
// invisible until the very last step of a filing:
|
|
// - `agd` = arbetsgivardeklaration/inlamning (POST underlag,
|
|
// kontrollresultat, spara, skapaGranskningsunderlag).
|
|
// - `agdredovisningperiod` = arbetsgivardeklaration/hanteraredovisningsperiod
|
|
// (kvittenser, las, lasUpp). Note the spelling:
|
|
// "redovisningperiod", no genitive s, exactly as
|
|
// SKV registers it. Added 2026-08-13 after a real
|
|
// prod filing signed fine and then failed on
|
|
// "Hämta kvittens" with 403 {"error": "The
|
|
// required scopes are not authorized"}: the token
|
|
// carried `agd` only, so the hantera API refused
|
|
// while inlämning kept working. Same body as the
|
|
// APIGW subscription gap (#973), which is why the
|
|
// gateway/token distinction has to be made with
|
|
// the APIGW client, not from this string alone.
|
|
const DEFAULT_SCOPES =
|
|
'momsdeklaration inkforetag skahmst skattekonto ska agd agdredovisningperiod'
|
|
|
|
function getOAuthBaseUrl(): string {
|
|
return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL
|
|
}
|
|
|
|
function getClientId(): string {
|
|
const id = process.env.SKATTEVERKET_OAUTH2_CLIENT_ID
|
|
if (!id) throw new Error('SKATTEVERKET_OAUTH2_CLIENT_ID is required')
|
|
return id
|
|
}
|
|
|
|
function getClientSecret(): string {
|
|
const secret = process.env.SKATTEVERKET_OAUTH2_CLIENT_SECRET
|
|
if (!secret) throw new Error('SKATTEVERKET_OAUTH2_CLIENT_SECRET is required')
|
|
return secret
|
|
}
|
|
|
|
/**
|
|
* Generate a PKCE verifier/challenge pair (RFC 7636, S256 method).
|
|
*
|
|
* SKV's per flow accepts (and on some test client configurations *requires*)
|
|
* PKCE. Without a code_challenge SKV may issue tokens that downstream APIs
|
|
* (notably the AGI APIGW) reject as revoked when called, even though the
|
|
* initial token exchange succeeds. Always sending PKCE is safe regardless
|
|
* of whether SKV strictly requires it.
|
|
*
|
|
* Verifier: 64 random bytes → base64url → 86 chars (within RFC 7636's
|
|
* 43-128 range). Challenge: SHA-256 of the verifier, base64url-encoded.
|
|
*/
|
|
export function generatePkcePair(): { verifier: string; challenge: string } {
|
|
const verifier = crypto.randomBytes(64).toString('base64url')
|
|
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
|
|
return { verifier, challenge }
|
|
}
|
|
|
|
/**
|
|
* Build the Skatteverket OAuth2 authorization URL.
|
|
* User is redirected here to authenticate with BankID.
|
|
*/
|
|
export function buildAuthorizeUrl(
|
|
redirectUri: string,
|
|
state: string,
|
|
options?: { scope?: string; codeChallenge?: string }
|
|
): string {
|
|
const base = getOAuthBaseUrl()
|
|
const params = new URLSearchParams({
|
|
client_id: getClientId(),
|
|
response_type: 'code',
|
|
state,
|
|
redirect_uri: redirectUri,
|
|
scope: options?.scope || DEFAULT_SCOPES,
|
|
})
|
|
if (options?.codeChallenge) {
|
|
params.set('code_challenge', options.codeChallenge)
|
|
params.set('code_challenge_method', 'S256')
|
|
}
|
|
return `${base}/authorize?${params.toString()}`
|
|
}
|
|
|
|
/**
|
|
* Exchange an authorization code for tokens.
|
|
* Must be called immediately upon receiving the callback: code expires in 5 minutes.
|
|
*/
|
|
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({
|
|
grant_type: 'authorization_code',
|
|
client_id: getClientId(),
|
|
client_secret: getClientSecret(),
|
|
redirect_uri: redirectUri,
|
|
code,
|
|
})
|
|
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
|
|
|
const response = await fetchWithTimeout(
|
|
`${base}/token`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
|
body: body.toString(),
|
|
},
|
|
{
|
|
timeoutMs: SKATTEVERKET_EXCHANGE_TIMEOUT_MS,
|
|
description: 'Skatteverket token exchange',
|
|
},
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(`Skatteverket token exchange failed (${response.status}): ${text}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh an access token using a stored refresh token.
|
|
*
|
|
* The `per` flow supports up to 10 refreshes per session.
|
|
* Each refresh returns a NEW refresh_token that must be stored.
|
|
* Refresh tokens are valid for 65 minutes.
|
|
*/
|
|
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({
|
|
grant_type: 'refresh_token',
|
|
client_id: getClientId(),
|
|
client_secret: getClientSecret(),
|
|
refresh_token: refreshToken,
|
|
})
|
|
|
|
const response = await fetchWithTimeout(
|
|
`${base}/token`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
|
body: body.toString(),
|
|
},
|
|
{
|
|
timeoutMs: OAUTH_TIMEOUT_MS,
|
|
description: 'Skatteverket token refresh',
|
|
},
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(`Skatteverket token refresh failed (${response.status}): ${text}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
return {
|
|
access_token: data.access_token,
|
|
// Each refresh returns a new refresh_token: must be stored
|
|
refresh_token: data.refresh_token ?? null,
|
|
expires_at: Date.now() + (data.expires_in ?? 3600) * 1000,
|
|
refresh_count: previousRefreshCount + 1,
|
|
scope: data.scope ?? '',
|
|
}
|
|
}
|