Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e2fc54c2 |
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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};
|
||||
@@ -1,24 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Short git commit, baked into /app/.build_commit at image build time (see
|
||||
// Dockerfile gitinfo stage) or resolved from git on bare metal.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function resolve() {
|
||||
try {
|
||||
const baked = path.join(__dirname, '../../.build_commit');
|
||||
if (fs.existsSync(baked)) return fs.readFileSync(baked, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
try {
|
||||
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
||||
} catch (_) {}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
let version = 'unknown';
|
||||
try { version = require('../package.json').version; } catch (_) {}
|
||||
|
||||
module.exports = { commit: resolve(), version };
|
||||
+17
-3
@@ -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' });
|
||||
@@ -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};
|
||||
@@ -1,64 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
||||
|
||||
|
||||
class Token extends Table{
|
||||
static _key = 'token';
|
||||
static _keyMap = {
|
||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
||||
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
|
||||
'is_valid': {default: true, type: 'boolean'},
|
||||
}
|
||||
|
||||
constructor(...args){
|
||||
super(...args);
|
||||
}
|
||||
|
||||
async check(){
|
||||
try{
|
||||
return this.is_valid;
|
||||
}catch(error){
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Token.register();
|
||||
|
||||
class AuthToken extends Token{
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
user: {model: 'User', rel: 'one', localKey: 'created_by'},
|
||||
// Group memberships captured at login (OIDC `groups` claim or LDAP
|
||||
// group membership), stored as a JSON string. Drives authorization for
|
||||
// the life of the session without re-querying the IdP on every request.
|
||||
groups: {default: '[]', isRequired: false, type: 'string'},
|
||||
}
|
||||
|
||||
static async create(data){
|
||||
data.created_by = data.username;
|
||||
if(Array.isArray(data.groups)){
|
||||
data.groups = JSON.stringify(data.groups);
|
||||
}
|
||||
return super.create(data)
|
||||
|
||||
}
|
||||
|
||||
// Parse the stored groups JSON back into an array, tolerating bad/missing
|
||||
// data so authorization never crashes on a malformed token.
|
||||
groupsArray(){
|
||||
try{
|
||||
let parsed = JSON.parse(this.groups);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}catch(error){
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthToken.register();
|
||||
|
||||
module.exports = {Token, AuthToken};
|
||||
+12
-99
@@ -1,109 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
// Thin LDAP helpers — the jump host's entire LDAP surface:
|
||||
// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the
|
||||
// shared @simpleworkjs/ldap package:
|
||||
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
||||
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
|
||||
// checkPassword(dn, pw) -> bool (simple bind as the user)
|
||||
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
|
||||
//
|
||||
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
|
||||
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
|
||||
// TypeOrValueExists treated as success on key add).
|
||||
// Behavior is unchanged from the previous in-tree implementation: posixAccount
|
||||
// user filter, groupOfNames group filter, bind-as-user password check,
|
||||
// TypeOrValueExists treated as success on key add, and the same loose TLS
|
||||
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
|
||||
|
||||
const { Client, Change, Attribute } = require('ldapts');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||
|
||||
function ldapConf() {
|
||||
return conf.ldap || {};
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
const c = ldapConf();
|
||||
return new Client({
|
||||
url: c.url,
|
||||
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
// Escape a value being interpolated into an LDAP filter (RFC 4515).
|
||||
function escapeFilter(value) {
|
||||
return String(value).replace(/[\\*()\0]/g, (ch) => ({
|
||||
'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00',
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
async function withClient(fn) {
|
||||
const c = ldapConf();
|
||||
const client = makeClient();
|
||||
try {
|
||||
await client.bind(c.bindDN, c.bindPassword);
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser(uid) {
|
||||
const c = ldapConf();
|
||||
const attr = c.userNameAttribute || 'uid';
|
||||
return withClient(async (client) => {
|
||||
const { searchEntries } = await client.search(c.userBase, {
|
||||
scope: 'sub',
|
||||
filter: `(&(objectClass=posixAccount)(${attr}=${escapeFilter(uid)}))`,
|
||||
attributes: ['dn', attr, 'cn', 'sshPublicKey'],
|
||||
});
|
||||
if (!searchEntries.length) return null;
|
||||
const e = searchEntries[0];
|
||||
let keys = e.sshPublicKey || [];
|
||||
if (!Array.isArray(keys)) keys = [keys];
|
||||
return {
|
||||
dn: e.dn,
|
||||
uid: String(e[attr]),
|
||||
sshPublicKeys: keys.map(String),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getGroups(dn) {
|
||||
const c = ldapConf();
|
||||
return withClient(async (client) => {
|
||||
const { searchEntries } = await client.search(c.groupBase, {
|
||||
scope: 'sub',
|
||||
filter: `(&(objectClass=groupOfNames)(member=${escapeFilter(dn)}))`,
|
||||
attributes: ['cn'],
|
||||
});
|
||||
return searchEntries.map((e) => String(e.cn));
|
||||
});
|
||||
}
|
||||
|
||||
async function checkPassword(dn, password) {
|
||||
if (!password) return false;
|
||||
const client = makeClient();
|
||||
try {
|
||||
await client.bind(dn, password);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
await client.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function addSshKey(dn, keyLine) {
|
||||
return withClient(async (client) => {
|
||||
try {
|
||||
await client.modify(dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({ type: 'sshPublicKey', values: [keyLine] }),
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
// Same de-dup semantics as the SSO's User.addSSHkey.
|
||||
if (error.name === 'TypeOrValueExistsError') return;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { getUser, getGroups, checkPassword, addSshKey, escapeFilter, makeClient };
|
||||
const ldapConf = conf.ldap || {};
|
||||
module.exports = createLdapClient({
|
||||
...ldapConf,
|
||||
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
const Table = require('.');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const saltRounds = 10;
|
||||
|
||||
class User extends Table{
|
||||
@@ -86,38 +85,6 @@ class User extends Table{
|
||||
|
||||
User.register();
|
||||
|
||||
(async function(){
|
||||
// The anti-lockout local admin: the first entry in conf.auth.adminUsers
|
||||
// (default 'jumpadmin'). A local login that works even if the SSO/OIDC is
|
||||
// unreachable — the whole point of "OIDC + internal users".
|
||||
var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'jumpadmin';
|
||||
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
|
||||
// auth.localAdminPass in jump-secrets.js to a generated password so this
|
||||
// bootstrap account isn't left at a well-known default. Only used on first
|
||||
// creation -- once the account exists this is never read again, so it's
|
||||
// safe to leave set. If unset, a random password is generated and printed
|
||||
// once; save it from the log or set auth.localAdminPass explicitly.
|
||||
var defaultPass = (conf.auth && conf.auth.localAdminPass);
|
||||
if (!defaultPass) {
|
||||
defaultPass = crypto.randomBytes(16).toString('hex');
|
||||
console.warn(`====================================================================`);
|
||||
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
|
||||
console.warn(`${defaultPass}`);
|
||||
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
|
||||
console.warn(`====================================================================`);
|
||||
}
|
||||
try{
|
||||
let user = await User.get(defaultUser);
|
||||
}catch(error){
|
||||
try{
|
||||
let user = await User.create({
|
||||
username:defaultUser,
|
||||
password: defaultPass,
|
||||
created_by: defaultUser
|
||||
});
|
||||
console.log(defaultUser, 'created');
|
||||
}catch(error){
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
})();
|
||||
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
|
||||
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
|
||||
// registered. See the package lib/bootstrap.js for the original logic.
|
||||
|
||||
Generated
+108
-148
@@ -10,7 +10,11 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"compression": "^1.8.1",
|
||||
@@ -19,11 +23,11 @@
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^3.7.1",
|
||||
"ldapts": "^8.1.2",
|
||||
"ldapts": "^8.1.8",
|
||||
"model-redis": "^1.6.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"redis": "^4.7.0",
|
||||
"redis": "^6.1.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"ssh2": "^1.16.0"
|
||||
},
|
||||
@@ -55,62 +59,87 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.1.0.tgz",
|
||||
"integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz",
|
||||
"integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
"yallist": "4.0.0"
|
||||
"cluster-key-slot": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/graph": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
||||
"license": "MIT",
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
"@node-rs/xxhash": "^1.1.0",
|
||||
"@opentelemetry/api": ">=1 <2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@node-rs/xxhash": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-6.1.0.tgz",
|
||||
"integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-6.1.0.tgz",
|
||||
"integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.1.0.tgz",
|
||||
"integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/app-stack": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
|
||||
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/conf": {
|
||||
@@ -125,6 +154,41 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/directory-schema": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.0.0.tgz",
|
||||
"integrity": "sha512-thZhPGNdDYlD8rlhXidnbCHTKjdSkj9ag1zE/gz1AwuclYypsKAP+v3BAvcZ/YDQP8RBDJNPXof5EpVheLovTg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/ldap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
||||
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ldapts": "^8.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/oidc-client": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/oidc-client/-/oidc-client-1.0.0.tgz",
|
||||
"integrity": "sha512-AzxIaE32p4yKDlp0mWvZp1wmXi8tMFckcPwMiQyZrDgEC0IybGhbjppPa+vNGx1AoVLp64vRL/zR3yXb/19NPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
@@ -876,15 +940,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -1224,94 +1279,6 @@
|
||||
"redis": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/model-redis/node_modules/@redis/bloom": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.1.0.tgz",
|
||||
"integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/model-redis/node_modules/@redis/client": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz",
|
||||
"integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@node-rs/xxhash": "^1.1.0",
|
||||
"@opentelemetry/api": ">=1 <2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@node-rs/xxhash": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/model-redis/node_modules/@redis/json": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-6.1.0.tgz",
|
||||
"integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/model-redis/node_modules/@redis/search": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-6.1.0.tgz",
|
||||
"integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/model-redis/node_modules/@redis/time-series": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.1.0.tgz",
|
||||
"integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/model-redis/node_modules/redis": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-6.1.0.tgz",
|
||||
"integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redis/bloom": "6.1.0",
|
||||
"@redis/client": "6.1.0",
|
||||
"@redis/json": "6.1.0",
|
||||
"@redis/search": "6.1.0",
|
||||
"@redis/time-series": "6.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
@@ -1617,20 +1584,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-6.1.0.tgz",
|
||||
"integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@redis/bloom": "1.2.0",
|
||||
"@redis/client": "1.6.1",
|
||||
"@redis/graph": "1.1.1",
|
||||
"@redis/json": "1.0.7",
|
||||
"@redis/search": "1.2.0",
|
||||
"@redis/time-series": "1.1.0"
|
||||
"@redis/bloom": "6.1.0",
|
||||
"@redis/client": "6.1.0",
|
||||
"@redis/json": "6.1.0",
|
||||
"@redis/search": "6.1.0",
|
||||
"@redis/time-series": "6.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -2079,12 +2045,6 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
@@ -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}));
|
||||
|
||||
@@ -53,3 +53,17 @@ test('caches per uid', async () => {
|
||||
await accessibleHosts(user, { fetchImpl, ldap });
|
||||
assert.strictEqual(calls, 1);
|
||||
});
|
||||
|
||||
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'dave', dn: 'd' };
|
||||
// drift shape: a bare array instead of { results: [...] }. The shared client
|
||||
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
|
||||
// good group alongside still yields its hosts.
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
|
||||
};
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
|
||||
});
|
||||
|
||||
+11
-8
@@ -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 } = {}) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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};
|
||||
@@ -6,7 +6,7 @@
|
||||
<a href="https://theta42.com" target="_blank">
|
||||
<img width="64" src="/static/img/theta42.svg"/>
|
||||
</a>
|
||||
© <%- (new Date()).getFullYear() %> theta42 ·
|
||||
© <%- buildYear %> theta42 ·
|
||||
<a href="https://github.com/theta42/jump-host/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-3">
|
||||
@@ -17,7 +17,7 @@
|
||||
<i class="fa-brands fa-github"></i> GitHub
|
||||
</a>
|
||||
</span>
|
||||
<span>v<%- version %> (<%- commit %>)</span>
|
||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user