The image is built once with sentinel values
(ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__) that
docker-entrypoint.sh seds into .next at container start. Comparing a flag in
place defeats that: the bundler inlines the sentinel, the minifier folds
"__NEXT_PUBLIC_SELF_HOSTED__" === 'true' to false and eliminates the branch, so
both the variable name and the sentinel disappear and sed has nothing left to
replace. The flag is then permanently false whatever the operator configures.
Diagnosed against a running self-hosted instance: the compiled gate read
function r(){return"true"!==process.env.FORCE_PAYWALL
&&"true"===process.env.DISABLE_PAYWALL}
with the isSelfHosted() branch gone. The un-prefixed FORCE_PAYWALL /
DISABLE_PAYWALL survived precisely because they are never inlined, and
NODE_ENV === 'development' was folded away by the same mechanism. The one
place the flag still worked, getSessionTimeoutConfig(env = process.env), reads
it off a parameter the bundler cannot fold.
Consequence: every Docker self-host ran with the entitlement paywall live, so
ai, bank_sync, skatteverket and email_send went dark 30 days after company
creation when the seeded trial grants expired. Nothing surfaced it, because
dev and the Vercel build both have real env values and never reproduce it.
Analytics, forced MFA, BankID and the hosted upload ceiling read the same flag
and were wrong in the same direction.
Flags are now read as values through lib/env/public-flags, which keeps the
sentinel in the output as a live string literal and defers the comparison to
runtime. flagEnabled uses a Set lookup rather than ===, which a minifier could
fold if it ever inlined the helper.
Guarded twice, because the source fix alone would not have caught this:
- check:guards folded-public-flag fails any in-place NEXT_PUBLIC_* comparison
(AST, no baseline, verified to fire on a probe file);
- docker-publish asserts the sentinels survive the built image, which is the
only artifact where the failure is observable.
npm test 14999 passed, npm run lint 0 errors, npm run check:guards clean.
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
142 lines
5.0 KiB
TypeScript
142 lines
5.0 KiB
TypeScript
/**
|
|
* BankID authentication helpers.
|
|
*
|
|
* BankID is only available on the hosted deployment (requires TIC Identity API).
|
|
* Self-hosted deployments never show the BankID option.
|
|
*/
|
|
|
|
import crypto from 'crypto'
|
|
import { flagEnabled, isSelfHosted } from '@/lib/env/public-flags'
|
|
|
|
const ALGORITHM = 'aes-256-gcm'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Feature flag
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function isBankIdEnabled(): boolean {
|
|
if (isSelfHosted()) return false
|
|
return flagEnabled(process.env.NEXT_PUBLIC_BANKID_ENABLED)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Personnummer hashing (for lookup)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** SHA-256 hash of a personnummer for fast DB lookup. */
|
|
export function hashPersonalNumber(personalNumber: string): string {
|
|
return crypto.createHash('sha256').update(personalNumber).digest('hex')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Personnummer encryption (for display in settings)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getEncryptionKey(): Buffer {
|
|
const key = process.env.BANKID_ENCRYPTION_KEY
|
|
if (!key) throw new Error('BANKID_ENCRYPTION_KEY is required for BankID operations')
|
|
return Buffer.from(key, 'hex')
|
|
}
|
|
|
|
/** AES-256-GCM encrypt a personnummer for storage. */
|
|
export function encryptPersonalNumber(personalNumber: string): Buffer {
|
|
const key = getEncryptionKey()
|
|
const iv = crypto.randomBytes(12)
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
|
|
const encrypted = Buffer.concat([cipher.update(personalNumber, 'utf8'), cipher.final()])
|
|
const tag = cipher.getAuthTag()
|
|
|
|
// Format: iv (12) + tag (16) + ciphertext
|
|
return Buffer.concat([iv, tag, encrypted])
|
|
}
|
|
|
|
/** AES-256-GCM decrypt a stored personnummer. */
|
|
export function decryptPersonalNumber(data: Buffer): string {
|
|
const key = getEncryptionKey()
|
|
|
|
const iv = data.subarray(0, 12)
|
|
const tag = data.subarray(12, 28)
|
|
const encrypted = data.subarray(28)
|
|
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
|
decipher.setAuthTag(tag)
|
|
|
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Storage codec (bankid_identities.personal_number_enc)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Encrypt a personnummer and encode it for a PostgREST bytea insert.
|
|
*
|
|
* Passing a raw Buffer to supabase-js serializes it as JSON
|
|
* ('{"type":"Buffer","data":[...]}'), storing that literal text in the
|
|
* column instead of the bytes. PostgREST's bytea input format is a
|
|
* '\x'-prefixed hex string, which is what this returns.
|
|
*/
|
|
export function encryptPersonalNumberForStorage(personalNumber: string): string {
|
|
return '\\x' + encryptPersonalNumber(personalNumber).toString('hex')
|
|
}
|
|
|
|
/** Legacy shape written by supabase-js Buffer serialization before 2026-07. */
|
|
type SerializedBuffer = { type: 'Buffer'; data: number[] }
|
|
|
|
function isSerializedBuffer(value: unknown): value is SerializedBuffer {
|
|
return (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
(value as SerializedBuffer).type === 'Buffer' &&
|
|
Array.isArray((value as SerializedBuffer).data)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Decode and decrypt a personal_number_enc value as read back through
|
|
* PostgREST (a '\x'-prefixed hex string) or a raw Buffer.
|
|
*
|
|
* Tolerates rows written before migration 20260727170000, where the column
|
|
* holds the UTF-8 text of a JSON-serialized Buffer rather than the raw
|
|
* iv|tag|ciphertext bytes.
|
|
*/
|
|
export function decryptStoredPersonalNumber(stored: string | Buffer | SerializedBuffer): string {
|
|
let raw: Buffer
|
|
if (Buffer.isBuffer(stored)) {
|
|
raw = stored
|
|
} else if (typeof stored === 'string') {
|
|
raw = stored.startsWith('\\x')
|
|
? Buffer.from(stored.slice(2), 'hex')
|
|
: Buffer.from(stored, 'utf8')
|
|
} else if (isSerializedBuffer(stored)) {
|
|
raw = Buffer.from(stored.data)
|
|
} else {
|
|
throw new Error('Unsupported personal_number_enc value')
|
|
}
|
|
|
|
// Legacy JSON-serialized Buffer stored as text: unwrap to the real bytes.
|
|
if (raw[0] === 0x7b) {
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw.toString('utf8'))
|
|
if (isSerializedBuffer(parsed)) raw = Buffer.from(parsed.data)
|
|
} catch {
|
|
// Not JSON after all: treat as raw ciphertext.
|
|
}
|
|
}
|
|
|
|
return decryptPersonalNumber(raw)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Display helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Mask a personnummer for display: "XXXXXXXX-1234" */
|
|
export function maskPersonalNumber(personalNumber: string): string {
|
|
if (personalNumber.length < 4) return '****'
|
|
const last4 = personalNumber.slice(-4)
|
|
const masked = personalNumber.length === 12 ? 'XXXXXXXX' : 'XXXXXX'
|
|
return `${masked}-${last4}`
|
|
}
|