* fix(supabase): stop server clients leaking a 30s refresh ticker per request
`autoRefreshToken` defaults to true in supabase-js, and off-browser
@supabase/auth-js starts the refresh ticker unconditionally:
// in non-browser environments the refresh token ticker runs always
this.startAutoRefresh()
That is a setInterval firing every 30 s. It calls unref(), so the process
still exits, tests pass, and Vercel never notices because the process is
torn down long before the tickers accumulate. But unref() does not make a
timer collectable: it stays registered in the event loop and remains a GC
root for its callback, which closes over the GoTrueClient, the
SupabaseClient, and the whole request scope around it.
A long-running self-hosted instance therefore leaks one timer plus one
entire request graph (socket, IncomingMessage, ServerResponse, headers,
route context: ~100 kB) per client constructed. One died of "JavaScript
heap out of memory" after 42 h, the last 24 of them completely idle. The
heap snapshot showed 445 retained request graphs and ~1050 Timeouts in
the 30 000 ms bucket, retained via `autoRefreshTicker`, and the rate
matched the traffic exactly: the Docker healthcheck polls /api/health
every 30 s and the webhook dispatch cron runs every minute, so
3 clients/min x 148 min = 444.
- new lib/supabase/service-client.ts: createServiceRoleClient() applies
SERVER_AUTH_OPTIONS, spread LAST so a caller passing its own auth block
cannot re-enable the ticker
- 22 call sites migrated; only booking-templates/sync/cron had ever
passed the options itself
- guard 9 in no-new-antipatterns.mjs fails CI on any new value import of
supabase-js's createClient outside the wrapper; type-only imports are
fine. Verified to fail on a deliberate regression and pass once fixed
- browser clients untouched: a signed-in tab genuinely needs the refresh,
and lib/supabase/client.ts is built on createBrowserClient anyway
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(checks): catch namespace imports in the leaky-supabase-client guard
The guard only matched named imports, so
import * as sb from '@supabase/supabase-js'
sb.createClient(url, key)
reached createClient through member access without ever naming it, and
passed. Verified against the real script before and after: the shape is
flagged now, and `import type * as sb` still passes.
Namespace value imports are treated as leaky outright rather than tracking
member access, which keeps the check a regex over source text with no new
dependency.
Review also suggested excluding *.test.tsx alongside *.test.ts. Skipped: the
repo has no .test.tsx files, and all four sibling checks in this file use
`.test.ts`. Diverging in one of them would read as an accident; if such files
appear, all four should change together.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
236 lines
8.0 KiB
TypeScript
236 lines
8.0 KiB
TypeScript
import crypto from 'crypto'
|
|
import { type SupabaseClient } from '@supabase/supabase-js'
|
|
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
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 = createServiceRoleClient(url, key)
|
|
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,
|
|
}
|
|
}
|