Files
mc-bot-town/nodejs/controller/auth/index.js
T
2026-07-12 17:26:59 -04:00

435 lines
14 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const express = require('express');
const Database = require('../storage/database');
/**
* OpenID Connect (authorization-code + PKCE) login for the web dashboard,
* modeled on theta42/proxy's auth flow.
*
* - Config lives in the settings manager under auth.* (SSO endpoints, client
* credentials, allowed users/groups). auth.enabled=false (default) leaves
* the dashboard open exactly as before.
* - Sessions are opaque random tokens in the storage sqlite DB, delivered as
* an HttpOnly SameSite=Lax cookie so the existing dashboard fetch() calls
* work unchanged. API clients may instead send the token in an
* `auth-token` header.
* - The in-flight OIDC state (PKCE verifier + post-login redirect) is held
* in memory with a 5-minute TTL — single process, no cleanup job needed.
*
* Identity is read from the SSO's userinfo endpoint server-side; ID-token
* signatures are not verified (same trade-off as the reference impl).
*/
const COOKIE_NAME = 'mcbt_session';
const STATE_TTL_MS = 5 * 60 * 1000;
// ========================================
// Config
// ========================================
function authConf() {
const settings = require('../settings/manager');
return {
enabled: settings.get('auth.enabled') === true,
authorizationEndpoint: settings.get('auth.authorizationEndpoint'),
tokenEndpoint: settings.get('auth.tokenEndpoint'),
userinfoEndpoint: settings.get('auth.userinfoEndpoint'),
clientId: settings.get('auth.clientId'),
clientSecret: settings.get('auth.clientSecret'),
redirectUri: settings.get('auth.redirectUri'),
scopes: settings.get('auth.scopes') || ['openid', 'profile', 'email', 'groups'],
usernameClaim: settings.get('auth.usernameClaim') || 'preferred_username',
groupsClaim: settings.get('auth.groupsClaim') || 'groups',
allowedUsers: settings.get('auth.allowedUsers') || [],
allowedGroups: settings.get('auth.allowedGroups') || [],
tokenTTL: settings.get('auth.tokenTTL') || 30 * 24 * 3600, // seconds
};
}
// ========================================
// Small helpers (ported from theta42/proxy)
// ========================================
const base64url = buf => buf.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
function randomToken(bytes = 32) {
return base64url(crypto.randomBytes(bytes));
}
function codeChallengeS256(verifier) {
return base64url(crypto.createHash('sha256').update(verifier).digest());
}
/**
* Constrain a post-login redirect target to a same-origin path.
* Rejects absolute URLs, protocol-relative ("//evil.com"), and scheme
* targets ("javascript:..."). Anything not a plain "/path" becomes "/".
*/
function safeInternalPath(path) {
if (typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\') {
return '/';
}
return path;
}
/** Minimal per-IP fixed-window rate limiter (no external dependency). */
function rateLimiter(max = 60, windowMs = 15 * 60 * 1000) {
const hits = new Map();
return (req, res, next) => {
const now = Date.now();
const ip = req.ip || req.socket.remoteAddress || 'unknown';
let rec = hits.get(ip);
if (!rec || now > rec.reset) {
rec = { count: 0, reset: now + windowMs };
hits.set(ip, rec);
}
if (++rec.count > max) {
return res.status(429).json({ error: 'Too many attempts, please try again later.' });
}
if (hits.size > 1000) {
for (const [k, v] of hits) if (now > v.reset) hits.delete(k);
}
next();
};
}
function parseCookies(req) {
const header = req.headers.cookie;
if (!header) return {};
const out = {};
for (const part of header.split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
}
return out;
}
function isSecureRequest(req) {
return req.secure || req.headers['x-forwarded-proto'] === 'https';
}
// ========================================
// OIDC client
// ========================================
function createAuthRequest() {
const state = randomToken(32);
const codeVerifier = randomToken(32);
return { state, codeVerifier, codeChallenge: codeChallengeS256(codeVerifier) };
}
function buildAuthUrl(state, codeChallenge) {
const o = authConf();
const params = new URLSearchParams({
response_type: 'code',
client_id: o.clientId,
redirect_uri: o.redirectUri,
scope: o.scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${o.authorizationEndpoint}?${params.toString()}`;
}
async function exchangeCode(code, codeVerifier) {
const o = authConf();
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: o.redirectUri,
client_id: o.clientId,
client_secret: o.clientSecret,
code_verifier: codeVerifier,
});
const res = await fetch(o.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.toString(),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Token exchange failed (${res.status}): ${text.slice(0, 200)}`);
}
return res.json();
}
async function fetchUserInfo(accessToken) {
const o = authConf();
const res = await fetch(o.userinfoEndpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if (!res.ok) throw new Error(`Userinfo request failed (${res.status})`);
return res.json();
}
function claimsToIdentity(claims) {
const o = authConf();
const username = claims[o.usernameClaim] || claims.sub;
let groups = claims[o.groupsClaim] || [];
if (!Array.isArray(groups)) groups = [groups].filter(Boolean);
return { username, groups };
}
/** allowedUsers / allowedGroups gate — both empty means any SSO user. */
function identityAllowed(identity) {
const o = authConf();
const users = (o.allowedUsers || []).map(u => String(u).toLowerCase());
const groups = (o.allowedGroups || []).map(g => String(g).toLowerCase());
if (users.length === 0 && groups.length === 0) return true;
if (users.includes(String(identity.username).toLowerCase())) return true;
return identity.groups.some(g => groups.includes(String(g).toLowerCase()));
}
// ========================================
// One-time OIDC state store (in-memory, TTL)
// ========================================
const _states = new Map(); // state -> { codeVerifier, redirect, expires }
function saveState(state, data) {
_states.set(state, { ...data, expires: Date.now() + STATE_TTL_MS });
// Opportunistic sweep of expired/abandoned logins
for (const [k, v] of _states) if (Date.now() > v.expires) _states.delete(k);
}
/** Consume a state record — one-time use bounds replay. */
function takeState(state) {
const rec = _states.get(state);
if (!rec) return null;
_states.delete(state);
if (Date.now() > rec.expires) return null;
return rec;
}
// ========================================
// Session token store (sqlite)
// ========================================
let _tableReady = false;
async function ensureTable() {
if (_tableReady) return;
await Database.db.run(`
CREATE TABLE IF NOT EXISTS auth_tokens (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
groups TEXT DEFAULT '[]',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)
`);
_tableReady = true;
}
async function createSession(identity) {
await ensureTable();
const token = randomToken(32);
const now = Date.now();
await Database.db.run(
'INSERT INTO auth_tokens (token, username, groups, created_at, expires_at) VALUES (?, ?, ?, ?, ?)',
[token, identity.username, JSON.stringify(identity.groups || []), now, now + authConf().tokenTTL * 1000]
);
// Opportunistic cleanup of expired sessions
await Database.db.run('DELETE FROM auth_tokens WHERE expires_at < ?', [now]);
return token;
}
async function checkSession(token) {
if (!token) return null;
await ensureTable();
const row = await Database.db.get('SELECT * FROM auth_tokens WHERE token = ?', [token]);
if (!row) return null;
if (row.expires_at < Date.now()) {
await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]);
return null;
}
return { username: row.username, groups: JSON.parse(row.groups || '[]') };
}
async function destroySession(token) {
if (!token) return;
await ensureTable();
await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]);
}
// ========================================
// Middleware
// ========================================
function readToken(req) {
return parseCookies(req)[COOKIE_NAME] || req.header('auth-token') || null;
}
/**
* Gate every route behind a session when auth.enabled. Browsers get a
* redirect to the login page; API callers get a 401.
*/
async function middleware(req, res, next) {
try {
if (!authConf().enabled) return next();
if (req.path === '/health' || req.path === '/auth' || req.path.startsWith('/auth/')) return next();
const session = await checkSession(readToken(req));
if (session) {
req.user = session.username;
req.groups = session.groups;
return next();
}
if (req.method === 'GET' && req.accepts(['json', 'html']) === 'html') {
return res.redirect('/auth/login?redirect=' + encodeURIComponent(safeInternalPath(req.originalUrl)));
}
return res.status(401).json({ error: 'Authentication required' });
} catch (error) {
next(error);
}
}
// ========================================
// Router
// ========================================
function createRouter() {
const router = express.Router();
const limiter = rateLimiter(60, 15 * 60 * 1000);
router.get('/login', (req, res) => {
const redirect = safeInternalPath(req.query.redirect || '/');
const error = req.query.error ? String(req.query.error).slice(0, 200) : null;
res.send(loginPageHTML(redirect, error));
});
// OIDC login start: create a PKCE + state challenge, stash it, redirect
// the browser to the SSO authorize endpoint.
router.get('/oidc/start', limiter, (req, res) => {
const o = authConf();
if (!o.enabled) return res.status(404).json({ error: 'Auth is not enabled' });
if (!o.authorizationEndpoint || !o.clientId) {
return res.status(500).json({ error: 'OIDC is not configured (auth.authorizationEndpoint / auth.clientId)' });
}
const { state, codeVerifier, codeChallenge } = createAuthRequest();
saveState(state, {
codeVerifier,
// Sanitize now so a hostile ?redirect= can't be stored and later
// reflected into navigation.
redirect: safeInternalPath(req.query.redirect || '/'),
});
res.redirect(buildAuthUrl(state, codeChallenge));
});
// OIDC callback: validate + consume state, exchange the code, read
// identity from userinfo, set the session cookie, redirect into the app.
router.get('/oidc/callback', limiter, async (req, res) => {
try {
const { code, state } = req.query;
if (!code || !state) throw new Error('Missing code or state');
const saved = takeState(String(state));
if (!saved) throw new Error('Unknown or expired login attempt — try again');
const tokens = await exchangeCode(String(code), saved.codeVerifier);
const claims = await fetchUserInfo(tokens.access_token);
const identity = claimsToIdentity(claims);
if (!identityAllowed(identity)) {
console.log(`Auth: DENIED login for '${identity.username}' (groups: ${identity.groups.join(', ') || 'none'})`);
return res.redirect('/auth/login?error=' + encodeURIComponent(`Account '${identity.username}' is not authorized for this dashboard.`));
}
const token = await createSession(identity);
console.log(`Auth: '${identity.username}' logged in`);
const flags = [
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
'HttpOnly', 'Path=/', 'SameSite=Lax',
`Max-Age=${authConf().tokenTTL}`,
];
if (isSecureRequest(req)) flags.push('Secure');
res.setHeader('Set-Cookie', flags.join('; '));
res.redirect(safeInternalPath(saved.redirect || '/'));
} catch (error) {
console.error('Auth: OIDC callback error:', error.message);
res.redirect('/auth/login?error=' + encodeURIComponent(error.message));
}
});
router.all('/logout', async (req, res) => {
try {
await destroySession(readToken(req));
} catch (error) {
console.error('Auth: logout error:', error.message);
}
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`);
if (req.accepts(['json', 'html']) === 'html') return res.redirect('/auth/login');
res.json({ message: 'Bye' });
});
// Who am I — lets the UI show the logged-in user
router.get('/me', async (req, res) => {
if (!authConf().enabled) return res.json({ enabled: false });
const session = await checkSession(readToken(req));
if (!session) return res.status(401).json({ enabled: true, error: 'Not logged in' });
res.json({ enabled: true, username: session.username, groups: session.groups });
});
return router;
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function loginPageHTML(redirect, error) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MC Bot Town — Login</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Tahoma,sans-serif;background:#111827;color:#e5e7eb;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#1f2937;border:1px solid #374151;border-radius:12px;padding:40px;width:360px;text-align:center}
.card h1{font-size:1.3em;color:#60a5fa;margin-bottom:8px}
.card p{color:#9ca3af;font-size:.9em;margin-bottom:24px}
.sso-btn{display:block;width:100%;background:#2563eb;color:#fff;border:none;padding:12px;border-radius:8px;font-size:1em;cursor:pointer;text-decoration:none}
.sso-btn:hover{background:#1d4ed8}
.error{background:#7f1d1d;border:1px solid #dc2626;color:#fecaca;padding:10px;border-radius:8px;font-size:.85em;margin-bottom:16px}
</style>
</head>
<body>
<div class="card">
<h1>MC Bot Town</h1>
<p>Sign in to manage the bot fleet</p>
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
<a class="sso-btn" href="/auth/oidc/start?redirect=${encodeURIComponent(redirect)}">Sign in with SSO</a>
</div>
</body>
</html>`;
}
module.exports = {
middleware,
createRouter,
authConf,
safeInternalPath,
checkSession,
};