From 653c79f099ccc1ee1eba30d0e787bc5c8a090276 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 12:01:54 -0400 Subject: [PATCH] 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 --- nodejs/models/host.js | 6 ++++++ nodejs/test/unit/basicauth.test.js | 23 +++++++++++++++++++++++ nodejs/utils/host_features.js | 28 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/nodejs/models/host.js b/nodejs/models/host.js index ae80c54..e6d7fb7 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -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',}, diff --git a/nodejs/test/unit/basicauth.test.js b/nodejs/test/unit/basicauth.test.js index c700d86..47bc600 100644 --- a/nodejs/test/unit/basicauth.test.js +++ b/nodejs/test/unit/basicauth.test.js @@ -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']); + }); +}); diff --git a/nodejs/utils/host_features.js b/nodejs/utils/host_features.js index 7bf391a..4e5cc83 100644 --- a/nodejs/utils/host_features.js +++ b/nodejs/utils/host_features.js @@ -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, };