import crypto from 'crypto' import type { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' import { refreshAccessToken } from './oauth' import { getTokens, storeTokens, deleteTokens } from './token-store' import { getSystemAccessToken, invalidateSystemToken } from './system-auth/token-provider' import type { SkatteverketTokens } from '../types' /** * Credential selector for SKV API calls. * * 'user' : the personal BankID OAuth token (per-user, 65-minute refresh * chain). The only mode that existed before the hybrid model. * 'system' : Accounted's own Client Credentials token (org certificate), * authorized per company via an ombud grant at Skatteverket. * Used by background reads; carries no user session at all. */ export type SkvAuth = | { mode: 'user'; supabase: SupabaseClient; userId: string } | { mode: 'system' } const log = createLogger('skatteverket-api-client') // Cap diagnostic-body logging at 200 chars and redact any Bearer token // patterns. The audit (V16.1 / A.8.15) flagged that raw 401/403 bodies were // being concatenated into user-facing error messages and written to logs // without redaction. Diagnostic data still belongs in server-side logs, but // not in unbounded form and not in anything that reaches the user. const MAX_LOG_BODY_LEN = 200 const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9\-._~+/=]+/gi function safeBodyForLog(body: string): string { const redacted = body.replace(BEARER_PATTERN, 'Bearer [REDACTED]') return redacted.length > MAX_LOG_BODY_LEN ? redacted.slice(0, MAX_LOG_BODY_LEN) + '…' : redacted } /** * Skatteverket API client. * * Handles: * - Automatic token refresh (transparent to callers) * - Required API gateway headers * - Rate limiting (4 req/sec per consumer) * - Correlation ID generation */ const DEFAULT_API_BASE_URL = 'https://api.test.skatteverket.se/momsdeklaration/v1' const MAX_REFRESH_COUNT = 10 const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000 // Refresh 5 min before expiry // Simple in-memory token bucket for 4 req/sec rate limit let lastRequestTime = 0 const MIN_REQUEST_INTERVAL_MS = 250 // 1000ms / 4 = 250ms function getApiBaseUrl(): string { return process.env.SKATTEVERKET_API_BASE_URL || DEFAULT_API_BASE_URL } function getApiGwClientId(): string { const id = process.env.SKATTEVERKET_APIGW_CLIENT_ID if (!id) throw new Error('SKATTEVERKET_APIGW_CLIENT_ID is required') return id } function getApiGwClientSecret(): string { const secret = process.env.SKATTEVERKET_APIGW_CLIENT_SECRET if (!secret) throw new Error('SKATTEVERKET_APIGW_CLIENT_SECRET is required') return secret } /** * Kill switch: when SKATTEVERKET_DISABLED=true, all SKV API calls fail with a * single, clear Swedish error. Useful during incidents (provider outage, key * rotation, suspended access) to surface a graceful failure mode instead of * letting requests hang or leak partial state. */ function isDisabled(): boolean { const v = (process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() return v === 'true' || v === '1' || v === 'yes' } /** * Detect whether we're pointed at SKV's test or prod environment. * Used by the UI to surface an obvious badge so the user knows whether their * filings will hit Skatteverket's production system. */ export function getSkatteverketEnvironment(): 'test' | 'prod' { const baseUrl = process.env.SKATTEVERKET_API_BASE_URL || process.env.SKATTEVERKET_AGD_INLAMNING_API_BASE_URL || process.env.SKATTEVERKET_SKATTEKONTO_API_BASE_URL || DEFAULT_API_BASE_URL return baseUrl.includes('api.test.skatteverket.se') ? 'test' : 'prod' } /** * Ensure rate limit compliance (4 req/sec). * Delays if the last request was too recent. */ async function enforceRateLimit(): Promise { const now = Date.now() const elapsed = now - lastRequestTime lastRequestTime = now // Claim the slot immediately to prevent concurrent bypass if (elapsed < MIN_REQUEST_INTERVAL_MS) { await new Promise(resolve => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed)) } } // Coalesce concurrent refresh attempts within this Node.js process. Without // this, two parallel SKV requests from the same user (e.g. rapid UI clicks) // would both call SKV's /token endpoint with the same refresh_token; SKV // rotates that token on first use, so the second call would fail with 401. // Cross-process races (separate Vercel function instances) are mitigated by // the re-read inside the critical section: if another process refreshed // while we waited on the network, we just use that newer token. const refreshInFlight = new Map>() /** * Get a valid access token, refreshing if needed. * Throws if no tokens exist or refresh is exhausted. */ async function getValidToken( supabase: SupabaseClient, userId: string ): Promise { const tokens = await getTokens(supabase, userId) if (!tokens) { throw new SkatteverketAuthError( 'Inte ansluten till Skatteverket. Anslut med BankID först.', 'NOT_CONNECTED' ) } // Token still valid (with 5-min margin) if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) { return tokens.access_token } // Need refresh: coalesce concurrent attempts. const inFlight = refreshInFlight.get(userId) if (inFlight) return inFlight const promise = refreshTokenForUser(supabase, userId) .finally(() => refreshInFlight.delete(userId)) refreshInFlight.set(userId, promise) return promise } async function refreshTokenForUser( supabase: SupabaseClient, userId: string, ): Promise { // Re-read after entering the critical section. Another process may have // refreshed while we were waiting; if so, the row now has a new // refresh_token and a future expiry: just hand it back. const tokens = await getTokens(supabase, userId) if (!tokens) { throw new SkatteverketAuthError( 'Inte ansluten till Skatteverket. Anslut med BankID först.', 'NOT_CONNECTED' ) } if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) { return tokens.access_token } if (!tokens.refresh_token) { throw new SkatteverketAuthError( 'Sessionen har gått ut. Logga in med BankID igen.', 'SESSION_EXPIRED' ) } if (tokens.refresh_count >= MAX_REFRESH_COUNT) { throw new SkatteverketAuthError( 'Maximalt antal förnyelser uppnått. Logga in med BankID igen.', 'REFRESH_EXHAUSTED' ) } let refreshed try { refreshed = await refreshAccessToken(tokens.refresh_token, tokens.refresh_count) } catch (err) { // SKV's `per`-flow refresh tokens live 65 minutes. Any daily cron (or a // user returning the next day) therefore always finds a dead token and // gets 404 id_not_found back — that's ordinary session expiry, not a // runtime error. Classify it so the crons' quiet buckets and the UI's // reconnect flow catch it instead of a raw Error escaping to the logs. // SKV speaks several dialects for the same terminal state: 404 with // id_not_found / "refresh token is not found", 400 access_denied with // "Refresh Token status is expired", and OAuth2's standard 400 // invalid_grant. Config-shaped 400s (invalid_client, invalid_scope) // deliberately stay raw errors: telling the user to reconnect cannot // fix those, and mislabeling them re-creates the self-perpetuating // reconnect banner from the 2026-07 MISSING_SCOPE incident. const message = err instanceof Error ? err.message : String(err) const deadRefreshToken = (/\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))) if (deadRefreshToken) { throw new SkatteverketAuthError( 'Sessionen har gått ut. Logga in med BankID igen.', 'SESSION_EXPIRED' ) } throw err } const updatedTokens: SkatteverketTokens = { ...refreshed, scope: tokens.scope, } await storeTokens(supabase, userId, updatedTokens) return updatedTokens.access_token } /** * MuleSoft APIGW contract enforcement, observed verbatim in production: * * { "error": "The required scopes are not authorized" } * * The gateway emits this when OUR APIGW client (SKATTEVERKET_APIGW_CLIENT_ID) * has no subscription for the API being called (#973). It is decided before * the bearer is ever evaluated, so it says nothing about the user's token. * * It has to be ruled out explicitly because it contains the substring * "required scope", which is how the SKV token-scope rejection used to be * detected: that collision classified every gateway 403 as MISSING_SCOPE, and * MISSING_SCOPE is in RECONSENT_ERROR_CODES, so a successful reconnect * (runPostConnectRefresh -> syncSkattekonto -> 403) instantly re-flagged the * token row and the reconnect banner perpetuated itself (#1155). */ function isApigwScopeContractError(body: string): boolean { return /required scopes?\s+are\s+not\s+authorized/i.test(body) } /** * A genuine token-scope rejection: the stored access token predates a scope * the service now requires, and only a fresh consent can widen it. * * Matches the two documented shapes and nothing else: the OAuth `invalid_scope` * error code (RFC 6749), and the sentence from SKV's AGI service description * (Tjänstebeskrivning v1.7 §4.1.2.2), "The required scope agd has been * requested for that access token." */ function isTokenScopeRejection(body: string): boolean { return ( /invalid_scope/i.test(body) || /required scope\s+\S+\s+has been requested/i.test(body) ) } /** * Make an authenticated request to the Skatteverket API with the user's * personal BankID token. Thin wrapper kept for the ~40 existing call sites; * new auth-aware code calls skvRequestWithAuth directly. */ export async function skvRequest( supabase: SupabaseClient, userId: string, method: string, path: string, body?: unknown, options?: { baseUrl?: string; contentType?: string } ): Promise { return skvRequestWithAuth({ mode: 'user', supabase, userId }, method, path, body, options) } /** * Make an authenticated request to the Skatteverket API. * * Automatically handles: * - Credential resolution per auth mode (user token refresh, or the cached * system CCG token) * - Required headers (Client_Id, Client_Secret, correlation ID) * - Rate limiting * * Error semantics differ by mode: user-mode 401/403s map to the reconnect * codes (SESSION_EXPIRED, TOKEN_REVOKED, ...); system-mode failures never * touch skatteverket_tokens and map to SYSTEM_AUTH_FAILED (run-level * credential problem) or OMBUD_GRANT_MISSING (this company has not granted, * or has revoked, the behorighet). */ export async function skvRequestWithAuth( auth: SkvAuth, method: string, path: string, body?: unknown, options?: { baseUrl?: string; contentType?: string } ): Promise { if (isDisabled()) { throw new SkatteverketAuthError( 'Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.', 'ACCESS_DENIED' ) } let accessToken: string if (auth.mode === 'user') { accessToken = await getValidToken(auth.supabase, auth.userId) } else { try { accessToken = await getSystemAccessToken() } catch (err) { throw new SkatteverketAuthError( err instanceof Error ? err.message : 'Systemtoken kunde inte hämtas.', 'SYSTEM_AUTH_FAILED' ) } } await enforceRateLimit() const url = `${options?.baseUrl || getApiBaseUrl()}${path}` const headers: Record = { 'Authorization': `Bearer ${accessToken}`, 'Client_Id': getApiGwClientId(), 'Client_Secret': getApiGwClientSecret(), 'skv_client_correlation_id': crypto.randomUUID(), } // contentType defaults to application/json, which is right for moms + // skattekonto. AGI's POST /underlag takes application/xml: callers pass // the XML as a string body and override contentType. let serializedBody: string | undefined if (body !== undefined) { const contentType = options?.contentType ?? 'application/json' headers['Content-Type'] = contentType serializedBody = typeof body === 'string' ? body : JSON.stringify(body) } const response = await fetch(url, { method, headers, body: serializedBody, signal: AbortSignal.timeout(15_000), }) // Handle Skatteverket-specific auth/throttle errors uniformly so callers // can catch a single error type rather than parsing status codes inline. if (response.status === 401) { // SKV returns 401 for two distinct reasons that need different remedies: // 1. Genuine token expiry / invalid bearer (user must re-auth) // 2. APIGW client lacks subscription for this API (developer portal fix): // the bearer is valid but the gateway rejects the call. // Read the body and gateway-side headers so we can distinguish and // surface a useful message. const text = await response.text().catch(() => '') // WWW-Authenticate carries OAuth's machine-readable failure reason // (insufficient_scope / invalid_token). The x-skv-* / x-amzn-* / x-api-* // families are gateway-side hints SKV's APIGW emits when it rejects the // call before reaching the application: the body is often empty in // that case so the headers are the only signal. const wwwAuth = response.headers.get('WWW-Authenticate') ?? '' const skvHeaders: Record = {} response.headers.forEach((v, k) => { const lk = k.toLowerCase() if ( lk === 'www-authenticate' || lk.startsWith('x-skv-') || lk.startsWith('x-amzn-') || lk.startsWith('x-api-') ) { skvHeaders[k] = v } }) // Diagnostic detail (headers, body) belongs in server-side logs only, // not in user-facing error messages. The structured logger redacts // sensitive keys and we further cap body length and strip Bearer tokens // so the diagnostic is bounded. warn, not error: auth rejections are // expected states (expired sessions, stale scopes) and the thrown // SkatteverketAuthError below carries the signal to the caller. log.warn('401 from Skatteverket API', { url, statusCode: 401, authMode: auth.mode, body: safeBodyForLog(text), headers: skvHeaders, }) if (auth.mode === 'system') { // A rejected system token is a run-level credential problem (cert, // token endpoint, APIGW subscription): drop the cache so the next // call mints fresh, and never touch the user token table from here. invalidateSystemToken() throw new SkatteverketAuthError( 'Skatteverket avvisade systemautentiseringen. Kontrollera certifikatet ' + 'och APIGW-prenumerationerna för systemklienten.', 'SYSTEM_AUTH_FAILED' ) } const lower = text.toLowerCase() // Gateway signature first, mirroring the 403 path. MuleSoft can pair its // contract-enforcement body with an OAuth-shaped challenge header, and the // scope branch below would then claim a token problem that reconnecting // cannot fix: SESSION_EXPIRED and MISSING_SCOPE are both reconsent codes, // so either verdict re-arms the banner the user just tried to clear. if (isApigwScopeContractError(text)) { throw new SkatteverketAuthError( 'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' + 'APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har prenumeration på ' + 'denna tjänst i Utvecklarportalen.', 'ACCESS_DENIED' ) } // OAuth's standard insufficient_scope marker. SKV sometimes emits this // as 401 (rather than 403) when the AGI APIGW evaluates scope before // the application sees the token. The remedy is the same as MISSING_SCOPE: // disconnect + reconnect to mint a token covering the AGI scope. const wwwLower = wwwAuth.toLowerCase() if ( wwwLower.includes('insufficient_scope') || wwwLower.includes('invalid_scope') ) { throw new SkatteverketAuthError( 'Anslutningen mot Skatteverket saknar nödvändig behörighet för denna ' + 'tjänst. Koppla bort och anslut igen via Inställningar → Skatteverket ' + 'för att förnya tokenen med rätt scope.', 'MISSING_SCOPE' ) } // SKV explicitly declares the token revoked. Body shape observed in // production: { "error": "Token has been revoked." } with a generic // `Bearer realm="OAuth2 Client Realm"` challenge header. This is a // terminal state: the bearer will never come back to life, regardless // of refresh attempts (refresh_token from the same family is also dead). // Auto-clear the local row so /status stops claiming we're connected // and the next interaction forces a clean reconnect. We swallow any // delete error: even if cleanup fails we still want to surface the // primary auth error to the user. if (lower.includes('revoked') || lower.includes('token has been revoked')) { try { await deleteTokens(auth.supabase, auth.userId) } catch (cleanupErr) { log.error('failed to clear revoked token row', cleanupErr as Error, { userId: auth.userId }) } throw new SkatteverketAuthError( 'Skatteverket har återkallat anslutningen. Detta händer t.ex. om ' + 'BankID-sessionen avslutats eller om en ny anslutning gjorts från ' + 'en annan enhet. Anslut igen med BankID för att fortsätta.', 'TOKEN_REVOKED' ) } // APIGW subscription / client-credential problems: the gateway responds // before the bearer is ever evaluated. The user reconnecting won't help // here: it's an Utvecklarportalen / APIGW configuration issue. // (the APIGW scope-contract body is already handled above) const looksLikeApigwIssue = lower.includes('client_id') || lower.includes('client id') || lower.includes('subscription') || lower.includes('not subscribed') || lower.includes('apigw') || lower.includes('api key') || lower.includes('consumer') if (looksLikeApigwIssue) { throw new SkatteverketAuthError( 'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' + 'APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har prenumeration på ' + 'denna tjänst i Utvecklarportalen.', 'ACCESS_DENIED' ) } // (B) Empty 401 with no diagnostic header → almost always a gateway/ // subscription issue rather than a real session expiry. We refreshed // the local bearer immediately above, so an empty body with no // WWW-Authenticate means SKV's APIGW rejected the call before it // reached the application: typically because the APIGW client isn't // subscribed to the API at the URL we just hit. Telling the user to // "log in again" sends them down a dead end; be explicit about the // likely fix instead. if (!text) { // Extract the API segment of the URL so the message tells the user // exactly which subscription is missing. Falls back to the raw URL // if parsing fails. let apiHint = url try { const u = new URL(url) const parts = u.pathname.split('/').filter(Boolean) // Take the first 3 segments, e.g. arbetsgivardeklaration/inlamning/v1 if (parts.length >= 1) apiHint = parts.slice(0, 3).join('/') } catch { // keep raw url } throw new SkatteverketAuthError( 'Skatteverkets API-gateway nekade anropet utan motivering. ' + 'Trolig orsak: APIGW-klienten (SKATTEVERKET_APIGW_CLIENT_ID) har ' + `inte prenumeration på tjänsten "${apiHint}" i Utvecklarportalen, ` + 'eller den lagrade tokenen saknar rätt scope. Kontrollera ' + 'prenumerationen, koppla annars bort och anslut igen via ' + 'Inställningar → Skatteverket.', 'ACCESS_DENIED' ) } throw new SkatteverketAuthError( 'Sessionen har gått ut. Logga in med BankID igen.', 'SESSION_EXPIRED' ) } if (response.status === 403) { const text = await response.text().catch(() => '') // Same diagnostic-vs-user-message split as the 401 path: log the body // server-side, surface only the actionable Swedish guidance. log.warn('403 from Skatteverket API', { url, statusCode: 403, authMode: auth.mode, body: safeBodyForLog(text), }) if (auth.mode === 'system') { // Both cases are run-level configuration problems (SYSTEM_AUTH_FAILED), // but they are fixed with different knobs, so the message must not // point at the scope list when the gateway is what refused. if (isApigwScopeContractError(text)) { throw new SkatteverketAuthError( 'Skatteverkets API-gateway nekade systemanropet: APIGW-klienten ' + '(SKATTEVERKET_APIGW_CLIENT_ID) saknar prenumeration på denna ' + 'tjänst i Utvecklarportalen.', 'SYSTEM_AUTH_FAILED' ) } if (isTokenScopeRejection(text)) { throw new SkatteverketAuthError( 'Systemtokenens scope räcker inte för denna tjänst. Kontrollera ' + 'SKATTEVERKET_SYSTEM_SCOPES mot tjänstens krav.', 'SYSTEM_AUTH_FAILED' ) } // With valid system credentials, a 403 means this company has not // granted (or has revoked) the behorighet for Accounted's org number. // Company-level: the caller downgrades the connection row, other // companies in the same run are unaffected. throw new SkatteverketAuthError( 'Företaget har inte gett Accounted behörighet hos Skatteverket, ' + 'eller så har behörigheten återkallats i Ombud och behörigheter.', 'OMBUD_GRANT_MISSING' ) } // Gateway contract failure, checked first: it wears scope wording but is // our APIGW subscription, not the user's token. Reconnecting cannot fix // it, and calling it MISSING_SCOPE made every reconnect re-flag the row // (#1155). ACCESS_DENIED is deliberately not in RECONSENT_ERROR_CODES. if (isApigwScopeContractError(text)) { throw new SkatteverketAuthError( 'Skatteverkets API-gateway nekade anropet. Kontrollera att din ' + 'APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har prenumeration på ' + 'denna tjänst i Utvecklarportalen.', 'ACCESS_DENIED' ) } // Missing scope on the access token: fires when an existing connection // pre-dates an extension that needed a new scope (the AGI/`agd` rollout // is the canonical example). The user has to disconnect + reconnect to // re-issue a token with the broader scope set; we want to say so // explicitly instead of letting it surface as a generic 403. // Body shape per SKV's AGI service description (Tjänstebeskrivning v1.7 // §4.1.2.2): { "error": "invalid_scope", "description": "The required // scope agd has been requested for that access token." } if (isTokenScopeRejection(text)) { throw new SkatteverketAuthError( 'Anslutningen mot Skatteverket saknar nödvändig behörighet för denna ' + 'tjänst. Koppla bort och anslut igen via Inställningar → Skatteverket ' + 'för att förnya tokenen med rätt scope.', 'MISSING_SCOPE' ) } // Behörighet saknas: user is authenticated but not authorized for this company if (text.includes('Behörighet') || text.includes('behörighet')) { throw new SkatteverketAuthError( 'Du har inte behörighet att agera för detta företag hos Skatteverket. ' + 'Kontrollera att du är registrerad som firmatecknare eller deklarationsombud.', 'BEHORIGHET_SAKNAS' ) } throw new SkatteverketAuthError( 'Åtkomst nekad av Skatteverket (403). Kontakta support om problemet kvarstår.', 'ACCESS_DENIED' ) } if (response.status === 429) { // Skatteverket may include a Retry-After header. We surface a generic // Swedish message: callers can inspect the header on the thrown error // if they need to schedule a retry. The 4 req/sec local rate limiter // should normally prevent this; a 429 here implies the per-consumer // gateway quota was exceeded. throw new SkatteverketAuthError( 'Skatteverket är överbelastat eller har strypt anropen. Försök igen om en stund.', 'RATE_LIMITED' ) } return response } /** * Structured error for Skatteverket auth/access/throttle issues. * The `code` field helps the frontend show appropriate UI. * * Codes: * NOT_CONNECTED : no tokens stored; user needs to run BankID flow * SESSION_EXPIRED : 401 from SKV; refresh exhausted or token rejected * REFRESH_EXHAUSTED : refresh count hit cap (10) before user re-auth * TOKEN_REVOKED : 401 with "Token has been revoked." body; SKV killed * the bearer (BankID session ended, parallel connect * from another device, or auth-code reuse). Local row * is auto-cleared; user must reconnect with BankID. * BEHORIGHET_SAKNAS : 403 with "Behörighet" body; user not authorized * for this company at SKV (firmatecknare / ombud) * MISSING_SCOPE : 403 with "invalid_scope" body; the stored token * was issued before the required scope existed. * User must disconnect + reconnect. NOT emitted for * the APIGW's "The required scopes are not authorized" * contract error: that is our subscription gap, and * treating it as a token problem made every reconnect * re-flag the row (#1155). * ACCESS_DENIED : generic 403, and the APIGW contract error above * RATE_LIMITED : 429 from SKV API gateway * TOKEN_CORRUPTED : stored tokens cannot be decrypted (key rotated * or row tampered with); user must reconnect * SYSTEM_AUTH_FAILED : system (CCG) credential problem: token could not * be minted, was rejected, or lacks scope. Run-level: * affects every company, fix is configuration-side. * OMBUD_GRANT_MISSING: 403 on a system-mode call: this company has not * granted (or has revoked) Accounted's behorighet at * Skatteverket. Company-level. */ export class SkatteverketAuthError extends Error { constructor( message: string, public readonly code: | 'NOT_CONNECTED' | 'SESSION_EXPIRED' | 'REFRESH_EXHAUSTED' | 'TOKEN_REVOKED' | 'BEHORIGHET_SAKNAS' | 'MISSING_SCOPE' | 'ACCESS_DENIED' | 'RATE_LIMITED' | 'TOKEN_CORRUPTED' | 'SYSTEM_AUTH_FAILED' | 'OMBUD_GRANT_MISSING' ) { super(message) this.name = 'SkatteverketAuthError' } }