* 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>
162 lines
8.4 KiB
TypeScript
162 lines
8.4 KiB
TypeScript
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, isSkvDeadRefreshTokenError, 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) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
// Ordinary session expiry must be distinguishable from a transient
|
|
// failure: SKV's per-flow refresh tokens live 65 minutes, so a dead
|
|
// refresh token is the DOMINANT outcome here, and collapsing it into the
|
|
// generic 502 stripped every connector instance of its reconnect flow
|
|
// (raw 500s, no banner, cron retry spam). The instance maps this code to
|
|
// SESSION_EXPIRED; everything else stays the opaque 502 so a transient
|
|
// SKV outage never masquerades as "reconnect needed".
|
|
if (parsed.data.grant_type === 'refresh_token' && isSkvDeadRefreshTokenError(message)) {
|
|
ctx.log.info('skv refresh token expired at upstream', { code: 'CONNECTOR_SKV_REFRESH_DEAD' })
|
|
return NextResponse.json(
|
|
{ error: 'Skatteverket refresh token is no longer valid; a new BankID consent is required', code: 'CONNECTOR_SKV_REFRESH_DEAD' },
|
|
{ status: 401 },
|
|
)
|
|
}
|
|
ctx.log.warn('skv token exchange failed', { err: message })
|
|
return NextResponse.json({ error: 'Skatteverket token exchange failed', code: 'CONNECTOR_SKV_TOKEN_FAILED' }, { status: 502 })
|
|
}
|
|
})
|