Files
michaelswanson 860d5f55cc Initial commit
2026-06-25 19:58:40 +00:00

650 lines
23 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(`<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Logga in - Enkelbudget</title>
<style>
:root { color-scheme: light; }
body { margin: 0; font-family: system-ui, sans-serif; background: linear-gradient(160deg, #eff6ff, #ffffff 45%, #ecfeff); color: #0f172a; }
main { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.card { width: min(460px, 100%); background: rgba(255,255,255,.94); border: 1px solid #dbeafe; border-radius: 24px; box-shadow: 0 24px 80px rgba(15, 23, 42, .12); padding: 28px; }
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: #2563eb; }
h1 { margin: 10px 0 12px; font-size: 30px; line-height: 1.1; }
p { margin: 0 0 22px; color: #475569; line-height: 1.55; }
.actions { display: flex; justify-content: center; margin-top: 8px; }
a { display: inline-flex; align-items: center; justify-content: center; min-width: 240px; max-width: 100%; border-radius: 14px; background: #2563eb; color: white; text-decoration: none; font-weight: 700; padding: 14px 22px; box-shadow: 0 10px 24px rgba(37, 99, 235, .18); }
.hint { margin-top: 14px; font-size: 13px; color: #64748b; text-align: center; }
</style>
</head>
<body>
<main>
<section class="card">
<div class="eyebrow">Pocket ID</div>
<h1>Logga in i Enkelbudget</h1>
<p>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.</p>
<div class="actions">
<a href="/auth/login?returnTo=${returnTo}">Fortsätt med Pocket ID</a>
</div>
<div class="hint">Om du inte ska vara här: stäng fliken eller kontrollera grupp- och klientinställningarna i Pocket ID.</div>
</section>
</main>
</body>
</html>`);
}
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 });
}
}