Compare commits

...

9 Commits

Author SHA1 Message Date
wmantly 9db530565d Merge pull request #5 from theta42/feature/ui-unification
Release 1.3.0: unified front-end UI shell
2026-07-26 00:30:12 -04:00
wmantly fe6306b7d3 Release 1.3.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly 240563e093 logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:08 -04:00
wmantly 047e54ce50 app.api.delete: accept the (url, data, callback) form formAJAX uses; defer the login-card reveal to DOM ready
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (the host/DNS delete buttons) landed its callback in
the data slot and never ran.

The login page's "reveal the card once we know you're logged out" branch
touched an element further down the same page, which threw when
isLoggedIn answered before the parser got there (it always did without a
stored token). It now runs on DOM ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly ee76088f86 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

jump-host specifics:
- .group-required base rule added to styles.css (no gated nav items yet).
- #spa-shell drops its inline margin-top; styles.css already sets it, and
  the shared shell adjusts it when a banner is shown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly 8db46bd9d9 Merge pull request #4 from theta42/release/v1.2.0
Release 1.2.0
2026-07-25 16:38:50 -04:00
wmantly 67e2fc54c2 Release 1.2.0: adopt shared @simpleworkjs/* packages; fix directory envelope drift
Rewire onto the shared @simpleworkjs/oidc-client, /directory-schema, /ldap, and
/app-stack packages (deleting the byte-identical local forks). utils/access.js
now fetches reachable hosts through the shared directory client, which
validates the {results} envelope and treats envelope drift as a failed group
rather than silently returning []. models/user_ldap.js is a thin wrapper over
createLdapClient (loose TLS default preserved). build_info moves to utils/ with
the shared {buildVersion,buildHash,buildYear} shape. Align ldapts ^8.1.8 and
redis ^6.1.0. Lockfile regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 15:55:03 -04:00
wmantly 23d99980ce Merge pull request #3 from theta42/feature/web-ui-rework
Rebuild web UI on the shared theta42 stack; OIDC + local-admin auth
2026-07-23 20:58:54 -04:00
wmantly aa3b3ed515 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>
2026-07-23 20:57:34 -04:00
39 changed files with 2619 additions and 664 deletions
+45
View File
@@ -4,6 +4,51 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.3.0] - 2026-07-26
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Added
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
- `#spa-shell` dropped its inline `margin-top`; `styles.css` already sets it and the shared shell adjusts it when a banner is shown.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
## [1.2.0] - 2026-07-25
### Added
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org, replacing this app's byte-identical forks of the same code so the theta42 apps share one codebase and API schema:
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, `routes/auth.js`; `models/index.js` wires the factory and the local-admin bootstrap.
- `@simpleworkjs/directory-schema` — the sso↔jump-host directory contract. `utils/access.js` now fetches reachable hosts through the shared `createDirectoryClient` (`getResourcesByGroup`).
- `@simpleworkjs/ldap``models/user_ldap.js` is now a thin wrapper over `createLdapClient`, preserving this app's loose TLS default (`rejectUnauthorized: false`) and the exact export shape.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `build_info` moved from `models/` to `utils/`; `routes/render.js` uses `mountStaticModules`.
### Fixed
- **Directory envelope drift was silently treated as "no reachable hosts".** `utils/access.js` previously read `data.results || []`, so if the SSO directory ever returned a bare array (envelope drift) every per-group query collapsed to `[]` and no user could bridge. The shared client now validates the `{ results }` envelope on every call and treats an envelope violation as a failed group fetch rather than silently returning `[]`.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`, `redis` `^4.7``^6.1.0` (the direct `redis` dep is unused — only `model-redis` is used, which already brings `redis` ^6.1.0). The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
## [1.1.0] - 2026-07-23
### Changed
- **Rebuilt the web UI on the shared theta42 app stack** so it looks and behaves like the SSO Manager and Proxy: Express + EJS with the same `top.ejs`/`bottom.ejs` shell, Bootstrap 5, jQuery, jq-repeat, FontAwesome, the shared `app-base.js` client framework, and Socket.IO — replacing the bespoke minimal theme. Dashboard, Sessions, and Audit pages now render in the common look/feel.
- **Web-UI auth is now OIDC + a local anti-lockout admin** (the proxy's model), replacing the direct LDAP-bind login. Normal users log in through the SSO ("Log in with SSO"); a local `auth.adminUsers` account (bootstrapped on first boot, password from `auth.localAdminPass`) still works if the SSO is unreachable. Admin access is gated by `auth.adminGroups` or the local admin account. New config: `oidc` block + `auth.adminUsers`/`localAdminPass`. **Note:** the SSH bridge and its own LDAP auth are unchanged — this only affects the web management UI.
## [1.0.1] - 2026-07-23 ## [1.0.1] - 2026-07-23
### Fixed ### Fixed
+7 -3
View File
@@ -91,9 +91,13 @@ The default SSH port is **2222** so the service needs no privilege. To listen on
## Web UI / API ## Web UI / API
`https://jump.example.com/` (behind the proxy) — admin login uses your LDAP `https://jump.example.com/` (behind the proxy) — built on the same
credentials and requires membership in `auth.adminGroups` (default Express + EJS + Bootstrap stack as the [SSO Manager](https://theta42.github.io/sso-manager-node/)
`app_sso_admin`). and [Proxy](https://theta42.github.io/proxy/), so it looks and behaves like the
rest of the stack. Login is **OIDC against the SSO** (the "Log in with SSO"
button) plus a **local anti-lockout admin** that works even if the SSO is
unreachable. Admin access requires membership in `auth.adminGroups` (default
`app_sso_admin`) or being the local `auth.adminUsers` account.
- `GET /health` — open; `{status, activeSessions, version}` - `GET /health` — open; `{status, activeSessions, version}`
- `GET /api/sessions` — active sessions - `GET /api/sessions` — active sessions
+4 -2
View File
@@ -98,8 +98,10 @@ Byte counts per direction are tallied cheaply for the audit record.
## Web UI, API & audit ## Web UI, API & audit
A small Express app on `:3002` (admin login via LDAP, gated by An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and
`auth.adminGroups`) exposes: look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a
local anti-lockout admin (`auth.adminUsers`), with admin access gated by
`auth.adminGroups`. It exposes:
- `GET /health` — open; `{status, activeSessions, version}` - `GET /health` — open; `{status, activeSessions, version}`
- `GET /api/sessions` — active sessions - `GET /api/sessions` — active sessions
+2 -1
View File
@@ -94,7 +94,8 @@ Every key is documented in
[`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example): [`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example):
`ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh` `ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh`
(`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts, (`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts,
`maxSessions`), `web.port`, `auth.adminGroups`, and `redis`. `maxSessions`), `web.port`, `oidc` (web-UI SSO login), `auth`
(`adminGroups` / `adminUsers` / `localAdminPass`), and `redis`.
## Verifying ## Verifying
+31 -21
View File
@@ -1,37 +1,47 @@
'use strict'; 'use strict';
const path = require('path');
const express = require('express'); const express = require('express');
const conf = require('@simpleworkjs/conf'); const compression = require('compression');
const registry = require('./services/session_registry'); require('./models'); // wire model-redis + register models
const { requireAdmin } = require('./middleware/auth');
const buildInfo = require('./models/build_info');
const app = express(); const app = express();
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); app.set('views', require('path').join(__dirname, 'views'));
app.use('/public', express.static(path.join(__dirname, 'public')));
// Open health check — no auth (used by Docker/compose + the proxy). // Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
app.get('/health', (req, res) => { // Set as an app local so every res.render has it, including routes that don't
res.json({ status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit }); // spread the render router's `values` object.
app.locals.ui = require('./utils/ui');
app.use(compression());
app.use(express.json());
app.use(express.urlencoded({extended: false}));
// Page shells + static assets + /health (mostly unauthenticated; the client
// gates itself on /api/user/me and redirects to /login).
app.use('/', require('./routes/render'));
// API — auth handled per-router inside (see routes/api.js).
app.use('/api', require('./routes/api'));
// 404
app.use((req, res, next) => {
const error = new Error('Not Found');
error.status = 404;
next(error);
}); });
// Login routes (no session required). // Error handler — JSON for API, redirect to login for pages on 401.
app.use('/', require('./routes/auth'));
// Everything else requires an admin session.
app.use(requireAdmin);
app.use('/api', require('./routes/api'));
app.use('/', require('./routes/index'));
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error(err); const status = err.status || 500;
if (req.path.startsWith('/api/')) return res.status(500).json({ error: err.message }); if(status >= 500) console.error(err);
res.status(500).render('login', { error: 'Internal error.', name: conf.name }); if(req.path.startsWith('/api/')){
return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'});
}
res.status(status).send(err.message || 'Error');
}); });
module.exports = app; module.exports = app;
+13 -4
View File
@@ -2,24 +2,33 @@
'use strict'; 'use strict';
// Boots BOTH faces of the jump host: the SSH front door (services/ssh_server) // Boots BOTH faces of the jump host: the SSH front door (services/ssh_server)
// and the web UI/API (app.js). One process, one redis, shared audit store. // and the web UI/API (app.js + Socket.IO). One process, one redis, shared
// audit store.
const http = require('http'); const http = require('http');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { Server } = require('socket.io');
require('../models'); // wire model-redis + register models require('../models');
const app = require('../app'); const app = require('../app');
const middleware = require('../middleware/auth');
const sshServer = require('../services/ssh_server'); const sshServer = require('../services/ssh_server');
// Web server
const webPort = (conf.web && conf.web.port) || 3002; const webPort = (conf.web && conf.web.port) || 3002;
const server = http.createServer(app); const server = http.createServer(app);
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
// We don't push anything yet, but serving /socket.io keeps the shared front-end
// working exactly as it does in the sibling apps.
const io = new Server(server);
io.use(middleware.authIO);
app.io = io;
server.listen(webPort, () => { server.listen(webPort, () => {
console.log(`[web] jump-host UI/API on :${server.address().port}`); console.log(`[web] jump-host UI/API on :${server.address().port}`);
}); });
// SSH server
sshServer.start(); sshServer.start();
function shutdown() { function shutdown() {
+25 -3
View File
@@ -7,6 +7,7 @@
module.exports = { module.exports = {
name: 'Jump Host', name: 'Jump Host',
logo: '/static/img/theta42.svg',
// LDAP directory the users live in (same directory the SSO manages). // LDAP directory the users live in (same directory the SSO manages).
// bindDN needs: read on ou=people (users + sshPublicKey) and ou=groups, // bindDN needs: read on ou=people (users + sshPublicKey) and ou=groups,
@@ -59,11 +60,32 @@ module.exports = {
port: 3002, port: 3002,
}, },
// Web UI/API login. Same model as the proxy: OIDC against the SSO for
// normal users, plus a local anti-lockout admin that works even if the SSO
// is unreachable. OIDC endpoints + clientId/clientSecret live in the
// secrets file; enabled:false hides the "Log in with SSO" button.
oidc: {
enabled: false,
issuer: '',
authorizationEndpoint: '',
tokenEndpoint: '',
userinfoEndpoint: '',
clientId: '',
clientSecret: '',
redirectUri: '',
scopes: ['openid', 'profile', 'email', 'groups'],
groupsClaim: 'groups',
usernameClaim: 'preferred_username',
},
auth: { auth: {
// LDAP groups whose members may use the web UI/API. // OIDC group memberships that grant web UI/API admin access.
adminGroups: ['app_sso_admin'], adminGroups: ['app_sso_admin'],
// Web session lifetime (ms). // Local anti-lockout admin: the first name here is bootstrapped as a
sessionTTLms: 12 * 60 * 60 * 1000, // redis-backed user on first boot (password from localAdminPass, or a
// random one printed to the log once). Lets you in even with OIDC down.
adminUsers: ['jumpadmin'],
localAdminPass: '',
}, },
redis: { redis: {
+45 -27
View File
@@ -1,36 +1,54 @@
'use strict'; 'use strict';
// Web UI/API auth: a signed-in admin session (cookie) whose LDAP groups // Web UI/API auth, mirroring the sibling apps: a browser session token
// intersect conf.auth.adminGroups. /health and the login routes are exempt // (`auth-token: <AuthToken uuid>`) established via local login or the OIDC
// (mounted before this middleware). // callback. The token carries the group snapshot captured at login.
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const Session = require('../models/session'); const { Auth } = require('../models');
function parseCookies(header) { async function auth(req, res, next){
const out = {}; try{
(header || '').split(';').forEach((p) => { req.token = await Auth.checkToken(req.header('auth-token'));
const i = p.indexOf('='); req.user = req.token.user;
if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
}); return next();
return out; }catch(error){
next(error);
}
} }
async function requireAdmin(req, res, next) { // Is the authenticated request an admin? Admin = a session whose OIDC groups
const token = parseCookies(req.headers.cookie).jump_session; // intersect conf.auth.adminGroups, OR the local anti-lockout admin
const session = await Session.verify(token); // (conf.auth.adminUsers). The whole web UI is admin-only (audit + metrics).
if (!session) { function isAdmin(req){
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' }); const adminGroups = (conf.auth && conf.auth.adminGroups) || [];
return res.redirect('/login'); const adminUsers = (conf.auth && conf.auth.adminUsers) || [];
} const username = req.user && req.user.username;
const groups = JSON.parse(session.groups || '[]'); if(username && adminUsers.includes(username)) return true;
const admin = (conf.auth.adminGroups || []).some((g) => groups.includes(g)); return (req.groups || []).some(g => adminGroups.includes(g));
if (!admin) {
if (req.path.startsWith('/api/')) return res.status(403).json({ error: 'forbidden' });
return res.status(403).render('login', { error: 'Your account is not a jump-host admin.', name: conf.name });
}
req.jumpUser = { uid: session.uid, groups };
next();
} }
module.exports = { requireAdmin, parseCookies }; async function requireAdmin(req, res, next){
if(isAdmin(req)) return next();
const error = new Error('Forbidden');
error.name = 'Forbidden';
error.status = 403;
error.message = 'Admin access required.';
next(error);
}
// Socket.IO handshake auth (app-base.js connects with the session token).
async function authIO(socket, next){
try{
const tok = socket.handshake.auth && socket.handshake.auth.token;
if(!tok) return next(Auth.errors.login());
const token = await Auth.checkToken(tok);
socket.user = token.user;
next();
}catch(error){
next(error);
}
}
module.exports = { auth, requireAdmin, authIO, isAdmin };
-24
View File
@@ -1,24 +0,0 @@
'use strict';
// Short git commit, baked into /app/.build_commit at image build time (see
// Dockerfile gitinfo stage) or resolved from git on bare metal.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function resolve() {
try {
const baked = path.join(__dirname, '../../.build_commit');
if (fs.existsSync(baked)) return fs.readFileSync(baked, 'utf8').trim();
} catch (_) {}
try {
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch (_) {}
return 'unknown';
}
let version = 'unknown';
try { version = require('../package.json').version; } catch (_) {}
module.exports = { commit: resolve(), version };
+21 -4
View File
@@ -1,11 +1,12 @@
'use strict'; 'use strict';
// model-redis backing (same store the other stack apps use). Table is the // model-redis backing (same store the sibling apps use). Table is the base
// base class; getRedis() exposes the underlying node-redis client for the // class; getRedis() exposes the underlying node-redis client for the counters
// counters and sorted-set index in models/metrics.js and models/audit_event.js. // and sorted-set index in models/metrics.js and models/audit_event.js.
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { setUpTable } = require('model-redis'); const { setUpTable } = require('model-redis');
const { createOidcClient, bootstrapLocalAdmin } = require('@simpleworkjs/oidc-client');
const Table = setUpTable(conf.redis); const Table = setUpTable(conf.redis);
@@ -31,5 +32,21 @@ async function getRedis() {
module.exports.getRedis = getRedis; module.exports.getRedis = getRedis;
require('./session'); // Register models (order matters: User before AuthToken's relation resolves).
require('./user_redis'); // User (redis-backed local + OIDC JIT)
// Shared OIDC client (authorization-code + PKCE): session models (Token,
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
// /oidc/callback router — all created on this app's Table/redis. jump-host has
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent).
const oidcClient = createOidcClient({ Table });
module.exports.Token = oidcClient.Token;
module.exports.AuthToken = oidcClient.AuthToken;
module.exports.OidcState = oidcClient.OidcState;
module.exports.Auth = oidcClient.Auth;
module.exports.authRouter = oidcClient.router;
require('./audit_event'); require('./audit_event');
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
-42
View File
@@ -1,42 +0,0 @@
'use strict';
// Web UI sessions — a signed-in admin's browser token. model-redis Table with
// a TTL so entries expire and survive restarts.
const crypto = require('crypto');
const Table = require('.');
class Session extends Table {
static _key = 'token';
static _keyMap = {
'token': {default: function(){ return crypto.randomUUID() }, type: 'string'},
'uid': {isRequired: true, type: 'string'},
'groups': {default: '[]', type: 'string'},
'created_on': {default: function(){ return (new Date).getTime() }},
'expires_at': {default: 0, type: 'number'},
}
}
Session.register();
Session.start = async function (uid, groups, ttlMs) {
return Session.create({
uid,
groups: JSON.stringify(groups || []),
expires_at: Date.now() + ttlMs,
}, { ttl: Math.ceil(ttlMs / 1000) });
};
Session.verify = async function (token) {
if (!token) return null;
let session;
try {
session = await Session.get(token);
} catch (_) {
return null;
}
if (!session || session.expires_at < Date.now()) return null;
return session;
};
module.exports = Session;
+12 -99
View File
@@ -1,109 +1,22 @@
'use strict'; 'use strict';
// Thin LDAP helpers — the jump host's entire LDAP surface: // Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the
// shared @simpleworkjs/ldap package:
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null // getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
// getGroups(dn) -> [cn, ...] (groupOfNames membership) // getGroups(dn) -> [cn, ...] (groupOfNames membership)
// checkPassword(dn, pw) -> bool (simple bind as the user) // checkPassword(dn, pw) -> bool (simple bind as the user)
// addSshKey(dn, keyLine) -> void (idempotent multi-value add) // addSshKey(dn, keyLine) -> void (idempotent multi-value add)
// //
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and // Behavior is unchanged from the previous in-tree implementation: posixAccount
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check, // user filter, groupOfNames group filter, bind-as-user password check,
// TypeOrValueExists treated as success on key add). // TypeOrValueExists treated as success on key add, and the same loose TLS
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
const { Client, Change, Attribute } = require('ldapts');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { createLdapClient } = require('@simpleworkjs/ldap');
function ldapConf() { const ldapConf = conf.ldap || {};
return conf.ldap || {}; module.exports = createLdapClient({
} ...ldapConf,
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
function makeClient() { });
const c = ldapConf();
return new Client({
url: c.url,
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
});
}
// Escape a value being interpolated into an LDAP filter (RFC 4515).
function escapeFilter(value) {
return String(value).replace(/[\\*()\0]/g, (ch) => ({
'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00',
}[ch]));
}
async function withClient(fn) {
const c = ldapConf();
const client = makeClient();
try {
await client.bind(c.bindDN, c.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
async function getUser(uid) {
const c = ldapConf();
const attr = c.userNameAttribute || 'uid';
return withClient(async (client) => {
const { searchEntries } = await client.search(c.userBase, {
scope: 'sub',
filter: `(&(objectClass=posixAccount)(${attr}=${escapeFilter(uid)}))`,
attributes: ['dn', attr, 'cn', 'sshPublicKey'],
});
if (!searchEntries.length) return null;
const e = searchEntries[0];
let keys = e.sshPublicKey || [];
if (!Array.isArray(keys)) keys = [keys];
return {
dn: e.dn,
uid: String(e[attr]),
sshPublicKeys: keys.map(String),
};
});
}
async function getGroups(dn) {
const c = ldapConf();
return withClient(async (client) => {
const { searchEntries } = await client.search(c.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)(member=${escapeFilter(dn)}))`,
attributes: ['cn'],
});
return searchEntries.map((e) => String(e.cn));
});
}
async function checkPassword(dn, password) {
if (!password) return false;
const client = makeClient();
try {
await client.bind(dn, password);
return true;
} catch (_) {
return false;
} finally {
await client.unbind().catch(() => {});
}
}
async function addSshKey(dn, keyLine) {
return withClient(async (client) => {
try {
await client.modify(dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'sshPublicKey', values: [keyLine] }),
}),
]);
} catch (error) {
// Same de-dup semantics as the SSO's User.addSSHkey.
if (error.name === 'TypeOrValueExistsError') return;
throw error;
}
});
}
module.exports = { getUser, getGroups, checkPassword, addSshKey, escapeFilter, makeClient };
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const Table = require('.');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const saltRounds = 10;
class User extends Table{
static _key = 'username';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_by': {default:"__NONE__", isRequired: false, type: 'string',},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'username': {isRequired: true, type: 'string', min: 3, max: 500},
'password': {isRequired: true, type: 'string', min: 3, max: 500, isPrivate: true},
'backing': {default:"redis", isRequired: false, type: 'string',},
}
static backing = 'redis'
static async create(data) {
try{
data['password'] = await bcrypt.hash(data['password'], saltRounds);
data['backing'] = data['backing'] || 'redis';
return await super.create(data)
}catch(error){
throw error;
}
}
async setPassword(data){
try{
data['password'] = await bcrypt.hash(data['password'], saltRounds);
return this.update(data);
}catch(error){
throw error;
}
}
/**
* Just-in-time provisioning for an OIDC-authenticated user. Creates the
* local user on first login so relations (tokens, created_by, grants) have
* something to point at. OIDC users get a random, unusable password — they
* authenticate through the SSO, never the local password form.
*
* @param {Object} data - {username, ...} from the OIDC userinfo claims
* @returns {User} the existing or newly created user
*/
static async upsertOidc(data){
try{
return await User.get(data.username);
}catch(error){
return await User.create({
username: data.username,
password: crypto.randomBytes(24).toString('hex'),
created_by: data.username,
backing: 'oidc',
});
}
}
static async login(data){
try{
let user = await User.get(data);
let auth = await bcrypt.compare(data.password, user.password);
if(auth){
return user
}else{
throw this.errors.login();
}
}catch(error){
console.error('!!!!!!!!!!', error)
if (error == 'Authentication failure'){
throw this.errors.login()
}
throw error;
}
};
}
User.register();
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
// registered. See the package lib/bootstrap.js for the original logic.
+589 -146
View File
@@ -1,20 +1,34 @@
{ {
"name": "t42-jump-host", "name": "t42-jump-host",
"version": "1.0.1", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-jump-host", "name": "t42-jump-host",
"version": "1.0.1", "version": "1.3.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.2.1", "express": "^5.2.1",
"ldapts": "^8.1.2", "express-rate-limit": "^8.5.2",
"jq-repeat": "^2.2.0",
"jquery": "^4.0.0",
"ldapts": "^8.1.8",
"model-redis": "^1.6.0", "model-redis": "^1.6.0",
"redis": "^4.7.0", "moment": "^2.30.1",
"mustache": "^4.2.0",
"redis": "^6.1.0",
"socket.io": "^4.8.3",
"ssh2": "^1.16.0" "ssh2": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
@@ -24,63 +38,108 @@
"node": ">=20.14" "node": ">=20.14"
} }
}, },
"node_modules/@redis/bloom": { "node_modules/@fortawesome/fontawesome-free": {
"version": "1.2.0", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", "integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@redis/bloom": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.1.0.tgz",
"integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==",
"license": "MIT",
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": { "peerDependencies": {
"@redis/client": "^1.0.0" "@redis/client": "^6.1.0"
} }
}, },
"node_modules/@redis/client": { "node_modules/@redis/client": {
"version": "1.6.1", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2"
"generic-pool": "3.9.0",
"yallist": "4.0.0"
}, },
"engines": { "engines": {
"node": ">=14" "node": ">= 20.0.0"
} },
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@redis/client": "^1.0.0" "@node-rs/xxhash": "^1.1.0",
"@opentelemetry/api": ">=1 <2"
},
"peerDependenciesMeta": {
"@node-rs/xxhash": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
}
} }
}, },
"node_modules/@redis/json": { "node_modules/@redis/json": {
"version": "1.0.7", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.1.0.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", "integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==",
"license": "MIT", "license": "MIT",
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": { "peerDependencies": {
"@redis/client": "^1.0.0" "@redis/client": "^6.1.0"
} }
}, },
"node_modules/@redis/search": { "node_modules/@redis/search": {
"version": "1.2.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.1.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", "integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==",
"license": "MIT", "license": "MIT",
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": { "peerDependencies": {
"@redis/client": "^1.0.0" "@redis/client": "^6.1.0"
} }
}, },
"node_modules/@redis/time-series": { "node_modules/@redis/time-series": {
"version": "1.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", "integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==",
"license": "MIT", "license": "MIT",
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": { "peerDependencies": {
"@redis/client": "^1.0.0" "@redis/client": "^6.1.0"
}
},
"node_modules/@simpleworkjs/app-stack": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
"license": "MIT",
"dependencies": {
"express": "^5.2.1"
},
"engines": {
"node": ">=18.0.0"
} }
}, },
"node_modules/@simpleworkjs/conf": { "node_modules/@simpleworkjs/conf": {
@@ -95,6 +154,74 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/@simpleworkjs/directory-schema": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.0.0.tgz",
"integrity": "sha512-thZhPGNdDYlD8rlhXidnbCHTKjdSkj9ag1zE/gz1AwuclYypsKAP+v3BAvcZ/YDQP8RBDJNPXof5EpVheLovTg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/ldap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
"license": "MIT",
"dependencies": {
"ldapts": "^8.1.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/oidc-client": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/oidc-client/-/oidc-client-1.0.0.tgz",
"integrity": "sha512-AzxIaE32p4yKDlp0mWvZp1wmXi8tMFckcPwMiQyZrDgEC0IybGhbjppPa+vNGx1AoVLp64vRL/zR3yXb/19NPg==",
"license": "MIT",
"dependencies": {
"@simpleworkjs/conf": "^1.2.0",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -143,6 +270,29 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcrypt-pbkdf": { "node_modules/bcrypt-pbkdf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@@ -202,6 +352,25 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
@@ -305,6 +474,60 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -345,6 +568,23 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cpu-features": { "node_modules/cpu-features": {
"version": "0.0.10", "version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
@@ -429,6 +669,79 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/engine.io": {
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -517,6 +830,25 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz",
"integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/extend": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -608,15 +940,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -762,6 +1085,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -840,6 +1172,25 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/jq-repeat": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.1.tgz",
"integrity": "sha512-1M0jRo7rJKO2mHbeENNjUx9YMOCobUuG4XElKt2NWDc4+j22wrZELqt7Twy95BBEkZQuwDteoPTd1etupZZPtQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"jquery": ">=3.0.0",
"mustache": ">=4.0.0"
}
},
"node_modules/jquery": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
"license": "MIT"
},
"node_modules/ldapts": { "node_modules/ldapts": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz", "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz",
@@ -928,92 +1279,13 @@
"redis": "^6.1.0" "redis": "^6.1.0"
} }
}, },
"node_modules/model-redis/node_modules/@redis/bloom": { "node_modules/moment": {
"version": "6.1.0", "version": "2.30.1",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.1.0.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20.0.0" "node": "*"
},
"peerDependencies": {
"@redis/client": "^6.1.0"
}
},
"node_modules/model-redis/node_modules/@redis/client": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz",
"integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"@node-rs/xxhash": "^1.1.0",
"@opentelemetry/api": ">=1 <2"
},
"peerDependenciesMeta": {
"@node-rs/xxhash": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/model-redis/node_modules/@redis/json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-6.1.0.tgz",
"integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==",
"license": "MIT",
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"@redis/client": "^6.1.0"
}
},
"node_modules/model-redis/node_modules/@redis/search": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-6.1.0.tgz",
"integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==",
"license": "MIT",
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"@redis/client": "^6.1.0"
}
},
"node_modules/model-redis/node_modules/@redis/time-series": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.1.0.tgz",
"integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==",
"license": "MIT",
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"@redis/client": "^6.1.0"
}
},
"node_modules/model-redis/node_modules/redis": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/redis/-/redis-6.1.0.tgz",
"integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "6.1.0",
"@redis/client": "6.1.0",
"@redis/json": "6.1.0",
"@redis/search": "6.1.0",
"@redis/time-series": "6.1.0"
},
"engines": {
"node": ">= 20.0.0"
} }
}, },
"node_modules/ms": { "node_modules/ms": {
@@ -1022,6 +1294,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/nan": { "node_modules/nan": {
"version": "2.28.0", "version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
@@ -1038,6 +1319,26 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -1116,6 +1417,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1140,6 +1450,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1265,20 +1584,19 @@
} }
}, },
"node_modules/redis": { "node_modules/redis": {
"version": "4.7.1", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", "resolved": "https://registry.npmjs.org/redis/-/redis-6.1.0.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", "integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==",
"license": "MIT", "license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": { "dependencies": {
"@redis/bloom": "1.2.0", "@redis/bloom": "6.1.0",
"@redis/client": "1.6.1", "@redis/client": "6.1.0",
"@redis/graph": "1.1.1", "@redis/json": "6.1.0",
"@redis/json": "1.0.7", "@redis/search": "6.1.0",
"@redis/search": "1.2.0", "@redis/time-series": "6.1.0"
"@redis/time-series": "1.1.0" },
"engines": {
"node": ">= 20.0.0"
} }
}, },
"node_modules/router": { "node_modules/router": {
@@ -1297,6 +1615,26 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -1452,6 +1790,90 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.21.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ssh2": { "node_modules/ssh2": {
"version": "1.17.0", "version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
@@ -1573,6 +1995,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1597,11 +2025,26 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yallist": { "node_modules/ws": {
"version": "4.0.0", "version": "8.21.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "ISC" "license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
} }
} }
} }
+17 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-jump-host", "name": "t42-jump-host",
"version": "1.0.1", "version": "1.3.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics", "description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [ "author": [
{ {
@@ -19,12 +19,26 @@
"test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js" "test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.2.1", "express": "^5.2.1",
"ldapts": "^8.1.2", "express-rate-limit": "^8.5.2",
"jq-repeat": "^2.2.0",
"jquery": "^4.0.0",
"ldapts": "^8.1.8",
"model-redis": "^1.6.0", "model-redis": "^1.6.0",
"redis": "^4.7.0", "moment": "^2.30.1",
"mustache": "^4.2.0",
"redis": "^6.1.0",
"socket.io": "^4.8.3",
"ssh2": "^1.16.0" "ssh2": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
-31
View File
@@ -1,31 +0,0 @@
:root { --bg:#0f1115; --panel:#181b22; --line:#272b34; --fg:#e6e8ec; --mut:#8b93a1; --acc:#4f9cf9; --bad:#ff6b6b; --ok:#4ec9a5; }
* { box-sizing: border-box; }
body { margin:0; font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
a { color:var(--acc); text-decoration:none; } a:hover { text-decoration:underline; }
.nav { display:flex; align-items:center; gap:16px; padding:12px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
.brand { font-weight:600; } .brand small { color:var(--mut); font-weight:400; }
.nav .spacer { flex:1; } .nav .who { color:var(--mut); }
.wrap { max-width:1100px; margin:0 auto; padding:24px 20px; }
h1 { font-size:20px; margin:0 0 16px; } h2 { font-size:15px; margin:24px 0 8px; }
.tiles { display:flex; gap:16px; flex-wrap:wrap; }
.tile { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px 20px; min-width:150px; }
.tile .n { display:block; font-size:28px; font-weight:600; } .tile .l { color:var(--mut); }
.cols { display:grid; grid-template-columns:2fr 1fr; gap:24px; }
@media (max-width:800px){ .cols { grid-template-columns:1fr; } }
table { width:100%; border-collapse:collapse; margin-top:8px; }
th,td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); }
th { color:var(--mut); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.03em; }
td.r,th.r { text-align:right; }
tr.bad td { color:var(--bad); }
.muted { color:var(--mut); }
.more { font-size:12px; font-weight:400; margin-left:8px; }
.foot { max-width:1100px; margin:0 auto; padding:16px 20px; color:var(--mut); font-size:12px; }
.filters { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.filters input,.filters select,.login input { background:#0c0e12; border:1px solid var(--line); color:var(--fg); border-radius:7px; padding:7px 10px; }
button { background:var(--acc); color:#fff; border:0; border-radius:7px; padding:8px 14px; cursor:pointer; font:inherit; }
button.link { background:none; color:var(--acc); padding:0; }
.inline { display:inline; } .pager { display:flex; gap:16px; align-items:center; margin-top:16px; color:var(--mut); }
.center { display:grid; place-items:center; min-height:100vh; }
.card.login { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:28px; width:320px; display:flex; flex-direction:column; gap:12px; }
.card.login h1 { margin:0 0 8px; } .card.login label { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--mut); }
.card.login .hint { color:var(--mut); font-size:12px; margin:4px 0 0; } .err { color:var(--bad); margin:0; }
+24
View File
@@ -0,0 +1,24 @@
nav.navbar{
padding-left: 1em;
padding-right: 1em;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
#spa-shell {
margin-top: 4.5rem;
padding-bottom: 1em;
flex-grow: 1;
}
.card-title{
font-weight: bold;
}
.group-required{
display: none;
}
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
<!-- Network nodes -->
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
<!-- Connection lines -->
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+21
View File
@@ -0,0 +1,21 @@
'use strict';
// Jump-host page controllers. app.api / app.auth come from app-base.js (the
// shared client framework); this adds the jump-host data calls and the small
// render helpers each page uses.
app.jump = (function(app){
function metrics(cb){ app.api.get('metrics', cb); }
function sessions(cb){ app.api.get('sessions', cb); }
function audit(query, cb){
var qs = $.param(query || {});
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
}
return {metrics: metrics, sessions: sessions, audit: audit};
})(app);
// Shared render helpers.
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
+763
View File
@@ -0,0 +1,763 @@
// Shared client framework for the theta42 apps.
//
// This file is byte-identical across sso-manager-node, proxy and jump-host —
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
// the /api/user/me response), never from edits to this file. Edit all three
// copies together.
//
// jQuery 4 safe: no $.isFunction, no $.holdReady.
var app = {};
app.pubsub = (function(){
app.topics = {};
app.subscribe = function(topic, listener){
if(topic instanceof RegExp){
listener.match = topic;
topic = "__REGEX__";
}
// create the topic if not yet created
if(!app.topics[topic]) app.topics[topic] = [];
// add the listener
app.topics[topic].push(listener);
}
app.matchTopics = function(topic){
topic = topic || '';
var matches = [... app.topics[topic] ? app.topics[topic] : []];
if(!app.topics['__REGEX__']) return matches;
for(var listener of app.topics['__REGEX__']){
if(topic.match(listener.match)) matches.push(listener);
}
return matches;
}
app.publish = function(topic, data){
// send the event to all listeners
app.matchTopics(topic).forEach(function(listener){
setTimeout(function(data, topic){
listener(data || {}, topic);
}, 0, data, topic);
});
}
return this;
})(app);
app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket;
$(document).ready(function(){
socket = io({
auth: {
token: app.auth.getToken()
}
});
// socket.emit('chat message', $('#m').val());
socket.on('P2PSub', function(msg){
msg.data.__noSocket = true;
app.publish(msg.topic, msg.data);
});
app.subscribe(/./g, function(data, topic){
// console.log('local_pubs', data, topic)
if(data.__noSocket) return;
// console.log('local_pubs 2', data, topic)
socket.emit('P2PSub', { topic, data });
});
})
return socket;
})(app);
app.api = (function(app){
var baseURL = '/api/'
// post/put/delete are dual-mode: pass a callback for the node-style
// (error, data, status) form, or omit it to get a Promise that resolves
// with the parsed body and rejects with the error body. get/options return
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
function body(method, url, data, callback){
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: method,
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: method,
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
);
}
});
}
function post(url, data, callback){
return body('POST', url, data, callback);
}
function put(url, data, callback){
return body('PUT', url, data, callback);
}
// Called both as (url, callback) and — from formAJAX, which always passes
// the serialized form as the second argument — as (url, data, callback).
// No request body is sent either way.
function remove(url, data, callback){
if(typeof data === 'function'){
callback = data;
data = undefined;
}
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: 'DELETE',
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: 'DELETE',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
);
}
});
}
function options(url, callback){
return $.ajax({
type: 'OPTIONS',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
}
});
}
function get(url, callback){
return $.ajax({
type: 'GET',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
}
});
}
return {post: post, get: get, put: put, delete: remove, options: options,}
})(app)
app.auth = (function(app){
// One in-flight/cached GET /api/user/me per page load. Every gating
// decision (nav items, per-view forceLogin, group-required elements) reads
// this same promise instead of re-fetching.
var userPromise = null;
function setToken(token){
localStorage.setItem('APIToken', token);
}
function getToken(){
return localStorage.getItem('APIToken');
}
async function getUser(){
try{
return await app.api.get('user/me');
}catch(error){
if(error && error.status === 401) return null;
throw error;
}
}
// Cached current user, or false when there's no token at all. Callers that
// need a fresh copy (after a login or a profile change) pass force.
function loadUser(force){
if(force || !userPromise){
userPromise = getToken() ? getUser() : Promise.resolve(null);
userPromise = userPromise.then(function(user){
app.auth.user = app.auth.perms = user || null;
return user;
});
}
return userPromise;
}
// The apps report group membership two ways: sso-manager-node returns LDAP
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
// is exposed as a synthetic `admin` group so one gating model covers both.
function groupCNs(user){
var raw = (user && (user.memberOf || user.groups)) || [];
if(!Array.isArray(raw)) raw = [raw];
var names = raw.map(function(group){
return String(group).split(',')[0].replace(/^cn=/i, '');
});
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
return names;
}
async function memberOf(groupNameToFind, user){
user = user || await loadUser();
if(!user) return false;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
return groupCNs(user).some(function(group){
return groupNameToFind.includes(group);
});
}
// True when the logged-in user is a global admin (per user/me). Sync — only
// meaningful once isLoggedIn/forceLogin has resolved.
function isAdmin(){
return !!(app.auth.perms && app.auth.perms.isAdmin);
}
// Dual-mode: returns a Promise resolving to the user (or false), and calls
// an optional node-style callback with the same result.
function isLoggedIn(callback){
var promise = loadUser().then(function(user){
return user || false;
});
if(typeof callback === 'function'){
promise.then(function(user){
callback(null, user);
}, function(error){
callback(error, false);
});
}
return promise;
}
function logIn(args, callback){
app.api.post('auth/login', args, function(error, data){
if(data.login){
setToken(data.token);
}
loadUser(true);
callback(error, !!data.token);
});
}
// Clears the session only — the caller decides where to go next (the nav's
// Log Out button uses ui.logoutRedirect).
function logOut(callback){
localStorage.removeItem('APIToken');
userPromise = null;
app.auth.user = app.auth.perms = null;
if(typeof callback === 'function') callback();
}
// Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
// Consume an app token handed back by the OIDC callback via the URL
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
// forwards to the intended page. Returns true if a token was consumed.
function consumeTokenFragment(){
if(!location.hash) return false;
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
var token = params.get('token');
if(!token) return false;
setToken(token);
// redirect comes from the URL fragment (attacker-controllable); only
// allow a same-origin path so it can't become an open redirect / XSS.
var redirect = safeInternalPath(params.get('redirect') || '/');
// Drop the token from the address bar before navigating on.
history.replaceState(null, '', location.pathname + location.search);
window.location.href = redirect;
return true;
}
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
// unauthorised user is kept off the page by a redirect / an error panel
// rather than by pausing document ready.
//
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
// `admin` group covers the OIDC clients' isAdmin flag.
async function forceLogin(requiredGroups){
var user = await loadUser();
if(!user){
logOut(function(){});
location.replace('/login?redirect=' + encodeURIComponent(
location.pathname + location.search
));
return false;
}
if(user.onboardingRequired && location.pathname !== '/onboarding'){
location.replace('/onboarding');
return false;
}
if(requiredGroups && !await memberOf(requiredGroups, user)){
app.util.actionMessage(
`<h1>
<i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b>
<i class="fa-solid fa-triangle-exclamation"></i>
</h1>`,
$('#spa-shell'),
'danger',
);
throw new Error("User does not have permission");
}
return user;
}
// Where to go after a successful login: the ?redirect= query param, or the
// legacy /login/<path> suffix form, constrained to a same-origin path. The
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
// is how the OIDC provider sends an unauthenticated user through login.
function logInRedirect(){
var params = new URLSearchParams(location.search);
var target = params.get('redirect')
|| location.href.replace(location.origin + '/login', '')
|| '/';
window.location.href = safeInternalPath(target);
}
return {
getToken: getToken,
setToken: setToken,
getUser: getUser,
loadUser: loadUser,
groupCNs: groupCNs,
memberOf: memberOf,
isAdmin: isAdmin,
isLoggedIn: isLoggedIn,
safeInternalPath: safeInternalPath,
consumeTokenFragment: consumeTokenFragment,
user: null,
perms: null,
logIn: logIn,
logOut: logOut,
forceLogin,
logInRedirect,
}
})(app);
// Back-compat alias for views that awaited the cached user directly.
Object.defineProperty(app.auth, 'asyncUser', {
get: function(){ return app.auth.loadUser(); },
});
app.user = (function(app){
function list(callback){
app.api.get('user/?detail=true', function(error, data){
callback(error, data);
})
}
function add(args, callback){
app.api.post('user/', args, function(error, data){
callback(error, data);
});
}
function remove(args, callback){
app.api.delete('user/'+ args.username, function(error, data){
callback(error, data);
});
}
function changePassword(args, callback){
app.api.put('users/'+ arg.username || '', args, function(error, data){
callback(error, data);
});
}
return {list, remove};
})(app);
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
// these endpoints; the calls are inert elsewhere.
app.permission = (function(app){
function list(callback){
app.api.get('permission/', function(error, data){
callback(error, data);
});
}
function subjects(callback){
app.api.get('permission/subjects', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('permission/', args, function(error, data){
callback(error, data);
});
}
function remove(id, callback){
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
callback(error, data);
});
}
return {list, subjects, add, remove};
})(app);
app.group = (function(app){
function list(callback){
app.api.get('group/', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('group/', args, function(error, data){
callback(error, data);
});
}
function remove(name, callback){
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
callback(error, data);
});
}
function addMember(name, username, callback){
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
callback(error, data);
});
}
function removeMember(name, username, callback){
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
callback(error, data);
});
}
return {list, add, remove, addMember, removeMember};
})(app);
app.util = (function(app){
function getUrlParameter(name){
name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
var results = regex.exec(location.search);
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
};
function actionMessage(message, $targetPassed, type, callback){
message = message || '';
let $target = $targetPassed.closest('div.card').find('.actionMessage');
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
type = type || 'info';
callback = callback || function(){};
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
// Messages that bring their own buttons (actionConfirm) are left
// alone; everything else gets the standard dismiss button.
if(!message.includes('<button')) message = `
<span class="align-middle">${message}</span>
<button class="action-close btn btn-sm btn-outline-dark float-end">
<i class="fa-solid fa-xmark"></i>
</button>
`
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
}
function actionConfirm(message, $target, type, callback){
return new Promise((resolve, reject) =>{
let id = crypto.randomUUID();
message = `
<h4 class"align-middle" >
<i class="fa-solid fa-triangle-exclamation"></i>
<b>${message}</b>
<span class="float-end">
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
<i class="fa-solid fa-circle-check"></i>
Confirm
</button>
<button type="button" class="btn btn-danger confirm-${id}">
<i class="fa-solid fa-circle-stop"></i>
Cancel
</button>
</span>
</h4>
`
actionMessage(message, $target, type);
$("body").on('click', `.confirm-${id}`, function(){
actionMessage('', $target, type);
resolve(!!$(this).data('confirm'));
});
});
}
$.fn.serializeObject = function() {
var obj = {};
// Get the form values and work over them
for (let {name, value} of $(this).serializeArray()) {
console.log(name, value)
if (obj[name] === undefined) {
if (!value
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
// Keep empty <textarea>s so a cleared field is submitted (and
// can reset a list, e.g. the per-host IP/header controls).
&& !$(this).filter(`textarea[name="${name}"]`).length
){
continue;
}
obj[name] = value;
let type = $(this).parent().find(`[name="${name}"]`).attr('type');
if (['number', 'range'].includes(type)) {
obj[name] = Number(value);
}
if (['radio'].includes(type) && ['true', 'false'].includes(value)) {
obj[name] = value == 'true' ? true : false;
}
} else {
if (!(obj[name] instanceof Array)) {
obj[name] = [obj[name]];
}
obj[name].push(value);
}
}
return obj;
};
function downloadFile(filename, text){
// https://stackoverflow.com/a/18197341
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
return {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
actionMessage: actionMessage,
actionConfirm,
}
})(app);
// Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
if(!groups.length) return;
var style = document.getElementById('group-required-rules');
if(!style){
style = document.createElement('style');
style.id = 'group-required-rules';
document.head.appendChild(style);
}
for(var group of groups){
try{
style.sheet.insertRule(
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// A group whose CN isn't a usable CSS identifier just gates nothing.
}
}
};
$( document ).ready(async function(){
// Show content the user's groups entitle them to.
app.auth.applyGroupVisibility(await app.auth.loadUser());
$('div.row').fadeIn('slow'); //show the page
//panel button's
$('.fa-arrows-v').click(function(){
$(this).closest('.card').find('.card-body').slideToggle('fast');
});
$('.fa-circle-minus').click(function(){
let $body = $(this).closest('.card').find('.card-body');
if($body.hasClass('d-none')){
$body.removeClass("d-none").removeClass('d-md-block');
if($body.is(":visible")) $body.hide();
}
$body.slideToggle('fast');
});
$('.fa-circle-xmark').click(function(){
$(this).closest('.card').slideUp('fast');
});
$('.actionMessage').on('click', 'button.action-close', function(event){
app.util.actionMessage(null, $(this));
});
setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{
var $el = $(el);
try{
$el.html(moment($(el).data('date')).fromNow());
}catch{}
})
}, 30000,);
});
(function($){
$.fn.scrollTo = function(){
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
console.log('y', y)
window.scrollTo({top: y, behavior: 'smooth'});
};
})(jQuery);
//ajax form submit
function formAJAX(btn){
event.preventDefault(btn); // avoid to execute the actual submit of the form.
var $form = $(btn || event.target).closest('[action]'); // gets the 'form' parent
var formData = $form.find('[name]').serializeObject(); // builds query formDataing
var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
return false;
}
app.util.actionMessage(
`<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>`,
$form,
'info'
);
app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear();
if(!error){
$form.trigger("reset");
eval($form.attr('evalAJAX')); //gets JS to run after completion
}else{
console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
}
if(data && data.keys){
console.log('form key errors', data.keys)
for(let keyError of data.keys){
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
}
}
}
});
}
+201
View File
@@ -0,0 +1,201 @@
( function( $ ) {
var settings = {
rule: {
eq: function(value, options){
var compare = $('[name=' + options + ']').val();
if ( value != compare ) {
return "Miss-match";
}
}
},
};
$.fn.validate = function(event) {
// let thisSettings = $.extend(true, settings, settingsObj);
let hasErrors = false;
if(this.is('[validate]')) return this.validateField(event);
if(!this.attr('isValid')){
console.log('adding reset event')
this.on('reset', function(){
$(this).attr('isValid', false);
$(this).validateClear();
})
}
this.find('[validate]').each(function(){
if(!$(this).validateField()) hasErrors = true;
});
this.attr('isValid', !hasErrors);
if(hasErrors && event) event.preventDefault();
return !hasErrors;
};
$.fn.validateClear = function(){
$(this).find('input').each(function(){
$(this).removeClass('is-invalid');
$(this).removeClass('is-valid');
})
}
$.fn.validateField = function(){
var attr = this.attr('validate').split(':'); //array of params
var rule = attr[0];
var options = attr[1];
var value = this.val(); //link to input value
var message;
if(this.prop('disabled')) return true;
//checks if field is required, and length
if(!isNaN(options) && value.length < options){
message = `Must be ${options} characters`;
}
//checks if empty to stop processing
if(!isNaN(options) && value.length === 0) {
}else if(rule in settings.rule){
message = settings.rule[rule].apply(this, [value, options]);
}
this.validateMessage(message)
return !message;
}
$.fn.validateMessage = function(message){
if(message && message !== true){
this.closest('.form-group').find('b.invalid-feedback').html(message);
this.addClass('is-invalid');
}else{
this.removeClass('is-invalid');
this.addClass('is-valid');
}
return this;
};
jQuery.extend({
validateSettings: function( settingsObj ) {
$.extend( true, settings, settingsObj );
},
validateInit: function( ettingsObj ) {
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
$( this ).validate( settingsObj, event );
});
}
});
}( jQuery ));
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
ip: function( value ) {
value = value.split( '.' );
if ( value.length != 4 ) {
return "Malformed IP";
}
$.each( value, function( key, value ) {
if( value > 255 || value < 0 ) {
return "Malformed IP";
}
});
},
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
user: function( value ) {
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
// or at least 3 of {lowercase, uppercase, number, symbol}.
password: function( value ) {
if ( typeof value !== 'string' || value.length < 8 ) {
return "Password must be at least 8 characters";
}
if ( value.length >= 12 ) return;
var classes = 0;
if ( /[a-z]/.test( value ) ) classes++;
if ( /[A-Z]/.test( value ) ) classes++;
if ( /[0-9]/.test( value ) ) classes++;
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
if ( classes < 3 ) {
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
}
}
}
});
})();
+8 -29
View File
@@ -1,36 +1,15 @@
'use strict'; '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'); // Authentication (local login + OIDC handshake). Unauthenticated by design.
const audit = require('../models/audit_event'); router.use('/auth', require('../models').authRouter);
const metrics = require('../models/metrics');
const registry = require('../services/session_registry');
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) => { // Jump-host data — admin only (audit log, active sessions, metrics).
res.json({ results: registry.list(), active: registry.count() }); router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
});
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); }
});
module.exports = router; module.exports = router;
-42
View File
@@ -1,42 +0,0 @@
'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 conf = require('@simpleworkjs/conf');
const userLdap = require('../models/user_ldap');
const Session = require('../models/session');
const router = express.Router();
router.get('/login', (req, res) => {
res.render('login', { error: null, name: conf.name });
});
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('/logout', (req, res) => {
res.setHeader('Set-Cookie', 'jump_session=; HttpOnly; Path=/; Max-Age=0');
res.redirect('/login');
});
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;
+47
View File
@@ -0,0 +1,47 @@
'use strict';
const path = require('path');
const express = require('express');
const router = require('express').Router();
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const registry = require('../services/session_registry');
const { safeInternalPath } = require('@simpleworkjs/oidc-client');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
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/.
mountStaticModules(router, {
root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'],
});
// Liveness probe — no auth.
router.get('/health', (req, res) => {
res.json({status: 'ok', activeSessions: registry.count(), buildVersion: buildInfo.buildVersion, buildHash: buildInfo.buildHash});
});
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: safeInternalPath(req.query.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;
+14
View File
@@ -53,3 +53,17 @@ test('caches per uid', async () => {
await accessibleHosts(user, { fetchImpl, ldap }); await accessibleHosts(user, { fetchImpl, ldap });
assert.strictEqual(calls, 1); assert.strictEqual(calls, 1);
}); });
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
clearCache();
const user = { uid: 'dave', dn: 'd' };
// drift shape: a bare array instead of { results: [...] }. The shared client
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
// good group alongside still yields its hosts.
const fetchImpl = async (url) => {
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
};
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
});
+11 -8
View File
@@ -16,20 +16,23 @@
// unit testing. // unit testing.
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
const userLdap = require('../models/user_ldap'); const userLdap = require('../models/user_ldap');
const CACHE_TTL_MS = 30 * 1000; const CACHE_TTL_MS = 30 * 1000;
const cache = new Map(); // uid -> {at, hosts} const cache = new Map(); // uid -> {at, hosts}
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) { // Build a directory client bound to conf.sso. fetchImpl is injectable so the
// unit tests can stub the transport; the shared client validates the
// `{ results }` envelope on every call (turns the old bare-array drift into a
// thrown error instead of a silent `[]`).
function directoryClient({ fetchImpl = fetch } = {}) {
const sso = conf.sso || {}; const sso = conf.sso || {};
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`; return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
const res = await fetchImpl(url, { }
headers: { Authorization: `Bearer ${sso.apiToken}` },
}); async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`); return directoryClient({ fetchImpl }).getResourcesByGroup(group);
const data = await res.json();
return (data && data.results) || [];
} }
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) { async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
+19
View File
@@ -0,0 +1,19 @@
'use strict';
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
// shared @simpleworkjs/app-stack. Previously this lived in models/build_info.js
// and exported { commit, version }; the shape is now aligned with sso + proxy.
//
// The baked commit file lives at the jump-host repo root (../../ from here in
// utils/), matching the Dockerfile gitinfo stage. cwd is utils/ for the
// bare-metal git fallback.
const path = require('path');
const { createBuildInfo } = require('@simpleworkjs/app-stack');
const { version } = require('../package.json');
module.exports = createBuildInfo({
version,
buildCommitPath: path.join(__dirname, '../../.build_commit'),
cwd: __dirname,
});
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
//
// Those two partials are byte-identical across sso-manager-node, proxy and
// jump-host — everything that differs between the apps lives here and is
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
// in sync across the three apps; a missing key is a render-time ReferenceError,
// not a silent fallback.
module.exports = {
// --- footer -------------------------------------------------------------
repoUrl: 'https://github.com/theta42/jump-host',
licenseUrl: 'https://github.com/theta42/jump-host/blob/master/LICENSE',
// No in-app /docs route here — point at the published docs site.
docsUrl: 'https://theta42.github.io/jump-host/',
docsExternal: true,
// Only sso-manager-node serves a Terms of Service page; null hides the link.
tosUrl: null,
// --- header / nav -------------------------------------------------------
faviconUrl: '/static/favicon.svg',
// Where the current-user chip links. null renders it as a plain span (for
// apps with no profile page).
profileUrl: null,
// Where "Log Out" lands.
logoutRedirect: '/login',
// Admin-only "a newer release is available" banner, backed by
// GET /api/update-check. Apps without that endpoint set false.
updateCheck: false,
updateLabel: 'the jump host',
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
// item; an empty list means "always visible". Gating is done client-side by
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: []},
],
};
+58 -35
View File
@@ -1,39 +1,62 @@
<%- include('top') %> <%- include('top') %>
<h1>Audit log</h1> <script type="text/javascript">app.auth.forceLogin();</script>
<form class="filters" method="get">
<input name="uid" placeholder="user" value="<%= query.uid || '' %>">
<input name="target" placeholder="target" value="<%= query.target || '' %>">
<select name="status">
<option value="">any</option>
<option value="success" <%= query.status === 'success' ? 'selected' : '' %>>success</option>
<option value="fail" <%= query.status === 'fail' ? 'selected' : '' %>>fail</option>
</select>
<button>Filter</button>
</form>
<table> <div class="card shadow-sm">
<thead><tr><th>Time</th><th>User</th><th>Method</th><th>Mode</th><th>Target</th><th>Chan</th><th>Client</th><th>Result</th><th>Bytes</th></tr></thead> <div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
<tbody> <div class="card-body pb-0">
<% data.results.forEach(e => { %> <form class="row g-2 mb-2" onsubmit="applyFilters(); return false;">
<tr class="<%= e.success ? '' : 'bad' %>"> <div class="col-auto"><input class="form-control form-control-sm" id="f-uid" placeholder="user"></div>
<td><%= new Date(e.ts).toLocaleString() %></td> <div class="col-auto"><input class="form-control form-control-sm" id="f-target" placeholder="target"></div>
<td><%= e.uid %></td> <div class="col-auto">
<td><%= e.authMethod %></td> <select class="form-select form-select-sm" id="f-status">
<td><%= e.mode %></td> <option value="">any result</option>
<td><%= e.targetSlug || e.targetAddr || '—' %></td> <option value="success">success</option>
<td><%= e.channel || '—' %></td> <option value="fail">fail</option>
<td><%= e.clientIp %></td> </select>
<td><%= e.success ? '✓' : '✗ ' + e.failReason %></td> </div>
<td class="r"><%= (e.bytesIn + e.bytesOut) || 0 %></td> <div class="col-auto"><button class="btn btn-sm btn-primary">Filter</button></div>
</tr> </form>
<% }) %> </div>
</tbody> <div class="table-responsive">
</table> <table class="table table-striped table-sm mb-0">
<thead><tr><th>Time</th><th>User</th><th>Method</th><th>Mode</th><th>Target</th><th>Chan</th><th>Client</th><th>Result</th><th class="text-end">Bytes</th></tr></thead>
<div class="pager"> <tbody id="audit-body"></tbody>
<% const p = data.page; %> </table>
<% if (p > 0) { %><a href="?page=<%= p-1 %>&uid=<%= query.uid||'' %>&target=<%= query.target||'' %>&status=<%= query.status||'' %>">← prev</a><% } %> </div>
<span><%= data.total %> events</span> <div class="card-footer d-flex justify-content-between align-items-center">
<% if ((p+1) * data.pageSize < data.total) { %><a href="?page=<%= p+1 %>&uid=<%= query.uid||'' %>&target=<%= query.target||'' %>&status=<%= query.status||'' %>">next →</a><% } %> <button class="btn btn-sm btn-outline-secondary" id="prev" onclick="changePage(-1)">&larr; prev</button>
<span class="text-muted small" id="page-info"></span>
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next &rarr;</button>
</div>
</div> </div>
<script type="text/javascript">
var page = 0;
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
function applyFilters(){ page = 0; load(); }
function changePage(d){ page = Math.max(0, page + d); load(); }
function load(){
app.jump.audit(filters(), function(error, data){
var $b = $('#audit-body').empty();
if(error || !data || !data.results.length){ $b.append('<tr><td colspan="9" class="text-muted">No events.</td></tr>'); }
else data.results.forEach(function(e){
$b.append('<tr class="' + (e.success ? '' : 'table-danger') + '">' +
'<td>' + app.jump.fmtTime(e.ts) + '</td>' +
'<td>' + app.jump.esc(e.uid) + '</td>' +
'<td>' + app.jump.esc(e.authMethod) + '</td>' +
'<td>' + app.jump.esc(e.mode) + '</td>' +
'<td>' + app.jump.esc(e.targetSlug || e.targetAddr || '—') + '</td>' +
'<td>' + app.jump.esc(e.channel || '—') + '</td>' +
'<td>' + app.jump.esc(e.clientIp) + '</td>' +
'<td>' + app.jump.result(e) + '</td>' +
'<td class="text-end">' + ((e.bytesIn + e.bytesOut) || 0) + '</td></tr>');
});
var total = data ? data.total : 0, size = data ? data.pageSize : 50;
$('#page-info').text(total + ' events · page ' + (page + 1));
$('#prev').prop('disabled', page === 0);
$('#next').prop('disabled', (page + 1) * size >= total);
});
}
$(document).ready(load);
</script>
<%- include('bottom') %> <%- include('bottom') %>
+29 -5
View File
@@ -1,6 +1,30 @@
</main> </div><!-- end spa-shell -->
<footer class="foot">
<% if (typeof buildInfo !== 'undefined') { %><span>v<%= buildInfo.version %> · <%= buildInfo.commit %></span><% } %> <!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
</footer> jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
</body> app.locals in app.js). Edit all three copies together. -->
<footer class="py-2 bg-dark text-light mt-4">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank">
<img width="64" src="/static/img/theta42.svg"/>
</a>
&copy; <%- buildYear %> theta42 &middot;
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
<i class="fa-solid fa-book"></i> Docs
</a>
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<% if(ui.tosUrl){ %>
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
<% } %>
</span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body>
</html> </html>
+58 -45
View File
@@ -1,51 +1,64 @@
<%- include('top') %> <%- include('top') %>
<h1>Dashboard</h1> <script type="text/javascript">app.auth.forceLogin();</script>
<div class="tiles">
<div class="tile"><span class="n"><%= metrics.active %></span><span class="l">active sessions</span></div> <div class="row g-3 mb-4">
<div class="tile"><span class="n"><%= metrics.total %></span><span class="l">total connections</span></div> <div class="col-6 col-md-3">
<div class="tile"><span class="n"><%= metrics.fail %></span><span class="l">failed</span></div> <div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-active"></div>
<div class="text-muted small text-uppercase">Active sessions</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-total"></div>
<div class="text-muted small text-uppercase">Total connections</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6 text-danger" id="stat-fail"></div>
<div class="text-muted small text-uppercase">Failed</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-users"></div>
<div class="text-muted small text-uppercase">Users seen</div>
</div></div>
</div>
</div> </div>
<div class="cols"> <div class="row g-3">
<section> <div class="col-md-6">
<h2>Active sessions</h2> <div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
<% if (!active.length) { %><p class="muted">None right now.</p><% } else { %> <table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
<table> </div>
<thead><tr><th>User</th><th>Target</th><th>Since</th></tr></thead> </div>
<tbody> <div class="col-md-6">
<% active.forEach(s => { %> <div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
<tr><td><%= s.uid %></td><td><%= s.slug || s.target %></td><td><%= new Date(s.startedAt).toLocaleTimeString() %></td></tr> <table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
<% }) %> </div>
</tbody> </div>
</table>
<% } %>
</section>
<section>
<h2>Top hosts</h2>
<% if (!metrics.topHosts.length) { %><p class="muted">No data.</p><% } else { %>
<table><tbody>
<% metrics.topHosts.forEach(h => { %><tr><td><%= h.name %></td><td class="r"><%= h.count %></td></tr><% }) %>
</tbody></table>
<% } %>
</section>
</div> </div>
<section> <script type="text/javascript">
<h2>Recent connections <a class="more" href="/audit">view all →</a></h2> function rows(sel, list){
<table> var $b = $(sel).empty();
<thead><tr><th>Time</th><th>User</th><th>Target</th><th>Method</th><th>Result</th></tr></thead> if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
<tbody> list.forEach(function(x){
<% recent.forEach(e => { %> $b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
<tr> });
<td><%= new Date(e.ts).toLocaleString() %></td> }
<td><%= e.uid %></td> $(document).ready(function(){
<td><%= e.targetSlug || e.targetAddr || '—' %></td> app.jump.metrics(function(error, data){
<td><%= e.authMethod %> / <%= e.mode %></td> if(error || !data) return;
<td><%= e.success ? '✓' : '✗ ' + e.failReason %></td> $('#stat-active').text(data.active);
</tr> $('#stat-total').text(data.total);
<% }) %> $('#stat-fail').text(data.fail);
</tbody> $('#stat-users').text((data.topUsers || []).length);
</table> rows('#top-hosts', data.topHosts);
</section> rows('#top-users', data.topUsers);
});
});
</script>
<%- include('bottom') %> <%- include('bottom') %>
Regular → Executable
+101 -19
View File
@@ -1,19 +1,101 @@
<!doctype html> <%- include('top') %>
<html lang="en"> <script type="text/javascript">
<head>
<meta charset="utf-8"> // If we arrived from the OIDC callback with a token in the URL fragment,
<meta name="viewport" content="width=device-width, initial-scale=1"> // store it and forward on before doing anything else.
<title><%= name %> — Jump Host login</title> if(!app.auth.consumeTokenFragment()){
<link rel="stylesheet" href="/public/css/app.css"> // The reveal below touches an element further down this page, so wait
</head> // for the DOM — isLoggedIn can answer before the parser gets there.
<body class="center"> $(document).ready(function(){
<form method="post" action="/login" class="card login"> app.auth.isLoggedIn(function(error, isLoggedIn){
<h1><%= name %> <small>jump host</small></h1> if(isLoggedIn){
<% if (error) { %><p class="err"><%= error %></p><% } %> app.auth.logInRedirect();
<label>Username <input name="uid" autofocus autocomplete="username"></label> }else{
<label>Password <input name="password" type="password" autocomplete="current-password"></label> // Reveal the login card once we know the user is not logged in.
<button type="submit">Sign in</button> document.getElementById('login-card-row').style.display = '';
<p class="hint">Admin group required. Uses your directory (LDAP) credentials.</p> }
</form> });
</body> });
</html> }
</script>
<div id="login-card-row" class="row" style="display: none;">
<div class="col-md-4">
<div class="shadow-lg card">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-lock"></i>
</span>
<span class="card-title">
User Login
</span>
<span class="float-end">
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
<div class="card-header shadow actionMessage" style="display:none"></div>
<div class="card-body">
<form action="auth/login" onsubmit="formAJAX(this)" evalAJAX="
app.auth.setToken(data.token);
app.auth.logInRedirect();
">
<input type="hidden" name="redirect" value="<%= redirect %>">
<div class="mb-3">
<label></label>
<div class="input-group">
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-user-tie"></i></span>
<input type="text" name="username" class="form-control" placeholder="jsmith" aria-label="Username" aria-describedby="addon-wrapping">
</div>
</div>
<div class="mb-3">
<div class="input-group">
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-key"></i></span>
<input type="password" name="password" class="form-control" placeholder="huunteR!23" aria-label="Username" aria-describedby="addon-wrapping">
</div>
</div>
<!-- <div class="group">
<label class="control-label">User name</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fa-solid fa-user-tie"></i></span>
</div>
<input type="text" name="username" class="input-control" placeholder="jsmith" />
</div>
</div>
<div class="group">
<label class="control-label">Password</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fa-solid fa-key"></i></span>
</div>
<input type="password" name="password" class="input-control" placeholder="hunter123!"/>
</div>
</div> -->
<hr />
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
</form>
<% if (typeof oidcEnabled === 'undefined' || oidcEnabled) { %>
<hr />
<div class="d-grid">
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
<i class="fa-solid fa-id-badge"></i> Log in with SSO
</a>
</div>
<% } %>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+28 -11
View File
@@ -1,13 +1,30 @@
<%- include('top') %> <%- include('top') %>
<h1>Active sessions</h1> <script type="text/javascript">app.auth.forceLogin();</script>
<% if (!active.length) { %><p class="muted">No active sessions.</p><% } else { %>
<table> <div class="card shadow-sm">
<thead><tr><th>User</th><th>Target host</th><th>Address</th><th>Started</th></tr></thead> <div class="card-header d-flex justify-content-between align-items-center">
<tbody> <span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
<% active.forEach(s => { %> <button class="btn btn-sm btn-outline-secondary" onclick="loadSessions()"><i class="fa-solid fa-rotate"></i></button>
<tr><td><%= s.uid %></td><td><%= s.slug || '—' %></td><td><%= s.target %></td><td><%= new Date(s.startedAt).toLocaleString() %></td></tr> </div>
<% }) %> <div class="table-responsive">
</tbody> <table class="table table-striped mb-0">
</table> <thead><tr><th>User</th><th>Target host</th><th>Address</th><th>Started</th></tr></thead>
<% } %> <tbody id="sessions-body"></tbody>
</table>
</div>
</div>
<script type="text/javascript">
function loadSessions(){
app.jump.sessions(function(error, data){
var $b = $('#sessions-body').empty();
if(error || !data || !data.results.length){ $b.append('<tr><td colspan="4" class="text-muted">No active sessions.</td></tr>'); return; }
data.results.forEach(function(s){
$b.append('<tr><td>' + app.jump.esc(s.uid) + '</td><td>' + app.jump.esc(s.slug || '—') +
'</td><td>' + app.jump.esc(s.target) + '</td><td>' + app.jump.fmtTime(s.startedAt) + '</td></tr>');
});
});
}
$(document).ready(loadSessions);
</script>
<%- include('bottom') %> <%- include('bottom') %>
+147 -19
View File
@@ -1,21 +1,149 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%= name %> — Jump Host</title> <title><%- name %> <%- title %></title>
<link rel="stylesheet" href="/public/css/app.css"> <!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
</head> jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
<body> via app.locals in app.js). Edit all three copies together. -->
<nav class="nav"> <!-- Favicon -->
<span class="brand"><%= name %> <small>jump host</small></span> <link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
<% if (typeof user !== 'undefined' && user) { %> <!-- CSS are placed here -->
<span class="spacer"></span> <link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
<a href="/">Dashboard</a> <link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
<a href="/sessions">Sessions</a>
<a href="/audit">Audit</a> <link rel='stylesheet' href='/static/css/styles.css' />
<span class="who"><%= user.uid %></span> <!-- Scripts are placed here -->
<form method="post" action="/logout" class="inline"><button class="link">logout</button></form> <script type="text/javascript" src="/socket.io/socket.io.js"></script>
<% } %> <script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
</nav> <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<main class="wrap"> <script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script>
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav">
<%# Items gated on a group start hidden (.group-required) and are
revealed by app-base.js for the groups the user is in. %>
<% for(const item of ui.nav){ %>
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
<%- item.label %>
</a>
</li>
<% } %>
</ul>
<div class="form-inline mt-2 mt-md-0">
<% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<% } else { %>
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</span>
<% } %>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-in"></i>
Login
</a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button>
</div>
</div>
</nav>
<% if(ui.updateCheck){ %>
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
<span id="update-banner-text"></span>
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
</div>
<script type="text/javascript">
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
sessionStorage.setItem('update-banner-dismissed', '1');
}
function checkForUpdate(){
if(sessionStorage.getItem('update-banner-dismissed')) return;
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
</script>
<% } %>
<script type="text/javascript">
$(document).ready(function(){
// Set the correct link to active in the top nav bar
$('.top-nav a').each(function(index){
let $this = $(this);
$this.removeClass('active');
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
$this.addClass('active')
}
})
// Set the correct login/logout button, and reveal the current user's
// name once we know who they are. Group-gated nav items are revealed
// by app-base.js off the same cached user/me.
app.auth.isLoggedIn(function(error, me){
if(me){
$('#cl-logout-button').show();
let username = me.uid || me.username;
if(username){
$('#cl-username-text').text(username);
$('#cl-username').css('display', '');
}
<% if(ui.updateCheck){ %>
if(me.isAdmin) checkForUpdate();
<% } %>
}else{
$('#cl-login-button').show();
}
});
});
</script>
<!-- Container -->
<div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div>
+27 -2
View File
@@ -59,8 +59,33 @@ module.exports = {
web: { port: 3002 }, web: { port: 3002 },
// LDAP groups whose members may use the web UI/API. // Web UI/API login. Same model as the proxy: OIDC against the SSO for
auth: { adminGroups: ['app_sso_admin'] }, // normal users, plus a local anti-lockout admin. Set enabled:true and fill
// in the endpoints + client creds to turn on "Log in with SSO" (in the
// theta-env bundle these are provisioned for you).
oidc: {
enabled: false,
issuer: 'https://sso.example.com',
authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
tokenEndpoint: 'http://sso-manager:3001/oauth/token',
userinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
clientId: 'CHANGE_ME',
clientSecret: 'CHANGE_ME',
redirectUri: 'https://jump.example.com/api/auth/oidc/callback',
scopes: ['openid', 'profile', 'email', 'groups'],
groupsClaim: 'groups',
usernameClaim: 'preferred_username',
},
auth: {
// OIDC group memberships that grant web UI/API admin access.
adminGroups: ['app_sso_admin'],
// Local anti-lockout admin — the first name is bootstrapped as a
// redis-backed user on first boot (password from localAdminPass below,
// or a random one printed to the log once). Works even if OIDC is down.
adminUsers: ['jumpadmin'],
localAdminPass: 'CHANGE_ME',
},
redis: { redis: {
prefix: 'jump_host_', prefix: 'jump_host_',