From c40e1114ca3e18dd9bdc45a1e01a1af0a6b10af9 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 10 May 2026 14:53:11 +0200 Subject: [PATCH] Bug/skv connection (#431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(invoices): implement öresavrundning logic and next invoice number preview - Added `getDisplayTotal` utility to handle rounding for SEK invoices based on company settings. - Updated `InvoicesPage` to utilize the new rounding logic when displaying totals. - Introduced `peek_next_invoice_number` function to allow previewing the next invoice number without consuming the sequence. - Modified invoice number generation to remove the year prefix and prevent truncation of numbers exceeding three digits. - Enhanced tests for invoice number generation and rounding functionality to ensure correctness. - Updated PDF template to reflect new rounding logic for totals and display appropriate values. - Adjusted company switcher to hide options in sandbox mode. - Improved error handling and logging in sandbox seeding process. * fix(skatteverket): remove unused scope labels from SCOPE_LABELS and DEFAULT_SCOPES * feat(skatteverket): add token revocation handling and disconnect functionality * feat(skatteverket): implement PKCE support for OAuth2 flow to enhance security --- extensions/general/skatteverket/index.ts | 31 ++++++++++++++++---- extensions/general/skatteverket/lib/oauth.ts | 31 ++++++++++++++++++-- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index b8b79913..7f29a34f 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -2,7 +2,7 @@ import crypto from 'crypto' import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' import { TimeoutError } from '@/lib/http/fetch-with-timeout' -import { buildAuthorizeUrl, exchangeCodeForTokens } from './lib/oauth' +import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth' import { storeTokens, getTokens, deleteTokens } from './lib/token-store' import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client' import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers' @@ -109,13 +109,21 @@ export const skatteverketExtension: Extension = { ? requestedReturn : null + // Generate PKCE pair — verifier persisted server-side, challenge sent + // to SKV. Some SKV per-flow client configurations issue revoked-on-use + // tokens unless PKCE is present, so we always send it. + const pkce = generatePkcePair() + // Store state for CSRF validation in callback await ctx.settings.set('oauth_state', state) await ctx.settings.set('oauth_redirect_uri', redirectUri) + await ctx.settings.set('oauth_code_verifier', pkce.verifier) if (returnTo) await ctx.settings.set('oauth_return_to', returnTo) else await ctx.settings.set('oauth_return_to', null) - const authorizeUrl = buildAuthorizeUrl(redirectUri, state) + const authorizeUrl = buildAuthorizeUrl(redirectUri, state, { + codeChallenge: pkce.challenge, + }) return NextResponse.redirect(authorizeUrl) }, @@ -251,6 +259,19 @@ export const skatteverketExtension: Extension = { const redirectUri = redirectData?.value || `${appUrl}/api/extensions/ext/skatteverket/callback` + // Retrieve the PKCE verifier stored in /authorize. Optional only for + // backward compatibility with in-flight flows that started before the + // PKCE rollout — once those drain, this can be made required. + const { data: verifierData } = await supabase + .from('extension_data') + .select('value') + .eq('company_id', companyId) + .eq('extension_id', 'skatteverket') + .eq('key', 'oauth_code_verifier') + .maybeSingle() + + const codeVerifier = (verifierData?.value as string | null) || undefined + // Optional in-app destination set by /authorize?return_to=... const { data: returnToData } = await supabase .from('extension_data') @@ -270,16 +291,16 @@ export const skatteverketExtension: Extension = { : `/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}` try { - const tokens = await exchangeCodeForTokens(code, redirectUri) + const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier) await storeTokens(supabase, user.id, tokens, companyId) - // Clean up CSRF state + the one-shot return_to. + // Clean up CSRF state + the one-shot return_to + PKCE verifier. await supabase .from('extension_data') .delete() .eq('company_id', companyId) .eq('extension_id', 'skatteverket') - .in('key', ['oauth_state', 'oauth_return_to']) + .in('key', ['oauth_state', 'oauth_return_to', 'oauth_code_verifier']) return respondWithSuccess(successPath) } catch (err) { diff --git a/extensions/general/skatteverket/lib/oauth.ts b/extensions/general/skatteverket/lib/oauth.ts index 8f6d62ec..ef29acb8 100644 --- a/extensions/general/skatteverket/lib/oauth.ts +++ b/extensions/general/skatteverket/lib/oauth.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto' import type { SkatteverketTokens } from '../types' import { fetchWithTimeout, @@ -40,6 +41,24 @@ function getClientSecret(): string { return secret } +/** + * Generate a PKCE verifier/challenge pair (RFC 7636, S256 method). + * + * SKV's per flow accepts (and on some test client configurations *requires*) + * PKCE. Without a code_challenge SKV may issue tokens that downstream APIs + * (notably the AGI APIGW) reject as revoked when called — even though the + * initial token exchange succeeds. Always sending PKCE is safe regardless + * of whether SKV strictly requires it. + * + * Verifier: 64 random bytes → base64url → 86 chars (within RFC 7636's + * 43–128 range). Challenge: SHA-256 of the verifier, base64url-encoded. + */ +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 } +} + /** * Build the Skatteverket OAuth2 authorization URL. * User is redirected here to authenticate with BankID. @@ -47,7 +66,7 @@ function getClientSecret(): string { export function buildAuthorizeUrl( redirectUri: string, state: string, - scope?: string + options?: { scope?: string; codeChallenge?: string } ): string { const base = getOAuthBaseUrl() const params = new URLSearchParams({ @@ -55,8 +74,12 @@ export function buildAuthorizeUrl( response_type: 'code', state, redirect_uri: redirectUri, - scope: scope || DEFAULT_SCOPES, + scope: options?.scope || DEFAULT_SCOPES, }) + if (options?.codeChallenge) { + params.set('code_challenge', options.codeChallenge) + params.set('code_challenge_method', 'S256') + } return `${base}/authorize?${params.toString()}` } @@ -66,7 +89,8 @@ export function buildAuthorizeUrl( */ export async function exchangeCodeForTokens( code: string, - redirectUri: string + redirectUri: string, + codeVerifier?: string, ): Promise { const base = getOAuthBaseUrl() @@ -77,6 +101,7 @@ export async function exchangeCodeForTokens( redirect_uri: redirectUri, code, }) + if (codeVerifier) body.set('code_verifier', codeVerifier) const response = await fetchWithTimeout( `${base}/token`,