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:
@@ -626,3 +626,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-27] support_feedback_submitted now reports BOTH channels (email + ticket) with a derived `lost` flag, not just email delivery. Both channels fail silently from the user's side: email is the guarantee so the UI still shows success when only the ticket failed, and a ticket that never opened leaves nothing in PostHog Support to look at either. Answering "did the ticket open?" previously required reproducing it with devtools open, which is exactly what happened the first time Support shipped. ticket: 'unavailable' is kept distinct from 'failed' because unavailable is the expected steady state (Support off, analytics off) while failed means conversations were live and the call still did not land; only the second is worth alerting on. `lost` (neither channel worked) is the single property to alert on. Still carries no message body, pinned by a test.
|
||||
[2026-07-27] support_feedback_submitted reports both channels (email + ticket) with a derived `lost` flag, and the ticket call runs concurrently with a 4s cap instead of being awaited after the email: both channels fail silently from the user's side, so "did the ticket open?" was previously only answerable by reproducing the submission with devtools open, and awaiting the ticket sequentially let a hung sendMessage hold the confirmation dialog open despite the code comment claiming it could not. ticket: 'unavailable' stays distinct from 'failed' and 'timeout' because unavailable is the expected steady state (Support off, analytics off, self-hosted) while the other two mean conversations were live and the call still did not land; only those deserve an alert. `lost` (neither channel worked) is the single property to alert on. Still carries no message body, pinned by a test.
|
||||
[2026-07-27] Supplier-invoice edits (#1230): block invoice_date / supplier_invoice_number once registration_journal_entry_id is set, rather than propagating the change into the verifikat via correct_entry_metadata: the friendlier propagate option turns a metadata PUT into a bookkeeping write (voucher rättelse, rattelse-log, period-lock checks) and needs a deliberate product call; blocking is the minimal legally correct behaviour and leaves due_date/payment_reference/notes editable for the aged-invoice flow (#1206).
|
||||
[2026-07-27] SKV 403 classification (#1155 item 1): the MuleSoft APIGW body "The required scopes are not authorized" is checked BEFORE the token-scope patterns and maps to ACCESS_DENIED (user mode) / SYSTEM_AUTH_FAILED with a subscription message (system mode). Substring matching on 'required scope' collided with it and produced MISSING_SCOPE, which is a RECONSENT code, so every reconnect re-flagged the token row. Token-scope detection is now a positive match on invalid_scope or SKV's documented "required scope <x> has been requested" sentence, not a loose substring.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user