Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d0496cfda | |||
| 48df638ddd | |||
| d6d2c7144a | |||
| 67374dc914 | |||
| a3b41c6775 | |||
| b25fb56a0d |
@@ -10,6 +10,11 @@ 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
|
## [1.3.6] - 2026-07-23
|
||||||
|
|
||||||
### Bumped
|
### Bumped
|
||||||
|
|||||||
Vendored
+58
-13
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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`).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
Submodule jump-host updated: 6e6f42e891...23d99980ce
Reference in New Issue
Block a user