Bug/skv connection (#431)

* 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
This commit is contained in:
Mattsson
2026-05-10 14:53:11 +02:00
committed by GitHub
parent d7a7ddae2c
commit c40e1114ca
2 changed files with 54 additions and 8 deletions
+26 -5
View File
@@ -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) {
+28 -3
View File
@@ -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<SkatteverketTokens> {
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`,