Compare commits

...

6 Commits

Author SHA1 Message Date
wmantly f4efdfb957 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 <noreply@anthropic.com>
2026-07-25 15:53:30 -04:00
wmantly 60dbfe5b9b Merge pull request #176 from theta42/release/v1.2.2
Release v1.2.2: fix load-balancing crash (wrong lua-resty-balancer module)
2026-07-21 16:22:32 -04:00
wmantly ecdced16fc Release 1.2.2: fix load-balancing crash (wrong lua-resty-balancer module)
Every request to a host with additional load-balancing targets 500'd:
targetinfo.lua required 'resty.balancer.round_robin', which does not
exist in the lua-resty-balancer rock actually installed by the
Dockerfile/install.sh. That rock provides resty.roundrobin instead,
with a different constructor (roundrobin:new(nodes), not
:new() + :reinit(nodes)).

Verified end-to-end in a rebuilt image: requests to a load-balanced
host now return 200 and alternate across both backend targets, with
no Lua errors in the OpenResty log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:21:03 -04:00
wmantly aecf275031 Merge pull request #174 from theta42/release/v1.2.1
Release v1.2.1: fix bootstrap admin lockout, refresh screenshots
2026-07-21 02:23:06 -04:00
wmantly 3f46a807e5 Correct version to 1.2.1: v1.2.0 was already tagged for load balancing
The load-balancing feature (merged in #172/#173) was tagged v1.2.0 on
GitHub, but nodejs/package.json and CHANGELOG.md were never bumped for
it. Backfill a 1.2.0 CHANGELOG entry and bump this release to 1.2.1
instead of 1.1.18, so package.json/CHANGELOG stay ahead of the highest
existing tag rather than behind it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:07:28 -04:00
wmantly bb1b84b56d Release 1.1.18: fix bootstrap admin lockout, refresh screenshots
models/user_redis.js hardcoded the bootstrap anti-lockout admin
username to 'proxyadmin2', while migrations/permission_bootstrap.js
grants the global-admin permission to conf.auth.adminUsers[0]. An
operator who customized adminUsers away from the default ended up
with a bootstrapped account that had no admin permissions -- a
silent lockout. user_redis.js now derives the bootstrap username the
same way permission_bootstrap.js does.

Also corrected a secrets.js.example comment that claimed the
bootstrap password defaults to the username itself (it actually
generates and logs a random password), and refreshed all README
screenshots against the current UI, including a new load-balancing
screenshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:04:26 -04:00
27 changed files with 156 additions and 594 deletions
+34
View File
@@ -6,6 +6,40 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [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=<username>))`. 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
- Multi-target load balancing (added in 1.2.0) crashed every request to a load-balanced host: `ops/nginx_conf/targetinfo.lua` required a nonexistent `resty.balancer.round_robin` module. The `lua-resty-balancer` rock installed by the Dockerfile/`install.sh` doesn't provide that path — it provides `resty.roundrobin` (constructed as `roundrobin:new(nodes)`, not `:new()` + `:reinit(nodes)`). Fixed `targetinfo.lua` to use the real module and API; verified end-to-end that requests now round-robin across targets with no Lua errors.
## [1.2.1] - 2026-07-21
### Fixed
- The bootstrap anti-lockout admin account was always created as `proxyadmin2` regardless of `conf.auth.adminUsers`, while `migrations/permission_bootstrap.js` grants the global-admin permission to `conf.auth.adminUsers[0]`. If an operator customized `adminUsers` away from the default, the bootstrapped account and the permissioned account were two different (non-matching) usernames, so the anti-lockout account ended up with no admin access. `models/user_redis.js` now derives the bootstrap username from `conf.auth.adminUsers[0]` (falling back to `proxyadmin2`), matching `permission_bootstrap.js`.
- Corrected a `secrets.js.example` comment that claimed the bootstrap admin's password "defaults to the username itself" — it actually generates a random password printed to the container log on first boot.
### Changed
- Refreshed all README screenshots (hosts, per-host SSO auth, per-host basic auth) against the current UI, and added a new load-balancing screenshot for the multi-target feature.
## [1.2.0] - 2026-07-21
### Added
- Multi-target load balancing: hosts can now specify additional backend targets (`IP:port`, one per line) alongside the primary target; the proxy distributes requests across all of them round-robin via `lua-resty-balancer`. Fixes #47.
## [1.1.17] - 2026-07-20 ## [1.1.17] - 2026-07-20
### Fixed ### Fixed
+4
View File
@@ -31,6 +31,10 @@ management once basic auth is enabled:
[![Per-host basic auth](docs/images/host-auth-basic.png)](docs/images/host-auth-basic.png) [![Per-host basic auth](docs/images/host-auth-basic.png)](docs/images/host-auth-basic.png)
Multiple backend targets per host, load balanced round-robin:
[![Load balancing](docs/images/load-balancing.png)](docs/images/load-balancing.png)
## Why this over the alternatives ## Why this over the alternatives
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
'use strict'; 'use strict';
const {Auth} = require('../models/auth'); const {Auth} = require('../models');
async function auth(req, res, next){ async function auth(req, res, next){
try{ try{
-136
View File
@@ -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_<id>_<secret>` 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};
+23 -4
View File
@@ -1,18 +1,37 @@
'use strict'; 'use strict';
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const {setUpTable} = require('model-redis'); const {setUpTable} = require('model-redis');
const {createOidcClient, bootstrapLocalAdmin} = require('@simpleworkjs/oidc-client');
const Table = setUpTable(conf.redis); const Table = setUpTable(conf.redis);
module.exports = Table; 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('./dns_provider');
require('./dynamic_record'); require('./dynamic_record');
require('./host'); require('./host');
require('./token');
require('./user');
require('./local_group'); require('./local_group');
require('./permission'); require('./permission');
require('./oidc_state');
require('./sso_session'); 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_<id>_<secret>); 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'});
-31
View File
@@ -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};
-64
View File
@@ -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};
+4 -2
View File
@@ -1,8 +1,8 @@
'use strict'; 'use strict';
const { Client, Attribute, Change } = require('ldapts'); const { Client, Attribute, Change } = require('ldapts');
const {Token} = require('./token');
const conf = require('@simpleworkjs/conf').ldap; const conf = require('@simpleworkjs/conf').ldap;
const { escapeFilter } = require('@simpleworkjs/ldap');
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to // 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 // 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); 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, { const res = await client.search(conf.searchBase, {
scope: 'sub', scope: 'sub',
-1
View File
@@ -2,7 +2,6 @@
const linuxUser = require('linux-sys-user').promise(); const linuxUser = require('linux-sys-user').promise();
const objValidate = require('../utils/object_validate'); const objValidate = require('../utils/object_validate');
const {Token} = require('./token');
const {promisify} = require('util'); const {promisify} = require('util');
const pam = require('authenticate-pam'); const pam = require('authenticate-pam');
const authenticate = promisify(pam.authenticate); const authenticate = promisify(pam.authenticate);
+3 -33
View File
@@ -3,7 +3,6 @@
const Table = require('.'); const Table = require('.');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const saltRounds = 10; const saltRounds = 10;
class User extends Table{ class User extends Table{
@@ -86,35 +85,6 @@ class User extends Table{
User.register(); User.register();
(async function(){ // Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
var defaultUser = 'proxyadmin2' // (bootstrapLocalAdmin); invoked once from models/index.js after User is
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set // registered. See the package lib/bootstrap.js for the original logic.
// 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)
}
}
})();
+47 -6
View File
@@ -1,17 +1,20 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.17", "version": "1.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.17", "version": "1.2.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -26,7 +29,7 @@
"ldapts": "^8.1.8", "ldapts": "^8.1.8",
"linux-sys-user": "^1.2.0", "linux-sys-user": "^1.2.0",
"marked": "^9.1.6", "marked": "^9.1.6",
"model-redis": "^1.5.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
@@ -281,6 +284,18 @@
"@redis/client": "^6.1.0" "@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": { "node_modules/@simpleworkjs/conf": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
@@ -293,6 +308,32 @@
"node": ">=16.0.0" "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": { "node_modules/@socket.io/component-emitter": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -1508,9 +1549,9 @@
} }
}, },
"node_modules/model-redis": { "node_modules/model-redis": {
"version": "1.5.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.5.0.tgz", "resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.6.0.tgz",
"integrity": "sha512-eVXQQN+k3cR5aJBvPQurgr8WXYpAvVoLu6ydMWenSOJZBDVcHqeNqUCp8n5lYrxv6iZ8PlF0WQzZFmbJ1zP6/A==", "integrity": "sha512-QinykZ0H9vhyj0qY9NnNd1m1YcBXBXuc0viENJuEyTwplhOWT0cpjpGxObqoD1ysb8AJkrBkJB9tAf4EZbISWA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis": "^6.1.0" "redis": "^6.1.0"
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.17", "version": "1.3.0",
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
@@ -22,6 +22,9 @@
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.2.0", "@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", "acme-client": "^5.4.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -36,7 +39,7 @@
"ldapts": "^8.1.8", "ldapts": "^8.1.8",
"linux-sys-user": "^1.2.0", "linux-sys-user": "^1.2.0",
"marked": "^9.1.6", "marked": "^9.1.6",
"model-redis": "^1.5.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
+1 -1
View File
@@ -6,7 +6,7 @@ const middleware = require('../middleware/auth');
const authz = require('../middleware/authz'); const authz = require('../middleware/authz');
// API routes for authentication. // 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. // 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 // User management is admin-only; the router allows self-service exceptions
-111
View File
@@ -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;
+3 -2
View File
@@ -3,7 +3,8 @@
/** /**
* Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host * 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 * (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 * successful + authorized login, mint a Redis-backed SsoSession and set the
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that * `__proxy_sso` cookie for the host. OpenResty then gates the host on that
* session (ops/nginx_conf/hostfeatures.lua). * session (ops/nginx_conf/hostfeatures.lua).
@@ -15,7 +16,7 @@
const router = require('express').Router(); const router = require('express').Router();
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const oidc = require('../utils/oidc'); const {oidc} = require('@simpleworkjs/oidc-client');
const {Host} = require('../models').models; const {Host} = require('../models').models;
const {HostSsoState, SsoSession} = require('../models/sso_session'); const {HostSsoState, SsoSession} = require('../models/sso_session');
const {identityAllowed} = require('../utils/host_sso'); const {identityAllowed} = require('../utils/host_sso');
+7 -14
View File
@@ -5,6 +5,7 @@ const express = require('express');
const router = require('express').Router(); const router = require('express').Router();
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info'); const buildInfo = require('../utils/build_info');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
const values ={ const values ={
title: conf.environment !== 'production' ? `dev` : '', title: conf.environment !== 'production' ? `dev` : '',
@@ -15,24 +16,16 @@ const values ={
} }
// List of front end node modules to be served // 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), // Vendor libraries only change when package versions are bumped (a rebuild),
// so they're safe to cache aggressively; ETag/Last-Modified (on by default) // 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. // still cover that rare case with a cheap 304 instead of a stale asset. The
frontEndModules.forEach(dep => { // app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'})) // 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) => { router.get('/', (req, res) => {
res.redirect(301, '/hosts'); res.redirect(301, '/hosts');
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ const {describe, test} = require('node:test');
const assert = require('node:assert'); const assert = require('node:assert');
const crypto = require('crypto'); const crypto = require('crypto');
const oidc = require('../../utils/oidc'); const oidc = require('@simpleworkjs/oidc-client').oidc;
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
/** /**
+1 -1
View File
@@ -3,7 +3,7 @@
const {describe, test} = require('node:test'); const {describe, test} = require('node:test');
const assert = require('node:assert'); 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 * safeInternalPath guards the OIDC post-login redirect against open-redirect
+12 -25
View File
@@ -1,29 +1,16 @@
'use strict'; '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 path = require('path');
const { execSync } = require('child_process'); const { createBuildInfo } = require('@simpleworkjs/app-stack');
const { version: buildVersion } = require('../package.json'); const { version } = require('../package.json');
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo module.exports = createBuildInfo({
// stage in Dockerfile) -- the final image has no git binary and no .git version,
// directory, so `git rev-parse` below always fails there. Bare-metal/dev buildCommitPath: path.join(__dirname, '../.build_commit'),
// runs have no baked file, so they fall back to asking git directly. cwd: __dirname,
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(),
};
-127
View File
@@ -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,
};
-23
View File
@@ -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};
+2 -3
View File
@@ -62,7 +62,7 @@ function M.get(ngx, domain, targetInfo)
local json = require "cjson" local json = require "cjson"
local redis = require "resty.redis" local redis = require "resty.redis"
local round_robin = require "resty.balancer.round_robin" local roundrobin = require "resty.roundrobin"
if not domain then if not domain then
return nil, 499 return nil, 499
@@ -114,12 +114,11 @@ function M.get(ngx, domain, targetInfo)
local cache_key = domain .. "_" .. (res["updated_on"] or "0") local cache_key = domain .. "_" .. (res["updated_on"] or "0")
if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then
local b = round_robin:new()
local nodes = {} local nodes = {}
for _, t in ipairs(target_list) do for _, t in ipairs(target_list) do
nodes[t] = 1 nodes[t] = 1
end end
b:reinit(nodes) local b = roundrobin:new(nodes)
M.host_balancers[domain] = { b = b, key = cache_key } M.host_balancers[domain] = { b = b, key = cache_key }
end end
+8 -6
View File
@@ -67,12 +67,14 @@ module.exports = {
adminUsers: ['proxyadmin'], adminUsers: ['proxyadmin'],
groupRoleMap: {}, groupRoleMap: {},
// Optional: the local anti-lockout admin's initial password, used // Optional: the local anti-lockout admin's initial password, used
// ONLY the first time that account is created. Leave unset and it // ONLY the first time that account is created. Leave unset and a
// defaults to the username itself ("proxyadmin2") — fine for a quick // random password is generated and printed to the container log on
// local test, but change it (or set this) before exposing the proxy // first boot — fine for a quick local test if you copy it from the
// publicly. Once the account exists, this key is never read again; // log right away, but set this (or change the password afterward)
// change the password via the app itself (or delete the Redis user // before exposing the proxy publicly. Once the account exists, this
// to force it to be re-bootstrapped with a new value here). // key is never read again; change the password via the app itself
// (or delete the Redis user to force it to be re-bootstrapped with a
// new value here).
// localAdminPass: 'change-me', // localAdminPass: 'change-me',
}, },