Merge pull request #107 from theta42/feat/oidc-authz

OIDC login + per-domain authorization
This commit is contained in:
2026-07-10 13:26:21 -04:00
committed by GitHub
32 changed files with 1451 additions and 58 deletions
+5
View File
@@ -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 = [];
+34
View File
@@ -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,
+2
View File
@@ -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);
+118
View File
@@ -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,
};
+40
View File
@@ -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);
}
})();
+23 -1
View File
@@ -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.
*
+112
View File
@@ -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};
+2
View File
@@ -10,3 +10,5 @@ require('./dns_provider');
require('./host');
require('./token');
require('./user');
require('./grant');
require('./oidc_state');
+31
View File
@@ -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};
+18
View File
@@ -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();
+15
View File
@@ -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;
}
+23
View File
@@ -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);
+28
View File
@@ -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",
+4 -3
View File
@@ -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",
+69 -8
View File
@@ -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){
+8 -1
View File
@@ -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;
+80 -1
View File
@@ -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;
+2 -1
View File
@@ -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){
+21 -13
View File
@@ -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();
+42
View File
@@ -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;
+20 -13
View File
@@ -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();
+9
View File
@@ -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/<path>.
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});
});
+26 -9
View File
@@ -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
});
+77
View File
@@ -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']);
});
});
+173
View File
@@ -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);
});
});
+43
View File
@@ -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,<script>'), '/');
});
test('rejects non-path and non-string input', () => {
assert.strictEqual(safeInternalPath('hosts'), '/'); // no leading slash
assert.strictEqual(safeInternalPath(''), '/');
assert.strictEqual(safeInternalPath(undefined), '/');
assert.strictEqual(safeInternalPath(null), '/');
assert.strictEqual(safeInternalPath({}), '/');
});
});
+125
View File
@@ -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,
};
+110
View File
@@ -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,
};
+23
View File
@@ -0,0 +1,23 @@
'use strict';
/**
* Constrain a post-login redirect target to a same-origin path.
*
* Rejects anything that could leave the site or execute script:
* - absolute URLs ("https://evil.com") -> not a "/" path
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
* - scheme targets ("javascript:...", "data:...") -> XSS
* Anything not a plain "/path" falls back to "/".
*
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
* two in sync.
*/
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
module.exports = {safeInternalPath};
+138
View File
@@ -0,0 +1,138 @@
<%- include('top') %>
<script type="text/javascript">
// Require login to see this page. The API is admin-only; non-admins get 403s.
app.auth.forceLogin();
</script>
<style type="text/css">
label.control-label{
font-weight: bold;
margin-bottom: 1px;
}
.card-title{
font-weight: bold;
}
</style>
<script type="text/javascript">
function populateGrants(){
app.grant.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.grants.$this, 'danger');
for(let grant of data.results){
$.scope.grants.push(grant);
}
});
}
function removeGrant(id){
app.grant.remove(id, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.grants.$this, 'danger');
$.scope.grants.remove(id);
});
}
$(document).ready(function(){
populateGrants();
$.scope.grants.__setTake(function($el, item, list){
$el.addClass('bg-danger');
$el.fadeOut(1000, function(){ $el.remove(); });
});
});
</script>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-user-shield"></i>
</span>
<span class="card-title">Add Grant</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="grant/" onsubmit="formAJAX(this)" evalAJAX="
$.scope.grants.remove(data.id);
$.scope.grants.splice(0, 0, data);
">
<div class="form-group">
<label class="control-label">Subject type</label>
<select class="form-control" name="subjectType">
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Subject (username or group)</label>
<input type="text" class="form-control" name="subject" placeholder="alice or dns-team" />
</div>
<div class="form-group">
<label class="control-label">Scope</label>
<select class="form-control" name="scope">
<option value="domain">Domain</option>
<option value="global">Global</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Domain (for domain scope)</label>
<input type="text" class="form-control" name="domain" placeholder="example.com" />
</div>
<div class="form-group">
<label class="control-label">Role</label>
<select class="form-control" name="role">
<option value="viewer">Viewer (read)</option>
<option value="manager">Manager (full over domain)</option>
<option value="admin">Admin (global only)</option>
</select>
</div>
<hr />
<button type="submit" class="btn btn-info">Add Grant</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-list-check"></i>
</span>
<span class="card-title">Grants</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Type</th>
<th>Subject</th>
<th>Scope</th>
<th>Domain</th>
<th>Role</th>
<th>Delete</th>
</thead>
<tbody>
<tr jq-repeat="grants" jq-repeat-index="id" style="display:none">
<td class="align-middle">{{ subjectType }}</td>
<td class="align-middle">{{ subject }}</td>
<td class="align-middle">{{ scope }}</td>
<td class="align-middle">{{ domain }}</td>
<td class="align-middle">{{ role }}</td>
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removeGrant('{{id}}')">
<i class="fa-solid fa-trash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<%- include('bottom') %>
+20 -6
View File
@@ -1,15 +1,22 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.isLoggedIn(function(error, isLoggedIn){
if(isLoggedIn){
app.auth.logInRedirect();
}
})
// If we arrived from the OIDC callback with a token in the URL fragment,
// store it and forward on before doing anything else.
if(!app.auth.consumeTokenFragment()){
app.auth.isLoggedIn(function(error, isLoggedIn){
if(isLoggedIn){
app.auth.logInRedirect();
}else{
// Reveal the login card once we know the user is not logged in.
document.getElementById('login-card-row').style.display = '';
}
})
}
</script>
<div class="row" style="display: none;">
<div id="login-card-row" class="row" style="display: none;">
<div class="col-md-4">
<div class="shadow-lg card">
@@ -74,6 +81,13 @@
<hr />
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-right-to-bracket"></i> Log in</button>
</form>
<hr />
<div class="d-grid">
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
<i class="fa-solid fa-id-badge"></i> Log in with SSO
</a>
</div>
</div>
</div>
</div>
+10 -2
View File
@@ -51,11 +51,16 @@
DNS
</a>
</li>
<li class="nav-item">
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
Users
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/grants"><i class="fa-solid fa-user-shield"></i>
Grants
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://github.com/theta42/proxy" target="_blank">
<i class="fa-brands fa-github"></i>
@@ -87,10 +92,13 @@
}
})
// Set the correct login/logout button
// Set the correct login/logout button, and reveal admin-only nav
// items for global admins.
app.auth.isLoggedIn(function(error, data){
if(data) $('#cl-logout-button').show();
else $('#cl-login-button').show();
if(data && data.isAdmin) $('.nav-admin').css('display', '');
});
});