From 10abd3634081a49661627550c9f323e6d4d2646e Mon Sep 17 00:00:00 2001 From: William Mantly Date: Fri, 10 Jul 2026 12:17:05 -0400 Subject: [PATCH 1/3] Add OIDC login and per-domain authorization Authentication previously implied full authorization: any valid token could manage every host, DNS provider, domain, and user. This adds SSO login and a per-domain rights model. OIDC login (authorization_code + PKCE): - conf.oidc + conf.auth blocks; clientSecret in (gitignored) secrets.js. - utils/oidc.js (state/PKCE, code exchange, userinfo) using global fetch. - models/oidc_state.js: short-lived state store, auto-expiring via model-redis 1.5 per-key TTL. - routes/auth.js: GET /auth/oidc/start + /auth/oidc/callback; JIT-provisions a local user, mints an AuthToken carrying the SSO groups, hands the token to the browser via a URL fragment. "Log in with SSO" button on the login page. Authorization (groups + app overrides, per-domain, with ownership): - models/grant.js + utils/roles.js (pure, unit-tested): effective rights from conf.auth (admin users/groups, group->role map), Grant records (user|group -> global|domain -> viewer|manager|admin), and ownership (created_by). Roles rank admin > manager(owner) > viewer. - AuthToken stores session groups; middleware/auth.js exposes req.groups. - middleware/authz.js: requireAdmin, requireDomainRole(minRole, resolveDomain), filterViewable. Applied across routes: host mutations need manager on the host's domain; reads are filtered to visible domains; DNS providers, user management, and grant management are global-admin-only; certs need viewer. - routes/grant.js: admin CRUD for grants. Anti-lockout via conf.auth.adminUsers plus migrations/grant_bootstrap.js. Frontend: /me returns effective rights; nav gates Users/Grants to admins; grants management page; OIDC token-fragment handling in app-base.js. Tests: utils/roles and utils/oidc unit-tested (no redis); wired into the test scripts. Full suite 89 pass. Also verified end-to-end against redis (grant resolution, middleware allow/deny/403, list filtering) and the OIDC pure flow. Co-Authored-By: Claude Opus 4.8 --- nodejs/conf/base.js | 34 ++++++ nodejs/middleware/auth.js | 2 + nodejs/middleware/authz.js | 118 ++++++++++++++++++ nodejs/migrations/grant_bootstrap.js | 40 +++++++ nodejs/models/auth.js | 24 +++- nodejs/models/grant.js | 112 +++++++++++++++++ nodejs/models/index.js | 2 + nodejs/models/oidc_state.js | 31 +++++ nodejs/models/token.js | 18 +++ nodejs/models/user_ldap.js | 15 +++ nodejs/models/user_redis.js | 23 ++++ nodejs/package.json | 6 +- nodejs/public/lib/js/app-base.js | 51 +++++++- nodejs/routes/api.js | 9 +- nodejs/routes/auth.js | 65 ++++++++++ nodejs/routes/cert.js | 3 +- nodejs/routes/dns.js | 34 ++++-- nodejs/routes/grant.js | 42 +++++++ nodejs/routes/host.js | 33 +++-- nodejs/routes/render.js | 9 ++ nodejs/routes/user.js | 35 ++++-- nodejs/test/unit/oidc.test.js | 77 ++++++++++++ nodejs/test/unit/roles.test.js | 173 +++++++++++++++++++++++++++ nodejs/utils/oidc.js | 125 +++++++++++++++++++ nodejs/utils/roles.js | 110 +++++++++++++++++ nodejs/views/grants.ejs | 138 +++++++++++++++++++++ nodejs/views/login.ejs | 26 +++- nodejs/views/top.ejs | 12 +- 28 files changed, 1317 insertions(+), 50 deletions(-) create mode 100644 nodejs/middleware/authz.js create mode 100644 nodejs/migrations/grant_bootstrap.js create mode 100644 nodejs/models/grant.js create mode 100644 nodejs/models/oidc_state.js create mode 100644 nodejs/routes/grant.js create mode 100644 nodejs/test/unit/oidc.test.js create mode 100644 nodejs/test/unit/roles.test.js create mode 100644 nodejs/utils/oidc.js create mode 100644 nodejs/utils/roles.js create mode 100644 nodejs/views/grants.ejs diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index d670d31..a10c2fe 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -22,6 +22,40 @@ module.exports = { // self-correct. 0 disables expiry (entries live until bustCache/clearCache). cacheTTL: 3600, + // OpenID Connect login against the SSO. Endpoints come from the SSO's + // /.well-known/openid-configuration. clientSecret lives in secrets.js. + // redirectUri MUST be registered on the SSO client and match exactly. + oidc: { + enabled: true, + issuer: 'https://sso.theta42.com', + authorizationEndpoint: 'https://sso.theta42.com/oauth/authorize', + tokenEndpoint: 'https://sso.theta42.com/oauth/token', + userinfoEndpoint: 'https://sso.theta42.com/oauth/userinfo', + endSessionEndpoint: 'https://sso.theta42.com/oauth/logout', + clientId: '__SET_ME__', + // Where the SSO sends the user back. Must be an absolute URL reachable + // by the browser and registered on the SSO client. + redirectUri: 'http://localhost:3000/api/auth/oidc/callback', + scopes: ['openid', 'profile', 'email', 'groups'], + // Claim on the userinfo response that carries group membership. + groupsClaim: 'groups', + // Claim used as the local username. + usernameClaim: 'preferred_username', + }, + + // Authorization: how groups map to roles, and which groups are global admin. + // Per-user overrides are Grant records managed in the app. + auth: { + // Members of these SSO/LDAP groups are always global admins. + adminGroups: [], + // Optional default role mapping for groups, e.g. + // { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } } + // { 'proxy-viewers': { role: 'viewer', scope: 'global' } } + groupRoleMap: {}, + // Local users always treated as global admin (anti-lockout bootstrap). + adminUsers: ['proxyadmin2'], + }, + service:{ hostScheduler:{ enabled: true, diff --git a/nodejs/middleware/auth.js b/nodejs/middleware/auth.js index 7918d9a..3de1a8b 100755 --- a/nodejs/middleware/auth.js +++ b/nodejs/middleware/auth.js @@ -6,6 +6,8 @@ async function auth(req, res, next){ try{ req.token = await Auth.checkToken(req.header('auth-token')); req.user = req.token.user; + // Session group memberships captured at login, used by authz middleware. + req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : []; return next(); }catch(error){ next(error); diff --git a/nodejs/middleware/authz.js b/nodejs/middleware/authz.js new file mode 100644 index 0000000..b1d7c1f --- /dev/null +++ b/nodejs/middleware/authz.js @@ -0,0 +1,118 @@ +'use strict'; + +const {Grant} = require('../models/grant'); +const tldExtract = require('tld-extract').parse_host; + +/** + * Authorization middleware. + * + * Builds on middleware/auth.js (which sets req.user + req.groups). Effective + * rights are resolved once per request via Grant.effectiveFor and cached on + * req._effective. Roles: admin > manager (owner/full over a domain) > viewer. + */ + +// The username for the request, tolerating a missing user relation by falling +// back to the token's created_by (which is the username). +function reqUsername(req){ + return (req.user && req.user.username) || (req.token && req.token.created_by) || null; +} + +// Normalize a host or domain string to its registrable domain. +function toDomain(value){ + if(!value) return value; + try{ + return tldExtract(value).domain; + }catch(error){ + return value; + } +} + +// Resolve (and cache) the effective rights for this request. +async function getEffective(req){ + if(req._effective) return req._effective; + req._effective = await Grant.effectiveFor({ + username: reqUsername(req), + groups: req.groups || [], + }); + return req._effective; +} + +function forbidden(message){ + let error = new Error('Forbidden'); + error.name = 'Forbidden'; + error.message = message || 'You do not have permission to perform this action.'; + error.status = 403; + return error; +} + +// Global-admin-only gate (user management, DNS providers, grant management). +async function requireAdmin(req, res, next){ + try{ + let effective = await getEffective(req); + if(effective.isAdmin) return next(); + return next(forbidden('Administrator access required.')); + }catch(error){ + return next(error); + } +} + +/** + * Require at least `minRole` on the domain resolved from the request. + * + * @param {string} minRole - 'viewer' | 'manager' + * @param {Function} resolveDomain - (req) => host|domain string + */ +function requireDomainRole(minRole, resolveDomain){ + return async function(req, res, next){ + try{ + let effective = await getEffective(req); + let domain = toDomain(resolveDomain(req)); + if(!domain) return next(forbidden('Could not determine the target domain.')); + + if(Grant.allows(effective, minRole, domain)) return next(); + return next(forbidden(`You need '${minRole}' rights on ${domain}.`)); + }catch(error){ + return next(error); + } + }; +} + +// Common domain resolvers for route wiring. +const resolve = { + // A host lives in req.params.item (e.g. api.example.com -> example.com). + hostParam: req => req.params.item, + // A host being created lives in the request body. + hostBody: req => req.body && req.body.host, + // A domain (or provider domain) name in req.params.item. + domainParam: req => req.params.item, +}; + +/** + * Filter a list of records to those the request may at least view. + * Admins and holders of a global role see everything; otherwise a record is + * kept when its domain (via `getDomain`) is one the user has rights on. + * + * @param {Object} req + * @param {Array} records + * @param {Function} getDomain - (record) => host|domain string + */ +async function filterViewable(req, records, getDomain){ + let effective = await getEffective(req); + if(effective.isAdmin || Grant.rank(effective.global) >= Grant.rank('viewer')){ + return records; + } + return records.filter(function(record){ + let domain = toDomain(getDomain(record)); + return Grant.allows(effective, 'viewer', domain); + }); +} + +module.exports = { + getEffective, + requireAdmin, + requireDomainRole, + filterViewable, + resolve, + toDomain, + reqUsername, +}; diff --git a/nodejs/migrations/grant_bootstrap.js b/nodejs/migrations/grant_bootstrap.js new file mode 100644 index 0000000..77487fa --- /dev/null +++ b/nodejs/migrations/grant_bootstrap.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Bootstrap a global-admin Grant for a user so there is always someone who can + * manage the system after per-domain authorization is enabled. + * + * Usage: + * node migrations/grant_bootstrap.js [username] + * + * Defaults to the first entry in conf.auth.adminUsers (or 'proxyadmin2'). + * Note: members of conf.auth.adminUsers / conf.auth.adminGroups are already + * treated as admins without a Grant; this just makes it explicit/visible in the + * grant list and survives config changes. + */ + +const conf = require('@simpleworkjs/conf'); +require('../models'); // register all models +const {Grant} = require('../models/grant'); + +(async function(){ + try{ + let username = process.argv[2] + || (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) + || 'proxyadmin2'; + + let grant = await Grant.create({ + subjectType: 'user', + subject: username, + scope: 'global', + role: 'admin', + created_by: username, + }); + + console.log(`Granted global admin to "${username}":`, grant.id); + }catch(error){ + console.error('grant_bootstrap error', error); + }finally{ + process.exit(0); + } +})(); diff --git a/nodejs/models/auth.js b/nodejs/models/auth.js index b7bb581..34f33b5 100644 --- a/nodejs/models/auth.js +++ b/nodejs/models/auth.js @@ -47,7 +47,10 @@ class Auth{ static async login(data){ try{ let user = await User.login(data); - let token = await AuthToken.create({username: user.username}); + // Backends may attach group membership to the user (LDAP); default + // to none for local/redis users. + let groups = Array.isArray(user.groups) ? user.groups : []; + let token = await AuthToken.create({username: user.username, groups}); return {user, token} }catch(error){ @@ -56,6 +59,25 @@ class Auth{ } } + /** + * Establish a session for an OIDC-authenticated identity: JIT-provision the + * local user (redis-backed) and mint an AuthToken carrying the SSO groups. + * + * @param {Object} identity - {username, groups} from utils/oidc claims + * @returns {Object} {user, token} + */ + static async oidcSession(identity){ + let user = typeof User.upsertOidc === 'function' + ? await User.upsertOidc(identity) + : await User.get(identity.username); + let token = await AuthToken.create({ + username: user.username, + groups: identity.groups || [], + }); + + return {user, token}; + } + /** * Validate an authentication token. * diff --git a/nodejs/models/grant.js b/nodejs/models/grant.js new file mode 100644 index 0000000..3ef0f6a --- /dev/null +++ b/nodejs/models/grant.js @@ -0,0 +1,112 @@ +'use strict'; + +const Table = require('.'); +const conf = require('@simpleworkjs/conf'); +const roles = require('../utils/roles'); + +/** + * Grant + * + * Assigns a role to a subject (a user or a group), either globally or for a + * single domain. Per-user overrides and group defaults both live here; group + * defaults can also be seeded from conf.auth.groupRoleMap. + * + * subjectType : 'user' | 'group' + * subject : username or group name + * scope : 'global' | 'domain' + * domain : domain name when scope==='domain' (else '*') + * role : 'admin' | 'manager' | 'viewer' + * + * See Grant.effectiveFor() for how these, plus ownership (created_by) and + * conf.auth, collapse into a request's effective rights. + */ +class Grant extends Table{ + static _key = 'id'; + static _keyMap = { + 'created_by': {isRequired: true, type: 'string', min: 3, max: 500}, + 'created_on': {default: function(){return (new Date).getTime()}}, + 'id': {isRequired: true, type: 'string', min: 3, max: 1100}, + 'subjectType': {isRequired: true, type: 'string'}, + 'subject': {isRequired: true, type: 'string', min: 1, max: 500}, + 'scope': {default: 'domain', isRequired: true, type: 'string'}, + 'domain': {default: '*', isRequired: false, type: 'string'}, + 'role': {isRequired: true, type: 'string'}, + } + + static roles = ['viewer', 'manager', 'admin']; + // Re-export the pure helpers so existing callers (middleware/authz) can use + // them off the model. + static rank = roles.rank; + static maxRole = roles.maxRole; + static roleForDomain = roles.roleForDomain; + static allows = roles.allows; + static visibleDomains = roles.visibleDomains; + + // Deterministic id so the same (subject, scope, domain) grant is a single + // record — re-granting updates rather than duplicating. + static mkId({subjectType, subject, scope, domain}){ + return `${subjectType}:${subject}:${scope || 'domain'}:${scope === 'global' ? '*' : (domain || '*')}`; + } + + static async create(data){ + if(!this.roles.includes(data.role)){ + throw this.errors.ObjectValidateError([{key: 'role', message: `role must be one of ${this.roles.join(', ')}`}]); + } + if(!['user', 'group'].includes(data.subjectType)){ + throw this.errors.ObjectValidateError([{key: 'subjectType', message: `subjectType must be 'user' or 'group'`}]); + } + if(data.scope === 'global') data.domain = '*'; + data.id = this.mkId(data); + // Upsert: replace an existing identical-scoped grant instead of 409ing. + try{ + let existing = await this.get(data.id); + if(existing) await existing.remove(); + }catch(error){ /* not found is fine */ } + + return super.create(data); + } + + /** + * Collapse conf.auth, grant records, and resource ownership into the + * effective rights for a session identity. + * + * @param {Object} identity - {username, groups: string[]} + * @returns {Object} { isAdmin, global: role|null, domains: {domain: role} } + * - isAdmin: full access to everything. + * - global: a non-admin global role (manager/viewer) applied to every + * domain the user can see. + * - domains: explicit per-domain roles (includes owned domains). + */ + static async effectiveFor(identity){ + let username = identity && identity.username; + + // Fetch the redis-backed inputs, then hand off to the pure resolver. + let grants = []; + try{ + grants = await this.listDetail(); + }catch(error){ grants = []; } + + // Ownership: a user has manager rights over every domain they (or the + // DNS provider they created) own. Domain.created_by already carries the + // provider creator, so a single Domain scan covers both. + let ownedDomains = []; + if(username){ + try{ + let Domain = require('.').models.Domain; + ownedDomains = (await Domain.listDetail()) + .filter(d => d.created_by === username) + .map(d => d.domain); + }catch(error){ /* domains unavailable, skip ownership */ } + } + + return roles.resolveEffective(identity, { + grants, + ownedDomains, + authConf: conf.auth || {}, + }); + } +} + +Grant.register(); + +module.exports = {Grant}; diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 5bd173b..beef71a 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -10,3 +10,5 @@ require('./dns_provider'); require('./host'); require('./token'); require('./user'); +require('./grant'); +require('./oidc_state'); diff --git a/nodejs/models/oidc_state.js b/nodejs/models/oidc_state.js new file mode 100644 index 0000000..d46ede4 --- /dev/null +++ b/nodejs/models/oidc_state.js @@ -0,0 +1,31 @@ +'use strict'; + +const Table = require('.'); + +/** + * OidcState + * + * Short-lived store for an in-flight OpenID Connect authorization request. + * Keyed by the random `state` value; holds the PKCE `code_verifier` and the + * post-login redirect target until the SSO calls us back. + * + * The record auto-expires via model-redis per-key TTL (static _ttl), so an + * abandoned login attempt leaves nothing behind and there is no cleanup job. + */ +class OidcState extends Table{ + static _key = 'state'; + + // Auth round-trips are quick; 5 minutes is plenty and bounds replay. + static _ttl = 300; + + 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}, + 'redirect': {default: '/', isRequired: false, type: 'string'}, + } +} + +OidcState.register(); + +module.exports = {OidcState}; diff --git a/nodejs/models/token.js b/nodejs/models/token.js index d108c1a..8af591d 100644 --- a/nodejs/models/token.js +++ b/nodejs/models/token.js @@ -33,13 +33,31 @@ class AuthToken extends Token{ static _keyMap = { ...super._keyMap, user: {model: 'User', rel: 'one', localKey: 'created_by'}, + // Group memberships captured at login (OIDC `groups` claim or LDAP + // group membership), stored as a JSON string. Drives authorization for + // the life of the session without re-querying the IdP on every request. + groups: {default: '[]', isRequired: false, type: 'string'}, } static async create(data){ data.created_by = data.username; + if(Array.isArray(data.groups)){ + data.groups = JSON.stringify(data.groups); + } return super.create(data) } + + // Parse the stored groups JSON back into an array, tolerating bad/missing + // data so authorization never crashes on a malformed token. + groupsArray(){ + try{ + let parsed = JSON.parse(this.groups); + return Array.isArray(parsed) ? parsed : []; + }catch(error){ + return []; + } + } } AuthToken.register(); diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index bb3b6e9..6c6dfa1 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -9,6 +9,19 @@ const client = new Client({ }); +// Best-effort group extraction from a directory entry's `memberOf` values. +// Turns `cn=dns-team,ou=groups,dc=...` into `dns-team`. Directories that don't +// return memberOf simply yield no groups (see conf note); explicit group-search +// can be added later if needed. +const parse_groups = function(memberOf){ + if(!memberOf) return []; + let values = Array.isArray(memberOf) ? memberOf : [memberOf]; + return values.map(function(dn){ + let match = /^cn=([^,]+)/i.exec(String(dn)); + return match ? match[1] : String(dn); + }); +} + const user_parse = function(data){ if(data[conf.userNameAttribute]){ data.username = data[conf.userNameAttribute] @@ -20,6 +33,8 @@ const user_parse = function(data){ delete data.uidNumber; } + data.groups = parse_groups(data.memberOf); + return data; } diff --git a/nodejs/models/user_redis.js b/nodejs/models/user_redis.js index c70de74..8f66c11 100644 --- a/nodejs/models/user_redis.js +++ b/nodejs/models/user_redis.js @@ -2,6 +2,7 @@ const Table = require('.'); const bcrypt = require('bcrypt'); +const crypto = require('crypto'); const saltRounds = 10; class User extends Table{ @@ -40,6 +41,28 @@ class User extends Table{ } } + /** + * Just-in-time provisioning for an OIDC-authenticated user. Creates the + * local user on first login so relations (tokens, created_by, grants) have + * something to point at. OIDC users get a random, unusable password — they + * authenticate through the SSO, never the local password form. + * + * @param {Object} data - {username, ...} from the OIDC userinfo claims + * @returns {User} the existing or newly created user + */ + static async upsertOidc(data){ + try{ + return await User.get(data.username); + }catch(error){ + return await User.create({ + username: data.username, + password: crypto.randomBytes(24).toString('hex'), + created_by: data.username, + backing: 'oidc', + }); + } + } + static async login(data){ try{ let user = await User.get(data); diff --git a/nodejs/package.json b/nodejs/package.json index 0ba5ba2..c2de640 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/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/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/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/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/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/unix_socket.test.js test/integration/dns_provider.test.js" }, "engines": { "node": ">=18.0.0" diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index ba4880d..3e1b0b6 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -191,7 +191,8 @@ app.auth = (function(app){ function isLoggedIn(callback){ if(getToken()){ return app.api.get('user/me', function(error, data){ - if(!error) app.auth.user = data; + // data now carries effective rights (isAdmin, global, domains). + if(!error) app.auth.user = app.auth.perms = data; return callback(error, data); }); }else{ @@ -199,6 +200,28 @@ app.auth = (function(app){ } } + // Consume an app token handed back by the OIDC callback via the URL + // fragment (#token=…&redirect=…). Stores it, strips the fragment, and + // forwards to the intended page. Returns true if a token was consumed. + function consumeTokenFragment(){ + if(!location.hash) return false; + var params = new URLSearchParams(location.hash.replace(/^#/, '')); + var token = params.get('token'); + if(!token) return false; + + setToken(token); + var redirect = params.get('redirect') || '/'; + // Drop the token from the address bar before navigating on. + history.replaceState(null, '', location.pathname + location.search); + window.location.href = redirect; + return true; + } + + // True when the logged-in user is a global admin (per user/me). + function isAdmin(){ + return !!(app.auth.perms && app.auth.perms.isAdmin); + } + function logIn(args, callback){ app.api.post('auth/login', args, function(error, data){ if(data.login){ @@ -233,6 +256,9 @@ app.auth = (function(app){ getToken: getToken, setToken: setToken, isLoggedIn: isLoggedIn, + consumeTokenFragment: consumeTokenFragment, + isAdmin: isAdmin, + perms: null, logIn: logIn, logOut: logOut, forceLogin, @@ -270,6 +296,29 @@ app.user = (function(app){ })(app); +app.grant = (function(app){ + function list(callback){ + app.api.get('grant/', function(error, data){ + callback(error, data); + }); + } + + function add(args, callback){ + app.api.post('grant/', args, function(error, data){ + callback(error, data); + }); + } + + function remove(id, callback){ + app.api.delete('grant/' + encodeURIComponent(id), function(error, data){ + callback(error, data); + }); + } + + return {list, add, remove}; + +})(app); + app.util = (function(app){ function getUrlParameter(name){ diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index 877906f..3b92587 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -3,14 +3,18 @@ const router = require('express').Router(); const conf = require('@simpleworkjs/conf'); const middleware = require('../middleware/auth'); +const authz = require('../middleware/authz'); -// API routes for authentication. +// API routes for authentication. router.use('/auth', require('./auth')); // API routes for working with users. All endpoints need to be have valid user. +// User management is admin-only; the router allows self-service exceptions +// (GET /me, PUT /password) before its own admin gate. router.use('/user', middleware.auth, require('./user')); // API routes for working with hosts. All endpoints need to be have valid user. +// Per-domain authorization is enforced inside the host router. router.use('/host', middleware.auth, require('./host')); router.use('/dns', middleware.auth, require('./dns')); @@ -18,4 +22,7 @@ router.use('/dns', middleware.auth, require('./dns')); // API routes for working with hosts. All endpoints need to be have valid user. router.use('/cert', middleware.auth, require('./cert')); +// Grant management (who can manage which domains) is global-admin-only. +router.use('/grant', middleware.auth, authz.requireAdmin, require('./grant')); + module.exports = router; \ No newline at end of file diff --git a/nodejs/routes/auth.js b/nodejs/routes/auth.js index 4b0bef8..dbffa44 100755 --- a/nodejs/routes/auth.js +++ b/nodejs/routes/auth.js @@ -1,7 +1,10 @@ 'use strict'; const router = require('express').Router(); +const conf = require('@simpleworkjs/conf'); const { Auth } = require('../models/auth'); +const { OidcState } = require('../models/oidc_state'); +const oidc = require('../utils/oidc'); router.post('/login', async function(req, res, next){ @@ -29,4 +32,66 @@ router.all('/logout', async function(req, res, next){ } }); +/** + * OIDC login start: create a PKCE + state challenge, persist it (auto-expiring + * via OidcState TTL), and redirect the browser to the SSO authorize endpoint. + */ +router.get('/oidc/start', async function(req, res, next){ + try{ + if(!conf.oidc || !conf.oidc.enabled){ + let error = new Error('OidcDisabled'); + error.status = 404; + error.message = 'OIDC login is not enabled.'; + throw error; + } + + let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest(); + await OidcState.create({ + state, + codeVerifier, + redirect: req.query.redirect || '/', + }); + + return res.redirect(oidc.buildAuthUrl(state, codeChallenge)); + }catch(error){ + next(error); + } +}); + +/** + * OIDC callback: validate state (consuming the one-time record), exchange the + * code for tokens, read identity from userinfo, establish a session, and hand + * the app token back to the browser via a URL fragment for the login page to + * store in localStorage. + */ +router.get('/oidc/callback', async function(req, res, next){ + try{ + let {code, state} = req.query; + if(!code || !state){ + let error = new Error('OidcCallbackInvalid'); + error.status = 400; + error.message = 'Missing code or state.'; + throw error; + } + + // get() throws if the state is unknown or has expired — this both binds + // the callback to our request and bounds replay. + let saved = await OidcState.get(state); + await saved.remove(); + + let tokens = await oidc.exchangeCode(code, saved.codeVerifier); + let claims = await oidc.fetchUserInfo(tokens.access_token); + let identity = oidc.claimsToIdentity(claims); + + let {token} = await Auth.oidcSession(identity); + + let redirect = saved.redirect || '/'; + return res.redirect( + `/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}` + ); + }catch(error){ + next(error); + } +}); + module.exports = router; diff --git a/nodejs/routes/cert.js b/nodejs/routes/cert.js index 40db330..b10e972 100644 --- a/nodejs/routes/cert.js +++ b/nodejs/routes/cert.js @@ -2,9 +2,10 @@ const router = require('express').Router(); const {getCert} = require('../models/cert'); +const authz = require('../middleware/authz'); -router.get('/:host', async function(req, res, next){ +router.get('/:host', authz.requireDomainRole('viewer', req => req.params.host), async function(req, res, next){ try{ return res.json(await getCert(req.params.host)); }catch(error){ diff --git a/nodejs/routes/dns.js b/nodejs/routes/dns.js index 6fdedc4..ee8a48f 100644 --- a/nodejs/routes/dns.js +++ b/nodejs/routes/dns.js @@ -2,10 +2,14 @@ const router = require('express').Router(); const {DnsProvider, Domain} = require('../models').models; +const authz = require('../middleware/authz'); const Model = DnsProvider; -router.get('/', async function(req, res, next){ +// Provider listing exposes credentials/config for every domain, so it is +// admin-only. The creator of a provider still owns its domains (via created_by) +// and manages hosts/records under them without being a global admin. +router.get('/', authz.requireAdmin, async function(req, res, next){ try{ return res.json({ results: await Model[req.query.detail ? "listDetail" : "list"]() @@ -15,7 +19,7 @@ router.get('/', async function(req, res, next){ } }); -router.options('/', async function(req, res, next){ +router.options('/', authz.requireAdmin, async function(req, res, next){ try{ return res.json({ results: await Model.listProviders() @@ -25,9 +29,9 @@ router.options('/', async function(req, res, next){ } }); -router.post('/', async function(req, res, next){ +router.post('/', authz.requireAdmin, async function(req, res, next){ try{ - req.body.created_by = req.user.username; + req.body.created_by = authz.reqUsername(req); let item = await Model.create(req.body); return res.json({ @@ -41,15 +45,19 @@ router.post('/', async function(req, res, next){ router.get('/domain', async function(req, res, next){ try{ - return res.json({ - results: await Domain[req.query.detail ? "listDetail" : "list"]() - }); + let results = await Domain[req.query.detail ? "listDetail" : "list"](); + + // Only surface domains the caller may view. + results = await authz.filterViewable(req, results, + item => (typeof item === 'string' ? item : item.domain)); + + return res.json({results}); }catch(error){ return next(error); } }); -router.post('/domain/refresh/:item', async function(req, res, next){ +router.post('/domain/refresh/:item', authz.requireAdmin, async function(req, res, next){ try{ let item = await Model.get(req.params.item); return res.json({results: await item.updateDomains()}); @@ -58,7 +66,7 @@ router.post('/domain/refresh/:item', async function(req, res, next){ } }) -router.get('/domain/:item', async function(req, res, next){ +router.get('/domain/:item', authz.requireDomainRole('viewer', authz.resolve.domainParam), async function(req, res, next){ try{ return res.json({ results: [await Domain.get(req.params.item)] @@ -68,7 +76,7 @@ router.get('/domain/:item', async function(req, res, next){ } }); -router.get('/:item', async function(req, res, next){ +router.get('/:item', authz.requireAdmin, async function(req, res, next){ try{ return res.json({ @@ -80,9 +88,9 @@ router.get('/:item', async function(req, res, next){ } }); -router.put('/:item', async function(req, res, next){ +router.put('/:item', authz.requireAdmin, async function(req, res, next){ try{ - req.body.updated_by = req.user.username; + req.body.updated_by = authz.reqUsername(req); let item = await Model.get(req.params.item); item = await item.update(req.body); @@ -98,7 +106,7 @@ router.put('/:item', async function(req, res, next){ } }); -router.delete('/:item', async function(req, res, next){ +router.delete('/:item', authz.requireAdmin, async function(req, res, next){ try{ let item = await Model.get(req.params.item); let count = await item.remove(); diff --git a/nodejs/routes/grant.js b/nodejs/routes/grant.js new file mode 100644 index 0000000..85b317f --- /dev/null +++ b/nodejs/routes/grant.js @@ -0,0 +1,42 @@ +'use strict'; + +const router = require('express').Router(); +const {Grant} = require('../models/grant'); +const {reqUsername} = require('../middleware/authz'); + +// All grant management is admin-only; the gate is applied where this router is +// mounted (routes/api.js). + +router.get('/', async function(req, res, next){ + try{ + return res.json({results: await Grant.listDetail()}); + }catch(error){ + next(error); + } +}); + +router.post('/', async function(req, res, next){ + try{ + req.body.created_by = reqUsername(req); + let grant = await Grant.create(req.body); + return res.json({ + message: `Granted ${req.body.role} to ${req.body.subjectType} "${req.body.subject}"` + + (req.body.scope === 'global' ? ' globally.' : ` on ${req.body.domain}.`), + ...grant, + }); + }catch(error){ + next(error); + } +}); + +router.delete('/:id', async function(req, res, next){ + try{ + let grant = await Grant.get(req.params.id); + await grant.remove(); + return res.json({message: `Grant ${req.params.id} removed.`}); + }catch(error){ + next(error); + } +}); + +module.exports = router; diff --git a/nodejs/routes/host.js b/nodejs/routes/host.js index 1d6e86c..ec25313 100755 --- a/nodejs/routes/host.js +++ b/nodejs/routes/host.js @@ -2,22 +2,28 @@ const router = require('express').Router(); const {Host, Domain} = require('../models').models; +const authz = require('../middleware/authz'); const Model = Host; router.get('/', async function(req, res, next){ try{ - return res.json({ - results: await Model[req.query.detail ? "listDetail" : "list"](), - }); + let results = await Model[req.query.detail ? "listDetail" : "list"](); + + // Restrict to hosts whose domain the caller may view. list() yields host + // strings; listDetail() yields instances with a .host. + results = await authz.filterViewable(req, results, + item => (typeof item === 'string' ? item : item.host)); + + return res.json({results}); }catch(error){ return next(error); } }); -router.post('/', async function(req, res, next){ +router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), async function(req, res, next){ try{ - req.body.created_by = req.user.username; + req.body.created_by = authz.reqUsername(req); let item = await Model.create(req.body); return res.json({ @@ -29,7 +35,7 @@ router.post('/', async function(req, res, next){ } }); -router.get('/lookup/:item', async function(req, res, next){ +router.get('/lookup/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){ try{ return res.json({ string: req.params.item, @@ -41,7 +47,8 @@ router.get('/lookup/:item', async function(req, res, next){ } }); -router.get('/lookupobj', async function(req, res, next){ +// The full lookup tree exposes every host, so restrict it to admins. +router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){ try{ return res.json({ results: Model.lookUpObj, @@ -52,7 +59,7 @@ router.get('/lookupobj', async function(req, res, next){ } }); -router.delete('/cache', async function(req, res, next){ +router.delete('/cache', authz.requireAdmin, async function(req, res, next){ try{ let count = await Model.clearCache(); @@ -65,7 +72,7 @@ router.delete('/cache', async function(req, res, next){ } }); -router.get('/:item', async function(req, res, next){ +router.get('/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){ try{ return res.json({ @@ -77,9 +84,9 @@ router.get('/:item', async function(req, res, next){ } }); -router.put('/:item', async function(req, res, next){ +router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){ try{ - req.body.updated_by = req.user.username; + req.body.updated_by = authz.reqUsername(req); let item = await Model.get(req.params.item); item = await item.update(req.body); @@ -95,7 +102,7 @@ router.put('/:item', async function(req, res, next){ } }); -router.delete('/:item', async function(req, res, next){ +router.delete('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){ try{ let item = await Model.get(req.params.item); let count = await item.remove(); @@ -110,7 +117,7 @@ router.delete('/:item', async function(req, res, next){ } }); -router.put('/:item/renew', async function(req, res, next){ +router.put('/:item/renew', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){ try{ let item = await Model.get(req.params.item); item.createWildcardCert(); diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index 86bacb8..d98fd5d 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -42,6 +42,15 @@ router.get('/users', async function(req, res, next) { res.render('users', {...values}); }); +router.get('/grants', async function(req, res, next) { + res.render('grants', {...values}); +}); + +// Bare /login (the OIDC callback redirect target) and /login/. +router.get('/login', async function(req, res, next) { + res.render('login', {...values, redirect: req.query.redirect}); +}); + router.get('/login/*splat', async function(req, res, next) { res.render('login', {...values, redirect: req.query.redirect}); }); diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 04d1dc8..7530d73 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -1,9 +1,14 @@ 'use strict'; const router = require('express').Router(); -const {User} = require('../models').models; +const {User} = require('../models').models; +const authz = require('../middleware/authz'); -router.get('/', async function(req, res, next){ +// User management is global-admin-only, except the self-service routes below +// (GET /me, PUT /password, POST /key) which any authenticated user may call for +// their own account. + +router.get('/', authz.requireAdmin, async function(req, res, next){ try{ return res.json({ results: await User[req.query.detail ? "listDetail" : "list"]() @@ -13,9 +18,9 @@ router.get('/', async function(req, res, next){ } }); -router.post('/', async function(req, res, next){ +router.post('/', authz.requireAdmin, async function(req, res, next){ try{ - req.body.created_by = req.user.username + req.body.created_by = authz.reqUsername(req) return res.json(await User.add(req.body)); }catch(error){ @@ -23,7 +28,7 @@ router.post('/', async function(req, res, next){ } }); -router.delete('/:username', async function(req, res, next){ +router.delete('/:username', authz.requireAdmin, async function(req, res, next){ try{ let user = await User.get(req.params.username); @@ -33,14 +38,24 @@ router.delete('/:username', async function(req, res, next){ } }); +// Self-service: the caller's own identity and effective rights. Drives the +// frontend's nav/button gating. router.get('/me', async function(req, res, next){ try{ - return res.json({username: req.user.username}); + let effective = await authz.getEffective(req); + return res.json({ + username: authz.reqUsername(req), + groups: req.groups || [], + isAdmin: effective.isAdmin, + global: effective.global, + domains: effective.domains, + }); }catch(error){ next(error); } }); +// Self-service: change your own password. router.put('/password', async function(req, res, next){ try{ return res.json({results: await req.user.setPassword(req.body)}) @@ -49,7 +64,8 @@ router.put('/password', async function(req, res, next){ } }); -router.put('/password/:username', async function(req, res, next){ +// Admin: reset another user's password. +router.put('/password/:username', authz.requireAdmin, async function(req, res, next){ try{ let user = await User.get(req.params.username); return res.json({results: await user.setPassword(req.body)}); @@ -58,7 +74,7 @@ router.put('/password/:username', async function(req, res, next){ } }); -router.post('/invite', async function(req, res, next){ +router.post('/invite', authz.requireAdmin, async function(req, res, next){ try{ let token = await req.user.invite(); @@ -68,10 +84,11 @@ router.post('/invite', async function(req, res, next){ } }); +// Self-service: add an SSH key to your own account. router.post('/key', async function(req, res, next){ try{ let added = await User.addSSHkey({ - username: req.user.username, + username: authz.reqUsername(req), key: req.body.key }); diff --git a/nodejs/test/unit/oidc.test.js b/nodejs/test/unit/oidc.test.js new file mode 100644 index 0000000..9de3f9f --- /dev/null +++ b/nodejs/test/unit/oidc.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const {describe, test} = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); + +const oidc = require('../../utils/oidc'); +const conf = require('@simpleworkjs/conf'); + +/** + * Tests for the pure parts of the OIDC client (utils/oidc): PKCE/state + * generation, authorize-URL construction, and claim mapping. Network calls + * (exchangeCode/fetchUserInfo) are not exercised here. + */ + +describe('oidc PKCE / state', () => { + test('createAuthRequest returns distinct high-entropy state and verifier', () => { + const a = oidc.createAuthRequest(); + assert.ok(a.state.length >= 20); + assert.ok(a.codeVerifier.length >= 20); + assert.notStrictEqual(a.state, a.codeVerifier); + + const b = oidc.createAuthRequest(); + assert.notStrictEqual(a.state, b.state); + }); + + test('code challenge is the base64url S256 of the verifier', () => { + const {codeVerifier, codeChallenge} = oidc.createAuthRequest(); + const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + assert.strictEqual(codeChallenge, expected); + }); + + test('challenge is base64url (no +, /, or = padding)', () => { + const {codeChallenge} = oidc.createAuthRequest(); + assert.ok(!/[+/=]/.test(codeChallenge)); + }); +}); + +describe('oidc buildAuthUrl', () => { + test('includes required authorization-code + PKCE params', () => { + const url = new URL(oidc.buildAuthUrl('the-state', 'the-challenge')); + assert.strictEqual(url.origin + url.pathname, conf.oidc.authorizationEndpoint); + const p = url.searchParams; + assert.strictEqual(p.get('response_type'), 'code'); + assert.strictEqual(p.get('client_id'), conf.oidc.clientId); + assert.strictEqual(p.get('redirect_uri'), conf.oidc.redirectUri); + assert.strictEqual(p.get('state'), 'the-state'); + assert.strictEqual(p.get('code_challenge'), 'the-challenge'); + assert.strictEqual(p.get('code_challenge_method'), 'S256'); + assert.ok(p.get('scope').includes('openid')); + assert.ok(p.get('scope').includes('groups')); + }); +}); + +describe('oidc claimsToIdentity', () => { + test('maps preferred_username and groups', () => { + const id = oidc.claimsToIdentity({ + sub: 'abc', + preferred_username: 'jane', + groups: ['dns-team', 'proxy-admins'], + }); + assert.strictEqual(id.username, 'jane'); + assert.deepStrictEqual(id.groups, ['dns-team', 'proxy-admins']); + }); + + test('falls back to sub when no preferred_username', () => { + const id = oidc.claimsToIdentity({sub: 'abc'}); + assert.strictEqual(id.username, 'abc'); + assert.deepStrictEqual(id.groups, []); + }); + + test('coerces a single group value to an array', () => { + const id = oidc.claimsToIdentity({sub: 'abc', groups: 'solo'}); + assert.deepStrictEqual(id.groups, ['solo']); + }); +}); diff --git a/nodejs/test/unit/roles.test.js b/nodejs/test/unit/roles.test.js new file mode 100644 index 0000000..eb0c025 --- /dev/null +++ b/nodejs/test/unit/roles.test.js @@ -0,0 +1,173 @@ +'use strict'; + +const {describe, test} = require('node:test'); +const assert = require('node:assert'); + +const roles = require('../../utils/roles'); + +/** + * Tests for the pure authorization logic (utils/roles). No redis: grant rows, + * owned domains, and conf.auth are passed in directly. This is the heart of the + * per-domain rights model — see models/grant.js for the redis-backed wiring. + */ + +const authConf = { + adminUsers: ['root'], + adminGroups: ['proxy-admins'], + groupRoleMap: { + 'global-viewers': {scope: 'global', role: 'viewer'}, + 'foo-managers': {scope: 'domain', domain: 'foo.com', role: 'manager'}, + 'super': {scope: 'global', role: 'admin'}, + }, +}; + +const effective = (identity, data) => + roles.resolveEffective(identity, {authConf, ...data}); + +describe('roles.resolveEffective', () => { + + describe('admin', () => { + test('conf adminUsers grants global admin', () => { + const e = effective({username: 'root', groups: []}); + assert.strictEqual(e.isAdmin, true); + }); + + test('conf adminGroups grants global admin', () => { + const e = effective({username: 'bob', groups: ['proxy-admins']}); + assert.strictEqual(e.isAdmin, true); + }); + + test('groupRoleMap admin role grants global admin', () => { + const e = effective({username: 'bob', groups: ['super']}); + assert.strictEqual(e.isAdmin, true); + }); + + test('a global admin Grant record grants admin', () => { + const e = effective({username: 'bob', groups: []}, { + grants: [{subjectType: 'user', subject: 'bob', scope: 'global', role: 'admin'}], + }); + assert.strictEqual(e.isAdmin, true); + }); + + test('admin passes every domain check', () => { + const e = effective({username: 'root', groups: []}); + assert.strictEqual(roles.allows(e, 'manager', 'anything.com'), true); + assert.strictEqual(roles.roleForDomain(e, 'anything.com'), 'admin'); + }); + + test('a plain user is not admin', () => { + const e = effective({username: 'nobody', groups: []}); + assert.strictEqual(e.isAdmin, false); + }); + }); + + describe('per-domain grants', () => { + test('user manager grant allows manage on that domain only', () => { + const e = effective({username: 'jane', groups: []}, { + grants: [{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'manager'}], + }); + assert.strictEqual(roles.allows(e, 'manager', 'ex.com'), true); + assert.strictEqual(roles.allows(e, 'viewer', 'ex.com'), true); + assert.strictEqual(roles.allows(e, 'manager', 'other.com'), false); + assert.strictEqual(roles.allows(e, 'viewer', 'other.com'), false); + }); + + test('viewer grant allows read but not manage', () => { + const e = effective({username: 'jane', groups: []}, { + grants: [{subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'viewer'}], + }); + assert.strictEqual(roles.allows(e, 'viewer', 'ex.com'), true); + assert.strictEqual(roles.allows(e, 'manager', 'ex.com'), false); + }); + + test('group grant applies to members', () => { + const e = effective({username: 'jane', groups: ['dns-team']}, { + grants: [{subjectType: 'group', subject: 'dns-team', scope: 'domain', domain: 'ex.com', role: 'manager'}], + }); + assert.strictEqual(roles.allows(e, 'manager', 'ex.com'), true); + }); + + test('group grant does not apply to non-members', () => { + const e = effective({username: 'jane', groups: []}, { + grants: [{subjectType: 'group', subject: 'dns-team', scope: 'domain', domain: 'ex.com', role: 'manager'}], + }); + assert.strictEqual(roles.allows(e, 'viewer', 'ex.com'), false); + }); + + test('groupRoleMap domain default applies', () => { + const e = effective({username: 'jane', groups: ['foo-managers']}); + assert.strictEqual(roles.allows(e, 'manager', 'foo.com'), true); + assert.strictEqual(roles.allows(e, 'manager', 'bar.com'), false); + }); + }); + + describe('override precedence (strongest wins)', () => { + test('a per-user manager grant beats a group viewer grant', () => { + const e = effective({username: 'jane', groups: ['team']}, { + grants: [ + {subjectType: 'group', subject: 'team', scope: 'domain', domain: 'ex.com', role: 'viewer'}, + {subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'manager'}, + ], + }); + assert.strictEqual(roles.roleForDomain(e, 'ex.com'), 'manager'); + }); + + test('grant order does not matter (max wins)', () => { + const e = effective({username: 'jane', groups: []}, { + grants: [ + {subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'manager'}, + {subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'ex.com', role: 'viewer'}, + ], + }); + assert.strictEqual(roles.roleForDomain(e, 'ex.com'), 'manager'); + }); + }); + + describe('ownership', () => { + test('owned domains grant manager without an explicit grant', () => { + const e = effective({username: 'owner', groups: []}, { + ownedDomains: ['mine.com'], + }); + assert.strictEqual(roles.roleForDomain(e, 'mine.com'), 'manager'); + assert.strictEqual(roles.allows(e, 'manager', 'mine.com'), true); + assert.strictEqual(roles.allows(e, 'viewer', 'notmine.com'), false); + }); + }); + + describe('global (non-admin) roles', () => { + test('global viewer sees every domain read-only', () => { + const e = effective({username: 'v', groups: ['global-viewers']}); + assert.strictEqual(e.global, 'viewer'); + assert.strictEqual(roles.allows(e, 'viewer', 'a.com'), true); + assert.strictEqual(roles.allows(e, 'viewer', 'b.com'), true); + assert.strictEqual(roles.allows(e, 'manager', 'a.com'), false); + }); + }); + + describe('visibleDomains', () => { + test('lists domains with at least viewer', () => { + const e = effective({username: 'jane', groups: []}, { + grants: [ + {subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'a.com', role: 'viewer'}, + {subjectType: 'user', subject: 'jane', scope: 'domain', domain: 'b.com', role: 'manager'}, + ], + }); + assert.deepStrictEqual(roles.visibleDomains(e).sort(), ['a.com', 'b.com']); + }); + }); +}); + +describe('roles.rank / maxRole', () => { + test('rank ordering', () => { + assert.ok(roles.rank('admin') > roles.rank('manager')); + assert.ok(roles.rank('manager') > roles.rank('viewer')); + assert.ok(roles.rank('viewer') > roles.rank(null)); + }); + + test('maxRole returns the stronger role', () => { + assert.strictEqual(roles.maxRole('viewer', 'manager'), 'manager'); + assert.strictEqual(roles.maxRole('manager', 'viewer'), 'manager'); + assert.strictEqual(roles.maxRole(null, 'viewer'), 'viewer'); + assert.strictEqual(roles.maxRole(null, null), null); + }); +}); diff --git a/nodejs/utils/oidc.js b/nodejs/utils/oidc.js new file mode 100644 index 0000000..c7c8e3e --- /dev/null +++ b/nodejs/utils/oidc.js @@ -0,0 +1,125 @@ +'use strict'; + +const crypto = require('crypto'); +const conf = require('@simpleworkjs/conf'); + +/** + * Minimal OpenID Connect authorization-code + PKCE client. + * + * The SSO publishes no jwks_uri, so we do not verify ID-token signatures; + * instead we treat the flow as opaque and read identity from the userinfo + * endpoint (the access token is exchanged server-side over TLS). Uses Node's + * global fetch (Node 18+) and crypto — no external dependency. + * + * All endpoints and client config come from conf.oidc (+ clientSecret from + * secrets.js, deep-merged by @simpleworkjs/conf). + */ + +const base64url = buf => buf.toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +// A high-entropy random string for `state` / PKCE verifier. +function randomToken(bytes = 32){ + return base64url(crypto.randomBytes(bytes)); +} + +// PKCE S256 challenge derived from the verifier. +function codeChallengeS256(verifier){ + return base64url(crypto.createHash('sha256').update(verifier).digest()); +} + +// Generate the {state, codeVerifier, codeChallenge} triple for a new login. +function createAuthRequest(){ + let state = randomToken(32); + let codeVerifier = randomToken(32); + let codeChallenge = codeChallengeS256(codeVerifier); + return {state, codeVerifier, codeChallenge}; +} + +// Build the SSO authorize URL the browser is redirected to. +function buildAuthUrl(state, codeChallenge){ + let o = conf.oidc; + let params = new URLSearchParams({ + response_type: 'code', + client_id: o.clientId, + redirect_uri: o.redirectUri, + scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '), + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + return `${o.authorizationEndpoint}?${params.toString()}`; +} + +// Exchange an authorization code for tokens at the token endpoint. +async function exchangeCode(code, codeVerifier){ + let o = conf.oidc; + let body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: o.redirectUri, + client_id: o.clientId, + client_secret: o.clientSecret, + code_verifier: codeVerifier, + }); + + let res = await fetch(o.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: body.toString(), + }); + + if(!res.ok){ + let text = await res.text().catch(() => ''); + let error = new Error('OidcTokenExchangeFailed'); + error.name = 'OidcTokenExchangeFailed'; + error.message = `Token exchange failed (${res.status}): ${text}`; + error.status = 502; + throw error; + } + + return res.json(); +} + +// Fetch the userinfo claims for an access token. +async function fetchUserInfo(accessToken){ + let o = conf.oidc; + let res = await fetch(o.userinfoEndpoint, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json', + }, + }); + + if(!res.ok){ + let error = new Error('OidcUserInfoFailed'); + error.name = 'OidcUserInfoFailed'; + error.message = `Userinfo request failed (${res.status})`; + error.status = 502; + throw error; + } + + return res.json(); +} + +// Pull the app username and group list out of userinfo claims per conf. +function claimsToIdentity(claims){ + let o = conf.oidc; + let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub; + let groups = claims[o.groupsClaim || 'groups'] || []; + if(!Array.isArray(groups)) groups = [groups].filter(Boolean); + return {username, groups, claims}; +} + +module.exports = { + randomToken, + codeChallengeS256, + createAuthRequest, + buildAuthUrl, + exchangeCode, + fetchUserInfo, + claimsToIdentity, +}; diff --git a/nodejs/utils/roles.js b/nodejs/utils/roles.js new file mode 100644 index 0000000..7f35f44 --- /dev/null +++ b/nodejs/utils/roles.js @@ -0,0 +1,110 @@ +'use strict'; + +/** + * Pure authorization role logic — no redis, no I/O — so it can be unit tested + * in isolation. models/grant.js supplies the data (grant records, owned + * domains, conf.auth) and this module collapses it into effective rights and + * answers allow/deny questions. + * + * Roles rank: admin > manager (owner/full over a domain) > viewer. + */ + +const ROLE_RANK = {viewer: 1, manager: 2, admin: 3}; + +function rank(role){ + return ROLE_RANK[role] || 0; +} + +// Whichever of two roles is stronger; either may be null/undefined. +function maxRole(a, b){ + if(rank(a) >= rank(b)) return a || b || null; + return b || a || null; +} + +/** + * Collapse config, grants, and ownership into effective rights. + * + * @param {Object} identity - {username, groups: string[]} + * @param {Object} data + * - grants: [{subjectType, subject, scope, domain, role}] + * - ownedDomains: string[] (domains the user owns via created_by) + * - authConf: conf.auth ({adminUsers, adminGroups, groupRoleMap}) + * @returns {Object} { isAdmin, global: role|null, domains: {domain: role} } + */ +function resolveEffective(identity, data){ + let username = identity && identity.username; + let groups = (identity && identity.groups) || []; + let grants = (data && data.grants) || []; + let ownedDomains = (data && data.ownedDomains) || []; + let authConf = (data && data.authConf) || {}; + + let result = {isAdmin: false, global: null, domains: {}}; + + // 1) Config-driven global admin (anti-lockout bootstrap). + if((authConf.adminUsers || []).includes(username)) result.isAdmin = true; + for(let g of groups){ + if((authConf.adminGroups || []).includes(g)) result.isAdmin = true; + } + + // 2) Config-driven group role defaults. + let groupRoleMap = authConf.groupRoleMap || {}; + for(let g of groups){ + let m = groupRoleMap[g]; + if(!m) continue; + if(m.role === 'admin' && (m.scope === 'global' || !m.scope)){ + result.isAdmin = true; + }else if(m.scope === 'global'){ + result.global = maxRole(result.global, m.role); + }else if(m.domain){ + result.domains[m.domain] = maxRole(result.domains[m.domain], m.role); + } + } + + // 3) Grant records for this user or any of their groups. + for(let grant of grants){ + let matches = (grant.subjectType === 'user' && grant.subject === username) + || (grant.subjectType === 'group' && groups.includes(grant.subject)); + if(!matches) continue; + + if(grant.scope === 'global'){ + if(grant.role === 'admin') result.isAdmin = true; + else result.global = maxRole(result.global, grant.role); + }else{ + result.domains[grant.domain] = maxRole(result.domains[grant.domain], grant.role); + } + } + + // 4) Ownership: manager rights over every owned domain. + for(let domain of ownedDomains){ + result.domains[domain] = maxRole(result.domains[domain], 'manager'); + } + + return result; +} + +// Effective role on one domain, folding in admin and any global role. +function roleForDomain(effective, domain){ + if(effective.isAdmin) return 'admin'; + return maxRole(effective.global, effective.domains[domain]); +} + +// Does `effective` meet or exceed `minRole` for `domain`? +function allows(effective, minRole, domain){ + return rank(roleForDomain(effective, domain)) >= rank(minRole); +} + +// Domain names the identity can at least view (excludes the global-role case, +// which callers treat as "sees everything"). +function visibleDomains(effective){ + return Object.keys(effective.domains).filter(d => rank(effective.domains[d]) >= rank('viewer')); +} + +module.exports = { + ROLE_RANK, + rank, + maxRole, + resolveEffective, + roleForDomain, + allows, + visibleDomains, +}; diff --git a/nodejs/views/grants.ejs b/nodejs/views/grants.ejs new file mode 100644 index 0000000..d82331c --- /dev/null +++ b/nodejs/views/grants.ejs @@ -0,0 +1,138 @@ +<%- include('top') %> + + + + + + + +<%- include('bottom') %> diff --git a/nodejs/views/login.ejs b/nodejs/views/login.ejs index 858427a..85b9c97 100755 --- a/nodejs/views/login.ejs +++ b/nodejs/views/login.ejs @@ -1,15 +1,22 @@ <%- include('top') %> -