From 5984652e4701c4beeb3718ac15e13dcf9fd358c0 Mon Sep 17 00:00:00 2001 From: bjornbergenheim <29535152+bjornbergenheim@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:01:02 +0200 Subject: [PATCH] fix(skatteverket): request the AGI kvittens scope and stop misdiagnosing the 403 (#1596) * 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 * 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 * 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 Signed-off-by: Emil * 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 Signed-off-by: Emil * 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 * 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 --------- Signed-off-by: Emil Co-authored-by: Claude Opus 5 Co-authored-by: Emil --- DECISIONS.md | 3 + components/salary/AGIPanel.tsx | 86 ++++++++++-- .../skatteverket/__tests__/api-client.test.ts | 50 ++++++- .../__tests__/oauth-scopes.test.ts | 53 ++++++++ .../general/skatteverket/lib/api-client.ts | 122 +++++++++++------- extensions/general/skatteverket/lib/oauth.ts | 20 ++- messages/en.json | 3 + messages/sv.json | 3 + 8 files changed, 278 insertions(+), 62 deletions(-) create mode 100644 extensions/general/skatteverket/__tests__/oauth-scopes.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 8066211c..862eb732 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -946,8 +946,11 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end. [2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. [2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales. +[2026-08-13] AGI needs two SKV scopes, not one: added `agdredovisningperiod` (hanteraredovisningsperiod: kvittenser/las) alongside `agd` (inlamning) in the per-flow DEFAULT_SCOPES, after a real prod filing signed fine and then 403'd on "Hamta kvittens". Pinned with a scope-set regression test since this is the third scope-cleanup casualty; the AGIPanel missing-scope banner now checks both, because a token with `agd` alone fails only at the last step. +[2026-08-13] Reversed the #1250 rule that the APIGW "required scopes are not authorized" body must name only the subscription: production proved the same body also fires when the TOKEN lacks the scope (AGI kvittens needed agdredovisningperiod), so naming one cause and hiding the other presents a coin flip as a diagnosis. The message now names both knobs plus the refused API path, and system mode points at SKATTEVERKET_SYSTEM_SCOPES again. Classification is unchanged (ACCESS_DENIED, still not a reconsent code): a reconnect only helps once the scope exists, so it can never be automatic. [2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue. [2026-08-13] Issue #1408 does not route locked candidates through the current correctEntry service or an unlock: the service creates its storno in the original period, so a compliant locked-period correction needs a separately tested open-period storno and replacement path that preserves the original and full rättelse trail. +[2026-08-13] The AGIPanel kvittens-scope notice is dismissible (persisted per granted scope string), unlike the hard agd banner: all existing hosted tokens lack agdredovisningperiod, and until Skatteverket's application registration carries that scope 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, each futile attempt costing a BankID round-trip via disconnect. Keying dismissal by the exact grant keeps a futile reconnect quiet while a widened grant re-evaluates from scratch. [2026-08-13] Web picker no longer advertises HEIC/HEIF (accept lists in InvoiceInboxWorkspace): supersedes the 2026-08-01 HEIC decision (runtime sharp transcode + hint). The transcode+hint stays as fallback for drag-drop/API/channel HEIC, but without heic in accept iOS transcodes photo-library picks to JPEG, which makes AI extraction work and shrinks 413 risk; magic-byte validation now covers real HEIC/HEIF via ftyp brands instead of a blanket exemption. [2026-08-13] Skipped the planned lib/browser/downscale-image.ts module: lib/documents/shrink-image.ts + upload-size.ts already shipped the client-side downscale wired into the inbox uploadFile (hosted 4 MB ceiling, 2400px long edge, honest refusal message); a second overlapping compressor with different thresholds would create contradictory policies. [2026-08-13] WhatsApp M1 greeting: media from unlinked senders bypasses the 1-hour throttle behind a 10-minute burst window (3/day cap kept): a photo is a receipt someone expects to be handled, and pure hour-long silence taught senders their receipts were being filed; one burst still earns exactly one reply. In-channel silence after stopp stays absolute (only the literal start is read): stopp is an opt-out and any other reply would violate it, so the escape hatch is app-side instead (POST /link/unmute + panel button), where the user is already authenticated and asking. diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index 20d81302..3ebb3746 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -165,6 +165,36 @@ export function AGIPanel(props: AGIPanelProps) { // /authorize call overwrites the stored oauth_state + PKCE verifier, so a // parallel flow guarantees a CSRF failure for whichever tab finishes last. const [connecting, setConnecting] = useState(false) + // Dismissal for the kvittens-scope notice, keyed by employer plus the exact + // granted scope string. The employer keeps dismissals from leaking across + // companies on a shared browser (tokens are per company, so each company's + // grant is its own question); the scope string means a reconnect that comes + // back with the same grant (the scope not yet registered on Skatteverket's + // application) keeps the dismissal, so the notice cannot become an + // un-clearable reconnect loop (#1010), while a new grant re-evaluates from + // scratch. + const [kvittensNoticeDismissed, setKvittensNoticeDismissed] = useState(false) + const grantedScopeString = typeof status?.scope === 'string' ? status.scope : null + const kvittensNoticeKey = grantedScopeString + ? `agi-kvittens-scope-notice:${arbetsgivare}:${grantedScopeString}` + : null + useEffect(() => { + if (!kvittensNoticeKey) return + try { + setKvittensNoticeDismissed(localStorage.getItem(kvittensNoticeKey) === 'dismissed') + } catch { + setKvittensNoticeDismissed(false) + } + }, [kvittensNoticeKey]) + const dismissKvittensNotice = useCallback(() => { + setKvittensNoticeDismissed(true) + if (!kvittensNoticeKey) return + try { + localStorage.setItem(kvittensNoticeKey, 'dismissed') + } catch { + // Best effort: the state update alone hides it for this mount. + } + }, [kvittensNoticeKey]) // "2026-06" for user-facing copy; the period prop is compact YYYYMM. const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}` @@ -884,12 +914,21 @@ export function AGIPanel(props: AGIPanelProps) { ) } - // Tokens issued before the agd scope was added to DEFAULT_SCOPES will - // 403 with invalid_scope at submission time: surface that proactively - // so the user reconnects before hitting the deadline rather than at it. - const missingAgdScope = - typeof status?.scope === 'string' && - !status.scope.split(/\s+/).filter(Boolean).includes('agd') + // Tokens issued before an AGI scope was added to DEFAULT_SCOPES will 403 at + // submission time: surface that proactively so the user reconnects before + // hitting the deadline rather than at it. The two scopes back different + // steps and get different treatments: `agd` (inlämning) fails already at + // submit, is proven grantable, and keeps the hard reconnect nudge. A token + // missing only `agdredovisningperiod` (hantera) sails through submit and + // signing and dies on "Hämta kvittens", but until Skatteverket's application + // registration carries that scope a reconnect mints the same grant again + // (SKV silently drops unregistered scope names), so its notice must be + // dismissible rather than a demand no reconnect can clear (#1010). + const grantedScopes = + typeof status?.scope === 'string' ? status.scope.split(/\s+/).filter(Boolean) : null + const missingAgdScope = grantedScopes !== null && !grantedScopes.includes('agd') + const missingKvittensScope = + grantedScopes !== null && !missingAgdScope && !grantedScopes.includes('agdredovisningperiod') // Recovery states expose the advanced actions on their own: the stale-draft // and error-report guidance below reference them by name. @@ -991,10 +1030,12 @@ export function AGIPanel(props: AGIPanelProps) { )} - {/* Missing-scope banner: proactive nudge before the user hits a - 403 invalid_scope at submission time. The agd scope was added - after some users had already connected, so their stored token - grants moms/skattekonto but not AGI. */} + {/* Missing-scope nudges: proactive, before the user hits a 403 + invalid_scope. The agd scope was added after some users had + already connected, so their stored token grants moms/skattekonto + but not AGI: that one stays a hard nudge. The kvittens scope only + breaks the final receipt fetch and may not be grantable yet, so + its notice is softer and dismissible. */} {missingAgdScope && !readOnly && (

@@ -1011,6 +1052,31 @@ export function AGIPanel(props: AGIPanelProps) {

)} + {missingKvittensScope && !kvittensNoticeDismissed && !readOnly && ( +
+

+ {t('kvittens_scope_title')} +

+

+ {t('kvittens_scope_description')} +

+
+ + {t('open_settings')} + + +
+
+ )} {/* Status summary */}
diff --git a/extensions/general/skatteverket/__tests__/api-client.test.ts b/extensions/general/skatteverket/__tests__/api-client.test.ts index cc27052b..373db3d3 100644 --- a/extensions/general/skatteverket/__tests__/api-client.test.ts +++ b/extensions/general/skatteverket/__tests__/api-client.test.ts @@ -126,6 +126,31 @@ describe('skvRequest: error mapping', () => { } }) + // The same body has two causes (subscription gap #973, missing token scope + // like the AGI kvittens one), and the gateway never says which. A message + // naming only the subscription sent a real prod filing down a dead end: the + // fix was a scope, so every minute spent in Utvecklarportalen was wasted. + it('names BOTH causes and the refused service on the gateway 403', async () => { + mockFetchStatus(403, '{"error": "The required scopes are not authorized"}') + try { + // The real shape of the incident: the kvittens read on the hantera API. + await skvRequest( + fakeSupabase, 'user-1', 'comp-1', 'GET', + '/arbetsgivare/165560000000/redovisningsperioder/202608/kvittenser', + undefined, + { baseUrl: 'https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1' }, + ) + expect.fail('expected throw') + } catch (e) { + const { message } = e as SkatteverketAuthError + expect(message).toMatch(/prenumeration/) + expect(message).toMatch(/scope/) + // Which service refused. Naming it is the whole point: the sibling + // inlamning API kept working, so "Skatteverket said no" is not a clue. + expect(message).toContain('arbetsgivardeklaration/hanteraredovisningsperiod/v1') + } + }) + 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( @@ -158,10 +183,19 @@ describe('skvRequest: error mapping', () => { 'WWW-Authenticate': 'Bearer error="invalid_scope"', }) try { - await skvRequest(fakeSupabase, 'user-1', 'comp-1', 'GET', '/x') + await skvRequest( + fakeSupabase, 'user-1', 'comp-1', 'GET', '/x', undefined, + { baseUrl: 'https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1' }, + ) expect.fail('expected throw') } catch (e) { - expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED') + const { code, message } = e as SkatteverketAuthError + expect(code).toBe('ACCESS_DENIED') + // Same shared message as the 403 contract path: both causes named plus + // the refused service, so the 401 shape cannot drift into vaguer text. + expect(message).toMatch(/prenumeration/) + expect(message).toMatch(/scope/) + expect(message).toContain('arbetsgivardeklaration/inlamning/v1') } }) @@ -274,10 +308,12 @@ describe('skvRequestWithAuth: system mode', () => { } }) - 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. + it('403 APIGW contract error in system mode names both knobs', async () => { + // Still SYSTEM_AUTH_FAILED (run-level config either way). This assertion + // is the inverse of what it was: #1250 removed the SKATTEVERKET_SYSTEM_SCOPES + // mention on the reasoning that the gateway, not the scope list, had + // refused. Production later proved the body cannot distinguish the two, so + // naming one and hiding the other is a coin flip presented as a diagnosis. mockFetchStatus(403, '{"error": "The required scopes are not authorized"}') try { await skvRequestWithAuth({ mode: 'system' }, 'GET', '/x') @@ -285,7 +321,7 @@ describe('skvRequestWithAuth: system mode', () => { } 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/) + expect((e as SkatteverketAuthError).message).toMatch(/SKATTEVERKET_SYSTEM_SCOPES/) } }) diff --git a/extensions/general/skatteverket/__tests__/oauth-scopes.test.ts b/extensions/general/skatteverket/__tests__/oauth-scopes.test.ts new file mode 100644 index 00000000..5f9a2d1d --- /dev/null +++ b/extensions/general/skatteverket/__tests__/oauth-scopes.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { buildAuthorizeUrl } from '../lib/oauth' + +/** + * Scope-set regression guard. + * + * Every entry here was added because removing it broke a live filing, and the + * damage is always delayed: SKV issues the token happily and the missing scope + * only surfaces as a 403 from the one API that needed it. A "remove unused + * scopes" cleanup has already cost us this twice (#431 for `ska`, and `agd` + * alone for AGI kvittenser). Pin them. + */ +describe('Skatteverket per-flow OAuth scopes', () => { + const originalClientId = process.env.SKATTEVERKET_OAUTH2_CLIENT_ID + + beforeEach(() => { + process.env.SKATTEVERKET_OAUTH2_CLIENT_ID = 'test-client-id' + }) + + afterEach(() => { + if (originalClientId === undefined) delete process.env.SKATTEVERKET_OAUTH2_CLIENT_ID + else process.env.SKATTEVERKET_OAUTH2_CLIENT_ID = originalClientId + }) + + function requestedScopes(): string[] { + const url = new URL(buildAuthorizeUrl('https://app.example/callback', 'state-123')) + return (url.searchParams.get('scope') ?? '').split(' ').filter(Boolean) + } + + it('requests both AGI scopes, one per backing API', () => { + // `agd` backs inlamning (submit/sign); `agdredovisningperiod` backs + // hanteraredovisningsperiod (kvittenser/las). A token with only the first + // completes a filing and then fails on "Hämta kvittens". + expect(requestedScopes()).toEqual( + expect.arrayContaining(['agd', 'agdredovisningperiod']), + ) + }) + + it('keeps the interactive skattekonto scope', () => { + expect(requestedScopes()).toContain('ska') + }) + + it('keeps the momsdeklaration scope', () => { + expect(requestedScopes()).toContain('momsdeklaration') + }) + + it('lets an explicit scope option override the defaults', () => { + const url = new URL( + buildAuthorizeUrl('https://app.example/callback', 'state-123', { scope: 'agd' }), + ) + expect(url.searchParams.get('scope')).toBe('agd') + }) +}) diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index 96e879bb..3e162464 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -220,25 +220,75 @@ async function refreshTokenForUser( } /** - * MuleSoft APIGW contract enforcement, observed verbatim in production: + * MuleSoft APIGW scope 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. + * TWO different misconfigurations produce this one body, and they are fixed + * with different knobs: * - * 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). + * 1. Our APIGW client (SKATTEVERKET_APIGW_CLIENT_ID) has no subscription for + * the API being called (#973). Fixed in Utvecklarportalen. + * 2. The token is missing the scope that API requires, because the SKV + * application was never registered for it, or because we never asked for + * it. AGI needs `agd` for inlamning AND `agdredovisningperiod` for + * hanteraredovisningsperiod; a token holding only the first files and + * signs perfectly, then dies on the kvittens read. `ska` behaved the same + * way for skattekonto (#431). Fixed by registering/requesting the scope + * and reconnecting. + * + * The gateway will not tell us which, so neither can we: the message names + * both, and callers still classify it as ACCESS_DENIED. That verdict is about + * blast radius, not about cause. ACCESS_DENIED is deliberately NOT in + * RECONSENT_ERROR_CODES: guessing "reconnect" is what made a successful + * reconnect (runPostConnectRefresh -> syncSkattekonto -> 403) instantly + * re-flag the token row, so the banner perpetuated itself (#1155). Case 2 does + * need a reconnect, but only AFTER the scope exists, so an automatic reconsent + * loop would still be wrong. + * + * It also has to be ruled out before isTokenScopeRejection, which matches the + * substring "required scope". + * + * To tell the two apart, call the API with a deliberately invalid bearer and + * the same Client_Id/Client_Secret. Case 1 fails at the gateway with this same + * body; case 2 reaches the bearer check and answers 401 invalid/revoked token. + * Compare against an API the client is known to be subscribed to. */ function isApigwScopeContractError(body: string): boolean { return /required scopes?\s+are\s+not\s+authorized/i.test(body) } +/** + * The API segment of a SKV URL ("arbetsgivardeklaration/inlamning/v1"), for + * error messages that have to say WHICH service refused. Without it the user + * cannot tell which subscription or scope to go check, which is exactly the + * dead end the 403 message used to leave them in. + */ +function apiHintFromUrl(url: string): string { + try { + const parts = new URL(url).pathname.split('/').filter(Boolean) + return parts.length >= 1 ? parts.slice(0, 3).join('/') : url + } catch { + return url + } +} + +/** + * User-facing message for the ambiguous gateway/scope refusal. Single-sourced + * so the 401 and 403 paths cannot drift apart. + */ +function apigwOrScopeMessage(url: string): string { + return ( + `Skatteverket nekade anropet till tjänsten "${apiHintFromUrl(url)}". ` + + 'Två saker ger samma svar: APIGW-klienten (SKATTEVERKET_APIGW_CLIENT_ID) ' + + 'saknar prenumeration på tjänsten, eller så saknar anslutningen det scope ' + + 'tjänsten kräver. Kontrollera båda i Utvecklarportalen: prenumerationen på ' + + 'API:et, och att applikationens scope-lista täcker det. Om ett scope har ' + + 'lagts till behöver du koppla bort och ansluta igen via Inställningar → ' + + 'Skatteverket för att få en ny token.' + ) +} + /** * A genuine token-scope rejection: the stored access token predates a scope * the service now requires, and only a fresh consent can widen it. @@ -405,12 +455,7 @@ export async function skvRequestWithAuth( // 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' - ) + throw new SkatteverketAuthError(apigwOrScopeMessage(url), 'ACCESS_DENIED') } // OAuth's standard insufficient_scope marker. SKV sometimes emits this @@ -466,10 +511,13 @@ export async function skvRequestWithAuth( lower.includes('api key') || lower.includes('consumer') if (looksLikeApigwIssue) { + // Named the subscription outright: unlike the scope-contract body above, + // these shapes (client_id, consumer, subscription) point at the gateway + // client alone, so the message must not muddy it with the scope story. 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.', + `Skatteverkets API-gateway nekade anropet till "${apiHintFromUrl(url)}". ` + + 'Kontrollera att din APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har ' + + 'prenumeration på denna tjänst i Utvecklarportalen.', 'ACCESS_DENIED' ) } @@ -483,18 +531,7 @@ export async function skvRequestWithAuth( // "log in again" sends them down a dead end; be explicit about the // likely fix instead. if (!text) { - // Extract the API segment of the URL so the message tells the user - // exactly which subscription is missing. Falls back to the raw URL - // if parsing fails. - let apiHint = url - try { - const u = new URL(url) - const parts = u.pathname.split('/').filter(Boolean) - // Take the first 3 segments, e.g. arbetsgivardeklaration/inlamning/v1 - if (parts.length >= 1) apiHint = parts.slice(0, 3).join('/') - } catch { - // keep raw url - } + const apiHint = apiHintFromUrl(url) throw new SkatteverketAuthError( 'Skatteverkets API-gateway nekade anropet utan motivering. ' + 'Trolig orsak: APIGW-klienten (SKATTEVERKET_APIGW_CLIENT_ID) har ' + @@ -529,9 +566,10 @@ export async function skvRequestWithAuth( // 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.', + 'Skatteverket nekade systemanropet till tjänsten ' + + `"${apiHintFromUrl(url)}": antingen saknar APIGW-klienten ` + + '(SKATTEVERKET_APIGW_CLIENT_ID) prenumeration på tjänsten, eller så ' + + 'täcker inte SKATTEVERKET_SYSTEM_SCOPES det scope tjänsten kräver.', 'SYSTEM_AUTH_FAILED' ) } @@ -552,17 +590,13 @@ 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. + // Gateway scope enforcement, checked first: the body wears token-scope + // wording but names neither cause, so it must not reach + // isTokenScopeRejection below. ACCESS_DENIED is deliberately not in + // RECONSENT_ERROR_CODES (#1155): a reconnect is only the fix once the + // scope actually exists, so it can never be automatic. 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' - ) + throw new SkatteverketAuthError(apigwOrScopeMessage(url), '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 diff --git a/extensions/general/skatteverket/lib/oauth.ts b/extensions/general/skatteverket/lib/oauth.ts index dc53cebe..56c56a98 100644 --- a/extensions/general/skatteverket/lib/oauth.ts +++ b/extensions/general/skatteverket/lib/oauth.ts @@ -38,7 +38,25 @@ const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1 // - `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' +// +// 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 diff --git a/messages/en.json b/messages/en.json index 0e55f74c..1ba00de0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6516,6 +6516,9 @@ "expired_banner_description": "Sign in with BankID again to be able to submit AGI.", "missing_scope_title": "The Skatteverket connection lacks permission for Arbetsgivardeklaration", "missing_scope_description": "Your connection was issued before AGI support was enabled. Disconnect and reconnect via Settings → Tax to be able to submit AGI directly.", + "kvittens_scope_title": "The receipt needs a new permission", + "kvittens_scope_description": "Your connection can submit and sign AGI, but lacks the permission (agdredovisningperiod) required to fetch the receipt automatically. Disconnect and reconnect via Settings → Tax to add it. If the permission does not yet appear on Skatteverket's consent page, you can hide this notice and reconnect later; the submission itself is unaffected, and the receipt for a submitted period can always be verified in Skatteverket's Arbetsgivardeklaration e-service.", + "kvittens_scope_dismiss": "Hide notice", "open_settings": "Open settings", "file_generated": "AGI file generated {date}", "file_not_generated": "The AGI file has not been generated yet.", diff --git a/messages/sv.json b/messages/sv.json index af08db1a..6338d755 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6516,6 +6516,9 @@ "expired_banner_description": "Logga in med BankID igen för att kunna skicka AGI.", "missing_scope_title": "Anslutningen mot Skatteverket saknar behörighet för Arbetsgivardeklaration", "missing_scope_description": "Din anslutning utfärdades innan AGI-stödet aktiverades. Koppla bort och anslut igen via Inställningar → Skatt för att kunna skicka AGI direkt.", + "kvittens_scope_title": "Kvittensen behöver en ny behörighet", + "kvittens_scope_description": "Din anslutning kan lämna in och signera AGI, men saknar behörigheten (agdredovisningperiod) som krävs för att hämta kvittensen automatiskt. Koppla bort och anslut igen via Inställningar → Skatt för att lägga till den. Syns behörigheten inte på Skatteverkets samtyckessida ännu kan du dölja notisen och ansluta igen senare; själva inlämningen påverkas inte, och kvittensen för en inlämnad period kan alltid kontrolleras i Skatteverkets e-tjänst Arbetsgivardeklaration.", + "kvittens_scope_dismiss": "Dölj notisen", "open_settings": "Öppna inställningar", "file_generated": "AGI-fil genererad {date}", "file_not_generated": "AGI-fil har inte genererats ännu.",