Per-host SSO: data model + normalization (#57)

Add Host fields sso_enabled / sso_allow_users / sso_allow_groups (empty
allow-lists = any authenticated user) and normalize them (parseAllowList). SSO
reuses conf.oidc and is OR'd with basic auth. Enforcement (session cookie + Lua
+ nginx auth location) lands separately. Unit tests included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:01:54 -04:00
parent 42a00dd3bf
commit 653c79f099
3 changed files with 57 additions and 0 deletions
+6
View File
@@ -47,6 +47,12 @@ class Host extends Table{
'basicauth_enabled': {default: false, isRequired: false, type: 'boolean',},
'basicauth_realm': {default: 'Restricted', isRequired: false, type: 'string', min: 1, max: 128},
'basicauth_users': {default: function(){return {}}, isRequired: false, type: 'object',},
// Per-host SSO (OIDC via conf.oidc) — enforced by a signed session cookie
// checked in ops/nginx_conf/hostfeatures.lua. Empty allow-lists mean "any
// authenticated user". basic auth and SSO are OR'd (either satisfies).
'sso_enabled': {default: false, isRequired: false, type: 'boolean',},
'sso_allow_users': {default: function(){return []}, isRequired: false, type: 'object',},
'sso_allow_groups': {default: function(){return []}, isRequired: false, type: 'object',},
'req_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
'resp_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
'ip_allow': {default: function(){return []}, isRequired: false, type: 'object',},
+23
View File
@@ -8,6 +8,7 @@ const {
parseBasicAuthLines,
sanitizeBasicAuthObject,
sanitizeRealm,
parseAllowList,
normalizeHostFeatures,
} = require('../../utils/host_features');
@@ -75,3 +76,25 @@ describe('normalizeHostFeatures (basic auth)', () => {
assert.deepStrictEqual(body.basicauth_users, {alice: 'secret'});
});
});
describe('SSO allow-lists (#57)', () => {
test('parseAllowList splits on commas/whitespace/newlines and dedupes', () => {
assert.deepStrictEqual(
parseAllowList('alice@x.com, bob@x.com\ncarol@x.com alice@x.com'),
['alice@x.com', 'bob@x.com', 'carol@x.com']
);
assert.deepStrictEqual(parseAllowList(['a', 'a', ' b ', '']), ['a', 'b']);
assert.deepStrictEqual(parseAllowList(''), []);
});
test('normalizeHostFeatures coerces sso_enabled and parses allow-lists', () => {
let body = {
sso_enabled: 'true',
sso_allow_users: 'alice@x.com\nbob@x.com',
sso_allow_groups: 'dns-team, admins',
};
normalizeHostFeatures(body);
assert.strictEqual(body.sso_enabled, true);
assert.deepStrictEqual(body.sso_allow_users, ['alice@x.com', 'bob@x.com']);
assert.deepStrictEqual(body.sso_allow_groups, ['dns-team', 'admins']);
});
});
+28
View File
@@ -174,6 +174,29 @@ function sanitizeBasicAuthObject(obj){
return out;
}
const MAX_ALLOW_ENTRIES = 500;
/**
* Newline/comma/whitespace-separated text -> deduped array of trimmed entries
* (usernames, emails, or group names for the SSO allow-lists). CR/LF stripped.
*/
function parseAllowList(input){
let items = Array.isArray(input)
? input
: String(input === undefined || input === null ? '' : input).split(/[\s,]+/);
let seen = new Set();
let out = [];
for(let raw of items){
let s = String(raw).replace(/[\r\n]/g, '').trim();
if(!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
if(out.length >= MAX_ALLOW_ENTRIES) break;
}
return out;
}
/** Realm goes into a WWW-Authenticate header; strip CR/LF and quotes, cap len. */
function sanitizeRealm(value){
return String(value === undefined || value === null ? '' : value)
@@ -226,6 +249,10 @@ function normalizeHostFeatures(body){
}
}
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled);
if('sso_allow_users' in body) body.sso_allow_users = parseAllowList(body.sso_allow_users);
if('sso_allow_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups);
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
@@ -259,5 +286,6 @@ module.exports = {
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
parseBasicAuthLines, sanitizeBasicAuthObject, sanitizeRealm,
parseAllowList,
normalizeHostFeatures,
};