Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3babf18fe4 | |||
| d78f1dfabf | |||
| c3c206b830 | |||
| 0a21dce0d7 | |||
| 5940880d9b | |||
| 17fcf2fed0 | |||
| ec76054e41 | |||
| 5c0fc4f016 | |||
| 43dae2a3eb | |||
| 20c0a48199 | |||
| 12da7140c2 | |||
| dfd5f46095 | |||
| 5dcc75195c | |||
| 12a99b550c | |||
| 4ce5a5f492 | |||
| c84141b2f3 | |||
| f76d93d840 | |||
| e910a492ba | |||
| 1ef868e23c | |||
| e11e39c23a | |||
| b91089ad4c | |||
| 95ae50a924 | |||
| 0076784fae |
+3
-3
@@ -20,9 +20,9 @@
|
|||||||
!directory_spec.md
|
!directory_spec.md
|
||||||
!docs/**/*.md
|
!docs/**/*.md
|
||||||
|
|
||||||
# Tests
|
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||||
nodejs/tests/
|
# nodejs/tests/
|
||||||
nodejs/*.test.js
|
# nodejs/*.test.js
|
||||||
|
|
||||||
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
|
# 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).
|
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
|
||||||
|
|||||||
@@ -4,6 +4,33 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
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`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
|
||||||
|
|
||||||
|
## [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
|
## [1.2.1] - 2026-07-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-3
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
|
|||||||
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
||||||
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
||||||
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
||||||
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS)
|
- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
|
||||||
|
|
||||||
### API tokens (personal access tokens)
|
### API tokens (personal access tokens)
|
||||||
|
|
||||||
@@ -483,8 +483,8 @@ netstat -tlnp | grep 389
|
|||||||
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
||||||
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
||||||
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
||||||
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should
|
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
|
||||||
use `ldaps://…:636` or StartTLS.
|
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
|
||||||
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
||||||
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
||||||
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
||||||
|
|||||||
+2
-1
@@ -131,7 +131,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
|
|||||||
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
||||||
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
||||||
# if you want LAN clients to bind without TLS (not recommended).
|
# if you want LAN clients to bind without TLS (not recommended).
|
||||||
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS)
|
# 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
|
||||||
|
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
|
||||||
EXPOSE 3001 389 636
|
EXPOSE 3001 389 636
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -41,9 +41,10 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
- **Web management UI** — manage users, groups, and OAuth clients from a
|
- **Web management UI** — manage users, groups, and OAuth clients from a
|
||||||
browser; invite and password-reset flows over email; user self-service for
|
browser; invite and password-reset flows over email; user self-service for
|
||||||
profile and API tokens.
|
profile and API tokens.
|
||||||
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and
|
- **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
|
||||||
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same
|
rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
|
||||||
directory, so you don't maintain a second user database for them.
|
and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
|
||||||
|
same directory, so you don't maintain a second user database for them.
|
||||||
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
||||||
drive the management API from scripts or CI, scoped to their own permissions.
|
drive the management API from scripts or CI, scoped to their own permissions.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
||||||
@@ -151,7 +152,7 @@ details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
|||||||
┌────────────────────────┐
|
┌────────────────────────┐
|
||||||
│ OpenLDAP (slapd) │
|
│ OpenLDAP (slapd) │
|
||||||
│ - users / groups │
|
│ - users / groups │
|
||||||
│ - LDAPS :636 │─── legacy apps bind directly
|
│ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
|
||||||
│ - StartTLS :389 │
|
│ - StartTLS :389 │
|
||||||
└────────────────────────┘
|
└────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -57,3 +57,41 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
|
|||||||
## Navigating the UI
|
## Navigating the UI
|
||||||
|
|
||||||
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
||||||
|
|
||||||
|
## Slug conventions
|
||||||
|
|
||||||
|
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
||||||
|
|
||||||
|
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
|
||||||
|
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
|
||||||
|
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
|
||||||
|
|
||||||
|
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
|
||||||
|
|
||||||
|
## Automatic registration
|
||||||
|
|
||||||
|
You don't have to build the graph by hand — the theta42 tooling registers itself:
|
||||||
|
|
||||||
|
### The stack itself (theta-env)
|
||||||
|
|
||||||
|
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
|
||||||
|
|
||||||
|
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
||||||
|
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
||||||
|
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
||||||
|
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||||
|
|
||||||
|
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
||||||
|
|
||||||
|
### Linux hosts (ldap-client)
|
||||||
|
|
||||||
|
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||||
|
|
||||||
|
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
|
||||||
|
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
|
||||||
|
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
|
||||||
|
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
|
||||||
|
|||||||
+4
-2
@@ -52,11 +52,13 @@ backend, that's the niche.
|
|||||||
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
||||||
invite and password-reset flows over email; self-service profile + API
|
invite and password-reset flows over email; self-service profile + API
|
||||||
tokens.
|
tokens.
|
||||||
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea,
|
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||||
Emby, …) uses LDAPS/StartTLS against the same directory.
|
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||||
|
directory.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
run the pieces separately via `app_*` env config.
|
run the pieces separately via `app_*` env config.
|
||||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||||
|
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client.
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -14,8 +14,9 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
|
|||||||
|
|
||||||
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
||||||
authenticates against it over `localhost:389` (inside the all-in-one container)
|
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
|
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||||
directly — Gitea, Emby, the theta42/proxy, etc.
|
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||||
|
theta42/proxy, etc.
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
|||||||
// OAuth 2.0 / OpenID Connect
|
// OAuth 2.0 / OpenID Connect
|
||||||
app.use('/oauth', oauthRouter);
|
app.use('/oauth', oauthRouter);
|
||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
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);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-7
@@ -23,13 +23,21 @@ async function initORM() {
|
|||||||
};
|
};
|
||||||
ormConf.redis = conf.redis;
|
ormConf.redis = conf.redis;
|
||||||
|
|
||||||
await init({
|
console.log('[initORM] Starting ORM initialization...');
|
||||||
conf: { orm: ormConf },
|
try {
|
||||||
models: [
|
await init({
|
||||||
Resource, ResourceEdge, ResourceGroup,
|
conf: { orm: ormConf },
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
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;
|
module.exports.initORM = initORM;
|
||||||
|
|||||||
@@ -16,11 +16,18 @@ class OAuthClient {
|
|||||||
const raw_secret = crypto.randomUUID();
|
const raw_secret = crypto.randomUUID();
|
||||||
const client_id = crypto.randomUUID();
|
const client_id = crypto.randomUUID();
|
||||||
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
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({
|
const r = await Resource.create({
|
||||||
id: client_id,
|
id: client_id,
|
||||||
kind: 'oauth',
|
kind: 'oauth',
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
slug: slug,
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
owner: data.created_by,
|
owner: data.created_by,
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -37,10 +44,13 @@ class OAuthClient {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
static async get(client_id) {
|
static async get(client_id) {
|
||||||
const resources = await Resource.list({ where: { id: client_id, kind: 'oauth' } });
|
let r;
|
||||||
if (!resources.length) throw new Error('OAuthClient not found');
|
try {
|
||||||
|
r = await Resource.get(client_id);
|
||||||
const r = resources[0];
|
} 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
|
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
|
||||||
r.client_id = r.id;
|
r.client_id = r.id;
|
||||||
r.client_secret_hash = r.metadata.client_secret_hash;
|
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.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
|
||||||
r.allowed_groups = r.metadata.allowed_groups || [];
|
r.allowed_groups = r.metadata.allowed_groups || [];
|
||||||
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
|
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.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
|
||||||
|
|
||||||
r.rotateSecret = async () => {
|
r.rotateSecret = async () => {
|
||||||
@@ -64,11 +76,11 @@ class OAuthClient {
|
|||||||
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
|
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
|
||||||
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
|
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.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 };
|
const updateData = { metadata: r.metadata };
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
if (data.is_valid !== undefined) updateData.is_valid = data.is_valid;
|
|
||||||
|
|
||||||
return originalUpdate(updateData);
|
return originalUpdate(updateData);
|
||||||
};
|
};
|
||||||
@@ -81,6 +93,10 @@ class OAuthClient {
|
|||||||
return Promise.all(resources.map(r => this.get(r.id)));
|
return Promise.all(resources.map(r => this.get(r.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async listDetail() {
|
||||||
|
return this.list();
|
||||||
|
}
|
||||||
|
|
||||||
static async verifySecret(client_id, secret) {
|
static async verifySecret(client_id, secret) {
|
||||||
const client = await this.get(client_id);
|
const client = await this.get(client_id);
|
||||||
return client.verifySecret(secret);
|
return client.verifySecret(secret);
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class OtpToken extends Token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async issue(uid, method) {
|
static async issue(uid, method) {
|
||||||
const existing = await this.find({uid});
|
const existing = await this.list({where: {uid}});
|
||||||
for (const t of existing) {
|
for (const t of existing) {
|
||||||
if (t.is_valid) await t.update({is_valid: false});
|
if (t.is_valid) await t.update({is_valid: false});
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ class OtpToken extends Token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async verify(uid, code) {
|
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);
|
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
await match.update({is_valid: false});
|
await match.update({is_valid: false});
|
||||||
|
|||||||
@@ -891,6 +891,12 @@ User.invite = async function(data = {}){
|
|||||||
|
|
||||||
User.login = async function(data){
|
User.login = async function(data){
|
||||||
try{
|
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);
|
let user = await this.get(data.uid || data.username);
|
||||||
|
|
||||||
const loginClient = makeClient();
|
const loginClient = makeClient();
|
||||||
|
|||||||
Generated
+1547
-340
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.2.1",
|
"version": "1.3.1",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/orm": "file:../../../simpleworkjs/orm",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.2",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^0.4.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ router.post('/impersonate/:uid', middleware.auth, async function(req, res, next)
|
|||||||
const target = await User.get(req.params.uid);
|
const target = await User.get(req.params.uid);
|
||||||
|
|
||||||
// Clean up any existing impersonation for this target
|
// 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) {
|
for (const old of existing) {
|
||||||
if (old.is_valid && !old.isExpired) {
|
if (old.is_valid && !old.isExpired) {
|
||||||
try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
|
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']);
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
|
||||||
const target = await User.get(req.params.uid);
|
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;
|
let revoked = 0;
|
||||||
for (const token of existing) {
|
for (const token of existing) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const DOCS = {
|
|||||||
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
|
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
|
||||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||||
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
|
|
||||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
|
|||||||
@@ -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
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/:name', async function(req, res, next){
|
router.get('/:name', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
// ORM models: list() on the redis adapter always returns full rows;
|
||||||
|
// detail is handled by serialization (isPrivate fields are excluded).
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]()
|
results: await tokens[req.params.name].list()
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/:name/:token', async function(req, res, next){
|
router.get('/:name/:token', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
return res.json({
|
const result = await tokens[req.params.name].get(req.params.token);
|
||||||
results: 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){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
const { createClient } = require('redis');
|
const { createClient } = require('redis');
|
||||||
|
|
||||||
module.exports = async function() {
|
module.exports = async function() {
|
||||||
const client = createClient();
|
const redisUrl = process.env.REDIS_URL || undefined;
|
||||||
|
const client = createClient(redisUrl ? { url: redisUrl } : {});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const keys = await client.keys('sso_manager_test_*');
|
const keys = await client.keys('sso_manager_test_*');
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const app = require('../app');
|
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' };
|
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="p-3 pb-0 text-muted small border-bottom">
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
|
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
|
||||||
|
<a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="card-body table table-striped mb-0">
|
<table class="card-body table table-striped mb-0">
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ async function fetchUsernameSuggestions() {
|
|||||||
// simply can't bind). Disabling (not just hiding) keeps disabled
|
// simply can't bind). Disabling (not just hiding) keeps disabled
|
||||||
// fields out of both form serialization and validation.
|
// fields out of both form serialization and validation.
|
||||||
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
$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=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||||
$form.find('[name=passwordMatch]').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..." />
|
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
|
||||||
</div>
|
</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">
|
<div class="mb-3">
|
||||||
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
|
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
const { hashPasswordSSHA512 } = require('./nodejs/models/user_ldap.js');
|
||||||
|
console.log(hashPasswordSSHA512('MyTestPassword!2'));
|
||||||
Reference in New Issue
Block a user