Release 1.2.0: adopt shared @simpleworkjs/* packages; fix directory envelope drift

Rewire onto the shared @simpleworkjs/oidc-client, /directory-schema, /ldap, and
/app-stack packages (deleting the byte-identical local forks). utils/access.js
now fetches reachable hosts through the shared directory client, which
validates the {results} envelope and treats envelope drift as a failed group
rather than silently returning []. models/user_ldap.js is a thin wrapper over
createLdapClient (loose TLS default preserved). build_info moves to utils/ with
the shared {buildVersion,buildHash,buildYear} shape. Align ldapts ^8.1.8 and
redis ^6.1.0. Lockfile regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-25 15:55:03 -04:00
parent 23d99980ce
commit 67e2fc54c2
21 changed files with 224 additions and 809 deletions
-118
View File
@@ -1,118 +0,0 @@
'use strict';
const Table = require('../models');
const {User, AuthToken} = Table.models;
/**
* Auth Model
*
* Handles authentication operations for the application.
* Manages user login, token validation, and logout processes.
*
* Dependencies:
* - User model: Validates user credentials
* - AuthToken model: Creates and manages authentication tokens
*
* All methods throw standardized login errors on failure to avoid
* leaking information about whether usernames exist or tokens are valid.
*/
class Auth{
/**
* Standardized error responses for authentication failures.
* Returns generic "Invalid Credentials" message for security.
*/
static errors = {
login: function(){
let error = new Error('LoginFailed');
error.name = 'LoginFailed';
error.message = `Invalid Credentials, login failed.`;
error.status = 401;
return error;
}
}
/**
* Authenticate user and create session token.
*
* @param {Object} data - Login credentials {username, password}
* @returns {Object} {user, token} - User object and auth token
* @throws {Error} Generic login error on any failure
*
* Flow:
* 1. Validate credentials via User.login()
* 2. Create new AuthToken for the user
* 3. Return both user data and token
*/
static async login(data){
try{
let user = await User.login(data);
// Backends may attach group membership to the user (LDAP); default
// to none for local/redis users.
let groups = Array.isArray(user.groups) ? user.groups : [];
let token = await AuthToken.create({username: user.username, groups});
return {user, token}
}catch(error){
console.log('login error', error);
throw this.errors.login();
}
}
/**
* Establish a session for an OIDC-authenticated identity: JIT-provision the
* local user (redis-backed) and mint an AuthToken carrying the SSO groups.
*
* @param {Object} identity - {username, groups} from utils/oidc claims
* @returns {Object} {user, token}
*/
static async oidcSession(identity){
let user = typeof User.upsertOidc === 'function'
? await User.upsertOidc(identity)
: await User.get(identity.username);
let token = await AuthToken.create({
username: user.username,
groups: identity.groups || [],
});
return {user, token};
}
/**
* Validate an authentication token.
*
* @param {string} token - Token string to validate
* @returns {Object} Token object if valid
* @throws {Error} Generic login error if token invalid or expired
*
* Checks:
* 1. Token exists in database
* 2. Token has not expired (via token.check())
*/
static async checkToken(token){
try{
token = await AuthToken.get(token);
if(token && token.check()) return token;
throw this.errors.login();
}catch(error){
console.log('check error', error);
throw this.errors.login();
}
}
/**
* Destroy an authentication token (logout).
*
* @param {string} data - Token string to destroy
* @returns {void}
*
* Removes token from database, invalidating the session.
*/
static async logout(data){
let token = await AuthToken.get(data);
await token.destroy();
}
}
module.exports = {Auth};
-24
View File
@@ -1,24 +0,0 @@
'use strict';
// Short git commit, baked into /app/.build_commit at image build time (see
// Dockerfile gitinfo stage) or resolved from git on bare metal.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function resolve() {
try {
const baked = path.join(__dirname, '../../.build_commit');
if (fs.existsSync(baked)) return fs.readFileSync(baked, 'utf8').trim();
} catch (_) {}
try {
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch (_) {}
return 'unknown';
}
let version = 'unknown';
try { version = require('../package.json').version; } catch (_) {}
module.exports = { commit: resolve(), version };
+17 -3
View File
@@ -6,6 +6,7 @@
const conf = require('@simpleworkjs/conf');
const { setUpTable } = require('model-redis');
const { createOidcClient, bootstrapLocalAdmin } = require('@simpleworkjs/oidc-client');
const Table = setUpTable(conf.redis);
@@ -32,7 +33,20 @@ async function getRedis() {
module.exports.getRedis = getRedis;
// Register models (order matters: User before AuthToken's relation resolves).
require('./user_redis');
require('./token');
require('./oidc_state');
require('./user_redis'); // User (redis-backed local + OIDC JIT)
// Shared OIDC client (authorization-code + PKCE): session models (Token,
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
// /oidc/callback router — all created on this app's Table/redis. jump-host has
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent).
const oidcClient = createOidcClient({ Table });
module.exports.Token = oidcClient.Token;
module.exports.AuthToken = oidcClient.AuthToken;
module.exports.OidcState = oidcClient.OidcState;
module.exports.Auth = oidcClient.Auth;
module.exports.authRouter = oidcClient.router;
require('./audit_event');
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
-31
View File
@@ -1,31 +0,0 @@
'use strict';
const Table = require('.');
/**
* OidcState
*
* Short-lived store for an in-flight OpenID Connect authorization request.
* Keyed by the random `state` value; holds the PKCE `code_verifier` and the
* post-login redirect target until the SSO calls us back.
*
* The record auto-expires via model-redis per-key TTL (static _ttl), so an
* abandoned login attempt leaves nothing behind and there is no cleanup job.
*/
class OidcState extends Table{
static _key = 'state';
// Auth round-trips are quick; 5 minutes is plenty and bounds replay.
static _ttl = 300;
static _keyMap = {
'created_on': {default: function(){return (new Date).getTime()}},
'state': {isRequired: true, type: 'string', min: 8, max: 500},
'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500},
'redirect': {default: '/', isRequired: false, type: 'string'},
}
}
OidcState.register();
module.exports = {OidcState};
-64
View File
@@ -1,64 +0,0 @@
'use strict';
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
class Token extends Table{
static _key = 'token';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
'is_valid': {default: true, type: 'boolean'},
}
constructor(...args){
super(...args);
}
async check(){
try{
return this.is_valid;
}catch(error){
return false
}
}
}
Token.register();
class AuthToken extends Token{
static _keyMap = {
...super._keyMap,
user: {model: 'User', rel: 'one', localKey: 'created_by'},
// Group memberships captured at login (OIDC `groups` claim or LDAP
// group membership), stored as a JSON string. Drives authorization for
// the life of the session without re-querying the IdP on every request.
groups: {default: '[]', isRequired: false, type: 'string'},
}
static async create(data){
data.created_by = data.username;
if(Array.isArray(data.groups)){
data.groups = JSON.stringify(data.groups);
}
return super.create(data)
}
// Parse the stored groups JSON back into an array, tolerating bad/missing
// data so authorization never crashes on a malformed token.
groupsArray(){
try{
let parsed = JSON.parse(this.groups);
return Array.isArray(parsed) ? parsed : [];
}catch(error){
return [];
}
}
}
AuthToken.register();
module.exports = {Token, AuthToken};
+12 -99
View File
@@ -1,109 +1,22 @@
'use strict';
// Thin LDAP helpers — the jump host's entire LDAP surface:
// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the
// shared @simpleworkjs/ldap package:
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
// checkPassword(dn, pw) -> bool (simple bind as the user)
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
//
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
// TypeOrValueExists treated as success on key add).
// Behavior is unchanged from the previous in-tree implementation: posixAccount
// user filter, groupOfNames group filter, bind-as-user password check,
// TypeOrValueExists treated as success on key add, and the same loose TLS
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
const { Client, Change, Attribute } = require('ldapts');
const conf = require('@simpleworkjs/conf');
const { createLdapClient } = require('@simpleworkjs/ldap');
function ldapConf() {
return conf.ldap || {};
}
function makeClient() {
const c = ldapConf();
return new Client({
url: c.url,
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
});
}
// Escape a value being interpolated into an LDAP filter (RFC 4515).
function escapeFilter(value) {
return String(value).replace(/[\\*()\0]/g, (ch) => ({
'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00',
}[ch]));
}
async function withClient(fn) {
const c = ldapConf();
const client = makeClient();
try {
await client.bind(c.bindDN, c.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
async function getUser(uid) {
const c = ldapConf();
const attr = c.userNameAttribute || 'uid';
return withClient(async (client) => {
const { searchEntries } = await client.search(c.userBase, {
scope: 'sub',
filter: `(&(objectClass=posixAccount)(${attr}=${escapeFilter(uid)}))`,
attributes: ['dn', attr, 'cn', 'sshPublicKey'],
});
if (!searchEntries.length) return null;
const e = searchEntries[0];
let keys = e.sshPublicKey || [];
if (!Array.isArray(keys)) keys = [keys];
return {
dn: e.dn,
uid: String(e[attr]),
sshPublicKeys: keys.map(String),
};
});
}
async function getGroups(dn) {
const c = ldapConf();
return withClient(async (client) => {
const { searchEntries } = await client.search(c.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)(member=${escapeFilter(dn)}))`,
attributes: ['cn'],
});
return searchEntries.map((e) => String(e.cn));
});
}
async function checkPassword(dn, password) {
if (!password) return false;
const client = makeClient();
try {
await client.bind(dn, password);
return true;
} catch (_) {
return false;
} finally {
await client.unbind().catch(() => {});
}
}
async function addSshKey(dn, keyLine) {
return withClient(async (client) => {
try {
await client.modify(dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'sshPublicKey', values: [keyLine] }),
}),
]);
} catch (error) {
// Same de-dup semantics as the SSO's User.addSSHkey.
if (error.name === 'TypeOrValueExistsError') return;
throw error;
}
});
}
module.exports = { getUser, getGroups, checkPassword, addSshKey, escapeFilter, makeClient };
const ldapConf = conf.ldap || {};
module.exports = createLdapClient({
...ldapConf,
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
});
+3 -36
View File
@@ -3,7 +3,6 @@
const Table = require('.');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const saltRounds = 10;
class User extends Table{
@@ -86,38 +85,6 @@ class User extends Table{
User.register();
(async function(){
// The anti-lockout local admin: the first entry in conf.auth.adminUsers
// (default 'jumpadmin'). A local login that works even if the SSO/OIDC is
// unreachable — the whole point of "OIDC + internal users".
var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'jumpadmin';
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
// auth.localAdminPass in jump-secrets.js to a generated password so this
// bootstrap account isn't left at a well-known default. Only used on first
// creation -- once the account exists this is never read again, so it's
// safe to leave set. If unset, a random password is generated and printed
// once; save it from the log or set auth.localAdminPass explicitly.
var defaultPass = (conf.auth && conf.auth.localAdminPass);
if (!defaultPass) {
defaultPass = crypto.randomBytes(16).toString('hex');
console.warn(`====================================================================`);
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
console.warn(`${defaultPass}`);
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
console.warn(`====================================================================`);
}
try{
let user = await User.get(defaultUser);
}catch(error){
try{
let user = await User.create({
username:defaultUser,
password: defaultPass,
created_by: defaultUser
});
console.log(defaultUser, 'created');
}catch(error){
console.error(error)
}
}
})();
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
// registered. See the package lib/bootstrap.js for the original logic.