Compare commits

...

8 Commits

Author SHA1 Message Date
wmantly 2d0496cfda Merge pull request #90 from theta42/release/v1.3.7
Release 1.3.7
2026-07-23 21:02:46 -04:00
wmantly 48df638ddd Release 1.3.7
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:02:22 -04:00
wmantly d6d2c7144a Merge pull request #89 from theta42/feature/jump-oidc
Provision jump-host web-UI SSO login (OIDC client)
2026-07-23 21:02:06 -04:00
wmantly 67374dc914 feat: provision jump-host web-UI SSO login (OIDC client); bump to v1.1.0
The jump host's web UI now authenticates via OIDC + a local admin
(jump-host v1.1.0). Wire that in the bundle:

- bootstrap mints a dedicated 'theta-jump' OAuth client (redirect
  https://<JUMP_HOST>/api/auth/oidc/callback) and writes a full oidc
  block + generated local admin password into config/jump-secrets.js,
  mirroring the proxy's OIDC provisioning
- an existing pre-OIDC jump-secrets.js (API token but no OIDC client) is
  regenerated so upgraders get SSO login
- bump jump-host submodule v1.0.x -> v1.1.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:01:36 -04:00
wmantly a3b41c6775 Merge pull request #88 from theta42/docs/jump-host
docs: add the optional SSH jump host to the Pages site
2026-07-23 16:23:35 -04:00
wmantly b25fb56a0d docs: add the optional SSH jump host to the theta-env Pages site
- index: mention the jump host as an optional third component (intro,
  What-you-get, Related projects)
- quickstart: CFG_JUMP_HOST_ENABLED / CFG_JUMP_HOST / JUMP_SSH_PORT
- reframe "legacy LDAP clients" -> "direct LDAP clients" (Linux hosts are
  first-class consumers)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:17:14 -04:00
wmantly a15002b588 Merge pull request #87 from theta42/release/v1.3.6
Release 1.3.6: bump sso-manager-node to v1.3.2 (bootstrap fix)
2026-07-23 16:10:47 -04:00
wmantly fe8133b21c Release 1.3.6: bump sso-manager-node to v1.3.2
Fixes the OAuth-client-API client_id serialization bug that broke this
stack's bootstrap (rotate -> 500 -> 'bootstrap failed'). See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:10:20 -04:00
7 changed files with 89 additions and 18 deletions
+15
View File
@@ -10,6 +10,21 @@ for what changed inside the apps it composes.
## [Unreleased] ## [Unreleased]
## [1.3.7] - 2026-07-23
### Added
- The bootstrap now provisions the jump host's **web-UI SSO login** when the jump host is enabled: it mints a dedicated `theta-jump` OAuth client and writes a full `oidc` block (endpoints, client id/secret, callback) plus a generated local anti-lockout admin password into `./config/jump-secrets.js`. Matches how the proxy's OIDC client is provisioned. An existing pre-OIDC `jump-secrets.js` (API token but no OIDC client) is regenerated so upgraders get SSO login. Requires jump-host ≥ v1.1.0.
## [1.3.6] - 2026-07-23
### Bumped
- sso-manager-node -> [v1.3.2](https://github.com/theta42/sso-manager-node/releases/tag/v1.3.2)
sso-manager-node 1.3.2:
### Fixed
- **OAuth client management API returned `client_id: undefined` on every GET**, which broke this stack's bootstrap: it lists the OAuth clients and rotates by the returned `client_id`, so it called `/api/oauth/client/undefined/rotate` and got a 500 — aborting `setup.sh` with `bootstrap failed` whenever `proxy-secrets.js` had no usable secret (e.g. a fresh/rotated deployment). The ORM's `toJSON()` was stripping the mapped `client_id`/`scopes`/… fields; `OAuthClient.get()` now emits them explicitly (and omits `client_secret_hash`). Unknown client ids now 404 instead of 500.
## [1.3.5] - 2026-07-23 ## [1.3.5] - 2026-07-23
### Added ### Added
+58 -13
View File
@@ -229,14 +229,15 @@ async function listClients(token) {
return (data && data.results) || []; return (data && data.results) || [];
} }
async function createClient(token) { async function createClient(token, opts) {
const o = opts || { name: CLIENT_NAME, description: 'theta-env proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, { const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
method: 'POST', method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' }, headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: CLIENT_NAME, name: o.name,
description: 'theta-env proxy (auto-registered)', description: o.description,
redirect_uris: [REDIRECT_URI], redirect_uris: o.redirect_uris,
scopes: ['openid', 'profile', 'email', 'groups'], scopes: ['openid', 'profile', 'email', 'groups'],
allowed_groups: [], allowed_groups: [],
}), }),
@@ -249,7 +250,7 @@ async function createClient(token) {
const id = (data.results && data.results.client_id) || data.client_id; const id = (data.results && data.results.client_id) || data.client_id;
const secret = data.client_secret; const secret = data.client_secret;
if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`); if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`);
log(`Created OAuth client ${CLIENT_NAME} (${id})`); log(`Created OAuth client ${o.name} (${id})`);
return { id, secret }; return { id, secret };
} }
@@ -487,6 +488,8 @@ const JUMP_ENABLED = /^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED ||
const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : ''); const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
const JUMP_SECRETS = '/config/jump-secrets.js'; const JUMP_SECRETS = '/config/jump-secrets.js';
const JUMP_TOKEN_NAME = 'theta-jump-host'; const JUMP_TOKEN_NAME = 'theta-jump-host';
const JUMP_CLIENT_NAME = 'theta-jump';
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
async function mintApiToken(token, name) { async function mintApiToken(token, name) {
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, { const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
@@ -501,14 +504,19 @@ async function mintApiToken(token, name) {
return raw; return raw;
} }
function jumpFileHasToken() { // The generated file is "complete" only if it has BOTH a real directory API
// token AND an OIDC client id — an existing file from the pre-OIDC layout (a
// token but no oidc block) is regenerated so the web UI's SSO login works.
function jumpFileComplete() {
try { try {
const src = fs.readFileSync(JUMP_SECRETS, 'utf8'); const src = fs.readFileSync(JUMP_SECRETS, 'utf8');
return /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src); const hasToken = /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src);
const hasOidc = /clientId:\s*['"][0-9a-f-]{8,}['"]/.test(src);
return hasToken && hasOidc;
} catch (_) { return false; } } catch (_) { return false; }
} }
function writeJumpSecrets(apiToken) { function writeJumpSecrets(apiToken, oidc, localAdminPass) {
const siteName = (sso.stack && sso.stack.siteName) || 'local'; const siteName = (sso.stack && sso.stack.siteName) || 'local';
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST; const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
const body = `'use strict'; const body = `'use strict';
@@ -537,7 +545,27 @@ module.exports = {
\t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)}, \t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)},
\t}, \t},
\tweb: { port: 3002 }, \tweb: { port: 3002 },
\tauth: { adminGroups: ['app_sso_admin'] }, \t// Web UI SSO login — the jump host's own OAuth client. tokenEndpoint /
\t// userinfoEndpoint use the internal docker-net address (server-to-server);
\t// authorizationEndpoint is the public SSO host (browser-facing).
\toidc: {
\t\tenabled: true,
\t\tissuer: ${JSON.stringify(`https://${SSO_HOST}`)},
\t\tauthorizationEndpoint: ${JSON.stringify(`https://${SSO_HOST}/oauth/authorize`)},
\t\ttokenEndpoint: 'http://sso-manager:3001/oauth/token',
\t\tuserinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
\t\tclientId: ${JSON.stringify(oidc.id)},
\t\tclientSecret: ${JSON.stringify(oidc.secret)},
\t\tredirectUri: ${JSON.stringify(JUMP_REDIRECT_URI)},
\t\tscopes: ['openid', 'profile', 'email', 'groups'],
\t\tgroupsClaim: 'groups',
\t\tusernameClaim: 'preferred_username',
\t},
\tauth: {
\t\tadminGroups: ['app_sso_admin'],
\t\tadminUsers: ['jumpadmin'],
\t\tlocalAdminPass: ${JSON.stringify(localAdminPass)},
\t},
\tredis: { prefix: 'jump_host_', redisConf: { url: 'redis://127.0.0.1:6379' } }, \tredis: { prefix: 'jump_host_', redisConf: { url: 'redis://127.0.0.1:6379' } },
\tstack: { ssoHost: ${JSON.stringify(SSO_HOST)}, jumpHost: ${JSON.stringify(JUMP_HOST)}, ldapsHost: ${JSON.stringify(ldapsHost)} }, \tstack: { ssoHost: ${JSON.stringify(SSO_HOST)}, jumpHost: ${JSON.stringify(JUMP_HOST)}, ldapsHost: ${JSON.stringify(ldapsHost)} },
}; };
@@ -546,13 +574,30 @@ module.exports = {
} }
async function provisionJumpHost(token) { async function provisionJumpHost(token) {
if (jumpFileHasToken()) { if (jumpFileComplete()) {
log('Jump host: /config/jump-secrets.js already has an API token — keeping.'); log('Jump host: /config/jump-secrets.js already has API token + OIDC client — keeping.');
return; return;
} }
const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME); const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME);
writeJumpSecrets(apiToken);
log('Jump host: wrote /config/jump-secrets.js (minted directory API token).'); // Mint (or reuse) the jump host's own OAuth client for web-UI SSO login.
const clients = await listClients(token);
let oidc = clients.find((c) => c.name === JUMP_CLIENT_NAME);
if (oidc && oidc.client_id) {
oidc = await rotateClient(token, oidc.client_id);
oidc = { id: oidc.id, secret: oidc.secret };
} else {
oidc = await createClient(token, {
name: JUMP_CLIENT_NAME,
description: 'theta-env jump host web UI (auto-registered)',
redirect_uris: [JUMP_REDIRECT_URI],
});
}
const localAdminPass = crypto.randomBytes(16).toString('hex');
writeJumpSecrets(apiToken, oidc, localAdminPass);
log(`Jump host: wrote /config/jump-secrets.js (API token + OAuth client ${oidc.id}).`);
log(`Jump host: local admin 'jumpadmin' password: ${localAdminPass}`);
} }
(async function main() { (async function main() {
+1 -1
View File
@@ -31,7 +31,7 @@ fetches all three in one step; `git submodule update --remote` bumps them.
``` ```
┌──────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ your browser / apps / legacy LDAP clients │ │ your browser / apps / direct LDAP clients │
└───────────────┬──────────────────────────────┘ └───────────────┬──────────────────────────────┘
│ https (:443) ldaps (:636) │ https (:443) ldaps (:636)
┌─────────▼─────────┐ ┌─────────▼─────────┐
+10 -2
View File
@@ -15,7 +15,9 @@ LDAP directory) and [Proxy](https://theta42.github.io/proxy/) (an
OIDC-protected reverse proxy that can also look users up directly in LDAP) — OIDC-protected reverse proxy that can also look users up directly in LDAP) —
and automates the fiddly part: registering the proxy as an OIDC client of the and automates the fiddly part: registering the proxy as an OIDC client of the
SSO and pointing it at the right LDAP directory, with hostnames and secrets SSO and pointing it at the right LDAP directory, with hostnames and secrets
generated from one `setup.env`. generated from one `setup.env`. An optional third component, the
[Jump Host](https://theta42.github.io/jump-host/), adds directory-driven SSH
access to your machines through one public entry point.
## Screenshots ## Screenshots
@@ -41,7 +43,11 @@ snapshots state before every rebuild.
- **SSO Manager**, fronted by the proxy under TLS — manage users, groups, - **SSO Manager**, fronted by the proxy under TLS — manage users, groups,
and OAuth clients. and OAuth clients.
- **Proxy** — add the hosts you want to protect with OIDC login. - **Proxy** — add the hosts you want to protect with OIDC login.
- **LDAPS** for legacy apps that bind directly. - **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and
LDAP-native apps authenticate against the same directory.
- **SSH Jump Host** *(optional)*`ssh uid_-_host@jump.<domain>` (WinSCP-friendly)
or an interactive picker; access is driven by directory group membership, with
a web UI for audit + metrics. Enable with `CFG_JUMP_HOST_ENABLED=true`.
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a - **Self-service API tokens** in both apps' UIs, for scripting/CI without a
browser session. browser session.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations. - **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
@@ -67,3 +73,5 @@ architecture, and running each project standalone, see the
provider + LDAP directory this stack runs. provider + LDAP directory this stack runs.
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this - **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this
stack runs in front of it. stack runs in front of it.
- **[Jump Host](https://theta42.github.io/jump-host/)** — the optional SSH jump
host this stack can bring up (`CFG_JUMP_HOST_ENABLED=true`).
+3
View File
@@ -60,6 +60,9 @@ setups `CFG_DOMAIN` is the only value you set:
| `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` | | `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` |
| `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | optional | | `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | optional |
| `CFG_BASE_DN` | `dc=lab,dc=local` | advanced: override the derived LDAP base DN | | `CFG_BASE_DN` | `dc=lab,dc=local` | advanced: override the derived LDAP base DN |
| `CFG_JUMP_HOST_ENABLED` | `true` | optional: bring up the [SSH jump host](https://theta42.github.io/jump-host/) (default off) |
| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.<domain>` |
| `JUMP_SSH_PORT` | `2222` | optional: host port for the jump host's SSH (never 22 by default) |
`setup.env` is used **only on the first run** to generate `./config/`; after `setup.env` is used **only on the first run** to generate `./config/`; after
that `./config/*.js` are operator-owned and `setup.env` is ignored. Secrets that `./config/*.js` are operator-owned and `setup.env` is ignored. Secrets