From d7a7ddae2c6eea749a8ce730e82f1eb3515c1067 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 10 May 2026 14:40:16 +0200 Subject: [PATCH] Bug/skv connection (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(invoices): implement öresavrundning logic and next invoice number preview - Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings. - Updated `InvoicesPage` to utilize the new rounding logic when displaying totals. - Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence. - Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits. - Enhanced tests for invoice number generation and rounding functionality to ensure correctness. - Updated PDF template to reflect new rounding logic for totals and display appropriate values. - Adjusted company switcher to hide options in sandbox mode. - Improved error handling and logging in sandbox seeding process. * fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES * feat(skatteverket): add token revocation handling and disconnect functionality --- components/salary/AGIPanel.tsx | 48 +++++++++++++++++-- .../skatteverket/__tests__/api-client.test.ts | 17 ++++++- extensions/general/skatteverket/index.ts | 1 + .../general/skatteverket/lib/api-client.ts | 33 ++++++++++++- 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx index 454c1310..68e29914 100644 --- a/components/salary/AGIPanel.tsx +++ b/components/salary/AGIPanel.tsx @@ -10,6 +10,7 @@ import { Link2Off, Loader2, Lock, + PlugZap, Send, ShieldAlert, Unlock, @@ -249,6 +250,29 @@ export function AGIPanel(props: AGIPanelProps) { kvittensTimers.current.push(setTimeout(poll, 300_000)) }, [arbetsgivare, period, fetchSubmission, onChange]) + const handleDisconnect = useCallback(async () => { + setActionLoading('disconnect') + setError(null) + setSuccess(null) + try { + const res = await fetch('/api/extensions/ext/skatteverket/disconnect', { + method: 'POST', + }) + if (!res.ok) { + const json = await res.json().catch(() => ({})) + setError(json.error || `Kunde inte koppla bort (${res.status})`) + return + } + setSuccess('Anslutningen mot Skatteverket har kopplats bort.') + await fetchStatus() + await fetchSubmission() + } catch (e) { + setError(e instanceof Error ? e.message : 'Kunde inte koppla bort') + } finally { + setActionLoading(null) + } + }, [fetchStatus, fetchSubmission]) + const handleConnect = () => { // Open the BankID OAuth flow in a centered popup. The callback page // detects `window.opener` and posts back a `skatteverket-oauth-success` @@ -563,9 +587,27 @@ export function AGIPanel(props: AGIPanelProps) { Arbetsgivardeklaration (AGI) - - - Ansluten + + + + Ansluten + + {!readOnly && ( + + )} diff --git a/extensions/general/skatteverket/__tests__/api-client.test.ts b/extensions/general/skatteverket/__tests__/api-client.test.ts index 64926d16..10ab1820 100644 --- a/extensions/general/skatteverket/__tests__/api-client.test.ts +++ b/extensions/general/skatteverket/__tests__/api-client.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' // Mock the token-store to bypass DB and supply a fresh access token. +const deleteTokensMock = vi.fn() vi.mock('../lib/token-store', () => ({ getTokens: vi.fn(async () => ({ access_token: 'test-access', @@ -10,7 +11,7 @@ vi.mock('../lib/token-store', () => ({ scope: 'momsdeklaration', })), storeTokens: vi.fn(), - deleteTokens: vi.fn(), + deleteTokens: (...args: unknown[]) => deleteTokensMock(...args), })) // Mock oauth so a refresh attempt (shouldn't fire) is harmless. @@ -66,6 +67,20 @@ describe('skvRequest — error mapping', () => { } }) + it('maps 401 with "Token has been revoked." body → TOKEN_REVOKED and clears local row', async () => { + deleteTokensMock.mockClear() + mockFetchStatus(401, '{"error":"Token has been revoked."}') + try { + await skvRequest(fakeSupabase, 'user-1', 'GET', '/x') + expect.fail('expected throw') + } catch (e) { + expect(e).toBeInstanceOf(SkatteverketAuthError) + expect((e as SkatteverketAuthError).code).toBe('TOKEN_REVOKED') + expect((e as SkatteverketAuthError).message).toMatch(/återkallat/i) + expect(deleteTokensMock).toHaveBeenCalledWith(fakeSupabase, 'user-1') + } + }) + it('maps 401 with WWW-Authenticate insufficient_scope → MISSING_SCOPE', async () => { mockFetchStatus(401, '', { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="agd"', diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 5fb870b2..b8b79913 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -1584,6 +1584,7 @@ function handleSkvError(err: unknown): NextResponse { : err.code === 'SESSION_EXPIRED' || err.code === 'REFRESH_EXHAUSTED' ? 401 : err.code === 'MISSING_SCOPE' ? 401 : err.code === 'TOKEN_CORRUPTED' ? 401 + : err.code === 'TOKEN_REVOKED' ? 401 : 403 return NextResponse.json( diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index 334e8b7f..26a12b16 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -1,7 +1,7 @@ import crypto from 'crypto' import type { SupabaseClient } from '@supabase/supabase-js' import { refreshAccessToken } from './oauth' -import { getTokens, storeTokens } from './token-store' +import { getTokens, storeTokens, deleteTokens } from './token-store' import type { SkatteverketTokens } from '../types' /** @@ -244,6 +244,7 @@ export async function skvRequest( ? ` Headers: ${JSON.stringify(skvHeaders)}` : '' const bodySuffix = text ? ` Svar: ${text}` : '' + const lower = text.toLowerCase() // OAuth's standard insufficient_scope marker. SKV sometimes emits this // as 401 (rather than 403) when the AGI APIGW evaluates scope before @@ -263,10 +264,33 @@ export async function skvRequest( ) } + // SKV explicitly declares the token revoked. Body shape observed in + // production: { "error": "Token has been revoked." } with a generic + // `Bearer realm="OAuth2 Client Realm"` challenge header. This is a + // terminal state — the bearer will never come back to life, regardless + // of refresh attempts (refresh_token from the same family is also dead). + // Auto-clear the local row so /status stops claiming we're connected + // and the next interaction forces a clean reconnect. We swallow any + // delete error: even if cleanup fails we still want to surface the + // primary auth error to the user. + if (lower.includes('revoked') || lower.includes('token has been revoked')) { + try { + await deleteTokens(supabase, userId) + } catch (cleanupErr) { + console.error('[skatteverket] failed to clear revoked token row', cleanupErr) + } + throw new SkatteverketAuthError( + 'Skatteverket har återkallat anslutningen. Detta händer t.ex. om ' + + 'BankID-sessionen avslutats eller om en ny anslutning gjorts från ' + + 'en annan enhet. Anslut igen med BankID för att fortsätta.' + + headerSuffix + bodySuffix, + 'TOKEN_REVOKED' + ) + } + // 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. - const lower = text.toLowerCase() const looksLikeApigwIssue = lower.includes('client_id') || lower.includes('client id') || @@ -379,6 +403,10 @@ export async function skvRequest( * NOT_CONNECTED — no tokens stored; user needs to run BankID flow * SESSION_EXPIRED — 401 from SKV; refresh exhausted or token rejected * REFRESH_EXHAUSTED — refresh count hit cap (10) before user re-auth + * TOKEN_REVOKED — 401 with "Token has been revoked." body; SKV killed + * the bearer (BankID session ended, parallel connect + * from another device, or auth-code reuse). Local row + * is auto-cleared; user must reconnect with BankID. * BEHORIGHET_SAKNAS — 403 with "Behörighet" body; user not authorized * for this company at SKV (firmatecknare / ombud) * MISSING_SCOPE — 403 with "invalid_scope" body; the stored token @@ -396,6 +424,7 @@ export class SkatteverketAuthError extends Error { | 'NOT_CONNECTED' | 'SESSION_EXPIRED' | 'REFRESH_EXHAUSTED' + | 'TOKEN_REVOKED' | 'BEHORIGHET_SAKNAS' | 'MISSING_SCOPE' | 'ACCESS_DENIED'