From 67e2fc54c27abc31efb7eda53911b047ba5eaeb2 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 25 Jul 2026 15:55:03 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 16 ++ nodejs/middleware/auth.js | 2 +- nodejs/models/auth.js | 118 -------------- nodejs/models/build_info.js | 24 --- nodejs/models/index.js | 20 ++- nodejs/models/oidc_state.js | 31 ---- nodejs/models/token.js | 64 -------- nodejs/models/user_ldap.js | 111 ++------------ nodejs/models/user_redis.js | 39 +---- nodejs/package-lock.json | 256 +++++++++++++------------------ nodejs/package.json | 12 +- nodejs/public/lib/js/app-base.js | 6 +- nodejs/routes/api.js | 2 +- nodejs/routes/auth.js | 111 -------------- nodejs/routes/render.js | 15 +- nodejs/test/unit/access.test.js | 14 ++ nodejs/utils/access.js | 19 ++- nodejs/utils/build_info.js | 19 +++ nodejs/utils/oidc.js | 127 --------------- nodejs/utils/safe_redirect.js | 23 --- nodejs/views/bottom.ejs | 4 +- 21 files changed, 224 insertions(+), 809 deletions(-) delete mode 100644 nodejs/models/auth.js delete mode 100644 nodejs/models/build_info.js delete mode 100644 nodejs/models/oidc_state.js delete mode 100644 nodejs/models/token.js delete mode 100755 nodejs/routes/auth.js create mode 100644 nodejs/utils/build_info.js delete mode 100644 nodejs/utils/oidc.js delete mode 100644 nodejs/utils/safe_redirect.js 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 %>)