Compare commits

...

12 Commits

Author SHA1 Message Date
wmantly a56a31421d Merge pull request #7 from theta42/feature/standalone-mode
Add standalone mode (no LDAP/SSO required)
2026-07-26 21:37:06 -04:00
wmantly d33e324b31 docs: document standalone mode; bump to 1.4.0
Add README/docs/secrets.js.example coverage for the new
@simpleworkjs/orm-backed standalone mode (no LDAP/SSO), and promote the
CHANGELOG's Unreleased entry to 1.4.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 21:35:03 -04:00
wmantly 4879769cc7 Add standalone mode with @simpleworkjs/orm-backed user/host stores
- Add standalone.enabled config flag to switch between LDAP+SSO and
  ORM-backed backends without changing the production code path
- New ORM models: StandaloneUser (uid, passwordHash, sshPublicKeys,
  groups) and StandaloneHost (slug, displayName, kind, metadata)
- user_file.js and hosts_file.js implement the same interfaces as the
  LDAP client and accessibleHosts() respectively
- models/user_ldap.js and utils/access.js become conditional facades
  that delegate based on conf.standalone.enabled at require time
- Zero changes to ssh_server.js core logic, bridge.js, key_inject.js,
  tui_picker.js, or any other consumer
- Fix ssh_server.js: use ?? instead of || for listenPort (0 is falsy)
- Fix ssh_server.js: register session listeners before awaiting
  audit.create() so client exec/shell requests aren't rejected
