diff --git a/CHANGELOG.md b/CHANGELOG.md index 82cc4c4..5d9abe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. +## [1.4.0] - 2026-07-25 + +### Security +- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either. + +### Fixed +- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed. +- `GET /api/discovery/resources?group=` now returns 200 with `{ results: [...] }` instead of 404 (the autoRouter's `search` supported `?group=`, but the route was effectively unreachable for jump-host's call pattern). + +### Added +- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org: + - `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection. + - `@simpleworkjs/ldap` — `models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged. + - `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers. +- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller. + +### Changed +- Dependency alignment: `ldapts` `^8.1.2` → `^8.1.8`. 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). + ## [1.3.2] - 2026-07-23 ### Fixed diff --git a/nodejs/app.js b/nodejs/app.js index 4888417..d4c3143 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -108,9 +108,6 @@ app.use(function(req, res, next) { next(err); }); -// Discovery API -app.use('/api/discovery', middleware.auth, require('./routes/api_discovery')); - // Error handling app.use(function(err, req, res, next) { const SILENT_404S = ['/.well-known/']; diff --git a/nodejs/models/group_ldap.js b/nodejs/models/group_ldap.js index 4f939aa..92722cc 100644 --- a/nodejs/models/group_ldap.js +++ b/nodejs/models/group_ldap.js @@ -3,44 +3,18 @@ const { Client, Attribute, Change } = require('ldapts'); const { LRUCache } = require('lru-cache'); const conf = require('@simpleworkjs/conf').ldap; - -// Escape a value used inside an LDAP search filter (RFC 4515). -function escapeLDAPSearchValue(val) { - return String(val) - .replace(/\\/g, '\\5c') - .replace(/\*/g, '\\2a') - .replace(/\(/g, '\\28') - .replace(/\)/g, '\\29') - .replace(/\0/g, '\\00'); -} - -// Escape a value used in an LDAP DN (RFC 4514). Defensive: usernames/cns -// are normally alphanumeric, but this prevents metacharacter injection. -function escapeLDAPDNValue(val) { - return String(val) - .replace(/\\/g, '\\\\') - .replace(/,/g, '\\,') - .replace(/\+/g, '\\+') - .replace(/"/g, '\\"') - .replace(//g, '\\>') - .replace(/;/g, '\\;') - .replace(/=/g, '\\=') - .replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match); -} +// Connection + escaping from the shared @simpleworkjs/ldap package. Local +// wrappers preserve the no-arg call signatures; see user_ldap.js for rationale. +const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap'); +const escapeLDAPSearchValue = escapeFilter; +const escapeLDAPDNValue = escapeDN; function makeClient() { - return new Client({ url: conf.url }); + return _makeClient(conf); } async function withClient(fn) { - const client = makeClient(); - try { - await client.bind(conf.bindDN, conf.bindPassword); - return await fn(client); - } finally { - await client.unbind().catch(() => {}); - } + return _withClient(conf, fn); } async function getGroups(client, member){ diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index d935ed6..dd82393 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token'); const {Group} = require('./group_ldap'); const {UserVerification} = require('./verification'); const conf = require('@simpleworkjs/conf').ldap; +// Connection + escaping come from the shared @simpleworkjs/ldap package. The +// wrappers below preserve this file's no-arg call signatures (makeClient() / +// withClient(fn)) so no call site changes; sso's makeClient passes no +// tlsOptions, which the shared client forwards as undefined — identical to the +// previous `new Client({ url: conf.url })`. +const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap'); +const escapeLDAPSearchValue = escapeFilter; +const escapeLDAPDNValue = escapeDN; function hashPasswordSSHA512(password) { const salt = crypto.randomBytes(8); @@ -23,40 +31,11 @@ const cache = new LRUCache({ }); function makeClient() { - return new Client({ url: conf.url }); + return _makeClient(conf); } async function withClient(fn) { - const client = makeClient(); - try { - await client.bind(conf.bindDN, conf.bindPassword); - return await fn(client); - } finally { - await client.unbind().catch(() => {}); - } -} - -// Helper to escape LDAP filter values (crucial for security) -function escapeLDAPSearchValue(val) { - return val.replace(/\\/g, '\\5c') - .replace(/\*/g, '\\2a') - .replace(/\(/g, '\\28') - .replace(/\)/g, '\\29') - .replace(/\0/g, '\\00'); -} - -// Escape a value used in an LDAP DN (RFC 4514). -function escapeLDAPDNValue(val) { - return String(val) - .replace(/\\/g, '\\\\') - .replace(/,/g, '\\,') - .replace(/\+/g, '\\+') - .replace(/"/g, '\\"') - .replace(//g, '\\>') - .replace(/;/g, '\\;') - .replace(/=/g, '\\=') - .replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match); + return _withClient(conf, fn); } // Compute the next available uid/gidNumber: the highest existing value below diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 961583e..385b615 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -11,7 +11,10 @@ "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", + "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", + "@simpleworkjs/directory-schema": "^1.0.0", + "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", @@ -23,7 +26,7 @@ "jq-repeat": "^2.2.0", "jquery": "^3.7.1", "jsonwebtoken": "^9.0.3", - "ldapts": "^8.1.2", + "ldapts": "^8.1.8", "lru-cache": "^11.5.1", "marked": "^9.1.6", "model-redis": "^1.6.0", @@ -1242,6 +1245,18 @@ "@redis/client": "^6.1.0" } }, + "node_modules/@simpleworkjs/app-stack": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz", + "integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==", + "license": "MIT", + "dependencies": { + "express": "^5.2.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@simpleworkjs/conf": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz", @@ -1254,6 +1269,27 @@ "node": ">=16.0.0" } }, + "node_modules/@simpleworkjs/directory-schema": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.0.0.tgz", + "integrity": "sha512-thZhPGNdDYlD8rlhXidnbCHTKjdSkj9ag1zE/gz1AwuclYypsKAP+v3BAvcZ/YDQP8RBDJNPXof5EpVheLovTg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@simpleworkjs/ldap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz", + "integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==", + "license": "MIT", + "dependencies": { + "ldapts": "^8.1.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@simpleworkjs/orm": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 8f2f874..948511f 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.3.2", + "version": "1.4.0", "description": "A very simple LDAP management and SSO system", "author": [ { @@ -24,6 +24,9 @@ "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", "@simpleworkjs/conf": "^1.2.0", + "@simpleworkjs/app-stack": "^1.0.0", + "@simpleworkjs/ldap": "^1.0.0", + "@simpleworkjs/directory-schema": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", @@ -35,7 +38,7 @@ "jq-repeat": "^2.2.0", "jquery": "^3.7.1", "jsonwebtoken": "^9.0.3", - "ldapts": "^8.1.2", + "ldapts": "^8.1.8", "lru-cache": "^11.5.1", "marked": "^9.1.6", "model-redis": "^1.6.0", diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 9c88998..46a0320 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -3,6 +3,7 @@ const router = require('express').Router(); const permission = require('../utils/permission'); const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource'); const { Group } = require('../models/group_ldap'); +const { projectResources } = require('@simpleworkjs/directory-schema'); // Require the admin group router.use(async (req, res, next) => { @@ -18,7 +19,9 @@ router.use(async (req, res, next) => { router.get('/resources', async (req, res, next) => { try { const resources = await Resource.list(); - res.json({ results: resources }); + // Even admins never receive secret metadata (e.g. client_secret_hash) over + // the wire; projectResources strips it unconditionally. + res.json({ results: projectResources(resources, { fullMetadata: true }) }); } catch (err) { next(err); } }); diff --git a/nodejs/routes/api_discovery.js b/nodejs/routes/api_discovery.js deleted file mode 100644 index baab398..0000000 --- a/nodejs/routes/api_discovery.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -const router = require('express').Router(); -const { Resource, ResourceGroup } = require('../models/resource'); - -// GET /api/discovery/me -// Returns the list of resources the current user has access to. -router.get('/me', async (req, res, next) => { - try { - const userGroups = req.user.groups || []; // array of LDAP group CNs - const accessibleResourceIds = new Set(); - - if (req.user.isMachine) { - // Machines only have access to themselves by default - accessibleResourceIds.add(req.resourceId); - } else { - // End users get access via groups - const allGroups = await ResourceGroup.list(); - for (const rg of allGroups) { - if (userGroups.includes(rg.groupCn)) { - accessibleResourceIds.add(rg.resourceId); - } - } - } - - // Fetch all resources and filter - const allResources = await Resource.list(); - const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic); - - res.json({ results: accessible }); - } catch (err) { - next(err); - } -}); - -module.exports = router; diff --git a/nodejs/routes/discovery.js b/nodejs/routes/discovery.js index 9c116a2..9acf543 100644 --- a/nodejs/routes/discovery.js +++ b/nodejs/routes/discovery.js @@ -1,4 +1,79 @@ -const autoRouter = require('./autoRouter'); -const { Resource } = require('../models/resource'); +'use strict'; -module.exports = autoRouter(Resource); +// Public directory discovery API. Mounted at /api/discovery (app.js, before +// the 404 catcher). Every response uses the `{ results }` envelope and the +// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an +// OAuth client's client_secret_hash) never leave the server and non-admins only +// see the public metadata allowlist. +// +// This replaces the autoRouter mount (which returned bare arrays — the shape +// jump-host's `data.results || []` silently collapsed to `[]`, so no user could +// bridge) and absorbs the dead /me handler that used to live in +// routes/api_discovery.js (mounted after the 404, so unreachable). + +const router = require('express').Router(); +const { Resource, ResourceGroup } = require('../models/resource'); +const { + envelope, + projectResource, + projectResources, + isDirectoryAdmin, +} = require('@simpleworkjs/directory-schema'); + +// GET /api/discovery/resources[?kind=&group=&parent=] +router.get('/resources', async (req, res, next) => { + try { + const resources = await Resource.search(req.query); + res.json(envelope(projectResources(resources, { fullMetadata: isDirectoryAdmin(req.user) }))); + } catch (err) { next(err); } +}); + +// GET /api/discovery/resources/:slug +router.get('/resources/:slug', async (req, res, next) => { + try { + const resource = await Resource.getBySlug(req.params.slug); + // parents/children are edges (no secrets); project only the resource body. + const projected = projectResource(resource, { fullMetadata: isDirectoryAdmin(req.user) }); + projected.parents = resource.parents; + projected.children = resource.children; + res.json(envelope(projected)); + } catch (err) { next(err); } +}); + +// GET /api/discovery/graph +router.get('/graph', async (req, res, next) => { + try { + const graph = await Resource.getGraph(); + res.json(envelope({ + resources: projectResources(graph.resources, { fullMetadata: isDirectoryAdmin(req.user) }), + edges: graph.edges, + })); + } catch (err) { next(err); } +}); + +// GET /api/discovery/me +// Returns the resources the current caller can reach. Machines see only their +// own resource; humans get the union of their LDAP groups' resources plus +// anything flagged isPublic. Uses req.user.groups (populated by the auth +// middleware for session/PAT callers) rather than re-querying LDAP by DN, so it +// works for every auth transport without assuming a .dn is present. +router.get('/me', async (req, res, next) => { + try { + let accessible; + if (req.user && req.user.isMachine) { + accessible = await Resource.list({ where: { id: req.resourceId } }); + } else { + const userGroups = (req.user && req.user.groups) || []; + const ids = new Set(); + if (userGroups.length) { + const rgs = await ResourceGroup.list({ where: { groupCn: { in: userGroups } } }); + for (const rg of rgs) ids.add(rg.resourceId); + } + const all = await Resource.list(); + accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic)); + } + res.json(envelope(projectResources(accessible, { fullMetadata: isDirectoryAdmin(req.user) }))); + } catch (err) { next(err); } +}); + +module.exports = router; \ No newline at end of file diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 0350ae2..f235313 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -10,6 +10,7 @@ const {InviteToken, PasswordResetToken} = require('./../models/token'); const {Tos} = require('../models/tos'); const conf = require('@simpleworkjs/conf'); const buildInfo = require('../utils/build_info'); +const { mountStaticModules } = require('@simpleworkjs/app-stack'); const values ={ title: conf.environment !== 'production' ? `dev` : '', @@ -20,24 +21,16 @@ const values ={ } // List of front end node modules to be served -const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome', - 'moment', '@popper', 'jq-repeat', -]; - -// Server front end modules -// https://stackoverflow.com/a/55700773/3140931 // Vendor libraries only change when package versions are bumped (a rebuild), // so they're safe to cache aggressively; ETag/Last-Modified (on by default) -// still cover that rare case with a cheap 304 instead of a stale asset. -frontEndModules.forEach(dep => { - router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'})) +// still cover that rare case with a cheap 304 instead of a stale asset. The +// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on +// every deploy and isn't cache-busted/fingerprinted. +mountStaticModules(router, { + root: path.join(__dirname, '..'), + deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'], }); -// Have express server static content( images, CSS, browser JS) from the public -// local folder. Shorter maxAge than /static-modules since this is the app's -// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted. -router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'})) - // Public health endpoint for container/orchestration healthchecks. // Mounted at / (no auth) in app.js, so this is intentionally unauthenticated. router.get('/health', function(req, res) { diff --git a/nodejs/tests/discovery.test.js b/nodejs/tests/discovery.test.js new file mode 100644 index 0000000..ff3611b --- /dev/null +++ b/nodejs/tests/discovery.test.js @@ -0,0 +1,111 @@ +'use strict'; + +// Directory discovery API — security + contract regression coverage. +// +// These tests run under the jest + docker harness (redis + the test seed). +// They lock in the two fixes from the @simpleworkjs/directory-schema release: +// 1. /api/discovery/* returns the { results } envelope (not a bare array — +// the drift that made jump-host's `data.results || []` collapse to []). +// 2. No response path leaks secret metadata (e.g. an OAuth client's +// client_secret_hash), regardless of caller. +// +// The core assertions hold for any authenticated caller. The admin-projection +// assertion (fullMetadata for directory admins) additionally requires the `test` +// seed user to be a member of app_sso_directory_admin — see setup.js. + +const { login, request, app } = require('./setup'); + +let token; + +beforeAll(async () => { + token = await login(); +}); + +function assertNoSecrets(results, path) { + for (const r of results || []) { + // toBeUndefined() in this jest version takes no message arg, so assert + // manually and throw with context — this also surfaces the leaked value + // if the projection ever regresses. + const secretHash = r.metadata && r.metadata.client_secret_hash; + if (secretHash !== undefined) { + throw new Error( + `client_secret_hash leaked from ${path} on ${r.slug || r.id} (value: ${JSON.stringify(secretHash)})` + ); + } + if (r.metadata) { + for (const k of Object.keys(r.metadata)) { + if (/secret|password|privatekey/i.test(k)) { + throw new Error(`secret-ish key "${k}" leaked from ${path} on ${r.slug || r.id}`); + } + } + } + } +} + +describe('Discovery — envelope + security', () => { + test('GET /api/discovery/resources returns 200 with { results } (not a bare array)', async () => { + const res = await request(app).get('/api/discovery/resources').set('auth-token', token); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.results)).toBe(true); + expect(Array.isArray(res.body)).toBe(false); // never a bare array + }); + + test('GET /api/discovery/resources never leaks client_secret_hash', async () => { + const res = await request(app).get('/api/discovery/resources').set('auth-token', token); + assertNoSecrets(res.body.results, '/resources'); + }); + + test('GET /api/discovery/resources?group= returns 200 (regression: was 404)', async () => { + const res = await request(app) + .get('/api/discovery/resources?group=host_web01_access') + .set('auth-token', token); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.results)).toBe(true); + }); + + test('GET /api/discovery/graph returns { results: { resources, edges } } and strips secrets', async () => { + const res = await request(app).get('/api/discovery/graph').set('auth-token', token); + expect(res.status).toBe(200); + expect(res.body.results).toBeDefined(); + expect(Array.isArray(res.body.results.resources)).toBe(true); + assertNoSecrets(res.body.results.resources, '/graph'); + }); + + test('GET /api/discovery/me returns 200 with { results } and strips secrets', async () => { + const res = await request(app).get('/api/discovery/me').set('auth-token', token); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.results)).toBe(true); + assertNoSecrets(res.body.results, '/me'); + }); + + test('GET /api/discovery/resources/:slug returns 200 + { results } for a known slug', async () => { + // Seed-dependent: pick the first slug from the list, then fetch it. + const list = await request(app).get('/api/discovery/resources').set('auth-token', token); + const slug = list.body.results[0] && list.body.results[0].slug; + if (!slug) return; // empty seed — skip rather than fail + const res = await request(app) + .get(`/api/discovery/resources/${encodeURIComponent(slug)}`) + .set('auth-token', token); + expect(res.status).toBe(200); + expect(res.body.results).toBeDefined(); + expect(res.body.results.slug).toBe(slug); + assertNoSecrets([res.body.results], '/resources/:slug'); + }); +}); + +describe('Discovery — admin projection (requires test user in app_sso_directory_admin)', () => { + // If the seed `test` user is a directory admin, /resources should keep + // admin-only (non-secret) metadata like redirect_uris/token_lifetime for + // them. If not, this assertion is skipped — the no-secrets assertion above + // already covers the security guarantee for every caller. + test('admin callers keep token_lifetime / redirect_uris (non-secret admin keys)', async () => { + const res = await request(app).get('/api/discovery/resources?kind=oauth').set('auth-token', token); + const oauth = (res.body.results || []).find(r => r.kind === 'oauth'); + if (!oauth) return; // no oauth resource seeded + // Only meaningful if the caller is an admin; non-admins correctly get + // the public allowlist (no redirect_uris). We assert the absence of + // secrets regardless, and skip the positive admin check without a known + // admin seed. + expect(oauth.metadata && oauth.metadata.client_secret_hash).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/nodejs/utils/build_info.js b/nodejs/utils/build_info.js index 4cc3f83..e5ca1dc 100644 --- a/nodejs/utils/build_info.js +++ b/nodejs/utils/build_info.js @@ -1,29 +1,16 @@ 'use strict'; -const fs = require('fs'); +// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the +// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit +// (../ from here in utils/), matching the Dockerfile.openldap gitinfo stage; +// cwd is utils/ for the bare-metal git fallback. + const path = require('path'); -const { execSync } = require('child_process'); -const { version: buildVersion } = require('../package.json'); +const { createBuildInfo } = require('@simpleworkjs/app-stack'); +const { version } = require('../package.json'); -// Docker builds bake the commit hash into ../.build_commit (see the gitinfo -// stage in Dockerfile.openldap) -- the final image has no git binary and no -// .git directory, so `git rev-parse` below always fails there. Bare-metal/dev -// runs have no baked file, so they fall back to asking git directly. -function readBuildHash() { - try { - const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim(); - if (baked) return baked; - } catch (_) {} - - try { - return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim(); - } catch (_) { - return 'unknown'; - } -} - -module.exports = { - buildVersion, - buildHash: readBuildHash(), - buildYear: new Date().getFullYear(), -}; +module.exports = createBuildInfo({ + version, + buildCommitPath: path.join(__dirname, '../.build_commit'), + cwd: __dirname, +}); \ No newline at end of file