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:
@@ -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',},
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user