Initial commit

This commit is contained in:
michaelswanson
2026-06-25 19:58:40 +00:00
commit 860d5f55cc
47 changed files with 19216 additions and 0 deletions
Executable
+649
View File
@@ -0,0 +1,649 @@
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 });
}
}