feat: rebuild web UI on the shared theta42 app stack; OIDC + local admin auth

The web management UI was a bespoke minimal theme with LDAP-bind login.
Rebuild it to match the SSO Manager and Proxy — same stack, same look/feel,
same auth model. The SSH bridge, audit, metrics, and access logic are
unchanged; this is purely the web layer.

Frontend (mirrors proxy/sso):
- Express + EJS with the shared top.ejs/bottom.ejs shell, Bootstrap 5,
  jQuery, jq-repeat, FontAwesome, Socket.IO, and the shared app-base.js /
  val.js client framework (copied verbatim). Vendor libs served from
  node_modules via /static-modules; app assets via /static.
- Dashboard / Sessions / Audit pages render in the common look/feel,
  loading data through the authenticated /api/* endpoints.

Auth (mirrors proxy):
- OIDC against the SSO (utils/oidc.js + routes/auth.js + models/oidc_state)
  plus a local anti-lockout admin (models/user_redis.js, bootstrapped from
  auth.adminUsers[0] / auth.localAdminPass). AuthToken sessions carry the
  group snapshot; middleware gates the data API on adminGroups or the local
  admin. New config: oidc{} + auth.adminUsers/localAdminPass.
- /api/user/me drives the client login state; "Log in with SSO" hidden when
  oidc.enabled is false.

Verified end to end: local admin login -> token -> /api/user/me isAdmin,
metrics/sessions/audit 200 with token / 401 without / 401 bad password;
static + page shells serve; 26 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 20:57:34 -04:00
parent eb8e5b409e
commit aa3b3ed515
38 changed files with 2550 additions and 374 deletions
+8 -29
View File
@@ -1,36 +1,15 @@
'use strict';
// Auditing + metrics API (admin-gated by middleware/auth in app.js).
const router = require('express').Router();
const middleware = require('../middleware/auth');
const express = require('express');
const audit = require('../models/audit_event');
const metrics = require('../models/metrics');
const registry = require('../services/session_registry');
// Authentication (local login + OIDC handshake). Unauthenticated by design.
router.use('/auth', require('./auth'));
const router = express.Router();
// Who am I — needs a valid session but no admin gate (drives the login state).
router.use('/user', middleware.auth, require('./user'));
router.get('/sessions', (req, res) => {
res.json({ results: registry.list(), active: registry.count() });
});
router.get('/audit', async (req, res, next) => {
try {
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
const data = await audit.list({
page,
pageSize: Math.min(200, parseInt(req.query.pageSize, 10) || 50),
uid: req.query.uid || undefined,
target: req.query.target || undefined,
status: req.query.status || undefined,
});
res.json(data);
} catch (err) { next(err); }
});
router.get('/metrics', async (req, res, next) => {
try {
res.json({ ...(await metrics.summary()), active: registry.count() });
} catch (err) { next(err); }
});
// Jump-host data — admin only (audit log, active sessions, metrics).
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
module.exports = router;
Regular → Executable
+98 -29
View File
@@ -1,42 +1,111 @@
'use strict';
// Web login: LDAP bind as the user, require an adminGroups membership, mint a
// session cookie. (OIDC against the SSO is a follow-up.)
const express = require('express');
const router = require('express').Router();
const { rateLimit } = require('express-rate-limit');
const conf = require('@simpleworkjs/conf');
const userLdap = require('../models/user_ldap');
const Session = require('../models/session');
const { Auth } = require('../models/auth');
const { OidcState } = require('../models/oidc_state');
const oidc = require('../utils/oidc');
const { safeInternalPath } = require('../utils/safe_redirect');
const router = express.Router();
router.get('/login', (req, res) => {
res.render('login', { error: null, name: conf.name });
// Throttle unauthenticated auth endpoints (credential login + the OIDC
// handshake) to blunt brute-force / callback abuse. Keyed per IP.
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 60, // 60 attempts per IP per window
standardHeaders: true,
legacyHeaders: false,
message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'},
});
router.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
const { uid, password } = req.body || {};
const fail = (msg) => res.status(401).render('login', { error: msg, name: conf.name });
try {
const user = await userLdap.getUser(uid);
if (!user) return fail('Invalid credentials.');
const ok = await userLdap.checkPassword(user.dn, password);
if (!ok) return fail('Invalid credentials.');
const groups = await userLdap.getGroups(user.dn);
const admin = (conf.auth.adminGroups || []).some((g) => groups.includes(g));
if (!admin) return fail('Your account is not a jump-host admin.');
const session = await Session.start(user.uid, groups, conf.auth.sessionTTLms);
res.setHeader('Set-Cookie', `jump_session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(conf.auth.sessionTTLms / 1000)}`);
res.redirect('/');
} catch (err) {
return fail('Login failed.');
router.post('/login', authLimiter, async function(req, res, next){
try{
let auth = await Auth.login(req.body);
return res.json({
login: true,
token: auth.token.token,
message:`${req.body.username} logged in!`,
});
}catch(error){
next(error);
}
});
router.post('/logout', (req, res) => {
res.setHeader('Set-Cookie', 'jump_session=; HttpOnly; Path=/; Max-Age=0');
res.redirect('/login');
router.all('/logout', async function(req, res, next){
try{
if(req.user){
await req.user.logout();
}
res.json({message: 'Bye'})
}catch(error){
next(error);
}
});
/**
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
*/
router.get('/oidc/start', authLimiter, async function(req, res, next){
try{
if(!conf.oidc || !conf.oidc.enabled){
let error = new Error('OidcDisabled');
error.status = 404;
error.message = 'OIDC login is not enabled.';
throw error;
}
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
await OidcState.create({
state,
codeVerifier,
// Sanitize now so a hostile ?redirect= can't be stored and later
// reflected into the login page's navigation.
redirect: safeInternalPath(req.query.redirect || '/'),
});
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
}catch(error){
next(error);
}
});
/**
* OIDC callback: validate state (consuming the one-time record), exchange the
* code for tokens, read identity from userinfo, establish a session, and hand
* the app token back to the browser via a URL fragment for the login page to
* store in localStorage.
*/
router.get('/oidc/callback', authLimiter, async function(req, res, next){
try{
let {code, state} = req.query;
if(!code || !state){
let error = new Error('OidcCallbackInvalid');
error.status = 400;
error.message = 'Missing code or state.';
throw error;
}
// get() throws if the state is unknown or has expired — this both binds
// the callback to our request and bounds replay.
let saved = await OidcState.get(state);
await saved.remove();
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
let claims = await oidc.fetchUserInfo(tokens.access_token);
let identity = oidc.claimsToIdentity(claims);
let {token} = await Auth.oidcSession(identity);
let redirect = safeInternalPath(saved.redirect || '/');
return res.redirect(
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
);
}catch(error){
next(error);
}
});
module.exports = router;
-39
View File
@@ -1,39 +0,0 @@
'use strict';
const express = require('express');
const audit = require('../models/audit_event');
const metrics = require('../models/metrics');
const registry = require('../services/session_registry');
const buildInfo = require('../models/build_info');
const conf = require('@simpleworkjs/conf');
const router = express.Router();
router.get('/', async (req, res, next) => {
try {
const [m, recent] = await Promise.all([
metrics.summary(),
audit.list({ page: 0, pageSize: 10 }),
]);
res.render('dashboard', {
name: conf.name, buildInfo, user: req.jumpUser,
metrics: { ...m, active: registry.count() },
active: registry.list(),
recent: recent.results,
});
} catch (err) { next(err); }
});
router.get('/sessions', (req, res) => {
res.render('sessions', { name: conf.name, buildInfo, user: req.jumpUser, active: registry.list() });
});
router.get('/audit', async (req, res, next) => {
try {
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
const data = await audit.list({ page, pageSize: 50, uid: req.query.uid, target: req.query.target, status: req.query.status });
res.render('audit', { name: conf.name, buildInfo, user: req.jumpUser, data, query: req.query });
} catch (err) { next(err); }
});
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
'use strict';
// Jump-host data API: active sessions, the audit log, and metrics. Admin-gated
// (mounted behind middleware.auth + requireAdmin in routes/api.js).
const router = require('express').Router();
const audit = require('../models/audit_event');
const metrics = require('../models/metrics');
const registry = require('../services/session_registry');
router.get('/sessions', (req, res) => {
res.json({results: registry.list(), active: registry.count()});
});
router.get('/audit', async (req, res, next) => {
try{
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
const data = await audit.list({
page,
pageSize: Math.min(200, parseInt(req.query.pageSize, 10) || 50),
uid: req.query.uid || undefined,
target: req.query.target || undefined,
status: req.query.status || undefined,
});
res.json(data);
}catch(error){ next(error); }
});
router.get('/metrics', async (req, res, next) => {
try{
res.json({...(await metrics.summary()), active: registry.count()});
}catch(error){ next(error); }
});
module.exports = router;
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const path = require('path');
const express = require('express');
const router = require('express').Router();
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../models/build_info');
const registry = require('../services/session_registry');
const values = {
title: conf.environment !== 'production' ? 'dev' : '',
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
logo: conf.logo,
...buildInfo,
};
// Serve front-end vendor libraries straight from node_modules (same convention
// as the sibling apps), and the app's own JS/CSS/img from public/.
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'];
frontEndModules.forEach(dep => {
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}));
});
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}));
// Liveness probe — no auth.
router.get('/health', (req, res) => {
res.json({status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit});
});
router.get('/', (req, res) => res.redirect(302, '/dashboard'));
// Page shells. The client framework (app-base.js + app.js) loads data via the
// authenticated /api/* endpoints and gates the UI on /api/user/me, so these
// render unauthenticated (like the sibling apps) and the client redirects to
// /login when there's no valid session.
router.get('/login', (req, res) => res.render('login', {
...values,
redirect: '/',
oidcEnabled: !!(conf.oidc && conf.oidc.enabled),
}));
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
router.get('/sessions', (req, res) => res.render('sessions', {...values}));
router.get('/audit', (req, res) => res.render('audit', {...values}));
module.exports = router;
+17
View File
@@ -0,0 +1,17 @@
'use strict';
// Minimal user endpoint the client framework needs: GET /api/user/me tells the
// browser who it is and whether it's an admin (drives login state + nav).
const router = require('express').Router();
const { isAdmin } = require('../middleware/auth');
router.get('/me', (req, res) => {
res.json({
username: req.user && req.user.username,
groups: req.groups || [],
isAdmin: isAdmin(req),
});
});
module.exports = router;