* feat(connect): wire the SKV extension through the connector broker + data proxy (PR6b-2) In connector mode (GNUBOK_CONNECTOR_KEY set, no own SKV credentials) the Skatteverket extension now routes through the hosted connector stack (#1757) instead of calling Skatteverket directly: - skvRequestWithAuth routes to the data proxy: base URL maps to a service segment (moms/skattekonto/agd-inlamning/agd-period), the user's SKV Bearer moves to X-Connector-Upstream-Authorization, the connector key authenticates the proxy, and the gateway Client_Id/Client_Secret are omitted (the proxy adds Arcim's). Connector-layer 4xx bodies (code CONNECTOR_*) are classified before the SKV-shaped 401/403 sniffing so a broker refusal surfaces operator guidance (check GNUBOK_CONNECTOR_KEY), never APIGW/BankID guidance for knobs the instance does not have. - OAuth: /authorize starts the consent via the broker's authorize-url (persisting its redirect_uri + connector_state), the hosted SKV callback bounces the code back to the instance, and exchangeCodeForTokens / refreshAccessToken exchange through the broker's /oauth/token, unwrapping its { data } envelope. Tokens still rest encrypted on the instance; client_id/client_secret never exist there. - Broker refresh 404 CONNECTOR_NOT_OWNED maps to SESSION_EXPIRED (terminal; reconnect fixes); broker 502 stays a raw error so a transient SKV outage never re-arms the reconnect banner (#1155). - getSkatteverketEnvironment() reports 'prod' in connector mode: the upstream env is hosted's, and the instance's unset defaults would show a false Testmiljo badge on real filings. - System (CCG/ombud) auth is deliberately not brokered: hosted-only, stays direct. Hosted and own-credentials self-hosts are byte-identical: every branch gates on skatteverketConnectorMode(), which is null whenever own SKV credentials exist or no connector key is set. Direct-path tests pin that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRfamAKDqvRNwbjr5XD2VS * fix(connect): classify SKV dead-refresh-token dialects broker-side; forward diagnostic headers; connector-aware gateway guidance Skeptic refutation on PR #2103 (found independently by the correctness and compliance skeptics): the broker's /oauth/token catch-all collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the dominant refresh outcome, per-flow tokens live 65 minutes) into the generic 502 CONNECTOR_SKV_TOKEN_FAILED, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing operations consumed as non-recoverable, crons retrying raw forever. - Broker /oauth/token: re-codes those dialects as 401 CONNECTOR_SKV_REFRESH_DEAD, refresh grant only (invalid_grant on the code exchange means an expired one-shot code and keeps the generic 502). The classifier (isSkvDeadRefreshTokenError) uses the same regex set the extension's direct path classifies with. - Instance dead-token classifier maps CONNECTOR_SKV_REFRESH_DEAD to SESSION_EXPIRED alongside 404 CONNECTOR_NOT_OWNED; the generic 502 stays a raw error so a transient SKV outage never re-arms the reconnect banner. - Data proxy: forwards WWW-Authenticate and x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; body-less gateway rejections carry no other signal). - Instance gateway-refusal guidance is connector-aware: a self-host has no SKATTEVERKET_APIGW_CLIENT_ID and no Utvecklarportalen access, so connector mode points at /api/connector/status and support instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRfamAKDqvRNwbjr5XD2VS * fix(connect): reject redirects on the instance's broker OAuth requests CodeRabbit inline finding (CWE-200): the connector-mode authorize-url and token requests followed redirects by default, so a 307/308 would resend the connector key (and code/refresh token) to the redirect target. redirect 'error', matching the broker's own postToken rule; the token response must only ever come from the broker endpoint itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KRfamAKDqvRNwbjr5XD2VS --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
|
|
// The route is exercised through the extension registration; the connector
|
|
// seam and the paywall gate are mocked so the test pins ONLY the /authorize
|
|
// wiring: what is stored, and where the browser is sent.
|
|
const { mockConnectorMode, mockStartAuth } = vi.hoisted(() => ({
|
|
mockConnectorMode: vi.fn(),
|
|
mockStartAuth: vi.fn(),
|
|
}))
|
|
vi.mock('../lib/connector-mode', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../lib/connector-mode')>()
|
|
return {
|
|
...actual,
|
|
skatteverketConnectorMode: mockConnectorMode,
|
|
startConnectorAuthorization: mockStartAuth,
|
|
}
|
|
})
|
|
|
|
vi.mock('../lib/oauth', () => ({
|
|
buildAuthorizeUrl: vi.fn().mockReturnValue('https://skv.test/authorize?direct=1'),
|
|
generatePkcePair: vi.fn().mockReturnValue({ verifier: 'pkce-v', challenge: 'pkce-c' }),
|
|
exchangeCodeForTokens: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/entitlements/has-capability', () => ({
|
|
requireCapability: vi.fn(async () => null),
|
|
}))
|
|
|
|
import { skatteverketExtension } from '../index'
|
|
import { buildAuthorizeUrl } from '../lib/oauth'
|
|
|
|
function authorizeRoute() {
|
|
const route = skatteverketExtension.apiRoutes?.find(
|
|
(r) => r.method === 'GET' && r.path === '/authorize',
|
|
)
|
|
expect(route, 'GET /authorize must be registered').toBeDefined()
|
|
return route!
|
|
}
|
|
|
|
function makeCtx() {
|
|
const stored: Record<string, string> = {}
|
|
const cleared: string[] = []
|
|
return {
|
|
stored,
|
|
cleared,
|
|
ctx: {
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
supabase: {} as any,
|
|
settings: {
|
|
set: vi.fn(async (key: string, value: string) => {
|
|
stored[key] = value
|
|
}),
|
|
clear: vi.fn(async (key: string) => {
|
|
cleared.push(key)
|
|
}),
|
|
get: vi.fn(async () => null),
|
|
},
|
|
} as any,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.NEXT_PUBLIC_APP_URL = 'https://instans.example.se'
|
|
delete process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL
|
|
})
|
|
|
|
describe('skatteverket /authorize: connector mode', () => {
|
|
beforeEach(() => {
|
|
mockConnectorMode.mockReturnValue({
|
|
baseUrl: 'https://app.hosted.example/api/connect/skv',
|
|
key: 'gnubok_ck_test',
|
|
})
|
|
mockStartAuth.mockResolvedValue({
|
|
authorizeUrl: 'https://peroauth2.test.skatteverket.se/oauth2/v1/per/authorize?broker=1',
|
|
redirectUri: 'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
|
connectorState: 'signed-cs',
|
|
})
|
|
})
|
|
|
|
it('starts the consent through the broker and stores its redirect_uri + connector_state', async () => {
|
|
const { ctx, stored } = makeCtx()
|
|
const res = await authorizeRoute().handler(
|
|
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
|
ctx,
|
|
)
|
|
|
|
expect(res.status).toBe(307)
|
|
expect(res.headers.get('location')).toBe(
|
|
'https://peroauth2.test.skatteverket.se/oauth2/v1/per/authorize?broker=1',
|
|
)
|
|
|
|
expect(mockStartAuth).toHaveBeenCalledWith(
|
|
{ baseUrl: 'https://app.hosted.example/api/connect/skv', key: 'gnubok_ck_test' },
|
|
{
|
|
companyRef: 'company-1',
|
|
// The instance's own callback: where the hosted SKV callback bounces
|
|
// the browser back to.
|
|
returnUrl: 'https://instans.example.se/api/extensions/ext/skatteverket/callback',
|
|
state: stored.oauth_state,
|
|
codeChallenge: 'pkce-c',
|
|
},
|
|
)
|
|
// The BROKER's redirect_uri (what SKV saw) is what the token exchange
|
|
// must repeat, so it replaces the locally computed one.
|
|
expect(stored.oauth_redirect_uri).toBe(
|
|
'https://app.hosted.example/api/extensions/ext/skatteverket/callback',
|
|
)
|
|
expect(stored.oauth_connector_state).toBe('signed-cs')
|
|
expect(stored.oauth_code_verifier).toBe('pkce-v')
|
|
expect(buildAuthorizeUrl).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('answers 502 with operator guidance when the broker refuses, storing no flow state', async () => {
|
|
mockStartAuth.mockRejectedValueOnce(new Error('Connector authorize-url failed (403): quota'))
|
|
const { ctx, stored } = makeCtx()
|
|
const res = await authorizeRoute().handler(
|
|
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
|
ctx,
|
|
)
|
|
|
|
expect(res.status).toBe(502)
|
|
const body = await (res as Response).json()
|
|
expect(body.error).toMatch(/GNUBOK_CONNECTOR_KEY/)
|
|
expect(Object.keys(stored)).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('skatteverket /authorize: direct mode', () => {
|
|
it('builds the authorize URL locally and clears any stale connector state', async () => {
|
|
mockConnectorMode.mockReturnValue(null)
|
|
const { ctx, stored, cleared } = makeCtx()
|
|
const res = await authorizeRoute().handler(
|
|
new Request('https://instans.example.se/api/extensions/ext/skatteverket/authorize'),
|
|
ctx,
|
|
)
|
|
|
|
expect(res.status).toBe(307)
|
|
expect(res.headers.get('location')).toBe('https://skv.test/authorize?direct=1')
|
|
expect(mockStartAuth).not.toHaveBeenCalled()
|
|
expect(stored.oauth_redirect_uri).toBe(
|
|
'https://instans.example.se/api/extensions/ext/skatteverket/callback',
|
|
)
|
|
// A row surviving from a connector-era flow must not leak into a direct
|
|
// exchange.
|
|
expect(cleared).toContain('oauth_connector_state')
|
|
})
|
|
})
|