340 lines
12 KiB
JavaScript
340 lines
12 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');
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
});
|