diff --git a/nodejs/app.js b/nodejs/app.js index b64b5d4..a829835 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -7,6 +7,11 @@ const express = require('express'); // Set up the express app. const app = express(); +// The app always runs behind the OpenResty reverse proxy (a single hop) which +// sets X-Real-IP / X-Forwarded-For. Trust that one proxy so req.ip reflects the +// real client — needed for correct per-client rate limiting on /api/auth. +app.set('trust proxy', 1); + // Hold list of functions to run when the server is ready app.onListen = []; 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-lock.json b/nodejs/package-lock.json index 8c88a71..a30d2d1 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -18,6 +18,7 @@ "bootstrap": "^5.3.8", "ejs": "^6.0.1", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "extend": "^3.0.2", "jq-repeat": "^2.0.1", "jquery": "^4.0.0", @@ -917,6 +918,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -1229,6 +1248,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 0ba5ba2..88669bd 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/safe_redirect.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/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/safe_redirect.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" }, "engines": { "node": ">=18.0.0" @@ -29,6 +29,7 @@ "bootstrap": "^5.3.8", "ejs": "^6.0.1", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "extend": "^3.0.2", "jq-repeat": "^2.0.1", "jquery": "^4.0.0", diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index ba4880d..3a7b153 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -76,7 +76,7 @@ app.api = (function(app){ var baseURL = '/api/' function post(url, data, callback){ - if(!$.isFunction(callback)) callback = callback2; + if(typeof callback !== 'function') callback = callback2; return $.ajax({ type: 'POST', url: baseURL+url, @@ -97,7 +97,7 @@ app.api = (function(app){ } function put(url, data, callback){ - if(!$.isFunction(callback)) callback = callback2; + if(typeof callback !== 'function') callback = callback2; return $.ajax({ type: 'PUT', url: baseURL+url, @@ -118,7 +118,7 @@ app.api = (function(app){ } function remove(url, callback, callback2){ - if(!$.isFunction(callback)) callback = callback2; + if(typeof callback !== 'function') callback = callback2; return $.ajax({ type: 'delete', url: baseURL+url, @@ -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,41 @@ app.auth = (function(app){ } } + // Constrain a redirect target to a same-origin absolute path. Rejects + // absolute URLs (open redirect), protocol-relative "//host" and "/\host", + // and non-path schemes like "javascript:" (XSS). Falls back to "/". + function safeInternalPath(path){ + if(typeof path !== 'string' || path.charAt(0) !== '/' + || path.charAt(1) === '/' || path.charAt(1) === '\\'){ + return '/'; + } + return path; + } + + // 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); + // redirect comes from the URL fragment (attacker-controllable); only + // allow a same-origin path so it can't become an open redirect / XSS. + var redirect = safeInternalPath(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){ @@ -214,25 +250,27 @@ app.auth = (function(app){ } function forceLogin(){ - $.holdReady(true); + // jQuery 4 removed $.holdReady; rely on the redirect below to keep an + // unauthenticated user off the page instead of pausing document ready. app.auth.isLoggedIn(function(error, isLoggedIn){ if(error || !isLoggedIn){ app.auth.logOut(function(){}) location.replace(`/login${location.href.replace(location.origin, '')}`); - }else{ - $.holdReady(false); } }); } function logInRedirect(){ - window.location.href = location.href.replace(location.origin+'/login', '') || '/' + window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/') } return { getToken: getToken, setToken: setToken, isLoggedIn: isLoggedIn, + consumeTokenFragment: consumeTokenFragment, + isAdmin: isAdmin, + perms: null, logIn: logIn, logOut: logOut, forceLogin, @@ -270,6 +308,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..9e40523 100755 --- a/nodejs/routes/auth.js +++ b/nodejs/routes/auth.js @@ -1,10 +1,25 @@ 'use strict'; const router = require('express').Router(); +const { rateLimit } = require('express-rate-limit'); +const conf = require('@simpleworkjs/conf'); const { Auth } = require('../models/auth'); +const { OidcState } = require('../models/oidc_state'); +const oidc = require('../utils/oidc'); +const { safeInternalPath } = require('../utils/safe_redirect'); + +// Throttle unauthenticated auth endpoints (credential login + the OIDC +// handshake) to blunt brute-force / callback abuse. Keyed per IP. +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 60, // 60 attempts per IP per window + standardHeaders: true, + legacyHeaders: false, + message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'}, +}); -router.post('/login', async function(req, res, next){ +router.post('/login', authLimiter, async function(req, res, next){ try{ let auth = await Auth.login(req.body); return res.json({ @@ -29,4 +44,68 @@ 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', authLimiter, 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, + // Sanitize now so a hostile ?redirect= can't be stored and later + // reflected into the login page's navigation. + redirect: safeInternalPath(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', authLimiter, 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 = safeInternalPath(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/test/unit/safe_redirect.test.js b/nodejs/test/unit/safe_redirect.test.js new file mode 100644 index 0000000..5a449e0 --- /dev/null +++ b/nodejs/test/unit/safe_redirect.test.js @@ -0,0 +1,43 @@ +'use strict'; + +const {describe, test} = require('node:test'); +const assert = require('node:assert'); + +const {safeInternalPath} = require('../../utils/safe_redirect'); + +/** + * safeInternalPath guards the OIDC post-login redirect against open-redirect + * and script-scheme (XSS) targets. Only same-origin "/path" values pass. + */ +describe('safeInternalPath', () => { + + test('allows plain same-origin paths', () => { + assert.strictEqual(safeInternalPath('/'), '/'); + assert.strictEqual(safeInternalPath('/hosts'), '/hosts'); + assert.strictEqual(safeInternalPath('/dns?x=1'), '/dns?x=1'); + assert.strictEqual(safeInternalPath('/a/b/c#frag'), '/a/b/c#frag'); + }); + + test('rejects absolute URLs', () => { + assert.strictEqual(safeInternalPath('https://evil.com'), '/'); + assert.strictEqual(safeInternalPath('http://evil.com/x'), '/'); + }); + + test('rejects protocol-relative and backslash host tricks', () => { + assert.strictEqual(safeInternalPath('//evil.com'), '/'); + assert.strictEqual(safeInternalPath('/\\evil.com'), '/'); + }); + + test('rejects script / data schemes', () => { + assert.strictEqual(safeInternalPath('javascript:alert(1)'), '/'); + assert.strictEqual(safeInternalPath('data:text/html, + + + + + + +<%- 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') %> -