import crypto from 'crypto'; import { getDb } from './db.js'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import { assertSafeUrl } from './ssrf.js'; const SESSION_COOKIE = 'enkelbudget_session'; const TRANSACTION_COOKIE = 'enkelbudget_auth_tx'; const discoveryCache = new Map(); const jwksCache = new Map(); function getJwks(jwksUri) { if (!jwksCache.has(jwksUri)) { jwksCache.set(jwksUri, createRemoteJWKSet(new URL(jwksUri))); } return jwksCache.get(jwksUri); } function base64url(input) { return Buffer.from(input) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } function sha256base64url(input) { return crypto.createHash('sha256').update(input).digest('base64url'); } function parseList(value) { return String(value || '') .split(',') .map(item => item.trim()) .filter(Boolean); } function parseCookies(header) { const cookies = {}; for (const part of String(header || '').split(';')) { const index = part.indexOf('='); if (index < 0) continue; const name = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); cookies[name] = decodeURIComponent(value); } return cookies; } function signValue(value, secret) { return crypto.createHmac('sha256', secret).update(value).digest('base64url'); } function encodeSignedValue(value, secret) { return `${value}.${signValue(value, secret)}`; } function decodeSignedValue(raw, secret) { if (!raw) return null; const index = raw.lastIndexOf('.'); if (index < 0) return null; const value = raw.slice(0, index); const signature = raw.slice(index + 1); const expected = signValue(value, secret); const signatureBuffer = Buffer.from(signature); const expectedBuffer = Buffer.from(expected); if (signatureBuffer.length !== expectedBuffer.length) return null; if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) return null; return value; } function serializeCookie(name, value, options = {}) { const parts = [`${name}=${encodeURIComponent(value)}`]; if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`); if (options.httpOnly !== false) parts.push('HttpOnly'); parts.push(`Path=${options.path || '/'}`); parts.push(`SameSite=${options.sameSite || 'Lax'}`); if (options.secure) parts.push('Secure'); return parts.join('; '); } function normalizeIssuer(value) { return String(value || '').trim().replace(/\/+$/, ''); } function normalizeAbsoluteUrl(value) { const raw = String(value || '').trim(); if (!raw) return ''; if (raw.startsWith('ttps://')) return `h${raw}`; if (raw.startsWith('tps://')) return `ht${raw}`; return raw; } function getEnvAuthDefaults() { const issuer = normalizeIssuer(process.env.OIDC_ISSUER); const discoveryUrl = String(process.env.OIDC_DISCOVERY_URL || '').trim() || (issuer ? `${issuer}/.well-known/openid-configuration` : ''); const clientId = String(process.env.OIDC_CLIENT_ID || '').trim(); const clientSecret = String(process.env.OIDC_CLIENT_SECRET || '').trim(); const appBaseUrl = normalizeIssuer(process.env.APP_BASE_URL); const redirectUri = normalizeAbsoluteUrl(process.env.OIDC_REDIRECT_URI) || (appBaseUrl ? `${appBaseUrl}/auth/callback` : ''); const sessionSecret = String(process.env.AUTH_SESSION_SECRET || process.env.SESSION_SECRET || clientSecret).trim(); return { enabled: Boolean(issuer && clientId && clientSecret && redirectUri && sessionSecret), issuer, discovery_url: discoveryUrl, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, scope: String(process.env.OIDC_SCOPE || 'openid profile email groups').trim(), session_secret: sessionSecret, session_ttl_hours: Math.max(1, parseInt(process.env.AUTH_SESSION_TTL_HOURS || '12', 10) || 12), allowed_groups: String(process.env.OIDC_ALLOWED_GROUPS || '').trim(), allowed_emails: String(process.env.OIDC_ALLOWED_EMAILS || '').trim(), allowed_domains: String(process.env.OIDC_ALLOWED_DOMAINS || '').trim(), }; } function normalizeStoredSettings(input = {}, fallback = {}) { const issuer = normalizeIssuer(input.issuer ?? fallback.issuer); const discoveryUrl = String((input.discovery_url ?? fallback.discovery_url) || '').trim() || (issuer ? `${issuer}/.well-known/openid-configuration` : ''); const clientId = String((input.client_id ?? fallback.client_id) || '').trim(); const clientSecret = String((input.client_secret ?? fallback.client_secret) || '').trim(); const redirectUri = normalizeAbsoluteUrl((input.redirect_uri ?? fallback.redirect_uri) || ''); const scope = String((input.scope ?? fallback.scope) || 'openid profile email groups').trim() || 'openid profile email groups'; const sessionSecret = String((input.session_secret ?? fallback.session_secret) || '').trim() || clientSecret; const allowedGroups = String((input.allowed_groups ?? fallback.allowed_groups) || '').trim(); const allowedEmails = String((input.allowed_emails ?? fallback.allowed_emails) || '').trim(); const allowedDomains = String((input.allowed_domains ?? fallback.allowed_domains) || '').trim(); const sessionTtlHours = Math.max(1, parseInt(input.session_ttl_hours ?? fallback.session_ttl_hours ?? 12, 10) || 12); const explicitEnabled = input.enabled !== undefined ? Boolean(input.enabled) : Boolean(fallback.enabled); return { enabled: explicitEnabled && Boolean(issuer && clientId && clientSecret && redirectUri && sessionSecret), issuer, discovery_url: discoveryUrl, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, scope, session_secret: sessionSecret, session_ttl_hours: sessionTtlHours, allowed_groups: allowedGroups, allowed_emails: allowedEmails, allowed_domains: allowedDomains, }; } export async function getAuthSettings() { const db = await getDb(); db.data.auth ||= { settings: {}, sessions: [], pending: [] }; db.data.auth.settings ||= {}; db.data.auth.sessions ||= []; db.data.auth.pending ||= []; const fallback = getEnvAuthDefaults(); const settings = normalizeStoredSettings(db.data.auth.settings, fallback); db.data.auth.settings = { ...db.data.auth.settings, enabled: settings.enabled, issuer: settings.issuer, discovery_url: settings.discovery_url, client_id: settings.client_id, client_secret: settings.client_secret, redirect_uri: settings.redirect_uri, scope: settings.scope, session_secret: settings.session_secret, allowed_groups: settings.allowed_groups, allowed_emails: settings.allowed_emails, allowed_domains: settings.allowed_domains, session_ttl_hours: settings.session_ttl_hours, }; return settings; } export async function getPublicAuthSettings() { const settings = await getAuthSettings(); return { enabled: settings.enabled, issuer: settings.issuer, discovery_url: settings.discovery_url, client_id: settings.client_id, redirect_uri: settings.redirect_uri, scope: settings.scope, session_ttl_hours: settings.session_ttl_hours, allowed_groups: settings.allowed_groups, allowed_emails: settings.allowed_emails, allowed_domains: settings.allowed_domains, has_client_secret: Boolean(settings.client_secret), has_session_secret: Boolean(settings.session_secret), is_valid: Boolean(settings.issuer && settings.client_id && settings.client_secret && settings.redirect_uri && settings.session_secret), }; } export async function updateAuthSettings(input) { const db = await getDb(); db.data.auth ||= { settings: {}, sessions: [], pending: [] }; db.data.auth.settings ||= {}; db.data.auth.sessions ||= []; db.data.auth.pending ||= []; const current = normalizeStoredSettings(db.data.auth.settings, getEnvAuthDefaults()); const next = normalizeStoredSettings({ ...current, enabled: input.enabled, issuer: input.issuer, discovery_url: input.discovery_url, client_id: input.client_id, client_secret: typeof input.client_secret === 'string' && input.client_secret.trim() ? input.client_secret.trim() : current.client_secret, redirect_uri: input.redirect_uri, scope: input.scope, session_secret: typeof input.session_secret === 'string' && input.session_secret.trim() ? input.session_secret.trim() : current.session_secret, allowed_groups: input.allowed_groups, allowed_emails: input.allowed_emails, allowed_domains: input.allowed_domains, session_ttl_hours: input.session_ttl_hours, }); if (input.clear_client_secret) next.client_secret = ''; if (input.clear_session_secret) next.session_secret = ''; next.enabled = Boolean(input.enabled) && Boolean(next.issuer && next.client_id && next.client_secret && next.redirect_uri && next.session_secret); db.data.auth.settings = { ...next }; await db.write(); return getPublicAuthSettings(); } async function getEffectiveAuthConfig() { const settings = await getAuthSettings(); return { enabled: settings.enabled, issuer: settings.issuer, discoveryUrl: settings.discovery_url, clientId: settings.client_id, clientSecret: settings.client_secret, redirectUri: settings.redirect_uri, scope: settings.scope, sessionSecret: settings.session_secret, sessionTtlHours: settings.session_ttl_hours, allowedGroups: parseList(settings.allowed_groups), allowedEmails: parseList(settings.allowed_emails).map(item => item.toLowerCase()), allowedDomains: parseList(settings.allowed_domains).map(item => item.toLowerCase()), }; } async function getDiscovery(config) { const cacheKey = config.discoveryUrl; const cached = discoveryCache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < 60 * 60 * 1000) { return cached.value; } await assertSafeUrl(config.discoveryUrl); const response = await fetch(config.discoveryUrl); if (!response.ok) { throw new Error(`Kunde inte läsa OIDC discovery (${response.status})`); } const data = await response.json(); discoveryCache.set(cacheKey, { value: data, fetchedAt: Date.now() }); return data; } function normalizeGroups(value) { if (Array.isArray(value)) return value.map(item => String(item)); if (typeof value === 'string') return value.split(',').map(item => item.trim()).filter(Boolean); return []; } function buildUserProfile(idTokenClaims, userInfo) { const source = { ...idTokenClaims, ...userInfo }; return { sub: source.sub, name: source.name || source.preferred_username || source.email || source.sub, email: source.email || null, preferred_username: source.preferred_username || null, groups: normalizeGroups(source.groups), }; } function ensureAuthorizedUser(config, user) { if (config.allowedEmails.length && !config.allowedEmails.includes(String(user.email || '').toLowerCase())) { throw new Error('Det här kontot är inte tillåtet för Enkelbudget.'); } if (config.allowedDomains.length) { const domain = String(user.email || '').split('@')[1]?.toLowerCase() || ''; if (!config.allowedDomains.includes(domain)) { throw new Error('E-postdomänen är inte tillåten för Enkelbudget.'); } } if (config.allowedGroups.length) { const groups = (user.groups || []).map(group => group.toLowerCase()); const hasMatch = config.allowedGroups.some(group => groups.includes(group.toLowerCase())); if (!hasMatch) { throw new Error('Kontot saknar rätt Pocket ID-grupp för att logga in.'); } } } async function cleanupAuthState(db) { const now = Date.now(); const sessions = db.data.auth.sessions || []; db.data.auth.sessions = sessions.filter(session => !session.expires_at || new Date(session.expires_at).getTime() > now); const pending = db.data.auth.pending || []; db.data.auth.pending = pending.filter(transaction => now - new Date(transaction.created_at).getTime() < 15 * 60 * 1000); } async function saveDb(db) { await cleanupAuthState(db); await db.write(); } function getSafeReturnTo(value) { if (!value || typeof value !== 'string') return '/'; if (!value.startsWith('/')) return '/'; if (value.startsWith('//')) return '/'; if (value.startsWith('/api/')) return '/'; if (value.startsWith('/auth/')) return '/'; if (value.startsWith('/login')) return '/'; if (value.includes('returnTo=')) return '/'; return value; } async function createPendingTransaction(returnTo) { const config = await getEffectiveAuthConfig(); const discovery = await getDiscovery(config); const db = await getDb(); db.data.auth ||= { settings: {}, sessions: [], pending: [] }; db.data.auth.settings ||= {}; db.data.auth.sessions ||= []; db.data.auth.pending ||= []; const state = crypto.randomUUID(); const nonce = crypto.randomUUID(); const codeVerifier = base64url(crypto.randomBytes(48)); const transactionId = crypto.randomUUID(); db.data.auth.pending.push({ id: transactionId, state, nonce, code_verifier: codeVerifier, return_to: getSafeReturnTo(returnTo), created_at: new Date().toISOString(), }); await saveDb(db); const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: config.redirectUri, response_type: 'code', scope: config.scope, state, nonce, code_challenge: sha256base64url(codeVerifier), code_challenge_method: 'S256', }); return { config, transactionId, authUrl: `${discovery.authorization_endpoint}?${params.toString()}`, }; } async function exchangeCodeForSession(code, transaction) { const config = await getEffectiveAuthConfig(); const discovery = await getDiscovery(config); const tokenResponse = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: config.redirectUri, client_id: config.clientId, client_secret: config.clientSecret, code_verifier: transaction.code_verifier, }), }); if (!tokenResponse.ok) { const details = await tokenResponse.text(); throw new Error(`Pocket ID nekade tokenutbytet (${tokenResponse.status}). ${details}`.trim()); } const tokenData = await tokenResponse.json(); if (!tokenData.id_token) { throw new Error('Pocket ID returnerade inget id_token.'); } if (!discovery.jwks_uri) { throw new Error('OIDC discovery saknar jwks_uri – kan inte verifiera id_token.'); } // Verifierar signatur, iss, aud och exp kryptografiskt mot providerns JWKS. let idTokenClaims; try { const jwks = getJwks(discovery.jwks_uri); const verified = await jwtVerify(tokenData.id_token, jwks, { issuer: normalizeIssuer(discovery.issuer || config.issuer), audience: config.clientId, }); idTokenClaims = verified.payload; } catch (error) { throw new Error(`Kunde inte verifiera id_token från Pocket ID: ${error.message}`); } if (idTokenClaims.nonce && idTokenClaims.nonce !== transaction.nonce) { throw new Error('Pocket ID returnerade ett id_token med fel nonce.'); } let userInfo = {}; if (discovery.userinfo_endpoint && tokenData.access_token) { const userInfoResponse = await fetch(discovery.userinfo_endpoint, { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); if (!userInfoResponse.ok) { const details = await userInfoResponse.text(); throw new Error(`Kunde inte läsa användarprofil från Pocket ID (${userInfoResponse.status}). ${details}`.trim()); } userInfo = await userInfoResponse.json(); // Skydd mot att userinfo tillhör en annan användare än id_token. if (userInfo.sub && idTokenClaims.sub && userInfo.sub !== idTokenClaims.sub) { throw new Error('userinfo-svaret matchar inte id_token (sub skiljer sig).'); } } const user = buildUserProfile(idTokenClaims, userInfo); ensureAuthorizedUser(config, user); const sessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + config.sessionTtlHours * 60 * 60 * 1000).toISOString(); const db = await getDb(); db.data.auth ||= { settings: {}, sessions: [], pending: [] }; db.data.auth.sessions.push({ id: sessionId, user, created_at: new Date().toISOString(), expires_at: expiresAt, }); db.data.auth.pending = (db.data.auth.pending || []).filter(item => item.id !== transaction.id); await saveDb(db); return { config, id: sessionId, user, expires_at: expiresAt, return_to: getSafeReturnTo(transaction.return_to), }; } export async function getAuthSession(req) { const config = await getEffectiveAuthConfig(); if (!config.enabled) { return { enabled: false, authenticated: false, user: null }; } const cookies = parseCookies(req.headers.cookie); const signedValue = decodeSignedValue(cookies[SESSION_COOKIE], config.sessionSecret); if (!signedValue) { return { enabled: true, authenticated: false, user: null }; } const db = await getDb(); db.data.auth ||= { settings: {}, sessions: [], pending: [] }; db.data.auth.settings ||= {}; db.data.auth.sessions ||= []; db.data.auth.pending ||= []; await cleanupAuthState(db); const session = db.data.auth.sessions.find(item => item.id === signedValue); if (!session) { return { enabled: true, authenticated: false, user: null }; } return { enabled: true, authenticated: true, user: session.user, expires_at: session.expires_at, }; } export async function handleOidcLogin(req, res) { const { config, transactionId, authUrl } = await createPendingTransaction(req.query.returnTo); if (!config.enabled) { res.redirect('/'); return; } res.setHeader('Set-Cookie', serializeCookie( TRANSACTION_COOKIE, encodeSignedValue(transactionId, config.sessionSecret), { maxAge: 15 * 60, secure: req.secure || req.headers['x-forwarded-proto'] === 'https' } )); res.redirect(authUrl); } export async function handleOidcCallback(req, res) { const config = await getEffectiveAuthConfig(); if (!config.enabled) { res.redirect('/'); return; } const cookies = parseCookies(req.headers.cookie); const transactionId = decodeSignedValue(cookies[TRANSACTION_COOKIE], config.sessionSecret); const db = await getDb(); db.data.auth ||= { settings: {}, sessions: [], pending: [] }; db.data.auth.settings ||= {}; db.data.auth.sessions ||= []; db.data.auth.pending ||= []; const transaction = (db.data.auth.pending || []).find(item => item.id === transactionId); if (!transaction || transaction.state !== req.query.state) { res.status(400).send('Ogiltig eller utgången Pocket ID-inloggning.'); return; } if (!req.query.code) { res.status(400).send('Pocket ID returnerade ingen auth code.'); return; } try { const session = await exchangeCodeForSession(String(req.query.code), transaction); res.setHeader('Set-Cookie', [ serializeCookie( SESSION_COOKIE, encodeSignedValue(session.id, session.config.sessionSecret), { maxAge: session.config.sessionTtlHours * 60 * 60, secure: req.secure || req.headers['x-forwarded-proto'] === 'https', } ), serializeCookie( TRANSACTION_COOKIE, '', { maxAge: 0, secure: req.secure || req.headers['x-forwarded-proto'] === 'https', } ), ]); res.redirect(session.return_to || '/'); } catch (error) { db.data.auth.pending = (db.data.auth.pending || []).filter(item => item.id !== transaction.id); await saveDb(db); res.status(500).send(`Pocket ID-inloggningen misslyckades: ${error.message}`); } } export async function handleOidcLogout(req, res) { const config = await getEffectiveAuthConfig(); if (config.enabled) { const cookies = parseCookies(req.headers.cookie); const sessionId = decodeSignedValue(cookies[SESSION_COOKIE], config.sessionSecret); if (sessionId) { const db = await getDb(); db.data.auth ||= { settings: {}, sessions: [], pending: [] }; db.data.auth.settings ||= {}; db.data.auth.sessions ||= []; db.data.auth.pending ||= []; db.data.auth.sessions = (db.data.auth.sessions || []).filter(item => item.id !== sessionId); await saveDb(db); } } res.setHeader('Set-Cookie', serializeCookie( SESSION_COOKIE, '', { maxAge: 0, secure: req.secure || req.headers['x-forwarded-proto'] === 'https' } )); res.json({ ok: true }); } export async function renderLoginPage(req, res) { const config = await getEffectiveAuthConfig(); if (!config.enabled) { res.redirect('/'); return; } const returnTo = encodeURIComponent(getSafeReturnTo(String(req.query.returnTo || '/'))); res.type('html').send(` Logga in - Enkelbudget
Pocket ID

Logga in i Enkelbudget

Appen är skyddad med OIDC/SSO via Pocket ID. Fortsätt med ditt godkända konto för att komma åt budget, lån, transaktioner och inställningar.

Om du inte ska vara här: stäng fliken eller kontrollera grupp- och klientinställningarna i Pocket ID.
`); } export async function requireApiAuth(req, res, next) { try { const session = await getAuthSession(req); req.auth = session; if (!session.enabled || session.authenticated) { next(); return; } res.status(401).json({ error: 'Autentisering krävs', login_url: `/login?returnTo=${encodeURIComponent(req.originalUrl === '/api/auth/session' ? '/' : req.originalUrl.replace(/^\/api/, '') || '/')}`, }); } catch (error) { res.status(500).json({ error: error.message }); } }