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); });