diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d9abe6..b066dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. +## [1.5.0] - 2026-07-26 + +### Changed +- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all. +- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape. +- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request. +- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`. +- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`). +- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`. + +### Fixed +- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`. +- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback. +- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready. +- **`logInRedirect` on the legacy `/login/` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string. + +### Fixed (sso-manager-node) +- `public/lib/js/val.js` shadowed `message` with `let` inside `validateField`, so a custom rule's return value never reached `validateMessage` and the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings the `target`/`hostname` rules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app. +- `public/js/app.js` used `$.isFunction`, removed in jQuery 4. + +### Added (sso-manager-node) +- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`. + +### Verified +- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients. + ## [1.4.0] - 2026-07-25 ### Security diff --git a/nodejs/app.js b/nodejs/app.js index d4c3143..faeb43a 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -61,6 +61,11 @@ app.set('trust proxy', 1); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); +// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs). +// Set as an app local so every res.render has it, including routes that don't +// spread the routers' `values` object. +app.locals.ui = require('./utils/ui'); + // Have express server static content( images, CSS, browser JS) from the public // local folder. maxAge is short since this is the app's own JS/CSS, which // changes on every deploy and isn't cache-busted/fingerprinted. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 385b615..50581dc 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.3.2", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.3.2", + "version": "1.5.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", @@ -24,7 +24,7 @@ "express-rate-limit": "^8.5.2", "extend": "^3.0.2", "jq-repeat": "^2.2.0", - "jquery": "^3.7.1", + "jquery": "^4.0.0", "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.8", "lru-cache": "^11.5.1", @@ -4853,9 +4853,9 @@ } }, "node_modules/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz", + "integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==", "license": "MIT" }, "node_modules/js-tokens": { diff --git a/nodejs/package.json b/nodejs/package.json index 948511f..ae51b96 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.4.0", + "version": "1.5.0", "description": "A very simple LDAP management and SSO system", "author": [ { @@ -36,7 +36,7 @@ "express-rate-limit": "^8.5.2", "extend": "^3.0.2", "jq-repeat": "^2.2.0", - "jquery": "^3.7.1", + "jquery": "^4.0.0", "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.8", "lru-cache": "^11.5.1", diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index c77cb04..97d7e3a 100755 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -396,7 +396,7 @@ app.impersonate = (function(app){ app.token = (function(app){ function list(name, callack){ - if($.isFunction(name)){ + if(typeof name === 'function'){ callack = name; name = ''; } diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 125717b..7aa874d 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -1,3 +1,12 @@ +// Shared client framework for the theta42 apps. +// +// This file is byte-identical across sso-manager-node, proxy and jump-host — +// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and +// the /api/user/me response), never from edits to this file. Edit all three +// copies together. +// +// jQuery 4 safe: no $.isFunction, no $.holdReady. + var app = {}; app.pubsub = (function(){ @@ -45,7 +54,7 @@ app.pubsub = (function(){ app.socket = (function(app){ // $.getScript('/socket.io/socket.io.js') // - + var socket; $(document).ready(function(){ socket = io({ @@ -75,11 +84,17 @@ app.socket = (function(app){ app.api = (function(app){ var baseURL = '/api/' - function post(url, data, callback){ - if (!$.isFunction(callback)) { - return new Promise((resolve, reject) => { + // post/put/delete are dual-mode: pass a callback for the node-style + // (error, data, status) form, or omit it to get a Promise that resolves + // with the parsed body and rejects with the error body. get/options return + // the jqXHR, which is itself thenable, so `await app.api.get(...)` works. + + function body(method, url, data, callback){ + if(typeof callback !== 'function'){ + return new Promise(function(resolve, reject){ $.ajax({ - type: 'POST', url: baseURL+url, + type: method, + url: baseURL+url, headers: { 'auth-token': app.auth.getToken() }, data: JSON.stringify(data), contentType: 'application/json; charset=utf-8', @@ -88,9 +103,11 @@ app.api = (function(app){ }); } return $.ajax({ - type: 'POST', + type: method, url: baseURL+url, - headers:{ 'auth-token': app.auth.getToken() }, + headers:{ + 'auth-token': app.auth.getToken() + }, data: JSON.stringify(data), contentType: "application/json; charset=utf-8", dataType: "json", @@ -104,40 +121,27 @@ app.api = (function(app){ }); } + function post(url, data, callback){ + return body('POST', url, data, callback); + } + function put(url, data, callback){ - if (!$.isFunction(callback)) { - return new Promise((resolve, reject) => { - $.ajax({ - type: 'PUT', url: baseURL+url, - headers: { 'auth-token': app.auth.getToken() }, - data: JSON.stringify(data), - contentType: 'application/json; charset=utf-8', - dataType: 'json', - }).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); }); - }); - } - return $.ajax({ - type: 'PUT', - url: baseURL+url, - headers:{ 'auth-token': app.auth.getToken() }, - data: JSON.stringify(data), - contentType: "application/json; charset=utf-8", - dataType: "json", - complete: function(res, text){ - callback( - text !== 'success' ? res.statusText : null, - JSON.parse(res.responseText), - res.status - ); - } - }); + return body('PUT', url, data, callback); } - function remove(url, callback){ - if (!$.isFunction(callback)) { - return new Promise((resolve, reject) => { + // Called both as (url, callback) and — from formAJAX, which always passes + // the serialized form as the second argument — as (url, data, callback). + // No request body is sent either way. + function remove(url, data, callback){ + if(typeof data === 'function'){ + callback = data; + data = undefined; + } + if(typeof callback !== 'function'){ + return new Promise(function(resolve, reject){ $.ajax({ - type: 'DELETE', url: baseURL+url, + type: 'DELETE', + url: baseURL+url, headers: { 'auth-token': app.auth.getToken() }, contentType: 'application/json; charset=utf-8', dataType: 'json', @@ -147,7 +151,9 @@ app.api = (function(app){ return $.ajax({ type: 'DELETE', url: baseURL+url, - headers:{ 'auth-token': app.auth.getToken() }, + headers:{ + 'auth-token': app.auth.getToken() + }, contentType: "application/json; charset=utf-8", dataType: "json", complete: function(res, text){ @@ -202,7 +208,10 @@ app.api = (function(app){ })(app) app.auth = (function(app){ - var user = {}; + // One in-flight/cached GET /api/user/me per page load. Every gating + // decision (nav items, per-view forceLogin, group-required elements) reads + // this same promise instead of re-fetching. + var userPromise = null; function setToken(token){ localStorage.setItem('APIToken', token); @@ -216,35 +225,70 @@ app.auth = (function(app){ try{ return await app.api.get('user/me'); }catch(error){ - if(error?.status === 401) return null; - throw error + if(error && error.status === 401) return null; + throw error; } } + // Cached current user, or false when there's no token at all. Callers that + // need a fresh copy (after a login or a profile change) pass force. + function loadUser(force){ + if(force || !userPromise){ + userPromise = getToken() ? getUser() : Promise.resolve(null); + userPromise = userPromise.then(function(user){ + app.auth.user = app.auth.perms = user || null; + return user; + }); + } + return userPromise; + } + + // The apps report group membership two ways: sso-manager-node returns LDAP + // DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both + // normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag) + // is exposed as a synthetic `admin` group so one gating model covers both. + function groupCNs(user){ + var raw = (user && (user.memberOf || user.groups)) || []; + if(!Array.isArray(raw)) raw = [raw]; + var names = raw.map(function(group){ + return String(group).split(',')[0].replace(/^cn=/i, ''); + }); + if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin'); + return names; + } + async function memberOf(groupNameToFind, user){ - try{ - user = user || await app.auth.asyncUser; - groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind] + user = user || await loadUser(); + if(!user) return false; + groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]; - for(let group of user.memberOf){ - group = group.split(',ou=groups')[0].replace('cn=', ''); - if(groupNameToFind.includes(group)) return true; - } - - return false; - - }catch(error){ - throw(error); - } + return groupCNs(user).some(function(group){ + return groupNameToFind.includes(group); + }); } - async function isLoggedIn(){ - if(getToken()){ - user = await app.auth.asyncUser; - return user; - }else{ - return false; + // True when the logged-in user is a global admin (per user/me). Sync — only + // meaningful once isLoggedIn/forceLogin has resolved. + function isAdmin(){ + return !!(app.auth.perms && app.auth.perms.isAdmin); + } + + // Dual-mode: returns a Promise resolving to the user (or false), and calls + // an optional node-style callback with the same result. + function isLoggedIn(callback){ + var promise = loadUser().then(function(user){ + return user || false; + }); + + if(typeof callback === 'function'){ + promise.then(function(user){ + callback(null, user); + }, function(error){ + callback(error, false); + }); } + + return promise; } function logIn(args, callback){ @@ -252,62 +296,125 @@ app.auth = (function(app){ if(data.login){ setToken(data.token); } + loadUser(true); callback(error, !!data.token); }); } + // Clears the session only — the caller decides where to go next (the nav's + // Log Out button uses ui.logoutRedirect). function logOut(callback){ localStorage.removeItem('APIToken'); - location.replace(`/login${location.href.replace(location.origin, '')}`); - callback(); + userPromise = null; + app.auth.user = app.auth.perms = null; + if(typeof callback === 'function') callback(); } + // 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; + } + + // Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or + // unauthorised user is kept off the page by a redirect / an error panel + // rather than by pausing document ready. + // + // `requiredGroups` is a group CN or an OR-list of them; the synthetic + // `admin` group covers the OIDC clients' isAdmin flag. async function forceLogin(requiredGroups){ - $.holdReady(true); - if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){}); + var user = await loadUser(); + + if(!user){ + logOut(function(){}); + location.replace('/login?redirect=' + encodeURIComponent( + location.pathname + location.search + )); + return false; + } if(user.onboardingRequired && location.pathname !== '/onboarding'){ location.replace('/onboarding'); + return false; } - if(requiredGroups){ - if(!await memberOf(requiredGroups)){ - console.log("Does not have permission!!!") - app.util.actionMessage( - `

