* 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>
254 lines
8.8 KiB
TypeScript
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,
|
|
)
|
|
}
|