diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1060129..f26c9c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/nodejs/middleware/auth.js b/nodejs/middleware/auth.js
index 8c1b8bb..e141989 100644
--- a/nodejs/middleware/auth.js
+++ b/nodejs/middleware/auth.js
@@ -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{
diff --git a/nodejs/models/auth.js b/nodejs/models/auth.js
deleted file mode 100644
index 34f33b5..0000000
--- a/nodejs/models/auth.js
+++ /dev/null
@@ -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};
diff --git a/nodejs/models/build_info.js b/nodejs/models/build_info.js
deleted file mode 100644
index 43099e0..0000000
--- a/nodejs/models/build_info.js
+++ /dev/null
@@ -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 };
diff --git a/nodejs/models/index.js b/nodejs/models/index.js
index 5561597..143e794 100644
--- a/nodejs/models/index.js
+++ b/nodejs/models/index.js
@@ -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' });
\ No newline at end of file
diff --git a/nodejs/models/oidc_state.js b/nodejs/models/oidc_state.js
deleted file mode 100644
index d46ede4..0000000
--- a/nodejs/models/oidc_state.js
+++ /dev/null
@@ -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};
diff --git a/nodejs/models/token.js b/nodejs/models/token.js
deleted file mode 100644
index f497e36..0000000
--- a/nodejs/models/token.js
+++ /dev/null
@@ -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};
diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js
index 8816dab..72d0d7e 100644
--- a/nodejs/models/user_ldap.js
+++ b/nodejs/models/user_ldap.js
@@ -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 },
+});
\ No newline at end of file
diff --git a/nodejs/models/user_redis.js b/nodejs/models/user_redis.js
index 4c01d59..6737028 100644
--- a/nodejs/models/user_redis.js
+++ b/nodejs/models/user_redis.js
@@ -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)
- }
- }
-})();
\ No newline at end of file
+// 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.
diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json
index b9f84b9..e9b0d71 100644
--- a/nodejs/package-lock.json
+++ b/nodejs/package-lock.json
@@ -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"
}
}
}
diff --git a/nodejs/package.json b/nodejs/package.json
index 525ed8f..35c61b5 100644
--- a/nodejs/package.json
+++ b/nodejs/package.json
@@ -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"
},
diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js
index 1463c93..86eac3c 100644
--- a/nodejs/public/lib/js/app-base.js
+++ b/nodejs/public/lib/js/app-base.js
@@ -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 {
diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js
index 1065a44..c97d660 100644
--- a/nodejs/routes/api.js
+++ b/nodejs/routes/api.js
@@ -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'));
diff --git a/nodejs/routes/auth.js b/nodejs/routes/auth.js
deleted file mode 100755
index 9e40523..0000000
--- a/nodejs/routes/auth.js
+++ /dev/null
@@ -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;
diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js
index 1409dde..6e6ccd2 100644
--- a/nodejs/routes/render.js
+++ b/nodejs/routes/render.js
@@ -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}));
diff --git a/nodejs/test/unit/access.test.js b/nodejs/test/unit/access.test.js
index 719cd5e..2ee31ff 100644
--- a/nodejs/test/unit/access.test.js
+++ b/nodejs/test/unit/access.test.js
@@ -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']);
+});
diff --git a/nodejs/utils/access.js b/nodejs/utils/access.js
index c806687..26afc4c 100644
--- a/nodejs/utils/access.js
+++ b/nodejs/utils/access.js
@@ -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 } = {}) {
diff --git a/nodejs/utils/build_info.js b/nodejs/utils/build_info.js
new file mode 100644
index 0000000..8eaa4fd
--- /dev/null
+++ b/nodejs/utils/build_info.js
@@ -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,
+});
\ No newline at end of file
diff --git a/nodejs/utils/oidc.js b/nodejs/utils/oidc.js
deleted file mode 100644
index 9c0b034..0000000
--- a/nodejs/utils/oidc.js
+++ /dev/null
@@ -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,
-};
diff --git a/nodejs/utils/safe_redirect.js b/nodejs/utils/safe_redirect.js
deleted file mode 100644
index b1adcce..0000000
--- a/nodejs/utils/safe_redirect.js
+++ /dev/null
@@ -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};
diff --git a/nodejs/views/bottom.ejs b/nodejs/views/bottom.ejs
index 5cdafbd..6daa39c 100644
--- a/nodejs/views/bottom.ejs
+++ b/nodejs/views/bottom.ejs
@@ -6,7 +6,7 @@
- © <%- (new Date()).getFullYear() %> theta42 ·
+ © <%- buildYear %> theta42 ·
MIT License
@@ -17,7 +17,7 @@
GitHub
- v<%- version %> (<%- commit %>)
+ v<%- buildVersion %> (<%- buildHash %>)