* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals Skatteverket connections become per (user, company): the token table carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half migrations), so one connection leaked "connected" onto every company the user belongs to, sync ran the token against the wrong orgnr (behorighet 403), and reconnecting from another company silently moved the row and went dark on the first company's crons. Token reads/writes are now scoped by company through the whole chain (token-store, api-client refresh coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP), /skattekonto/saldo answers 401 NOT_CONNECTED for companies without their own row (which is what the page's startkort keys on), and the dashboard connect-nudge counts only the active company's row. Bookkeeping's pristine start card now keys on all-years emptiness via a count probe instead of "no active filters": the default fiscal-year selection counted as a filter, which made the card unreachable on brand-new companies (it showed "inga traffar" instead). Two browser-global localStorage keys become company-scoped with legacy fallbacks: the inbox onboarding dismissal (dismissing on one company hid the card everywhere) and the periodisering auto-detect toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scoping): dedupe cron work per (user, company) + guard the ledger probe CodeRabbit findings on #1610: the skattekonto sync cron still deduped token rows by user_id alone, which would drop every company but one for multi-company operators (the exact scenario the PR fixes); and the all-years ledger probe could leave a stale false behind on a failed refetch, letting the pristine card render unconfirmed. The probe now resets to unknown in flight and carries the fetch generation guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
235 lines
7.9 KiB
TypeScript
235 lines
7.9 KiB
TypeScript
import crypto from 'crypto'
|
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
|
import { createLogger } from '@/lib/logger'
|
|
import type { SkatteverketTokens } from '../types'
|
|
import { SkatteverketAuthError } from './api-client'
|
|
|
|
const log = createLogger('skatteverket-token-store')
|
|
|
|
/**
|
|
* Encrypted token storage for Skatteverket OAuth2 tokens.
|
|
*
|
|
* Uses AES-256-GCM with a dedicated encryption key (not the Supabase service
|
|
* role key) to encrypt tokens at rest in the skatteverket_tokens table.
|
|
*
|
|
* Pattern mirrors lib/auth/oauth-codes.ts but adapted for persistent storage.
|
|
*
|
|
* All DB operations route through a service-role client. The original RLS
|
|
* design (auth.uid() = user_id) is correct, but at least one deployed
|
|
* environment is missing the INSERT/UPDATE/DELETE policies and the
|
|
* UNIQUE(user_id) constraint, so user-session writes get rejected. The
|
|
* service-role client bypasses RLS, and the calling handlers (the OAuth
|
|
* callback in particular) verify the user identity via cookies before
|
|
* passing user_id here, so the access-control invariant is upheld at the
|
|
* application layer.
|
|
*/
|
|
|
|
let _serviceClient: SupabaseClient | null = null
|
|
function getServiceClient(): SupabaseClient {
|
|
if (_serviceClient) return _serviceClient
|
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
if (!url || !key) {
|
|
throw new Error('skatteverket token-store requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY')
|
|
}
|
|
_serviceClient = createClient(url, key, { auth: { persistSession: false } })
|
|
return _serviceClient
|
|
}
|
|
|
|
const ALGORITHM = 'aes-256-gcm'
|
|
|
|
function getEncryptionKey(): Buffer {
|
|
const key = process.env.SKATTEVERKET_TOKEN_ENCRYPTION_KEY
|
|
if (!key) throw new Error('SKATTEVERKET_TOKEN_ENCRYPTION_KEY is required')
|
|
return crypto.createHash('sha256').update(key).digest()
|
|
}
|
|
|
|
function encrypt(plaintext: string): string {
|
|
const key = getEncryptionKey()
|
|
const iv = crypto.randomBytes(12)
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
const tag = cipher.getAuthTag()
|
|
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
|
}
|
|
|
|
function decrypt(ciphertext: string): string {
|
|
const key = getEncryptionKey()
|
|
const combined = Buffer.from(ciphertext, 'base64url')
|
|
const iv = combined.subarray(0, 12)
|
|
const tag = combined.subarray(12, 28)
|
|
const encrypted = combined.subarray(28)
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
|
decipher.setAuthTag(tag)
|
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
|
}
|
|
|
|
/**
|
|
* Store (replace) Skatteverket tokens for a user + company pair.
|
|
* Both access_token and refresh_token are encrypted at rest.
|
|
*
|
|
* Connections are per (user_id, company_id): the OAuth token is the person's
|
|
* BankID session, but which company it is wired to is an explicit product
|
|
* choice, and a multi-company operator holds one row per company. The
|
|
* DELETE + INSERT (instead of UPSERT) predates the UNIQUE constraint and is
|
|
* safe because OAuth callbacks and refreshes for a given pair are coalesced.
|
|
*/
|
|
export async function storeTokens(
|
|
_supabase: SupabaseClient,
|
|
userId: string,
|
|
tokens: SkatteverketTokens,
|
|
companyId: string,
|
|
): Promise<void> {
|
|
const encryptedAccess = encrypt(tokens.access_token)
|
|
const encryptedRefresh = tokens.refresh_token ? encrypt(tokens.refresh_token) : null
|
|
const db = getServiceClient()
|
|
|
|
const { error: deleteError } = await db
|
|
.from('skatteverket_tokens')
|
|
.delete()
|
|
.eq('user_id', userId)
|
|
.eq('company_id', companyId)
|
|
if (deleteError) throw new Error(`Failed to clear existing tokens: ${deleteError.message}`)
|
|
|
|
const { error: insertError } = await db.from('skatteverket_tokens').insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
access_token: encryptedAccess,
|
|
refresh_token: encryptedRefresh,
|
|
expires_at: new Date(tokens.expires_at).toISOString(),
|
|
refresh_count: tokens.refresh_count,
|
|
scope: tokens.scope,
|
|
})
|
|
if (insertError) throw new Error(`Failed to store tokens: ${insertError.message}`)
|
|
}
|
|
|
|
/**
|
|
* Retrieve and decrypt Skatteverket tokens for a user.
|
|
* Returns null if no tokens are stored.
|
|
*/
|
|
export async function getTokens(
|
|
_supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string
|
|
): Promise<SkatteverketTokens | null> {
|
|
const db = getServiceClient()
|
|
const { data, error } = await db
|
|
.from('skatteverket_tokens')
|
|
.select('access_token, refresh_token, expires_at, refresh_count, scope')
|
|
.eq('user_id', userId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
|
|
if (error || !data) return null
|
|
|
|
// Distinguish three states:
|
|
// 1. No row → caller treats as NOT_CONNECTED (return null above)
|
|
// 2. Decryption error → log + throw TOKEN_CORRUPTED so the caller can
|
|
// tell the user to reconnect. Previously returned null silently
|
|
// which masked the real problem (key rotation, tampering, or a
|
|
// schema-level bug) as "not connected".
|
|
try {
|
|
return {
|
|
access_token: decrypt(data.access_token),
|
|
refresh_token: data.refresh_token ? decrypt(data.refresh_token) : null,
|
|
expires_at: new Date(data.expires_at).getTime(),
|
|
refresh_count: data.refresh_count ?? 0,
|
|
scope: data.scope,
|
|
}
|
|
} catch (err) {
|
|
log.error('decryption failed for stored tokens', {
|
|
userId,
|
|
companyId,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
throw new SkatteverketAuthError(
|
|
'Tokens kunde inte läsas. Anslut igen med BankID.',
|
|
'TOKEN_CORRUPTED'
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete stored tokens (disconnect from Skatteverket).
|
|
*/
|
|
export async function deleteTokens(
|
|
_supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string
|
|
): Promise<void> {
|
|
const db = getServiceClient()
|
|
await db
|
|
.from('skatteverket_tokens')
|
|
.delete()
|
|
.eq('user_id', userId)
|
|
.eq('company_id', companyId)
|
|
}
|
|
|
|
/**
|
|
* Terminal auth-error codes: the stored token can never recover on its own —
|
|
* only a fresh BankID consent fixes it. SESSION_EXPIRED qualifies because
|
|
* SKV's `per`-flow refresh tokens live 65 minutes: once expired there is
|
|
* nothing left to refresh with.
|
|
*/
|
|
export const RECONSENT_ERROR_CODES = [
|
|
'SESSION_EXPIRED',
|
|
'REFRESH_EXHAUSTED',
|
|
'MISSING_SCOPE',
|
|
'TOKEN_CORRUPTED',
|
|
] as const
|
|
|
|
/**
|
|
* Flag a connection as needing re-consent so crons stop retrying it every
|
|
* night and the UI can prompt proactively. storeTokens() (delete + insert)
|
|
* resets the row to status 'active' on the next successful consent.
|
|
* Best-effort: health bookkeeping must never mask the original auth error.
|
|
*/
|
|
export async function markNeedsReconsent(
|
|
_supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
errorCode: string,
|
|
): Promise<void> {
|
|
const db = getServiceClient()
|
|
const { error } = await db
|
|
.from('skatteverket_tokens')
|
|
.update({
|
|
status: 'needs_reconsent',
|
|
last_error_code: errorCode,
|
|
last_error_at: new Date().toISOString(),
|
|
})
|
|
.eq('user_id', userId)
|
|
.eq('company_id', companyId)
|
|
if (error) {
|
|
log.warn('failed to mark token row needs_reconsent', {
|
|
userId,
|
|
errorCode,
|
|
error: error.message,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the persisted connection health for a user's token row.
|
|
* Returns null when no row exists (not connected).
|
|
*/
|
|
export async function getTokenHealth(
|
|
_supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
): Promise<{ status: string; last_error_code: string | null; last_error_at: string | null } | null> {
|
|
const db = getServiceClient()
|
|
const { data, error } = await db
|
|
.from('skatteverket_tokens')
|
|
.select('status, last_error_code, last_error_at')
|
|
.eq('user_id', userId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (error || !data) return null
|
|
return {
|
|
status: (data.status as string | null) ?? 'active',
|
|
last_error_code: (data.last_error_code as string | null) ?? null,
|
|
last_error_at: (data.last_error_at as string | null) ?? null,
|
|
}
|
|
}
|