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>
This commit is contained in:
2026-07-25 15:55:03 -04:00
parent 23d99980ce
commit 67e2fc54c2
21 changed files with 224 additions and 809 deletions
+16
View File
@@ -4,6 +4,22 @@ All notable changes to this project are documented here. Format loosely
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`.
## [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
+1 -1
View File
@@ -5,7 +5,7 @@
// callback. The token carries the group snapshot captured at login.
const conf = require('@simpleworkjs/conf');
const { Auth } = require('../models/auth');
const { Auth } = require('../models');
async function auth(req, res, next){
try{
-118
View File
@@ -1,118 +0,0 @@
'use strict';
const Table = require('../models');
const {User, AuthToken} = Table.models;
/**
* Auth Model
*
* Handles authentication operations for the application.
* Manages user login, token validation, and logout processes.
*
* Dependencies:
* - User model: Validates user credentials
* - AuthToken model: Creates and manages authentication tokens
*
* All methods throw standardized login errors on failure to avoid
* leaking information about whether usernames exist or tokens are valid.
*/
class Auth{
/**
* Standardized error responses for authentication failures.
* Returns generic "Invalid Credentials" message for security.
*/
static errors = {
login: function(){
let error = new Error('LoginFailed');
error.name = 'LoginFailed';
error.message = `Invalid Credentials, login failed.`;
error.status = 401;
return error;
}
}
/**
* Authenticate user and create session token.
*
* @param {Object} data - Login credentials {username, password}
* @returns {Object} {user, token} - User object and auth token
* @throws {Error} Generic login error on any failure
*
* Flow:
* 1. Validate credentials via User.login()
* 2. Create new AuthToken for the user
* 3. Return both user data and token
*/
static async login(data){
try{
let user = await User.login(data);
// Backends may attach group membership to the user (LDAP); default
// to none for local/redis users.
let groups = Array.isArray(user.groups) ? user.groups : [];
let token = await AuthToken.create({username: user.username, groups});
return {user, token}
}catch(error){
console.log('login error', error);
throw this.errors.login();
}
}
/**
* Establish a session for an OIDC-authenticated identity: JIT-provision the
* local user (redis-backed) and mint an AuthToken carrying the SSO groups.
*
* @param {Object} identity - {username, groups} from utils/oidc claims
* @returns {Object} {user, token}
*/
static async oidcSession(identity){
let user = typeof User.upsertOidc === 'function'
? await User.upsertOidc(identity)
: await User.get(identity.username);
let token = await AuthToken.create({
username: user.username,
groups: identity.groups || [],
});
return {user, token};
}
/**
* Validate an authentication token.
*
* @param {string} token - Token string to validate
* @returns {Object} Token object if valid
* @throws {Error} Generic login error if token invalid or expired
*
* Checks:
* 1. Token exists in database
* 2. Token has not expired (via token.check())
*/
static async checkToken(token){
try{
token = await AuthToken.get(token);
if(token && token.check()) return token;
throw this.errors.login();
}catch(error){
console.log('check error', error);
throw this.errors.login();
}
}
/**
* Destroy an authentication token (logout).
*
* @param {string} data - Token string to destroy
* @returns {void}
*
* Removes token from database, invalidating the session.
*/
static async logout(data){
let token = await AuthToken.get(data);
await token.destroy();
}
}
module.exports = {Auth};
-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 };
+17 -3
View File
@@ -6,6 +6,7 @@
const conf = require('@simpleworkjs/conf');
const { setUpTable } = require('model-redis');
const { createOidcClient, bootstrapLocalAdmin } = require('@simpleworkjs/oidc-client');
const Table = setUpTable(conf.redis);
@@ -32,7 +33,20 @@ async function getRedis() {
module.exports.getRedis = getRedis;
// Register models (order matters: User before AuthToken's relation resolves).
require('./user_redis');
require('./token');
require('./oidc_state');
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');
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
-31
View File
@@ -1,31 +0,0 @@
'use strict';
const Table = require('.');
/**
* OidcState
*
* Short-lived store for an in-flight OpenID Connect authorization request.
* Keyed by the random `state` value; holds the PKCE `code_verifier` and the
* post-login redirect target until the SSO calls us back.
*
* The record auto-expires via model-redis per-key TTL (static _ttl), so an
* abandoned login attempt leaves nothing behind and there is no cleanup job.
*/
class OidcState extends Table{
static _key = 'state';
// Auth round-trips are quick; 5 minutes is plenty and bounds replay.
static _ttl = 300;
static _keyMap = {
'created_on': {default: function(){return (new Date).getTime()}},
'state': {isRequired: true, type: 'string', min: 8, max: 500},
'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500},
'redirect': {default: '/', isRequired: false, type: 'string'},
}
}
OidcState.register();
module.exports = {OidcState};
-64
View File
@@ -1,64 +0,0 @@
'use strict';
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
class Token extends Table{
static _key = 'token';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
'is_valid': {default: true, type: 'boolean'},
}
constructor(...args){
super(...args);
}
async check(){
try{
return this.is_valid;
}catch(error){
return false
}
}
}
Token.register();
class AuthToken extends Token{
static _keyMap = {
...super._keyMap,
user: {model: 'User', rel: 'one', localKey: 'created_by'},
// Group memberships captured at login (OIDC `groups` claim or LDAP
// group membership), stored as a JSON string. Drives authorization for
// the life of the session without re-querying the IdP on every request.
groups: {default: '[]', isRequired: false, type: 'string'},
}
static async create(data){
data.created_by = data.username;
if(Array.isArray(data.groups)){
data.groups = JSON.stringify(data.groups);
}
return super.create(data)
}
// Parse the stored groups JSON back into an array, tolerating bad/missing
// data so authorization never crashes on a malformed token.
groupsArray(){
try{
let parsed = JSON.parse(this.groups);
return Array.isArray(parsed) ? parsed : [];
}catch(error){
return [];
}
}
}
AuthToken.register();
module.exports = {Token, AuthToken};
+12 -99
View File
@@ -1,109 +1,22 @@
'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
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
// checkPassword(dn, pw) -> bool (simple bind as the user)
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
//
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
// TypeOrValueExists treated as success on key add).
// Behavior is unchanged from the previous in-tree implementation: posixAccount
// user filter, groupOfNames group filter, bind-as-user password check,
// 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 { createLdapClient } = require('@simpleworkjs/ldap');
function ldapConf() {
return conf.ldap || {};
}
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 };
const ldapConf = conf.ldap || {};
module.exports = createLdapClient({
...ldapConf,
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
});
+3 -36
View File
@@ -3,7 +3,6 @@
const Table = require('.');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const saltRounds = 10;
class User extends Table{
@@ -86,38 +85,6 @@ class User extends Table{
User.register();
(async function(){
// The anti-lockout local admin: the first entry in conf.auth.adminUsers
// (default 'jumpadmin'). A local login that works even if the SSO/OIDC is
// unreachable — the whole point of "OIDC + internal users".
var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'jumpadmin';
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
// auth.localAdminPass in jump-secrets.js to a generated password so this
// bootstrap account isn't left at a well-known default. Only used on first
// creation -- once the account exists this is never read again, so it's
// safe to leave set. If unset, a random password is generated and printed
// once; save it from the log or set auth.localAdminPass explicitly.
var defaultPass = (conf.auth && conf.auth.localAdminPass);
if (!defaultPass) {
defaultPass = crypto.randomBytes(16).toString('hex');
console.warn(`====================================================================`);
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
console.warn(`${defaultPass}`);
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
console.warn(`====================================================================`);
}
try{
let user = await User.get(defaultUser);
}catch(error){
try{
let user = await User.create({
username:defaultUser,
password: defaultPass,
created_by: defaultUser
});
console.log(defaultUser, 'created');
}catch(error){
console.error(error)
}
}
})();
// 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.
+108 -148
View File
@@ -10,7 +10,11 @@
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/app-stack": "^1.0.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",
@@ -19,11 +23,11 @@
"express-rate-limit": "^8.5.2",
"jq-repeat": "^2.2.0",
"jquery": "^3.7.1",
"ldapts": "^8.1.2",
"ldapts": "^8.1.8",
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"redis": "^4.7.0",
"redis": "^6.1.0",
"socket.io": "^4.8.3",
"ssh2": "^1.16.0"
},
@@ -55,62 +59,87 @@
}
},
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"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": {
"@redis/client": "^1.0.0"
"@redis/client": "^6.1.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"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",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">=14"
}
},
"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",
"node": ">= 20.0.0"
},
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"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": "^1.0.0"
"@redis/client": "^6.1.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"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": "^1.0.0"
"@redis/client": "^6.1.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"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": "^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": {
@@ -125,6 +154,41 @@
"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",
@@ -876,15 +940,6 @@
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1224,94 +1279,6 @@
"redis": "^6.1.0"
}
},
"node_modules/model-redis/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": {
"@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/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -1617,20 +1584,19 @@
}
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/redis/-/redis-6.1.0.tgz",
"integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
"@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/router": {
@@ -2079,12 +2045,6 @@
"optional": true
}
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
}
}
}
+8 -4
View File
@@ -1,7 +1,7 @@
{
"name": "t42-jump-host",
"version": "1.1.0",
"description": "SSH jump host for the theta42 stack LDAP-authenticated, directory-driven host bridging with audit and metrics",
"version": "1.2.0",
"description": "SSH jump host for the theta42 stack \u2014 LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
"name": "William Mantly",
@@ -21,6 +21,10 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.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",
@@ -29,11 +33,11 @@
"express-rate-limit": "^8.5.2",
"jq-repeat": "^2.2.0",
"jquery": "^3.7.1",
"ldapts": "^8.1.2",
"ldapts": "^8.1.8",
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"redis": "^4.7.0",
"redis": "^6.1.0",
"socket.io": "^4.8.3",
"ssh2": "^1.16.0"
},
+4 -2
View File
@@ -255,13 +255,15 @@ app.auth = (function(app){
app.auth.isLoggedIn(function(error, isLoggedIn){
if(error || !isLoggedIn){
app.auth.logOut(function(){})
location.replace(`/login${location.href.replace(location.origin, '')}`);
var path = location.href.replace(location.origin, '');
location.replace('/login?redirect=' + encodeURIComponent(path));
}
});
}
function logInRedirect(){
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
var params = new URLSearchParams(location.search);
window.location.href = safeInternalPath(params.get('redirect') || '/');
}
return {
+1 -1
View File
@@ -4,7 +4,7 @@ const router = require('express').Router();
const middleware = require('../middleware/auth');
// Authentication (local login + OIDC handshake). Unauthenticated by design.
router.use('/auth', require('./auth'));
router.use('/auth', require('../models').authRouter);
// Who am I — needs a valid session but no admin gate (drives the login state).
router.use('/user', middleware.auth, require('./user'));
-111
View File
@@ -1,111 +0,0 @@
'use strict';
const router = require('express').Router();
const { rateLimit } = require('express-rate-limit');
const conf = require('@simpleworkjs/conf');
const { Auth } = require('../models/auth');
const { OidcState } = require('../models/oidc_state');
const oidc = require('../utils/oidc');
const { safeInternalPath } = require('../utils/safe_redirect');
// Throttle unauthenticated auth endpoints (credential login + the OIDC
// handshake) to blunt brute-force / callback abuse. Keyed per IP.
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 60, // 60 attempts per IP per window
standardHeaders: true,
legacyHeaders: false,
message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'},
});
router.post('/login', authLimiter, async function(req, res, next){
try{
let auth = await Auth.login(req.body);
return res.json({
login: true,
token: auth.token.token,
message:`${req.body.username} logged in!`,
});
}catch(error){
next(error);
}
});
router.all('/logout', async function(req, res, next){
try{
if(req.user){
await req.user.logout();
}
res.json({message: 'Bye'})
}catch(error){
next(error);
}
});
/**
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
*/
router.get('/oidc/start', authLimiter, async function(req, res, next){
try{
if(!conf.oidc || !conf.oidc.enabled){
let error = new Error('OidcDisabled');
error.status = 404;
error.message = 'OIDC login is not enabled.';
throw error;
}
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
await OidcState.create({
state,
codeVerifier,
// Sanitize now so a hostile ?redirect= can't be stored and later
// reflected into the login page's navigation.
redirect: safeInternalPath(req.query.redirect || '/'),
});
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
}catch(error){
next(error);
}
});
/**
* OIDC callback: validate state (consuming the one-time record), exchange the
* code for tokens, read identity from userinfo, establish a session, and hand
* the app token back to the browser via a URL fragment for the login page to
* store in localStorage.
*/
router.get('/oidc/callback', authLimiter, async function(req, res, next){
try{
let {code, state} = req.query;
if(!code || !state){
let error = new Error('OidcCallbackInvalid');
error.status = 400;
error.message = 'Missing code or state.';
throw error;
}
// get() throws if the state is unknown or has expired — this both binds
// the callback to our request and bounds replay.
let saved = await OidcState.get(state);
await saved.remove();
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
let claims = await oidc.fetchUserInfo(tokens.access_token);
let identity = oidc.claimsToIdentity(claims);
let {token} = await Auth.oidcSession(identity);
let redirect = safeInternalPath(saved.redirect || '/');
return res.redirect(
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
);
}catch(error){
next(error);
}
});
module.exports = router;
+8 -7
View File
@@ -4,8 +4,10 @@ const path = require('path');
const express = require('express');
const router = require('express').Router();
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../models/build_info');
const 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' : '',
@@ -17,15 +19,14 @@ const values = {
// Serve front-end vendor libraries straight from node_modules (same convention
// as the sibling apps), and the app's own JS/CSS/img from public/.
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'];
frontEndModules.forEach(dep => {
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}));
mountStaticModules(router, {
root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'],
});
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}));
// Liveness probe — no auth.
router.get('/health', (req, res) => {
res.json({status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit});
res.json({status: 'ok', activeSessions: registry.count(), buildVersion: buildInfo.buildVersion, buildHash: buildInfo.buildHash});
});
router.get('/', (req, res) => res.redirect(302, '/dashboard'));
@@ -36,7 +37,7 @@ router.get('/', (req, res) => res.redirect(302, '/dashboard'));
// /login when there's no valid session.
router.get('/login', (req, res) => res.render('login', {
...values,
redirect: '/',
redirect: safeInternalPath(req.query.redirect || '/'),
oidcEnabled: !!(conf.oidc && conf.oidc.enabled),
}));
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
+14
View File
@@ -53,3 +53,17 @@ test('caches per uid', async () => {
await accessibleHosts(user, { fetchImpl, ldap });
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.
const conf = require('@simpleworkjs/conf');
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
const userLdap = require('../models/user_ldap');
const CACHE_TTL_MS = 30 * 1000;
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 url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`;
const res = await fetchImpl(url, {
headers: { Authorization: `Bearer ${sso.apiToken}` },
});
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`);
const data = await res.json();
return (data && data.results) || [];
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
}
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
}
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,
});
-127
View File
@@ -1,127 +0,0 @@
'use strict';
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
/**
* Minimal OpenID Connect authorization-code + PKCE client.
*
* The SSO publishes no jwks_uri, so we do not verify ID-token signatures;
* instead we treat the flow as opaque and read identity from the userinfo
* endpoint (the access token is exchanged server-side over TLS). Uses Node's
* global fetch (Node 18+) and crypto — no external dependency.
*
* All endpoints and client config come from conf.oidc (+ clientSecret from
* secrets.js, deep-merged by @simpleworkjs/conf).
*/
const base64url = buf => buf.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// A high-entropy random string for `state` / PKCE verifier.
function randomToken(bytes = 32){
return base64url(crypto.randomBytes(bytes));
}
// PKCE S256 challenge derived from the verifier.
function codeChallengeS256(verifier){
return base64url(crypto.createHash('sha256').update(verifier).digest());
}
// Generate the {state, codeVerifier, codeChallenge} triple for a new login.
function createAuthRequest(){
let state = randomToken(32);
let codeVerifier = randomToken(32);
let codeChallenge = codeChallengeS256(codeVerifier);
return {state, codeVerifier, codeChallenge};
}
// Build the SSO authorize URL the browser is redirected to. `redirectUri`
// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback).
function buildAuthUrl(state, codeChallenge, redirectUri){
let o = conf.oidc;
let params = new URLSearchParams({
response_type: 'code',
client_id: o.clientId,
redirect_uri: redirectUri || o.redirectUri,
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${o.authorizationEndpoint}?${params.toString()}`;
}
// Exchange an authorization code for tokens at the token endpoint. `redirectUri`
// must match the one used in buildAuthUrl (per-host for per-host SSO).
async function exchangeCode(code, codeVerifier, redirectUri){
let o = conf.oidc;
let body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri || o.redirectUri,
client_id: o.clientId,
client_secret: o.clientSecret,
code_verifier: codeVerifier,
});
let res = await fetch(o.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.toString(),
});
if(!res.ok){
let text = await res.text().catch(() => '');
let error = new Error('OidcTokenExchangeFailed');
error.name = 'OidcTokenExchangeFailed';
error.message = `Token exchange failed (${res.status}): ${text}`;
error.status = 502;
throw error;
}
return res.json();
}
// Fetch the userinfo claims for an access token.
async function fetchUserInfo(accessToken){
let o = conf.oidc;
let res = await fetch(o.userinfoEndpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if(!res.ok){
let error = new Error('OidcUserInfoFailed');
error.name = 'OidcUserInfoFailed';
error.message = `Userinfo request failed (${res.status})`;
error.status = 502;
throw error;
}
return res.json();
}
// Pull the app username and group list out of userinfo claims per conf.
function claimsToIdentity(claims){
let o = conf.oidc;
let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub;
let groups = claims[o.groupsClaim || 'groups'] || [];
if(!Array.isArray(groups)) groups = [groups].filter(Boolean);
return {username, groups, claims};
}
module.exports = {
randomToken,
codeChallengeS256,
createAuthRequest,
buildAuthUrl,
exchangeCode,
fetchUserInfo,
claimsToIdentity,
};
-23
View File
@@ -1,23 +0,0 @@
'use strict';
/**
* Constrain a post-login redirect target to a same-origin path.
*
* Rejects anything that could leave the site or execute script:
* - absolute URLs ("https://evil.com") -> not a "/" path
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
* - scheme targets ("javascript:...", "data:...") -> XSS
* Anything not a plain "/path" falls back to "/".
*
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
* two in sync.
*/
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
module.exports = {safeInternalPath};
+2 -2
View File
@@ -6,7 +6,7 @@
<a href="https://theta42.com" target="_blank">
<img width="64" src="/static/img/theta42.svg"/>
</a>
&copy; <%- (new Date()).getFullYear() %> theta42 &middot;
&copy; <%- buildYear %> theta42 &middot;
<a href="https://github.com/theta42/jump-host/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
@@ -17,7 +17,7 @@
<i class="fa-brands fa-github"></i> GitHub
</a>
</span>
<span>v<%- version %> (<%- commit %>)</span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body>