- - You do not have permission to be here. - -

`, - $('#spa-shell'), - 'danger', - ); - throw new Error("User does not have permission"); - } + if(requiredGroups && !await memberOf(requiredGroups, user)){ + app.util.actionMessage( + `

+ + You do not have permission to be here. + +

`, + $('#spa-shell'), + 'danger', + ); + throw new Error("User does not have permission"); } - $.holdReady(false); + return user; } + // Where to go after a successful login: the ?redirect= query param, or the + // legacy /login/ suffix form, constrained to a same-origin path. The + // suffix form keeps its query string — /login/oauth/authorize?client_id=… + // is how the OIDC provider sends an unauthenticated user through login. function logInRedirect(){ - window.location.href = location.href.replace(location.origin+'/login', '') || '/' + var params = new URLSearchParams(location.search); + var target = params.get('redirect') + || location.href.replace(location.origin + '/login', '') + || '/'; + window.location.href = safeInternalPath(target); } return { getToken: getToken, setToken: setToken, + getUser: getUser, + loadUser: loadUser, + groupCNs: groupCNs, + memberOf: memberOf, + isAdmin: isAdmin, isLoggedIn: isLoggedIn, + safeInternalPath: safeInternalPath, + consumeTokenFragment: consumeTokenFragment, + user: null, + perms: null, logIn: logIn, logOut: logOut, forceLogin, logInRedirect, - getUser, - memberOf, } })(app); -app.auth.asyncUser = app.auth.getUser(); +// Back-compat alias for views that awaited the cached user directly. +Object.defineProperty(app.auth, 'asyncUser', { + get: function(){ return app.auth.loadUser(); }, +}); app.user = (function(app){ function list(callback){ @@ -338,6 +445,72 @@ app.user = (function(app){ })(app); +// Local (app-managed) permissions and groups. Only the OIDC-client apps serve +// these endpoints; the calls are inert elsewhere. +app.permission = (function(app){ + function list(callback){ + app.api.get('permission/', function(error, data){ + callback(error, data); + }); + } + + function subjects(callback){ + app.api.get('permission/subjects', function(error, data){ + callback(error, data); + }); + } + + function add(args, callback){ + app.api.post('permission/', args, function(error, data){ + callback(error, data); + }); + } + + function remove(id, callback){ + app.api.delete('permission/' + encodeURIComponent(id), function(error, data){ + callback(error, data); + }); + } + + return {list, subjects, add, remove}; + +})(app); + +app.group = (function(app){ + function list(callback){ + app.api.get('group/', function(error, data){ + callback(error, data); + }); + } + + function add(args, callback){ + app.api.post('group/', args, function(error, data){ + callback(error, data); + }); + } + + function remove(name, callback){ + app.api.delete('group/' + encodeURIComponent(name), function(error, data){ + callback(error, data); + }); + } + + function addMember(name, username, callback){ + app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){ + callback(error, data); + }); + } + + function removeMember(name, username, callback){ + app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){ + callback(error, data); + }); + } + + return {list, add, remove, addMember, removeMember}; + +})(app); + app.util = (function(app){ function getUrlParameter(name){ @@ -370,7 +543,10 @@ app.util = (function(app){ }else{ if(type) $target.addClass('bg-' + type); - if(!message.includes('${message} @@ -415,8 +591,11 @@ app.util = (function(app){ for (let {name, value} of $(this).serializeArray()) { console.log(name, value) if (obj[name] === undefined) { - if (!value + if (!value && !$(this).parent().find(`[name="${name}"]`).attr('value') + // Keep empty