* fix(skatteverket): request the AGI kvittens scope, not just the inlamning one
AGI is backed by two SKV APIs and each needs its own scope, but DEFAULT_SCOPES
only carried `agd`. That covers arbetsgivardeklaration/inlamning (POST underlag,
kontrollresultat, spara, skapaGranskningsunderlag), so a filing submits and
signs perfectly. The kvittens read lives on hanteraredovisningsperiod, which
requires `agdredovisningperiod`, so the flow died on its very last step with
403 {"error": "The required scopes are not authorized"}.
Confirmed against production: the same APIGW client id and secret reach the
bearer check on both APIs (401 "Token has been revoked" from each with a bogus
token), proving both subscriptions exist and the gateway contract was never the
problem, and SKV's application registration lists `agd`, `agdredovisningperiod`
and `momsdeklaration` as three separate scopes.
The body is identical to the APIGW subscription gap of #973, which is why
api-client's classification cannot distinguish the two from the string alone;
that split still needs the gateway-side check, so it is left as is.
Note the spelling `agdredovisningperiod`: no genitive s, exactly as SKV
registers it. Pinned with a scope-set regression test, since this is the third
time a scope has gone missing (#431 removed `ska` the same way) and the damage
is always invisible until a real filing fails.
The AGIPanel missing-scope banner now checks both scopes. It only looked for
`agd`, so the token shape that actually hurts, one that gets all the way
through signing before failing, produced no warning at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(skatteverket): stop blaming the APIGW subscription for a missing token scope
`{"error": "The required scopes are not authorized"}` has two causes and the
gateway never says which: our APIGW client has no subscription for the API
(#973), or the token lacks the scope that API requires. The message named only
the first, so a real production filing spent its debugging time in
Utvecklarportalen while the actual fix was a scope the code never requested.
The message now names both knobs, and says WHICH service refused. That last
part is what was really missing: the sibling inlamning API kept working
throughout, so "Skatteverket denied the call" pointed at nothing. Ruling the
two apart still needs the out-of-band test (call the API with a deliberately
invalid bearer and the same Client_Id: a subscription gap fails at the gateway
with this body, a scope gap reaches the bearer check and answers 401), which is
now documented at the predicate instead of living in someone's memory.
Classification is deliberately unchanged. ACCESS_DENIED stays out of
RECONSENT_ERROR_CODES: the scope case does need a reconnect, but only after the
scope exists, so an automatic reconsent would rebuild the self-perpetuating
banner of #1155.
This reverses one specific decision from #1250, which removed the
SKATTEVERKET_SYSTEM_SCOPES mention on the reasoning that the gateway rather
than the scope list had refused. That reasoning assumed the body could tell
them apart. It cannot. The test asserting the omission is inverted, with the
reason recorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(salary): make the AGI kvittens-scope notice dismissible
The two AGI scopes get different treatments in AGIPanel: a token
without agd keeps the hard reconnect nudge, but a token missing only
agdredovisningperiod gets its own softer, dismissible notice. Every
existing hosted token lacks the new scope, and until Skatteverket's
application registration carries it a reconnect mints the same grant
again (SKV silently drops unregistered scope names), so a hard
reconnect demand would be the #1010 un-clearable banner, with each
futile attempt costing a BankID round-trip. Dismissal persists per
granted scope string: a futile reconnect stays quiet, a widened grant
re-evaluates from scratch.
Also asserts the shared prenumeration/scope message and the refused
API path on the 401 contract test, matching the 403 test (review nit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>
* fix(salary): point the kvittens-scope notice at Skatteverket's own e-service
The compliance review flagged that dismissing the notice could leave a
filer without a software-side path to the receipt. Retrieval never
depended on the notice (the kvittens cron retries server-side and the
period's status row stays unconfirmed until a kvittens lands), but the
copy now also names the fallback that always works: verifying the
receipt in Skatteverket's Arbetsgivardeklaration e-service.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>
* fix(salary): use the ladder radius on the kvittens notice
The radius ladder guard landed on main after this notice was written;
rounded-md is dead vocabulary, bordered boxes are rounded-lg.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(salary): scope the kvittens-notice dismissal to the employer
The localStorage key carried only the granted scope string, so on a
shared browser one company's dismissal hid the notice for every other
company with an identical grant. Skatteverket tokens are per company,
so the key now includes arbetsgivare alongside the scope string; the
same-grant reconnect behavior per company is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
217 lines
7.9 KiB
TypeScript
217 lines
7.9 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.
|
|
//
|
|
// 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,
|
|
): 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 ?? '',
|
|
}
|
|
}
|