diff --git a/nodejs/app.js b/nodejs/app.js index b8b644f..1330fdd 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -60,10 +60,15 @@ app.use(express.json()); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); +// Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to +// the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the +// per-host session cookie. Mounted before the page router. +app.use('/__proxy_auth', require('./routes/host_auth')); + // Routes for front end content. app.use('/', require('./routes/render')); -// Routes for API +// Routes for API app.use('/api', require('./routes/api')); // Catch 404 and forward to error handler. If none of the above routes are diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index bb74a45..027cbe5 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -77,4 +77,13 @@ module.exports = { 'https://ifconfig.me/ip', ], }, + + // Per-host SSO (#57). Reuses conf.oidc for the identity provider. Sessions + // are Redis-backed and read directly by OpenResty; the cookie only carries a + // random session id. + hostSso:{ + enabled: true, + sessionTtl: 28800, // 8 hours, in seconds + cookieName: '__proxy_sso', + }, }; diff --git a/nodejs/models/index.js b/nodejs/models/index.js index e71cc09..6477866 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -14,3 +14,4 @@ require('./user'); require('./local_group'); require('./permission'); require('./oidc_state'); +require('./sso_session'); diff --git a/nodejs/models/sso_session.js b/nodejs/models/sso_session.js new file mode 100644 index 0000000..9bb5a47 --- /dev/null +++ b/nodejs/models/sso_session.js @@ -0,0 +1,52 @@ +'use strict'; + +const Table = require('.'); +const conf = require('@simpleworkjs/conf'); + +/** + * Per-host SSO models (#57). + * + * HostSsoState — short-lived, in-flight OIDC authorization request for a + * protected host (like OidcState, but carries the target host + post-login + * redirect). Auto-expires via TTL. + * + * SsoSession — an established session after a successful, authorized login. + * Keyed by a random session id stored in the browser's `__proxy_sso` cookie. + * OpenResty (ops/nginx_conf/hostfeatures.lua) reads `proxy_SsoSession_` + * straight from Redis to gate requests; the allow-list was already enforced at + * callback time (utils/host_sso.js), so the Lua side only checks that a session + * exists and belongs to this host. Auto-expires via TTL. + */ + +const SESSION_TTL = (conf.hostSso && conf.hostSso.sessionTtl) || 28800; // 8h + +class HostSsoState extends Table{ + static _key = 'state'; + static _ttl = 300; // 5 minutes bounds the auth round-trip / replay + static _keyMap = { + 'created_on': {default: function(){return (new Date).getTime()}}, + 'state': {isRequired: true, type: 'string', min: 8, max: 500}, + 'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500}, + 'host': {isRequired: true, type: 'string', min: 1, max: 500}, + 'rd': {default: '/', isRequired: false, type: 'string'}, + } +} +HostSsoState.register(); + +class SsoSession extends Table{ + static _key = 'sid'; + static _ttl = SESSION_TTL; + static _keyMap = { + 'created_on': {default: function(){return (new Date).getTime()}}, + 'sid': {isRequired: true, type: 'string', min: 16, max: 500}, + 'host': {isRequired: true, type: 'string', min: 1, max: 500}, + 'sub': {isRequired: true, type: 'string', min: 1, max: 500}, + 'email': {default: '', isRequired: false, type: 'string'}, + 'groups': {default: function(){return []}, isRequired: false, type: 'object'}, + } + + static ttl(){ return SESSION_TTL; } +} +SsoSession.register(); + +module.exports = {HostSsoState, SsoSession}; diff --git a/nodejs/package.json b/nodejs/package.json index 48539be..36401bf 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,10 +11,10 @@ "scripts": { "start": "node ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www", - "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", - "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/unix_socket.test.js", + "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", + "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js", "test:integration": "node --test test/integration/dns_provider.test.js", - "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" + "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" }, "engines": { "node": ">=18.0.0" diff --git a/nodejs/routes/host_auth.js b/nodejs/routes/host_auth.js new file mode 100644 index 0000000..751f591 --- /dev/null +++ b/nodejs/routes/host_auth.js @@ -0,0 +1,145 @@ +'use strict'; + +/** + * Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host + * (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the + * OIDC authorization-code flow (reusing utils/oidc.js and conf.oidc) and, on a + * successful + authorized login, mint a Redis-backed SsoSession and set the + * `__proxy_sso` cookie for the host. OpenResty then gates the host on that + * session (ops/nginx_conf/hostfeatures.lua). + * + * Callback style: per-host (option A) — redirect_uri is + * https:///__proxy_auth/callback, so each protected host's callback must + * be an allowed redirect URI at the IdP (a wildcard redirect URI covers all). + */ + +const router = require('express').Router(); +const conf = require('@simpleworkjs/conf'); +const oidc = require('../utils/oidc'); +const {Host} = require('../models').models; +const {HostSsoState, SsoSession} = require('../models/sso_session'); +const {identityAllowed} = require('../utils/host_sso'); + +const COOKIE = (conf.hostSso && conf.hostSso.cookieName) || '__proxy_sso'; + +// Minimal HTML notice page (these endpoints are hit by browsers, not the API). +function page(message){ + return `Sign in` + + `` + + `` + + `

