From f4efdfb9577a5f7c1c34f8a1c7c25d36b41f11a5 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 25 Jul 2026 15:53:30 -0400 Subject: [PATCH] Release 1.3.0: adopt shared @simpleworkjs/* packages; fix LDAP filter injection Rewire onto the shared @simpleworkjs/oidc-client, /ldap, and /app-stack packages (deleting the byte-identical local forks of the same code), close the LDAP filter-injection in User.get by routing the username through escapeFilter (RFC 4515), align model-redis ^1.6.0 and ldapts ^8.1.8, and unify build_info to {buildVersion, buildHash, buildYear}. package-lock regenerated from the npm registry (no file:/link:), so npm ci is clean in docker builds. Co-Authored-By: Claude --- CHANGELOG.md | 15 +++ nodejs/middleware/auth.js | 2 +- nodejs/models/auth.js | 136 ------------------------- nodejs/models/index.js | 27 ++++- nodejs/models/oidc_state.js | 31 ------ nodejs/models/token.js | 64 ------------ nodejs/models/user_ldap.js | 6 +- nodejs/models/user_pam.js | 1 - nodejs/models/user_redis.js | 40 +------- nodejs/package-lock.json | 49 ++++++++- nodejs/package.json | 7 +- nodejs/routes/api.js | 2 +- nodejs/routes/auth.js | 111 -------------------- nodejs/routes/host_auth.js | 5 +- nodejs/routes/render.js | 21 ++-- nodejs/test/unit/oidc.test.js | 2 +- nodejs/test/unit/safe_redirect.test.js | 2 +- nodejs/utils/build_info.js | 37 +++---- nodejs/utils/oidc.js | 127 ----------------------- nodejs/utils/safe_redirect.js | 23 ----- 20 files changed, 121 insertions(+), 587 deletions(-) delete mode 100644 nodejs/models/auth.js delete mode 100644 nodejs/models/oidc_state.js delete mode 100644 nodejs/models/token.js delete mode 100755 nodejs/routes/auth.js delete mode 100644 nodejs/utils/oidc.js delete mode 100644 nodejs/utils/safe_redirect.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e592c31..8e45db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. ## [Unreleased] +## [1.3.0] - 2026-07-25 + +### Added +- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org, replacing this app's byte-identical forks of the same code so the theta42 apps share one codebase and API schema: + - `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, and `routes/auth.js`; `models/index.js` now wires the factory. The per-host SSO in `routes/host_auth.js` is unchanged but consumes the shared OIDC utils. + - `@simpleworkjs/ldap` — the ldapts client + RFC 4515/4514 escaping. + - `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/render.js` now use the shared helpers. + +### Security +- **LDAP filter injection in `User.get`.** The user lookup built its search filter by interpolating `data.username` raw into `(&(objectClass=inetOrgPerson)(uid=))`. A username containing `*`, `(`, `)`, `\`, or NUL could widen or alter the filter (e.g. `*` → match-all). The filter value is now passed through `escapeFilter` from `@simpleworkjs/ldap` (RFC 4515 escaping). + +### Changed +- Dependency alignment: `model-redis` `^1.5` → `^1.6.0`, `ldapts` `^8.1.2` → `^8.1.8`. The four new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds. +- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`. + ## [1.2.2] - 2026-07-21 ### Fixed diff --git a/nodejs/middleware/auth.js b/nodejs/middleware/auth.js index 29baf4d..38a991c 100755 --- a/nodejs/middleware/auth.js +++ b/nodejs/middleware/auth.js @@ -1,6 +1,6 @@ 'use strict'; -const {Auth} = require('../models/auth'); +const {Auth} = require('../models'); async function auth(req, res, next){ try{ diff --git a/nodejs/models/auth.js b/nodejs/models/auth.js deleted file mode 100644 index bf1efdc..0000000 --- a/nodejs/models/auth.js +++ /dev/null @@ -1,136 +0,0 @@ -'use strict'; - -const Table = require('../models'); -const {User, AuthToken} = Table.models; -const {ApiToken} = require('./api_token'); - -/** - * Auth Model - * - * Handles authentication operations for the application. - * Manages user login, token validation, and logout processes. - * - * Dependencies: - * - User model: Validates user credentials - * - AuthToken model: Creates and manages authentication tokens - * - * All methods throw standardized login errors on failure to avoid - * leaking information about whether usernames exist or tokens are valid. - */ -class Auth{ - /** - * Standardized error responses for authentication failures. - * Returns generic "Invalid Credentials" message for security. - */ - static errors = { - login: function(){ - let error = new Error('LoginFailed'); - error.name = 'LoginFailed'; - error.message = `Invalid Credentials, login failed.`; - error.status = 401; - - return error; - } - } - - /** - * Authenticate user and create session token. - * - * @param {Object} data - Login credentials {username, password} - * @returns {Object} {user, token} - User object and auth token - * @throws {Error} Generic login error on any failure - * - * Flow: - * 1. Validate credentials via User.login() - * 2. Create new AuthToken for the user - * 3. Return both user data and token - */ - static async login(data){ - try{ - let user = await User.login(data); - // 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){ - console.log('login error', error); - throw this.errors.login(); - } - } - - /** - * 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. - * - * @param {string} token - Token string to validate - * @returns {Object} Token object if valid - * @throws {Error} Generic login error if token invalid or expired - * - * Checks: - * 1. Token exists in database - * 2. Token has not expired (via token.check()) - */ - static async checkToken(token){ - try{ - token = await AuthToken.get(token); - if(token && token.check()) return token; - - throw this.errors.login(); - }catch(error){ - console.log('check error', error); - throw this.errors.login(); - } - } - - /** - * Validate an `Authorization: Bearer prx__` API token. - * - * Returns the authenticated ApiToken record (with created_by + the - * mint-time groups snapshot); middleware/auth.js wraps it into the - * req.token shape the authz layer expects. Every failure collapses to the - * same generic login 401 — no leak of existence / wrong secret / expired. - */ - static async checkApiToken(raw){ - try{ - return await ApiToken.authenticate(raw); - }catch(error){ - console.log('api-token check error', error); - throw this.errors.login(); - } - } - - /** - * Destroy an authentication token (logout). - * - * @param {string} data - Token string to destroy - * @returns {void} - * - * Removes token from database, invalidating the session. - */ - static async logout(data){ - let token = await AuthToken.get(data); - await token.destroy(); - } -} - -module.exports = {Auth}; diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 71a5f68..a767ca0 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -1,18 +1,37 @@ 'use strict'; const conf = require('@simpleworkjs/conf'); const {setUpTable} = require('model-redis'); +const {createOidcClient, bootstrapLocalAdmin} = require('@simpleworkjs/oidc-client'); const Table = setUpTable(conf.redis); module.exports = Table; +// App-local models. User + ApiToken register before the OIDC client factory +// below: Auth binds User, and checkApiToken wraps ApiToken.authenticate. +require('./user'); // User (redis-backed local + OIDC JIT) +const {ApiToken} = require('./api_token'); // ApiToken (Bearer PATs) require('./dns_provider'); require('./dynamic_record'); require('./host'); -require('./token'); -require('./user'); require('./local_group'); require('./permission'); -require('./oidc_state'); require('./sso_session'); -require('./api_token'); + +// Shared OIDC client (authorization-code + PKCE): session models (Token, +// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start +// /oidc/callback router — all created on this app's Table/redis. PAT validation +// is wired in (proxy accepts Bearer prx__); the package collapses +// every checkApiToken failure to a generic 401. +const oidcClient = createOidcClient({ + Table, + checkApiToken: (raw) => ApiToken.authenticate(raw), +}); +module.exports.Token = oidcClient.Token; +module.exports.AuthToken = oidcClient.AuthToken; +module.exports.OidcState = oidcClient.OidcState; +module.exports.Auth = oidcClient.Auth; +module.exports.authRouter = oidcClient.router; + +// Idempotent anti-lockout local admin (was the IIFE in user_redis.js). +bootstrapLocalAdmin(Table.models.User, {defaultName: 'proxyadmin2'}); \ No newline at end of file diff --git a/nodejs/models/oidc_state.js b/nodejs/models/oidc_state.js deleted file mode 100644 index d46ede4..0000000 --- a/nodejs/models/oidc_state.js +++ /dev/null @@ -1,31 +0,0 @@ -'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 deleted file mode 100644 index f497e36..0000000 --- a/nodejs/models/token.js +++ /dev/null @@ -1,64 +0,0 @@ -'use strict'; - -const Table = require('.'); -const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}; - - -class Token extends Table{ - static _key = 'token'; - static _keyMap = { - 'created_by': {isRequired: true, type: 'string', min: 3, max: 500}, - 'created_on': {default: function(){return (new Date).getTime()}}, - 'updated_on': {default: function(){return (new Date).getTime()}, always: true}, - 'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true}, - 'is_valid': {default: true, type: 'boolean'}, - } - - constructor(...args){ - super(...args); - } - - async check(){ - try{ - return this.is_valid; - }catch(error){ - return false - } - } -} - -Token.register(); - -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(); - -module.exports = {Token, AuthToken}; diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index d7b99c3..091deb3 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -1,8 +1,8 @@ 'use strict'; const { Client, Attribute, Change } = require('ldapts'); -const {Token} = require('./token'); const conf = require('@simpleworkjs/conf').ldap; +const { escapeFilter } = require('@simpleworkjs/ldap'); // tlsOptions is optional and forwarded to ldapts so the proxy can bind to // ldaps:// with a self-signed or internal-CA cert. Set via conf/secrets.js or @@ -107,7 +107,9 @@ User.get = async function(data){ await client.bind(conf.bindDN, conf.bindPassword); - let filter = `(&${conf.userFilter}(${conf.userNameAttribute}=${data.username}))`; + // Escape the interpolated username (RFC 4515) — previously raw, which + // let `*`/`(`/`)`/`\`/NUL in a username break or broaden the filter. + let filter = `(&${conf.userFilter}(${conf.userNameAttribute}=${escapeFilter(data.username)}))`; const res = await client.search(conf.searchBase, { scope: 'sub', diff --git a/nodejs/models/user_pam.js b/nodejs/models/user_pam.js index 3c1589f..9ff7ad7 100644 --- a/nodejs/models/user_pam.js +++ b/nodejs/models/user_pam.js @@ -2,7 +2,6 @@ const linuxUser = require('linux-sys-user').promise(); const objValidate = require('../utils/object_validate'); -const {Token} = require('./token'); const {promisify} = require('util'); const pam = require('authenticate-pam'); const authenticate = promisify(pam.authenticate); diff --git a/nodejs/models/user_redis.js b/nodejs/models/user_redis.js index c543197..6737028 100644 --- a/nodejs/models/user_redis.js +++ b/nodejs/models/user_redis.js @@ -3,7 +3,6 @@ const Table = require('.'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); -const conf = require('@simpleworkjs/conf'); const saltRounds = 10; class User extends Table{ @@ -86,39 +85,6 @@ class User extends Table{ User.register(); -(async function(){ - // Matches migrations/permission_bootstrap.js: the anti-lockout account is - // the first entry in conf.auth.adminUsers (default 'proxyadmin2'), NOT a - // hardcoded name -- otherwise an operator who customizes adminUsers ends - // up with a bootstrap account that has no admin permissions. - var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'proxyadmin2'; - // Optional: an orchestrator (e.g. theta-env's setup.sh) can set - // auth.localAdminPass in proxy-secrets.js to a generated password so this - // bootstrap account isn't left at a well-known default. Only used on first - // creation -- once the account exists this is never read again, so it's - // safe to leave set. If unset, a random password is generated and printed - // once; save it from the log or set auth.localAdminPass explicitly. - var defaultPass = (conf.auth && conf.auth.localAdminPass); - if (!defaultPass) { - defaultPass = crypto.randomBytes(16).toString('hex'); - console.warn(`====================================================================`); - console.warn(`Bootstrap admin "${defaultUser}" created with random password:`); - console.warn(`${defaultPass}`); - console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`); - console.warn(`====================================================================`); - } - try{ - let user = await User.get(defaultUser); - }catch(error){ - try{ - let user = await User.create({ - username:defaultUser, - password: defaultPass, - created_by: defaultUser - }); - console.log(defaultUser, 'created'); - }catch(error){ - console.error(error) - } - } -})(); \ No newline at end of file +// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client +// (bootstrapLocalAdmin); invoked once from models/index.js after User is +// registered. See the package lib/bootstrap.js for the original logic. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index e691f1f..3cd3efb 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -11,7 +11,10 @@ "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", + "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", + "@simpleworkjs/ldap": "^1.0.0", + "@simpleworkjs/oidc-client": "^1.0.0", "acme-client": "^5.4.0", "axios": "^1.13.5", "bcrypt": "^6.0.0", @@ -26,7 +29,7 @@ "ldapts": "^8.1.8", "linux-sys-user": "^1.2.0", "marked": "^9.1.6", - "model-redis": "^1.5.0", + "model-redis": "^1.6.0", "moment": "^2.30.1", "mustache": "^4.2.0", "p2psub": "^0.2.0", @@ -281,6 +284,18 @@ "@redis/client": "^6.1.0" } }, + "node_modules/@simpleworkjs/app-stack": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz", + "integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==", + "license": "MIT", + "dependencies": { + "express": "^5.2.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@simpleworkjs/conf": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz", @@ -293,6 +308,32 @@ "node": ">=16.0.0" } }, + "node_modules/@simpleworkjs/ldap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz", + "integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==", + "license": "MIT", + "dependencies": { + "ldapts": "^8.1.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@simpleworkjs/oidc-client": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@simpleworkjs/oidc-client/-/oidc-client-1.0.0.tgz", + "integrity": "sha512-AzxIaE32p4yKDlp0mWvZp1wmXi8tMFckcPwMiQyZrDgEC0IybGhbjppPa+vNGx1AoVLp64vRL/zR3yXb/19NPg==", + "license": "MIT", + "dependencies": { + "@simpleworkjs/conf": "^1.2.0", + "express": "^5.2.1", + "express-rate-limit": "^8.5.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -1508,9 +1549,9 @@ } }, "node_modules/model-redis": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.5.0.tgz", - "integrity": "sha512-eVXQQN+k3cR5aJBvPQurgr8WXYpAvVoLu6ydMWenSOJZBDVcHqeNqUCp8n5lYrxv6iZ8PlF0WQzZFmbJ1zP6/A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.6.0.tgz", + "integrity": "sha512-QinykZ0H9vhyj0qY9NnNd1m1YcBXBXuc0viENJuEyTwplhOWT0cpjpGxObqoD1ysb8AJkrBkJB9tAf4EZbISWA==", "license": "MIT", "dependencies": { "redis": "^6.1.0" diff --git a/nodejs/package.json b/nodejs/package.json index d7d1788..4e94f99 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "proxy-api", - "version": "1.2.2", + "version": "1.3.0", "author": [ { "name": "William Mantly", @@ -22,6 +22,9 @@ "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", "@simpleworkjs/conf": "^1.2.0", + "@simpleworkjs/app-stack": "^1.0.0", + "@simpleworkjs/ldap": "^1.0.0", + "@simpleworkjs/oidc-client": "^1.0.0", "acme-client": "^5.4.0", "axios": "^1.13.5", "bcrypt": "^6.0.0", @@ -36,7 +39,7 @@ "ldapts": "^8.1.8", "linux-sys-user": "^1.2.0", "marked": "^9.1.6", - "model-redis": "^1.5.0", + "model-redis": "^1.6.0", "moment": "^2.30.1", "mustache": "^4.2.0", "p2psub": "^0.2.0", diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index e9c57e2..af56a6e 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -6,7 +6,7 @@ const middleware = require('../middleware/auth'); const authz = require('../middleware/authz'); // API routes for authentication. -router.use('/auth', require('./auth')); +router.use('/auth', require('../models').authRouter); // 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 diff --git a/nodejs/routes/auth.js b/nodejs/routes/auth.js deleted file mode 100755 index 9e40523..0000000 --- a/nodejs/routes/auth.js +++ /dev/null @@ -1,111 +0,0 @@ -'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', authLimiter, async function(req, res, next){ - try{ - let auth = await Auth.login(req.body); - return res.json({ - login: true, - token: auth.token.token, - message:`${req.body.username} logged in!`, - }); - }catch(error){ - next(error); - } -}); - -router.all('/logout', async function(req, res, next){ - try{ - if(req.user){ - await req.user.logout(); - } - - res.json({message: 'Bye'}) - }catch(error){ - next(error); - } -}); - -/** - * 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/host_auth.js b/nodejs/routes/host_auth.js index 751f591..b2ccdd4 100644 --- a/nodejs/routes/host_auth.js +++ b/nodejs/routes/host_auth.js @@ -3,7 +3,8 @@ /** * Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host * (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the - * OIDC authorization-code flow (reusing utils/oidc.js and conf.oidc) and, on a + * OIDC authorization-code flow (reusing @simpleworkjs/oidc-client's pure oidc + * utils and conf.oidc) and, on a * successful + authorized login, mint a Redis-backed SsoSession and set the * `__proxy_sso` cookie for the host. OpenResty then gates the host on that * session (ops/nginx_conf/hostfeatures.lua). @@ -15,7 +16,7 @@ const router = require('express').Router(); const conf = require('@simpleworkjs/conf'); -const oidc = require('../utils/oidc'); +const {oidc} = require('@simpleworkjs/oidc-client'); const {Host} = require('../models').models; const {HostSsoState, SsoSession} = require('../models/sso_session'); const {identityAllowed} = require('../utils/host_sso'); diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index a5c8746..60fd214 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -5,6 +5,7 @@ const express = require('express'); const router = require('express').Router(); const conf = require('@simpleworkjs/conf'); const buildInfo = require('../utils/build_info'); +const { mountStaticModules } = require('@simpleworkjs/app-stack'); const values ={ title: conf.environment !== 'production' ? `dev` : '', @@ -15,24 +16,16 @@ const values ={ } // List of front end node modules to be served -const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome', - 'moment', '@popper', 'jq-repeat', -]; - -// Server front end modules -// https://stackoverflow.com/a/55700773/3140931 // Vendor libraries only change when package versions are bumped (a rebuild), // so they're safe to cache aggressively; ETag/Last-Modified (on by default) -// still cover that rare case with a cheap 304 instead of a stale asset. -frontEndModules.forEach(dep => { - router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'})) +// still cover that rare case with a cheap 304 instead of a stale asset. The +// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on +// every deploy and isn't cache-busted/fingerprinted. +mountStaticModules(router, { + root: path.join(__dirname, '..'), + deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'], }); -// Have express server static content( images, CSS, browser JS) from the public -// local folder. Shorter maxAge than /static-modules since this is the app's -// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted. -router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'})) - router.get('/', (req, res) => { res.redirect(301, '/hosts'); }); diff --git a/nodejs/test/unit/oidc.test.js b/nodejs/test/unit/oidc.test.js index 9de3f9f..4406c86 100644 --- a/nodejs/test/unit/oidc.test.js +++ b/nodejs/test/unit/oidc.test.js @@ -4,7 +4,7 @@ const {describe, test} = require('node:test'); const assert = require('node:assert'); const crypto = require('crypto'); -const oidc = require('../../utils/oidc'); +const oidc = require('@simpleworkjs/oidc-client').oidc; const conf = require('@simpleworkjs/conf'); /** diff --git a/nodejs/test/unit/safe_redirect.test.js b/nodejs/test/unit/safe_redirect.test.js index 5a449e0..da79605 100644 --- a/nodejs/test/unit/safe_redirect.test.js +++ b/nodejs/test/unit/safe_redirect.test.js @@ -3,7 +3,7 @@ const {describe, test} = require('node:test'); const assert = require('node:assert'); -const {safeInternalPath} = require('../../utils/safe_redirect'); +const {safeInternalPath} = require('@simpleworkjs/oidc-client'); /** * safeInternalPath guards the OIDC post-login redirect against open-redirect diff --git a/nodejs/utils/build_info.js b/nodejs/utils/build_info.js index 66df054..32e1511 100644 --- a/nodejs/utils/build_info.js +++ b/nodejs/utils/build_info.js @@ -1,29 +1,16 @@ 'use strict'; -const fs = require('fs'); +// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the +// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit +// (../ from here in utils/), matching the Dockerfile gitinfo stage; cwd is +// utils/ for the bare-metal git fallback. + const path = require('path'); -const { execSync } = require('child_process'); -const { version: buildVersion } = require('../package.json'); +const { createBuildInfo } = require('@simpleworkjs/app-stack'); +const { version } = require('../package.json'); -// Docker builds bake the commit hash into ../.build_commit (see the gitinfo -// stage in Dockerfile) -- the final image has no git binary and no .git -// directory, so `git rev-parse` below always fails there. Bare-metal/dev -// runs have no baked file, so they fall back to asking git directly. -function readBuildHash() { - try { - const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim(); - if (baked) return baked; - } catch (_) {} - - try { - return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim(); - } catch (_) { - return 'unknown'; - } -} - -module.exports = { - buildVersion, - buildHash: readBuildHash(), - buildYear: new Date().getFullYear(), -}; +module.exports = createBuildInfo({ + version, + buildCommitPath: path.join(__dirname, '../.build_commit'), + cwd: __dirname, +}); \ No newline at end of file diff --git a/nodejs/utils/oidc.js b/nodejs/utils/oidc.js deleted file mode 100644 index 9c0b034..0000000 --- a/nodejs/utils/oidc.js +++ /dev/null @@ -1,127 +0,0 @@ -'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. `redirectUri` -// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback). -function buildAuthUrl(state, codeChallenge, redirectUri){ - let o = conf.oidc; - let params = new URLSearchParams({ - response_type: 'code', - client_id: o.clientId, - redirect_uri: redirectUri || 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. `redirectUri` -// must match the one used in buildAuthUrl (per-host for per-host SSO). -async function exchangeCode(code, codeVerifier, redirectUri){ - let o = conf.oidc; - let body = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri || o.redirectUri, - client_id: o.clientId, - client_secret: o.clientSecret, - code_verifier: codeVerifier, - }); - - let res = await fetch(o.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - }, - body: body.toString(), - }); - - if(!res.ok){ - let text = await res.text().catch(() => ''); - let error = new Error('OidcTokenExchangeFailed'); - error.name = 'OidcTokenExchangeFailed'; - error.message = `Token exchange failed (${res.status}): ${text}`; - error.status = 502; - throw error; - } - - return res.json(); -} - -// Fetch the userinfo claims for an access token. -async function fetchUserInfo(accessToken){ - let o = conf.oidc; - let res = await fetch(o.userinfoEndpoint, { - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Accept': 'application/json', - }, - }); - - if(!res.ok){ - let error = new Error('OidcUserInfoFailed'); - error.name = 'OidcUserInfoFailed'; - error.message = `Userinfo request failed (${res.status})`; - error.status = 502; - throw error; - } - - return res.json(); -} - -// Pull the app username and group list out of userinfo claims per conf. -function claimsToIdentity(claims){ - let o = conf.oidc; - let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub; - let groups = claims[o.groupsClaim || 'groups'] || []; - if(!Array.isArray(groups)) groups = [groups].filter(Boolean); - return {username, groups, claims}; -} - -module.exports = { - randomToken, - codeChallengeS256, - createAuthRequest, - buildAuthUrl, - exchangeCode, - fetchUserInfo, - claimsToIdentity, -}; diff --git a/nodejs/utils/safe_redirect.js b/nodejs/utils/safe_redirect.js deleted file mode 100644 index b1adcce..0000000 --- a/nodejs/utils/safe_redirect.js +++ /dev/null @@ -1,23 +0,0 @@ -'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};