proxy-oidc/server.js
2025-12-03 21:10:55 +01:00

389 lines
13 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');
// Import openid-client in a way that works with both CJS and ESM builds.
let Issuer, generators;
try {
const oc = require('openid-client');
// package may export under .default for some ESM->CJS interop
Issuer = oc.Issuer || (oc.default && oc.default.Issuer);
generators = oc.generators || (oc.default && oc.default.generators);
} catch (err) {
// don't crash here — we'll log and skip OIDC setup later
console.warn('openid-client not available via require():', err.message);
}
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');
}
});
// Admin preview page (iframe) — keep user on proxy domain while showing upstream
app.get('/admin/preview/: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/preview', { host });
});
// Proxy used by the preview iframe; strips headers that prevent embedding.
app.use('/preview-proxy/:id', ensureAdmin, (req, res, next) => {
const id = req.params.id;
const host = HOSTS.find(h => h.id === id);
if (!host) return res.status(404).send('Unknown host');
// Create a proxy middleware tailored to this host
const proxy = createProxyMiddleware({
target: host.upstream,
changeOrigin: true,
selfHandleResponse: false,
proxyTimeout: 15000,
pathRewrite: (path, req2) => {
// path starts with /preview-proxy/:id
const prefix = `/preview-proxy/${id}`;
let newPath = path.replace(prefix, '') || '/';
// ensure upstream receives the host's pathPrefix if configured
if (host.pathPrefix && host.pathPrefix !== '/') {
// avoid double slashes
newPath = host.pathPrefix.replace(/\/$/, '') + newPath;
}
return newPath;
},
onProxyReq(proxyReq, req2, res2) {
// pass admin identity header for upstream if desired
if (req.session && req.session.isAdmin) proxyReq.setHeader('X-Admin-User', req.session.username || 'admin');
},
onProxyRes(proxyRes, req2, res2) {
// Remove headers that would prevent embedding in an iframe
try {
delete proxyRes.headers['x-frame-options'];
delete proxyRes.headers['x-content-security-policy'];
delete proxyRes.headers['content-security-policy'];
delete proxyRes.headers['frame-options'];
} catch (e) {
// ignore
}
}
});
return proxy(req, res, next);
});
// 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() {
if (!Issuer) {
console.warn('openid-client Issuer is unavailable; skipping OIDC setup.');
return;
}
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);
});