650 lines
23 KiB
JavaScript
Executable File
650 lines
23 KiB
JavaScript
Executable File
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 });
|
||
}
|
||
}
|