Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
358 lines
17 KiB
JavaScript
358 lines
17 KiB
JavaScript
// ============================================================
|
|
// OIDC ROUTES — OpenID Connect SSO authentication
|
|
// Compatible with: Azure AD, Okta, Keycloak, PocketID, Google
|
|
// ============================================================
|
|
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
var crypto = require('crypto');
|
|
var jwt = require('jsonwebtoken');
|
|
var dns = require('dns').promises;
|
|
var net = require('net');
|
|
var db = require('../db/database');
|
|
var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth');
|
|
var { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
|
var { isMobileClient } = require('../utils/platform');
|
|
|
|
// SSRF guard: reject issuer URLs that resolve to private / loopback / link-local IPs.
|
|
// Prevents an admin (or compromised admin account) from pointing OIDC at AWS metadata etc.
|
|
function isPrivateIp(ip) {
|
|
if (!ip) return true;
|
|
if (net.isIPv4(ip)) {
|
|
var p = ip.split('.').map(Number);
|
|
if (p[0] === 10) return true;
|
|
if (p[0] === 127) return true;
|
|
if (p[0] === 0) return true;
|
|
if (p[0] === 169 && p[1] === 254) return true;
|
|
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
|
|
if (p[0] === 192 && p[1] === 168) return true;
|
|
if (p[0] >= 224) return true;
|
|
return false;
|
|
}
|
|
if (net.isIPv6(ip)) {
|
|
var low = ip.toLowerCase();
|
|
if (low === '::1' || low === '::' ) return true;
|
|
if (low.startsWith('fe80:') || low.startsWith('fc') || low.startsWith('fd')) return true;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
async function assertSafeIssuer(urlStr) {
|
|
var u = new URL(urlStr);
|
|
if (u.protocol !== 'https:') throw new Error('OIDC issuer must use https://');
|
|
var addrs = await dns.lookup(u.hostname, { all: true });
|
|
for (var i = 0; i < addrs.length; i++) {
|
|
if (isPrivateIp(addrs[i].address)) throw new Error('OIDC issuer resolves to a private IP (blocked)');
|
|
}
|
|
}
|
|
|
|
// Transaction lives only in an HttpOnly cookie; the URL contains an opaque challenge.
|
|
// ponytail: one pending login per browser; keyed cookies only if parallel logins are needed.
|
|
var transactionCookie = 'ped_oidc';
|
|
var transactionOptions = { httpOnly: true, secure: true, sameSite: 'lax', path: '/api/auth/oidc' };
|
|
var transactionTTL = 5 * 60 * 1000;
|
|
function signState(payload) {
|
|
var body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
var sig = crypto.createHmac('sha256', JWT_SECRET).update(body).digest('base64url');
|
|
return body + '.' + sig;
|
|
}
|
|
function verifyState(token) {
|
|
if (typeof token !== 'string' || token.length > 2048) return null;
|
|
var parts = token.split('.');
|
|
if (parts.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(parts[0]) || !/^[A-Za-z0-9_-]{43}$/.test(parts[1])) return null;
|
|
var expected = crypto.createHmac('sha256', JWT_SECRET).update(parts[0]).digest('base64url');
|
|
if (parts[1].length !== expected.length || !crypto.timingSafeEqual(Buffer.from(parts[1]), Buffer.from(expected))) return null;
|
|
try {
|
|
var payload = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
|
|
if (!payload || !Number.isSafeInteger(payload.expires) || payload.expires <= Date.now() || payload.expires > Date.now() + transactionTTL) return null;
|
|
if (typeof payload.s !== 'string' || !/^[a-f0-9]{48}$/.test(payload.s) || typeof payload.n !== 'string' || !/^[a-f0-9]{48}$/.test(payload.n) || typeof payload.v !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(payload.v)) return null;
|
|
return payload;
|
|
} catch (e) { return null; }
|
|
}
|
|
|
|
function setAuthCookie(res, token) {
|
|
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
|
|
res.cookie('ped_auth', token, {
|
|
httpOnly: true,
|
|
secure: !!isProduction,
|
|
sameSite: 'lax',
|
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30d safety net; middleware enforces 24h sliding idle
|
|
path: '/'
|
|
});
|
|
}
|
|
|
|
// ── GET OIDC status (public — frontend checks this to show SSO button) ──
|
|
router.get('/oidc-status', async function(req, res) {
|
|
try {
|
|
var enabled = await db.getSetting('oidc.enabled');
|
|
var disableLocal = await db.getSetting('oidc.disable_local_auth');
|
|
var buttonLabel = await db.getSetting('oidc.button_label');
|
|
res.json({
|
|
oidcEnabled: enabled === 'true',
|
|
disableLocalAuth: enabled === 'true' && disableLocal === 'true',
|
|
buttonLabel: buttonLabel || 'Sign in with SSO'
|
|
});
|
|
} catch (e) {
|
|
res.json({ oidcEnabled: false, disableLocalAuth: false });
|
|
}
|
|
});
|
|
|
|
// Which role a set of SSO group names earns. Empty settings mean "off": roles
|
|
// stay whatever they are locally. A claim that is a string is read as a
|
|
// comma- or space-separated list, since providers differ; names are compared
|
|
// case-blind; admin beats moderator; a member of neither is a user.
|
|
function parseGroupList(value) {
|
|
return String(value || '').split(/[,\s]+/).map(function (g) { return g.trim().toLowerCase(); }).filter(Boolean);
|
|
}
|
|
async function roleFromGroups(claim) {
|
|
var admins = parseGroupList(await db.getSetting('oidc.admin_groups'));
|
|
var moderators = parseGroupList(await db.getSetting('oidc.moderator_groups'));
|
|
if (!admins.length && !moderators.length) return null;
|
|
if (claim == null) return null;
|
|
var groups = Array.isArray(claim) ? claim.map(function (g) { return String(g).toLowerCase(); }) : parseGroupList(claim);
|
|
if (admins.some(function (g) { return groups.indexOf(g) !== -1; })) return 'admin';
|
|
if (moderators.some(function (g) { return groups.indexOf(g) !== -1; })) return 'moderator';
|
|
return 'user';
|
|
}
|
|
|
|
// ── GET /api/auth/oidc — initiate OIDC login (redirects to IdP) ──────────
|
|
router.get('/oidc', async function(req, res) {
|
|
try {
|
|
var enabled = await db.getSetting('oidc.enabled');
|
|
if (enabled !== 'true') {
|
|
return res.status(400).json({ error: 'SSO is not enabled' });
|
|
}
|
|
|
|
var issuer = await db.getSetting('oidc.issuer');
|
|
var clientId = await db.getSetting('oidc.client_id');
|
|
|
|
if (!issuer || !clientId) {
|
|
return res.status(500).json({ error: 'OIDC not fully configured' });
|
|
}
|
|
|
|
var oidc = require('openid-client');
|
|
|
|
var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
|
var redirectUri = appUrl + '/api/auth/oidc/callback';
|
|
|
|
await assertSafeIssuer(issuer);
|
|
var config = await oidc.discovery(new URL(issuer), clientId);
|
|
|
|
var nonce = crypto.randomBytes(24).toString('hex');
|
|
var codeVerifier = oidc.randomPKCECodeVerifier();
|
|
var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
|
|
|
// A silent attempt asks the provider for an answer without showing anyone
|
|
// anything: signed in there already means signed in here, the way a
|
|
// Kerberos ticket works; not signed in there comes back as a refusal the
|
|
// callback turns into the ordinary sign-in page. The page starts it once
|
|
// per browser session, never after an explicit sign-out.
|
|
var silent = req.query.silent === '1';
|
|
var state = crypto.randomBytes(24).toString('hex');
|
|
res.cookie(transactionCookie, signState({
|
|
s: state,
|
|
n: nonce,
|
|
v: codeVerifier,
|
|
q: silent ? 1 : 0,
|
|
expires: Date.now() + transactionTTL
|
|
}), Object.assign({ maxAge: transactionTTL }, transactionOptions));
|
|
|
|
var authParams = {
|
|
redirect_uri: redirectUri,
|
|
scope: 'openid email profile',
|
|
state: state,
|
|
nonce: nonce,
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: 'S256'
|
|
};
|
|
if (silent) authParams.prompt = 'none';
|
|
var authUrl = oidc.buildAuthorizationUrl(config, authParams);
|
|
|
|
res.redirect(authUrl.href);
|
|
} catch (err) {
|
|
res.clearCookie(transactionCookie, transactionOptions);
|
|
console.error('[OIDC] Auth initiation failed');
|
|
res.status(500).json({ error: 'SSO login failed' });
|
|
}
|
|
});
|
|
|
|
// ── GET /api/auth/oidc/callback — handle IdP callback ────────────────────
|
|
router.get('/oidc/callback', async function(req, res) {
|
|
var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
|
|
|
res.clearCookie(transactionCookie, transactionOptions);
|
|
try {
|
|
var state = req.query.state;
|
|
var pending = verifyState(req.cookies && req.cookies[transactionCookie]);
|
|
if (typeof state !== 'string' || !/^[a-f0-9]{48}$/.test(state) || !pending || pending.s !== state) {
|
|
return res.redirect(appUrl + '?error=invalid_state');
|
|
}
|
|
if (typeof req.query.error === 'string' && req.query.error) {
|
|
// login_required / interaction_required / consent_required: the provider
|
|
// could not answer without a person. For a silent attempt that is the
|
|
// expected "no session there" and the page simply shows sign-in.
|
|
if (pending.q === 1) return res.redirect(appUrl + '?sso=none');
|
|
return res.redirect(appUrl + '?error=sso_failed');
|
|
}
|
|
|
|
if (await db.getSetting('oidc.enabled') !== 'true') return res.redirect(appUrl + '?error=sso_disabled');
|
|
var issuer = await db.getSetting('oidc.issuer');
|
|
var clientId = await db.getSetting('oidc.client_id');
|
|
var clientSecret = await db.getSetting('oidc.client_secret');
|
|
|
|
var oidc = require('openid-client');
|
|
var redirectUri = appUrl + '/api/auth/oidc/callback';
|
|
|
|
await assertSafeIssuer(issuer);
|
|
var config = await oidc.discovery(new URL(issuer), clientId, clientSecret || undefined);
|
|
|
|
var tokens = await oidc.authorizationCodeGrant(config, new URL(req.protocol + '://' + req.get('host') + req.originalUrl), {
|
|
pkceCodeVerifier: pending.v,
|
|
expectedNonce: pending.n,
|
|
expectedState: state
|
|
});
|
|
|
|
var claims = tokens.claims();
|
|
var sub = claims.sub;
|
|
if (typeof sub !== 'string' || !sub || sub.length > 255) return res.redirect(appUrl + '?error=invalid_identity');
|
|
var email = claims.email;
|
|
var emailVerified = claims.email_verified;
|
|
var name = claims.name || claims.preferred_username || email;
|
|
|
|
if (!email) {
|
|
// Try userinfo endpoint
|
|
var userinfo = await oidc.fetchUserInfo(config, tokens.access_token, sub);
|
|
email = userinfo.email;
|
|
if (typeof emailVerified === 'undefined') emailVerified = userinfo.email_verified;
|
|
name = name || userinfo.name || userinfo.preferred_username || email;
|
|
}
|
|
|
|
if (!email) {
|
|
return res.redirect(appUrl + '?error=no_email');
|
|
}
|
|
|
|
email = email.toLowerCase();
|
|
|
|
// Find or create user
|
|
var user = await db.get('SELECT * FROM users WHERE email = ?', [email]);
|
|
|
|
if (user) {
|
|
if (user.disabled) return res.redirect(appUrl + '?error=disabled');
|
|
// Existing user. Two safe linking paths:
|
|
// 1. Already linked (oidc_sub matches) → log in
|
|
// 2. Not yet linked → auto-link ONLY if both local and IdP email
|
|
// are verified. Otherwise an attacker could squat on
|
|
// an email they don't own and take over the local account.
|
|
if (user.oidc_sub && user.oidc_sub !== sub) {
|
|
console.warn('[OIDC] sub mismatch on existing user id=' + user.id + ' — IdP returned different sub than recorded. Refusing to auto-relink.');
|
|
return res.redirect(appUrl + '?error=sub_mismatch');
|
|
}
|
|
if (!user.oidc_sub) {
|
|
// A pre-registered, unverified account may have an attacker-known password.
|
|
if (user.email_verified !== true) return res.redirect(appUrl + '?error=account_link_required');
|
|
// Some IdPs (rare) serialize booleans as strings — accept both.
|
|
var verified = emailVerified === true || emailVerified === 'true';
|
|
if (!verified) {
|
|
console.warn('[OIDC] refusing to auto-link existing user ' + user.id + ' — IdP did not assert email_verified=true');
|
|
return res.redirect(appUrl + '?error=email_unverified');
|
|
}
|
|
await db.run('UPDATE users SET oidc_sub = ?, email_verified = true WHERE id = ?', [sub, user.id]);
|
|
await db.run('INSERT INTO audit_log (user_id, action, category, details, ip_address) VALUES (?, ?, ?, ?, ?)',
|
|
[user.id, 'oidc_linked', 'auth', 'Linked SSO identity ' + sub + ' via ' + issuer, req.ip]).catch(function(){});
|
|
}
|
|
} else {
|
|
// Auto-create user from OIDC
|
|
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
|
|
var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user';
|
|
var randomPw = crypto.randomBytes(32).toString('hex'); // Not used for OIDC login
|
|
|
|
var result = await db.run(
|
|
'INSERT INTO users (email, password, name, role, email_verified, oidc_sub) VALUES (?, ?, ?, ?, true, ?)',
|
|
[email, randomPw, name, role, sub]
|
|
);
|
|
user = await db.get('SELECT * FROM users WHERE id = ?', [result.lastInsertRowid]);
|
|
}
|
|
|
|
// The role comes from the SSO's groups, on every sign-in, when the admin
|
|
// has said which groups mean what (oidc.admin_groups, oidc.moderator_groups).
|
|
// Applied every time rather than only at creation, so removal at the SSO
|
|
// demotes here too. The last admin is never demoted by a claim: a group
|
|
// edit at the SSO must not be able to lock everyone out of this panel.
|
|
var mapped = await roleFromGroups(claims.groups);
|
|
if (mapped && mapped !== user.role) {
|
|
var lastAdmin = user.role === 'admin' && mapped !== 'admin' &&
|
|
parseInt((await db.get("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND disabled IS NOT TRUE", [])).count, 10) <= 1;
|
|
if (lastAdmin) {
|
|
console.warn('[OIDC] not demoting the last admin (user ' + user.id + ') on the SSO\'s say-so');
|
|
} else {
|
|
await db.run('UPDATE users SET role = ? WHERE id = ?', [mapped, user.id]);
|
|
await db.run('INSERT INTO audit_log (user_id, action, category, details, ip_address) VALUES (?, ?, ?, ?, ?)',
|
|
[user.id, 'oidc_role', 'auth', 'Role ' + user.role + ' → ' + mapped + ' from SSO groups', req.ip]).catch(function(){});
|
|
user.role = mapped;
|
|
}
|
|
}
|
|
|
|
// Issue JWT
|
|
var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: isMobileClient(req) ? '365d' : '30d' });
|
|
|
|
// Persist the session before issuing any authentication cookie.
|
|
var ssoSessionId = generateSessionId();
|
|
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
|
|
[ssoSessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
|
|
|
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
|
[user.id, 'login_oidc', req.ip, 'SSO via ' + issuer]);
|
|
|
|
setAuthCookie(res, token);
|
|
// Redirect to app — token is in httpOnly cookie, pass session ID
|
|
res.redirect(appUrl + '?sso=ok&sid=' + ssoSessionId);
|
|
} catch (err) {
|
|
// IdP errors can contain callback URLs or PKCE material; do not log them.
|
|
console.error('[OIDC] Callback failed');
|
|
res.redirect(appUrl + '?error=sso_failed');
|
|
}
|
|
});
|
|
|
|
// ── Admin: GET OIDC config ──────────────────────────────────────────────
|
|
router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
|
|
try {
|
|
var keys = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups'];
|
|
var config = {};
|
|
for (var i = 0; i < keys.length; i++) {
|
|
config[keys[i]] = await db.getSetting(keys[i]) || '';
|
|
}
|
|
// Mask client secret
|
|
if (config['oidc.client_secret']) {
|
|
config['oidc.client_secret'] = '••••••••' + config['oidc.client_secret'].slice(-4);
|
|
}
|
|
res.json({ success: true, config: config });
|
|
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
// ── Admin: PUT update OIDC config ───────────────────────────────────────
|
|
router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
|
|
try {
|
|
// The sign-in provider is the most consequential setting there is; under
|
|
// lockdown it is read-only like every other setting that changes how the
|
|
// service behaves. This router is not behind the admin gate, so it says so itself.
|
|
if (lockdown.enabled()) return res.status(403).json({ error: lockdown.refusal('oidc') });
|
|
var allowed = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips', 'oidc.admin_groups', 'oidc.moderator_groups'];
|
|
var updates = req.body;
|
|
|
|
for (var i = 0; i < allowed.length; i++) {
|
|
var key = allowed[i];
|
|
if (updates[key] !== undefined) {
|
|
// Don't overwrite secret with masked value
|
|
if (key === 'oidc.client_secret' && updates[key].indexOf('••••') === 0) continue;
|
|
await db.setSetting(key, updates[key]);
|
|
}
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
|
});
|
|
|
|
module.exports = router;
|
|
|
|
module.exports.roleFromGroups = roleFromGroups;
|
|
module.exports.parseGroupList = parseGroupList;
|