Files
accounted/extensions/general/skatteverket/lib/api-client.ts
T
Jakob WennbergandClaude Fable 5 5c42852d7e fix(skatteverket): classify 400 expired-refresh-token as SESSION_EXPIRED (#1151)
The AGI kvittenser cron error-logged 'Skatteverket token refresh failed
(400): access_denied / Refresh Token status is expired' every 15 minutes
(19+ times on 2026-07-24 alone) and kept re-attempting the dead token
against SKV forever. Only the 404 id_not_found dialect of a dead refresh
session was classified as SESSION_EXPIRED; the 400 variants escaped as
raw Errors, so the cron's needs_reconsent flagging never ran.

Extend the classifier in refreshTokenForUser to also match the 400
'Refresh Token status is expired' body and OAuth2's standard 400
invalid_grant. With a typed SESSION_EXPIRED, the cron marks the token
row needs_reconsent on the first failure and resolveReadAuth skips the
connection on every later run until the user reconnects via BankID.

Config-shaped 400s (invalid_client, invalid_scope) deliberately stay
raw errors: a reconnect cannot fix those, and mislabeling them would
re-create the self-perpetuating reconnect banner from the 2026-07
MISSING_SCOPE incident.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:06:02 +02:00

582 lines
23 KiB
TypeScript

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<void> {
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<string, Promise<string>>()
/**
* 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<string> {
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<string> {
// 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
}
/**
* 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<Response> {
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<Response> {
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<string, string> = {
'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<string, string> = {}
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()
// 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.
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') {
if (text.includes('invalid_scope') || text.includes('required scope')) {
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'
)
}
// 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 (text.includes('invalid_scope') || text.includes('required 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'
)
}
// 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.
* ACCESS_DENIED : generic 403
* 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'
}
}