feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance) (#1757)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the entitlement partition (#1747). Nothing is purchasable yet; this is the plumbing both ends need before the first manually issued key. Hosted side: - Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix, org_number, pinned instance_url, scopes, status, Stripe ids, current_period_end, per-minute rate limit, active_company_count, last_seen/synced) and connector_usage_events (per-request metering, separate from metered_events whose company_id references hosted companies). RLS on, NO policies: service role only. RPC validate_and_increment_connector_key copies the api_keys pattern (FOR UPDATE, minute window, suspended reported not counted, revoked = no row) and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role. pg-real test covers validate/count, unknown+revoked, suspended, rate limit, execute privileges per role, RLS invisibility, usage cascade. - lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts (generate/hash/validate -> 401/403/429 mapping), with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per request, 500 envelope on handler throw), /api/connect/entitlements GET + POST (records active_company_count, pins instance_url on first report, never moves a pinned one), scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once + the .env lines). Instance side: - lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL default https://app.gnubok.se), sync.ts: reports the active company count and writes source='connector' grants for every company x covered scope, expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status deletes them (freeze-and-retain); network/5xx/429 leave them alone. /api/connector/sync/cron (hourly) runs it; not_configured without a key. - Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json, with reasons) + drift tests; docker/crontab.self-hosted regenerated with the hourly sync. Docs (SELF-HOSTING connector section, env templates), DECISIONS. Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests run locally against supabase/postgres with every migration applied. no-phantom-columns ceiling +1 with a reason (the bulk grant upsert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A self-hosted instance with a `bank_sync`-scoped connector key can now connect a bank through Arcim's PSD2 credentials; the bank session id and all transaction data stay in the instance's own database (founder decision: tokens on the instance, proxy stateless). - Migration 20260820124000: `connector_connections` (secret-free ledger: sha256 of the EB session id + account uids, service-role only), `connector_upstream_counters` + RPC `connector_reserve_upstream` (global budget under EB Annex 1 §5's 300/min, shared with hosted), and `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real covers all of it. - EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core must not import @/extensions/); the extension re-exports it, tests unchanged. - lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed connector state (15-min TTL) so the consent redirect can use OUR registered EB callback and bounce back to the instance, no per-instance redirect URI at EB; the callback route gains that connector branch. - app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions, accounts/{uid}/{balances,transactions}), never open passthrough. POST /auth enforces the per-company connection quota + rewrites redirect/state; reads/deletes verify ledger ownership; every upstream call takes the global budget (429 + Retry-After when exhausted). - issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of v1), --bank/skv-connections-per-company + --sync-min-interval. - Docs (SELF-HOSTING: bank connector live), DECISIONS. Verified: 52 connect unit tests + 13 pg-real (run locally against supabase/postgres with all migrations) + EB extension suite (225, jwt relocation intact); full unit suite 15 979 green; tsc, guards, lint clean. Not in this PR: SKV broker (PR5b) and instance wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance) Sovereign plan WS3 PR5b, stacked on the bank proxy (#1751). A self-hosted instance with a `skatteverket`-scoped connector key can now run the BankID consent, file VAT/AGI and sync skattekonto through Arcim's registered Skatteverket client; the SKV tokens are returned to the instance and stored (encrypted) there. - lib/connect/upstreams/skatteverket-oauth.ts: core-side SKV OAuth + data helpers (authorize URL, code/refresh exchange with Arcim's client secret, the four backing-API base URLs, the API-gateway Client_Id/Client_Secret headers). Core can't import @/extensions/, so this duplicates the extension's endpoints/scope set (one integrator = Arcim), mirroring the EB JWT relocation. - app/api/connect/skv/oauth/authorize-url: builds the authorize URL against OUR registered redirect_uri + a signed connector state, per-company SKV connection quota, pending ledger row. - app/api/connect/skv/oauth/token: exchanges/refreshes and RETURNS the tokens to the instance; the ledger keeps only sha256(access_token) + sha256(refresh_token). - app/api/connect/skv/api/[...path]: allowlist over moms / skattekonto / agd-inlamning / agd-period. The instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key; the proxy checks the token hash against the ledger, adds Arcim's gateway credentials (never exposed to the instance), forwards. Same per-key + global budget as bank. - The Skatteverket extension /callback gains the connector branch (isConnectorState -> 302 back to the instance; code never exchanged there). - Docs (SELF-HOSTING: SKV connector live) + DECISIONS. Tests: SKV oauth lib, authorize-url, token, data proxy, callback connector branch (all green; 74 connect + 425 connect/SKV). tsc, guards, lint clean; no-phantom-columns held at 380 (literal update branches). Not in this PR: instance-side wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(connect): SKV broker review+skeptic batch: state-bound exchange, owned-only refresh, identity-number redaction, quota reservation, https-only bases, docs dedupe Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * docs(self-host): restore the instance-wiring qualifier in the connector section Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): PR #1757 review batch 2: redirect 'error' on credential fetches, mandatory ledger writes before token return, MD037 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): reject encoded path separators in SKV data-proxy segments (traversal guard) The WHATWG parser normalizes raw dot segments before the route runs; what survives is an encoded separator inside a segment (a%2Fb, ..%2Fx, a%5Cb), which would escape the allowlisted service base once the upstream fetch re-normalizes. splitPath now decodes each segment and rejects dot segments and separator-bearing values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
Emil
parent
5131ee9085
commit
37e50c272d
@@ -1143,6 +1143,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES and outside the trial-seed trigger, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
|
||||
[2026-08-20] Connector-key infrastructure (plan WS3 PR4) ships the hosted registry + the instance sync, nothing a customer can buy yet: connector_keys / connector_usage_events are service-role-only tables (RLS on, no policies) with an atomic validate_and_increment_connector_key RPC that copies the api_keys pattern (SHA-256 at rest, FOR UPDATE row lock, per-minute window) and is REVOKEd from PUBLIC, anon and authenticated before anyone can call it (the SECURITY DEFINER exposure lesson applied up front); /api/connect/entitlements behind withConnectorAuth (Bearer or X-Connector-Key, 401/403/429, one usage row per request); keys issued by scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once). The instance side writes source='connector' grants expiring at min(now+72h, period_end+3d) on every hourly sync, deletes them on 401/403 or a non-active status, and leaves them alone on network/5xx errors: the grant rows ARE the offline cache, no new cache code. The hourly job lives only in docker/crontab.self-hosted through a new EXTRA_JOBS table in the crontab generator (with its own drift tests), because vercel.json is the hosted schedule and hosted has no connector key. connector_usage_events is a separate table because metered_events.company_id references hosted companies and a connector key belongs to an instance, not a company here. Deferred: the proxy routes (bank/skv/org/migration: founder legal check with Enable Banking/SKV/TIC is the launch blocker), a connect.gnubok.se host rewrite (the instance calls app.gnubok.se/api/connect directly; a dedicated host is a later DNS decision), the self-host Docker preset change and the settings row.
|
||||
[2026-08-20] Connector bank proxy (plan WS3 PR5a): app/api/connect/bank/[...path] brokers Enable Banking for self-hosted instances with tokens staying on the instance (founder decision) and the proxy stateless apart from a secret-free connection ledger. Design that keeps EB Annex 1 §3/§7 satisfied: the instance never holds the EB JWT (minting moved to lib/connect/upstreams/enable-banking-jwt.ts so core does not import @/extensions/; the extension re-exports it); the consent redirect goes to OUR already-registered EB callback, which detects an HMAC-signed connector state (lib/connect/hosted/state.ts, 15-min TTL, CONNECTOR_STATE_SECRET or a one-way derivation of the service-role key) and 302s the browser back to the instance, so no per-instance redirect URI is registered at EB. Ownership: connector_connections ledger stores sha256(session_id) and the account uids, never the session; GET/DELETE /sessions and /accounts/{uid} calls verify the handle/account belongs to the presenting key. Quotas: per-company bank connection limit (connector_keys.limits, sold in the package, checked at POST /auth), per-key RPM (validate RPC), and a GLOBAL budget (connector_reserve_upstream RPC, connector_upstream_counters, ~30% of EB's 300/min so hosted is never starved; fail-open on a counter error). validate_and_increment_connector_key gained a limits column (v2). issue-connector-key.ts scopes default to bank_sync,skatteverket (TIC/org_lookup out of v1 per founder) and take --bank-connections-per-company etc. Path allowlist only, never an open passthrough. Deferred: the SKV broker (PR5b, same pattern, callback branch on the SKV extension) and the instance-side wiring (PR6: EB client connector-mode branch, self-host preset, settings row).
|
||||
[2026-08-20] Connector Skatteverket broker (plan WS3 PR5b, stacked on PR5a): the SKV OAuth/data path is core-side (lib/connect/upstreams/skatteverket-oauth.ts) so `app/api/connect/skv/*` does not import @/extensions/; it duplicates the extension's endpoints/scope set (one integrator = Arcim's registered SKV client) rather than sharing, matching the EB JWT relocation pattern. Tokens stay on the instance: POST /api/connect/skv/oauth/token exchanges the code/refresh with Arcim's client secret and RETURNS the tokens to the instance (which encrypts+stores them), the ledger keeps only sha256(access_token) and sha256(refresh_token). authorize-url uses OUR registered redirect_uri + a signed connector state; the SKV extension /callback gained the same connector branch as EB (isConnectorState → 302 back to the instance, code never exchanged there). The data proxy app/api/connect/skv/api/<service>/<path> is an allowlist over the four backing APIs (moms, skattekonto, agd-inlamning, agd-period): the instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key, the proxy verifies the token hash against the ledger and adds Arcim's Client_Id/Client_Secret gateway headers (never exposed to the instance). Same per-key/global budget as bank. SKATTEVERKET_ENABLED + SKATTEVERKET_TOKEN_ENCRYPTION_KEY stay operator-set on the instance since the tokens live there. Refresh update written as two literal .update() branches to keep the no-phantom-columns ceiling at 380.
|
||||
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES; the trial-seed trigger does seed 30-day source='trial' rows for bank_sync/skatteverket (they are PAID keys) but never writes source='connector' and never seeds the connector-only keys (org_lookup, migration), and on a self-host only source='connector' rows unlock a connector capability, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
|
||||
[2026-08-20] Sovereign package docs (plan WS2 PR1): docs/SOVEREIGN.md is written as regulatory-risk elimination with a per-provider fact sheet checked on the vendors' own pages (Elastx CaaS/DBaaS/3 Stockholm AZs/ISO 27001:2022; GleSYS VPS + S3, no managed k8s, EU-owned not Swedish-owned; Safespring S3 with Object Lock COMPLIANCE/GOVERNANCE; Berget api.berget.ai/v1 with gemma-4-31B-it vision and an SLA that excludes serverless; evroc Think Models EU-only), never as "US cloud is illegal", and it leads with the MCP server as the agent surface that needs no AI provider at all (alignment rule R5). The connector subscription is described as planned and not yet available rather than documented as if it shipped. Vercel Speed Insights is now gated behind !isSelfHosted() in app/layout.tsx (the last ungated hosted-only telemetry; read via lib/env/public-flags per the folded-flag rule). Backup/restore ship as scripts/self-host/{backup,restore}.sh (pg_dump custom format + storage volume tar + optional db-config volume for the pgsodium root key, SHA-256 manifest, AWS CLI v2 against any S3-compatible endpoint, optional COMPLIANCE-mode Object Lock) with a bash -n + refusal-path test, because self-hosted Supabase has no managed backups and BFL 7 kap needs a credible 7-year archive. Stale self-host docs fixed: the 4-of-23 cron table replaced by a pointer to the generated crontab and the pgvector line corrected (nothing stores embeddings).
|
||||
[2026-08-20] Vercel build heap is raised through vercel.json `buildCommand` (`NODE_OPTIONS=--max-old-space-size=6144 npm run build`), not a project env var and not `build.env`: a project-level NODE_OPTIONS also reaches function runtime (V8 sizes the heap against a limit the function does not have), and `build.env` is marked deprecated in the vercel.json schema; `buildCommand` scopes the flag to the build exactly like core-build.yml's 8192 does for CI. 6144 fits the standard 4-core/8 GB build machine next to the main next process; the type-check needs ~4.5 GB and was hanging at V8's ~4 GB default ceiling (4 production timeouts 2026-08-14..20).
|
||||
@@ -1413,3 +1414,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-31] Connector usage metering redacts opaque path segments to ':id' before insert (skeptic refutation on PR #1751): the proxied bank paths carry the raw EB session id and account uid as segments, so persisting the raw pathname in connector_usage_events put the cleartext handle next to the ledger that exists precisely to store only sha256(handle). redactEndpoint() replaces UUID/long-hex/long-base64url segments; literal route words survive so metering keys stay useful.
|
||||
[2026-08-31] Bank-proxy review batch (PR #1751): EB base URL must be https (JWT in Authorization; lazy check so a bad env 500s the request, never the build); forwardToEb reads the body inside the abort-timeout window (headers-then-stall no longer wedges the request); the per-company quota uses the pending row as a reservation (pre-count, insert, re-count, roll back own row on loss) with fresh-pending rows holding quota for the 15-min consent window, closing the concurrent-auth TOCTOU without a new RPC; DELETE revokes the ledger row only on upstream success or 404 (a transient EB error no longer strands a live remote session unreachable).
|
||||
[2026-08-31] POST /sessions binds the code exchange to its state's own pending row (Superagent P1 on PR #1751): verified signature + key/service match + existing pending row are preconditions for calling EB, and a concurrent consumption of the same state after exchange closes the upstream session and answers 409 instead of handing out a session the ledger never recorded. Full code-to-state binding at the callback (recording a code hash on the pending row) would need a column and is deferred; the pre-exchange binding plus one-shot pending->active activation removes the cross-key and stateless-exchange paths, and same-key crossover between an instance's own concurrent flows only relabels its own sessions.
|
||||
[2026-08-31] SKV broker hardening (two skeptic refutations on PR #1757, same classes as the bank fixes): the token route's code exchange now requires a verified connector state (signature, key, svc 'skv') plus an existing pending row before spending Arcim's client secret, and a concurrently consumed state withholds the tokens with 409 (SKV has no revoke endpoint; the pair expires unused); refresh requires the presented token's hash to match an ACTIVE ledger row under the presenting key (it was an open refresh oracle for any leaked token); the metering redaction gains a 10+-digit rule because personnummer/orgnr/redovisare12 in SKV data-proxy paths slipped the EB-tuned thresholds and rested in cleartext; authorize-url adopts the countHeldConnections reservation re-count; all SKV base URLs are https-only (loopback excepted). SELF-HOSTING.md's connector section collapsed from three contradictory copies (accreted across the stack merges) to one.
|
||||
|
||||
@@ -104,6 +104,8 @@ async function forwardToEb(method: string, path: string, body?: unknown): Promis
|
||||
const res = await fetch(`${ebBaseUrl()}${path}`, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
// A followed redirect would resend the EB JWT to the redirect target.
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
Authorization: getAuthorizationHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
let key = {
|
||||
id: 'key-1', orgNumber: 'x', instanceUrl: 'https://i', scopes: ['skatteverket'], status: 'active' as const,
|
||||
currentPeriodEnd: null as string | null, limits: { bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 },
|
||||
}
|
||||
vi.mock('@/lib/connect/hosted/with-connector-auth', () => ({
|
||||
withConnectorAuth: (_o: string, h: (r: Request, c: unknown) => Promise<Response>) => (r: Request) =>
|
||||
h(r, { requestId: 't', log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, supabase: {}, key }),
|
||||
}))
|
||||
const hh = vi.hoisted(() => ({ budget: vi.fn(), find: vi.fn(), touch: vi.fn() }))
|
||||
vi.mock('@/lib/connect/hosted/upstream-budget', () => ({ reserveUpstream: (...a: unknown[]) => hh.budget(...a) }))
|
||||
vi.mock('@/lib/connect/hosted/ledger', () => ({ findByHandle: (...a: unknown[]) => hh.find(...a), touchConnection: (...a: unknown[]) => hh.touch(...a) }))
|
||||
vi.mock('@/lib/connect/upstreams/skatteverket-oauth', () => ({
|
||||
SKV_API_BASES: {
|
||||
moms: () => 'https://api.skv/momsdeklaration/v1',
|
||||
skattekonto: () => 'https://api.skv/skattekonto/v2',
|
||||
'agd-inlamning': () => 'https://api.skv/agd/inlamning/v1',
|
||||
'agd-period': () => 'https://api.skv/agd/period/v1',
|
||||
},
|
||||
skvGatewayHeaders: () => ({ Client_Id: 'gw', Client_Secret: 'gws', skv_client_correlation_id: 'corr' }),
|
||||
}))
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
function req(method: string, path: string, token = 'user-token', body?: string): Request {
|
||||
const headers: Record<string, string> = { 'x-connector-upstream-authorization': `Bearer ${token}` }
|
||||
return new Request(`https://app.gnubok.se/api/connect/skv/api${path}`, { method, headers, ...(body !== undefined ? { body } : {}) })
|
||||
}
|
||||
function skvOk(body: unknown, status = 200) {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
key = { ...key, scopes: ['skatteverket'] }
|
||||
hh.budget.mockResolvedValue({ ok: true })
|
||||
hh.find.mockResolvedValue({ id: 'l1' })
|
||||
})
|
||||
|
||||
describe('skv data proxy', () => {
|
||||
it('rejects dot-segment traversal, raw and percent-encoded', async () => {
|
||||
// Raw ../ and %2E dot segments are normalized away by the WHATWG URL
|
||||
// parser before the route sees them (they cannot escape past the route
|
||||
// prefix). What survives to splitPath is an encoded separator INSIDE a
|
||||
// segment, which would traverse once the upstream fetch re-normalizes:
|
||||
// those must be rejected here.
|
||||
for (const path of ['/moms/a%2Fb', '/moms/..%2Fx', '/moms/a%5Cb']) {
|
||||
const res = await GET(req('GET', path))
|
||||
expect(res.status, path).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_PATH_NOT_ALLOWED')
|
||||
}
|
||||
})
|
||||
|
||||
it('403 without the scope', async () => {
|
||||
key = { ...key, scopes: [] }
|
||||
expect((await GET(req('GET', '/moms/deklarationer'))).status).toBe(403)
|
||||
})
|
||||
it('403 on an unknown service', async () => {
|
||||
const res = await GET(req('GET', '/unknown/x'))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_PATH_NOT_ALLOWED')
|
||||
})
|
||||
it('400 without a user token', async () => {
|
||||
const res = await GET(new Request('https://app.gnubok.se/api/connect/skv/api/moms/x', { method: 'GET' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_UPSTREAM_TOKEN_MISSING')
|
||||
})
|
||||
it('404 when the token is not owned by this key', async () => {
|
||||
hh.find.mockResolvedValue(null)
|
||||
expect((await GET(req('GET', '/moms/x'))).status).toBe(404)
|
||||
})
|
||||
it('forwards a GET with the user Bearer + Arcim gateway headers to the right backing API', async () => {
|
||||
skvOk({ ok: true })
|
||||
const res = await GET(req('GET', '/skattekonto/saldo?period=2026-08'))
|
||||
expect(res.status).toBe(200)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://api.skv/skattekonto/v2/saldo?period=2026-08')
|
||||
expect(init.headers.Authorization).toBe('Bearer user-token')
|
||||
expect(init.headers.Client_Id).toBe('gw')
|
||||
expect(hh.touch).toHaveBeenCalledWith(expect.anything(), 'l1')
|
||||
})
|
||||
it('forwards a POST body to the AGI inlämning API', async () => {
|
||||
skvOk({ id: 'u1' }, 201)
|
||||
const res = await POST(req('POST', '/agd-inlamning/underlag', 'user-token', '<xml/>'))
|
||||
expect(res.status).toBe(201)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://api.skv/agd/inlamning/v1/underlag')
|
||||
expect(fetchMock.mock.calls[0][1].body).toBe('<xml/>')
|
||||
})
|
||||
it('429 when the budget is exhausted', async () => {
|
||||
hh.budget.mockResolvedValue({ ok: false, scope: 'hour', retryAfterSec: 3600 })
|
||||
const res = await GET(req('GET', '/moms/x'))
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('3600')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
|
||||
import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget'
|
||||
import { findByHandle, touchConnection } from '@/lib/connect/hosted/ledger'
|
||||
import { SKV_API_BASES, skvGatewayHeaders } from '@/lib/connect/upstreams/skatteverket-oauth'
|
||||
|
||||
/**
|
||||
* Skatteverket data proxy for self-hosted instances.
|
||||
*
|
||||
* ANY /api/connect/skv/api/<service>/<path>
|
||||
* Authorization: Bearer <the END USER's SKV access token, from the instance>
|
||||
* X-Connector-Key: gnubok_ck_... (the instance's key auth)
|
||||
*
|
||||
* The instance holds the user token (it did the BankID flow through our
|
||||
* broker); it presents that token as the upstream Bearer while proving its
|
||||
* own subscription with X-Connector-Key. The proxy checks that the presented
|
||||
* token belongs to a connection this key owns (ledger), then adds Arcim's
|
||||
* API-gateway client credentials (Client_Id/Client_Secret) and forwards to
|
||||
* the right SKV backing API. Arcim's gateway secret never leaves us.
|
||||
*
|
||||
* <service> is one of the SKV_API_BASES keys (moms, skattekonto,
|
||||
* agd-inlamning, agd-period): an allowlist, never an open passthrough.
|
||||
*/
|
||||
|
||||
const FETCH_TIMEOUT_MS = 20_000
|
||||
|
||||
function requireScope(ctx: ConnectorContext): NextResponse | null {
|
||||
if (ctx.key.scopes.includes('skatteverket')) return null
|
||||
return NextResponse.json({ error: 'This connector key does not include Skatteverket', code: 'CONNECTOR_SCOPE_MISSING' }, { status: 403 })
|
||||
}
|
||||
|
||||
function splitPath(request: Request): { service: string; rest: string; query: string } | null {
|
||||
const marker = '/api/connect/skv/api'
|
||||
const idx = request.url.indexOf(marker)
|
||||
if (idx === -1) return null
|
||||
const after = request.url.slice(idx + marker.length)
|
||||
const [pathPart, ...q] = after.split('?')
|
||||
const segments = pathPart.split('/').filter(Boolean)
|
||||
if (segments.length === 0) return null
|
||||
// Traversal guard: a '.'/'..' segment (raw or percent-encoded) would let
|
||||
// the proxied URL escape the allowlisted service base once fetch
|
||||
// normalizes it. Decode each segment and reject dot segments and anything
|
||||
// that decodes to contain a path separator.
|
||||
for (const seg of segments) {
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = decodeURIComponent(seg)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) return null
|
||||
}
|
||||
const [service, ...restSegs] = segments
|
||||
return { service, rest: `/${restSegs.join('/')}`, query: q.length ? `?${q.join('?')}` : '' }
|
||||
}
|
||||
|
||||
/** The end-user SKV token the instance forwards, from the upstream-Authorization header. */
|
||||
function userToken(request: Request): string | null {
|
||||
const h = request.headers.get('x-connector-upstream-authorization') || request.headers.get('authorization')
|
||||
if (!h?.startsWith('Bearer ')) return null
|
||||
return h.slice(7).trim() || null
|
||||
}
|
||||
|
||||
async function handle(request: Request, ctx: ConnectorContext): Promise<Response> {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
|
||||
const parts = splitPath(request)
|
||||
if (!parts || !(parts.service in SKV_API_BASES)) {
|
||||
return NextResponse.json({ error: 'Unknown Skatteverket service', code: 'CONNECTOR_PATH_NOT_ALLOWED' }, { status: 403 })
|
||||
}
|
||||
const token = userToken(request)
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Missing user token', code: 'CONNECTOR_UPSTREAM_TOKEN_MISSING' }, { status: 400 })
|
||||
}
|
||||
const owned = await findByHandle(ctx.supabase, { keyId: ctx.key.id, service: 'skatteverket', handle: token })
|
||||
if (!owned) {
|
||||
return NextResponse.json({ error: 'Unknown Skatteverket connection for this key', code: 'CONNECTOR_NOT_OWNED' }, { status: 404 })
|
||||
}
|
||||
const budget = await reserveUpstream(ctx.supabase, 'skatteverket')
|
||||
if (!budget.ok) {
|
||||
return NextResponse.json({ error: 'Skatteverket connector is busy', code: 'CONNECTOR_RATE_LIMITED' }, { status: 429, headers: { 'Retry-After': String(budget.retryAfterSec) } })
|
||||
}
|
||||
await touchConnection(ctx.supabase, owned.id)
|
||||
|
||||
const url = `${SKV_API_BASES[parts.service]()}${parts.rest}${parts.query}`
|
||||
const contentType = request.headers.get('x-connector-upstream-content-type') || request.headers.get('content-type') || 'application/json'
|
||||
const method = request.method
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD'
|
||||
const body = hasBody ? await request.text() : undefined
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
// A followed redirect would resend the gateway Client_Secret headers.
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...skvGatewayHeaders(),
|
||||
...(hasBody ? { 'Content-Type': contentType } : {}),
|
||||
},
|
||||
...(body !== undefined && body.length > 0 ? { body } : {}),
|
||||
})
|
||||
const text = await res.text()
|
||||
if ([204, 205, 304].includes(res.status)) return new NextResponse(null, { status: res.status })
|
||||
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' } })
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withConnectorAuth('connect.skv', handle)
|
||||
export const POST = withConnectorAuth('connect.skv', handle)
|
||||
export const PUT = withConnectorAuth('connect.skv', handle)
|
||||
export const PATCH = withConnectorAuth('connect.skv', handle)
|
||||
export const DELETE = withConnectorAuth('connect.skv', handle)
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
let key = {
|
||||
id: 'key-1', orgNumber: '5561234567', instanceUrl: 'https://bokforing.example.se',
|
||||
scopes: ['skatteverket'], status: 'active' as const, currentPeriodEnd: null as string | null,
|
||||
limits: { bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 },
|
||||
}
|
||||
vi.mock('@/lib/connect/hosted/with-connector-auth', () => ({
|
||||
withConnectorAuth: (_o: string, h: (r: Request, c: unknown) => Promise<Response>) => (r: Request) =>
|
||||
h(r, { requestId: 't', log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, supabase: {}, key }),
|
||||
}))
|
||||
const hh = vi.hoisted(() => ({ budget: vi.fn(), count: vi.fn(), deletePending: vi.fn(), pending: vi.fn() }))
|
||||
vi.mock('@/lib/connect/hosted/upstream-budget', () => ({ reserveUpstream: (...a: unknown[]) => hh.budget(...a) }))
|
||||
vi.mock('@/lib/connect/hosted/ledger', () => ({ countHeldConnections: (...a: unknown[]) => hh.count(...a), deletePendingConnectionById: (...a: unknown[]) => hh.deletePending(...a), createPendingConnection: (...a: unknown[]) => hh.pending(...a) }))
|
||||
vi.mock('@/lib/connect/hosted/state', () => ({ signConnectorState: () => 'ck1.signed' }))
|
||||
vi.mock('@/lib/connect/upstreams/skatteverket-oauth', () => ({
|
||||
buildSkvAuthorizeUrl: (redirectUri: string, state: string) => `https://skv/authorize?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`,
|
||||
skvDefaultScopes: () => 'moms agd',
|
||||
}))
|
||||
import { POST } from '../route'
|
||||
|
||||
const body = (o: Record<string, unknown> = {}) => ({
|
||||
company_ref: 'company-1', return_url: 'https://bokforing.example.se/cb', state: 'inst', code_challenge: 'a'.repeat(43), ...o,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL = 'https://app.gnubok.se'
|
||||
key = { ...key, scopes: ['skatteverket'], limits: { bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 } }
|
||||
hh.budget.mockResolvedValue({ ok: true })
|
||||
hh.count.mockResolvedValue(0)
|
||||
})
|
||||
|
||||
describe('POST /api/connect/skv/oauth/authorize-url', () => {
|
||||
it('403 without the skatteverket scope', async () => {
|
||||
key = { ...key, scopes: [] }
|
||||
const res = await POST(createMockRequest('/api/connect/skv/oauth/authorize-url', { method: 'POST', body: body() }))
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
it('400 on a return_url off the instance', async () => {
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: body({ return_url: 'https://evil.example.com/cb' }) }))
|
||||
const { status, body: b } = await parseJsonResponse<{ code: string }>(res)
|
||||
expect(status).toBe(400)
|
||||
expect(b.code).toBe('CONNECTOR_REDIRECT_INVALID')
|
||||
})
|
||||
it('403 when the per-company SKV quota is reached', async () => {
|
||||
hh.count.mockResolvedValue(1)
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: body() }))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_QUOTA_EXCEEDED')
|
||||
})
|
||||
it('records a pending row and returns the authorize URL with our redirect + signed state', async () => {
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: body() }))
|
||||
const { status, body: b } = await parseJsonResponse<{ data: { authorize_url: string; redirect_uri: string; connector_state: string } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(hh.pending).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ service: 'skatteverket', companyRef: 'company-1', pendingState: 'ck1.signed' }))
|
||||
expect(b.data.redirect_uri).toBe('https://app.gnubok.se/api/extensions/ext/skatteverket/callback')
|
||||
expect(b.data.connector_state).toBe('ck1.signed')
|
||||
expect(b.data.authorize_url).toContain('state=ck1.signed')
|
||||
})
|
||||
it('429 when the budget is exhausted', async () => {
|
||||
hh.budget.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 60 })
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: body() }))
|
||||
expect(res.status).toBe(429)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
|
||||
import { buildSkvAuthorizeUrl, skvDefaultScopes } from '@/lib/connect/upstreams/skatteverket-oauth'
|
||||
import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget'
|
||||
import { countHeldConnections, createPendingConnection, deletePendingConnectionById } from '@/lib/connect/hosted/ledger'
|
||||
import { signConnectorState } from '@/lib/connect/hosted/state'
|
||||
|
||||
/**
|
||||
* POST /api/connect/skv/oauth/authorize-url
|
||||
*
|
||||
* The instance asks the connector broker to start a Skatteverket BankID
|
||||
* consent for one company. The broker builds the authorize URL against
|
||||
* Arcim's registered SKV client, using OUR registered redirect_uri, with a
|
||||
* signed connector state that carries the instance's own return URL; the SKV
|
||||
* extension callback (on the hosted host) detects that state and bounces the
|
||||
* browser back to the instance with the code. The instance keeps its own PKCE
|
||||
* verifier and later calls /oauth/token with the code.
|
||||
*
|
||||
* Tokens never touch us: the token exchange returns them to the instance,
|
||||
* which stores them (encrypted) in its own database.
|
||||
*/
|
||||
|
||||
const Schema = z.object({
|
||||
company_ref: z.string().min(1).max(200),
|
||||
return_url: z.string().url().max(512),
|
||||
state: z.string().min(1).max(200),
|
||||
code_challenge: z.string().min(16).max(256),
|
||||
scope: z.string().max(400).optional(),
|
||||
})
|
||||
|
||||
function requireScope(ctx: ConnectorContext): NextResponse | null {
|
||||
if (ctx.key.scopes.includes('skatteverket')) return null
|
||||
return NextResponse.json({ error: 'This connector key does not include Skatteverket', code: 'CONNECTOR_SCOPE_MISSING' }, { status: 403 })
|
||||
}
|
||||
|
||||
function hostedRedirectUri(): string {
|
||||
const base = (process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000').replace(/\/+$/, '')
|
||||
return `${base}/api/extensions/ext/skatteverket/callback`
|
||||
}
|
||||
|
||||
function isOnInstance(url: string, instanceUrl: string | null): boolean {
|
||||
if (!instanceUrl) return false
|
||||
try {
|
||||
return new URL(url).origin === new URL(instanceUrl).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withConnectorAuth('connect.skv', async (request, ctx) => {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
const parsed = await validateBody(request, Schema, { log: ctx.log, operation: 'connect.skv.authorize-url' })
|
||||
if (!parsed.success) return parsed.response
|
||||
const { company_ref: cref, return_url, state, code_challenge, scope } = parsed.data
|
||||
|
||||
if (!isOnInstance(return_url, ctx.key.instanceUrl)) {
|
||||
return NextResponse.json({ error: 'return_url must be on the connector key\'s instance', code: 'CONNECTOR_REDIRECT_INVALID' }, { status: 400 })
|
||||
}
|
||||
// Quota with a reservation re-count (same TOCTOU fix as the bank /auth):
|
||||
// pre-count fast-rejects, the pending row reserves, the post-insert
|
||||
// re-count rolls the own row back when concurrent authorizes overshoot.
|
||||
const quotaExceeded = () =>
|
||||
NextResponse.json(
|
||||
{ error: 'Skatteverket connection quota reached for this company', code: 'CONNECTOR_QUOTA_EXCEEDED', limit: ctx.key.limits.skv_connections_per_company },
|
||||
{ status: 403 },
|
||||
)
|
||||
const held = await countHeldConnections(ctx.supabase, ctx.key.id, 'skatteverket', cref)
|
||||
if (held >= ctx.key.limits.skv_connections_per_company) return quotaExceeded()
|
||||
const budget = await reserveUpstream(ctx.supabase, 'skatteverket')
|
||||
if (!budget.ok) {
|
||||
return NextResponse.json({ error: 'Skatteverket connector is busy', code: 'CONNECTOR_RATE_LIMITED' }, { status: 429, headers: { 'Retry-After': String(budget.retryAfterSec) } })
|
||||
}
|
||||
|
||||
const signedState = signConnectorState({ kid: ctx.key.id, svc: 'skv', ret: return_url, st: state, cref })
|
||||
const redirectUri = hostedRedirectUri()
|
||||
const pendingId = await createPendingConnection(ctx.supabase, { keyId: ctx.key.id, service: 'skatteverket', companyRef: cref, provider: 'skatteverket', pendingState: signedState })
|
||||
const heldAfter = await countHeldConnections(ctx.supabase, ctx.key.id, 'skatteverket', cref)
|
||||
if (heldAfter > ctx.key.limits.skv_connections_per_company) {
|
||||
await deletePendingConnectionById(ctx.supabase, pendingId)
|
||||
return quotaExceeded()
|
||||
}
|
||||
|
||||
const authorizeUrl = buildSkvAuthorizeUrl(redirectUri, signedState, { scope: scope || skvDefaultScopes(), codeChallenge: code_challenge })
|
||||
return NextResponse.json({ data: { authorize_url: authorizeUrl, redirect_uri: redirectUri, connector_state: signedState } })
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
let key = {
|
||||
id: 'key-1', orgNumber: 'x', instanceUrl: 'https://i', scopes: ['skatteverket'], status: 'active' as const,
|
||||
currentPeriodEnd: null as string | null, limits: { bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 },
|
||||
}
|
||||
const chain = { update: vi.fn(() => chain), eq: vi.fn(() => chain), then: (r: (v: unknown) => void) => r({ error: null }) }
|
||||
const supabase = { from: vi.fn(() => chain) }
|
||||
vi.mock('@/lib/connect/hosted/with-connector-auth', () => ({
|
||||
withConnectorAuth: (_o: string, h: (r: Request, c: unknown) => Promise<Response>) => (r: Request) =>
|
||||
h(r, { requestId: 't', log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, supabase, key }),
|
||||
}))
|
||||
const hh = vi.hoisted(() => ({
|
||||
budget: vi.fn(),
|
||||
exchange: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
activate: vi.fn(),
|
||||
findPending: vi.fn(),
|
||||
findRefresh: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/connect/hosted/upstream-budget', () => ({ reserveUpstream: (...a: unknown[]) => hh.budget(...a) }))
|
||||
vi.mock('@/lib/connect/upstreams/skatteverket-oauth', () => ({ exchangeSkvCode: (...a: unknown[]) => hh.exchange(...a), refreshSkvToken: (...a: unknown[]) => hh.refresh(...a) }))
|
||||
vi.mock('@/lib/connect/hosted/ledger', () => ({
|
||||
activateByPendingState: (...a: unknown[]) => hh.activate(...a),
|
||||
findPendingByState: (...a: unknown[]) => hh.findPending(...a),
|
||||
findByRefreshHash: (...a: unknown[]) => hh.findRefresh(...a),
|
||||
hashHandle: (s: string) => `h(${s})`,
|
||||
}))
|
||||
vi.mock('@/lib/connect/hosted/state', () => ({
|
||||
verifyConnectorState: (token: string) =>
|
||||
token === 'ck1.signed'
|
||||
? { ok: true, payload: { kid: 'key-1', svc: 'skv', ret: 'https://i/cb', st: 's', cref: 'c1', iat: 0 } }
|
||||
: token === 'ck1.foreign'
|
||||
? { ok: true, payload: { kid: 'key-OTHER', svc: 'skv', ret: 'x', st: 's', cref: 'c', iat: 0 } }
|
||||
: { ok: false, reason: 'malformed' },
|
||||
}))
|
||||
import { POST } from '../route'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
key = { ...key, scopes: ['skatteverket'] }
|
||||
hh.budget.mockResolvedValue({ ok: true })
|
||||
hh.findPending.mockResolvedValue({ id: 'p1', status: 'pending' })
|
||||
hh.activate.mockResolvedValue({ id: 'p1', status: 'active' })
|
||||
hh.findRefresh.mockResolvedValue({ id: 'r1', status: 'active' })
|
||||
})
|
||||
|
||||
describe('POST /api/connect/skv/oauth/token', () => {
|
||||
it('exchanges an authorization code, activates the ledger row, returns tokens', async () => {
|
||||
hh.exchange.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 's' })
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'authorization_code', code: 'c', redirect_uri: 'https://app.gnubok.se/cb', code_verifier: 'v'.repeat(20), connector_state: 'ck1.signed' } }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { access_token: string; refresh_token: string } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({ access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 's' })
|
||||
expect(hh.activate).toHaveBeenCalledWith(supabase, { keyId: 'key-1', pendingState: 'ck1.signed', handle: 'at' })
|
||||
})
|
||||
|
||||
it('refuses an invalid or foreign connector state before spending the client secret', async () => {
|
||||
let res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'authorization_code', code: 'c', redirect_uri: 'https://app.gnubok.se/cb', connector_state: 'garbage' } }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_STATE_INVALID')
|
||||
res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'authorization_code', code: 'c', redirect_uri: 'https://app.gnubok.se/cb', connector_state: 'ck1.foreign' } }))
|
||||
expect(res.status).toBe(403)
|
||||
hh.findPending.mockResolvedValue(null)
|
||||
res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'authorization_code', code: 'c', redirect_uri: 'https://app.gnubok.se/cb', connector_state: 'ck1.signed' } }))
|
||||
expect(res.status).toBe(404)
|
||||
expect(hh.exchange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('withholds tokens with 409 when the state was consumed concurrently', async () => {
|
||||
hh.exchange.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 's' })
|
||||
hh.activate.mockResolvedValue(null)
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'authorization_code', code: 'c', redirect_uri: 'https://app.gnubok.se/cb', connector_state: 'ck1.signed' } }))
|
||||
expect(res.status).toBe(409)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_STATE_CONSUMED')
|
||||
})
|
||||
|
||||
it('refreshes only a refresh token owned by this key (rotates the ledger hashes)', async () => {
|
||||
hh.refresh.mockResolvedValue({ access_token: 'at2', refresh_token: 'rt2', expires_in: 3600, scope: 's' })
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'refresh_token', refresh_token: 'rt' } }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(hh.findRefresh).toHaveBeenCalledWith(supabase, { keyId: 'key-1', refreshHash: 'h(rt)' })
|
||||
expect(supabase.from).toHaveBeenCalledWith('connector_connections')
|
||||
})
|
||||
|
||||
it('404s a refresh token with no active ledger row for this key, never calling upstream', async () => {
|
||||
hh.findRefresh.mockResolvedValue(null)
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'refresh_token', refresh_token: 'stolen-rt' } }))
|
||||
expect(res.status).toBe(404)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_NOT_OWNED')
|
||||
expect(hh.refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 on an invalid grant shape', async () => {
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'client_credentials' } }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('502 when the upstream exchange fails', async () => {
|
||||
hh.exchange.mockRejectedValue(new Error('boom'))
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: { grant_type: 'authorization_code', code: 'c', redirect_uri: 'https://app.gnubok.se/cb', connector_state: 'ck1.signed' } }))
|
||||
expect(res.status).toBe(502)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
|
||||
import { exchangeSkvCode, refreshSkvToken, type SkvTokenResponse } from '@/lib/connect/upstreams/skatteverket-oauth'
|
||||
import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget'
|
||||
import { activateByPendingState, findByRefreshHash, findPendingByState, hashHandle } from '@/lib/connect/hosted/ledger'
|
||||
import { verifyConnectorState } from '@/lib/connect/hosted/state'
|
||||
|
||||
/**
|
||||
* POST /api/connect/skv/oauth/token
|
||||
*
|
||||
* The broker exchanges an authorization code (or refreshes) with Arcim's SKV
|
||||
* client secret and returns the tokens to the instance, which stores them.
|
||||
* The ledger records only the SHA-256 of the access token (and refresh token),
|
||||
* so later data calls can prove the presenting bearer belongs to this key.
|
||||
*
|
||||
* grant_type = authorization_code : { code, redirect_uri, code_verifier?, connector_state }
|
||||
* grant_type = refresh_token : { refresh_token }
|
||||
*/
|
||||
|
||||
const AuthCodeSchema = z.object({
|
||||
grant_type: z.literal('authorization_code'),
|
||||
code: z.string().min(1).max(4096),
|
||||
redirect_uri: z.string().url().max(512),
|
||||
code_verifier: z.string().min(16).max(256).optional(),
|
||||
connector_state: z.string().min(1).max(2048),
|
||||
})
|
||||
const RefreshSchema = z.object({
|
||||
grant_type: z.literal('refresh_token'),
|
||||
refresh_token: z.string().min(1).max(4096),
|
||||
})
|
||||
const Schema = z.discriminatedUnion('grant_type', [AuthCodeSchema, RefreshSchema])
|
||||
|
||||
function requireScope(ctx: ConnectorContext): NextResponse | null {
|
||||
if (ctx.key.scopes.includes('skatteverket')) return null
|
||||
return NextResponse.json({ error: 'This connector key does not include Skatteverket', code: 'CONNECTOR_SCOPE_MISSING' }, { status: 403 })
|
||||
}
|
||||
|
||||
function tokenResponse(t: SkvTokenResponse): NextResponse {
|
||||
return NextResponse.json({
|
||||
data: { access_token: t.access_token, refresh_token: t.refresh_token, expires_in: t.expires_in, scope: t.scope },
|
||||
})
|
||||
}
|
||||
|
||||
export const POST = withConnectorAuth('connect.skv', async (request, ctx) => {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
const parsed = await validateBody(request, Schema, { log: ctx.log, operation: 'connect.skv.token' })
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const budget = await reserveUpstream(ctx.supabase, 'skatteverket')
|
||||
if (!budget.ok) {
|
||||
return NextResponse.json({ error: 'Skatteverket connector is busy', code: 'CONNECTOR_RATE_LIMITED' }, { status: 429, headers: { 'Retry-After': String(budget.retryAfterSec) } })
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.data.grant_type === 'authorization_code') {
|
||||
const { code, redirect_uri, code_verifier, connector_state } = parsed.data
|
||||
// Same binding as the bank /sessions exchange: verified state signature,
|
||||
// this key, skv service, and an existing pending row are preconditions
|
||||
// for the privileged exchange (Arcim's client secret). Without them a
|
||||
// code could be exchanged under a foreign or consumed state and live
|
||||
// tokens handed out with no ledger row proving ownership.
|
||||
const verified = verifyConnectorState(connector_state)
|
||||
if (!verified.ok) {
|
||||
return NextResponse.json({ error: 'Invalid connector state', code: 'CONNECTOR_STATE_INVALID' }, { status: 400 })
|
||||
}
|
||||
if (verified.payload.kid !== ctx.key.id || verified.payload.svc !== 'skv') {
|
||||
return NextResponse.json({ error: 'State does not belong to this key', code: 'CONNECTOR_STATE_INVALID' }, { status: 403 })
|
||||
}
|
||||
const pendingRow = await findPendingByState(ctx.supabase, { keyId: ctx.key.id, pendingState: connector_state })
|
||||
if (!pendingRow) {
|
||||
return NextResponse.json({ error: 'Unknown connection for this key', code: 'CONNECTOR_NOT_OWNED' }, { status: 404 })
|
||||
}
|
||||
const tokens = await exchangeSkvCode(code, redirect_uri, code_verifier)
|
||||
const activated = await activateByPendingState(ctx.supabase, {
|
||||
keyId: ctx.key.id,
|
||||
pendingState: connector_state,
|
||||
handle: tokens.access_token,
|
||||
})
|
||||
if (!activated) {
|
||||
// Consumed concurrently (replay of the same state): never hand out
|
||||
// tokens the ledger cannot vouch for. SKV has no revoke endpoint;
|
||||
// the unreturned pair simply expires unused.
|
||||
ctx.log.warn('skv token exchange raced a consumed state; tokens withheld')
|
||||
return NextResponse.json(
|
||||
{ error: 'Connector state already consumed', code: 'CONNECTOR_STATE_CONSUMED' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
if (tokens.refresh_token) {
|
||||
// The ledger write must take before the tokens leave: a silently
|
||||
// failed update strands a connection the ledger cannot vouch for.
|
||||
const { error: hashError } = await ctx.supabase
|
||||
.from('connector_connections')
|
||||
.update({ refresh_hash: hashHandle(tokens.refresh_token) })
|
||||
.eq('connector_key_id', ctx.key.id)
|
||||
.eq('handle_hash', hashHandle(tokens.access_token))
|
||||
if (hashError) {
|
||||
ctx.log.error('skv ledger refresh-hash write failed; tokens withheld', hashError)
|
||||
return NextResponse.json({ error: 'Ledger update failed', code: 'CONNECTOR_LEDGER_FAILED' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
return tokenResponse(tokens)
|
||||
}
|
||||
|
||||
// refresh: ownership FIRST. The presented refresh token must hash to an
|
||||
// ACTIVE ledger row under the presenting key before the broker spends
|
||||
// Arcim's client secret on it; without this the route was an open refresh
|
||||
// oracle for any leaked refresh token. Then rotate the ledger's handle +
|
||||
// refresh hashes to the new pair. Two literal payloads (no runtime-built
|
||||
// object) so the no-phantom-columns scanner can resolve the columns.
|
||||
const { refresh_token } = parsed.data
|
||||
const oldRefreshHash = hashHandle(refresh_token)
|
||||
const owned = await findByRefreshHash(ctx.supabase, { keyId: ctx.key.id, refreshHash: oldRefreshHash })
|
||||
if (!owned) {
|
||||
return NextResponse.json({ error: 'Unknown connection for this key', code: 'CONNECTOR_NOT_OWNED' }, { status: 404 })
|
||||
}
|
||||
const tokens = await refreshSkvToken(refresh_token)
|
||||
const newHandleHash = tokens.access_token ? hashHandle(tokens.access_token) : null
|
||||
const lastUsedAt = new Date().toISOString()
|
||||
// The rotation write must take before the tokens leave: SKV has already
|
||||
// consumed the old refresh token, so a silently failed update would leave
|
||||
// a ledger that can vouch for neither the old nor the new pair.
|
||||
const { error: rotateError } = tokens.refresh_token
|
||||
? await ctx.supabase
|
||||
.from('connector_connections')
|
||||
.update({ handle_hash: newHandleHash, last_used_at: lastUsedAt, refresh_hash: hashHandle(tokens.refresh_token) })
|
||||
.eq('id', owned.id)
|
||||
.eq('status', 'active')
|
||||
: await ctx.supabase
|
||||
.from('connector_connections')
|
||||
.update({ handle_hash: newHandleHash, last_used_at: lastUsedAt })
|
||||
.eq('id', owned.id)
|
||||
.eq('status', 'active')
|
||||
if (rotateError) {
|
||||
ctx.log.error('skv ledger rotation write failed; tokens withheld', rotateError)
|
||||
return NextResponse.json({ error: 'Ledger update failed', code: 'CONNECTOR_LEDGER_FAILED' }, { status: 502 })
|
||||
}
|
||||
return tokenResponse(tokens)
|
||||
} catch (err) {
|
||||
ctx.log.warn('skv token exchange failed', { err: err instanceof Error ? err.message : String(err) })
|
||||
return NextResponse.json({ error: 'Skatteverket token exchange failed', code: 'CONNECTOR_SKV_TOKEN_FAILED' }, { status: 502 })
|
||||
}
|
||||
})
|
||||
+1
-18
@@ -352,24 +352,7 @@ The cron sidecar calls `/api/connector/sync/cron` hourly (it is listed in `docke
|
||||
curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/connector/sync/cron
|
||||
```
|
||||
|
||||
The bank proxy (`app.gnubok.se/api/connect/bank/*`) is live server-side with this release; the Skatteverket broker and the instance-side client wiring that makes the proxies carry traffic ship in following releases. Until that wiring lands, the key is validated and the grants are written, nothing more.
|
||||
|
||||
### Connector subscription (self-hosted instances)
|
||||
|
||||
Everything a self-hosted instance runs itself is free (AGPL). Four capabilities depend on services only Accounted operates and are therefore gated on a self-host: bank sync (our PSD2/AISP credentials), Skatteverket API submission and skattekonto sync (our API client registration), company lookup (TIC) and migration from Fortnox/Visma/Bokio/Björn Lundén (the migration gateway). A **connector key** unlocks them for every company on the instance; it is priced per active company at parity with hosted and is issued manually by Accounted for now (self-serve later).
|
||||
|
||||
```bash
|
||||
GNUBOK_CONNECTOR_KEY=gnubok_ck_... # issued by Accounted, shown once
|
||||
# GNUBOK_CONNECT_URL=https://app.gnubok.se # default: the hosted connector service
|
||||
```
|
||||
|
||||
The cron sidecar calls `/api/connector/sync/cron` hourly (it is listed in `docker/crontab.self-hosted` only): the instance reports its active company count, the hosted service answers with the key's status and scopes, and the instance writes `capability_grants` rows with `source = 'connector'` that expire after **72 hours** (or three days past the paid period, whichever is sooner). Those rows are the offline cache: a hosted outage shorter than that changes nothing, a revoked or lapsed key freezes the connector capabilities within days, and nothing in the instance phones home for permission to run the bookkeeping. An instance without a key answers `not_configured` and stays unaffected. To run the sync once by hand after pasting the key:
|
||||
|
||||
```bash
|
||||
curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/connector/sync/cron
|
||||
```
|
||||
|
||||
The **bank connector** proxy is live (`app.gnubok.se/api/connect/bank/*`): with `bank_sync` in your key's scopes, the instance connects a bank through Arcim's PSD2 credentials while the bank session id and all transaction data stay in the instance's own database. Skatteverket, company lookup and migration through the connector ship in following releases; until each lands, a key is validated and its grants are written, and the unshipped services stay unconfigured.
|
||||
The **bank** and **Skatteverket** connector proxies are live (`app.gnubok.se/api/connect/bank/*` and `/api/connect/skv/*`): with `bank_sync` / `skatteverket` in your key's scopes, the instance connects a bank through Arcim's PSD2 credentials and files VAT/AGI + syncs skattekonto through Arcim's registered Skatteverket client, while all tokens (the bank session id, the SKV BankID tokens) stay encrypted in the instance's own database. Company lookup and migration through the connector ship in following releases, and so does the instance-side client wiring that makes the bank/Skatteverket clients call the proxies: until that wiring lands, a key is validated and its grants are written, and the services stay unconfigured on the instance. On the instance, Skatteverket still needs `SKATTEVERKET_ENABLED=true` and `SKATTEVERKET_TOKEN_ENCRYPTION_KEY` (the tokens are stored there, so the encryption key is the operator's).
|
||||
|
||||
### Push Notifications
|
||||
|
||||
|
||||
@@ -298,3 +298,39 @@ describe('skatteverket OAuth callback', () => {
|
||||
expect(mockStoreTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Connector branch: a self-hosted instance's SKV consent, started through the
|
||||
// /api/connect/skv broker. The callback must NOT exchange the code here; it
|
||||
// bounces the browser back to the instance with the code + original state.
|
||||
describe('skatteverket OAuth callback: connector branch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.example'
|
||||
process.env.CONNECTOR_STATE_SECRET = 'test-secret'
|
||||
})
|
||||
|
||||
it('redirects a valid connector state back to the instance without exchanging the code', async () => {
|
||||
const { signConnectorState } = await import('@/lib/connect/hosted/state')
|
||||
const signed = signConnectorState({ kid: 'k1', svc: 'skv', ret: 'https://bokforing.example.se/skv/cb', st: 'inst-state', cref: 'company-1' })
|
||||
const route = callbackRoute()
|
||||
const res = await route.handler(callbackRequest(`code=auth-code&state=${encodeURIComponent(signed)}`))
|
||||
expect(res.status).toBe(307)
|
||||
const loc = new URL(res.headers.get('location') as string)
|
||||
expect(loc.origin + loc.pathname).toBe('https://bokforing.example.se/skv/cb')
|
||||
expect(loc.searchParams.get('code')).toBe('auth-code')
|
||||
expect(loc.searchParams.get('state')).toBe('inst-state')
|
||||
expect(loc.searchParams.get('connector_state')).toBe(signed)
|
||||
expect(exchangeCodeForTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a connector state for the wrong service', async () => {
|
||||
const { signConnectorState } = await import('@/lib/connect/hosted/state')
|
||||
const signed = signConnectorState({ kid: 'k1', svc: 'bank', ret: 'https://bokforing.example.se/cb', st: 's', cref: 'c' })
|
||||
const route = callbackRoute()
|
||||
const res = await route.handler(callbackRequest(`code=c&state=${encodeURIComponent(signed)}`))
|
||||
expect(res.status).toBe(307)
|
||||
expect(new URL(res.headers.get('location') as string).searchParams.get('connector_error')).toBe('wrong_service')
|
||||
expect(exchangeCodeForTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
|
||||
import { isConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
|
||||
import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store'
|
||||
import { skvRequest, skvRequestWithAuth, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
|
||||
import { writeSkatteverketAudit } from './lib/audit'
|
||||
@@ -334,6 +335,27 @@ export const skatteverketExtension: Extension = {
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
// Connector branch: a self-hosted instance started this SKV consent
|
||||
// through the /api/connect/skv broker, which registered OUR redirect
|
||||
// uri and a signed connector state. We never exchange the code here
|
||||
// (the instance does, through the broker's /oauth/token): just bounce
|
||||
// the browser back to the instance with the code + its original
|
||||
// state, so no per-instance redirect uri is registered at SKV.
|
||||
if (isConnectorState(state)) {
|
||||
const verified = verifyConnectorState(state as string)
|
||||
if (!verified.ok || verified.payload.svc !== 'skv') {
|
||||
return NextResponse.redirect(`${appUrl}/?connector_error=${encodeURIComponent(verified.ok ? 'wrong_service' : verified.reason)}`)
|
||||
}
|
||||
const ret = new URL(verified.payload.ret)
|
||||
if (error) ret.searchParams.set('error', error)
|
||||
const errorDescription = url.searchParams.get('error_description')
|
||||
if (errorDescription) ret.searchParams.set('error_description', errorDescription)
|
||||
if (code) ret.searchParams.set('code', code)
|
||||
if (verified.payload.st) ret.searchParams.set('state', verified.payload.st)
|
||||
ret.searchParams.set('connector_state', state as string)
|
||||
return NextResponse.redirect(ret.toString())
|
||||
}
|
||||
|
||||
// Injection-safety invariants: appUrl comes from NEXT_PUBLIC_APP_URL
|
||||
// (deployment configuration, never user input), and jsLiteral
|
||||
// JSON-encodes and escapes `<` so embedded values cannot break out of
|
||||
|
||||
@@ -122,6 +122,27 @@ export async function findPendingByState(
|
||||
return (data as LedgerRow | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACTIVE row whose refresh-token hash matches, under the presenting key.
|
||||
* Ownership precondition for the SKV refresh exchange: without it the broker
|
||||
* was an open refresh oracle for any leaked refresh token.
|
||||
*/
|
||||
export async function findByRefreshHash(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; refreshHash: string },
|
||||
): Promise<LedgerRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.select('id, connector_key_id, service, company_ref, provider, account_uids, status')
|
||||
.eq('connector_key_id', params.keyId)
|
||||
.eq('service', 'skatteverket')
|
||||
.eq('refresh_hash', params.refreshHash)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`ledger refresh lookup failed: ${error.message}`)
|
||||
return (data as LedgerRow | null) ?? null
|
||||
}
|
||||
|
||||
/** Activate a pending connection (found by its signed pending_state) with the live handle + accounts. */
|
||||
export async function activateByPendingState(
|
||||
supabase: SupabaseClient,
|
||||
|
||||
@@ -50,7 +50,11 @@ export function extractConnectorKey(request: Request): string | null {
|
||||
* before a path is persisted for metering. Literal route words (sessions,
|
||||
* accounts, balances, aspsps, ...) survive, so the metric keys stay useful.
|
||||
*/
|
||||
const OPAQUE_SEGMENT = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,}|[A-Za-z0-9_-]{20,})$/i
|
||||
// The 10+-digit rule covers Swedish identity numbers riding in SKV data-proxy
|
||||
// paths (personnummer 10/12 digits, orgnr 10, redovisare12 16+orgnr): they are
|
||||
// personal data of the instance's downstream clients and must never rest in
|
||||
// metering. Period segments (YYYYMM, 6 digits) survive for metric granularity.
|
||||
const OPAQUE_SEGMENT = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,}|[A-Za-z0-9_-]{20,}|\d{10,})$/i
|
||||
|
||||
export function redactEndpoint(pathname: string): string {
|
||||
return pathname
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const fetchWithTimeout = vi.fn()
|
||||
vi.mock('@/lib/http/fetch-with-timeout', () => ({
|
||||
fetchWithTimeout: (...a: unknown[]) => fetchWithTimeout(...a),
|
||||
OAUTH_TIMEOUT_MS: 10_000,
|
||||
SKATTEVERKET_EXCHANGE_TIMEOUT_MS: 8_000,
|
||||
}))
|
||||
|
||||
import {
|
||||
buildSkvAuthorizeUrl,
|
||||
exchangeSkvCode,
|
||||
refreshSkvToken,
|
||||
SKV_API_BASES,
|
||||
skvGatewayHeaders,
|
||||
} from '../skatteverket-oauth'
|
||||
|
||||
const ENV = ['SKATTEVERKET_OAUTH_BASE_URL', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_OAUTH2_CLIENT_SECRET', 'SKATTEVERKET_APIGW_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_SECRET'] as const
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('SKATTEVERKET_OAUTH2_CLIENT_ID', 'oauth-client')
|
||||
vi.stubEnv('SKATTEVERKET_OAUTH2_CLIENT_SECRET', 'oauth-secret')
|
||||
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'gw-client')
|
||||
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_SECRET', 'gw-secret')
|
||||
})
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
function tokenResponse(body: Record<string, unknown>, ok = true, status = 200) {
|
||||
return { ok, status, text: async () => JSON.stringify(body), json: async () => body }
|
||||
}
|
||||
|
||||
describe('buildSkvAuthorizeUrl', () => {
|
||||
it('carries the registered client id, the connector state, PKCE and the default scopes', () => {
|
||||
const u = new URL(buildSkvAuthorizeUrl('https://app.gnubok.se/cb', 'ck1.signed', { codeChallenge: 'chal' }))
|
||||
expect(u.searchParams.get('client_id')).toBe('oauth-client')
|
||||
expect(u.searchParams.get('redirect_uri')).toBe('https://app.gnubok.se/cb')
|
||||
expect(u.searchParams.get('state')).toBe('ck1.signed')
|
||||
expect(u.searchParams.get('code_challenge')).toBe('chal')
|
||||
expect(u.searchParams.get('code_challenge_method')).toBe('S256')
|
||||
expect(u.searchParams.get('scope')).toContain('agd')
|
||||
expect(u.searchParams.get('scope')).toContain('agdredovisningperiod')
|
||||
})
|
||||
})
|
||||
|
||||
describe('token exchanges send the client secret and return tokens verbatim', () => {
|
||||
it('authorization_code', async () => {
|
||||
fetchWithTimeout.mockResolvedValue(tokenResponse({ access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 's' }))
|
||||
const t = await exchangeSkvCode('the-code', 'https://app.gnubok.se/cb', 'verifier')
|
||||
expect(t).toEqual({ access_token: 'at', refresh_token: 'rt', expires_in: 3600, scope: 's' })
|
||||
const body = fetchWithTimeout.mock.calls[0][1].body as string
|
||||
expect(body).toContain('grant_type=authorization_code')
|
||||
expect(body).toContain('client_secret=oauth-secret')
|
||||
expect(body).toContain('code_verifier=verifier')
|
||||
})
|
||||
|
||||
it('refresh_token', async () => {
|
||||
fetchWithTimeout.mockResolvedValue(tokenResponse({ access_token: 'at2', refresh_token: 'rt2', expires_in: 3600 }))
|
||||
const t = await refreshSkvToken('rt')
|
||||
expect(t.access_token).toBe('at2')
|
||||
expect(fetchWithTimeout.mock.calls[0][1].body).toContain('grant_type=refresh_token')
|
||||
})
|
||||
|
||||
it('throws on a non-ok token response', async () => {
|
||||
fetchWithTimeout.mockResolvedValue(tokenResponse({ error: 'invalid_grant' }, false, 400))
|
||||
await expect(exchangeSkvCode('c', 'https://x/cb')).rejects.toThrow(/token exchange failed \(400\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('data API allowlist + gateway headers', () => {
|
||||
it('exposes exactly the four backing services', () => {
|
||||
expect(Object.keys(SKV_API_BASES).sort()).toEqual(['agd-inlamning', 'agd-period', 'moms', 'skattekonto'])
|
||||
})
|
||||
it('adds Arcim gateway client credentials + a correlation id', () => {
|
||||
const h = skvGatewayHeaders()
|
||||
expect(h.Client_Id).toBe('gw-client')
|
||||
expect(h.Client_Secret).toBe('gw-secret')
|
||||
expect(h.skv_client_correlation_id).toMatch(/[0-9a-f-]{36}/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { fetchWithTimeout, OAUTH_TIMEOUT_MS, SKATTEVERKET_EXCHANGE_TIMEOUT_MS } from '@/lib/http/fetch-with-timeout'
|
||||
|
||||
/**
|
||||
* Skatteverket OAuth2 (`per`/BankID flow) helpers for the hosted connector
|
||||
* broker. The instance-side extension has its own copy (it runs its own flow
|
||||
* on hosted); this module is the CORE copy the connector proxy uses, so core
|
||||
* does not import from @/extensions/. Same endpoints and client credentials
|
||||
* (SKV registers ONE integrator = Arcim), the difference is only who calls:
|
||||
* here the hosted proxy exchanges the code/refresh on behalf of a self-hosted
|
||||
* instance and returns the tokens for the instance to store.
|
||||
*/
|
||||
|
||||
const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1/per'
|
||||
// AGI needs both agd and agdredovisningperiod; skattekonto needs ska (do not
|
||||
// "clean up"): the exact set is documented in the extension's oauth.ts.
|
||||
const DEFAULT_SCOPES = 'momsdeklaration inkforetag skahmst skattekonto ska agd agdredovisningperiod'
|
||||
|
||||
/**
|
||||
* Every SKV base URL must be https (http only for loopback dev): the OAuth
|
||||
* exchange carries Arcim's client secret and the data calls carry the
|
||||
* gateway Client_Secret, so a plaintext override would ship credentials
|
||||
* unencrypted. Throws so a bad env fails the request, never the build.
|
||||
*/
|
||||
function httpsOnly(raw: string, name: string): string {
|
||||
const url = new URL(raw)
|
||||
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
||||
throw new Error(`${name} must be https: Skatteverket credentials ride in these requests`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export function skvOauthBaseUrl(): string {
|
||||
return httpsOnly(process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL, 'SKATTEVERKET_OAUTH_BASE_URL')
|
||||
}
|
||||
export function skvDefaultScopes(): string {
|
||||
return DEFAULT_SCOPES
|
||||
}
|
||||
function clientId(): string {
|
||||
const v = process.env.SKATTEVERKET_OAUTH2_CLIENT_ID
|
||||
if (!v) throw new Error('SKATTEVERKET_OAUTH2_CLIENT_ID is required')
|
||||
return v
|
||||
}
|
||||
function clientSecret(): string {
|
||||
const v = process.env.SKATTEVERKET_OAUTH2_CLIENT_SECRET
|
||||
if (!v) throw new Error('SKATTEVERKET_OAUTH2_CLIENT_SECRET is required')
|
||||
return v
|
||||
}
|
||||
|
||||
export function buildSkvAuthorizeUrl(
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
options?: { scope?: string; codeChallenge?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId(),
|
||||
response_type: 'code',
|
||||
state,
|
||||
redirect_uri: redirectUri,
|
||||
scope: options?.scope || DEFAULT_SCOPES,
|
||||
})
|
||||
if (options?.codeChallenge) {
|
||||
params.set('code_challenge', options.codeChallenge)
|
||||
params.set('code_challenge_method', 'S256')
|
||||
}
|
||||
return `${skvOauthBaseUrl()}/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
export interface SkvTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string | null
|
||||
expires_in: number
|
||||
scope: string
|
||||
}
|
||||
|
||||
/** Raw token exchange, returned verbatim to the instance (it stores them). */
|
||||
export async function exchangeSkvCode(code: string, redirectUri: string, codeVerifier?: string): Promise<SkvTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId(),
|
||||
client_secret: clientSecret(),
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
})
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
||||
return postToken(body, SKATTEVERKET_EXCHANGE_TIMEOUT_MS, 'Skatteverket token exchange')
|
||||
}
|
||||
|
||||
export async function refreshSkvToken(refreshToken: string): Promise<SkvTokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: clientId(),
|
||||
client_secret: clientSecret(),
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return postToken(body, OAUTH_TIMEOUT_MS, 'Skatteverket token refresh')
|
||||
}
|
||||
|
||||
async function postToken(body: URLSearchParams, timeoutMs: number, description: string): Promise<SkvTokenResponse> {
|
||||
const response = await fetchWithTimeout(
|
||||
`${skvOauthBaseUrl()}/token`,
|
||||
// redirect 'error': a 307/308 would resend client_secret + code/refresh
|
||||
// token to the redirect target.
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: body.toString(), redirect: 'error' },
|
||||
{ timeoutMs, description },
|
||||
)
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`${description} failed (${response.status}): ${text}`)
|
||||
}
|
||||
const data = (await response.json()) as { access_token: string; refresh_token?: string; expires_in?: number; scope?: string }
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token ?? null,
|
||||
expires_in: data.expires_in ?? 3600,
|
||||
scope: data.scope ?? DEFAULT_SCOPES,
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePkcePair(): { verifier: string; challenge: string } {
|
||||
const verifier = crypto.randomBytes(64).toString('base64url')
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Skatteverket data-API base URLs, keyed by the short service segment the
|
||||
* connector proxy exposes. One integrator, several backing APIs; the instance
|
||||
* addresses them as /api/connect/skv/api/<service>/<path>.
|
||||
*/
|
||||
export const SKV_API_BASES: Record<string, () => string> = {
|
||||
moms: () => httpsOnly(process.env.SKATTEVERKET_API_BASE_URL || 'https://api.test.skatteverket.se/momsdeklaration/v1', 'SKATTEVERKET_API_BASE_URL'),
|
||||
skattekonto: () => httpsOnly(process.env.SKATTEVERKET_SKATTEKONTO_API_BASE_URL || 'https://api.test.skatteverket.se/beskattning/skattekonto/v2', 'SKATTEVERKET_SKATTEKONTO_API_BASE_URL'),
|
||||
'agd-inlamning': () => httpsOnly(process.env.SKATTEVERKET_AGD_INLAMNING_API_BASE_URL || 'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1', 'SKATTEVERKET_AGD_INLAMNING_API_BASE_URL'),
|
||||
'agd-period': () => httpsOnly(process.env.SKATTEVERKET_AGD_PERIOD_API_BASE_URL || 'https://api.test.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1', 'SKATTEVERKET_AGD_PERIOD_API_BASE_URL'),
|
||||
}
|
||||
|
||||
function apigwClientId(): string {
|
||||
const v = process.env.SKATTEVERKET_APIGW_CLIENT_ID
|
||||
if (!v) throw new Error('SKATTEVERKET_APIGW_CLIENT_ID is required')
|
||||
return v
|
||||
}
|
||||
function apigwClientSecret(): string {
|
||||
const v = process.env.SKATTEVERKET_APIGW_CLIENT_SECRET
|
||||
if (!v) throw new Error('SKATTEVERKET_APIGW_CLIENT_SECRET is required')
|
||||
return v
|
||||
}
|
||||
|
||||
/** The API-gateway headers Arcim's registered client must add to every SKV data call. */
|
||||
export function skvGatewayHeaders(): Record<string, string> {
|
||||
return {
|
||||
Client_Id: apigwClientId(),
|
||||
Client_Secret: apigwClientSecret(),
|
||||
skv_client_correlation_id: crypto.randomUUID(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user