* 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>
179 lines
7.6 KiB
TypeScript
179 lines
7.6 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import { fetchWithTimeout, OAUTH_TIMEOUT_MS, SKATTEVERKET_EXCHANGE_TIMEOUT_MS } from '@/lib/http/fetch-with-timeout'
|
|
|
|
/**
|
|
* Skatteverket OAuth2 (`per`/BankID flow) helpers for the hosted connector
|
|
* broker. The instance-side extension has its own copy (it runs its own flow
|
|
* on hosted); this module is the CORE copy the connector proxy uses, so core
|
|
* does not import from @/extensions/. Same endpoints and client credentials
|
|
* (SKV registers ONE integrator = Arcim), the difference is only who calls:
|
|
* here the hosted proxy exchanges the code/refresh on behalf of a self-hosted
|
|
* instance and returns the tokens for the instance to store.
|
|
*/
|
|
|
|
const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1/per'
|
|
// AGI needs both agd and agdredovisningperiod; skattekonto needs ska (do not
|
|
// "clean up"): the exact set is documented in the extension's oauth.ts.
|
|
const DEFAULT_SCOPES = 'momsdeklaration inkforetag skahmst skattekonto ska agd agdredovisningperiod'
|
|
|
|
/**
|
|
* Every SKV base URL must be https (http only for loopback dev): the OAuth
|
|
* exchange carries Arcim's client secret and the data calls carry the
|
|
* gateway Client_Secret, so a plaintext override would ship credentials
|
|
* unencrypted. Throws so a bad env fails the request, never the build.
|
|
*/
|
|
function httpsOnly(raw: string, name: string): string {
|
|
const url = new URL(raw)
|
|
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
|
|
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
throw new Error(`${name} must be https: Skatteverket credentials ride in these requests`)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
export function skvOauthBaseUrl(): string {
|
|
return httpsOnly(process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL, 'SKATTEVERKET_OAUTH_BASE_URL')
|
|
}
|
|
export function skvDefaultScopes(): string {
|
|
return DEFAULT_SCOPES
|
|
}
|
|
function clientId(): string {
|
|
const v = process.env.SKATTEVERKET_OAUTH2_CLIENT_ID
|
|
if (!v) throw new Error('SKATTEVERKET_OAUTH2_CLIENT_ID is required')
|
|
return v
|
|
}
|
|
function clientSecret(): string {
|
|
const v = process.env.SKATTEVERKET_OAUTH2_CLIENT_SECRET
|
|
if (!v) throw new Error('SKATTEVERKET_OAUTH2_CLIENT_SECRET is required')
|
|
return v
|
|
}
|
|
|
|
export function buildSkvAuthorizeUrl(
|
|
redirectUri: string,
|
|
state: string,
|
|
options?: { scope?: string; codeChallenge?: string },
|
|
): string {
|
|
const params = new URLSearchParams({
|
|
client_id: clientId(),
|
|
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 `${skvOauthBaseUrl()}/authorize?${params.toString()}`
|
|
}
|
|
|
|
export interface SkvTokenResponse {
|
|
access_token: string
|
|
refresh_token: string | null
|
|
expires_in: number
|
|
scope: string
|
|
}
|
|
|
|
/** Raw token exchange, returned verbatim to the instance (it stores them). */
|
|
export async function exchangeSkvCode(code: string, redirectUri: string, codeVerifier?: string): Promise<SkvTokenResponse> {
|
|
const body = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: clientId(),
|
|
client_secret: clientSecret(),
|
|
redirect_uri: redirectUri,
|
|
code,
|
|
})
|
|
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
|
return postToken(body, SKATTEVERKET_EXCHANGE_TIMEOUT_MS, 'Skatteverket token exchange')
|
|
}
|
|
|
|
/**
|
|
* SKV's terminal dead-refresh-token dialects, matched against the error
|
|
* message postToken throws (which embeds status + body). The same set the
|
|
* instance-side extension classifies as SESSION_EXPIRED on the direct path:
|
|
* 404 id_not_found / "refresh token is not found", 400 "Refresh Token status
|
|
* is expired", and OAuth2's standard 400 invalid_grant. `per`-flow refresh
|
|
* tokens live 65 minutes, so this is the DOMINANT refresh outcome, not an
|
|
* edge case: the broker must distinguish it from transient failures or every
|
|
* connector instance loses its reconnect flow (skeptic refutation on PR6b-2).
|
|
* Config-shaped 400s (invalid_client, invalid_scope) deliberately do NOT
|
|
* match: a reconnect cannot fix those.
|
|
*/
|
|
export function isSkvDeadRefreshTokenError(message: string): boolean {
|
|
return (
|
|
(/\b404\b/.test(message) && /id_not_found|refresh token is not found/i.test(message)) ||
|
|
(/\b400\b/.test(message) &&
|
|
(/refresh token status is expired/i.test(message) ||
|
|
/"error"\s*:\s*"invalid_grant"/i.test(message)))
|
|
)
|
|
}
|
|
|
|
export async function refreshSkvToken(refreshToken: string): Promise<SkvTokenResponse> {
|
|
const body = new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
client_id: clientId(),
|
|
client_secret: clientSecret(),
|
|
refresh_token: refreshToken,
|
|
})
|
|
return postToken(body, OAUTH_TIMEOUT_MS, 'Skatteverket token refresh')
|
|
}
|
|
|
|
async function postToken(body: URLSearchParams, timeoutMs: number, description: string): Promise<SkvTokenResponse> {
|
|
const response = await fetchWithTimeout(
|
|
`${skvOauthBaseUrl()}/token`,
|
|
// redirect 'error': a 307/308 would resend client_secret + code/refresh
|
|
// token to the redirect target.
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: body.toString(), redirect: 'error' },
|
|
{ timeoutMs, description },
|
|
)
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(`${description} failed (${response.status}): ${text}`)
|
|
}
|
|
const data = (await response.json()) as { access_token: string; refresh_token?: string; expires_in?: number; scope?: string }
|
|
return {
|
|
access_token: data.access_token,
|
|
refresh_token: data.refresh_token ?? null,
|
|
expires_in: data.expires_in ?? 3600,
|
|
scope: data.scope ?? DEFAULT_SCOPES,
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
/**
|
|
* The Skatteverket data-API base URLs, keyed by the short service segment the
|
|
* connector proxy exposes. One integrator, several backing APIs; the instance
|
|
* addresses them as /api/connect/skv/api/<service>/<path>.
|
|
*/
|
|
export const SKV_API_BASES: Record<string, () => string> = {
|
|
moms: () => httpsOnly(process.env.SKATTEVERKET_API_BASE_URL || 'https://api.test.skatteverket.se/momsdeklaration/v1', 'SKATTEVERKET_API_BASE_URL'),
|
|
skattekonto: () => httpsOnly(process.env.SKATTEVERKET_SKATTEKONTO_API_BASE_URL || 'https://api.test.skatteverket.se/beskattning/skattekonto/v2', 'SKATTEVERKET_SKATTEKONTO_API_BASE_URL'),
|
|
'agd-inlamning': () => httpsOnly(process.env.SKATTEVERKET_AGD_INLAMNING_API_BASE_URL || 'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1', 'SKATTEVERKET_AGD_INLAMNING_API_BASE_URL'),
|
|
'agd-period': () => httpsOnly(process.env.SKATTEVERKET_AGD_PERIOD_API_BASE_URL || 'https://api.test.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1', 'SKATTEVERKET_AGD_PERIOD_API_BASE_URL'),
|
|
}
|
|
|
|
function apigwClientId(): string {
|
|
const v = process.env.SKATTEVERKET_APIGW_CLIENT_ID
|
|
if (!v) throw new Error('SKATTEVERKET_APIGW_CLIENT_ID is required')
|
|
return v
|
|
}
|
|
function apigwClientSecret(): string {
|
|
const v = process.env.SKATTEVERKET_APIGW_CLIENT_SECRET
|
|
if (!v) throw new Error('SKATTEVERKET_APIGW_CLIENT_SECRET is required')
|
|
return v
|
|
}
|
|
|
|
/** The API-gateway headers Arcim's registered client must add to every SKV data call. */
|
|
export function skvGatewayHeaders(): Record<string, string> {
|
|
return {
|
|
Client_Id: apigwClientId(),
|
|
Client_Secret: apigwClientSecret(),
|
|
skv_client_correlation_id: crypto.randomUUID(),
|
|
}
|
|
}
|