From 25b0d57a9782b09efe726a00cce0dbf7945f141a Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 1 Aug 2026 21:15:16 -0400 Subject: [PATCH] feat(conf): mask SMTP/OAuth secrets + leave-blank-to-keep on /conf (v1.17.1) GET /api/conf no longer returns smtp.pass / oauth.jwtSecret in cleartext (masked to ********). POST treats a blank or ******** secret submission as "keep the stored value," so editing the From address or token lifetimes no longer requires re-entering or leaks the SMTP password / JWT secret. The /conf form fields carry a leave-unchanged hint. Storage stays in OpenBao at secret/sso-manager/conf (unchanged); no theta-suite policy change needed. Co-Authored-By: Claude --- API.md | 33 +++++++++++++++++++++++++++ CHANGELOG.md | 30 ++++++++++++++++++++++++ docs/vault.md | 2 +- nodejs/package.json | 2 +- nodejs/routes/api_conf.js | 48 ++++++++++++++++++++++++++++++++------- nodejs/views/conf.ejs | 10 +++++--- 6 files changed, 112 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index a881186..9f23cf1 100644 --- a/API.md +++ b/API.md @@ -1303,6 +1303,39 @@ Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, o **`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row. +## Configuration Endpoints + +Base path: `/api/conf` + +All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it. + +### Get Configuration + +**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`. + +**Response:** +```json +{ + "smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager " }, + "discovery": { }, + "oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } } +} +``` + +### Save Configuration + +**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret. + +**Request:** +```json +{ + "smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager " }, + "oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } } +} +``` + +**Response:** `{ "success": true }` + ## Error Responses All endpoints return errors in this format: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3a28e..a388384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ 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.17.1] - 2026-08-01 + +Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to +match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are +no longer returned in cleartext by `GET /api/conf` or round-tripped through the +form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime +(unchanged) — only how they're surfaced to the admin changes. + +### Changed +- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********` + (was: returned in cleartext). Non-secret fields (host, port, user, from, + secure, issuer, token lifetimes) are returned as before. +- **`POST /api/conf`** now treats a blank or `********` secret-field submission + as "keep the current stored value" — so an admin editing the From address or + token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT + secret. Only a genuinely new, non-blank value overwrites. The preserved values + are re-applied to live `conf` immediately, as before. +- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry + a "leave unchanged to keep the current value stored in OpenBao" hint; the page + copy notes secret fields are masked. No JSON-textarea editing is involved — + SMTP is and remains configured through structured form fields. + +### Notes +- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before + this release (via `POST /api/conf` → `baoConf.set('sso-manager/conf')`, and + overlaid back at boot by `bao-conf.init`). This release closes the + cleartext-exposure gap; it does not move the storage path. +- No theta-suite policy change required — `secret/sso-manager/conf` was already + granted to the `sso-broker` policy. + ## [1.17.0] - 2026-08-01 A real **plugin system**: the half-built discovery plugins (statically diff --git a/docs/vault.md b/docs/vault.md index 2dfcf55..a301682 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -20,7 +20,7 @@ When the environment is initialized via `setup.sh`, OpenBao is automatically uns The SSO Manager Vault can be accessed in two ways: -1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. +1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it. 2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token. ### API Example diff --git a/nodejs/package.json b/nodejs/package.json index a19c1cc..b9b804f 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.17.0", + "version": "1.17.1", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/api_conf.js b/nodejs/routes/api_conf.js index ba21072..aac87ba 100644 --- a/nodejs/routes/api_conf.js +++ b/nodejs/routes/api_conf.js @@ -12,12 +12,31 @@ router.use(async (req, res, next) => { } }); +// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned +// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or +// mask-valued submission preserves the stored value so an admin editing an +// unrelated field (e.g. the From address) doesn't have to re-enter — or leak — +// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline. +const MASK = '********'; +const SECRET_PATHS = [ + ['smtp', 'pass'], + ['oauth', 'jwtSecret'], +]; + +function maskSecrets(obj) { + const out = JSON.parse(JSON.stringify(obj)); + for (const [grp, key] of SECRET_PATHS) { + if (out[grp] && out[grp][key]) out[grp][key] = MASK; + } + return out; +} + router.get('/', async (req, res) => { - const editable = { + const editable = maskSecrets({ smtp: conf.smtp || {}, discovery: conf.discovery || {}, oauth: conf.oauth || {} - }; + }); res.json(editable); }); @@ -38,18 +57,31 @@ function applyToLiveConf(src) { router.post('/', async (req, res, next) => { try { const existing = await baoConf.get('sso-manager/conf') || {}; - // Deep merge req.body into existing - for (const key of Object.keys(req.body)) { - if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) { - existing[key] = { ...(existing[key] || {}), ...req.body[key] }; + const incoming = req.body || {}; + + // Preserve secret fields the admin left blank (or left showing the mask): + // drop them from the incoming merge so the stored value survives. Only a + // genuinely new, non-blank, non-mask value overwrites. + for (const [grp, key] of SECRET_PATHS) { + if (incoming[grp] && incoming[grp][key] !== undefined) { + const submitted = incoming[grp][key]; + if (submitted === '' || submitted === MASK) delete incoming[grp][key]; + } + } + + // Deep merge incoming into existing + for (const key of Object.keys(incoming)) { + if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) { + existing[key] = { ...(existing[key] || {}), ...incoming[key] }; } else { - existing[key] = req.body[key]; + existing[key] = incoming[key]; } } await baoConf.set('sso-manager/conf', existing); // Reflect the saved values in the live conf immediately (the next boot's // bao-conf.init() would pick them up too, but this keeps running readers - // current without a restart, as the old conf_manager did). + // current without a restart, as the old conf_manager did). `existing` + // carries the preserved secret values, so live conf keeps them too. applyToLiveConf(existing); res.json({ success: true }); } catch(err) { diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index 1d9f334..d9750e4 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -83,7 +83,9 @@

System Configuration

Manage runtime configuration such as SMTP settings and OAuth parameters. - These secrets are stored securely in OpenBao Vault. + These are stored securely in OpenBao and take effect immediately. Secret fields + (the SMTP password and OAuth JWT secret) are masked — leave them unchanged to + keep the stored value.

@@ -115,9 +117,10 @@
- +
+
Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.
@@ -144,9 +147,10 @@
- +
+
Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.