* 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>
133 lines
6.2 KiB
TypeScript
133 lines
6.2 KiB
TypeScript
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 })
|
|
// Forward SKV's diagnostic headers: WWW-Authenticate carries OAuth's
|
|
// machine-readable failure reason (the instance's insufficient_scope →
|
|
// MISSING_SCOPE classification depends on it), and the x-skv-*/x-amzn-*/
|
|
// x-api-* families are the only signal on body-less gateway rejections.
|
|
// Nothing secret rides in them; stripping them blinded the instance's
|
|
// 401 classifier (skeptic finding on PR6b-2).
|
|
const headers: Record<string, string> = { 'Content-Type': res.headers.get('content-type') ?? 'application/json' }
|
|
res.headers.forEach((v, k) => {
|
|
const lk = k.toLowerCase()
|
|
if (lk === 'www-authenticate' || lk.startsWith('x-skv-') || lk.startsWith('x-amzn-') || lk.startsWith('x-api-')) {
|
|
headers[k] = v
|
|
}
|
|
})
|
|
return new NextResponse(text, { status: res.status, headers })
|
|
} 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)
|