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
+127
View File
@@ -0,0 +1,127 @@
'use strict';
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
/**
* Minimal OpenID Connect authorization-code + PKCE client.
*
* The SSO publishes no jwks_uri, so we do not verify ID-token signatures;
* instead we treat the flow as opaque and read identity from the userinfo
* endpoint (the access token is exchanged server-side over TLS). Uses Node's
* global fetch (Node 18+) and crypto — no external dependency.
*
* All endpoints and client config come from conf.oidc (+ clientSecret from
* secrets.js, deep-merged by @simpleworkjs/conf).
*/
const base64url = buf => buf.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// A high-entropy random string for `state` / PKCE verifier.
function randomToken(bytes = 32){
return base64url(crypto.randomBytes(bytes));
}
// PKCE S256 challenge derived from the verifier.
function codeChallengeS256(verifier){
return base64url(crypto.createHash('sha256').update(verifier).digest());
}
// Generate the {state, codeVerifier, codeChallenge} triple for a new login.
function createAuthRequest(){
let state = randomToken(32);
let codeVerifier = randomToken(32);
let codeChallenge = codeChallengeS256(codeVerifier);
return {state, codeVerifier, codeChallenge};
}
// Build the SSO authorize URL the browser is redirected to. `redirectUri`
// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback).
function buildAuthUrl(state, codeChallenge, redirectUri){
let o = conf.oidc;
let params = new URLSearchParams({
response_type: 'code',
client_id: o.clientId,
redirect_uri: redirectUri || o.redirectUri,
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${o.authorizationEndpoint}?${params.toString()}`;
}
// Exchange an authorization code for tokens at the token endpoint. `redirectUri`
// must match the one used in buildAuthUrl (per-host for per-host SSO).
async function exchangeCode(code, codeVerifier, redirectUri){
let o = conf.oidc;
let body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri || o.redirectUri,
client_id: o.clientId,
client_secret: o.clientSecret,
code_verifier: codeVerifier,
});
let res = await fetch(o.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.toString(),
});
if(!res.ok){
let text = await res.text().catch(() => '');
let error = new Error('OidcTokenExchangeFailed');
error.name = 'OidcTokenExchangeFailed';
error.message = `Token exchange failed (${res.status}): ${text}`;
error.status = 502;
throw error;
}
return res.json();
}
// Fetch the userinfo claims for an access token.
async function fetchUserInfo(accessToken){
let o = conf.oidc;
let res = await fetch(o.userinfoEndpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if(!res.ok){
let error = new Error('OidcUserInfoFailed');
error.name = 'OidcUserInfoFailed';
error.message = `Userinfo request failed (${res.status})`;
error.status = 502;
throw error;
}
return res.json();
}
// Pull the app username and group list out of userinfo claims per conf.
function claimsToIdentity(claims){
let o = conf.oidc;
let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub;
let groups = claims[o.groupsClaim || 'groups'] || [];
if(!Array.isArray(groups)) groups = [groups].filter(Boolean);
return {username, groups, claims};
}
module.exports = {
randomToken,
codeChallengeS256,
createAuthRequest,
buildAuthUrl,
exchangeCode,
fetchUserInfo,
claimsToIdentity,
};
+23
View File
@@ -0,0 +1,23 @@
'use strict';
/**
* Constrain a post-login redirect target to a same-origin path.
*
* Rejects anything that could leave the site or execute script:
* - absolute URLs ("https://evil.com") -> not a "/" path
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
* - scheme targets ("javascript:...", "data:...") -> XSS
* Anything not a plain "/path" falls back to "/".
*
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
* two in sync.
*/
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
module.exports = {safeInternalPath};