feat: SSO group autocomplete for per-host SSO allow-lists (v1.34.0)
Pull Request Tests / Run Tests (18.x) (push) Successful in 33s
Pull Request Tests / Run Tests (20.x) (push) Successful in 28s
Pull Request Tests / Run Tests (22.x) (push) Successful in 31s
Pull Request Tests / Test Summary (push) Successful in 3s

The per-host "Allowed groups" field suggested only local groups,
permission subjects and conf.auth maps. None of those can ever match an
SSO-gated host: its allow-list is checked against the `groups` claim the
SSO issues (utils/host_sso.js), so only SSO groups are candidates.

Adds a conf.sso block (url + read-only apiToken, minted by theta-suite's
bootstrap) and a cached /api/group lookup merged into the suggestions.
Degrades silently to the previous local-only list when unset, and never
fails the request.

Authenticates with `Authorization: Bearer <token>` -- the SSO's
`auth-token` header is for browser session UUIDs and rejects a minted
API token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 18:42:50 -04:00
parent bbaa006925
commit 1e38ef8dc5
7 changed files with 106 additions and 5 deletions
+12
View File
@@ -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: {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxy-api",
"version": "1.33.0",
"version": "1.34.0",
"author": [
{
"name": "William Mantly",
+41 -2
View File
@@ -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_<id>_<secret>`) 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()){