Compare commits

...

17 Commits

Author SHA1 Message Date
wmantly ec76054e41 Merge pull request #93 from theta42/release/v1.3.0
Release 1.3.0
2026-07-23 02:25:27 -04:00
wmantly 5c0fc4f016 Release 1.3.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:22:46 -04:00
wmantly 43dae2a3eb Merge branch 'simpleworkjs' (superseded by tested ORM-port fixes on this branch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:21:50 -04:00
wmantly 20c0a48199 ui: remove mobile phone field from user form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly 12da7140c2 test: dockerized test suite (openldap + redis + test-runner)
docker-compose.test.yml spins up the all-in-one OpenLDAP image, a
standalone Redis, and a test-runner that seeds the test user and runs
jest against them. globalSetup honors REDIS_URL; tests/setup.js
initializes the ORM and flushes test Redis keys before the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly dfd5f46095 feat: OAuth client management API at /api/oauth/client
CRUD + secret rotation for OAuth clients (app_sso_oauth_admin group),
backed by the Resource model. Normalizes form-style string inputs
(newline-separated redirect_uris/allowed_groups, space-separated
scopes, bracketed token_lifetime fields).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly 5dcc75195c fix: complete ORM port — token/oauth-client API mismatches, use published orm 0.2.8
- Use published @simpleworkjs/orm ^0.2.8 (fixes redis adapter write path)
  and model-redis ^1.6.0 instead of a local file: link that broke docker
  npm ci with a misleading "no lockfile" error.
- OtpToken.issue/verify: replace nonexistent find()/listDetail() with
  list({where}).
- routes/auth.js: ImpersonationToken.listDetail() -> list({where}).
- routes/token.js: drop listDetail() call; 404 on missing token instead
  of returning {results: null} with 200 (orm get() returns null, does
  not throw like model-redis Table.get did).
- OAuthClient: Resource has no is_valid column, so every client read as
  disabled and all /oauth/authorize requests 400'd — validity now lives
  in metadata (absent = valid). Also generate a unique slug on create
  (Resource.slug is required+unique) and use Resource.get() for lookup.
- User.login: 401 cleanly when neither uid nor username is supplied.
- models/index.js: log ORM init and surface init failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:49 -04:00
wmantly 12a99b550c Bump @simpleworkjs/orm to 0.2.8 to fix redis bugs 2026-07-22 23:24:41 -04:00
wmantly 4ce5a5f492 Monkey-patch RedisAdapter 2026-07-22 22:41:57 -04:00
wmantly c84141b2f3 Restore package-lock.json 2026-07-22 22:37:51 -04:00
wmantly f76d93d840 Fix User.login credentials check and flush Redis before tests 2026-07-22 22:37:45 -04:00
wmantly e910a492ba Fix Token API compatibility 2026-07-22 22:30:26 -04:00
wmantly 1ef868e23c Fix ORM API usages in Token subclasses 2026-07-22 22:26:26 -04:00
wmantly e11e39c23a fix: initialize ORM in tests 2026-07-22 22:17:23 -04:00
wmantly b91089ad4c fix: use published @simpleworkjs/orm instead of local path 2026-07-22 22:11:11 -04:00
wmantly 95ae50a924 fix: generate package-lock.json with node 20 for CI 2026-07-22 22:06:31 -04:00
wmantly 0076784fae fix: sync package-lock.json version 2026-07-22 22:03:53 -04:00
19 changed files with 2085 additions and 374 deletions
+3 -3
View File
@@ -20,9 +20,9 @@
!directory_spec.md
!docs/**/*.md
# Tests
nodejs/tests/
nodejs/*.test.js
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
# nodejs/tests/
# nodejs/*.test.js
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
+19
View File
@@ -4,6 +4,25 @@ 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.3.0] - 2026-07-23
### Added
- **OAuth client management API** at `/api/oauth/client` (group `app_sso_oauth_admin`): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separated `redirect_uris`/`allowed_groups`, space-separated `scopes`).
- **Dockerized test suite**: `docker-compose -f docker-compose.test.yml up --build` spins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them. `tests/globalSetup.js` honors `REDIS_URL`.
### Fixed
- Completed the model-redis → `@simpleworkjs/orm` port that shipped half-finished in 1.2.1:
- `OtpToken.issue`/`verify` called nonexistent `find()`/`listDetail()` — every OTP login 500'd.
- Impersonation create/revoke called nonexistent `ImpersonationToken.listDetail()` — both endpoints 500'd.
- `OAuthClient` read `is_valid` from the Resource model, which has no such column — every client evaluated as disabled and **all `/oauth/authorize` requests were rejected with 400**. Client validity now lives in `metadata` (absent = valid).
- `OAuthClient.add` didn't set the required-unique `Resource.slug`; clients now get a slug derived from the client name.
- `GET /api/token/:name/:token` returned `{results: null}` with 200 for unknown tokens (orm `get()` returns null instead of throwing); now 404s.
- `User.login` returns a clean 401 instead of crashing when neither `uid` nor `username` is supplied.
- Depend on published `@simpleworkjs/orm` ^0.2.8 and `model-redis` ^1.6.0 instead of a local `file:` link that broke `npm ci` in docker builds.
### Changed
- Removed the Mobile Phone field from the user create/edit form.
## [1.2.1] - 2026-07-22
### Added
+50
View File
@@ -0,0 +1,50 @@
# Test-runner image for SSO Manager.
#
# Installs all dependencies (including dev) and bundles the app code plus
# the seed script. The entrypoint waits for LDAP + Redis, seeds the test
# user, then runs whatever command is given (default: npm test).
FROM node:20-alpine
# Install OpenLDAP clients (ldapadd, ldapsearch) and bash for the seed script
RUN apk add --no-cache openldap-clients bash
WORKDIR /app
# Copy and install dependencies (including devDependencies for jest/supertest)
COPY nodejs/package*.json ./
RUN npm ci
# Copy the application source
COPY nodejs/app.js ./
COPY nodejs/bin ./bin
COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
COPY nodejs/routes ./routes
COPY nodejs/services ./services
COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY nodejs/tests ./tests
# SQLite database directory (config/inventory.sqlite for Resource model's ORM)
RUN mkdir -p /app/config
# Files expected at the flattened /app path (see Dockerfile.openldap notes)
COPY tos.md /tos.md
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Seed script and utility
COPY test_seed.js ./test_seed.js
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
RUN chmod +x /usr/local/bin/seed-test-user
# Default command: seed the test user, then run the test suite
CMD ["sh", "-c", "seed-test-user && npm test"]
+81
View File
@@ -0,0 +1,81 @@
# Docker Compose for running the SSO Manager test suite.
#
# Spins up:
# ldap — OpenLDAP + Redis (all-in-one image, slapd + redis only, no app)
# redis — Standalone Redis for the app's model/token storage
# test-runner — Seeds the test user, then runs `npm test`
#
# Usage:
# docker compose -f docker-compose.test.yml up --build
# # Or to run a specific test file:
# docker compose -f docker-compose.test.yml run --rm test-runner npx jest tests/auth.test.js
#
# The LDAP service uses the same Dockerfile.openldap image as production but
# overrides the command to only start slapd + redis (the entrypoint handles
# slapd.conf generation, directory initialization, and Redis startup before
# running the given command — "sleep infinity" keeps it alive).
#
# The test-runner connects to ldap:389 and redis:6379 via Docker networking.
# app_* env vars override conf/secrets.js (highest precedence in
# @simpleworkjs/conf), so the production secrets.js is never read.
services:
ldap:
build:
context: .
dockerfile: Dockerfile.openldap
environment:
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
- ORG_NAME=Test SSO
# The entrypoint starts slapd + redis, then runs whatever command is given.
# "sleep infinity" keeps the container alive so the test-runner can connect.
command: ["sleep", "infinity"]
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
test-runner:
build:
context: .
dockerfile: Dockerfile.test-runner
environment:
# Tell the app which environment it's in (loads conf/test.js for Redis prefix)
- NODE_ENV=test
# LDAP — point at the ldap service container
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
# Redis — point at the redis service container
- app_redis__redisConf__url=redis://redis:6379
# Also used by tests/globalSetup.js (direct Redis client, not @simpleworkjs/conf)
- REDIS_URL=redis://redis:6379
# JWT secret (required by the app, not sensitive in test)
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
# App name
- app_name=Test SSO
depends_on:
ldap:
condition: service_healthy
redis:
condition: service_healthy
volumes:
ldap-data:
ldap-certs:
+1
View File
@@ -95,6 +95,7 @@ app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.get('/.well-known/openid-configuration', discovery);
+15 -7
View File
@@ -23,13 +23,21 @@ async function initORM() {
};
ormConf.redis = conf.redis;
await init({
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] Starting ORM initialization...');
try {
await init({
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] ORM initialized successfully');
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
} catch (err) {
console.error('[initORM] ORM initialization failed:', err.message);
throw err;
}
}
module.exports.initORM = initORM;
+23 -7
View File
@@ -16,11 +16,18 @@ class OAuthClient {
const raw_secret = crypto.randomUUID();
const client_id = crypto.randomUUID();
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
// Generate a unique slug from the client name
let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
// Ensure uniqueness by appending a suffix if needed
const existing = await Resource.list({ where: { slug } });
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
const r = await Resource.create({
id: client_id,
kind: 'oauth',
name: data.name,
slug: slug,
description: data.description || '',
owner: data.created_by,
metadata: {
@@ -37,10 +44,13 @@ class OAuthClient {
return r;
}
static async get(client_id) {
const resources = await Resource.list({ where: { id: client_id, kind: 'oauth' } });
if (!resources.length) throw new Error('OAuthClient not found');
const r = resources[0];
let r;
try {
r = await Resource.get(client_id);
} catch (_) {
throw new Error('OAuthClient not found');
}
if (r.kind !== 'oauth') throw new Error('OAuthClient not found');
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
r.client_id = r.id;
r.client_secret_hash = r.metadata.client_secret_hash;
@@ -48,6 +58,8 @@ class OAuthClient {
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
r.allowed_groups = r.metadata.allowed_groups || [];
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
// Resource has no is_valid column; validity lives in metadata (absent = valid)
r.is_valid = r.metadata.is_valid !== false;
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
r.rotateSecret = async () => {
@@ -64,11 +76,11 @@ class OAuthClient {
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
if (data.is_valid !== undefined) r.metadata.is_valid = data.is_valid;
const updateData = { metadata: r.metadata };
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.is_valid !== undefined) updateData.is_valid = data.is_valid;
return originalUpdate(updateData);
};
@@ -81,6 +93,10 @@ class OAuthClient {
return Promise.all(resources.map(r => this.get(r.id)));
}
static async listDetail() {
return this.list();
}
static async verifySecret(client_id, secret) {
const client = await this.get(client_id);
return client.verifySecret(secret);
+2 -2
View File
@@ -95,7 +95,7 @@ class OtpToken extends Token {
}
static async issue(uid, method) {
const existing = await this.find({uid});
const existing = await this.list({where: {uid}});
for (const t of existing) {
if (t.is_valid) await t.update({is_valid: false});
}
@@ -104,7 +104,7 @@ class OtpToken extends Token {
}
static async verify(uid, code) {
const tokens = await this.listDetail({uid});
const tokens = await this.list({where: {uid}});
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
if (!match) return null;
await match.update({is_valid: false});
+6
View File
@@ -891,6 +891,12 @@ User.invite = async function(data = {}){
User.login = async function(data){
try{
if (!data.uid && !data.username) {
let error = new Error('Invalid Credentials, login failed.');
error.name = 'LDAPLoginFailed';
error.status = 401;
throw error;
}
let user = await this.get(data.uid || data.username);
const loginClient = makeClient();
+1547 -340
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.2.1",
"version": "1.3.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
@@ -24,7 +24,7 @@
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/orm": "file:../../../simpleworkjs/orm",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
@@ -38,7 +38,7 @@
"ldapts": "^8.1.2",
"lru-cache": "^11.5.1",
"marked": "^9.1.6",
"model-redis": "^0.4.0",
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"nodemailer": "^9.0.0",
+2 -2
View File
@@ -203,7 +203,7 @@ router.post('/impersonate/:uid', middleware.auth, async function(req, res, next)
const target = await User.get(req.params.uid);
// Clean up any existing impersonation for this target
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
for (const old of existing) {
if (old.is_valid && !old.isExpired) {
try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
@@ -237,7 +237,7 @@ router.delete('/impersonate/:uid', middleware.auth, async function(req, res, nex
await permission.byGroup(req.user, ['app_sso_admin']);
const target = await User.get(req.params.uid);
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
let revoked = 0;
for (const token of existing) {
+127
View File
@@ -0,0 +1,127 @@
'use strict';
const router = require('express').Router();
const { OAuthClient } = require('../models/oauth_client');
const permission = require('../utils/permission');
const ADMIN_GROUP = 'app_sso_oauth_admin';
router.get('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
return res.json({ results: await OAuthClient.listDetail() });
} catch(error) {
next(error);
}
});
router.post('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
req.body.created_by = req.user.uid;
// Parse redirect_uris if sent as newline-separated string from the form
if (typeof req.body.redirect_uris === 'string') {
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
}
// Parse scopes if sent as space-separated string
if (typeof req.body.scopes === 'string') {
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
}
// Parse allowed_groups if sent as newline-separated string
if (typeof req.body.allowed_groups === 'string') {
req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean);
}
// jQuery serializeObject sends nested fields as "token_lifetime[access_token]"
if (req.body['token_lifetime[access_token]'] || req.body['token_lifetime[refresh_token]']) {
req.body.token_lifetime = {
access_token: Number(req.body['token_lifetime[access_token]']) || 3600,
refresh_token: Number(req.body['token_lifetime[refresh_token]']) || 2592000,
};
delete req.body['token_lifetime[access_token]'];
delete req.body['token_lifetime[refresh_token]'];
}
const client = await OAuthClient.add(req.body);
const result = client.toJSON ? client.toJSON() : { ...client };
result.client_id = client.client_id || client.id;
return res.json({
results: result,
client_secret: client._raw_secret,
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
router.get('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
return res.json({ results: await OAuthClient.get(req.params.client_id) });
} catch(error) {
next(error);
}
});
router.put('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
if (typeof req.body.redirect_uris === 'string') {
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
}
if (typeof req.body.scopes === 'string') {
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
}
if (typeof req.body.allowed_groups === 'string') {
req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean);
}
return res.json({
results: await client.update(req.body),
message: `OAuth client '${client.name}' updated.`,
});
} catch(error) {
next(error);
}
});
router.delete('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
await client.remove();
return res.json({
client_id: req.params.client_id,
message: `OAuth client '${client.name}' deleted.`,
});
} catch(error) {
next(error);
}
});
router.post('/:client_id/rotate', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
const new_secret = await client.rotateSecret();
return res.json({
client_secret: new_secret,
message: `Client secret rotated for '${client.name}'. Save it — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
module.exports = router;
+10 -4
View File
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
router.get('/:name', async function(req, res, next){
try{
// ORM models: list() on the redis adapter always returns full rows;
// detail is handled by serialization (isPrivate fields are excluded).
return res.json({
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]()
results: await tokens[req.params.name].list()
});
}catch(error){
next(error);
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
router.get('/:name/:token', async function(req, res, next){
try{
return res.json({
results: await tokens[req.params.name].get(req.params.token)
});
const result = await tokens[req.params.name].get(req.params.token);
if (!result) {
const error = new Error('Token not found');
error.status = 404;
throw error;
}
return res.json({ results: result });
}catch(error){
next(error);
}
+2 -1
View File
@@ -5,7 +5,8 @@
const { createClient } = require('redis');
module.exports = async function() {
const client = createClient();
const redisUrl = process.env.REDIS_URL || undefined;
const client = createClient(redisUrl ? { url: redisUrl } : {});
await client.connect();
const keys = await client.keys('sso_manager_test_*');
+13
View File
@@ -3,6 +3,19 @@
const crypto = require('crypto');
const request = require('supertest');
const app = require('../app');
const { initORM } = require('../models');
beforeAll(async () => {
await initORM();
const { Token } = require('../models/token');
try {
if (Token.orm) {
await Token.orm.adapter(Token).Table.redisClient.flushDb();
}
} catch (e) {
console.warn('Could not flush Redis:', e.message);
}
});
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
+2 -5
View File
@@ -64,7 +64,7 @@ async function fetchUsernameSuggestions() {
// simply can't bind). Disabling (not just hiding) keeps disabled
// fields out of both form serialization and validation.
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=mobile]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
@@ -137,10 +137,7 @@ async function fetchUsernameSuggestions() {
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
</div>
<div class="mb-3">
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
</div>
<div class="mb-3">
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# seed-test-user.sh — Create the test user in LDAP for the test suite.
#
# The test suite (tests/setup.js) logs in as uid=test / password=MyTestPassword!2.
# This script creates that user in the LDAP directory along with its personal
# posixGroup, and adds it to the app_sso_admin group so admin-gated tests pass.
#
# Environment variables (from docker-compose.test.yml):
# LDAP_HOST — LDAP server hostname (default: ldap)
# LDAP_PORT — LDAP server port (default: 389)
# BIND_DN — LDAP admin bind DN (default: cn=admin,dc=test,dc=local)
# BIND_PW — LDAP admin password (default: secret)
# BASE_DN — LDAP base DN (default: dc=test,dc=local)
set -euo pipefail
LDAP_HOST="${LDAP_HOST:-ldap}"
LDAP_PORT="${LDAP_PORT:-389}"
BIND_DN="${BIND_DN:-cn=admin,dc=test,dc=local}"
BIND_PW="${BIND_PW:-secret}"
BASE_DN="${BASE_DN:-dc=test,dc=local}"
LDAP_URI="ldap://${LDAP_HOST}:${LDAP_PORT}"
USER_UID="test"
USER_PASSWORD="MyTestPassword!2"
info() { echo "[INFO] $*"; }
error() { echo "[ERROR] $*" >&2; }
# ── Wait for LDAP to be reachable ────────────────────────────────────────────
info "Waiting for LDAP at ${LDAP_URI}..."
for i in $(seq 1 30); do
if ldapsearch -x -H "$LDAP_URI" -b '' -s base '(objectClass=*)' >/dev/null 2>&1; then
info "LDAP is reachable"
break
fi
if [ "$i" -eq 30 ]; then
error "LDAP not reachable after 30 attempts"
exit 1
fi
sleep 1
done
# ── Check if the test user already exists ────────────────────────────────────
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Test user '${USER_UID}' already exists — skipping seed"
exit 0
fi
# ── Generate the SSHA512 password hash ───────────────────────────────────────
# Inline the hash function to avoid requiring the full model chain (which
# tries to connect to Redis/LDAP during module loading and would hang).
info "Generating password hash..."
PASSWORD_HASH=$(node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('${USER_PASSWORD}').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
")
info "Password hash generated"
# ── Create a temporary LDIF file ─────────────────────────────────────────────
TMPFILE=$(mktemp /tmp/seed-test-user.XXXXXX)
trap 'rm -f "$TMPFILE"' EXIT
cat > "$TMPFILE" << LDIF
# Personal posixGroup for the test user
dn: cn=${USER_UID},ou=groups,${BASE_DN}
objectClass: posixGroup
objectClass: top
cn: ${USER_UID}
gidNumber: 1500
description: Personal group for test user
# Test user posixAccount
dn: cn=${USER_UID},ou=people,${BASE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${USER_UID}
sn: Test
uid: ${USER_UID}
uidNumber: 1500
gidNumber: 1500
homeDirectory: /home/${USER_UID}
loginShell: /bin/bash
mail: test@test.local
userPassword: ${PASSWORD_HASH}
description: Test user for automated test suite
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${USER_UID}
LDIF
# ── Add the entries to LDAP ──────────────────────────────────────────────────
info "Creating test user '${USER_UID}' in LDAP..."
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Test user '${USER_UID}' exists or was created"
else
error "Failed to create test user '${USER_UID}'"
exit 1
fi
# ── Add the test user to required SSO groups ─────────────────────────────────
info "Adding test user to SSO admin groups..."
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
ldapmodify -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" << EOF 2>/dev/null || true
dn: cn=${group},ou=groups,${BASE_DN}
changetype: modify
add: member
member: cn=${USER_UID},ou=people,${BASE_DN}
EOF
done
info "Test user added to SSO admin groups"
# ── Create additional users needed by tests ─────────────────────────────────
# wmantly is referenced by OTP (otp.test.js) and impersonation (impersonate.test.js)
# tests as an existing non-admin user.
WMANTLY_UID="wmantly"
if ! ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Creating additional test user '${WMANTLY_UID}'..."
# Generate password hash for wmantly
WMANTLY_HASH=$(node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('testpass').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
")
cat > "$TMPFILE" << LDIF
dn: cn=${WMANTLY_UID},ou=groups,${BASE_DN}
objectClass: posixGroup
objectClass: top
cn: ${WMANTLY_UID}
gidNumber: 1501
dn: cn=${WMANTLY_UID},ou=people,${BASE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${WMANTLY_UID}
sn: Mantly
uid: ${WMANTLY_UID}
uidNumber: 1501
gidNumber: 1501
homeDirectory: /home/${WMANTLY_UID}
loginShell: /bin/bash
mail: wmantly@test.local
userPassword: ${WMANTLY_HASH}
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${WMANTLY_UID}
LDIF
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Additional test user '${WMANTLY_UID}' exists or was created"
else
error "Failed to create additional test user '${WMANTLY_UID}'"
exit 1
fi
fi
info "Seed complete — all test users are ready"
+2
View File
@@ -0,0 +1,2 @@
const { hashPasswordSSHA512 } = require('./nodejs/models/user_ldap.js');
console.log(hashPasswordSSHA512('MyTestPassword!2'));