fix(skatteverket): stop classifying the APIGW scope-contract 403 as MISSING_SCOPE (#1250)

* fix(skatteverket): stop classifying the APIGW scope-contract 403 as MISSING_SCOPE

The user-mode 403 branch matched the substring `required scope`, which also
matches the MuleSoft APIGW contract body `{"error": "The required scopes are
not authorized"}`. That body is a subscription gap on our APIGW client (#973),
decided before the bearer is evaluated, so it says nothing about the user's
token. Calling it MISSING_SCOPE put it in RECONSENT_ERROR_CODES, so a
successful reconnect ran runPostConnectRefresh -> syncSkattekonto -> 403 and
instantly re-flagged the row: the reconnect banner perpetuated itself and no
amount of reconnecting could clear it.

Token-scope detection is now a positive match on the two documented shapes
(the OAuth `invalid_scope` code, and SKV's "The required scope <x> has been
requested for that access token." per AGI Tjänstebeskrivning v1.7 §4.1.2.2).
The gateway signature is checked first and maps to ACCESS_DENIED, which is
deliberately not a reconsent code. System mode keeps SYSTEM_AUTH_FAILED for
both (run-level config either way) but no longer points the operator at
SKATTEVERKET_SYSTEM_SCOPES when the gateway is what refused, and the same
wording on a 401 now joins the existing APIGW branch instead of falling
through to SESSION_EXPIRED (also a reconsent code).

Refs #1155 (item 1), #973

* fix(skatteverket): let the gateway signature win over the OAuth challenge header on 401

Review follow-up: the 401 path checked WWW-Authenticate for invalid_scope
before looking at the body, so a MuleSoft contract error arriving with an
OAuth-shaped challenge header would still be classified MISSING_SCOPE. Both
that and SESSION_EXPIRED are reconsent codes, so either verdict re-arms the
banner the user just tried to clear.

The gateway body check now runs first, mirroring the 403 path, and the
redundant entry in looksLikeApigwIssue is gone. The 401 test carries the
challenge header to pin the precedence.
This commit is contained in:
Jakob Wennberg
2026-07-27 19:51:23 +02:00
committed by GitHub
parent 7c44cef66d
commit 63bf0d5ad7
3 changed files with 155 additions and 4 deletions
@@ -109,6 +109,62 @@ describe('skvRequest: error mapping', () => {
}
})
// #1155: the MuleSoft APIGW contract error wears scope wording but is our
// subscription gap (#973), not the user's token. It used to match the
// `required scope` substring test and surface as MISSING_SCOPE, which is in
// RECONSENT_ERROR_CODES: every reconnect ran runPostConnectRefresh ->
// syncSkattekonto -> 403 and instantly re-flagged the row, so the reconnect
// banner could never be cleared.
it('maps the APIGW "required scopes are not authorized" 403 → ACCESS_DENIED, not MISSING_SCOPE', async () => {
mockFetchStatus(403, '{"error": "The required scopes are not authorized"}')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
expect((e as SkatteverketAuthError).message).toMatch(/APIGW|Utvecklarportalen/)
}
})
it('still maps a real token-scope rejection → MISSING_SCOPE', async () => {
// Body shape from SKV's AGI Tjänstebeskrivning v1.7 §4.1.2.2.
mockFetchStatus(
403,
'{"error":"invalid_scope","description":"The required scope agd has been requested for that access token."}',
)
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
}
})
it('maps the SKV scope sentence alone (no invalid_scope code) → MISSING_SCOPE', async () => {
mockFetchStatus(403, 'The required scope agd has been requested for that access token.')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('MISSING_SCOPE')
}
})
it('treats the APIGW contract wording on a 401 as a gateway issue, even with an OAuth challenge header', async () => {
// SESSION_EXPIRED and MISSING_SCOPE are both reconsent codes, so either
// verdict would re-arm the banner. The gateway signature wins over the
// WWW-Authenticate scope marker when both are present.
mockFetchStatus(401, '{"error": "The required scopes are not authorized"}', {
'WWW-Authenticate': 'Bearer error="invalid_scope"',
})
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
}
})
it('maps generic 403 → ACCESS_DENIED', async () => {
mockFetchStatus(403, 'Forbidden')
try {
@@ -214,6 +270,22 @@ describe('skvRequestWithAuth: system mode', () => {
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
expect((e as SkatteverketAuthError).message).toMatch(/SKATTEVERKET_SYSTEM_SCOPES/)
}
})
it('403 APIGW contract error in system mode names the subscription, not the scope list', async () => {
// Still SYSTEM_AUTH_FAILED (run-level config either way), but the two are
// fixed with different knobs, so the message must not send the operator to
// SKATTEVERKET_SYSTEM_SCOPES when the gateway is what refused.
mockFetchStatus(403, '{"error": "The required scopes are not authorized"}')
try {
await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect((e as SkatteverketAuthError).code).toBe('SYSTEM_AUTH_FAILED')
expect((e as SkatteverketAuthError).message).toMatch(/prenumeration/)
expect((e as SkatteverketAuthError).message).not.toMatch(/SKATTEVERKET_SYSTEM_SCOPES/)
}
})
@@ -216,6 +216,42 @@ async function refreshTokenForUser(
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;
@@ -359,6 +395,20 @@ export async function skvRequestWithAuth(
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:
@@ -402,6 +452,7 @@ export async function skvRequestWithAuth(
// 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') ||
@@ -469,7 +520,18 @@ export async function skvRequestWithAuth(
})
if (auth.mode === 'system') {
if (text.includes('invalid_scope') || text.includes('required scope')) {
// 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.',
@@ -486,6 +548,18 @@ export async function skvRequestWithAuth(
'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
@@ -494,7 +568,7 @@ export async function skvRequestWithAuth(
// 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')) {
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 ' +
@@ -547,8 +621,12 @@ export async function skvRequestWithAuth(
* 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
* 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