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:
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user