proxy-oidc/server.js
2025-12-03 20:54:35 +01:00

325 lines
11 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const session = require('express-session');
const bodyParser = require('body-parser');
const { createProxyMiddleware } = require('http-proxy-middleware');
const path = require('path');
const fs = require('fs');
const { Issuer, generators } = require('openid-client');
const app = express();
const PORT = process.env.PORT || 3000;
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.use(bodyParser.urlencoded({ extended: false }));
app.use(session({
secret: process.env.SESSION_SECRET || 'dev-secret',
resave: false,
saveUninitialized: false,
cookie: { secure: false }
}));
// --- Admin credentials (set via env) ---
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || 'admin';
function ensureAdmin(req, res, next) {
if (req.session && req.session.isAdmin) return next();
return res.redirect('/admin/login');
}
// Admin routes: login, logout, manage hosts
app.get('/admin/login', (req, res) => {
res.render('admin/login', { error: null });
});
app.post('/admin/login', (req, res) => {
const { username, password } = req.body || {};
if (username === ADMIN_USER && password === ADMIN_PASS) {
req.session.isAdmin = true;
return res.redirect('/admin');
}
res.render('admin/login', { error: 'Invalid credentials' });
});
app.get('/admin/logout', (req, res) => {
if (req.session) delete req.session.isAdmin;
res.redirect('/admin/login');
});
// list hosts
app.get('/admin', ensureAdmin, (req, res) => {
res.render('admin/index', { hosts: HOSTS });
});
app.get('/admin/new', ensureAdmin, (req, res) => {
res.render('admin/edit_host', { host: null });
});
app.get('/admin/edit/:id', ensureAdmin, (req, res) => {
const host = HOSTS.find(h => h.id === req.params.id);
if (!host) return res.status(404).send('Not found');
res.render('admin/edit_host', { host });
});
// save new or existing host
app.post('/admin/save', ensureAdmin, (req, res) => {
const idInput = (req.body.idInput || '').trim();
if (!idInput) return res.status(400).send('id required');
const existingIndex = HOSTS.findIndex(h => h.id === idInput);
const hostObj = {
id: idInput,
pathPrefix: req.body.pathPrefix || '/',
upstream: req.body.upstream || ''
};
if (req.body.oidc_issuer) {
hostObj.oidc = {
issuer: req.body.oidc_issuer,
client_id: req.body.oidc_client_id,
client_secret: req.body.oidc_client_secret,
redirect_uri: req.body.oidc_redirect_uri
};
}
if (existingIndex >= 0) {
HOSTS[existingIndex] = hostObj;
} else {
HOSTS.push(hostObj);
}
// persist to config.json
try {
fs.writeFileSync(cfgPath, JSON.stringify(HOSTS, null, 2), 'utf8');
} catch (err) {
console.error('Failed writing config.json', err);
}
return res.redirect('/admin');
});
app.post('/admin/delete/:id', ensureAdmin, (req, res) => {
const id = req.params.id;
HOSTS = HOSTS.filter(h => h.id !== id);
try { fs.writeFileSync(cfgPath, JSON.stringify(HOSTS, null, 2), 'utf8'); } catch(e){console.error(e)}
res.redirect('/admin');
});
app.post('/admin/reload', ensureAdmin, async (req, res) => {
// reload hosts from disk and re-run OIDC setup
try {
if (fs.existsSync(cfgPath)) {
HOSTS = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
}
// reinitialize oidc clients
Object.keys(oidcClients).forEach(k => delete oidcClients[k]);
await setupOidc();
res.redirect('/admin');
} catch (err) {
console.error('Reload error', err);
res.status(500).send('Reload failed');
}
});
// Load hosts configuration. If `config.json` exists, use it; otherwise fallback to UPSTREAM env.
let HOSTS = [];
const cfgPath = path.join(__dirname, 'config.json');
if (fs.existsSync(cfgPath)) {
try {
HOSTS = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
} catch (err) {
console.error('Failed parsing config.json:', err.message);
process.exit(1);
}
} else {
if (process.env.UPSTREAM) {
HOSTS = [{ id: 'default', pathPrefix: '/', upstream: process.env.UPSTREAM }];
} else {
console.warn('No config.json found and no UPSTREAM env; create config.json or set UPSTREAM');
}
}
// Prepare OIDC clients for hosts that include OIDC config
const oidcClients = {};
async function setupOidc() {
for (const host of HOSTS) {
if (host.oidc && host.oidc.issuer) {
try {
const issuer = await Issuer.discover(host.oidc.issuer);
const client = new issuer.Client({
client_id: host.oidc.client_id,
client_secret: host.oidc.client_secret,
redirect_uris: [host.oidc.redirect_uri],
response_types: ['code']
});
oidcClients[host.id] = { client, config: host.oidc };
console.log(`OIDC client ready for host ${host.id}`);
} catch (err) {
console.error(`Failed to configure OIDC for host ${host.id}:`, err.message);
}
}
}
}
// Simple in-memory user store for non-OIDC hosts (demo only)
const USERS = {
'alice': 'password123',
'bob': 's3cret'
};
app.get('/login', (req, res) => {
// If multiple hosts configured, show choices
if (HOSTS.length > 1) return res.render('login', { error: null, hosts: HOSTS });
// otherwise if the single host has OIDC configured, redirect
const host = HOSTS[0];
if (host && host.oidc && oidcClients[host.id]) return res.redirect(`/login/${host.id}`);
res.render('login', { error: null, hosts: HOSTS });
});
// Start login for a specific host (OIDC or local form)
app.get('/login/:hostId', async (req, res) => {
const hostId = req.params.hostId;
const host = HOSTS.find(h => h.id === hostId);
if (!host) return res.status(404).send('Unknown host');
if (host.oidc && oidcClients[hostId]) {
const { client } = oidcClients[hostId];
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const state = generators.state();
const nonce = generators.nonce();
// store PKCE and nonce in session scoped by host
req.session.oidc = req.session.oidc || {};
req.session.oidc[hostId] = { code_verifier, state, nonce, dest: req.query.dest || req.session.dest || '/' };
const authUrl = client.authorizationUrl({
scope: 'openid profile email',
code_challenge,
code_challenge_method: 'S256',
state,
nonce,
redirect_uri: host.oidc.redirect_uri
});
return res.redirect(authUrl);
}
// No OIDC: render local form with host hint
return res.render('login', { error: null, hosts: [host] });
});
// Local username/password POST (for non-OIDC hosts)
app.post('/login/:hostId', (req, res) => {
const hostId = req.params.hostId;
const host = HOSTS.find(h => h.id === hostId);
if (!host) return res.status(404).send('Unknown host');
const { username, password } = req.body || {};
if (username && password && USERS[username] === password) {
req.session.user = { username, host: hostId };
const dest = req.session.dest || host.pathPrefix || '/';
delete req.session.dest;
return res.redirect(dest);
}
res.status(401).render('login', { error: 'Invalid credentials', hosts: [host] });
});
// OIDC callback per-host
app.get('/callback/:hostId', async (req, res) => {
const hostId = req.params.hostId;
const host = HOSTS.find(h => h.id === hostId);
if (!host) return res.status(404).send('Unknown host');
const clientEntry = oidcClients[hostId];
if (!clientEntry) return res.status(400).send('OIDC not configured for this host');
const params = clientEntry.client.callbackParams(req);
const saved = (req.session.oidc || {})[hostId];
if (!saved) return res.status(400).send('Missing OIDC session data');
try {
const tokenSet = await clientEntry.client.callback(host.oidc.redirect_uri, params, { code_verifier: saved.code_verifier, state: saved.state, nonce: saved.nonce });
const userinfo = await clientEntry.client.userinfo(tokenSet.access_token);
// store tokens and profile in session under host
req.session.tokens = req.session.tokens || {};
req.session.tokens[hostId] = tokenSet;
req.session.user = { username: userinfo.preferred_username || userinfo.email || userinfo.sub, profile: userinfo, host: hostId };
const dest = saved.dest || host.pathPrefix || '/';
delete req.session.oidc[hostId];
return res.redirect(dest);
} catch (err) {
console.error('OIDC callback error:', err);
return res.status(500).send('Authentication error');
}
});
app.get('/logout', (req, res) => {
req.session.destroy(() => res.redirect('/login'));
});
// Middleware to require login per host
function findHostForReq(req) {
// Prefer longest matching pathPrefix
const urlPath = req.originalUrl || req.url;
let match = null;
for (const host of HOSTS) {
const prefix = host.pathPrefix || '/';
if (prefix === '/') {
if (!match) match = host;
} else if (urlPath.startsWith(prefix)) {
if (!match || (prefix.length > (match.pathPrefix || '').length)) match = host;
}
}
return match;
}
function requireLoginForHost(req, res, next) {
const host = findHostForReq(req);
if (!host) return res.status(404).send('No host configured for this path');
// If OIDC configured for host, require token
if (host.oidc) {
if (req.session && req.session.tokens && req.session.tokens[host.id] && req.session.user && req.session.user.host === host.id) return next();
// store original path and redirect to host-specific login
req.session.dest = req.originalUrl;
return res.redirect(`/login/${host.id}`);
}
// Non-OIDC: allow if session user exists (and optionally matches host)
if (req.session && req.session.user) return next();
req.session.dest = req.originalUrl;
return res.redirect('/login');
}
// Proxy handler: select host then proxy, optionally injecting Authorization header
app.use('/', requireLoginForHost, (req, res, next) => {
const host = findHostForReq(req);
if (!host) return res.status(404).send('No host configured');
// build proxy middleware on the fly for this host
const prefix = host.pathPrefix || '/';
const pathRewrite = {};
if (prefix !== '/') {
// remove prefix when forwarding
pathRewrite[`^${prefix}`] = '';
}
const proxy = createProxyMiddleware({
target: host.upstream,
changeOrigin: true,
proxyTimeout: 10000,
pathRewrite,
selfHandleResponse: false,
onProxyReq(proxyReq, req, res) {
// if we have an access token for this host, supply it
if (req.session && req.session.tokens && req.session.tokens[host.id] && req.session.tokens[host.id].access_token) {
proxyReq.setHeader('Authorization', `Bearer ${req.session.tokens[host.id].access_token}`);
}
// add forwarded user header
if (req.session && req.session.user) {
proxyReq.setHeader('X-Forwarded-User', req.session.user.username);
}
}
});
return proxy(req, res, next);
});
// Initialize and start server
setupOidc().then(() => {
app.listen(PORT, () => {
console.log(`Auth proxy listening on http://localhost:${PORT}`);
});
}).catch(err => {
console.error('Failed to setup OIDC clients:', err);
process.exit(1);
});