openvibers/openvibe.network Commits

service-principal.js: create/rotate a service's client credentials straight into its env file (0600, never printed); list clients

OP OpenVibers committed 23 Sept 2026 · deedd64793 · 3 files, +105 −1


Changes

ddiff --git a/package.json b/package.json
iindex 787d543d..1231e7ff 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js",
++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/setup/service-principal.js b/server/setup/service-principal.js
nnew file mode 100644
iindex 00000000..ddb34b09
---- /dev/null
++++ b/server/setup/service-principal.js
@@@ -0,0 +1,68 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Provision or rotate a service principal's client credentials (roadmap Wave 1, ADR-003).
++ *
++ * sudo node server/setup/service-principal.js create <id> --env-file /etc/openvibe/<id>.env [--db data/network.db]
++ * sudo node server/setup/service-principal.js rotate <id> --env-file /etc/openvibe/<id>.env
++ * node server/setup/service-principal.js list
++ *
++ * A service principal is an OAuth client with no redirect URIs (it can only use client_credentials).
++ * The secret is written straight into the service's env file as OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET
++ * (file mode 0600) and is never printed. Grants are separate (principal_grants); this only makes the
++ * identity exist.
++ */
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++const Database = require('better-sqlite3');
++
++const ID_RE = /^[a-z][a-z0-9-]{1,39}$/;
++
++function upsertEnv(file, pairs) {
++ const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [];
++ const keys = new Set(Object.keys(pairs));
++ const kept = lines.filter(l => !keys.has((l.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/) || [])[1]));
++ while (kept.length && kept[kept.length - 1] === '') kept.pop();
++ for (const [k, v] of Object.entries(pairs)) kept.push(`${k}=${v}`);
++ fs.writeFileSync(file, kept.join('\n') + '\n', { mode: 0o600 });
++ fs.chmodSync(file, 0o600);
++}
++
++function main(argv) {
++ const [cmd, id] = argv;
++ const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };
++ const dbPath = path.resolve(opt('db') || process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'network.db'));
++ const db = new Database(dbPath, { fileMustExist: true });
++ try {
++ if (cmd === 'list') {
++ for (const r of db.prepare("SELECT client_id, name, redirect_uris FROM oauth_clients ORDER BY client_id").all()) {
++ const kind = JSON.parse(r.redirect_uris || '[]').length ? 'site' : 'service';
++ console.log(`${r.client_id}\t${kind}\t${r.name}`);
++ }
++ return 0;
++ }
++ if (!['create', 'rotate'].includes(cmd) || !ID_RE.test(id || '')) {
++ console.error('usage: service-principal.js create|rotate <id> --env-file <path> | list');
++ return 2;
++ }
++ const envFile = opt('env-file');
++ if (!envFile) { console.error('--env-file is required (the secret is written there, never printed)'); return 2; }
++ const secret = crypto.randomBytes(32).toString('base64url');
++ const existing = db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);
++ if (cmd === 'create') {
++ if (existing) { console.error(`${id} already exists; use rotate`); return 1; }
++ db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, '[]', 1)")
++ .run(id, secret, `OpenVibe.${id} (service)`);
++ } else {
++ if (!existing) { console.error(`${id} does not exist; use create`); return 1; }
++ db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, id);
++ }
++ upsertEnv(envFile, { OV_OAUTH_CLIENT_ID: id, OV_OAUTH_CLIENT_SECRET: secret });
++ console.log(`${cmd === 'create' ? 'created' : 'rotated'} service principal ${id}; credentials written to ${envFile} (0600). Restart the service to pick them up.`);
++ return 0;
++ } finally { db.close(); }
++}
++
++if (require.main === module) process.exitCode = main(process.argv.slice(2));
++module.exports = { main, upsertEnv };
ddiff --git a/test/service-principal.test.js b/test/service-principal.test.js
nnew file mode 100644
iindex 00000000..ffb16014
---- /dev/null
++++ b/test/service-principal.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++// server/setup/service-principal.js: create/rotate a service client; the secret lands in the env file (0600),
++// never on stdout; existing env lines are kept.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { initDb } = require('../server/db/database');
++const sp = require('../server/setup/service-principal');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sp-'));
++const dbPath = path.join(dir, 'network.db');
++const log = console.log; console.log = () => {}; initDb(dbPath).close(); console.log = log;
++const env = path.join(dir, 'events.env');
++fs.writeFileSync(env, 'PORT=4300\nOV_OAUTH_CLIENT_SECRET=old\n');
++
++const out = [];
++console.log = (...a) => out.push(a.join(' '));
++assert.strictEqual(sp.main(['create', 'events', '--env-file', env, '--db', dbPath]), 0);
++assert.strictEqual(sp.main(['create', 'events', '--env-file', env, '--db', dbPath]), 1, 'no double create');
++const first = fs.readFileSync(env, 'utf8');
++assert.strictEqual(sp.main(['rotate', 'events', '--env-file', env, '--db', dbPath]), 0);
++console.log = log;
++const second = fs.readFileSync(env, 'utf8');
++const secretOf = (s) => s.match(/^OV_OAUTH_CLIENT_SECRET=(.+)$/m)[1];
++assert.ok(/^PORT=4300$/m.test(second), 'other lines kept');
++assert.strictEqual((second.match(/OV_OAUTH_CLIENT_SECRET=/g) || []).length, 1, 'one secret line');
++assert.notStrictEqual(secretOf(first), secretOf(second), 'rotate changes the secret');
++assert.strictEqual(fs.statSync(env).mode & 0o777, 0o600);
++assert.ok(!out.join('\n').includes(secretOf(second)) && !out.join('\n').includes(secretOf(first)), 'secrets are never printed');
++const Database = require('better-sqlite3');
++const row = new Database(dbPath).prepare("SELECT client_secret, redirect_uris FROM oauth_clients WHERE client_id = 'events'").get();
++assert.strictEqual(row.client_secret, secretOf(second));
++assert.strictEqual(row.redirect_uris, '[]', 'service principals cannot do user sign-in');
++fs.rmSync(dir, { recursive: true, force: true });
++console.log('service principal provisioning: all checks passed');