* fix(skatteverket): request the ska scope for skattekonto v2 The skattekonto v2 API rejects skahmst-only tokens with 403 "The required scopes are not authorized" (observed in prod 2026-07-20; no company has synced since 2026-05-10). The requested `skattekonto` scope is silently dropped from every grant, while `ska` appears in one real May grant, so request it too: SKV grants the intersection, so this is harmless if wrong. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): correct the skattekonto scope model around ska Root cause of the May 10 skattekonto outage, confirmed via git history and prod token data: the `ska` scope (the interactive skattekonto API's actual scope, requested since the extension's first commit in March) was removed by the "remove unused scopes" cleanup in the #431 series. Every token issued after that hour lacks it and the API answers 403 "The required scopes are not authorized"; no company has synced since. The May 15 repair re-added skahmst, which per its tjanstebeskrivning is a different bulk E-transport service and does not substitute; `skattekonto` is not a real SKV scope name and is silently dropped from grants. Follow-up to the ska re-request (cd8f7a30): - document the confirmed scope model in oauth.ts so ska is never "cleaned up" again - panel missing-scope warning and reconnect-button now gate on ska, not skahmst/skattekonto - scope badge labels: ska takes the saldo & transaktioner label, skahmst relabeled as the E-transport file service - consent-page note covers both terse scope names and says ska is required Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(year-end): warn on untaxed profit at verkstall, Swedish readiness messages, always-visible period selector An aktiebolag could execute year-end with a profit and zero bolagsskatt booked without any warning (support case: closing moved 592k to 2099 untaxed). The preview now computes bolagsskattMissing (AB + profit + no 89xx account among closed accounts, 8999 excluded) and both the preview and execute steps render an advisory, bypassable warning. validateYearEndReadiness messages are now Swedish (the bokslut wizard is a stays-Swedish surface); the MCP year_end_readiness classifier matches both the new Swedish strings and the legacy English ones. The wizard period selector now always renders, keeps a selected-but- ineligible period selectable, and resets a stale ?period= id from another company instead of leaving the user stuck on the wrong year. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(year-end): administrative undo of an executed year-end closing Storno-only reset used when a bokslut was executed prematurely (e.g. without bolagsskatt) and no arsredovisning exists yet: reverses the next period's result_appropriation and opening_balance entries, reopens the period, reverses the closing entry, and detaches closing_entry_id. Resumable if interrupted midway; attribution per BFL 5 kap 6. Migration 20260720140000 adds the trigger escape hatch: closing_entry_id may only change once set when the old closing entry is reversed with a posted storno chain (status flag alone is forgeable via PostgREST), and a non-NULL replacement must be a posted year_end entry in the same period. Covered by a pg-real test. planResultAppropriation idempotency is now posted-only: a reversed omforing no longer blocks the re-run from posting a fresh 2099 -> 2098 reclassification (it previously returned null silently, leaving the new year's equity polluted). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address CodeRabbit, PR-Agent and compliance findings - undo script: company_id filters on verify queries, period-scope the arsredovisning precondition checks, validate service-key format, escalate audit_log insert failure to a hard error (BFNAR 2013:2) - detach migration: company-scope the storno chain EXISTS, replace the em dash in the new error message Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address round-2 compliance swarm and Swedish review findings - undo script: require --confirm-url with --commit so an env swap fails loud; retry the audit_log insert 3x and direct the operator to insert the behandlingshistorik row manually on final failure (BFNAR 2013:2) - year-end preview: document why resultAccountSummary is a complete 89xx scan; warning text now also names periodiseringsfond and overavskrivningar as legitimate zero-tax reasons Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { SkatteverketTokens } from '../types'
|
|
import {
|
|
fetchWithTimeout,
|
|
OAUTH_TIMEOUT_MS,
|
|
SKATTEVERKET_EXCHANGE_TIMEOUT_MS,
|
|
} from '@/lib/http/fetch-with-timeout'
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
|
|
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.
|
|
const DEFAULT_SCOPES = 'momsdeklaration inkforetag skahmst skattekonto ska agd'
|
|
|
|
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,
|
|
): Promise<SkatteverketTokens> {
|
|
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<SkatteverketTokens> {
|
|
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 ?? '',
|
|
}
|
|
}
|