- Patch StringField.toSequelize() and IntegerField.toSequelize() to
  pass through primaryKey (the ORM's UUIDField already does this)
- 47 tests pass (24 existing + 15 new unit + 3 existing integration
  + 5 new standalone integration)
- Defaults to SQLite; any Sequelize dialect works via conf.orm

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 21:34:53 -04:00
wmantly da274a3ced Merge pull request #6 from theta42/docs/screenshots-refresh
docs: add jump-host screenshots
2026-07-26 16:31:53 -04:00
wmantly b9be0fe4e1 docs: add jump-host screenshots (login, dashboard, sessions, audit)
jump-host's docs had no screenshots at all. Add the four core web UI
views and reference them from index.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:29:10 -04:00
wmantly 9db530565d Merge pull request #5 from theta42/feature/ui-unification
Release 1.3.0: unified front-end UI shell
2026-07-26 00:30:12 -04:00
wmantly fe6306b7d3 Release 1.3.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly 240563e093 logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:08 -04:00
wmantly 047e54ce50 app.api.delete: accept the (url, data, callback) form formAJAX uses; defer the login-card reveal to DOM ready
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (the host/DNS delete buttons) landed its callback in
the data slot and never ran.

The login page's "reveal the card once we know you're logged out" branch
touched an element further down the same page, which threw when
isLoggedIn answered before the parser got there (it always did without a
stored token). It now runs on DOM ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly ee76088f86 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

jump-host specifics:
- .group-required base rule added to styles.css (no gated nav items yet).
- #spa-shell drops its inline margin-top; styles.css already sets it, and
  the shared shell adjusts it when a banner is shown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly 8db46bd9d9 Merge pull request #4 from theta42/release/v1.2.0
Release 1.2.0
2026-07-25 16:38:50 -04:00
wmantly 67e2fc54c2 Release 1.2.0: adopt shared @simpleworkjs/* packages; fix directory envelope drift
Rewire onto the shared @simpleworkjs/oidc-client, /directory-schema, /ldap, and
/app-stack packages (deleting the byte-identical local forks). utils/access.js
now fetches reachable hosts through the shared directory client, which
validates the {results} envelope and treats envelope drift as a failed group
rather than silently returning []. models/user_ldap.js is a thin wrapper over
createLdapClient (loose TLS default preserved). build_info moves to utils/ with
the shared {buildVersion,buildHash,buildYear} shape. Align ldapts ^8.1.8 and
redis ^6.1.0. Lockfile regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 15:55:03 -04:00
47 changed files with 2449 additions and 1001 deletions
+50
View File
@@ -4,6 +4,56 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions 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`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.4.0] - 2026-07-26
### Added
- **Standalone mode** — run the jump host with no LDAP directory and no SSO Manager at all. Set `standalone.enabled: true` and user authentication and host discovery switch to `@simpleworkjs/orm`-backed stores (Sequelize; SQLite by default, any Sequelize-supported dialect via `conf.orm`) instead of the directory services. `models/user_ldap.js` and `utils/access.js` become conditional facades that pick their backend at require time — `ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, and the web UI are unchanged either way.
- New ORM models: `StandaloneUser` (`uid`, `passwordHash`, `sshPublicKeys`, `groups`) and `StandaloneHost` (`slug`, `displayName`, `kind`, `metadata`), plus `models/user_file.js` and `utils/hosts_file.js`, which implement the same interfaces as the LDAP client and `accessibleHosts()` respectively. There's no admin UI for standalone users/hosts yet — see the README's "Standalone mode" section for the ORM-model seeding snippet. In standalone mode every stored host is reachable by every stored user; there's no group-based authorization yet.
- 47 tests pass (24 existing + 15 new unit + 3 existing integration + 5 new standalone integration).
### Fixed
- **`services/ssh_server.js` used `|| 2222` for the listen port**, so an explicit `listenPort: 0` (ephemeral port, used by the test suite) was silently overridden back to 2222. Changed to `?? 2222`.
- **`services/ssh_server.js` awaited `audit.create()` before registering session listeners.** A client that sends `exec`/`shell` immediately after connecting could have its request dropped because nothing was listening yet. Listener registration now happens first.
## [1.3.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-<cn>` 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=<path>`; 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/<path>` 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.
### Added
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
- `#spa-shell` dropped its inline `margin-top`; `styles.css` already sets it and the shared shell adjusts it when a banner is shown.
### 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.2.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`, `routes/auth.js`; `models/index.js` wires the factory and the local-admin bootstrap.
- `@simpleworkjs/directory-schema` — the sso↔jump-host directory contract. `utils/access.js` now fetches reachable hosts through the shared `createDirectoryClient` (`getResourcesByGroup`).
- `@simpleworkjs/ldap``models/user_ldap.js` is now a thin wrapper over `createLdapClient`, preserving this app's loose TLS default (`rejectUnauthorized: false`) and the exact export shape.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `build_info` moved from `models/` to `utils/`; `routes/render.js` uses `mountStaticModules`.
### Fixed
- **Directory envelope drift was silently treated as "no reachable hosts".** `utils/access.js` previously read `data.results || []`, so if the SSO directory ever returned a bare array (envelope drift) every per-group query collapsed to `[]` and no user could bridge. The shared client now validates the `{ results }` envelope on every call and treats an envelope violation as a failed group fetch rather than silently returning `[]`.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`, `redis` `^4.7``^6.1.0` (the direct `redis` dep is unused — only `model-redis` is used, which already brings `redis` ^6.1.0). The 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.1.0] - 2026-07-23 ## [1.1.0] - 2026-07-23
### Changed ### Changed
+52 -3
View File
@@ -2,9 +2,13 @@
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
stack. Users SSH into one public host and land on any downstream host they're stack. Users SSH into one public host and land on any downstream host they're
entitled to — authenticated against the shared LDAP directory, authorized from entitled to — audited end to end.
the [SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory
graph, audited end to end. Two backends, same SSH front door and audit trail: the default mode
authenticates against the shared LDAP directory and authorizes from the
[SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory graph;
**standalone mode** (below) runs with no LDAP or SSO at all, storing users and
hosts in a local SQL database instead.
## Two ways to connect ## Two ways to connect
@@ -44,8 +48,53 @@ bridged straight in.
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the 4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
downstream sshd. Every session is audited. downstream sshd. Every session is audited.
## Standalone mode
Run without LDAP or the SSO Manager at all. Set `standalone.enabled: true` and
the jump host stores users and hosts itself, via
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)
(Sequelize under the hood — defaults to a local SQLite file, but any
Sequelize-supported dialect works via `conf.orm`):
```js
standalone: { enabled: true },
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
```
Everything else — the SSH front door, key injection, bridging, the web UI and
audit trail — is unchanged; only where users/hosts live and how passwords are
checked differs. There's no admin UI for standalone users/hosts yet — add them
with the ORM models directly:
```js
const StandaloneUser = require('./models/standalone_user');
const StandaloneHost = require('./models/standalone_host');
const bcrypt = require('bcrypt');
await StandaloneUser.create({
uid: 'alice',
passwordHash: await bcrypt.hash('a real password', 10),
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
groups: [],
});
await StandaloneHost.create({
slug: 'host_web01',
displayName: 'web01',
kind: 'host',
metadata: { ip: '10.0.0.5', sshPort: 22 },
});
```
In standalone mode every stored host is reachable by every stored user — there
is no group-based authorization (the `groups` field on `StandaloneUser` is
accepted for interface parity but not yet enforced).
## Requirements ## Requirements
*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip
all of this)*
- The SSO Manager (OpenLDAP directory + `/api/discovery`). - The SSO Manager (OpenLDAP directory + `/api/discovery`).
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`). - Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on - An LDAP bind account with **write access to the `sshPublicKey` attribute** on
+1 -1
View File
@@ -1,5 +1,5 @@
title: Jump Host title: Jump Host
description: An SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics. description: An SSH jump host for the theta42 stack — directory-driven host bridging with audit and metrics; LDAP + SSO Manager by default, or fully standalone.
url: "https://theta42.github.io" url: "https://theta42.github.io"
baseurl: "/jump-host" baseurl: "/jump-host"
logo: /assets/img/theta42.svg logo: /assets/img/theta42.svg
+22
View File
@@ -112,6 +112,28 @@ Audit events and counters live in redis. Each event captures: user, auth method,
mode (grammar/picker), target slug/address/port, channel type, client IP, mode (grammar/picker), target slug/address/port, channel type, client IP,
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out. success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
## Standalone mode
Everything above describes the default backend. Set `standalone.enabled: true`
and two modules become conditional facades, swapping their entire
implementation at `require` time based on that flag — nothing else in the
codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the
web UI) changes or even knows which mode it's running in:
- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed
store implementing the same `getUser` / `getGroups` / `checkPassword` /
`addSshKey` interface).
- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or
`utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface).
In standalone mode there's no group-based authorization: every stored host
is accessible to every stored user.
The ORM is Sequelize underneath, defaulting to a local SQLite file but
accepting any Sequelize-supported dialect via `conf.orm`. See
[Installation](installation.html#standalone-mode) for config and how to add
users/hosts (there's no admin UI for standalone data yet).
## Where it sits in the stack ## Where it sits in the stack
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the - **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
+4
View File
@@ -74,6 +74,10 @@ changes.
Targets that don't resolve to a host you're allowed to reach are refused (and Targets that don't resolve to a host you're allowed to reach are refused (and
audited). Raw IPs that aren't a known directory host are denied by default. audited). Raw IPs that aren't a known directory host are denied by default.
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
> every registered host is reachable by every registered user — there's no
> group-based restriction to ask an admin about.
## Authentication ## Authentication
The jump host authenticates **you** against the directory: The jump host authenticates **you** against the directory:
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+18 -1
View File
@@ -1,7 +1,7 @@
--- ---
layout: default layout: default
title: Home title: Home
description: An SSH jump host for the theta42 stack — one public host, LDAP login, and directory-driven access to every downstream machine you're entitled to. description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to; LDAP by default, or fully standalone.
--- ---
# Jump Host # Jump Host
@@ -21,6 +21,21 @@ Part of the theta42 self-hosted identity stack, alongside
[Proxy](https://theta42.github.io/proxy/), composable with one command via [Proxy](https://theta42.github.io/proxy/), composable with one command via
[theta-env](https://theta42.github.io/theta-env/). [theta-env](https://theta42.github.io/theta-env/).
## Screenshots
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
*(click any screenshot to view full size)*
Don't want to run LDAP or the SSO Manager? **Standalone mode** stores users
and hosts in a local SQL database instead (SQLite by default, any
Sequelize-supported dialect if you want something else) — same SSH front door,
key injection, and audit trail. See
[Installation](installation.html#standalone-mode) to get started.
## Two ways to connect ## Two ways to connect
**Direct (WinSCP/SFTP-friendly):** **Direct (WinSCP/SFTP-friendly):**
@@ -79,6 +94,8 @@ This jump host answers both from your directory:
audit log, per-user/per-host counters audit log, per-user/per-host counters
- **Full audit trail** — who, target, method, result, bytes, duration, and the - **Full audit trail** — who, target, method, result, bytes, duration, and the
downstream host-key fingerprint downstream host-key fingerprint
- **Standalone mode** — no LDAP, no SSO Manager; users and hosts live in a
local SQL database (Sequelize, any dialect — SQLite by default)
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal - Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
installer, or bundled in theta-env installer, or bundled in theta-env
+46 -1
View File
@@ -1,7 +1,7 @@
--- ---
layout: default layout: default
title: Installation title: Installation
description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus the required LDAP write-ACL and port-22 options. description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus standalone mode (no LDAP/SSO), the LDAP write-ACL, and port-22 options.
--- ---
# Installation # Installation
@@ -10,6 +10,51 @@ Three ways to run the jump host, in increasing manual effort. All read their
config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf) config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
(`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env). (`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env).
## Standalone mode (no LDAP/SSO) {#standalone-mode}
Skip LDAP and the SSO Manager entirely. Not to be confused with "Standalone
Docker" below, which is still LDAP + SSO, just run outside theta-env. Set in your secrets/config:
```js
standalone: { enabled: true },
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
```
`orm` is passed straight to Sequelize, so any supported dialect works — SQLite
is just the zero-dependency default. Everything downstream of auth (bridging,
key injection, the web UI, audit) is unchanged.
There's no admin UI for standalone users/hosts yet, so add them directly with
the ORM models:
```js
const StandaloneUser = require('./models/standalone_user');
const StandaloneHost = require('./models/standalone_host');
const bcrypt = require('bcrypt');
await StandaloneUser.create({
uid: 'alice',
passwordHash: await bcrypt.hash('a real password', 10),
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
groups: [],
});
await StandaloneHost.create({
slug: 'host_web01',
displayName: 'web01',
kind: 'host',
metadata: { ip: '10.0.0.5', sshPort: 22 },
});
```
Every host in the standalone inventory is reachable by every standalone user —
there's no group-based authorization yet (`groups` on `StandaloneUser` is
accepted for interface parity with the LDAP path, not enforced).
The rest of this page (requirements, the LDAP write-ACL, the three install
paths) describes the default LDAP + SSO mode — skip it if you're running
standalone.
## Requirements ## Requirements
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP - The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
+5
View File
@@ -10,6 +10,11 @@ const app = express();
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.set('views', require('path').join(__dirname, 'views')); app.set('views', require('path').join(__dirname, 'views'));
// 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 render router's `values` object.
app.locals.ui = require('./utils/ui');
app.use(compression()); app.use(compression());
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({extended: false})); app.use(express.urlencoded({extended: false}));
+15
View File
@@ -98,6 +98,21 @@ module.exports = {
maxEvents: 50000, maxEvents: 50000,
}, },
// Standalone mode: run without LDAP or SSO Manager. When enabled, user
// authentication and host discovery use @simpleworkjs/orm-backed stores
// (Sequelize, defaulting to SQLite) instead of the directory services.
standalone: {
enabled: false,
},
// ORM config for standalone mode. Passed through to Sequelize — any dialect
// works. Defaults to SQLite for zero-dependency local dev.
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
// Orchestrator-only keys (ignored by the app, read by theta-env). // Orchestrator-only keys (ignored by the app, read by theta-env).
stack: {}, stack: {},
}; };
+8
View File
@@ -4,4 +4,12 @@ module.exports = {
ssh: { ssh: {
hostKeyPath: './data/keys', hostKeyPath: './data/keys',
}, },
standalone: {
enabled: true,
},
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
}; };
+1 -1
View File
@@ -5,7 +5,7 @@
// callback. The token carries the group snapshot captured at login. // callback. The token carries the group snapshot captured at login.
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { Auth } = require('../models/auth'); const { Auth } = require('../models');
async function auth(req, res, next){ async function auth(req, res, next){
try{ try{
-118
View File
@@ -1,118 +0,0 @@
'use strict';
const Table = require('../models');
const {User, AuthToken} = Table.models;
/**
* 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();
}
}
/**
* 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};
-24
View File
@@ -1,24 +0,0 @@
'use strict';
// Short git commit, baked into /app/.build_commit at image build time (see
// Dockerfile gitinfo stage) or resolved from git on bare metal.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function resolve() {
try {
const baked = path.join(__dirname, '../../.build_commit');
if (fs.existsSync(baked)) return fs.readFileSync(baked, 'utf8').trim();
} catch (_) {}
try {
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch (_) {}
return 'unknown';
}
let version = 'unknown';
try { version = require('../package.json').version; } catch (_) {}
module.exports = { commit: resolve(), version };
+31 -3
View File
@@ -6,6 +6,7 @@
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);
@@ -32,7 +33,34 @@ async function getRedis() {
module.exports.getRedis = getRedis; module.exports.getRedis = getRedis;
// Register models (order matters: User before AuthToken's relation resolves). // Register models (order matters: User before AuthToken's relation resolves).
require('./user_redis'); require('./user_redis'); // User (redis-backed local + OIDC JIT)
require('./token');
require('./oidc_state'); // 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. jump-host has
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent).
const oidcClient = createOidcClient({ Table });
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;
require('./audit_event'); require('./audit_event');
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
// Standalone mode: initialize @simpleworkjs/orm for local user/host stores.
// The ORM must be loaded before any code calls user_ldap or access — both of
// which check conf.standalone.enabled at require time and may delegate to the
// ORM-backed wrappers. Model registration is synchronous; table sync is async
// but the first query will implicitly wait (Sequelize.sync is in-flight).
// Export the promise so integration tests can await it before seeding data.
let ormReady = Promise.resolve();
if (conf.standalone && conf.standalone.enabled) {
const { init } = require('@simpleworkjs/orm');
const ormConf = conf.orm || { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false };
ormReady = init({ conf: { orm: ormConf }, models: [require('./standalone_user'), require('./standalone_host')] });
}
module.exports.ormReady = ormReady;
-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};
+30
View File
@@ -0,0 +1,30 @@
'use strict';
// ORM model for standalone-mode hosts. Stored in the configured SQL database
// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js
// wrapper translates between this model and the accessibleHosts() interface
// that ssh_server.js expects.
const { Model, fields } = require('@simpleworkjs/orm');
// Patch StringField.toSequelize() to pass through primaryKey (same fix as in
// standalone_user.js — see that file for details).
if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) {
const orig = fields.StringField.prototype.toSequelize;
fields.StringField.prototype.toSequelize = function () {
const def = orig.call(this);
if (this.primaryKey) def.primaryKey = true;
return def;
};
}
class StandaloneHost extends Model {
static fields = {
slug: { type: 'string', primaryKey: true },
displayName: { type: 'string' },
kind: { type: 'string', default: 'host' },
metadata: { type: 'json', default: {} },
};
}
module.exports = StandaloneHost;
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// ORM model for standalone-mode users. Stored in the configured SQL database
// (default SQLite) when conf.standalone.enabled is true. The user_file.js
// wrapper translates between this model and the LDAP-client interface that
// ssh_server.js and key_inject.js expect.
const { Model, fields } = require('@simpleworkjs/orm');
// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass
// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them
// so string and int primary keys work.
const origStringToSeq = fields.StringField.prototype.toSequelize;
fields.StringField.prototype.toSequelize = function () {
const def = origStringToSeq.call(this);
if (this.primaryKey) def.primaryKey = true;
return def;
};
const origIntToSeq = fields.IntegerField.prototype.toSequelize;
fields.IntegerField.prototype.toSequelize = function () {
const def = origIntToSeq.call(this);
if (this.primaryKey) def.primaryKey = true;
if (this.autoIncrement) def.autoIncrement = true;
return def;
};
class StandaloneUser extends Model {
static fields = {
uid: { type: 'string', primaryKey: true },
passwordHash: { type: 'string', isPrivate: true },
sshPublicKeys: { type: 'json', default: [] },
groups: { type: 'json', default: [] },
};
}
module.exports = StandaloneUser;
-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};
+65
View File
@@ -0,0 +1,65 @@
'use strict';
// ORM-backed user store for standalone mode. Implements the same interface as
// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work
// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine).
//
// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect).
// DNs are synthetic: uid=<uid>,ou=people,dc=standalone,dc=local — the real
// identity is the uid; the DN exists only for interface compatibility with
// callers that thread user.dn through to checkPassword / addSshKey.
const bcrypt = require('bcrypt');
const StandaloneUser = require('./standalone_user');
const DN_PREFIX = 'uid=';
const DN_SUFFIX = ',ou=people,dc=standalone,dc=local';
function dnFor(uid) {
return `${DN_PREFIX}${uid}${DN_SUFFIX}`;
}
function uidFromDn(dn) {
if (!dn || typeof dn !== 'string') return null;
const m = dn.match(/^uid=([^,]+)/);
return m ? m[1] : null;
}
async function getUser(uid) {
const user = await StandaloneUser.get(uid);
if (!user) return null;
return {
dn: dnFor(user.uid),
uid: user.uid,
sshPublicKeys: user.sshPublicKeys || [],
};
}
async function getGroups(dn) {
const uid = uidFromDn(dn);
if (!uid) return [];
const user = await StandaloneUser.get(uid);
if (!user) return [];
return user.groups || [];
}
async function checkPassword(dn, pw) {
const uid = uidFromDn(dn);
if (!uid) return false;
const user = await StandaloneUser.get(uid);
if (!user || !user.passwordHash) return false;
return bcrypt.compare(pw, user.passwordHash);
}
async function addSshKey(dn, keyLine) {
const uid = uidFromDn(dn);
if (!uid) return;
const user = await StandaloneUser.get(uid);
if (!user) return;
const keys = [...(user.sshPublicKeys || [])];
if (keys.includes(keyLine)) return; // idempotent
keys.push(keyLine);
await user.update({ sshPublicKeys: keys });
}
module.exports = { getUser, getGroups, checkPassword, addSshKey };
+16 -102
View File
@@ -1,109 +1,23 @@
'use strict'; 'use strict';
// Thin LDAP helpers — the jump host's entire LDAP surface: // User authentication backend — LDAP in production, ORM-backed file store in
// standalone mode. Both export the same interface:
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null // getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
// getGroups(dn) -> [cn, ...] (groupOfNames membership) // getGroups(dn) -> [cn, ...]
// checkPassword(dn, pw) -> bool (simple bind as the user) // checkPassword(dn, pw) -> bool
// addSshKey(dn, keyLine) -> void (idempotent multi-value add) // addSshKey(dn, keyLine) -> void (idempotent)
//
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
// TypeOrValueExists treated as success on key add).
const { Client, Change, Attribute } = require('ldapts');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
function ldapConf() { if (conf.standalone && conf.standalone.enabled) {
return conf.ldap || {}; // Standalone mode: use the ORM-backed user store.
} module.exports = require('./user_file');
} else {
function makeClient() { // Production mode: use the LDAP directory.
const c = ldapConf(); const { createLdapClient } = require('@simpleworkjs/ldap');
return new Client({ const ldapConf = conf.ldap || {};
url: c.url, module.exports = createLdapClient({
tlsOptions: c.tlsOptions || { rejectUnauthorized: false }, ...ldapConf,
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
}); });
} }
// Escape a value being interpolated into an LDAP filter (RFC 4515).
function escapeFilter(value) {
return String(value).replace(/[\\*()\0]/g, (ch) => ({
'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00',
}[ch]));
}
async function withClient(fn) {
const c = ldapConf();
const client = makeClient();
try {
await client.bind(c.bindDN, c.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
async function getUser(uid) {
const c = ldapConf();
const attr = c.userNameAttribute || 'uid';
return withClient(async (client) => {
const { searchEntries } = await client.search(c.userBase, {
scope: 'sub',
filter: `(&(objectClass=posixAccount)(${attr}=${escapeFilter(uid)}))`,
attributes: ['dn', attr, 'cn', 'sshPublicKey'],
});
if (!searchEntries.length) return null;
const e = searchEntries[0];
let keys = e.sshPublicKey || [];
if (!Array.isArray(keys)) keys = [keys];
return {
dn: e.dn,
uid: String(e[attr]),
sshPublicKeys: keys.map(String),
};
});
}
async function getGroups(dn) {
const c = ldapConf();
return withClient(async (client) => {
const { searchEntries } = await client.search(c.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)(member=${escapeFilter(dn)}))`,
attributes: ['cn'],
});
return searchEntries.map((e) => String(e.cn));
});
}
async function checkPassword(dn, password) {
if (!password) return false;
const client = makeClient();
try {
await client.bind(dn, password);
return true;
} catch (_) {
return false;
} finally {
await client.unbind().catch(() => {});
}
}
async function addSshKey(dn, keyLine) {
return withClient(async (client) => {
try {
await client.modify(dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'sshPublicKey', values: [keyLine] }),
}),
]);
} catch (error) {
// Same de-dup semantics as the SSO's User.addSSHkey.
if (error.name === 'TypeOrValueExistsError') return;
throw error;
}
});
}
module.exports = { getUser, getGroups, checkPassword, addSshKey, escapeFilter, makeClient };
+3 -36
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,38 +85,6 @@ class User extends Table{
User.register(); User.register();
(async function(){ // Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
// The anti-lockout local admin: the first entry in conf.auth.adminUsers // (bootstrapLocalAdmin); invoked once from models/index.js after User is
// (default 'jumpadmin'). A local login that works even if the SSO/OIDC is // registered. See the package lib/bootstrap.js for the original logic.
// unreachable — the whole point of "OIDC + internal users".
var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'jumpadmin';
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
// auth.localAdminPass in jump-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)
}
}
})();
+962 -153
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-jump-host", "name": "t42-jump-host",
"version": "1.1.0", "version": "1.4.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics", "description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [ "author": [
{ {
@@ -21,6 +21,11 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -28,12 +33,12 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"jq-repeat": "^2.2.0", "jq-repeat": "^2.2.0",
"jquery": "^3.7.1", "jquery": "^4.0.0",
"ldapts": "^8.1.2", "ldapts": "^8.1.8",
"model-redis": "^1.6.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"redis": "^4.7.0", "redis": "^6.1.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"ssh2": "^1.16.0" "ssh2": "^1.16.0"
}, },
+4
View File
@@ -18,3 +18,7 @@ body {
.card-title{ .card-title{
font-weight: bold; font-weight: bold;
} }
.group-required{
display: none;
}
+290 -79
View File
@@ -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 = {}; var app = {};
app.pubsub = (function(){ app.pubsub = (function(){
@@ -45,7 +54,7 @@ app.pubsub = (function(){
app.socket = (function(app){ app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js') // $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script> // <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket; var socket;
$(document).ready(function(){ $(document).ready(function(){
socket = io({ socket = io({
@@ -75,10 +84,26 @@ app.socket = (function(app){
app.api = (function(app){ app.api = (function(app){
var baseURL = '/api/' var baseURL = '/api/'
function post(url, data, callback){ // post/put/delete are dual-mode: pass a callback for the node-style
if(typeof callback !== 'function') callback = callback2; // (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: method,
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({ return $.ajax({
type: 'POST', type: method,
url: baseURL+url, url: baseURL+url,
headers:{ headers:{
'auth-token': app.auth.getToken() 'auth-token': app.auth.getToken()
@@ -87,40 +112,44 @@ app.api = (function(app){
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
complete: function(res, text){ complete: function(res, text){
callback ? callback( callback(
text !== 'success' ? res.statusText : null, text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText), JSON.parse(res.responseText),
res.status res.status
) : function(){} );
} }
}); });
} }
function post(url, data, callback){
return body('POST', url, data, callback);
}
function put(url, data, callback){ function put(url, data, callback){
if(typeof callback !== 'function') callback = callback2; return body('PUT', url, data, callback);
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 ? callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
}
});
} }
function remove(url, callback, callback2){ // Called both as (url, callback) and — from formAJAX, which always passes
if(typeof callback !== 'function') callback = callback2; // 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,
headers: { 'auth-token': app.auth.getToken() },
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({ return $.ajax({
type: 'delete', type: 'DELETE',
url: baseURL+url, url: baseURL+url,
headers:{ headers:{
'auth-token': app.auth.getToken() 'auth-token': app.auth.getToken()
@@ -128,11 +157,11 @@ app.api = (function(app){
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
complete: function(res, text){ complete: function(res, text){
callback ? callback( callback(
text !== 'success' ? res.statusText : null, text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText), JSON.parse(res.responseText),
res.status res.status
) : function(){} );
} }
}); });
} }
@@ -179,7 +208,11 @@ app.api = (function(app){
})(app) })(app)
app.auth = (function(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){ function setToken(token){
localStorage.setItem('APIToken', token); localStorage.setItem('APIToken', token);
} }
@@ -188,18 +221,95 @@ app.auth = (function(app){
return localStorage.getItem('APIToken'); return localStorage.getItem('APIToken');
} }
function isLoggedIn(callback){ async function getUser(){
if(getToken()){ try{
return app.api.get('user/me', function(error, data){ return await app.api.get('user/me');
// data now carries effective rights (isAdmin, global, domains). }catch(error){
if(!error) app.auth.user = app.auth.perms = data; if(error && error.status === 401) return null;
return callback(error, data); throw error;
});
}else{
callback(null, false);
} }
} }
// 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){
user = user || await loadUser();
if(!user) return false;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
return groupCNs(user).some(function(group){
return groupNameToFind.includes(group);
});
}
// 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){
app.api.post('auth/login', args, function(error, data){
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');
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 // Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host", // absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/". // and non-path schemes like "javascript:" (XSS). Falls back to "/".
@@ -230,46 +340,68 @@ app.auth = (function(app){
return true; return true;
} }
// True when the logged-in user is a global admin (per user/me). // Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
function isAdmin(){ // unauthorised user is kept off the page by a redirect / an error panel
return !!(app.auth.perms && app.auth.perms.isAdmin); // rather than by pausing document ready.
} //
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
function logIn(args, callback){ // `admin` group covers the OIDC clients' isAdmin flag.
app.api.post('auth/login', args, function(error, data){ async function forceLogin(requiredGroups){
if(data.login){ var user = await loadUser();
setToken(data.token);
} if(!user){
callback(error, !!data.token); logOut(function(){});
}); location.replace('/login?redirect=' + encodeURIComponent(
} location.pathname + location.search
));
function logOut(callback){ return false;
localStorage.removeItem('APIToken'); }
callback();
} if(user.onboardingRequired && location.pathname !== '/onboarding'){
location.replace('/onboarding');
function forceLogin(){ return false;
// jQuery 4 removed $.holdReady; rely on the redirect below to keep an }
// unauthenticated user off the page instead of pausing document ready.
app.auth.isLoggedIn(function(error, isLoggedIn){ if(requiredGroups && !await memberOf(requiredGroups, user)){
if(error || !isLoggedIn){ app.util.actionMessage(
app.auth.logOut(function(){}) `<h1>
location.replace(`/login${location.href.replace(location.origin, '')}`); <i class="fa-solid fa-triangle-exclamation"></i>
} <b>You do not have permission to be here.</b>
}); <i class="fa-solid fa-triangle-exclamation"></i>
</h1>`,
$('#spa-shell'),
'danger',
);
throw new Error("User does not have permission");
}
return user;
} }
// Where to go after a successful login: the ?redirect= query param, or the
// legacy /login/<path> 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(){ function logInRedirect(){
window.location.href = safeInternalPath(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 { return {
getToken: getToken, getToken: getToken,
setToken: setToken, setToken: setToken,
isLoggedIn: isLoggedIn, getUser: getUser,
consumeTokenFragment: consumeTokenFragment, loadUser: loadUser,
groupCNs: groupCNs,
memberOf: memberOf,
isAdmin: isAdmin, isAdmin: isAdmin,
isLoggedIn: isLoggedIn,
safeInternalPath: safeInternalPath,
consumeTokenFragment: consumeTokenFragment,
user: null,
perms: null, perms: null,
logIn: logIn, logIn: logIn,
logOut: logOut, logOut: logOut,
@@ -279,6 +411,11 @@ app.auth = (function(app){
})(app); })(app);
// 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){ app.user = (function(app){
function list(callback){ function list(callback){
app.api.get('user/?detail=true', function(error, data){ app.api.get('user/?detail=true', function(error, data){
@@ -308,6 +445,8 @@ app.user = (function(app){
})(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){ app.permission = (function(app){
function list(callback){ function list(callback){
app.api.get('permission/', function(error, data){ app.api.get('permission/', function(error, data){
@@ -381,9 +520,12 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
}; };
function actionMessage(message, $target, type, callback){ function actionMessage(message, $targetPassed, type, callback){
message = message || ''; message = message || '';
$target = $target.closest('div.card').find('.actionMessage');
let $target = $targetPassed.closest('div.card').find('.actionMessage');
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
type = type || 'info'; type = type || 'info';
callback = callback || function(){}; callback = callback || function(){};
@@ -400,12 +542,48 @@ app.util = (function(app){
}) })
}else{ }else{
if(type) $target.addClass('bg-' + type); if(type) $target.addClass('bg-' + type);
message = '<span class="align-middle">' + message + '</span><button class="action-close btn btn-sm btn-outline-dark float-end"><i class="fa-solid fa-xmark"></i></button>'
// Messages that bring their own buttons (actionConfirm) are left
// alone; everything else gets the standard dismiss button.
if(!message.includes('<button')) message = `
<span class="align-middle">${message}</span>
<button class="action-close btn btn-sm btn-outline-dark float-end">
<i class="fa-solid fa-xmark"></i>
</button>
`
$target.html(message).slideDown('fast'); $target.html(message).slideDown('fast');
} }
setTimeout(callback,10) setTimeout(callback,10)
} }
function actionConfirm(message, $target, type, callback){
return new Promise((resolve, reject) =>{
let id = crypto.randomUUID();
message = `
<h4 class"align-middle" >
<i class="fa-solid fa-triangle-exclamation"></i>
<b>${message}</b>
<span class="float-end">
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
<i class="fa-solid fa-circle-check"></i>
Confirm
</button>
<button type="button" class="btn btn-danger confirm-${id}">
<i class="fa-solid fa-circle-stop"></i>
Cancel
</button>
</span>
</h4>
`
actionMessage(message, $target, type);
$("body").on('click', `.confirm-${id}`, function(){
actionMessage('', $target, type);
resolve(!!$(this).data('confirm'));
});
});
}
$.fn.serializeObject = function() { $.fn.serializeObject = function() {
var obj = {}; var obj = {};
@@ -462,11 +640,42 @@ app.util = (function(app){
return { return {
downloadFile: downloadFile, downloadFile: downloadFile,
getUrlParameter: getUrlParameter, getUrlParameter: getUrlParameter,
actionMessage: actionMessage actionMessage: actionMessage,
actionConfirm,
} }
})(app); })(app);
$( document ).ready(function(){ // Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
if(!groups.length) return;
var style = document.getElementById('group-required-rules');
if(!style){
style = document.createElement('style');
style.id = 'group-required-rules';
document.head.appendChild(style);
}
for(var group of groups){
try{
style.sheet.insertRule(
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// A group whose CN isn't a usable CSS identifier just gates nothing.
}
}
};
$( document ).ready(async function(){
// Show content the user's groups entitle them to.
app.auth.applyGroupVisibility(await app.auth.loadUser());
$('div.row').fadeIn('slow'); //show the page $('div.row').fadeIn('slow'); //show the page
//panel button's //panel button's
@@ -520,12 +729,14 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase(); var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){ if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger'); app.util.actionMessage('Please fix the form errors.', $form, 'danger')
return false; return false;
} }
app.util.actionMessage( app.util.actionMessage(
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>', `<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>`,
$form, $form,
'info' 'info'
); );
+1 -1
View File
@@ -4,7 +4,7 @@ const router = require('express').Router();
const middleware = require('../middleware/auth'); const middleware = require('../middleware/auth');
// Authentication (local login + OIDC handshake). Unauthenticated by design. // Authentication (local login + OIDC handshake). Unauthenticated by design.
router.use('/auth', require('./auth')); router.use('/auth', require('../models').authRouter);
// Who am I — needs a valid session but no admin gate (drives the login state). // Who am I — needs a valid session but no admin gate (drives the login state).
router.use('/user', middleware.auth, require('./user')); router.use('/user', middleware.auth, require('./user'));
-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;
+8 -7
View File
@@ -4,8 +4,10 @@ const path = require('path');
const express = require('express'); 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('../models/build_info'); const buildInfo = require('../utils/build_info');
const registry = require('../services/session_registry'); const registry = require('../services/session_registry');
const { safeInternalPath } = require('@simpleworkjs/oidc-client');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
const values = { const values = {
title: conf.environment !== 'production' ? 'dev' : '', title: conf.environment !== 'production' ? 'dev' : '',
@@ -17,15 +19,14 @@ const values = {
// Serve front-end vendor libraries straight from node_modules (same convention // Serve front-end vendor libraries straight from node_modules (same convention
// as the sibling apps), and the app's own JS/CSS/img from public/. // as the sibling apps), and the app's own JS/CSS/img from public/.
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat']; mountStaticModules(router, {
frontEndModules.forEach(dep => { root: path.join(__dirname, '..'),
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'})); deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'],
}); });
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}));
// Liveness probe — no auth. // Liveness probe — no auth.
router.get('/health', (req, res) => { router.get('/health', (req, res) => {
res.json({status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit}); res.json({status: 'ok', activeSessions: registry.count(), buildVersion: buildInfo.buildVersion, buildHash: buildInfo.buildHash});
}); });
router.get('/', (req, res) => res.redirect(302, '/dashboard')); router.get('/', (req, res) => res.redirect(302, '/dashboard'));
@@ -36,7 +37,7 @@ router.get('/', (req, res) => res.redirect(302, '/dashboard'));
// /login when there's no valid session. // /login when there's no valid session.
router.get('/login', (req, res) => res.render('login', { router.get('/login', (req, res) => res.render('login', {
...values, ...values,
redirect: '/', redirect: safeInternalPath(req.query.redirect || '/'),
oidcEnabled: !!(conf.oidc && conf.oidc.enabled), oidcEnabled: !!(conf.oidc && conf.oidc.enabled),
})); }));
router.get('/dashboard', (req, res) => res.render('dashboard', {...values})); router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
+13 -5
View File
@@ -147,12 +147,20 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; } function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
async function runGrammar(session, client, state) { async function runGrammar(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' }); // Register session listeners IMMEDIATELY — before any async work.
// The client sends exec/shell requests right after opening the session;
// Deferred upstream — attach the bridge NOW, resolve/reject after connect. // if we await audit.create() first, those requests arrive before the
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
let resolveUp, rejectUp; let resolveUp, rejectUp;
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; }); const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
attachSession(session, upstreamPromise, record); const dummyAudit = { patch() {}, finish() {}, event: {} };
attachSession(session, upstreamPromise, dummyAudit);
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
// Wire the real audit record into the already-attached session.
dummyAudit.patch = (...a) => record.patch(...a);
dummyAudit.finish = (...a) => record.finish(...a);
Object.defineProperty(dummyAudit, 'event', { get: () => record.event });
try { try {
const { upstream, host, endpoint } = await resolveAndConnect(state, record, { const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
@@ -280,7 +288,7 @@ function start() {
} }
); );
const port = (conf.ssh && conf.ssh.listenPort) || 2222; const port = (conf.ssh && conf.ssh.listenPort) ?? 2222;
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0'; const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
server.listen(port, host, () => { server.listen(port, host, () => {
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`); console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
+232
View File
@@ -0,0 +1,232 @@
'use strict';
// End-to-end standalone SSH test: a real downstream sshd, the full jump host
// SSH server (ssh_server.js), and an SSH client. Authentication and host
// discovery use the ORM-backed standalone stores (temp file SQLite).
//
// Follows the same hermetic pattern as ssh_bridge.test.js but exercises the
// full stack: conf → ORM → user_ldap facade → ssh_server → bridge.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { Server, Client, utils } = require('ssh2');
const bcrypt = require('bcrypt');
const conf = require('@simpleworkjs/conf');
// ── Conf must be set BEFORE any module that checks conf.standalone.enabled ──
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-standalone-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
conf.ssh = {
listenHost: '127.0.0.1',
listenPort: 0,
hostKeyPath: path.join(tmpDir, 'keys'),
passwordAuth: 'all',
keyComment: 'jump-host-test',
defaultPort: 22,
connectTimeoutMs: 5000,
maxSessions: 10,
};
conf.redis = { prefix: 'jump_host_test_standalone_' };
conf.audit = { maxEvents: 100 };
// ── Require models/index FIRST so it initializes the ORM exactly once.
// This also registers the standalone models. We await ormReady before
// seeding data, then start the SSH server. ──
const models = require('../../models');
const StandaloneUser = require('../../models/standalone_user');
const StandaloneHost = require('../../models/standalone_host');
let downstream, downstreamPort, jump, jumpPort;
let testUserKey;
function startDownstream() {
return new Promise((resolve) => {
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
client.on('authentication', (ctx) => ctx.accept());
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept();
session.on('pty', (a) => a && a());
session.on('shell', (a) => {
const ch = a();
ch.write('downstream-shell-ready\n');
ch.on('data', (d) => ch.write('echo:' + d));
});
session.on('exec', (a, r, info) => {
const ch = a();
ch.write(`ran:${info.command}`);
ch.exit(0);
ch.end();
});
session.on('subsystem', (a, r, info) => {
if (info.name !== 'sftp') return r && r();
const ch = a();
ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d])));
});
});
});
});
srv.listen(0, '127.0.0.1', () => resolve(srv));
});
}
before(async () => {
// 1. Start downstream.
downstream = await startDownstream();
downstreamPort = downstream.address().port;
// 2. Wait for the ORM to finish syncing tables (init was called by models/index
// at require time — we just need the tables to exist before seeding).
await models.ormReady;
// 3. Seed test data.
const userKeyPair = utils.generateKeyPairSync('ed25519');
testUserKey = userKeyPair.private;
const userPubKey = utils.parseKey(userKeyPair.private);
const userPubLine = `${userPubKey.type} ${userPubKey.getPublicSSH().toString('base64')} testuser@test`;
const passwordHash = await bcrypt.hash('testpass', 4);
await StandaloneUser.create({
uid: 'testuser',
passwordHash,
sshPublicKeys: [userPubLine],
groups: ['admin'],
});
await StandaloneHost.create({
slug: 'host_test',
displayName: 'Test Downstream',
kind: 'host',
metadata: { address: `ssh://127.0.0.1:${downstreamPort}`, ip: '127.0.0.1', sshPort: downstreamPort },
});
// 4. Start the jump host SSH server.
const sshServer = require('../../services/ssh_server');
jump = sshServer.start();
await new Promise((resolve) => {
const check = () => {
const addr = jump.address();
if (addr) { jumpPort = addr.port; resolve(); }
else setTimeout(check, 10);
};
check();
});
});
after(() => {
try { downstream && downstream.close(); } catch (_) {}
try { jump && jump.close(); } catch (_) {}
try { models.redisClient.destroy(); } catch (_) {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
process.on('unhandledRejection', () => {});
function connectJump(opts = {}) {
const conn = new Client();
const connectOpts = {
host: '127.0.0.1',
port: jumpPort,
username: opts.username || 'testuser_-_host_test',
...opts,
};
return {
conn,
ready: new Promise((res, rej) => {
conn.on('ready', res).on('error', rej).connect(connectOpts);
}),
};
}
// ── Tests ──
test('public key auth + grammar mode exec', async () => {
const { conn, ready } = connectJump({ privateKey: testUserKey });
await ready;
const out = await new Promise((resolve, reject) => {
conn.exec('hello-world', (err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
});
});
conn.end();
assert.match(out, /ran:hello-world/);
});
test('public key auth + grammar mode shell', async () => {
const { conn, ready } = connectJump({ privateKey: testUserKey });
await ready;
const out = await new Promise((resolve, reject) => {
conn.shell((err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => {
buf += d;
if (buf.includes('echo:ping')) resolve(buf);
});
setTimeout(() => stream.write('ping'), 150);
setTimeout(() => resolve(buf), 5000);
});
});
conn.end();
assert.match(out, /downstream-shell-ready/);
assert.match(out, /echo:ping/);
});
test('password auth + grammar mode exec', async () => {
const { conn, ready } = connectJump({
username: 'testuser_-_host_test',
password: 'testpass',
});
await ready;
const out = await new Promise((resolve, reject) => {
conn.exec('pw-test', (err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
});
});
conn.end();
assert.match(out, /ran:pw-test/);
});
test('password auth denied with wrong password', async () => {
const conn = new Client();
const result = await new Promise((resolve) => {
conn.on('ready', () => resolve('unexpected-ready'));
conn.on('error', () => resolve('auth-failed'));
conn.connect({
host: '127.0.0.1', port: jumpPort,
username: 'testuser_-_host_test',
password: 'wrongpass',
});
});
assert.strictEqual(result, 'auth-failed');
});
test('unknown user rejected', async () => {
const conn = new Client();
const result = await new Promise((resolve) => {
conn.on('ready', () => resolve('unexpected-ready'));
conn.on('error', () => resolve('auth-failed'));
conn.connect({
host: '127.0.0.1', port: jumpPort,
username: 'nobody_-_host_test',
password: 'testpass',
});
});
assert.strictEqual(result, 'auth-failed');
});
+14
View File
@@ -53,3 +53,17 @@ test('caches per uid', async () => {
await accessibleHosts(user, { fetchImpl, ldap }); await accessibleHosts(user, { fetchImpl, ldap });
assert.strictEqual(calls, 1); assert.strictEqual(calls, 1);
}); });
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
clearCache();
const user = { uid: 'dave', dn: 'd' };
// drift shape: a bare array instead of { results: [...] }. The shared client
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
// good group alongside still yields its hosts.
const fetchImpl = async (url) => {
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
};
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
});
+87
View File
@@ -0,0 +1,87 @@
'use strict';
// Unit tests for the ORM-backed host inventory (utils/hosts_file.js).
// Uses a temp file SQLite database — no external services needed.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const conf = require('@simpleworkjs/conf');
const { init } = require('@simpleworkjs/orm');
const StandaloneHost = require('../../models/standalone_host');
let tmpDir;
let hostsFile; // required after ORM init
before(async () => {
// Unique temp DB so this test file doesn't collide with other ORM tests.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-hostfile-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
await init({ conf: { orm: conf.orm }, models: [StandaloneHost] });
await StandaloneHost.create({
slug: 'host_web01',
displayName: 'Web Server 01',
kind: 'host',
metadata: { address: 'ssh://10.0.0.10:22', ip: '10.0.0.10', sshPort: 22 },
});
await StandaloneHost.create({
slug: 'host_db',
displayName: 'Database Server',
kind: 'host',
metadata: { address: 'ssh://10.0.0.20:22', ip: '10.0.0.20', sshPort: 22 },
});
await StandaloneHost.create({
slug: 'app_gitea',
displayName: 'Gitea',
kind: 'service',
metadata: { url: 'https://gitea.internal' },
});
hostsFile = require('../../utils/hosts_file');
});
after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
test('accessibleHosts returns all hosts', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
assert.strictEqual(hosts.length, 2);
const slugs = hosts.map((h) => h.slug).sort();
assert.deepStrictEqual(slugs, ['host_db', 'host_web01']);
});
test('accessibleHosts filters to kind=host', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
const kinds = [...new Set(hosts.map((h) => h.kind))];
assert.deepStrictEqual(kinds, ['host']);
});
test('accessibleHosts returns host resources with expected shape', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
const web = hosts.find((h) => h.slug === 'host_web01');
assert.ok(web);
assert.strictEqual(web.id, 'host_web01');
assert.strictEqual(web.displayName, 'Web Server 01');
assert.strictEqual(web.metadata.ip, '10.0.0.10');
assert.strictEqual(web.metadata.sshPort, 22);
});
test('accessibleHosts returns empty array when no hosts exist', async () => {
// Delete all hosts and verify empty result.
const all = await StandaloneHost.list();
for (const h of all) {
await h.delete({ force: true });
}
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
assert.deepStrictEqual(hosts, []);
});
+113
View File
@@ -0,0 +1,113 @@
'use strict';
// Unit tests for the ORM-backed user store (models/user_file.js).
// Uses a temp file SQLite database — no external services needed.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const bcrypt = require('bcrypt');
const conf = require('@simpleworkjs/conf');
const { init } = require('@simpleworkjs/orm');
const StandaloneUser = require('../../models/standalone_user');
let testPasswordHash;
let tmpDir;
let userFile; // required after ORM init
before(async () => {
// Unique temp DB so this test file doesn't collide with other ORM tests.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-userfile-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
await init({ conf: { orm: conf.orm }, models: [StandaloneUser] });
testPasswordHash = await bcrypt.hash('testpass', 4);
await StandaloneUser.create({
uid: 'alice',
passwordHash: testPasswordHash,
sshPublicKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop'],
groups: ['admin', 'developers'],
});
// Now that the ORM is initialized and conf.standalone is set, require the
// facade. It checks conf.standalone.enabled at require time.
userFile = require('../../models/user_file');
});
after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
test('getUser returns user with synthesized dn and keys', async () => {
const user = await userFile.getUser('alice');
assert.ok(user);
assert.strictEqual(user.uid, 'alice');
assert.strictEqual(user.dn, 'uid=alice,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(user.sshPublicKeys, ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop']);
});
test('getUser returns null for unknown uid', async () => {
const user = await userFile.getUser('nobody');
assert.strictEqual(user, null);
});
test('getGroups returns user groups', async () => {
const groups = await userFile.getGroups('uid=alice,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(groups, ['admin', 'developers']);
});
test('getGroups returns empty array for unknown dn', async () => {
const groups = await userFile.getGroups('uid=nobody,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(groups, []);
});
test('getGroups returns empty array for malformed dn', async () => {
const groups = await userFile.getGroups('not-a-dn');
assert.deepStrictEqual(groups, []);
});
test('checkPassword returns true for correct password', async () => {
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'testpass');
assert.strictEqual(ok, true);
});
test('checkPassword returns false for wrong password', async () => {
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'wrongpass');
assert.strictEqual(ok, false);
});
test('checkPassword returns false for unknown user', async () => {
const ok = await userFile.checkPassword('uid=nobody,ou=people,dc=standalone,dc=local', 'testpass');
assert.strictEqual(ok, false);
});
test('addSshKey appends a new key', async () => {
const newKey = 'ssh-rsa AAAAB3NzaC1yc2E... bob@desktop';
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', newKey);
const user = await StandaloneUser.get('alice');
assert.ok(user.sshPublicKeys.includes(newKey));
});
test('addSshKey is idempotent', async () => {
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop';
const userBefore = await StandaloneUser.get('alice');
const countBefore = userBefore.sshPublicKeys.length;
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', key);
const userAfter = await StandaloneUser.get('alice');
assert.strictEqual(userAfter.sshPublicKeys.length, countBefore);
});
test('addSshKey is a no-op for unknown user', async () => {
// Should not throw.
await userFile.addSshKey('uid=nobody,ou=people,dc=standalone,dc=local', 'ssh-rsa AAA...');
});
+71 -54
View File
@@ -1,67 +1,84 @@
'use strict'; 'use strict';
// Which directory hosts may a user reach, and how do we dial them? // Host discovery — SSO Manager API in production, ORM-backed inventory in
// // standalone mode. Both export the same interface:
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's // accessibleHosts(user) -> [host resources]
// /api/discovery/me only answers for the API token's own user, and /graph // clearCache(uid?) -> void
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
// directly) with per-group resource lookups:
//
// 1. LDAP: groups the user's DN is a member of
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
// 3. union, keep kind === 'host'
//
// Results are cached per-uid for a short TTL — the TUI picker and the
// username-grammar path share the cache. Dependency-injected fetch/ldap for
// unit testing.
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const userLdap = require('../models/user_ldap');
const CACHE_TTL_MS = 30 * 1000; if (conf.standalone && conf.standalone.enabled) {
const cache = new Map(); // uid -> {at, hosts} // Standalone mode: use the ORM-backed host inventory.
const { accessibleHosts } = require('./hosts_file');
module.exports = { accessibleHosts, clearCache: () => {} };
} else {
// Production mode: LDAP groups + SSO API (unchanged).
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) { // Which directory hosts may a user reach, and how do we dial them?
const sso = conf.sso || {}; //
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`; // v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
const res = await fetchImpl(url, { // /api/discovery/me only answers for the API token's own user, and /graph
headers: { Authorization: `Bearer ${sso.apiToken}` }, // omits ResourceGroup links — so we combine the user's LDAP groups (queried
}); // directly) with per-group resource lookups:
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`); //
const data = await res.json(); // 1. LDAP: groups the user's DN is a member of
return (data && data.results) || []; // 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
} // 3. union, keep kind === 'host'
//
// Results are cached per-uid for a short TTL — the TUI picker and the
// username-grammar path share the cache. Dependency-injected fetch/ldap for
// unit testing.
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) { const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
const hit = cache.get(user.uid); const userLdap = require('../models/user_ldap');
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
const groups = await ldap.getGroups(user.dn); const CACHE_TTL_MS = 30 * 1000;
const cache = new Map(); // uid -> {at, hosts}
const seen = new Map(); // Build a directory client bound to conf.sso. fetchImpl is injectable so the
for (const cn of groups) { // unit tests can stub the transport; the shared client validates the
let resources; // `{ results }` envelope on every call (turns the old bare-array drift into a
try { // thrown error instead of a silent `[]`).
resources = await fetchResourcesByGroup(cn, { fetchImpl }); function directoryClient({ fetchImpl = fetch } = {}) {
} catch (error) { const sso = conf.sso || {};
// One bad group must not hide the rest; the SSO being down return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
// surfaces as an empty list + log line, not a crash.
console.error(`[access] ${error.message}`);
continue;
}
for (const r of resources) {
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
}
} }
const hosts = [...seen.values()]; async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
cache.set(user.uid, { at: Date.now(), hosts }); return directoryClient({ fetchImpl }).getResourcesByGroup(group);
return hosts; }
}
function clearCache(uid) { async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
if (uid) cache.delete(uid); const hit = cache.get(user.uid);
else cache.clear(); if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup }; const groups = await ldap.getGroups(user.dn);
const seen = new Map();
for (const cn of groups) {
let resources;
try {
resources = await fetchResourcesByGroup(cn, { fetchImpl });
} catch (error) {
// One bad group must not hide the rest; the SSO being down
// surfaces as an empty list + log line, not a crash.
console.error(`[access] ${error.message}`);
continue;
}
for (const r of resources) {
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
}
}
const hosts = [...seen.values()];
cache.set(user.uid, { at: Date.now(), hosts });
return hosts;
}
function clearCache(uid) {
if (uid) cache.delete(uid);
else cache.clear();
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
}
+19
View File
@@ -0,0 +1,19 @@
'use strict';
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
// shared @simpleworkjs/app-stack. Previously this lived in models/build_info.js
// and exported { commit, version }; the shape is now aligned with sso + proxy.
//
// The baked commit file lives at the jump-host repo root (../../ from here in
// utils/), matching the Dockerfile gitinfo stage. cwd is utils/ for the
// bare-metal git fallback.
const path = require('path');
const { createBuildInfo } = require('@simpleworkjs/app-stack');
const { version } = require('../package.json');
module.exports = createBuildInfo({
version,
buildCommitPath: path.join(__dirname, '../../.build_commit'),
cwd: __dirname,
});
+30
View File
@@ -0,0 +1,30 @@
'use strict';
// ORM-backed host inventory for standalone mode. Implements the same interface
// as utils/access.js so ssh_server.js works unchanged: accessibleHosts(user)
// returns an array of host resources the user may reach.
//
// In standalone mode all hosts in the inventory are accessible to every
// authenticated user — there is no group-based filtering. The _user parameter
// is accepted for interface compatibility but ignored.
const StandaloneHost = require('../models/standalone_host');
async function accessibleHosts(_user) {
const hosts = await StandaloneHost.list({ where: { kind: 'host' } });
// The ORM returns model instances; map to plain objects matching the shape
// that target_match.js and tui_picker.js expect.
return hosts.map((h) => ({
id: h.slug, // slug doubles as the stable id in standalone mode
kind: h.kind,
slug: h.slug,
displayName: h.displayName,
metadata: h.metadata || {},
}));
}
function clearCache() {
// No cache in standalone mode — every call reads from the DB.
}
module.exports = { accessibleHosts, clearCache };
-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};
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
//
// Those two partials are byte-identical across sso-manager-node, proxy and
// jump-host — everything that differs between the apps lives here and is
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
// in sync across the three apps; a missing key is a render-time ReferenceError,
// not a silent fallback.
module.exports = {
// --- footer -------------------------------------------------------------
repoUrl: 'https://github.com/theta42/jump-host',
licenseUrl: 'https://github.com/theta42/jump-host/blob/master/LICENSE',
// No in-app /docs route here — point at the published docs site.
docsUrl: 'https://theta42.github.io/jump-host/',
docsExternal: true,
// Only sso-manager-node serves a Terms of Service page; null hides the link.
tosUrl: null,
// --- header / nav -------------------------------------------------------
faviconUrl: '/static/favicon.svg',
// Where the current-user chip links. null renders it as a plain span (for
// apps with no profile page).
profileUrl: null,
// Where "Log Out" lands.
logoutRedirect: '/login',
// Admin-only "a newer release is available" banner, backed by
// GET /api/update-check. Apps without that endpoint set false.
updateCheck: false,
updateLabel: 'the jump host',
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
// item; an empty list means "always visible". Gating is done client-side by
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: []},
],
};
+27 -21
View File
@@ -1,24 +1,30 @@
</div> </div><!-- end spa-shell -->
<footer class="py-2 bg-dark text-light mt-4"> <!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2"> jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
<span class="d-flex align-items-center gap-2"> app.locals in app.js). Edit all three copies together. -->
<a href="https://theta42.com" target="_blank"> <footer class="py-2 bg-dark text-light mt-4">
<img width="64" src="/static/img/theta42.svg"/> <div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
</a> <span class="d-flex align-items-center gap-2">
&copy; <%- (new Date()).getFullYear() %> theta42 &middot; <a href="https://theta42.com" target="_blank">
<a href="https://github.com/theta42/jump-host/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a> <img width="64" src="/static/img/theta42.svg"/>
</span> </a>
<span class="d-flex align-items-center gap-3"> &copy; <%- buildYear %> theta42 &middot;
<a href="https://theta42.github.io/jump-host/" target="_blank" class="text-light text-decoration-none"> <a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
<i class="fa-solid fa-book"></i> Docs </span>
</a> <span class="d-flex align-items-center gap-3">
<a href="https://github.com/theta42/jump-host" target="_blank" class="text-light text-decoration-none"> <a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub <i class="fa-solid fa-book"></i> Docs
</a> </a>
</span> <a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
<span>v<%- version %> (<%- commit %>)</span> <i class="fa-brands fa-github"></i> GitHub
</div> </a>
</footer> <% if(ui.tosUrl){ %>
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
<% } %>
</span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body> </body>
</html> </html>
+12 -8
View File
@@ -4,14 +4,18 @@
// If we arrived from the OIDC callback with a token in the URL fragment, // If we arrived from the OIDC callback with a token in the URL fragment,
// store it and forward on before doing anything else. // store it and forward on before doing anything else.
if(!app.auth.consumeTokenFragment()){ if(!app.auth.consumeTokenFragment()){
app.auth.isLoggedIn(function(error, isLoggedIn){ // The reveal below touches an element further down this page, so wait
if(isLoggedIn){ // for the DOM — isLoggedIn can answer before the parser gets there.
app.auth.logInRedirect(); $(document).ready(function(){
}else{ app.auth.isLoggedIn(function(error, isLoggedIn){
// Reveal the login card once we know the user is not logged in. if(isLoggedIn){
document.getElementById('login-card-row').style.display = ''; app.auth.logInRedirect();
} }else{
}) // Reveal the login card once we know the user is not logged in.
document.getElementById('login-card-row').style.display = '';
}
});
});
} }
</script> </script>
+94 -23
View File
@@ -4,10 +4,17 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%- name %> <%- title %></title> <title><%- name %> <%- title %></title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
via app.locals in app.js). Edit all three copies together. -->
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
<!-- CSS are placed here -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css"> <link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
<link rel='stylesheet' href='/static/css/styles.css' /> <link rel='stylesheet' href='/static/css/styles.css' />
<!-- Scripts are placed here -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script> <script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script> <script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
@@ -28,51 +35,115 @@
</button> </button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent"> <div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav"> <ul class="navbar-nav top-nav">
<li class="nav-item"> <%# Items gated on a group start hidden (.group-required) and are
<a class="nav-link" href="/dashboard"><i class="fa-solid fa-gauge-high"></i> Dashboard</a> revealed by app-base.js for the groups the user is in. %>
</li> <% for(const item of ui.nav){ %>
<li class="nav-item"> <li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
<a class="nav-link" href="/sessions"><i class="fa-solid fa-plug-circle-bolt"></i> Sessions</a> <a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
</li> <%- item.label %>
<li class="nav-item"> </a>
<a class="nav-link" href="/audit"><i class="fa-solid fa-clipboard-list"></i> Audit</a>
</li> </li>
<% } %>
</ul> </ul>
<div class="form-inline mt-2 mt-md-0"> <div class="form-inline mt-2 mt-md-0">
<% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<% } else { %>
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;"> <span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span> <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</span> </span>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" href="/login" style="display: none;"> <% } %>
<i class="fas fa-sign-in"></i> Login <a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-in"></i>
Login
</a> </a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href='/login'; })" style="display: none;">
<i class="fas fa-sign-out"></i> Log Out <button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button> </button>
</div> </div>
</div> </div>
</nav> </nav>
<% if(ui.updateCheck){ %>
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
<span id="update-banner-text"></span>
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
</div>
<script type="text/javascript">
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
sessionStorage.setItem('update-banner-dismissed', '1');
}
function checkForUpdate(){
if(sessionStorage.getItem('update-banner-dismissed')) return;
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
</script>
<% } %>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
$('.top-nav a').each(function(){
var $this = $(this); // Set the correct link to active in the top nav bar
$('.top-nav a').each(function(index){
let $this = $(this);
$this.removeClass('active'); $this.removeClass('active');
if($this.attr('href').toLowerCase() === window.location.pathname.toLowerCase()){ if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
$this.addClass('active'); $this.addClass('active')
} }
}); })
app.auth.isLoggedIn(function(error, data){
if(data){ // Set the correct login/logout button, and reveal the current user's
// name once we know who they are. Group-gated nav items are revealed
// by app-base.js off the same cached user/me.
app.auth.isLoggedIn(function(error, me){
if(me){
$('#cl-logout-button').show(); $('#cl-logout-button').show();
if(data.username){ let username = me.uid || me.username;
$('#cl-username-text').text(data.username); if(username){
$('#cl-username-text').text(username);
$('#cl-username').css('display', ''); $('#cl-username').css('display', '');
} }
<% if(ui.updateCheck){ %>
if(me.isAdmin) checkForUpdate();
<% } %>
}else{ }else{
$('#cl-login-button').show(); $('#cl-login-button').show();
} }
}); });
}); });
</script> </script>
<div id="spa-shell" class="container-fluid" style="margin-top: 4.5rem;">
<!-- Container -->
<div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div>
+18
View File
@@ -11,7 +11,24 @@
module.exports = { module.exports = {
name: 'My Org', name: 'My Org',
// Standalone mode: run with no LDAP directory and no SSO Manager. When
// enabled, user auth and host discovery use the ORM-backed stores below
// instead of `ldap` + `sso` (both become unused). See the README's
// "Standalone mode" section for how to add users/hosts.
standalone: {
enabled: false,
},
// ORM config for standalone mode (Sequelize — any dialect works, not just
// sqlite). Ignored unless standalone.enabled is true.
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
// The directory the users live in (the SSO Manager's OpenLDAP). // The directory the users live in (the SSO Manager's OpenLDAP).
// Unused when standalone.enabled is true.
// //
// IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on // IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on
// the sshPublicKey attribute of user entries — the jump host injects its // the sshPublicKey attribute of user entries — the jump host injects its
@@ -36,6 +53,7 @@ module.exports = {
// SSO Manager directory (inventory) API. apiToken is a personal access // SSO Manager directory (inventory) API. apiToken is a personal access
// token (sso_<id>_<secret>) of any user that can read /api/discovery/*. // token (sso_<id>_<secret>) of any user that can read /api/discovery/*.
// Unused when standalone.enabled is true.
sso: { sso: {
url: 'https://sso.example.com', url: 'https://sso.example.com',
apiToken: 'sso_CHANGE_ME', apiToken: 'sso_CHANGE_ME',