From 8b09b06e14f07085a8590d05d46097a1dfce515f Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:42:23 +0200 Subject: [PATCH] feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync (#2130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync Users reported the Skatteverket connection "just disappearing" with no banner, needing BankID again every time. Two causes, both fixed here: 1. SKV's per-flow refresh token lives 65 minutes. /status and the skv_disconnected notice called any stored refresh token "refreshable", so a days-dead session reported healthy and the reconnect banner never fired until a submission failed live. lib/skatteverket/session-lifetime now decides refreshability (expires_at + 5 min, refresh cap) for both surfaces; the settings panel states the one-hour session lifetime. 2. The durable fix is the ombud (system certificate) path, dormant since July behind SKATTEVERKET_SYSTEM_AUTH_MODE. Skatteverket added scope `obr` (Ombudshantering v2) to our application id on 2026-09-01, so grant verification can now ask the ombudsregister instead of classifying 403s from the read services: - lib/ombud-client.ts: GET /ombud/autentisieratOmbud, GET /roller, POST .../djuplank/utseombud, on the system identity, per the public tjanstebeskrivning v2.0 (mirrored in dev_docs/skatteverket/ombudshantering). Role codes are env-pinned (SKATTEVERKET_OMBUD_ROLL_LASOMBUD/_MOMS) or matched on rollbeskrivning text; a deep link never mints with a guessed code. - grant-probe.ts: register first, read-service probes only as fallback. - New daily cron /api/extensions/skatteverket/ombud/sync/cron (30 3 * * *): one register call discovers every company that granted us, creates or downgrades connection rows by org number, runs from shadow mode on, and never mass-revokes on an empty register. - POST /system-connection/deeplink + "Utse {app} som ombud" button: the company lands in SKV's e-service with roles pre-selected. - Default system scopes include `obr`; skvRequestWithAuth gains an `accept` option (Ombudshantering requires the Accept header). Still inert in prod until the org certificate and avtal land; the cron and verify routes no-op while system auth is off or unconfigured. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu * fix(skatteverket): skeptic round on ombud sync, register 404 fallback, opt-in-only rows, mass-downgrade guard Cron touches only existing connection rows (a tenant's own Verifiera or deep-link opt-in; the deeplink route now records a pending row), so an org-number twin never gets auto-verified, and rows the tenant revoked locally stay revoked. A register 404 throws by default (spec: wrong URI) and is empty only for the cron, which guards it. Decisions are planned before any upsert; a run that would fully deny >= 3 rows and > 50% of the granted ones applies no downgrade. Grants that classify as neither behörighet are 'error', not 'denied'. Literal select in listConnections for the phantom-column scanner. window.open without 'noopener' so the pre-opened tab exists; opener nulled by hand. Deeplink route test added. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu * fix(skatteverket): CodeRabbit round: exact role labels, paginate connections, deny never-listed rows, fail deeplink without opt-in row Role descriptions match the whole label so 'Momsdeklaration, deklarationsombud' is never read as the narrow moms role. listConnections pages through fetchAllRows on (created_at, id). A pending row the register never lists is written once as denied instead of staying 'Inte verifierad'. The deeplink route returns 500 when the opt-in row cannot be stored, and the panel navigates in-tab when the pre-opened tab was blocked. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu * fix(skatteverket): fence ombud grants on contested org numbers; cron honours unrecognised role codes An org number claimed by more than one live company is contested: verify and deep link answer 409 ORG_NUMBER_CONTESTED and the nightly sync changes nothing on it, so a tenant that typed a victim's public org number cannot inherit the victim's grant. The sync also skips huvudmän whose register roles classify as neither behörighet (pinning problem, never a denial), mirroring probeViaOmbudsregister. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu * fix(skatteverket): validate the ombud deep link host; withdraw grants on contested org numbers The register's djuplank must be an https skatteverket.se URL before it is returned or navigated to (the settings page follows it). The nightly sync now withdraws a grant already recorded on an org number that more than one live company claims, instead of only refusing new ones. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 4 + .../ombud/sync/cron/__tests__/route.test.ts | 304 +++++++++++++ .../skatteverket/ombud/sync/cron/route.ts | 273 +++++++++++ .../settings/SkatteverketConnectPanel.tsx | 47 +- docker/crontab.hosted | 1 + docker/crontab.self-hosted | 1 + .../__tests__/deeplink-route.test.ts | 195 ++++++++ .../__tests__/grant-probe.test.ts | 182 +++++++- .../__tests__/ombud-client.test.ts | 263 +++++++++++ .../__tests__/status-route.test.ts | 103 +++++ .../__tests__/system-auth.test.ts | 2 +- extensions/general/skatteverket/index.ts | 143 +++++- .../general/skatteverket/lib/api-client.ts | 5 +- .../skatteverket/lib/connection-store.ts | 84 ++++ .../general/skatteverket/lib/grant-probe.ts | 121 ++++- .../general/skatteverket/lib/ombud-client.ts | 427 ++++++++++++++++++ .../skatteverket/lib/system-auth/config.ts | 8 +- lib/notices/__tests__/categories.test.ts | 18 +- lib/notices/categories.ts | 12 +- .../__tests__/session-lifetime.test.ts | 66 +++ lib/skatteverket/session-lifetime.ts | 69 +++ messages/en.json | 7 +- messages/sv.json | 7 +- vercel.json | 4 + 24 files changed, 2292 insertions(+), 54 deletions(-) create mode 100644 app/api/extensions/skatteverket/ombud/sync/cron/__tests__/route.test.ts create mode 100644 app/api/extensions/skatteverket/ombud/sync/cron/route.ts create mode 100644 extensions/general/skatteverket/__tests__/deeplink-route.test.ts create mode 100644 extensions/general/skatteverket/__tests__/ombud-client.test.ts create mode 100644 extensions/general/skatteverket/__tests__/status-route.test.ts create mode 100644 extensions/general/skatteverket/lib/ombud-client.ts create mode 100644 lib/skatteverket/__tests__/session-lifetime.test.ts create mode 100644 lib/skatteverket/session-lifetime.ts diff --git a/DECISIONS.md b/DECISIONS.md index 0cd07ab5..d68143df 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1457,6 +1457,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-01] F2 bank-data staleness: ship freshness reads only (last_synced_at/consent_expires/error_message on gnubok_connect_bank + new GET /api/v1/.../bank-connections, scope companies:read mirroring the MCP mapping): the daily cron already syncs server-side, so visibility is what the incident lacked; an agent-triggerable sync is a product bet (EB call cost, runaway agents) and was deferred by Emil. [2026-09-01] Verifikationsserie in the Ny verifikation modal is a closed dropdown instead of a one-letter free-text field: a typo there silently opens a brand-new series with its own number sequence, and the letters only mean anything if everyone uses the same ones. The letters are NOT prescribed by law (BFL 5 kap. 7 § requires only unbroken systematic numbering within each series), and the incumbents disagree: Björn Lundén uses A Huvudserie, F Kundfakturor, I Inbetalningar, L Leverantörsfakturor, N Löner, U Utbetalningar, J Bokslut. We ship FORTNOX's table verbatim (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport), from their own Systemdokumentation, because Fortnox is the system most companies migrate here from and an imported ledger should keep its meaning. REJECTED an earlier draft that labelled A as Kundfakturor: A is the general series manual entries land in (the one point Fortnox and BL agree on, and Fortnox allows manuell kontering ONLY in A), and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Calling it Kundfakturor would mislabel their entire history and the modal's own default. The list is closed but any letter the company already configured, or that a draft was saved with, is appended so no existing value can fall out of the picker. Also: tabbing or clicking into an untouched amount field now proposes the outstanding difference (pre-selected, so typing replaces it) when the row already has an account and the difference belongs on that side. This deliberately reverses part of the note in updateLine that said a balancing amount must never auto-fill: that note was about filling on ACCOUNT selection, which stole the amount before the user had a chance to split it. Filling on focus keeps the split case intact because the proposal is selected text, and it fixes the common moms case where the last line is just the remainder. [2026-09-01] Settings PUT cross-field VAT validations scoped to touched field groups (vat-completeness, 40m-monthly, periodisk sammanstallning), not fixed at onboarding: partial saves from surfaces without VAT fields (invoice bank-details dialog) were hard-blocked by pre-existing vat_registered-without-number state (Marketio Lab case). The invariant still holds on every save that touches its group; explicit null now counts as a clear instead of falling back to the stored value during validation. Onboarding-side VAT number collection left as follow-up. +[2026-09-01] SKV ombud grant verification asks the ombudsregister (Ombudshantering v2, scope obr) first and keeps the read-service 403 probes only as fallback: the register is the authoritative source (role + validity per huvudman) and the 403 heuristics were assumptions never validated against real bodies. Role codes are env-pinned, otherwise matched on rollbeskrivning text; a deep link never mints with a guessed code (OBR_ROLE_UNRESOLVED). The daily ombud sync cron records grant state from shadow mode on but changes no credential policy (resolveReadAuth owns that), and an empty register with local rows downgrades nothing (mass-revocation guard). Separately, SKV session refreshability is now computed from the 65-minute refresh-token life (lib/skatteverket/session-lifetime.ts): /status and the skv_disconnected notice used to call a days-dead refresh token refreshable, which is why users saw the connection vanish with no banner. +[2026-09-01] Skeptic BLOCK on PR #2130 (SKV ombud phase 0) fixed by: the ombud sync cron only touches EXISTING connection rows (a row = the tenant's own Verifiera or deep-link opt-in; the deeplink route now records a pending row) so an org-number twin never gets auto-verified; rows the tenant revoked locally are skipped (a standing grant at SKV is not permission to switch the row back on); a register 404 throws by default (spec: wrong URI) and is empty only for the cron, which guards it; a mass-downgrade guard (>=3 rows and >50% of granted rows losing everything) applies no downgrade that run; grants that classify as neither behörighet are 'error' not 'denied' (unpinned or renamed role codes must never downgrade a verified company); decisions are planned before any upsert so a failed write cannot leak into a downgrade. window.open('', '_blank', 'noopener') returns null, so the pre-opened tab is opened without the feature and opener is nulled by hand. [2026-09-01] #2125 mapping table: kept the 7 columns and shrank the fixed layout from 1216px to ~990px (Källkonto 144->80, Källnamn 256->160 with the existing truncate+tooltip, 13px text, px-3 cells, icon-only Bekräfta with the label in the header + InfoTooltip) instead of folding Källnamn under Källkonto: the reporters asked for narrower columns, closer column 2, an icon confirm and smaller text, and the #1684 sticky-scroll fallback still covers narrower panels (agent dock open). Also dropped the VAT cell's min-w-72, which overflowed 32px into Konfidens under table-fixed. [2026-09-01] #2127 skattekonto bulk: every unbooked, non-ignored skattekonto row in the inbox is selectable (isSkvSelectable); bulk Bokför keeps re-filtering through isSkvBulkEligible (button count, summary and submit read one skvBookableSelectedRows list) and bulk Ignorera spans bank + skattekonto selections in one confirmation, calling the per-row PATCH .../ignore 5-wide since no batch endpoint exists. Bullet 2 of the issue (unbooked skattekonto rows "not in att göra after migration") is scoped out: neither Hem's Att göra (lib/worklist book_transaction) nor the nav badge counts skattekonto_transactions, by construction of the canonical predicate, while the inbox itself lists them regardless of date. Whether skattekonto rows join the Att bokföra count is a founder call; reply asks the reporter where they looked. [2026-09-01] #2128 (row checkbox always visible) left open, no PR: #2093 (merged the same morning, after the Discord report) already made CHECKBOX_REVEAL_CLASS rest at opacity-50 with border-foreground and go solid on hover/focus/checked/coarse pointer, on all 8 list surfaces. The literal ask (fully solid at rest) is a one-token change to that constant but a design change across every list page, so it stays with the founder rather than being bumped in a bug-fix batch. @@ -1473,5 +1475,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-01] mcp.tool_called gets errorCause = errorCauseTag(err) on the two execution catch paths only (#2051): SQLSTATE or coded-error code, else the error class name, capped at 64 chars; a plain Error deliberately tags null because the class name 'Error' is noise, and pre-execution denials pass nothing since their errorCode already IS the vocabulary. Raw driver messages stay out of event_log on purpose: a constraint-violation message can quote row values. [2026-09-01] counterparty_aliases joins the categorization_templates audit-trigger strip list (20260901200000) instead of staying logged: prod falsified the original exclusion list within 30 minutes of 20260901103000 going live (15 of the first 16 UPDATE audit rows were alias+learning noise, ~800/day projected vs ~50/day of real rule changes), because the learning path merges aliases in the same write that bumps occurrence_count. Explicit trade-off: a human editing ONLY aliases is no longer logged; accepted since alias growth is overwhelmingly automatic and any change also touching accounts/VAT/pattern/active still logs (first real one, 19:02:17Z same day, captured correctly). Pre-fix noise rows stay in audit_log (append-only) and the read model stops labelling the column so they render as no-ops. [2026-09-01] MCP catalog budget attacked at the duplicated staged envelope rather than by demoting more reads: measuring the payload by segment showed outputSchema is 38 % of the whole catalog (23 290 tokens) and STAGED_OPERATION_SCHEMA alone 14 736 of it, the same envelope transmitted 58 times, while descriptions (what the three previous rounds trimmed) are only 10 %. period_status now carries its shape in one sentence instead of declared JSON Schema, matching actor/approve/preview which were always bare objects; 2 552 tokens reclaimed with no tool demoted and no field removed. Every edit is in the LOOSER direction because the server emits structuredContent for every tool and the documented failure mode is a declaration too tight making a strict client reject a successful call. next kept additionalProperties: false: staging.test.ts pins it closed and a guard whose reason is not in front of you is not one to loosen for 420 tokens. Ceiling ratcheted to 60 000 rather than the usual ~300 margin, leaving ~1 070 deliberate working margin: server.ts took 70 commits in 14 days and the previous 116-token margin is what starts the ratchet-block-bump-demote cycle visible in the bench log. +[2026-09-01] PR #2130 CodeRabbit P1 (org-number twin inherits a grant): the ombud path binds SKV system-credential access to an org number, and org numbers are public and tenant-editable, so while more than one live (non-archived) company claims the same 12-digit org number NO company may verify, mint a deep link, or be granted by the nightly sync on it (409 ORG_NUMBER_CONTESTED; cron counts them as contested and changes nothing). This does not re-add the company-creation org-number guard (org-number reuse stays allowed); it only fences the one feature where the org number is the authority boundary. Also: the cron honours summarizeGrants.recognized (unknown role codes = pinning problem, never a denial), mirroring probeViaOmbudsregister. [2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract. +[2026-09-01] PR #2130 security-scan round: the register's djuplank is validated (https + skatteverket.se host) before it is returned or navigated to, since the settings page follows it; a contested org number now WITHDRAWS an already-recorded grant nightly (not only blocks new ones), outside the downgrade guards on purpose. NOT done: proof of org-number ownership (Bolagsverket firmatecknare / BankID) before any ombud grant; the org number is tenant-editable across the product (AGI, invoices, årsredovisning) and binding it to a verified identity is a product decision for Emil, tracked as a follow-up rather than declined. diff --git a/app/api/extensions/skatteverket/ombud/sync/cron/__tests__/route.test.ts b/app/api/extensions/skatteverket/ombud/sync/cron/__tests__/route.test.ts new file mode 100644 index 00000000..573d88ef --- /dev/null +++ b/app/api/extensions/skatteverket/ombud/sync/cron/__tests__/route.test.ts @@ -0,0 +1,304 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() })) +const mockRegistryGet = vi.fn((_id: string): unknown => ({ id: 'skatteverket' })) +vi.mock('@/lib/extensions/registry', () => ({ + extensionRegistry: { get: (id: string) => mockRegistryGet(id) }, +})) + +const mockVerifyCronSecret = vi.fn() +vi.mock('@/lib/auth/cron', () => ({ + verifyCronSecret: (...a: unknown[]) => mockVerifyCronSecret(...a), +})) + +const mockMode = vi.fn() +const mockConfigured = vi.fn() +vi.mock('@/extensions/general/skatteverket/lib/system-auth/config', () => ({ + getSystemAuthMode: () => mockMode(), + isSystemAuthConfigured: () => mockConfigured(), +})) + +vi.mock('@/extensions/general/skatteverket/lib/resolve-auth', () => ({ + currentSkvEnvironment: () => 'test', +})) + +const mockListConnections = vi.fn() +const mockRecordProbeResult = vi.fn() +const mockContested = vi.fn(async (): Promise> => new Set()) +vi.mock('@/extensions/general/skatteverket/lib/connection-store', () => ({ + listConnections: (...a: unknown[]) => mockListConnections(...a), + recordProbeResult: (...a: unknown[]) => mockRecordProbeResult(...a), + findContestedOrgNumbers: () => mockContested(), +})) + +const mockListOmbudGrants = vi.fn() +vi.mock('@/extensions/general/skatteverket/lib/ombud-client', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + listOmbudGrants: (...a: unknown[]) => mockListOmbudGrants(...a), + } +}) + +import { GET } from '../route' + +const ENV_KEYS = ['SKATTEVERKET_ENABLED'] +let savedEnv: Record + +type Status = 'unknown' | 'granted' | 'denied' | 'error' +function connection( + companyId: string, + orgNumber: string, + lasombud: Status = 'granted', + moms: Status = 'granted', + status = 'verified' +) { + return { + id: `conn-${companyId}`, + company_id: companyId, + environment: 'test', + org_number: orgNumber, + status, + lasombud_status: lasombud, + moms_ombud_status: moms, + } +} + +const JLO = (huvudman: string) => ({ + huvudman, + roll: 'JLO', + rollbeskrivning: 'Juridiskt läsombud', + giltigFrom: '2026-07-19', +}) +const MOMS = (huvudman: string) => ({ + huvudman, + roll: 'MOMS', + rollbeskrivning: 'Momsdeklaration, ombud', + giltigFrom: '2026-07-19', +}) + +beforeEach(() => { + vi.clearAllMocks() + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) + process.env.SKATTEVERKET_ENABLED = 'true' + mockVerifyCronSecret.mockReturnValue(null) + mockMode.mockReturnValue('shadow') + mockConfigured.mockReturnValue(true) + mockListConnections.mockResolvedValue([]) + mockRecordProbeResult.mockResolvedValue({ id: 'conn' }) + mockContested.mockResolvedValue(new Set()) + mockListOmbudGrants.mockResolvedValue([]) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } +}) + +const request = () => new Request('http://localhost/api/extensions/skatteverket/ombud/sync/cron') +const recordedFor = (companyId: string) => + mockRecordProbeResult.mock.calls.map((c) => c[0]).filter((input) => input.companyId === companyId) + +describe('GET /api/extensions/skatteverket/ombud/sync/cron', () => { + it('401 without the cron secret', async () => { + mockVerifyCronSecret.mockReturnValue(new Response('nope', { status: 401 })) + const res = await GET(request()) + expect(res.status).toBe(401) + expect(mockListOmbudGrants).not.toHaveBeenCalled() + }) + + it('503 EXTENSION_DISABLED when the skatteverket extension is not in the registry', async () => { + mockRegistryGet.mockReturnValueOnce(undefined) + const res = await GET(request()) + expect(res.status).toBe(503) + expect(await res.json()).toMatchObject({ code: 'EXTENSION_DISABLED' }) + expect(mockListOmbudGrants).not.toHaveBeenCalled() + }) + + it('no-ops when the extension is disabled or system auth is off/unconfigured', async () => { + process.env.SKATTEVERKET_ENABLED = 'false' + expect(await (await GET(request())).json()).toMatchObject({ processed: 0 }) + + process.env.SKATTEVERKET_ENABLED = 'true' + mockMode.mockReturnValue('off') + expect(await (await GET(request())).json()).toMatchObject({ message: 'System auth not active' }) + + mockMode.mockReturnValue('on') + mockConfigured.mockReturnValue(false) + expect(await (await GET(request())).json()).toMatchObject({ message: 'System auth not active' }) + expect(mockListOmbudGrants).not.toHaveBeenCalled() + }) + + it('502 when the register cannot be read, without touching any row', async () => { + mockListOmbudGrants.mockRejectedValue(new Error('down')) + const res = await GET(request()) + expect(res.status).toBe(502) + expect(mockRecordProbeResult).not.toHaveBeenCalled() + }) + + it('asks the register with the cron-only empty-on-404 option', async () => { + await GET(request()) + expect(mockListOmbudGrants).toHaveBeenCalledWith({}, { emptyOn404: true }) + }) + + it('records grants only on rows that already exist (tenant opt-in); a listed huvudman without a row is ignored', async () => { + mockListConnections.mockResolvedValue([ + connection('c-pending', '165560000000', 'unknown', 'unknown', 'pending'), + connection('c-partial', '195001011234', 'denied', 'denied', 'pending'), + ]) + mockListOmbudGrants.mockResolvedValue([ + JLO('165560000000'), + MOMS('165560000000'), + { ...JLO('195001011234'), roll: 'DEKL', rollbeskrivning: 'Deklarationsombud' }, + MOMS('195001011234'), + JLO('165550000000'), // no row for this org number: the twin/unmatched case + ]) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ registryHuvudman: 3, rows: 2, granted: 2, denied: 0, revoked: 0, guardTripped: false }) + expect(mockRecordProbeResult).toHaveBeenCalledTimes(2) + expect(recordedFor('c-pending')[0]).toMatchObject({ + environment: 'test', + orgNumber: '165560000000', + lasombud: expect.objectContaining({ status: 'granted' }), + momsOmbud: expect.objectContaining({ status: 'granted' }), + }) + expect(recordedFor('c-partial')[0]).toMatchObject({ + lasombud: expect.objectContaining({ status: 'denied' }), + momsOmbud: expect.objectContaining({ status: 'granted' }), + }) + // Never creates a row for the unmatched huvudman. + expect(mockRecordProbeResult.mock.calls.some((c) => c[0].orgNumber === '165550000000')).toBe(false) + }) + + it('skips rows the tenant revoked locally even though the grant still stands at Skatteverket', async () => { + mockListConnections.mockResolvedValue([connection('c-off', '165560000000', 'unknown', 'unknown', 'revoked')]) + mockListOmbudGrants.mockResolvedValue([JLO('165560000000'), MOMS('165560000000')]) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ rows: 0 }) + expect(mockRecordProbeResult).not.toHaveBeenCalled() + }) + + it('leaves unchanged rows alone, records denial once for a never-listed pending row, downgrades one revoked-at-SKV row', async () => { + mockListConnections.mockResolvedValue([ + connection('c-keep', '165560000000'), + connection('c-keep2', '165570000000'), + connection('c-keep3', '165580000000'), + connection('c-gone', '165590000000'), + connection('c-never', '165500000000', 'denied', 'denied', 'pending'), + connection('c-fresh', '165510000000', 'unknown', 'unknown', 'pending'), + ]) + mockListOmbudGrants.mockResolvedValue([ + JLO('165560000000'), MOMS('165560000000'), + JLO('165570000000'), MOMS('165570000000'), + JLO('165580000000'), MOMS('165580000000'), + ]) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ unchanged: 4, revoked: 1, denied: 1, granted: 0, guardTripped: false }) + expect(mockRecordProbeResult).toHaveBeenCalledTimes(2) + expect(recordedFor('c-gone')[0]).toMatchObject({ + orgNumber: '165590000000', + lasombud: { status: 'denied', detail: expect.stringContaining('huvudman saknas') }, + momsOmbud: { status: 'denied' }, + }) + // The deep-link row nobody signed yet: written once as "Saknas", not left "Inte verifierad". + expect(recordedFor('c-fresh')[0]).toMatchObject({ lasombud: { status: 'denied' }, momsOmbud: { status: 'denied' } }) + expect(recordedFor('c-never')).toHaveLength(0) + }) + + it('a failed upsert is counted once and never turns into a downgrade', async () => { + mockListConnections.mockResolvedValue([connection('c-1', '165560000000', 'unknown', 'unknown', 'pending')]) + mockListOmbudGrants.mockResolvedValue([JLO('165560000000')]) + mockRecordProbeResult.mockResolvedValue(null) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ failed: 1, revoked: 0, granted: 0 }) + expect(mockRecordProbeResult).toHaveBeenCalledTimes(1) + }) + + it('empty-register guard: no grants while rows exist downgrades nothing', async () => { + mockListOmbudGrants.mockResolvedValue([]) + mockListConnections.mockResolvedValue([connection('c-1', '165560000000')]) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ registryHuvudman: 0, revoked: 0, skipped: 1, guardTripped: true, guardReason: 'empty_register' }) + expect(mockRecordProbeResult).not.toHaveBeenCalled() + }) + + it('mass-downgrade guard: a run that would deny most granted rows applies no downgrade, but still records upgrades', async () => { + // Four granted rows; the register (say, with a mistyped pinned code) lists + // only one of them, plus a pending row that did get its grant. + mockListConnections.mockResolvedValue([ + connection('c-1', '165510000000'), + connection('c-2', '165520000000'), + connection('c-3', '165530000000'), + connection('c-4', '165540000000'), + connection('c-new', '165550000000', 'unknown', 'unknown', 'pending'), + ]) + mockListOmbudGrants.mockResolvedValue([JLO('165510000000'), MOMS('165510000000'), JLO('165550000000'), MOMS('165550000000')]) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ guardTripped: true, guardReason: 'mass_downgrade', revoked: 0, skipped: 3, granted: 1, unchanged: 1 }) + expect(mockRecordProbeResult).toHaveBeenCalledTimes(1) + expect(recordedFor('c-new')[0]).toMatchObject({ lasombud: { status: 'granted' } }) + }) + + it('a contested org number (two live companies claim it) is never granted, and an existing grant on it is withdrawn', async () => { + mockListConnections.mockResolvedValue([ + connection('c-victim', '165560000000'), // verified before the twin appeared + connection('c-twin', '165560000000', 'unknown', 'unknown', 'pending'), + connection('c-clear', '165570000000', 'unknown', 'unknown', 'pending'), + ]) + mockContested.mockResolvedValue(new Set(['165560000000'])) + mockListOmbudGrants.mockResolvedValue([JLO('165560000000'), MOMS('165560000000'), JLO('165570000000')]) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ contested: 2, revoked: 1, granted: 1, guardTripped: false }) + expect(mockRecordProbeResult).toHaveBeenCalledTimes(2) + expect(recordedFor('c-clear')[0]).toMatchObject({ lasombud: { status: 'granted' } }) + expect(recordedFor('c-victim')[0]).toMatchObject({ + lasombud: { status: 'denied', detail: expect.stringContaining('fler än ett företag') }, + momsOmbud: { status: 'denied' }, + }) + expect(recordedFor('c-twin')).toHaveLength(0) + }) + + it('a huvudman listed with only unrecognised role codes is neither granted nor denied (pinning problem)', async () => { + mockListConnections.mockResolvedValue([connection('c-1', '165560000000')]) + mockListOmbudGrants.mockResolvedValue([ + { huvudman: '165560000000', roll: 'ZZ9', rollbeskrivning: 'Läsombud, juridisk person', giltigFrom: '2026-07-19' }, + ]) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ unrecognized: 1, revoked: 0, denied: 0, guardTripped: false }) + expect(mockRecordProbeResult).not.toHaveBeenCalled() + }) + + it('mass-downgrade guard needs at least three planned downgrades', async () => { + mockListConnections.mockResolvedValue([ + connection('c-1', '165510000000'), + connection('c-2', '165520000000'), + ]) + mockListOmbudGrants.mockResolvedValue([JLO('165510000000'), MOMS('165510000000')]) + + const body = await (await GET(request())).json() + + expect(body).toMatchObject({ guardTripped: false, revoked: 1 }) + }) +}) diff --git a/app/api/extensions/skatteverket/ombud/sync/cron/route.ts b/app/api/extensions/skatteverket/ombud/sync/cron/route.ts new file mode 100644 index 00000000..3fddce7c --- /dev/null +++ b/app/api/extensions/skatteverket/ombud/sync/cron/route.ts @@ -0,0 +1,273 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { verifyCronSecret } from '@/lib/auth/cron' +import { loadExtensions } from '@/lib/extensions/loader' +import { extensionRegistry } from '@/lib/extensions/registry' +import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config' +import { currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth' +import { + findContestedOrgNumbers, + listConnections, + recordProbeResult, + type GrantStatus, + type SkvCompanyConnection, +} from '@/extensions/general/skatteverket/lib/connection-store' +import { + isoDate, + listOmbudGrants, + summarizeGrants, + type HuvudmanGrantSummary, +} from '@/extensions/general/skatteverket/lib/ombud-client' + +ensureInitialized() + +export const maxDuration = 60 + +const TIME_BUDGET_MS = 50_000 + +/** + * Downgrade guard: a single run may not turn more than this share of the + * currently granted rows into fully denied ones. A mistyped pinned role code, + * a renamed rollbeskrivning, or a partial register response would otherwise + * deny every company in one night and silently push their background reads + * back onto 65-minute personal tokens. Below MIN_ROWS the share test is + * meaningless (one of two rows is 50%), so small fleets are guarded by the + * absolute floor instead. + */ +export const MASS_DOWNGRADE_MIN_ROWS = 3 +export const MASS_DOWNGRADE_MAX_SHARE = 0.5 + +/** + * GET /api/extensions/skatteverket/ombud/sync/cron + * + * Daily ombudsregister sync (cron 30 3 * * *, half an hour before the + * skattekonto sync so a grant signed yesterday is used this morning). + * + * Companies appoint Accounted as ombud in Skatteverket's e-service, and + * nothing calls us back. One call to Ombudshantering v2 + * (GET /ombud/autentisieratOmbud on the system identity) lists every huvudman + * that granted Accounted anything, and this route reconciles that list + * against the company's OWN connection row: + * + * - only rows that already exist are touched. A row exists because a + * member of that company pressed Verifiera or minted the deep link: the + * tenant's explicit opt-in. Rows are never created here, + * - an org number claimed by more than one live company is contested: + * no row on it is granted or changed (org-number reuse is allowed and + * org numbers are public, so a tenant that typed a victim's number must + * not inherit the victim's grant; see findContestedOrgNumbers), + * - rows marked 'revoked' by the tenant's own "Koppla från" stay revoked + * until a member re-verifies; a still-standing grant at Skatteverket is + * not permission to switch the row back on, + * - a listed huvudman: granted/denied per behörighet from its active roles, + * - an unlisted huvudman: both behörigheter denied (withdrawn or expired), + * subject to the downgrade guards below. + * + * Runs in shadow mode as well as on: it only records grant state, never + * changes which credentials a read uses (that policy lives in + * resolveReadAuth). Off mode, or an unconfigured system flow, is a no-op. + * + * Downgrade guards: (1) an empty register while rows exist downgrades + * nothing (a wrong base URL or a "wrong URI" 404 must not mass-revoke); + * (2) a run that would fully deny more than MASS_DOWNGRADE_MAX_SHARE of the + * currently granted rows (and at least MASS_DOWNGRADE_MIN_ROWS of them) + * applies no downgrades at all and reports guardTripped, so a classification + * or partial-response problem surfaces in the logs instead of in every + * company's settings. + */ +export async function GET(request: Request) { + const authError = verifyCronSecret(request) + if (authError) return authError + + // Physical routes under app/api/extensions// compile into every build, + // including core-with-zero-extensions: the registry is what switches the + // extension on, so a disabled extension must refuse visibly (503). + loadExtensions() + if (!extensionRegistry.get('skatteverket')) { + return NextResponse.json( + { error: 'Skatteverket extension is not enabled', code: 'EXTENSION_DISABLED' }, + { status: 503 } + ) + } + + if (process.env.SKATTEVERKET_ENABLED !== 'true') { + return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 }) + } + if (getSystemAuthMode() === 'off' || !isSystemAuthConfigured()) { + return NextResponse.json({ message: 'System auth not active', processed: 0 }) + } + + const startedAt = Date.now() + const environment = currentSkvEnvironment() + const today = isoDate(new Date()) + + let grants: Map + try { + grants = summarizeGrants(await listOmbudGrants({}, { emptyOn404: true }), today) + } catch (error) { + console.error('[ombud-sync-cron] ombudsregister lookup failed', { + message: error instanceof Error ? error.message : String(error), + }) + return NextResponse.json({ error: 'Ombudsregister lookup failed' }, { status: 502 }) + } + + const rows = (await listConnections(environment)).filter((row) => row.status !== 'revoked') + const contested = await findContestedOrgNumbers() + const decisions = planDecisions(rows, grants, today, contested) + + const grantedNow = rows.filter(isAnyGranted).length + const fullDowngrades = decisions.filter((d) => d.kind === 'downgrade').length + const emptyRegisterGuard = grants.size === 0 && rows.length > 0 + const massDowngradeGuard = + fullDowngrades >= MASS_DOWNGRADE_MIN_ROWS && fullDowngrades > grantedNow * MASS_DOWNGRADE_MAX_SHARE + const guardTripped = emptyRegisterGuard || massDowngradeGuard + if (guardTripped) { + console.warn('[ombud-sync-cron] downgrade guard tripped; no row is downgraded this run', { + environment, + registryHuvudman: grants.size, + rows: rows.length, + grantedNow, + plannedDowngrades: fullDowngrades, + reason: emptyRegisterGuard ? 'empty_register' : 'mass_downgrade', + }) + } + + const counts = { granted: 0, denied: 0, revoked: 0, unchanged: 0, contested: 0, unrecognized: 0, failed: 0, skipped: 0 } + for (const decision of decisions) { + if (Date.now() - startedAt > TIME_BUDGET_MS) { + counts.skipped += 1 + continue + } + if (decision.kind === 'downgrade' && guardTripped) { + counts.skipped += 1 + continue + } + if (decision.kind === 'contested') { + counts.contested += 1 + if (!decision.lasombud || !decision.momsOmbud) continue + const withdrawn = await recordProbeResult({ + companyId: decision.row.company_id, + environment, + orgNumber: decision.row.org_number, + lasombud: { status: decision.lasombud, detail: decision.detail }, + momsOmbud: { status: decision.momsOmbud, detail: decision.detail }, + error: null, + }) + if (withdrawn) counts.revoked += 1 + else counts.failed += 1 + continue + } + if (decision.kind === 'unrecognized') { + counts.unrecognized += 1 + continue + } + if (decision.kind === 'unchanged') { + counts.unchanged += 1 + continue + } + const recorded = await recordProbeResult({ + companyId: decision.row.company_id, + environment, + orgNumber: decision.row.org_number, + lasombud: { status: decision.lasombud, detail: decision.detail }, + momsOmbud: { status: decision.momsOmbud, detail: decision.detail }, + error: null, + }) + if (!recorded) { + counts.failed += 1 + continue + } + if (decision.kind === 'downgrade') counts.revoked += 1 + else if (decision.lasombud === 'granted' || decision.momsOmbud === 'granted') counts.granted += 1 + else counts.denied += 1 + } + + return NextResponse.json({ + environment, + registryHuvudman: grants.size, + rows: rows.length, + ...counts, + guardTripped, + guardReason: emptyRegisterGuard ? 'empty_register' : massDowngradeGuard ? 'mass_downgrade' : null, + durationMs: Date.now() - startedAt, + }) +} + +function isAnyGranted(row: SkvCompanyConnection): boolean { + return row.lasombud_status === 'granted' || row.moms_ombud_status === 'granted' +} + +type Decision = + | { kind: 'unchanged'; row: SkvCompanyConnection } + /** + * More than one live company claims this org number: nobody gets granted + * on it, and a row that already holds a grant has it withdrawn (the + * denied statuses are present exactly then). + */ + | { + kind: 'contested' + row: SkvCompanyConnection + lasombud?: GrantStatus + momsOmbud?: GrantStatus + detail?: string + } + /** The register lists roles we cannot name (codes unpinned/renamed): never a denial. */ + | { kind: 'unrecognized'; row: SkvCompanyConnection } + | { + /** 'record' updates from the register; 'downgrade' is a granted row that would lose every grant. */ + kind: 'record' | 'downgrade' + row: SkvCompanyConnection + lasombud: GrantStatus + momsOmbud: GrantStatus + detail: string + } + +/** + * What the register says about each opted-in row, without writing anything: + * the guards need the whole picture before the first upsert. + */ +export function planDecisions( + rows: SkvCompanyConnection[], + grants: Map, + today: string, + contested: Set = new Set() +): Decision[] { + const decisions: Decision[] = [] + for (const row of rows) { + if (contested.has(row.org_number)) { + // A contested number is frozen in both directions: never granted, and + // any grant already recorded on it is withdrawn until support settles + // which tenant the org number belongs to. Withdrawn outside the + // downgrade guards on purpose: this is the guard. + if (isAnyGranted(row)) { + const detail = `ombudsregister ${today}: organisationsnumret används av fler än ett företag` + decisions.push({ kind: 'contested', row, lasombud: 'denied', momsOmbud: 'denied', detail }) + } else { + decisions.push({ kind: 'contested', row }) + } + continue + } + const summary = grants.get(row.org_number) + if (summary && summary.roles.length > 0 && !summary.recognized) { + // Same rule as probeViaOmbudsregister: unknown role codes are a + // pinning problem on our side, not a company withdrawing anything. + decisions.push({ kind: 'unrecognized', row }) + continue + } + const lasombud: GrantStatus = summary?.lasombud ? 'granted' : 'denied' + const momsOmbud: GrantStatus = summary?.moms_ombud ? 'granted' : 'denied' + const detail = summary + ? `ombudsregister ${today}: roller ${summary.roles.join(', ') || 'inga'}` + : `ombudsregister ${today}: huvudman saknas` + // A row already showing exactly this state is left alone, so a never-listed + // company is written once (unknown -> denied, "Saknas" in settings) and + // then skipped every following night. + if (row.lasombud_status === lasombud && row.moms_ombud_status === momsOmbud) { + decisions.push({ kind: 'unchanged', row }) + continue + } + const losesEverything = isAnyGranted(row) && lasombud === 'denied' && momsOmbud === 'denied' + decisions.push({ kind: losesEverything ? 'downgrade' : 'record', row, lasombud, momsOmbud, detail }) + } + return decisions +} diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index 6203e25a..3d9eb3dc 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -395,7 +395,7 @@ function SkatteverketPersonalConnectionCard() { - + {expiresAtDate.toLocaleString('sv-SE')} {!status.expired && expiresInMinutes > 0 && ( @@ -460,6 +460,47 @@ function SkatteverketSystemConnectionCard() { const { toast } = useToast() const [state, setState] = useState(null) const [verifying, setVerifying] = useState(false) + const [linking, setLinking] = useState(false) + + /** + * Ombudshantering deep link: Skatteverket's e-service opens with the app + * pre-filled as ombud and both roles pre-selected, so the company only + * signs. The tab is opened synchronously on click (popup blockers) and + * pointed at the link once the server has minted it. + */ + async function openDeepLink() { + setLinking(true) + // No 'noopener' feature here: with it window.open returns null and the + // pre-opened tab (the popup-blocker mitigation) would never exist. The + // opener link is cut by hand instead. + const tab = window.open('', '_blank') + if (tab) tab.opener = null + try { + const res = await fetch('/api/extensions/ext/skatteverket/system-connection/deeplink', { + method: 'POST', + }) + const body = await res.json().catch(() => ({})) + const url = typeof body?.data?.djuplank === 'string' ? body.data.djuplank : null + if (!res.ok || !url) { + tab?.close() + toast({ + title: t('system_deeplink_failed'), + description: typeof body?.error === 'string' ? body.error : undefined, + variant: 'destructive', + }) + return + } + // A blocked pre-open means a second window.open after the await would + // be blocked too: navigate this tab instead so the link is never lost. + if (tab) tab.location.href = url + else window.location.assign(url) + } catch { + tab?.close() + toast({ title: t('system_deeplink_failed'), variant: 'destructive' }) + } finally { + setLinking(false) + } + } async function loadState() { try { @@ -547,6 +588,10 @@ function SkatteverketSystemConnectionCard() { )}
+ {state.grant_url && ( 502 mapping. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockRequireCapability = vi.fn() +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, requireCapability: (...a: unknown[]) => mockRequireCapability(...a) } +}) + +const mockCreateDeepLink = vi.fn() +vi.mock('../lib/ombud-client', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, createUtseOmbudDeepLink: (...a: unknown[]) => mockCreateDeepLink(...a) } +}) + +const mockRecordProbeResult = vi.fn() +const mockContested = vi.fn(async (_orgNumber: string) => false) +vi.mock('../lib/connection-store', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + recordProbeResult: (...a: unknown[]) => mockRecordProbeResult(...a), + isOrgNumberContested: (orgNumber: string) => mockContested(orgNumber), + } +}) + +const mockWriteAudit = vi.fn() +vi.mock('../lib/audit', () => ({ writeSkatteverketAudit: (...a: unknown[]) => mockWriteAudit(...a) })) + +import { skatteverketExtension } from '../index' +import { OmbudApiError } from '../lib/ombud-client' +import type { ExtensionContext } from '@/lib/extensions/types' + +const ENV_KEYS = ['SKATTEVERKET_SYSTEM_AUTH_MODE', 'SKATTEVERKET_SYSTEM_AUTH_MECHANISM'] +let savedEnv: Record + +function findRoute() { + const route = skatteverketExtension.apiRoutes?.find( + (r) => r.method === 'POST' && r.path === '/system-connection/deeplink' + ) + if (!route) throw new Error('deeplink route not registered') + return route +} + +/** Supabase stub: company_members role lookup, then company_settings org number. */ +function makeContext(opts: { role?: string; orgNumber?: string | null } = {}): ExtensionContext { + const role = opts.role ?? 'owner' + const orgNumber = opts.orgNumber === undefined ? '556000-0000' : opts.orgNumber + const from = vi.fn((table: string) => { + const chain: Record = {} + const self = () => chain + for (const m of ['select', 'eq', 'order', 'limit']) chain[m] = vi.fn(self) + if (table === 'company_members') { + chain.single = vi.fn(async () => ({ data: { role }, error: null })) + chain.maybeSingle = vi.fn(async () => ({ data: { role }, error: null })) + } else if (table === 'company_settings') { + chain.single = vi.fn(async () => ({ + data: orgNumber ? { org_number: orgNumber, entity_type: 'aktiebolag' } : { org_number: null, entity_type: 'aktiebolag' }, + error: null, + })) + chain.maybeSingle = chain.single + } else { + chain.single = vi.fn(async () => ({ data: null, error: null })) + chain.maybeSingle = chain.single + } + return chain + }) + return { + userId: 'user-1', + companyId: 'company-1', + extensionId: 'skatteverket', + requestId: 'req_test', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: { from } as any, + emit: vi.fn().mockResolvedValue(undefined), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() }, + settings: { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + clear: vi.fn().mockResolvedValue(undefined), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +const request = () => + new Request('http://localhost/api/extensions/ext/skatteverket/system-connection/deeplink', { method: 'POST' }) + +beforeEach(() => { + vi.clearAllMocks() + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) + process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'on' + process.env.SKATTEVERKET_SYSTEM_AUTH_MECHANISM = 'stub' + mockRequireCapability.mockResolvedValue(null) + mockRecordProbeResult.mockResolvedValue({ id: 'conn-1', status: 'pending' }) + mockContested.mockResolvedValue(false) + mockWriteAudit.mockResolvedValue(undefined) + mockCreateDeepLink.mockResolvedValue({ + djuplank: 'https://sso.skatteverket.se/ombud?x=1', + roller: { lasombud: 'JLO', moms_ombud: 'MOMS' }, + expiresOn: '2026-09-22', + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } +}) + +describe('POST /system-connection/deeplink', () => { + it('500 without an extension context', async () => { + const res = await findRoute().handler(request()) + expect(res.status).toBe(500) + }) + + it('refuses when the capability gate blocks', async () => { + const { NextResponse } = await import('next/server') + mockRequireCapability.mockResolvedValue(NextResponse.json({ error: 'capability_blocked' }, { status: 403 })) + const res = await findRoute().handler(request(), makeContext()) + expect(res.status).toBe(403) + expect(mockCreateDeepLink).not.toHaveBeenCalled() + }) + + it('refuses a viewer (AGI write role required)', async () => { + const res = await findRoute().handler(request(), makeContext({ role: 'viewer' })) + expect(res.status).toBe(403) + expect(mockCreateDeepLink).not.toHaveBeenCalled() + }) + + it('503 while system auth is off', async () => { + process.env.SKATTEVERKET_SYSTEM_AUTH_MODE = 'off' + const res = await findRoute().handler(request(), makeContext()) + expect(res.status).toBe(503) + }) + + it('400 when the company has no org number', async () => { + const res = await findRoute().handler(request(), makeContext({ orgNumber: null })) + expect(res.status).toBe(400) + expect(mockCreateDeepLink).not.toHaveBeenCalled() + }) + + it('mints the link for the company\'s own 12-digit org number, records the opt-in row, audits', async () => { + const res = await findRoute().handler(request(), makeContext()) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + data: { + djuplank: 'https://sso.skatteverket.se/ombud?x=1', + roller: { lasombud: 'JLO', moms_ombud: 'MOMS' }, + expires_on: '2026-09-22', + }, + }) + expect(mockCreateDeepLink).toHaveBeenCalledWith('165560000000', ['lasombud', 'moms_ombud']) + expect(mockRecordProbeResult).toHaveBeenCalledWith( + expect.objectContaining({ companyId: 'company-1', orgNumber: '165560000000', createdBy: 'user-1', error: null }) + ) + // No grant state is asserted by minting a link. + expect(mockRecordProbeResult.mock.calls[0][0]).not.toHaveProperty('lasombud') + expect(mockWriteAudit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ endpoint: 'system-connection/deeplink', agRegistreradId: '165560000000', outcome: 'ok' }) + ) + }) + + it('409 ORG_NUMBER_CONTESTED when another live company claims the same org number: no link, no row', async () => { + mockContested.mockResolvedValue(true) + const res = await findRoute().handler(request(), makeContext()) + expect(res.status).toBe(409) + expect(await res.json()).toMatchObject({ code: 'ORG_NUMBER_CONTESTED' }) + expect(mockContested).toHaveBeenCalledWith('165560000000') + expect(mockCreateDeepLink).not.toHaveBeenCalled() + expect(mockRecordProbeResult).not.toHaveBeenCalled() + }) + + it('500 when the opt-in row cannot be stored: the link is not handed out', async () => { + mockRecordProbeResult.mockResolvedValue(null) + const res = await findRoute().handler(request(), makeContext()) + expect(res.status).toBe(500) + expect(mockWriteAudit).not.toHaveBeenCalled() + }) + + it('502 with the error code when the register refuses or the role codes are unresolved', async () => { + mockCreateDeepLink.mockRejectedValue(new OmbudApiError('Rollkod saknas', 'OBR_ROLE_UNRESOLVED')) + const res = await findRoute().handler(request(), makeContext()) + expect(res.status).toBe(502) + expect(await res.json()).toMatchObject({ code: 'OBR_ROLE_UNRESOLVED' }) + expect(mockRecordProbeResult).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/skatteverket/__tests__/grant-probe.test.ts b/extensions/general/skatteverket/__tests__/grant-probe.test.ts index bc8b01ca..f02ef4aa 100644 --- a/extensions/general/skatteverket/__tests__/grant-probe.test.ts +++ b/extensions/general/skatteverket/__tests__/grant-probe.test.ts @@ -1,8 +1,9 @@ /** - * Grant-probe classification: 200 / felkod 3 / OMBUD_GRANT_MISSING / 404 / - * transient failures, and the transient-error-never-downgrades rule (which - * lives in connection-store's recordProbeResult and is asserted through the - * recorded input here + directly below). + * Grant verification: the ombudsregister (Ombudshantering v2) decides when it + * answers; the read-service probes (200 / felkod 3 / OMBUD_GRANT_MISSING / + * 404 / transient) decide only when the register cannot be consulted. The + * transient-error-never-downgrades rule lives in connection-store's + * recordProbeResult and is asserted through the recorded input here. */ import { describe, it, expect, vi, beforeEach } from 'vitest' @@ -24,70 +25,212 @@ vi.mock('../lib/connection-store', async (importOriginal) => { } }) -import { probeCompanyGrants } from '../lib/grant-probe' +import { probeCompanyGrants, probeViaOmbudsregister } from '../lib/grant-probe' import { SkatteverketAuthError } from '../lib/api-client' +const ORG = '165560000000' +const TODAY = '2026-09-01' + +/** The register call is always first; make it fail so the service probes decide. */ +function registryUnavailable() { + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 503, text: async () => 'down' }) +} + +function registryAnswers(posts: unknown[]) { + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => posts }) +} + beforeEach(() => { vi.clearAllMocks() mockRecordProbeResult.mockResolvedValue({ id: 'conn-1', status: 'verified' }) vi.spyOn(console, 'info').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) }) -describe('probeCompanyGrants', () => { +describe('probeCompanyGrants via the ombudsregister', () => { + it('both roles active today -> both granted, source registry, no service probes', async () => { + registryAnswers([ + { huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', ombud: '165590000000', giltigFrom: '2026-07-19' }, + { huvudman: ORG, roll: 'MOMS', rollbeskrivning: 'Momsdeklaration, ombud', ombud: '165590000000', giltigFrom: '2026-07-19', giltigTom: '' }, + ]) + + const result = await probeCompanyGrants('company-1', ORG) + + expect(result.source).toBe('registry') + expect(result.lasombud.status).toBe('granted') + expect(result.momsOmbud.status).toBe('granted') + expect(mockSkvRequestWithAuth).toHaveBeenCalledTimes(1) + // System identity, ombud base URL, Accept header, huvudman filter. + const [auth, method, path, , options] = mockSkvRequestWithAuth.mock.calls[0] + expect(auth).toEqual({ mode: 'system' }) + expect(method).toBe('GET') + expect(path).toBe(`/ombud/autentisieratOmbud?huvudman=${ORG}`) + expect(options).toMatchObject({ + baseUrl: 'https://api.test.skatteverket.se/behorighet/ombudshantering/v2', + accept: 'application/json', + }) + }) + + it('only läsombud granted -> moms denied with the roles listed in the detail', async () => { + registryAnswers([ + { huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-07-19' }, + { huvudman: ORG, roll: 'DEKL', rollbeskrivning: 'Deklarationsombud', giltigFrom: '2026-07-19' }, + ]) + + const result = await probeCompanyGrants('company-1', ORG) + + expect(result.lasombud.status).toBe('granted') + expect(result.momsOmbud.status).toBe('denied') + expect(result.momsOmbud.detail).toContain('JLO, DEKL') + expect(mockRecordProbeResult).toHaveBeenCalledWith( + expect.objectContaining({ + lasombud: expect.objectContaining({ status: 'granted' }), + momsOmbud: expect.objectContaining({ status: 'denied' }), + error: null, + }) + ) + }) + + it('a 200 with no posts for the huvudman -> both denied (the company never granted)', async () => { + registryAnswers([]) + + const result = await probeCompanyGrants('company-1', ORG) + + expect(result.source).toBe('registry') + expect(result.lasombud.status).toBe('denied') + expect(result.momsOmbud.status).toBe('denied') + }) + + it('a future-dated or expired grant does not count', async () => { + const future = await probeViaOmbudsregisterWith([ + { huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-12-01' }, + ]) + expect(future.result?.lasombud.status).toBe('denied') + + const expired = await probeViaOmbudsregisterWith([ + { huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2025-01-01', giltigTom: '2026-08-31' }, + ]) + expect(expired.result?.lasombud.status).toBe('denied') + + const active = await probeViaOmbudsregisterWith([ + { huvudman: ORG, roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2025-01-01', giltigTom: '2026-09-01' }, + ]) + expect(active.result?.lasombud.status).toBe('granted') + }) + + it('a register 404 is a transport fault: falls back to the service probes instead of denying', async () => { + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '{"message":"Not found"}' }) + mockSkvRequestWithAuth + .mockResolvedValueOnce({ ok: true, status: 200 }) + .mockResolvedValueOnce({ ok: true, status: 200 }) + + const result = await probeCompanyGrants('company-1', ORG) + + expect(result.source).toBe('service') + expect(result.lasombud.status).toBe('granted') + expect(result.lasombud.detail).toContain('OBR_HTTP_ERROR') + }) + + it('grants that classify as neither behörighet are an error (unrecognised role codes), never a denial', async () => { + registryAnswers([ + { huvudman: ORG, roll: 'ZZ1', rollbeskrivning: 'Läsombud, juridisk person', giltigFrom: '2026-07-19' }, + { huvudman: ORG, roll: 'ZZ2', rollbeskrivning: 'Ombud för momsdeklaration', giltigFrom: '2026-07-19' }, + ]) + + const result = await probeCompanyGrants('company-1', ORG) + + expect(result.source).toBe('registry') + expect(result.lasombud.status).toBe('error') + expect(result.momsOmbud.status).toBe('error') + expect(result.lasombud.detail).toContain('ZZ1, ZZ2') + expect(mockRecordProbeResult.mock.calls[0][0].error).toBeTruthy() + }) + + it('a register 403 (scope/avtal missing) is an error, never a company denial', async () => { + // api-client maps a system-mode 403 to OMBUD_GRANT_MISSING; on the register that is run-level. + mockSkvRequestWithAuth.mockRejectedValueOnce(new SkatteverketAuthError('nope', 'OMBUD_GRANT_MISSING')) + // Fallback service probes both succeed. + mockSkvRequestWithAuth + .mockResolvedValueOnce({ ok: true, status: 200 }) + .mockResolvedValueOnce({ ok: true, status: 200 }) + + const result = await probeCompanyGrants('company-1', ORG) + + expect(result.source).toBe('service') + expect(result.lasombud.status).toBe('granted') + expect(result.lasombud.detail).toContain('OBR_FORBIDDEN') + expect(mockSkvRequestWithAuth).toHaveBeenCalledTimes(3) + }) +}) + +async function probeViaOmbudsregisterWith(posts: unknown[]) { + registryAnswers(posts) + return probeViaOmbudsregister(ORG, TODAY) +} + +describe('probeCompanyGrants service-probe fallback', () => { it('both probes 200 -> both granted', async () => { + registryUnavailable() mockSkvRequestWithAuth .mockResolvedValueOnce({ ok: true, status: 200 }) // saldo .mockResolvedValueOnce({ ok: true, status: 200 }) // utkast - const result = await probeCompanyGrants('company-1', '165560000000') + const result = await probeCompanyGrants('company-1', ORG) + expect(result.source).toBe('service') expect(result.lasombud.status).toBe('granted') expect(result.momsOmbud.status).toBe('granted') - // Both calls ran on SYSTEM credentials. - expect(mockSkvRequestWithAuth.mock.calls[0][0]).toEqual({ mode: 'system' }) - expect(mockSkvRequestWithAuth.mock.calls[1][0]).toEqual({ mode: 'system' }) + // All calls ran on SYSTEM credentials. + for (const call of mockSkvRequestWithAuth.mock.calls) expect(call[0]).toEqual({ mode: 'system' }) + // The fallback reason travels in the detail for the settings panel/probe row. + expect(result.lasombud.detail).toContain('ombudsregister otillgängligt') }) it('records the actual 2xx status as detail, not a hardcoded 200', async () => { + registryUnavailable() mockSkvRequestWithAuth .mockResolvedValueOnce({ ok: true, status: 204 }) // saldo .mockResolvedValueOnce({ ok: true, status: 200 }) // utkast - const result = await probeCompanyGrants('company-1', '165560000000') + const result = await probeCompanyGrants('company-1', ORG) - expect(result.lasombud).toEqual({ status: 'granted', detail: '204' }) - expect(result.momsOmbud).toEqual({ status: 'granted', detail: '200' }) + expect(result.lasombud.status).toBe('granted') + expect(result.lasombud.detail.startsWith('204')).toBe(true) + expect(result.momsOmbud.detail.startsWith('200')).toBe(true) }) it('felkod 3 (no skattekonto) still proves the lasombud authorization', async () => { + registryUnavailable() mockSkvRequestWithAuth .mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({ felkod: 3 }) }) .mockResolvedValueOnce({ ok: false, status: 404 }) - const result = await probeCompanyGrants('company-1', '165560000000') + const result = await probeCompanyGrants('company-1', ORG) expect(result.lasombud.status).toBe('granted') // 404 on /utkast = no draft, but the gateway authorized us. expect(result.momsOmbud.status).toBe('granted') }) - it('OMBUD_GRANT_MISSING classifies as denied', async () => { + it('OMBUD_GRANT_MISSING on the read services classifies as denied', async () => { + registryUnavailable() mockSkvRequestWithAuth .mockRejectedValueOnce(new SkatteverketAuthError('saknas', 'OMBUD_GRANT_MISSING')) .mockRejectedValueOnce(new SkatteverketAuthError('saknas', 'OMBUD_GRANT_MISSING')) - const result = await probeCompanyGrants('company-1', '165560000000') + const result = await probeCompanyGrants('company-1', ORG) expect(result.lasombud.status).toBe('denied') expect(result.momsOmbud.status).toBe('denied') }) it('transient failures classify as error, never denied', async () => { + registryUnavailable() mockSkvRequestWithAuth .mockRejectedValueOnce(new SkatteverketAuthError('overload', 'RATE_LIMITED')) .mockRejectedValueOnce(new Error('fetch failed')) - const result = await probeCompanyGrants('company-1', '165560000000') + const result = await probeCompanyGrants('company-1', ORG) expect(result.lasombud.status).toBe('error') expect(result.momsOmbud.status).toBe('error') @@ -97,16 +240,17 @@ describe('probeCompanyGrants', () => { }) it('persists the probe outcome via recordProbeResult', async () => { + registryUnavailable() mockSkvRequestWithAuth .mockResolvedValueOnce({ ok: true, status: 200 }) .mockResolvedValueOnce({ ok: true, status: 200 }) - await probeCompanyGrants('company-1', '165560000000', 'user-1') + await probeCompanyGrants('company-1', ORG, 'user-1') expect(mockRecordProbeResult).toHaveBeenCalledWith( expect.objectContaining({ companyId: 'company-1', - orgNumber: '165560000000', + orgNumber: ORG, createdBy: 'user-1', lasombud: expect.objectContaining({ status: 'granted' }), momsOmbud: expect.objectContaining({ status: 'granted' }), diff --git a/extensions/general/skatteverket/__tests__/ombud-client.test.ts b/extensions/general/skatteverket/__tests__/ombud-client.test.ts new file mode 100644 index 00000000..cfdf4058 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/ombud-client.test.ts @@ -0,0 +1,263 @@ +/** + * Ombudshantering v2 client: base URL selection, role classification (pinned + * code vs. description text), list envelope tolerance, 404-as-empty on the + * list, the 403 remap, deep-link role resolution, and the pure grant helpers. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockSkvRequestWithAuth = vi.fn() +const mockEnvironment = vi.fn(() => 'test') +vi.mock('../lib/api-client', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + skvRequestWithAuth: (...a: unknown[]) => mockSkvRequestWithAuth(...a), + getSkatteverketEnvironment: () => mockEnvironment(), + } +}) + +import { + classifyOmbudRole, + createUtseOmbudDeepLink, + getOmbudApiBaseUrl, + getOmbudRoleDescriptions, + isAllowedDeepLinkUrl, + isGrantActive, + listOmbudGrants, + normalizeHuvudman, + OmbudApiError, + resolveOmbudRoleCodes, + summarizeGrants, +} from '../lib/ombud-client' +import { SkatteverketAuthError } from '../lib/api-client' + +const ENV_KEYS = ['SKATTEVERKET_OMBUD_API_BASE_URL', 'SKATTEVERKET_OMBUD_ROLL_LASOMBUD', 'SKATTEVERKET_OMBUD_ROLL_MOMS'] +let savedEnv: Record + +beforeEach(() => { + vi.clearAllMocks() + mockEnvironment.mockReturnValue('test') + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) + for (const k of ENV_KEYS) delete process.env[k] + vi.spyOn(console, 'info').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } +}) + +describe('getOmbudApiBaseUrl', () => { + it('follows the SKV environment and honours the override', () => { + expect(getOmbudApiBaseUrl()).toBe('https://api.test.skatteverket.se/behorighet/ombudshantering/v2') + mockEnvironment.mockReturnValue('prod') + expect(getOmbudApiBaseUrl()).toBe('https://api.skatteverket.se/behorighet/ombudshantering/v2') + process.env.SKATTEVERKET_OMBUD_API_BASE_URL = 'https://example.test/obr/' + expect(getOmbudApiBaseUrl()).toBe('https://example.test/obr') + }) +}) + +describe('classifyOmbudRole', () => { + it('matches by description text when no code is pinned', () => { + expect(classifyOmbudRole({ roll: 'X1', rollbeskrivning: 'Juridiskt läsombud' })).toBe('lasombud') + expect(classifyOmbudRole({ roll: 'X2', rollbeskrivning: 'Momsdeklaration, ombud' })).toBe('moms_ombud') + expect(classifyOmbudRole({ roll: 'X3', rollbeskrivning: 'Deklarationsombud' })).toBeNull() + expect(classifyOmbudRole({ roll: 'X4' })).toBeNull() + // Broader roles that merely contain the words are not the narrow ones. + expect(classifyOmbudRole({ roll: 'X5', rollbeskrivning: 'Momsdeklaration, deklarationsombud' })).toBeNull() + expect(classifyOmbudRole({ roll: 'X6', rollbeskrivning: 'Juridiskt läsombud och skattekonto' })).toBeNull() + expect(classifyOmbudRole({ roll: 'X7', rollbeskrivning: ' momsdeklaration,ombud ' .trim() })).toBe('moms_ombud') + }) + + it('a pinned code wins and disables the text fallback for that key', () => { + process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO' + expect(classifyOmbudRole({ roll: 'JLO', rollbeskrivning: 'whatever' })).toBe('lasombud') + // Same wording, different code: not the pinned role. + expect(classifyOmbudRole({ roll: 'OTHER', rollbeskrivning: 'Juridiskt läsombud' })).toBeNull() + // The un-pinned key still falls back to text. + expect(classifyOmbudRole({ roll: 'M', rollbeskrivning: 'Momsdeklaration, ombud' })).toBe('moms_ombud') + }) +}) + +describe('listOmbudGrants', () => { + it('accepts a bare array and an enveloped list', async () => { + const post = { huvudman: '165560000000', roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-01-01' } + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => [post] }) + expect(await listOmbudGrants()).toEqual([post]) + + mockSkvRequestWithAuth.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ behorighetsposter: [post] }), + }) + expect(await listOmbudGrants({ huvudman: '165560000000' })).toEqual([post]) + expect(mockSkvRequestWithAuth.mock.calls[1][2]).toBe('/ombud/autentisieratOmbud?huvudman=165560000000') + }) + + it('404 throws by default (wrong URI per the spec) and is empty only with emptyOn404', async () => { + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '{"message":"Not found"}' }) + await expect(listOmbudGrants({ huvudman: '165560000000' })).rejects.toMatchObject({ + code: 'OBR_HTTP_ERROR', + status: 404, + }) + + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 404, text: async () => '{"message":"Not found"}' }) + expect(await listOmbudGrants({}, { emptyOn404: true })).toEqual([]) + }) + + it('rejects an unparsable body and other HTTP errors as OmbudApiError', async () => { + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ nope: 1 }) }) + await expect(listOmbudGrants()).rejects.toMatchObject({ name: 'OmbudApiError', code: 'OBR_BAD_RESPONSE' }) + + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'boom' }) + await expect(listOmbudGrants()).rejects.toMatchObject({ code: 'OBR_HTTP_ERROR', status: 500 }) + }) + + it('remaps the system-mode 403 (OMBUD_GRANT_MISSING) to OBR_FORBIDDEN', async () => { + mockSkvRequestWithAuth.mockRejectedValueOnce(new SkatteverketAuthError('nope', 'OMBUD_GRANT_MISSING')) + await expect(listOmbudGrants()).rejects.toMatchObject({ code: 'OBR_FORBIDDEN' }) + // Other auth errors pass through untouched (run-level, classified upstream). + mockSkvRequestWithAuth.mockRejectedValueOnce(new SkatteverketAuthError('token', 'SYSTEM_AUTH_FAILED')) + await expect(listOmbudGrants()).rejects.toBeInstanceOf(SkatteverketAuthError) + }) +}) + +describe('roles and deep links', () => { + it('getOmbudRoleDescriptions parses the roller list', async () => { + mockSkvRequestWithAuth.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ roller: [{ roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud' }] }), + }) + expect(await getOmbudRoleDescriptions()).toEqual([{ roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud' }]) + expect(mockSkvRequestWithAuth.mock.calls[0][2]).toBe('/roller') + }) + + it('resolveOmbudRoleCodes uses pinned codes without a network call', async () => { + process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO' + process.env.SKATTEVERKET_OMBUD_ROLL_MOMS = 'MOMS' + expect(await resolveOmbudRoleCodes(['lasombud', 'moms_ombud'])).toEqual({ lasombud: 'JLO', moms_ombud: 'MOMS' }) + expect(mockSkvRequestWithAuth).not.toHaveBeenCalled() + }) + + it('resolveOmbudRoleCodes looks unpinned codes up in /roller and refuses to guess', async () => { + mockSkvRequestWithAuth.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { roll: 'A1', rollbeskrivning: 'Juridiskt läsombud' }, + { roll: 'B2', rollbeskrivning: 'Deklarationsombud' }, + ], + }) + await expect(resolveOmbudRoleCodes(['lasombud', 'moms_ombud'])).rejects.toMatchObject({ + code: 'OBR_ROLE_UNRESOLVED', + }) + + mockSkvRequestWithAuth.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { roll: 'A1', rollbeskrivning: 'Juridiskt läsombud' }, + { roll: 'C3', rollbeskrivning: 'Momsdeklaration, ombud' }, + ], + }) + expect(await resolveOmbudRoleCodes(['lasombud', 'moms_ombud'])).toEqual({ lasombud: 'A1', moms_ombud: 'C3' }) + }) + + it('createUtseOmbudDeepLink posts the resolved codes for the huvudman and returns the link', async () => { + process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO' + process.env.SKATTEVERKET_OMBUD_ROLL_MOMS = 'MOMS' + mockSkvRequestWithAuth.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ djuplank: 'https://sso.skatteverket.se/ombud?x=1' }), + }) + + const link = await createUtseOmbudDeepLink('165560000000', ['lasombud', 'moms_ombud'], undefined, new Date('2026-09-01T10:00:00Z')) + + expect(link).toEqual({ + djuplank: 'https://sso.skatteverket.se/ombud?x=1', + roller: { lasombud: 'JLO', moms_ombud: 'MOMS' }, + expiresOn: '2026-09-22', + }) + const [auth, method, path, body, options] = mockSkvRequestWithAuth.mock.calls[0] + expect(auth).toEqual({ mode: 'system' }) + expect(method).toBe('POST') + expect(path).toBe('/ombud/autentisieratOmbud/huvudman/165560000000/djuplank/utseombud') + expect(body).toEqual({ ombudsroller: ['JLO', 'MOMS'] }) + expect(options).toMatchObject({ accept: 'application/json' }) + }) + + it('createUtseOmbudDeepLink rejects a response without djuplank', async () => { + process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO' + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) }) + await expect(createUtseOmbudDeepLink('165560000000', ['lasombud'])).rejects.toBeInstanceOf(OmbudApiError) + }) + + it('createUtseOmbudDeepLink refuses a deep link that is not an https skatteverket.se URL (open redirect)', async () => { + process.env.SKATTEVERKET_OMBUD_ROLL_LASOMBUD = 'JLO' + for (const bad of [ + 'http://sso.skatteverket.se/ombud', + 'https://evil.example/skatteverket.se', + 'https://skatteverket.se.evil.example/', + 'javascript:alert(1)', + 'not a url', + ]) { + mockSkvRequestWithAuth.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ djuplank: bad }) }) + await expect(createUtseOmbudDeepLink('165560000000', ['lasombud'])).rejects.toMatchObject({ code: 'OBR_BAD_RESPONSE' }) + } + expect(isAllowedDeepLinkUrl('https://sso.skatteverket.se/ombud?x=1')).toBe(true) + expect(isAllowedDeepLinkUrl('https://skatteverket.se/ombud')).toBe(true) + expect(isAllowedDeepLinkUrl('https://www.test.skatteverket.se/x')).toBe(true) + }) +}) + +describe('pure helpers', () => { + it('isGrantActive honours giltigFrom/giltigTom as date-only strings', () => { + expect(isGrantActive({ giltigFrom: '2026-09-01' }, '2026-09-01')).toBe(true) + expect(isGrantActive({ giltigFrom: '2026-09-02' }, '2026-09-01')).toBe(false) + expect(isGrantActive({ giltigFrom: '2026-01-01', giltigTom: '2026-08-31' }, '2026-09-01')).toBe(false) + expect(isGrantActive({ giltigFrom: '2026-01-01', giltigTom: '2026-09-01' }, '2026-09-01')).toBe(true) + expect(isGrantActive({ giltigFrom: '2026-01-01', giltigTom: '' }, '2026-09-01')).toBe(true) + expect(isGrantActive({ giltigFrom: '2026-01-01', giltigTom: null }, '2026-09-01')).toBe(true) + }) + + it('normalizeHuvudman reduces 12 or 13-character identities to 12 digits', () => { + expect(normalizeHuvudman('165560000000')).toBe('165560000000') + expect(normalizeHuvudman('16556000-0000')).toBe('165560000000') + expect(normalizeHuvudman('5560000000')).toBeNull() + expect(normalizeHuvudman('someone@example.com')).toBeNull() + }) + + it('summarizeGrants collapses posts per huvudman with only active, known roles deciding', () => { + const summary = summarizeGrants( + [ + { huvudman: '165560000000', roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-01-01' }, + { huvudman: '16556000-0000', roll: 'MOMS', rollbeskrivning: 'Momsdeklaration, ombud', giltigFrom: '2026-12-01' }, + { huvudman: '165560000000', roll: 'DEKL', rollbeskrivning: 'Deklarationsombud', giltigFrom: '2026-01-01' }, + { huvudman: '195001011234', roll: 'MOMS', rollbeskrivning: 'Momsdeklaration, ombud', giltigFrom: '2026-01-01' }, + { huvudman: 'bad', roll: 'JLO', rollbeskrivning: 'Juridiskt läsombud', giltigFrom: '2026-01-01' }, + ], + '2026-09-01', + ) + expect(summary.get('165560000000')).toEqual({ + huvudman: '165560000000', + lasombud: true, + moms_ombud: false, + roles: ['JLO', 'MOMS', 'DEKL'], + recognized: true, + }) + expect(summary.get('195001011234')).toMatchObject({ lasombud: false, moms_ombud: true, recognized: true }) + expect(summary.size).toBe(2) + + // Only unknown roles: recognized stays false even though roles were listed. + const unknown = summarizeGrants( + [{ huvudman: '165560000000', roll: 'ZZ', rollbeskrivning: 'Något annat', giltigFrom: '2026-01-01' }], + '2026-09-01', + ) + expect(unknown.get('165560000000')).toMatchObject({ roles: ['ZZ'], recognized: false, lasombud: false }) + }) +}) diff --git a/extensions/general/skatteverket/__tests__/status-route.test.ts b/extensions/general/skatteverket/__tests__/status-route.test.ts new file mode 100644 index 00000000..1c0de777 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/status-route.test.ts @@ -0,0 +1,103 @@ +/** + * GET /status: canRefresh follows the 65-minute refresh-token life, not + * merely "a refresh token is stored". The silent-drop case: a token that + * expired hours ago with its (dead) refresh token still in the row. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockGetTokens = vi.fn() +const mockGetTokenHealth = vi.fn() +vi.mock('../lib/token-store', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getTokens: (...a: unknown[]) => mockGetTokens(...a), + getTokenHealth: (...a: unknown[]) => mockGetTokenHealth(...a), + } +}) + +import { skatteverketExtension } from '../index' +import type { ExtensionContext } from '@/lib/extensions/types' + +function findRoute() { + const route = skatteverketExtension.apiRoutes?.find((r) => r.method === 'GET' && r.path === '/status') + if (!route) throw new Error('status route not registered') + return route +} + +function makeContext(): ExtensionContext { + return { + userId: 'user-1', + companyId: 'company-1', + extensionId: 'skatteverket', + requestId: 'req_test', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: { from: vi.fn() } as any, + emit: vi.fn().mockResolvedValue(undefined), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() }, + settings: { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + clear: vi.fn().mockResolvedValue(undefined), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +const NOW = Date.parse('2026-09-01T12:00:00Z') +const request = () => new Request('http://localhost/api/extensions/ext/skatteverket/status') + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(NOW) + mockGetTokenHealth.mockResolvedValue({ status: 'active', last_error_code: null, last_error_at: null }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('GET /status canRefresh', () => { + it('not connected without a token row', async () => { + mockGetTokens.mockResolvedValue(null) + const res = await findRoute().handler(request(), makeContext()) + expect(await res.json()).toMatchObject({ connected: false }) + }) + + it('a token expired hours ago with a stored refresh token is NOT refreshable', async () => { + mockGetTokens.mockResolvedValue({ + access_token: 'a', + refresh_token: 'r', + expires_at: NOW - 3 * 60 * 60 * 1000, + refresh_count: 0, + scope: 'momsdeklaration ska agd', + }) + const body = await (await findRoute().handler(request(), makeContext())).json() + expect(body).toMatchObject({ connected: true, expired: true, canRefresh: false, needsReconsent: false }) + }) + + it('a token three minutes past expiry is still refreshable', async () => { + mockGetTokens.mockResolvedValue({ + access_token: 'a', + refresh_token: 'r', + expires_at: NOW - 3 * 60 * 1000, + refresh_count: 2, + scope: 'momsdeklaration', + }) + const body = await (await findRoute().handler(request(), makeContext())).json() + expect(body).toMatchObject({ connected: true, expired: true, canRefresh: true }) + }) + + it('a live token at the refresh cap is not refreshable', async () => { + mockGetTokens.mockResolvedValue({ + access_token: 'a', + refresh_token: 'r', + expires_at: NOW + 30 * 60 * 1000, + refresh_count: 10, + scope: 'momsdeklaration', + }) + const body = await (await findRoute().handler(request(), makeContext())).json() + expect(body).toMatchObject({ connected: true, expired: false, canRefresh: false }) + }) +}) diff --git a/extensions/general/skatteverket/__tests__/system-auth.test.ts b/extensions/general/skatteverket/__tests__/system-auth.test.ts index 49c3b0ec..e26ea19b 100644 --- a/extensions/general/skatteverket/__tests__/system-auth.test.ts +++ b/extensions/general/skatteverket/__tests__/system-auth.test.ts @@ -85,7 +85,7 @@ describe('system-auth config', () => { }) it('parses scopes from env with a sensible default', () => { - expect(getSystemScopes()).toEqual(['skattekonto', 'agd:lasa', 'momsdeklaration']) + expect(getSystemScopes()).toEqual(['skattekonto', 'agd:lasa', 'momsdeklaration', 'obr']) process.env.SKATTEVERKET_SYSTEM_SCOPES = 'a b' expect(getSystemScopes()).toEqual(['a', 'b']) }) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 7a1ac3e3..7c17454a 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -27,10 +27,17 @@ import { import { submitVatDeclarationChain } from './lib/vat-submit' import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline' import { getSystemAuthMode, isSystemAuthConfigured, getOmbudOrgNumber, getSystemCertInfo } from './lib/system-auth/config' -import { getConnection, markConnectionRevoked } from './lib/connection-store' +import { + getConnection, + isOrgNumberContested, + markConnectionRevoked, + recordProbeResult, +} from './lib/connection-store' import { currentSkvEnvironment, resolveReadAuth } from './lib/resolve-auth' import { probeCompanyGrants } from './lib/grant-probe' import { formatRedovisare } from '@/lib/skatteverket/format' +import { isSkvSessionRefreshable } from '@/lib/skatteverket/session-lifetime' +import { createUtseOmbudDeepLink, OMBUD_ROLE_KEYS, OmbudApiError } from './lib/ombud-client' import { createExtensionContext } from '@/lib/extensions/context-factory' import type { SkvSubmitResult, @@ -154,10 +161,18 @@ const SkattekontoBokforBatchSchema = z.object({ * 4. Set SKATTEVERKET_SYSTEM_OAUTH_TOKEN_URL, _SCOPES, _CLIENT_ID, * _AUTH_MECHANISM per the docs; SKATTEVERKET_OMBUD_ORG_NUMBER = * Accounted's org number (shown to users in the grant instructions). - * 5. Validate grant-probe.ts classification against real sandbox 403 - * bodies (shadow mode in the test environment first). + * 5. First live call against Ombudshantering v2 in the test service + * (scope `obr` was added to application id arcimtechnologyab_gnubok_1 + * on 2026-09-01): confirm the list envelope, read the rollbeteckning + * codes from GET /roller and pin them in SKATTEVERKET_OMBUD_ROLL_LASOMBUD + * / SKATTEVERKET_OMBUD_ROLL_MOMS (lib/ombud-client.ts). Grant + * verification then runs on the register; the read-service probes in + * grant-probe.ts stay as the fallback and still need their 403 bodies + * validated (shadow mode in the test environment first). * 6. Godkännandetest per API, then SKATTEVERKET_SYSTEM_AUTH_MODE=on in - * prod. User tokens remain the fallback indefinitely. + * prod. User tokens remain the fallback indefinitely. The daily ombud + * sync cron (/api/extensions/skatteverket/ombud/sync/cron) runs from + * shadow mode on: it only records grant state. * * The /status endpoint reports which environment is active so the UI can * surface a Testmiljö / Produktion badge. @@ -177,6 +192,26 @@ async function requireSkvCapability(ctx: ExtensionContext): Promise { + if (!(await isOrgNumberContested(orgNumber))) return null + return NextResponse.json( + { + error: + 'Organisationsnumret används av fler än ett företag i Accounted. ' + + 'Kontakta support för att aktivera ombudsanslutningen.', + code: 'ORG_NUMBER_CONTESTED', + }, + { status: 409 } + ) +} + /** * Resolve auth for a company-scoped READ triggered from the UI: the caller's * own token if they connected, otherwise any active token another member of @@ -667,7 +702,15 @@ export const skatteverketExtension: Extension = { } const expired = tokens.expires_at < Date.now() - const canRefresh = tokens.refresh_token !== null && tokens.refresh_count < 10 + // Honest refreshability: SKV's per-flow refresh token dies 65 minutes + // after issue, five minutes past access-token expiry. A stored + // refresh token past that window is not "can refresh"; reporting it + // as such kept the reconnect banner silent for days. + const canRefresh = isSkvSessionRefreshable({ + expiresAt: tokens.expires_at, + hasRefreshToken: tokens.refresh_token !== null, + refreshCount: tokens.refresh_count, + }) // Persisted health, written by the crons when they hit a terminal // auth state. Lets the settings panel prompt for re-consent @@ -793,6 +836,9 @@ export const skatteverketExtension: Extension = { settings.entity_type as 'enskild_firma' | 'aktiebolag' ) + const contestedForVerify = await contestedOrgNumberResponse(orgNumber) + if (contestedForVerify) return contestedForVerify + try { const result = await probeCompanyGrants(ctx.companyId, orgNumber, ctx.userId) await writeSkatteverketAudit(ctx, { @@ -815,6 +861,93 @@ export const skatteverketExtension: Extension = { }, }, + // ── System connection: deep link to appoint Accounted as ombud ── + // Ombudshantering v2 mints a link into Skatteverket's e-service with + // Accounted pre-filled as ombud and both roles pre-selected; the company + // only signs with BankID there. Runs on the system identity (the link's + // ombud is whoever holds the token), so it needs the same configuration + // as verify. + { + method: 'POST', + path: '/system-connection/deeplink', + handler: async (_request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + const blocked = await requireSkvCapability(ctx) + if (blocked) return blocked + const roleBlocked = await requireAgiWriteRole(ctx) + if (roleBlocked) return roleBlocked + + if (getSystemAuthMode() === 'off' || !isSystemAuthConfigured()) { + return NextResponse.json( + { error: 'Systemanslutningen är inte aktiverad i denna miljö.' }, + { status: 503 } + ) + } + + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('org_number, entity_type') + .eq('company_id', ctx.companyId) + .single() + if (!settings?.org_number) { + return NextResponse.json( + { error: 'Organisationsnummer saknas. Ange det under Inställningar först.' }, + { status: 400 } + ) + } + const orgNumber = formatRedovisare( + settings.org_number as string, + settings.entity_type as 'enskild_firma' | 'aktiebolag' + ) + + const contestedForLink = await contestedOrgNumberResponse(orgNumber) + if (contestedForLink) return contestedForLink + + try { + const link = await createUtseOmbudDeepLink(orgNumber, OMBUD_ROLE_KEYS) + // Minting the link is the tenant's opt-in: record a pending row + // (no grant state yet) so the nightly ombud sync, which only ever + // touches existing rows, picks the signed grant up on its own. + const optIn = await recordProbeResult({ + companyId: ctx.companyId, + environment: currentSkvEnvironment(), + orgNumber, + createdBy: ctx.userId, + error: null, + }) + if (!optIn) { + // Handing out the link without the row would let the company + // sign at Skatteverket and never be picked up by the sync. + log.error('deep link minted but opt-in row could not be stored', { companyId: ctx.companyId }) + return NextResponse.json( + { error: 'Anslutningen kunde inte sparas. Försök igen.' }, + { status: 500 } + ) + } + await writeSkatteverketAudit(ctx, { + endpoint: 'system-connection/deeplink', + agRegistreradId: orgNumber, + outcome: 'ok', + }) + return NextResponse.json({ + data: { + djuplank: link.djuplank, + roller: link.roller, + expires_on: link.expiresOn, + }, + }) + } catch (err) { + if (err instanceof OmbudApiError) { + log.warn('ombud deep link failed', { companyId: ctx.companyId, code: err.code, message: err.message }) + return NextResponse.json({ error: err.message, code: err.code }, { status: 502 }) + } + return handleSkvError(err) + } + }, + }, + // ── System connection: revoke locally ─────────────────────────── { method: 'DELETE', diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index 2e5baa1c..3650f1d3 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -384,7 +384,7 @@ export async function skvRequestWithAuth( method: string, path: string, body?: unknown, - options?: { baseUrl?: string; contentType?: string } + options?: { baseUrl?: string; contentType?: string; accept?: string } ): Promise { if (isDisabled()) { throw new SkatteverketAuthError( @@ -434,6 +434,9 @@ export async function skvRequestWithAuth( headers['Client_Secret'] = getApiGwClientSecret() headers['skv_client_correlation_id'] = crypto.randomUUID() } + // Ombudshantering lists Accept as a required header (406 otherwise); the + // moms/skattekonto/AGI services never needed it, so it stays opt-in. + if (options?.accept) headers['Accept'] = options.accept // contentType defaults to application/json, which is right for moms + // skattekonto. AGI's POST /underlag takes application/xml: callers pass diff --git a/extensions/general/skatteverket/lib/connection-store.ts b/extensions/general/skatteverket/lib/connection-store.ts index ada34249..02591e4d 100644 --- a/extensions/general/skatteverket/lib/connection-store.ts +++ b/extensions/general/skatteverket/lib/connection-store.ts @@ -1,5 +1,7 @@ import { type SupabaseClient } from '@supabase/supabase-js' import { createServiceRoleClient } from '@/lib/supabase/service-client' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { toRedovisare12 } from '@/lib/invariants/org-number' import { createLogger } from '@/lib/logger' const log = createLogger('skatteverket-connection-store') @@ -230,6 +232,88 @@ export async function listVerifiedCompanies( return (data ?? []) as Array<{ company_id: string; org_number: string; created_by: string | null }> } +/** + * Every connection row for an environment, for the ombudsregister sync. The + * caller decides what a row's status means ('revoked' rows are the tenant's + * own disconnect and are skipped there). Paginated through fetchAllRows on a + * stable (created_at, id) order so nothing past PostgREST's 1000-row page is + * silently left stale. The select is spelled out rather than reusing + * CONNECTION_COLUMNS so the phantom-column scanner can check it. + */ +export async function listConnections(environment: SkvEnvironment): Promise { + try { + const client = getServiceClient() + return await fetchAllRows(({ from, to }) => + client + .from('skatteverket_company_connections') + .select('id, company_id, environment, org_number, status, lasombud_status, lasombud_checked_at, moms_ombud_status, moms_ombud_checked_at, verified_at, last_probe_at, last_probe_detail, last_error') + .eq('environment', environment) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) + ) + } catch (error) { + log.warn('listConnections failed', { + environment, + error: error instanceof Error ? error.message : String(error), + }) + return [] + } +} + +/** + * Org numbers (12-digit redovisare form) claimed by MORE than one live + * (non-archived) company. Org-number reuse is allowed in the product and + * tenant isolation is the boundary, but the ombud path binds Skatteverket's + * system-credential access to an org number: while two tenants claim the + * same one, neither may be marked granted, or a tenant that typed a victim's + * public org number would inherit the victim's grant. Verify, deep link and + * the nightly sync all consult this. + */ +export async function findContestedOrgNumbers(): Promise> { + const client = getServiceClient() + type SettingsRow = { company_id: string; org_number: string | null; entity_type: string | null } + type ArchivedRow = { id: string } + const [settings, archived] = await Promise.all([ + fetchAllRows(({ from, to }) => + client + .from('company_settings') + .select('company_id, org_number, entity_type') + .not('org_number', 'is', null) + .order('company_id', { ascending: true }) + .range(from, to) + ), + fetchAllRows(({ from, to }) => + client + .from('companies') + .select('id') + .not('archived_at', 'is', null) + .order('id', { ascending: true }) + .range(from, to) + ), + ]) + const archivedIds = new Set(archived.map((row) => row.id)) + const claimants = new Map() + for (const row of settings) { + if (!row.org_number || archivedIds.has(row.company_id)) continue + let redovisare: string + try { + redovisare = toRedovisare12(row.org_number, row.entity_type === 'enskild_firma' ? 'enskild_firma' : 'aktiebolag') + } catch { + continue + } + claimants.set(redovisare, (claimants.get(redovisare) ?? 0) + 1) + } + const contested = new Set() + for (const [orgNumber, count] of claimants) if (count > 1) contested.add(orgNumber) + return contested +} + +/** True when more than one live company claims this 12-digit org number. */ +export async function isOrgNumberContested(orgNumber: string): Promise { + return (await findContestedOrgNumbers()).has(orgNumber) +} + /** Test hook: reset the memoized service client. */ export function __resetConnectionStoreForTests(): void { _serviceClient = null diff --git a/extensions/general/skatteverket/lib/grant-probe.ts b/extensions/general/skatteverket/lib/grant-probe.ts index 964a9c70..07d19ce0 100644 --- a/extensions/general/skatteverket/lib/grant-probe.ts +++ b/extensions/general/skatteverket/lib/grant-probe.ts @@ -2,31 +2,38 @@ import { skvRequestWithAuth, SkatteverketAuthError } from './api-client' import { getSkattekontoBaseUrl } from './skattekonto-client' import { recordProbeResult, type GrantStatus, type SkvCompanyConnection } from './connection-store' import { currentSkvEnvironment } from './resolve-auth' +import { isoDate, listOmbudGrants, OmbudApiError, summarizeGrants } from './ombud-client' import { createLogger } from '@/lib/logger' const log = createLogger('skatteverket-grant-probe') /** - * Behorighet verification probes. + * Behorighet verification. * * After the user grants Accounted's org number a behorighet in Skatteverket's * Ombud och behorigheter e-service, nothing tells us: there is no callback. - * The probe makes one cheap read per behorighet with SYSTEM credentials and - * classifies the outcome: + * Two ways to find out, tried in order: * - * lasombud : GET skattekonto saldo for the company's org number. - * 200 -> granted; felkod 3 (no skattekonto registered) also - * proves authorization passed -> granted-with-note; - * OMBUD_GRANT_MISSING (403) -> denied; anything transient - * (5xx, timeout, rate limit) -> error, which never downgrades - * a previously granted state (connection-store rule). - * moms_ombud : GET moms /utkast for the current period. 200 or 404 (no - * draft exists, but the gateway authorized us) -> granted; - * OMBUD_GRANT_MISSING -> denied. + * 1. The ombudsregister itself (Ombudshantering via API v2, scope `obr`): + * GET /ombud/autentisieratOmbud?huvudman={orgNumber} on SYSTEM + * credentials lists exactly which roles this company gave Accounted and + * for how long. Authoritative: a role present and active today is + * granted, a 200 without it is denied. Only a failure to ASK the + * register (scope missing, 5xx, timeout, unparsable body) is an + * 'error', and then the service probes below decide instead. * - * The classification heuristics live here, in one file, because they are - * assumptions until validated against real sandbox 403 bodies (Phase 2 of - * the rollout): expected churn stays contained. + * 2. Service probes (the pre-`obr` heuristic), one cheap read per + * behorighet on SYSTEM credentials: + * lasombud : GET skattekonto saldo. 200 -> granted; felkod 3 (no + * skattekonto registered) also proves authorization -> + * granted-with-note; OMBUD_GRANT_MISSING (403) -> denied; + * transient (5xx, timeout, rate limit) -> error. + * moms_ombud : GET moms /utkast for the current period. 200 or 404 (no + * draft, but the gateway authorized us) -> granted; + * OMBUD_GRANT_MISSING -> denied. + * + * 'error' never downgrades a previously granted state (connection-store + * rule); only an explicit 'denied' does. */ export interface ProbeClassification { @@ -96,14 +103,72 @@ async function probeMomsOmbud(orgNumber: string): Promise { } } -export interface GrantProbeResult { - connection: SkvCompanyConnection | null +export interface RegistryProbe { lasombud: ProbeClassification momsOmbud: ProbeClassification } /** - * Run both behorighet probes for a company and persist the outcome. + * Ask the ombudsregister about one huvudman. Returns null when the register + * could not be consulted (so the caller falls back to the service probes); + * the reason is logged, and surfaces in the probe detail of the fallback. + */ +export async function probeViaOmbudsregister( + orgNumber: string, + today: string = isoDate(new Date()) +): Promise<{ result: RegistryProbe; roles: string[] } | { result: null; reason: string }> { + try { + const posts = await listOmbudGrants({ huvudman: orgNumber }) + const summary = summarizeGrants(posts, today).get(orgNumber) + const roles = summary?.roles ?? [] + // The company granted Accounted something, but none of it classifies as + // either behörighet: far more likely an unrecognised rollbeteckning (codes + // not pinned yet, or a renamed rollbeskrivning) than a company that chose + // only unrelated roles. 'error' never downgrades a granted row; 'denied' + // would. Pin the codes and the ambiguity disappears. + if (roles.length > 0 && !summary?.recognized) { + const detail = `ombudsregister: roller okända (${roles.join(', ')}); pinna rollkoderna` + return { + result: { + lasombud: { status: 'error', detail }, + momsOmbud: { status: 'error', detail }, + }, + roles, + } + } + const describe = (granted: boolean, key: 'lasombud' | 'moms_ombud'): ProbeClassification => + granted + ? { status: 'granted', detail: `ombudsregister: ${key} aktiv ${today}` } + : { status: 'denied', detail: `ombudsregister: ${key} saknas (roller: ${roles.join(', ') || 'inga'})` } + return { + result: { + lasombud: describe(summary?.lasombud ?? false, 'lasombud'), + momsOmbud: describe(summary?.moms_ombud ?? false, 'moms_ombud'), + }, + roles, + } + } catch (err) { + const reason = + err instanceof OmbudApiError || err instanceof SkatteverketAuthError + ? `${err.code}: ${err.message}` + : err instanceof Error + ? err.message + : String(err) + log.warn('ombudsregister lookup unavailable, falling back to service probes', { orgNumber, reason }) + return { result: null, reason } + } +} + +export interface GrantProbeResult { + connection: SkvCompanyConnection | null + lasombud: ProbeClassification + momsOmbud: ProbeClassification + /** 'registry' when the ombudsregister answered, 'service' when the read probes decided. */ + source: 'registry' | 'service' +} + +/** + * Verify both behorigheter for a company and persist the outcome. * The caller has already verified role + capability and resolved the * company's normalized 12-digit org number. */ @@ -112,10 +177,26 @@ export async function probeCompanyGrants( orgNumber: string, createdBy?: string ): Promise { - const [lasombud, momsOmbud] = [await probeLasombud(orgNumber), await probeMomsOmbud(orgNumber)] + const registry = await probeViaOmbudsregister(orgNumber) + + let lasombud: ProbeClassification + let momsOmbud: ProbeClassification + let source: GrantProbeResult['source'] + if (registry.result) { + ;({ lasombud, momsOmbud } = registry.result) + source = 'registry' + } else { + lasombud = await probeLasombud(orgNumber) + momsOmbud = await probeMomsOmbud(orgNumber) + source = 'service' + const note = ` (ombudsregister otillgängligt: ${registry.reason})` + lasombud = { ...lasombud, detail: lasombud.detail + note } + momsOmbud = { ...momsOmbud, detail: momsOmbud.detail + note } + } log.info('grant probe completed', { companyId, + source, lasombud: lasombud.status, momsOmbud: momsOmbud.status, }) @@ -136,5 +217,5 @@ export async function probeCompanyGrants( : null, }) - return { connection, lasombud, momsOmbud } + return { connection, lasombud, momsOmbud, source } } diff --git a/extensions/general/skatteverket/lib/ombud-client.ts b/extensions/general/skatteverket/lib/ombud-client.ts new file mode 100644 index 00000000..21885f81 --- /dev/null +++ b/extensions/general/skatteverket/lib/ombud-client.ts @@ -0,0 +1,427 @@ +import { z } from 'zod' +import { createLogger } from '@/lib/logger' +import { getSkatteverketEnvironment, skvRequestWithAuth, SkatteverketAuthError } from './api-client' +import type { SkvBehorighet } from './connection-store' + +const log = createLogger('skatteverket-ombud-client') + +/** + * Client for Skatteverket's "Ombudshantering via API" v2 (scope `obr`). + * + * Source: Tjänstebeskrivning Ombudshantering via API v2.0, dokumentversion + * 1.0 (2022-06-16), mirrored in dev_docs/skatteverket/ombudshantering/. + * Base URI `{host}/behorighet/ombudshantering/v2`, JSON over HTTPS, headers + * Accept + content-type application/json, Authorization Bearer, client_id, + * client_secret, skv_client_correlation_id. Operations: + * + * GET /ombud/autentisieratOmbud?huvudman&roll&giltigFrom&giltigTom + * The huvudmän (companies/persons) the AUTHENTICATED ombud may + * represent, with role + validity. The ombud identity comes from the + * token: with a personal BankID token that is the person, with the + * system (CCG, organisationscertifikat) token it is Accounted's org + * number. Only the system identity answers "who granted Accounted". + * GET /huvudman/autentisieradHuvudman?ombud&roll&giltigFrom&giltigTom + * The reverse view for a huvudman. Not used here. + * POST /ombud/autentisieratOmbud/huvudman/{huvudman}/djuplank/utseombud + * body { ombudsroller: [rollbeteckning...], giltigTom? }. Returns a + * deep link into the e-service "Ombud och behörigheter" with the roles + * pre-selected for the huvudman to sign with BankID. Valid three + * weeks from creation. + * GET /roller?roll + * Role descriptions: rollbeteckning + rollbeskrivning. + * + * Every call here runs on the SYSTEM identity: the whole point is to ask + * the register what companies granted Accounted, and to mint deep links + * that name Accounted as the ombud. + * + * Two things the service description does not pin down, deliberately + * handled tolerantly and logged so the first live call against the test + * service settles them (see dev_docs/skatteverket/ombudshantering/README.md): + * - the JSON envelope of list responses (bare array vs. an object holding + * the list): both are accepted, + * - the rollbeteckning codes for "Juridiskt läsombud" and "Momsdeklaration, + * ombud": pin them via env once known; until then roles are classified + * by their rollbeskrivning text, which the register returns alongside. + */ + +export const OMBUD_API_TEST_BASE_URL = 'https://api.test.skatteverket.se/behorighet/ombudshantering/v2' +export const OMBUD_API_PROD_BASE_URL = 'https://api.skatteverket.se/behorighet/ombudshantering/v2' + +/** Deep links from /djuplank/utseombud are valid this long (service description 4.1.1). */ +export const OMBUD_DEEP_LINK_VALIDITY_WEEKS = 3 + +export function getOmbudApiBaseUrl(): string { + const override = process.env.SKATTEVERKET_OMBUD_API_BASE_URL + if (override) return override.replace(/\/+$/, '') + return getSkatteverketEnvironment() === 'prod' ? OMBUD_API_PROD_BASE_URL : OMBUD_API_TEST_BASE_URL +} + +/** The behörigheter Accounted asks companies for, keyed as the connection row stores them. */ +export const OMBUD_ROLE_KEYS: readonly SkvBehorighet[] = ['lasombud', 'moms_ombud'] as const + +/** + * Pinned rollbeteckning codes (exact match on `roll`). Unknown until read + * from GET /roller in the test service; env-configurable so no deploy is + * needed when Skatteverket confirms them. + */ +const ROLE_CODE_ENV: Record = { + lasombud: 'SKATTEVERKET_OMBUD_ROLL_LASOMBUD', + moms_ombud: 'SKATTEVERKET_OMBUD_ROLL_MOMS', +} + +/** + * Description-text fallback. Skatteverket's own labels in the e-service are + * "Juridiskt läsombud" and "Momsdeklaration, ombud"; the register returns a + * rollbeskrivning with every behörighetspost, so the text is available even + * before the codes are pinned. Matching is exact on the whole label (case and + * whitespace aside): "Momsdeklaration, deklarationsombud" is a broader role + * and must never be read as "Momsdeklaration, ombud". + */ +const ROLE_DESCRIPTION_PATTERNS: Record = { + lasombud: /^juridiskt\s+l[äa]sombud$/i, + moms_ombud: /^momsdeklaration,\s*ombud$/i, +} + +export function getPinnedRoleCode(key: SkvBehorighet): string | null { + const raw = process.env[ROLE_CODE_ENV[key]]?.trim() + return raw ? raw : null +} + +export interface OmbudRoleLike { + roll: string + rollbeskrivning?: string | null +} + +/** + * Which of Accounted's behörigheter a register role represents, or null + * when it is some other role (companies hand out many; only two matter). + * A pinned code wins over the text match, and a pinned code that does NOT + * match disqualifies the text fallback for that key: once Emil has told us + * the code, a differently-coded role with similar wording is a different role. + */ +export function classifyOmbudRole(role: OmbudRoleLike): SkvBehorighet | null { + for (const key of OMBUD_ROLE_KEYS) { + const pinned = getPinnedRoleCode(key) + if (pinned) { + if (role.roll === pinned) return key + continue + } + if (role.rollbeskrivning && ROLE_DESCRIPTION_PATTERNS[key].test(role.rollbeskrivning)) return key + } + return null +} + +// ── Wire schemas ───────────────────────────────────────────────────── + +const BehorighetspostSchema = z.object({ + huvudman: z.string(), + roll: z.string(), + rollbeskrivning: z.string().nullish(), + ombud: z.string().nullish(), + giltigFrom: z.string(), + giltigTom: z.string().nullish(), +}) +export type Behorighetspost = z.infer + +const RollbeskrivningspostSchema = z.object({ + roll: z.string(), + rollbeskrivning: z.string().nullish(), +}) +export type Rollbeskrivningspost = z.infer + +/** + * Hosts a deep link may point at. The settings page navigates the browser + * to this URL, so the register's answer is not trusted blindly: only HTTPS + * on skatteverket.se (or a subdomain) passes. Test service links come from + * the same domain family. + */ +export function isAllowedDeepLinkUrl(raw: string): boolean { + let url: URL + try { + url = new URL(raw) + } catch { + return false + } + if (url.protocol !== 'https:') return false + const host = url.hostname.toLowerCase() + return host === 'skatteverket.se' || host.endsWith('.skatteverket.se') +} + +const DjuplankSchema = z.object({ + djuplank: z.string().min(1).refine(isAllowedDeepLinkUrl, 'djuplank must be an https skatteverket.se URL'), +}) + +/** + * Accept the list either bare or wrapped in an object under any of the names + * the service description uses for it. Logged once per shape so the first + * live call documents which one Skatteverket actually sends. + */ +function unwrapList(json: unknown, candidateKeys: string[], operation: string): unknown[] { + if (Array.isArray(json)) return json + if (json && typeof json === 'object') { + for (const key of candidateKeys) { + const value = (json as Record)[key] + if (Array.isArray(value)) { + log.info('ombud list envelope observed', { operation, key }) + return value + } + } + } + throw new OmbudApiError(`Oväntat svarsformat från Ombudshantering (${operation}).`, 'OBR_BAD_RESPONSE') +} + +export type OmbudApiErrorCode = + | 'OBR_FORBIDDEN' // 401/403 for the system identity: scope/avtal missing, not a company-level fact + | 'OBR_BAD_RESPONSE' + | 'OBR_HTTP_ERROR' + | 'OBR_ROLE_UNRESOLVED' + +export class OmbudApiError extends Error { + constructor( + message: string, + public readonly code: OmbudApiErrorCode, + public readonly status?: number + ) { + super(message) + this.name = 'OmbudApiError' + } +} + +async function ombudRequest(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + try { + return await skvRequestWithAuth({ mode: 'system' }, method, path, body, { + baseUrl: getOmbudApiBaseUrl(), + accept: 'application/json', + }) + } catch (err) { + // api-client maps a system-mode 403 to OMBUD_GRANT_MISSING because on the + // read services a 403 is "this company did not grant us". On the register + // itself a 403 means Accounted may not call the service at all (scope or + // avtal): a run-level condition that must never be recorded as a + // company's grant state. + if (err instanceof SkatteverketAuthError && err.code === 'OMBUD_GRANT_MISSING') { + throw new OmbudApiError( + 'Ombudshantering-tjänsten nekade anropet: kontrollera att scopet obr och avtalet är på plats.', + 'OBR_FORBIDDEN', + 403 + ) + } + throw err + } +} + +async function readJsonOrThrow(response: Response, operation: string): Promise { + if (response.ok) { + return response.json().catch(() => { + throw new OmbudApiError(`Ombudshantering svarade utan JSON (${operation}).`, 'OBR_BAD_RESPONSE', response.status) + }) + } + const text = await response.text().catch(() => '') + throw new OmbudApiError( + `Ombudshantering svarade ${response.status} (${operation})${text ? `: ${text.slice(0, 200)}` : ''}`, + 'OBR_HTTP_ERROR', + response.status + ) +} + +function buildQuery(params: Record): string { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value) search.set(key, value) + } + const qs = search.toString() + return qs ? `?${qs}` : '' +} + +// ── Operations ─────────────────────────────────────────────────────── + +export type ListOmbudGrantsFilter = { + /** 12-digit org/person number of one huvudman; omit for all of them. */ + huvudman?: string + roll?: string + giltigFrom?: string + giltigTom?: string +} + +/** + * GET /ombud/autentisieratOmbud: every behörighetspost where the system + * identity (Accounted's org number) is the ombud. Skatteverket returns + * current and FUTURE grants; use {@link isGrantActive} before trusting one. + * + * A 404 is "wrong URI" per the service description (4.7), yet its 1..* + * multiplicity for the list leaves an empty register indistinguishable from + * it. Default: a 404 throws like any other HTTP error, so a single-company + * probe falls back to the read-service probes instead of recording a denial + * on a transport fault. Only the sync cron opts into `emptyOn404`, and it + * pairs that with its empty-register guard (a zero result downgrades nothing). + */ +export async function listOmbudGrants( + filter: ListOmbudGrantsFilter = {}, + options: { emptyOn404?: boolean } = {} +): Promise { + const response = await ombudRequest('GET', `/ombud/autentisieratOmbud${buildQuery(filter)}`) + if (response.status === 404 && options.emptyOn404) return [] + const json = await readJsonOrThrow(response, 'ombud/autentisieratOmbud') + const rows = unwrapList(json, ['behorighetsposter', 'Behorighetsposter', 'behorigheter'], 'ombud/autentisieratOmbud') + const parsed = z.array(BehorighetspostSchema).safeParse(rows) + if (!parsed.success) { + throw new OmbudApiError('Behörighetsposter från Ombudshantering kunde inte tolkas.', 'OBR_BAD_RESPONSE') + } + return parsed.data +} + +/** GET /roller: all rollbeteckningar with descriptions (or one, when filtered). */ +export async function getOmbudRoleDescriptions(roll?: string): Promise { + const response = await ombudRequest('GET', `/roller${buildQuery({ roll })}`) + const json = await readJsonOrThrow(response, 'roller') + const rows = unwrapList(json, ['rollbeskrivningsposter', 'Rollbeskrivningsposter', 'roller'], 'roller') + const parsed = z.array(RollbeskrivningspostSchema).safeParse(rows) + if (!parsed.success) { + throw new OmbudApiError('Rollbeskrivningar från Ombudshantering kunde inte tolkas.', 'OBR_BAD_RESPONSE') + } + return parsed.data +} + +/** + * The rollbeteckning codes to put in a deep link: pinned env values first, + * otherwise resolved from GET /roller by description. Throws when a key + * cannot be resolved rather than minting a link with a guessed role. + */ +export async function resolveOmbudRoleCodes(keys: readonly SkvBehorighet[]): Promise> { + const resolved: Partial> = {} + const missing: SkvBehorighet[] = [] + for (const key of keys) { + const pinned = getPinnedRoleCode(key) + if (pinned) resolved[key] = pinned + else missing.push(key) + } + if (missing.length > 0) { + const roles = await getOmbudRoleDescriptions() + for (const key of missing) { + const hit = roles.find((role) => classifyOmbudRole(role) === key) + if (hit) resolved[key] = hit.roll + } + const unresolved = missing.filter((key) => !resolved[key]) + if (unresolved.length > 0) { + log.warn('ombud role codes unresolved from /roller', { + unresolved, + available: roles.map((r) => `${r.roll}=${r.rollbeskrivning ?? ''}`), + }) + throw new OmbudApiError( + `Rollkod saknas för ${unresolved.join(', ')}: sätt ${unresolved + .map((key) => ROLE_CODE_ENV[key]) + .join(' och ')} efter uppslag i GET /roller.`, + 'OBR_ROLE_UNRESOLVED' + ) + } + } + return resolved as Record +} + +export interface UtseOmbudDeepLink { + djuplank: string + /** rollbeteckning codes the link pre-selects, keyed by behörighet. */ + roller: Record + /** yyyy-mm-dd when the link stops working (three weeks from creation). */ + expiresOn: string +} + +/** + * POST .../huvudman/{huvudman}/djuplank/utseombud on the system identity: + * a link the company opens to appoint Accounted as ombud with the given + * roles pre-selected, then signs in the e-service with BankID. + */ +export async function createUtseOmbudDeepLink( + huvudman: string, + keys: readonly SkvBehorighet[] = OMBUD_ROLE_KEYS, + giltigTom?: string, + now: Date = new Date() +): Promise { + const roller = await resolveOmbudRoleCodes(keys) + const body: { ombudsroller: string[]; giltigTom?: string } = { + ombudsroller: keys.map((key) => roller[key]), + } + if (giltigTom) body.giltigTom = giltigTom + const response = await ombudRequest( + 'POST', + `/ombud/autentisieratOmbud/huvudman/${encodeURIComponent(huvudman)}/djuplank/utseombud`, + body + ) + const json = await readJsonOrThrow(response, 'djuplank/utseombud') + const parsed = DjuplankSchema.safeParse(json) + if (!parsed.success) { + log.warn('deep link rejected', { issues: parsed.error.issues.map((i) => i.message) }) + throw new OmbudApiError('Djuplänken från Ombudshantering kunde inte tolkas.', 'OBR_BAD_RESPONSE') + } + const expires = new Date(now.getTime() + OMBUD_DEEP_LINK_VALIDITY_WEEKS * 7 * 24 * 60 * 60 * 1000) + return { djuplank: parsed.data.djuplank, roller, expiresOn: expires.toISOString().slice(0, 10) } +} + +// ── Pure helpers over behörighetsposter ────────────────────────────── + +/** yyyy-mm-dd for the given instant, in UTC (Skatteverket dates are date-only). */ +export function isoDate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +/** + * A grant counts today when giltigFrom is today or earlier and giltigTom is + * empty (tillsvidare) or today or later. Date-only strings compare + * lexically, which is exactly the yyyy-mm-dd contract. + */ +export function isGrantActive(post: Pick, today: string): boolean { + if (!post.giltigFrom || post.giltigFrom > today) return false + if (post.giltigTom && post.giltigTom < today) return false + return true +} + +/** + * Register identities arrive as 12 digits, sometimes with a separator + * (string 12..13 per the service description). Reduce to 12 digits or null. + */ +export function normalizeHuvudman(raw: string): string | null { + const digits = raw.replace(/\D/g, '') + return /^\d{12}$/.test(digits) ? digits : null +} + +export interface HuvudmanGrantSummary { + huvudman: string + lasombud: boolean + moms_ombud: boolean + /** Every rollbeteckning seen for this huvudman, active or not: diagnostics. */ + roles: string[] + /** + * True when at least one post (active or not) classified as one of + * Accounted's behörigheter. False with a non-empty `roles` means the + * register lists roles we cannot name: a code-pinning problem, not a + * company decision. + */ + recognized: boolean +} + +/** + * Collapse behörighetsposter into one row per huvudman saying which of + * Accounted's two behörigheter are active today. Roles outside the two are + * kept in `roles` for the probe detail but decide nothing. + */ +export function summarizeGrants(posts: Behorighetspost[], today: string): Map { + const out = new Map() + for (const post of posts) { + const huvudman = normalizeHuvudman(post.huvudman) + if (!huvudman) { + log.warn('behorighetspost with unparsable huvudman skipped', { huvudman: post.huvudman, roll: post.roll }) + continue + } + let row = out.get(huvudman) + if (!row) { + row = { huvudman, lasombud: false, moms_ombud: false, roles: [], recognized: false } + out.set(huvudman, row) + } + if (!row.roles.includes(post.roll)) row.roles.push(post.roll) + const key = classifyOmbudRole(post) + if (!key) continue + row.recognized = true + if (isGrantActive(post, today)) row[key] = true + } + return out +} diff --git a/extensions/general/skatteverket/lib/system-auth/config.ts b/extensions/general/skatteverket/lib/system-auth/config.ts index 8f0a141a..574ac730 100644 --- a/extensions/general/skatteverket/lib/system-auth/config.ts +++ b/extensions/general/skatteverket/lib/system-auth/config.ts @@ -46,11 +46,13 @@ export function getSystemClientId(): string | null { } /** - * Default scopes for the system token. The real scope names for the org - * flow are pending SKV docs; override via env when they land. + * Default scopes for the system token. `obr` is Ombudshantering (confirmed + * by Skatteverket 2026-09-01 when they added it to application id + * arcimtechnologyab_gnubok_1); the other names are pending SKV's org-flow + * docs. Override via env when they land. */ export function getSystemScopes(): string[] { - const raw = process.env.SKATTEVERKET_SYSTEM_SCOPES ?? 'skattekonto agd:lasa momsdeklaration' + const raw = process.env.SKATTEVERKET_SYSTEM_SCOPES ?? 'skattekonto agd:lasa momsdeklaration obr' return raw.split(/\s+/).filter(Boolean) } diff --git a/lib/notices/__tests__/categories.test.ts b/lib/notices/__tests__/categories.test.ts index ffda4f94..2c2d4697 100644 --- a/lib/notices/__tests__/categories.test.ts +++ b/lib/notices/__tests__/categories.test.ts @@ -277,7 +277,7 @@ describe('detectSkvDisconnected', () => { }) }) - it('stays quiet while the token is expired but still refreshable', async () => { + it('fires on a token expired hours ago even though a refresh token is still stored (dead 65-minute session)', async () => { enqueue({ data: { status: 'active', @@ -287,6 +287,22 @@ describe('detectSkvDisconnected', () => { last_error_at: null, }, }) + await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toMatchObject({ + id: 'skv_disconnected:expired@2026-08-19T10:00:00Z', + }) + }) + + it('stays quiet while the token is expired but inside the refresh window', async () => { + enqueue({ + data: { + status: 'active', + // Three minutes past access-token expiry: the 65-minute refresh token still works. + expires_at: '2026-08-19T11:57:00Z', + refresh_token: 'ciphertext', + refresh_count: 3, + last_error_at: null, + }, + }) await expect(detectSkvDisconnected(supabase, USER, COMPANY, NOW)).resolves.toBeNull() }) diff --git a/lib/notices/categories.ts b/lib/notices/categories.ts index 1e30f2cf..242a3c83 100644 --- a/lib/notices/categories.ts +++ b/lib/notices/categories.ts @@ -23,6 +23,7 @@ import { type SkattekontoReconciliationLatest, } from '@/lib/reconciliation/skattekonto-latest' import { expiringBankConnectionsFrom, skvStatusNeedsReconnect } from './predicates' +import { isSkvSessionRefreshable } from '@/lib/skatteverket/session-lifetime' import type { Notice } from './types' // The pure decision layer lives in ./predicates (client-safe: 'use client' @@ -190,7 +191,16 @@ export async function detectSkvDisconnected( const status = (data.status as string | null) ?? 'active' const expiresAt = data.expires_at as string | null const expired = expiresAt !== null && new Date(expiresAt).getTime() < now.getTime() - const canRefresh = data.refresh_token !== null && ((data.refresh_count as number | null) ?? 0) < 10 + // Same rule as the extension's /status route: a refresh token past its + // 65-minute life does not count as refreshable (lib/skatteverket/session-lifetime). + const canRefresh = isSkvSessionRefreshable( + { + expiresAt, + hasRefreshToken: data.refresh_token !== null, + refreshCount: data.refresh_count as number | null, + }, + now, + ) const needsReconsent = status === 'needs_reconsent' if (!skvStatusNeedsReconnect({ connected: true, needsReconsent, expired, canRefresh })) { return null diff --git a/lib/skatteverket/__tests__/session-lifetime.test.ts b/lib/skatteverket/__tests__/session-lifetime.test.ts new file mode 100644 index 00000000..3ffb15ea --- /dev/null +++ b/lib/skatteverket/__tests__/session-lifetime.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { + isSkvSessionRefreshable, + SKV_REFRESH_WINDOW_AFTER_EXPIRY_MS, + SKV_MAX_REFRESH_COUNT, +} from '../session-lifetime' + +const T0 = Date.parse('2026-09-01T09:32:06.000Z') +const EXPIRES = T0 + 60 * 60 * 1000 + +describe('isSkvSessionRefreshable', () => { + it('is refreshable while the access token is still valid', () => { + expect( + isSkvSessionRefreshable({ expiresAt: EXPIRES, hasRefreshToken: true, refreshCount: 0 }, T0 + 30 * 60 * 1000), + ).toBe(true) + }) + + it('is refreshable inside the five-minute window after access-token expiry', () => { + expect( + isSkvSessionRefreshable( + { expiresAt: EXPIRES, hasRefreshToken: true, refreshCount: 0 }, + EXPIRES + SKV_REFRESH_WINDOW_AFTER_EXPIRY_MS - 1, + ), + ).toBe(true) + }) + + it('is NOT refreshable once the 65-minute refresh token has died (the silent-drop case)', () => { + expect( + isSkvSessionRefreshable( + { expiresAt: EXPIRES, hasRefreshToken: true, refreshCount: 0 }, + EXPIRES + SKV_REFRESH_WINDOW_AFTER_EXPIRY_MS, + ), + ).toBe(false) + // A user coming back the next day: refresh token still stored, still dead. + expect( + isSkvSessionRefreshable( + { expiresAt: new Date(EXPIRES).toISOString(), hasRefreshToken: true, refreshCount: 0 }, + EXPIRES + 24 * 60 * 60 * 1000, + ), + ).toBe(false) + }) + + it('is NOT refreshable without a refresh token', () => { + expect(isSkvSessionRefreshable({ expiresAt: EXPIRES, hasRefreshToken: false, refreshCount: 0 }, T0)).toBe(false) + }) + + it('is NOT refreshable at the refresh cap', () => { + expect( + isSkvSessionRefreshable({ expiresAt: EXPIRES, hasRefreshToken: true, refreshCount: SKV_MAX_REFRESH_COUNT }, T0), + ).toBe(false) + expect( + isSkvSessionRefreshable({ expiresAt: EXPIRES, hasRefreshToken: true, refreshCount: SKV_MAX_REFRESH_COUNT - 1 }, T0), + ).toBe(true) + }) + + it('treats a missing or unparsable expiry as unrefreshable', () => { + expect(isSkvSessionRefreshable({ expiresAt: null, hasRefreshToken: true, refreshCount: 0 }, T0)).toBe(false) + expect(isSkvSessionRefreshable({ expiresAt: 'not a date', hasRefreshToken: true, refreshCount: 0 }, T0)).toBe(false) + }) + + it('accepts Date inputs for both expiry and now', () => { + expect( + isSkvSessionRefreshable({ expiresAt: new Date(EXPIRES), hasRefreshToken: true, refreshCount: 0 }, new Date(T0)), + ).toBe(true) + }) +}) diff --git a/lib/skatteverket/session-lifetime.ts b/lib/skatteverket/session-lifetime.ts new file mode 100644 index 00000000..8ba978a7 --- /dev/null +++ b/lib/skatteverket/session-lifetime.ts @@ -0,0 +1,69 @@ +/** + * Lifetime rules for Skatteverket's personal (`per` / BankID) OAuth2 session. + * + * Skatteverket issues the access token for 60 minutes and the refresh token + * for 65 minutes, counted from the same issue moment, and allows at most 10 + * refreshes per BankID consent. A stored refresh token is therefore only + * usable inside a five-minute window after the access token expires: after + * that, nothing on our side can revive the session and only a fresh BankID + * consent helps. + * + * Every surface that decides "can this connection still refresh itself" + * (the extension's /status route, the skv_disconnected notice, the settings + * panel) must agree on this rule. Before it lived here, /status and the + * notice both answered "yes, it can refresh" for as long as a refresh token + * existed, so a connection dead for days still showed as healthy and the + * reconnect banner never fired until a submission failed live. + * + * Core code (lib/notices) consumes this, so it lives in lib/, not in the + * extension. + */ + +/** Access-token lifetime Skatteverket grants in the per flow. */ +export const SKV_ACCESS_TOKEN_LIFETIME_MS = 60 * 60 * 1000 + +/** Refresh-token lifetime Skatteverket grants in the per flow. */ +export const SKV_REFRESH_TOKEN_LIFETIME_MS = 65 * 60 * 1000 + +/** + * How long past access-token expiry the refresh token still works: the + * difference between the two lifetimes above. + */ +export const SKV_REFRESH_WINDOW_AFTER_EXPIRY_MS = + SKV_REFRESH_TOKEN_LIFETIME_MS - SKV_ACCESS_TOKEN_LIFETIME_MS + +/** Maximum refreshes Skatteverket allows per BankID consent. */ +export const SKV_MAX_REFRESH_COUNT = 10 + +export interface SkvSessionLike { + /** Access-token expiry as epoch ms, ISO string, or Date. */ + expiresAt: number | string | Date | null | undefined + /** Whether a refresh token is stored at all (ciphertext presence is enough). */ + hasRefreshToken: boolean + refreshCount: number | null | undefined +} + +function toEpochMs(value: number | string | Date | null | undefined): number | null { + if (value === null || value === undefined) return null + if (typeof value === 'number') return Number.isFinite(value) ? value : null + const ms = value instanceof Date ? value.getTime() : new Date(value).getTime() + return Number.isFinite(ms) ? ms : null +} + +/** + * True when the stored session can still be refreshed without a new BankID + * consent: a refresh token exists, the refresh cap is not reached, and the + * refresh token itself has not expired (65 minutes from issue, i.e. five + * minutes past access-token expiry). + * + * A session with no parsable expiry is treated as unrefreshable: claiming + * health for a row we cannot reason about is exactly the bug this replaces. + */ +export function isSkvSessionRefreshable(session: SkvSessionLike, now: number | Date = Date.now()): boolean { + if (!session.hasRefreshToken) return false + if ((session.refreshCount ?? 0) >= SKV_MAX_REFRESH_COUNT) return false + const expiresAt = toEpochMs(session.expiresAt) + if (expiresAt === null) return false + const nowMs = now instanceof Date ? now.getTime() : now + return nowMs < expiresAt + SKV_REFRESH_WINDOW_AFTER_EXPIRY_MS +} diff --git a/messages/en.json b/messages/en.json index 7e3bbaff..b6eb6d06 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2961,6 +2961,10 @@ "system_cert_expires_soon": "{appName}'s certificate with Skatteverket expires in {days} days.", "system_verify_failed": "Verification failed", "system_verify_rate_limited": "Wait a minute between verifications.", + "system_deeplink": "Appoint {appName} as ombud", + "system_deeplink_loading": "Creating link...", + "system_deeplink_failed": "The link could not be created", + "system_deeplink_hint": "The link opens Skatteverket's e-service with {appName} and the roles pre-selected. Sign there with BankID. The link is valid for three weeks.", "needs_reconsent_message": "The Skatteverket connection needs to be renewed. Automatic syncing is paused until you reconnect with BankID.", "missing_scope_message": "The connection lacks permission for one or more services, for example the tax account. Reconnect and approve all permissions on Skatteverket's consent page.", "connect_waiting": "Waiting for BankID…", @@ -2987,7 +2991,8 @@ "expires_in_minutes": "(in {minutes} min)", "refresh_label": "Refresh", "refresh_auto": "Refreshes automatically", - "refresh_exhausted": "Refresh exhausted: connect again", + "refresh_exhausted": "Can no longer be refreshed: connect again with BankID", + "session_lifetime_note": "The BankID session with Skatteverket lasts about one hour. After that a new login is needed before anything can be fetched or filed.", "permissions_label": "Permissions", "missing_skattekonto": "The Skattekonto permission is missing: disconnect and reconnect to enable the balance and transactions view.", "missing_agd": "The employer declaration (AGI) permission is missing: disconnect and reconnect to send AGI directly from accounted. Tokens issued before AGI support was activated lack this scope.", diff --git a/messages/sv.json b/messages/sv.json index 8214762a..a292fcfd 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2961,6 +2961,10 @@ "system_cert_expires_soon": "{appName}s certifikat mot Skatteverket går ut om {days} dagar.", "system_verify_failed": "Verifieringen misslyckades", "system_verify_rate_limited": "Vänta en minut mellan verifieringar.", + "system_deeplink": "Utse {appName} som ombud", + "system_deeplink_loading": "Skapar länk...", + "system_deeplink_failed": "Länken kunde inte skapas", + "system_deeplink_hint": "Länken öppnar Skatteverkets e-tjänst med {appName} och rollerna förvalda. Signera där med BankID. Länken gäller i tre veckor.", "needs_reconsent_message": "Anslutningen till Skatteverket behöver förnyas. Den automatiska synkroniseringen är pausad tills du ansluter igen med BankID.", "missing_scope_message": "Anslutningen saknar behörighet för en eller flera tjänster, till exempel skattekontot. Anslut igen och godkänn alla behörigheter på Skatteverkets samtyckessida.", "connect_waiting": "Väntar på BankID…", @@ -2987,7 +2991,8 @@ "expires_in_minutes": "(om {minutes} min)", "refresh_label": "Förnyelse", "refresh_auto": "Förnyas automatiskt", - "refresh_exhausted": "Förnyelse uttömd: anslut igen", + "refresh_exhausted": "Kan inte förnyas längre: anslut igen med BankID", + "session_lifetime_note": "BankID-sessionen mot Skatteverket gäller cirka en timme. Efter det behövs en ny inloggning innan något kan hämtas eller lämnas in.", "permissions_label": "Behörigheter", "missing_skattekonto": "Behörigheten för Skattekonto saknas: koppla från och anslut igen för att aktivera saldo- och transaktionsvyn.", "missing_agd": "Behörigheten för Arbetsgivardeklaration (AGI) saknas: koppla från och anslut igen för att kunna skicka AGI direkt från accounted. Tokens utfärdade innan AGI-stödet aktiverades saknar denna scope.", diff --git a/vercel.json b/vercel.json index 909a39b6..7d518e6f 100644 --- a/vercel.json +++ b/vercel.json @@ -62,6 +62,10 @@ "path": "/api/pending-operations/expire/cron", "schedule": "30 2 * * *" }, + { + "path": "/api/extensions/skatteverket/ombud/sync/cron", + "schedule": "30 3 * * *" + }, { "path": "/api/extensions/skatteverket/skattekonto/sync/cron", "schedule": "0 4 * * *"