${String(message).replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c]))}

`; +} + +// This host's own callback URL — must match between authorize and token steps. +function callbackUri(req){ + return `${req.protocol}://${req.get('host')}/__proxy_auth/callback`; +} + +// Constrain the post-login redirect to this same host (no open redirect). `rd` +// may be a bare path or a full same-host URL. +function safeRd(req, rd){ + try{ + if(!rd) return '/'; + if(rd.charAt(0) === '/' && rd.charAt(1) !== '/') return rd; + let u = new URL(rd); + if(u.host === req.get('host')) return u.pathname + u.search; + }catch(error){ /* fall through */ } + return '/'; +} + +function readCookie(req, name){ + for(let part of (req.headers.cookie || '').split(';')){ + let idx = part.indexOf('='); + if(idx === -1) continue; + if(part.slice(0, idx).trim() === name) return decodeURIComponent(part.slice(idx + 1).trim()); + } + return null; +} + +// Resolve the effective Host record (exact, else via the wildcard lookup tree) +// so we can read its SSO allow-lists. +async function resolveHost(hostname){ + try{ return await Host.get(hostname); }catch(error){ /* try wildcard */ } + try{ return Host.lookUp(hostname) || null; }catch(error){ return null; } +} + +// Begin login: create PKCE/state, remember the target host + return path, and +// redirect the browser to the IdP. +router.get('/start', async function(req, res, next){ + try{ + if(!conf.oidc || !conf.oidc.enabled){ + return res.status(503).send(page('SSO is not configured on this proxy.')); + } + let hostname = req.hostname; + let hostRec = await resolveHost(hostname); + if(!hostRec || !hostRec.sso_enabled){ + return res.status(404).send(page('SSO is not enabled for this host.')); + } + + let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest(); + await HostSsoState.create({state, codeVerifier, host: hostname, rd: safeRd(req, req.query.rd)}); + + return res.redirect(oidc.buildAuthUrl(state, codeChallenge, callbackUri(req))); + }catch(error){ + return next(error); + } +}); + +// OIDC redirect target: validate state, exchange the code, enforce the host's +// allow-list, then establish the session and return the user to where they were. +router.get('/callback', async function(req, res, next){ + try{ + let {code, state} = req.query; + if(!code || !state) return res.status(400).send(page('Missing authorization code.')); + + let st = await HostSsoState.get(state).catch(() => null); + if(!st) return res.status(400).send(page('Your login session expired. Please try again.')); + await st.remove().catch(() => {}); // one-time use + + let hostname = req.hostname; + if(st.host !== hostname) return res.status(400).send(page('Login host mismatch.')); + + let tokens = await oidc.exchangeCode(code, st.codeVerifier, callbackUri(req)); + let claims = await oidc.fetchUserInfo(tokens.access_token); + let identity = oidc.claimsToIdentity(claims); + let email = claims.email || ''; + + let hostRec = await resolveHost(hostname); + let allowUsers = (hostRec && hostRec.sso_allow_users) || []; + let allowGroups = (hostRec && hostRec.sso_allow_groups) || []; + + if(!identityAllowed({username: identity.username, email, groups: identity.groups}, allowUsers, allowGroups)){ + return res.status(403).send(page(`You are not authorized to access ${hostname}.`)); + } + + let sid = oidc.randomToken(32); + await SsoSession.create({sid, host: hostname, sub: identity.username, email, groups: identity.groups || []}); + + res.cookie(COOKIE, sid, { + httpOnly: true, + secure: req.protocol === 'https', + sameSite: 'lax', + path: '/', + maxAge: SsoSession.ttl() * 1000, + }); + return res.redirect(safeRd(req, st.rd)); + }catch(error){ + return next(error); + } +}); + +// End the session for this host. +router.get('/logout', async function(req, res, next){ + try{ + let sid = readCookie(req, COOKIE); + if(sid){ + try{ let s = await SsoSession.get(sid); await s.remove(); }catch(error){ /* gone */ } + } + res.clearCookie(COOKIE, {path: '/'}); + return res.redirect(safeRd(req, req.query.rd)); + }catch(error){ + return next(error); + } +}); + +module.exports = router; diff --git a/nodejs/test/unit/host_sso.test.js b/nodejs/test/unit/host_sso.test.js new file mode 100644 index 0000000..c7c88fd --- /dev/null +++ b/nodejs/test/unit/host_sso.test.js @@ -0,0 +1,42 @@ +'use strict'; + +const {describe, test} = require('node:test'); +const assert = require('node:assert'); + +const {identityAllowed} = require('../../utils/host_sso'); + +describe('identityAllowed (per-host SSO authorization)', () => { + const id = {username: 'alice', email: 'alice@x.com', groups: ['dns-team', 'staff']}; + + test('empty allow-lists allow any authenticated user', () => { + assert.strictEqual(identityAllowed(id, [], []), true); + assert.strictEqual(identityAllowed(id, undefined, undefined), true); + }); + + test('allows by username', () => { + assert.strictEqual(identityAllowed(id, ['bob', 'alice'], []), true); + }); + + test('allows by email', () => { + assert.strictEqual(identityAllowed(id, ['alice@x.com'], []), true); + }); + + test('allows by group membership', () => { + assert.strictEqual(identityAllowed(id, [], ['dns-team']), true); + }); + + test('denies when neither user nor group matches a non-empty list', () => { + assert.strictEqual(identityAllowed(id, ['bob'], ['admins']), false); + }); + + test('is case-insensitive', () => { + assert.strictEqual(identityAllowed(id, ['ALICE'], []), true); + assert.strictEqual(identityAllowed(id, [], ['DNS-Team']), true); + assert.strictEqual(identityAllowed({username: 'A', email: 'A@X.com'}, ['a@x.com'], []), true); + }); + + test('handles a missing/empty identity gracefully', () => { + assert.strictEqual(identityAllowed({}, ['bob'], ['admins']), false); + assert.strictEqual(identityAllowed({}, [], []), true); + }); +}); diff --git a/nodejs/utils/host_sso.js b/nodejs/utils/host_sso.js new file mode 100644 index 0000000..1f7e6e5 --- /dev/null +++ b/nodejs/utils/host_sso.js @@ -0,0 +1,35 @@ +'use strict'; + +/** + * Pure authorization check for per-host SSO (#57). + * + * After the OIDC dance, the callback decides whether the authenticated identity + * may access the host, based on the host's allow-lists. Enforcing here (at + * session creation) keeps the OpenResty side simple — the Lua gate only has to + * confirm a valid session exists for the host. + * + * Semantics: empty allow-lists mean "any authenticated user". Otherwise the + * identity is allowed if its username OR email is in sso_allow_users, or any of + * its groups is in sso_allow_groups. All comparisons are case-insensitive. + */ +function identityAllowed(identity, allowUsers, allowGroups){ + identity = identity || {}; + allowUsers = Array.isArray(allowUsers) ? allowUsers : []; + allowGroups = Array.isArray(allowGroups) ? allowGroups : []; + + if(allowUsers.length === 0 && allowGroups.length === 0) return true; + + let lc = s => String(s).trim().toLowerCase(); + + let ids = [identity.username, identity.email].filter(Boolean).map(lc); + let users = allowUsers.map(lc); + if(ids.some(id => users.includes(id))) return true; + + let groups = (Array.isArray(identity.groups) ? identity.groups : []).map(lc); + let allow = allowGroups.map(lc); + if(groups.some(g => allow.includes(g))) return true; + + return false; +} + +module.exports = {identityAllowed}; diff --git a/nodejs/utils/oidc.js b/nodejs/utils/oidc.js index c7c8e3e..9c0b034 100644 --- a/nodejs/utils/oidc.js +++ b/nodejs/utils/oidc.js @@ -36,13 +36,14 @@ function createAuthRequest(){ return {state, codeVerifier, codeChallenge}; } -// Build the SSO authorize URL the browser is redirected to. -function buildAuthUrl(state, codeChallenge){ +// Build the SSO authorize URL the browser is redirected to. `redirectUri` +// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback). +function buildAuthUrl(state, codeChallenge, redirectUri){ let o = conf.oidc; let params = new URLSearchParams({ response_type: 'code', client_id: o.clientId, - redirect_uri: o.redirectUri, + redirect_uri: redirectUri || o.redirectUri, scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '), state, code_challenge: codeChallenge, @@ -51,13 +52,14 @@ function buildAuthUrl(state, codeChallenge){ return `${o.authorizationEndpoint}?${params.toString()}`; } -// Exchange an authorization code for tokens at the token endpoint. -async function exchangeCode(code, codeVerifier){ +// Exchange an authorization code for tokens at the token endpoint. `redirectUri` +// must match the one used in buildAuthUrl (per-host for per-host SSO). +async function exchangeCode(code, codeVerifier, redirectUri){ let o = conf.oidc; let body = new URLSearchParams({ grant_type: 'authorization_code', code, - redirect_uri: o.redirectUri, + redirect_uri: redirectUri || o.redirectUri, client_id: o.clientId, client_secret: o.clientSecret, code_verifier: codeVerifier,