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 { 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 { 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 ?? '', } }