openvibers/openvibe.network Commits
service-principal.js: create/rotate a service's client credentials straight into its env file (0600, never printed); list clients
Changes
@@@ -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"},nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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');
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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');