Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
184 lines
5.6 KiB
TypeScript
184 lines
5.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.
|
|
const DEFAULT_SCOPES = 'momsdeklaration inkforetag skahmst skattekonto 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 ?? '',
|
|
}
|
|
}
|