diff --git a/CHANGELOG.md b/CHANGELOG.md index da6dc99..074a5a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v1.34.0 +- feat: the per-host SSO **Allowed groups** field now autocompletes from the SSO directory's groups. Suggestions previously came only from local groups, permission subjects and `conf.auth` maps — none of which can match an SSO-gated host, because its allow-list is checked against the `groups` claim the SSO issues. New `conf.sso` block (`url` + read-only `apiToken`, minted by theta-suite's bootstrap); results are cached for 5 minutes and the endpoint degrades silently to the old local-only list when unset. +- fix: the SSO group lookup authenticates with `Authorization: Bearer `, not the `auth-token` header — the latter is for browser session UUIDs and would be rejected for a minted API token. +- docs: `docs/concepts-hosts.md` gains a "Putting a host behind single sign-on" section covering the per-host `/__proxy_auth` flow, the wildcard redirect URI the IdP must allow, and where group suggestions come from. +- docs: `secrets.js.example` documents the new `sso` block. + ## v1.33.0 - feat: Add SSO-style error page (404/500) for browser navigation instead of a bare JSON/text response - feat: DNS page is now admin-only (hidden from non-admins; API already admin-gated) diff --git a/docs/concepts-hosts.md b/docs/concepts-hosts.md index 504d2e1..c6f18b8 100644 --- a/docs/concepts-hosts.md +++ b/docs/concepts-hosts.md @@ -68,6 +68,38 @@ host form whenever the name you're entering already has a matching wildcard available to reuse — including the wildcard's own bare base domain (e.g. `example.com` itself, not just `something.example.com`). +## Putting a host behind single sign-on + +Each host can be gated on its own, independently of the proxy's management UI. +On the host's **Auth** tab pick **Single sign-on (SSO)** and, optionally, fill in +the **Allowed users** / **Allowed groups** lists. Empty lists mean any +authenticated user is allowed; otherwise the identity must match one of them. + +The proxy runs the OIDC flow itself at `/__proxy_auth` on the protected host and +keeps a Redis-backed session in a `__proxy_sso` cookie, so the app behind it +needs no changes. + +**The IdP must allow the per-host callback.** Each protected host calls back to +`https:///__proxy_auth/callback`, which is a different URL for every +host, all against the proxy's one OAuth client. Register a wildcard redirect URI +on that client — the SSO Manager supports `*` (one label) and `**` (any number): + +``` +https://**.example.com/__proxy_auth/callback +https://example.com/__proxy_auth/callback +``` + +theta-suite's bootstrap registers both automatically, and backfills them onto an +existing client. Without them, switching a host to SSO fails at the IdP with +`400 redirect_uri is not registered for this client`. + +**Group suggestions come from the SSO.** The Allowed groups field autocompletes +from the SSO directory's groups when `sso.url` and `sso.apiToken` are set in the +proxy's config (theta-suite's bootstrap mints that read-only token). Without it +the field can only suggest the proxy's local groups, which for an SSO-gated host +are rarely the ones you want — the allow-list is matched against the `groups` +claim in the SSO's token, so only SSO groups can ever match. + ## Load Balancing If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration. diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index bc0b0a9..78ff947 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -45,6 +45,18 @@ module.exports = { usernameClaim: 'preferred_username', }, + // Read-only SSO management API access, used to populate the per-host SSO + // allow-list autocomplete with the groups that actually exist in the + // directory. Without it the "Allowed groups" field can only suggest groups + // the proxy already knows locally, which for an SSO-gated host is usually + // none of the ones the operator wants. `apiToken` is a machine token minted + // by the theta-suite bootstrap and lives in secrets.js; leaving it unset + // simply falls back to the local-only suggestions. + sso: { + url: '', // e.g. https://sso.example.com + apiToken: '', + }, + // Authorization: how groups map to roles, and which groups are global admin. // Per-user overrides are Grant records managed in the app. auth: { diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 6371c09..4204e64 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxy-api", - "version": "1.33.0", + "version": "1.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxy-api", - "version": "1.33.0", + "version": "1.34.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 1c843d1..b01c07b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "proxy-api", - "version": "1.33.0", + "version": "1.34.0", "author": [ { "name": "William Mantly", diff --git a/nodejs/routes/host.js b/nodejs/routes/host.js index 9132890..d35d1ca 100755 --- a/nodejs/routes/host.js +++ b/nodejs/routes/host.js @@ -58,16 +58,55 @@ function hashHostSecrets(body){ } } +// The SSO's directory groups, for the per-host SSO allow-list autocomplete. +// The host's allow-list is checked against the `groups` claim the SSO puts in +// the token (see utils/host_sso.js), so the only suggestions that can ever +// match are the SSO's own groups -- the local groups below are a fallback, not +// the real answer. Requires conf.sso.apiToken; degrades to [] without it, and +// never fails the request (the form still works, just without suggestions). +// +// Cached for a few minutes: this is typeahead fodder, and every host editor +// opening the form would otherwise hit the SSO. +let ssoGroupCache = {at: 0, groups: []}; +const SSO_GROUP_TTL = 5 * 60 * 1000; + +async function ssoGroups(){ + let sso = conf.sso || {}; + if(!sso.url || !sso.apiToken) return []; + if(Date.now() - ssoGroupCache.at < SSO_GROUP_TTL) return ssoGroupCache.groups; + try{ + let res = await fetch(`${sso.url.replace(/\/$/, '')}/api/group`, { + // A minted API token (`sso__`) authenticates as a bearer + // token; the SSO's `auth-token` header is for browser session UUIDs + // only and would be rejected here. + headers: {Authorization: `Bearer ${sso.apiToken}`, Accept: 'application/json'}, + signal: AbortSignal.timeout(5000), + }); + if(!res.ok) throw new Error(`SSO group list failed (${res.status})`); + let body = await res.json(); + // The SSO returns { results: [...] } -- either CN strings or objects. + let groups = (body && body.results || []).map(g => (typeof g === 'string' ? g : g && g.name)).filter(Boolean); + ssoGroupCache = {at: Date.now(), groups}; + return groups; + }catch(error){ + console.error(`[auth-suggestions] could not list SSO groups: ${error.message}`); + // Cache the failure briefly so a down SSO doesn't stall every form open. + ssoGroupCache = {at: Date.now(), groups: ssoGroupCache.groups}; + return ssoGroupCache.groups; + } +} + // Autocomplete source for the per-host auth allow-lists (SSO users/groups). // Available to any authenticated host editor (not just global admins). Groups -// are derived from local groups, existing permission group-subjects, and the -// conf.auth admin/role-map groups. +// are the SSO directory's groups plus local groups, existing permission +// group-subjects, and the conf.auth admin/role-map groups. router.get('/auth-suggestions', async function(req, res, next){ try{ let users = []; try{ users = (await User.list()) || []; }catch(error){ /* none */ } let groups = new Set(); + for(let g of await ssoGroups()) groups.add(g); try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ } try{ for(let p of await Permission.listDetail()){ diff --git a/secrets.js.example b/secrets.js.example index 2e49471..7a77a67 100644 --- a/secrets.js.example +++ b/secrets.js.example @@ -42,6 +42,18 @@ module.exports = { usernameClaim: 'preferred_username', }, + // Read-only access to the SSO's management API, used to populate the + // per-host SSO allow-list autocomplete with the directory's actual groups. + // A host gated on SSO matches its allow-list against the `groups` claim the + // SSO issues, so only SSO groups can ever match -- without this the field + // can only suggest the proxy's own local groups. `apiToken` is a machine + // token minted by theta-suite's bootstrap; leaving it blank simply falls + // back to local-only suggestions. + sso: { + url: 'http://sso-manager:3001', + apiToken: '', + }, + // Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a // self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path // for strict verification. bindPassword MUST match the