openvibers/openvibe.network Commits
Service principals: client_credentials tokens scoped by principal_grants; coins and notification pushes accept them alongside X-Internal-Key
Changes
@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0","openvibe-shared": "file:./packages/openvibe-shared","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -1464,9 +1464,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.1.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",-- "integrity": "sha512-HfrmCKJe6WuleFUo2FGBQl+6ABEh07gxyH+QG+n42TK/nRBuSPo5jbt7FMAvrwB1JlQ4BbqfNbcoTU02qt8Jsw==",++ "version": "0.2.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0",++ "integrity": "sha512-ARq8C+5x6CDimij62hOjqYa3nu4liwxPpPaACp3khb50YHeOPgMFJiXKZlbhsVTTE4xHe/FcMhkcRZAob3iTzw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -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",++ "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","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0","openvibe-shared": "file:./packages/openvibe-shared","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -9,6 +9,7 @@ const express = require('express');const crypto = require('crypto');const jwt = require('jsonwebtoken');const subjects = require('../identity/subjects');++const principals = require('../identity/principals');const router = express.Router();function getDb(req) { return req.app.locals.db; }@@@ -134,6 +135,17 @@ router.post('/token', (req, res) => {const config = getConfig(req);const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;++ // Service principals (Wave 1): a first-party service trades its client credentials for a++ // short-lived, capability-scoped token. Checked before the user-grant path below.++ if (grant_type === 'client_credentials') {++ const out = principals.issueToken(db, {++ clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,++ privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,++ });++ res.set('Cache-Control', 'no-store');++ return res.status(out.status).json(out.body);++ }++// Validate client credentialsconst client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);if (!client || client.client_secret !== client_secret) {@@@ -783,6 +783,8 @@ function initDb(dbPath) {// Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed, idempotent.require('../identity/subjects').ensureSchema(db);++ // Service principals: grants per OAuth client + usage counters (server/identity/principals.js).++ require('../identity/principals').ensureSchema(db);console.log('[DB] Central database initialized');return db;nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++/**++ * Service principals (roadmap Wave 1, ADR-003; contract identity.service-token-claims@1).++ *++ * A first-party service authenticates to /oauth/token with grant_type=client_credentials and the++ * OAuth client id/secret it already has, and receives a 5-minute RS256 token whose `cap` claim is++ * exactly what principal_grants allows it for the requested audience. Receivers check the one++ * capability each route performs (openvibe-contracts serviceAuth.requireCapability).++ *++ * The shared X-Internal-Key keeps working in parallel. principal_usage counts, per caller and route,++ * how each request authenticated, so the key can be retired once legacy use reaches zero.++ */++const crypto = require('crypto');++const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');++++const TOKEN_TTL_S = 300;++const SELF_AUDIENCE = 'openvibe.network';++++// Initial grants: what each service does against Network today (docs/roadmap-baseline/04-cross-service.md).++const DEFAULT_GRANTS = [++ ['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],++ ['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],++ ['live', 'network.coins.transfer', SELF_AUDIENCE, ['live']],++ ['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],++];++++function ensureSchema(db) {++ db.exec(`++ CREATE TABLE IF NOT EXISTS principal_grants (++ client_id TEXT NOT NULL,++ capability TEXT NOT NULL,++ audience TEXT NOT NULL,++ namespaces TEXT NOT NULL DEFAULT '[]',++ granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ granted_by TEXT,++ revoked_at DATETIME,++ PRIMARY KEY (client_id, capability, audience)++ );++ CREATE TABLE IF NOT EXISTS principal_usage (++ principal TEXT NOT NULL,++ route TEXT NOT NULL,++ capability TEXT NOT NULL,++ auth TEXT NOT NULL,++ allowed INTEGER NOT NULL,++ code TEXT NOT NULL DEFAULT '',++ count INTEGER NOT NULL DEFAULT 0,++ first_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ last_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (principal, route, auth, allowed, code)++ );++ `);++ const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");++ for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {++ if (db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) seed.run(client, cap, aud, JSON.stringify(ns));++ }++}++++function grantsFor(db, clientId, audience) {++ return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL ORDER BY capability')++ .all(clientId, audience).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));++}++++function sameSecret(a, b) {++ const x = Buffer.from(String(a || ''));++ const y = Buffer.from(String(b || ''));++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);++}++++/**++ * grant_type=client_credentials. Returns { status, body } (OAuth error shapes on failure).++ * `scope` (space-separated capability ids) narrows the token; omitted = every grant for the audience.++ */++function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {++ const client = db.prepare('SELECT client_id, client_secret FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));++ if (!client || !sameSecret(client.client_secret, clientSecret)) return { status: 401, body: { error: 'invalid_client', error_description: 'Invalid client credentials' } };++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(client.client_id)) return { status: 400, body: { error: 'unauthorized_client', error_description: 'client id is not a service principal' } };++ const aud = String(audience || '').trim();++ if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };++ const grants = grantsFor(db, client.client_id, aud);++ const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;++ const chosen = wanted ? grants.filter(g => wanted.includes(g.capability)) : grants;++ if (wanted && wanted.some(w => !chosen.find(g => g.capability === w))) {++ return { status: 400, body: { error: 'invalid_scope', error_description: `not granted: ${wanted.filter(w => !chosen.find(g => g.capability === w)).join(' ')}` } };++ }++ if (!chosen.length) return { status: 400, body: { error: 'invalid_scope', error_description: `no grants for audience ${aud}` } };++ const now = Math.floor(Date.now() / 1000);++ const claims = {++ iss: issuer, sub: `svc:${client.client_id}`, actor_type: 'service', aud: [aud],++ cap: chosen.map(g => g.capability),++ ns: [...new Set(chosen.flatMap(g => g.namespaces))],++ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,++ };++ assertValid('identity.service-token-claims@1', claims);++ return { status: 200, body: { access_token: serviceAuth.signServiceToken(claims, privateKey), token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: claims.cap.join(' ') } };++}++++/** Audit hook for requireCapability: one counter row per (principal, route, auth, outcome). */++function recordDecision(db) {++ const up = db.prepare(`INSERT INTO principal_usage (principal, route, capability, auth, allowed, code, count) VALUES (?, ?, ?, ?, ?, ?, 1)++ ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);++ return ({ req, capability, principal, allowed, code }) => {++ const who = principal ? (principal.legacy ? 'legacy-key' : principal.sub) : 'unknown';++ const auth = principal && principal.legacy ? 'internal-key' : 'service-token';++ try { up.run(who, `${req.method} ${req.baseUrl || ''}${req.route ? req.route.path : req.path}`, capability, auth, allowed ? 1 : 0, code || ''); } catch { /* best effort */ }++ };++}++++/**++ * Guard for a Network internal route. `ownApp(req)` returns the app id the request acts for; a++ * service token may only act for its own app (svc:live -> app_id 'live'). Legacy-key callers are++ * unchanged.++ */++function guard(capability, { ownApp } = {}) {++ if (!capabilities.get(capability)) throw new Error(`unknown capability ${capability}`);++ let check = null;++ let record = null;++ return function principalGuard(req, res, next) {++ if (!check) {++ record = recordDecision(req.app.locals.db);++ check = serviceAuth.requireCapability(capability, {++ getPublicKey: (r) => r.app.locals.publicKey,++ issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,++ audience: SELF_AUDIENCE,++ legacy: (r) => r.internalKeyOk === true,++ // Denials are final here; an allow is recorded below, after the ownership check.++ onDecision: (d) => { if (!d.allowed) record(d); },++ });++ }++ check(req, res, () => {++ const principal = req.principal;++ if (ownApp && principal && !principal.legacy) {++ const app = ownApp(req);++ const self = String(principal.sub).replace(/^svc:/, '');++ if (app !== undefined && app !== self) {++ record({ req, capability, principal, allowed: false, code: 'capability.owner_denied' });++ return http.sendProblem(res, 403, 'capability.owner_denied', { detail: `${principal.sub} may only act for app_id '${self}'` });++ }++ }++ record({ req, capability, principal, allowed: true, code: null });++ next();++ });++ };++}++++module.exports = { ensureSchema, issueToken, guard, grantsFor, recordDecision, DEFAULT_GRANTS, TOKEN_TTL_S, SELF_AUDIENCE };@@@ -20,11 +20,19 @@ function getConfig(req) { return req.app.locals.config; }function requireInternalKey(req, res, next) {const key = req.headers['x-internal-key'];const config = getConfig(req);-- if (!key || key !== config.internalKey) {-- return res.status(403).json({ error: 'Invalid or missing internal key' });++ if (key && key === config.internalKey) {++ req.internalKeyOk = true;++ return next();}-- next();++ // Routes guarded by a capability also accept a service-principal token instead of the key;++ // principals.guard() on the route verifies it (and refuses anything it can't verify).++ if (String(req.headers.authorization || '').startsWith('Bearer ') && TOKEN_ROUTES.has(`${req.method} ${req.path}`)) return next();++ return res.status(403).json({ error: 'Invalid or missing internal key' });}++const principals = require('../identity/principals');++const TOKEN_ROUTES = new Set(['POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk']);++const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);++const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);router.use(requireInternalKey);router.use('/identity', require('../identity/internal-routes'));@@@ -249,7 +257,7 @@ router.get('/coins/stats', (req, res) => {// ── POST /internal/coins/credit ──────────────────────────────// Body: { user_id, app_id, amount (positive int), reason, ref?, idempotency_key }// → { balance }--router.post('/coins/credit', (req, res) => {++router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), (req, res) => {try {const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};const result = wallet.credit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });@@@ -261,7 +269,7 @@ router.post('/coins/credit', (req, res) => {// ── POST /internal/coins/debit ───────────────────────────────// Same body → { balance }; insufficient funds → 409 { error: 'insufficient_funds', balance }--router.post('/coins/debit', (req, res) => {++router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: forApp }), (req, res) => {try {const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};const result = wallet.debit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });@@@ -274,7 +282,7 @@ router.post('/coins/debit', (req, res) => {// ── POST /internal/coins/transfer ────────────────────────────// Body: { from_user_id, to_user_id, app_id, amount, reason, ref?, idempotency_key }// → { from_balance, to_balance } (atomic)--router.post('/coins/transfer', (req, res) => {++router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownApp: forApp }), (req, res) => {try {const { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};const result = wallet.transfer(getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });@@@ -418,7 +426,7 @@ router.post('/events/stream-live', async (req, res) => {// ── Push Single Notification ─────────────────────────────────// POST /internal/notifications/push// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }--router.post('/notifications/push', (req, res) => {++router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {const notifService = req.app.locals.notificationService;if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });@@@ -438,7 +446,7 @@ router.post('/notifications/push', (req, res) => {// ── Push Bulk Notifications ──────────────────────────────────// POST /internal/notifications/push-bulk// Body: { user_ids: [], type, title, message, ... }--router.post('/notifications/push-bulk', (req, res) => {++router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {const notifService = req.app.locals.notificationService;if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++// Service principals (server/identity/principals.js): client_credentials tokens, capability guards on++// /internal routes, the legacy-key compatibility path and the usage audit. Roadmap Wave 1, ADR-003.++// node test/principals.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const { serviceAuth, validate } = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++++db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();++db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const ISSUER = 'https://openvibe.network';++const app = express();++app.use(express.json());++app.use(express.urlencoded({ extended: true }));++app.locals.db = db;++app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.use('/oauth', require('../server/auth/oauth-routes'));++app.use('/internal', require('../server/internal/routes'));++const server = http.createServer(app);++++(async () => {++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const token = (form) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', ...form }) })++ .then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }));++ const post = (p, body, headers) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })++ .then(async r => ({ status: r.status, type: r.headers.get('content-type') || '', body: await r.json() }));++ const credit = (extra = {}) => ({ user_id: 7, app_id: 'live', amount: 5, reason: 'test', idempotency_key: `k-${crypto.randomBytes(4).toString('hex')}`, ...extra });++++ // ── Issuing ──++ let t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));++ assert.strictEqual(t.cache, 'no-store');++ const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });++ assert.ok(claims.ok, claims.reason);++ assert.strictEqual(claims.claims.sub, 'svc:live');++ assert.deepStrictEqual(claims.claims.cap, ['network.coins.credit', 'network.coins.debit', 'network.coins.transfer', 'network.notifications.push']);++ assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);++ assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');++ const full = t.body.access_token;++++ t = await token({ client_id: 'live', client_secret: 'wrong', audience: 'openvibe.network' });++ assert.strictEqual(t.status, 401); assert.strictEqual(t.body.error, 'invalid_client');++ t = await token({ client_id: 'live', client_secret: 'live-secret' });++ assert.strictEqual(t.status, 400, 'audience required');++ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit' });++ assert.strictEqual(t.status, 200); assert.strictEqual(t.body.scope, 'network.coins.credit', 'scope narrows the token');++ const creditOnly = t.body.access_token;++ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit identity.subject.resolve' });++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'asking for an ungranted capability fails');++ t = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.network' });++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'a client with no grants gets no token');++ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.media' });++ assert.strictEqual(t.status, 400, 'no grants for that audience');++++ // ── Guarded routes ──++ let r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${full}` });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.strictEqual(r.body.balance, 5);++ r = await post('/internal/coins/credit', credit({ app_id: 'games' }), { authorization: `Bearer ${full}` });++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token acts only for its own app');++ assert.ok(r.type.startsWith('application/problem+json'));++ r = await post('/internal/coins/debit', credit({ amount: 1 }), { authorization: `Bearer ${creditOnly}` });++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');++ r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');++ r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });++ assert.strictEqual(r.status, 401, 'a bad token is not rescued by the legacy key');++ const forged = serviceAuth.signServiceToken({ ...claims.claims, jti: 'tok_forged000' }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey);++ r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${forged}` });++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.bad_signature');++ const expired = serviceAuth.signServiceToken({ ...claims.claims, iat: 1000, exp: 1300 }, keys.privateKey);++ r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${expired}` });++ assert.strictEqual(r.body.code, 'token.expired');++ const wrongAud = serviceAuth.signServiceToken({ ...claims.claims, aud: ['openvibe.media'] }, keys.privateKey);++ r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${wrongAud}` });++ assert.strictEqual(r.body.code, 'token.wrong_audience');++ r = await post('/internal/notifications/push', { user_id: 7, service: 'games', title: 'x' }, { authorization: `Bearer ${full}` });++ assert.strictEqual(r.status, 403, 'notifications are sent only as the principal\'s own service');++ r = await fetch(`${base}/internal/coins/stats`, { headers: { authorization: `Bearer ${full}` } }).then(x => ({ status: x.status }));++ assert.strictEqual(r.status, 403, 'unguarded internal routes still need the key');++ r = await post('/internal/identity/legacy-map', { entries: [] }, { authorization: `Bearer ${full}` });++ assert.strictEqual(r.status, 403, 'token routes are an explicit list');++++ // ── Legacy key keeps working ──++ r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key' });++ assert.strictEqual(r.status, 200);++ r = await post('/internal/coins/credit', credit(), {});++ assert.strictEqual(r.status, 403);++++ // ── Revocation: new tokens stop carrying a revoked grant ──++ db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = 'live' AND capability = 'network.coins.debit'").run();++ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });++ assert.ok(!t.body.scope.split(' ').includes('network.coins.debit'));++++ // ── Usage audit ──++ const usage = db.prepare('SELECT principal, route, auth, allowed, code, count FROM principal_usage ORDER BY principal, route, allowed').all();++ assert.ok(usage.some(u => u.principal === 'svc:live' && u.route === 'POST /internal/coins/credit' && u.allowed === 1 && u.count === 1));++ assert.ok(usage.some(u => u.principal === 'legacy-key' && u.auth === 'internal-key' && u.allowed === 1));++ assert.ok(usage.some(u => u.code === 'capability.denied' && u.allowed === 0));++ assert.ok(usage.some(u => u.code === 'capability.owner_denied' && u.allowed === 0), 'ownership denials are audited as denials');++ assert.ok(usage.some(u => u.code === 'token.bad_signature'));++++ server.close(); db.close();++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('principals: all checks passed');++})().catch(err => { console.error(err); process.exit(1); });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -1464,9 +1464,9 @@
@@ -1464,9 +1464,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.1.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
- "integrity": "sha512-HfrmCKJe6WuleFUo2FGBQl+6ABEh07gxyH+QG+n42TK/nRBuSPo5jbt7FMAvrwB1JlQ4BbqfNbcoTU02qt8Jsw==",
+ "version": "0.2.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0",
+ "integrity": "sha512-ARq8C+5x6CDimij62hOjqYa3nu4liwxPpPaACp3khb50YHeOPgMFJiXKZlbhsVTTE4xHe/FcMhkcRZAob3iTzw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -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",
+ "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",
"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"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.2.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -9,6 +9,7 @@ const express = require('express');
@@ -9,6 +9,7 @@ const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const subjects = require('../identity/subjects');
const subjects = require('../identity/subjects');
+const principals = require('../identity/principals');
const router = express.Router();
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
@@ -134,6 +135,17 @@ router.post('/token', (req, res) => {
@@ -134,6 +135,17 @@ router.post('/token', (req, res) => {
const config = getConfig(req);
const config = getConfig(req);
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
+ // Service principals (Wave 1): a first-party service trades its client credentials for a
+ // short-lived, capability-scoped token. Checked before the user-grant path below.
+ if (grant_type === 'client_credentials') {
+ const out = principals.issueToken(db, {
+ clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
+ privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
+ });
+ res.set('Cache-Control', 'no-store');
+ return res.status(out.status).json(out.body);
+ }
+
// Validate client credentials
// Validate client credentials
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
if (!client || client.client_secret !== client_secret) {
if (!client || client.client_secret !== client_secret) {
@@ -783,6 +783,8 @@ function initDb(dbPath) {
@@ -783,6 +783,8 @@ function initDb(dbPath) {
// Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed, idempotent.
// Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed, idempotent.
require('../identity/subjects').ensureSchema(db);
require('../identity/subjects').ensureSchema(db);
+ // Service principals: grants per OAuth client + usage counters (server/identity/principals.js).
+ require('../identity/principals').ensureSchema(db);
console.log('[DB] Central database initialized');
console.log('[DB] Central database initialized');
return db;
return db;
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+/**
+ * Service principals (roadmap Wave 1, ADR-003; contract identity.service-token-claims@1).
+ *
+ * A first-party service authenticates to /oauth/token with grant_type=client_credentials and the
+ * OAuth client id/secret it already has, and receives a 5-minute RS256 token whose `cap` claim is
+ * exactly what principal_grants allows it for the requested audience. Receivers check the one
+ * capability each route performs (openvibe-contracts serviceAuth.requireCapability).
+ *
+ * The shared X-Internal-Key keeps working in parallel. principal_usage counts, per caller and route,
+ * how each request authenticated, so the key can be retired once legacy use reaches zero.
+ */
+const crypto = require('crypto');
+const { serviceAuth, capabilities, assertValid, http } = require('openvibe-contracts');
+
+const TOKEN_TTL_S = 300;
+const SELF_AUDIENCE = 'openvibe.network';
+
+// Initial grants: what each service does against Network today (docs/roadmap-baseline/04-cross-service.md).
+const DEFAULT_GRANTS = [
+ ['live', 'network.coins.credit', SELF_AUDIENCE, ['live']],
+ ['live', 'network.coins.debit', SELF_AUDIENCE, ['live']],
+ ['live', 'network.coins.transfer', SELF_AUDIENCE, ['live']],
+ ['live', 'network.notifications.push', SELF_AUDIENCE, ['live']],
+];
+
+function ensureSchema(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS principal_grants (
+ client_id TEXT NOT NULL,
+ capability TEXT NOT NULL,
+ audience TEXT NOT NULL,
+ namespaces TEXT NOT NULL DEFAULT '[]',
+ granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ granted_by TEXT,
+ revoked_at DATETIME,
+ PRIMARY KEY (client_id, capability, audience)
+ );
+ CREATE TABLE IF NOT EXISTS principal_usage (
+ principal TEXT NOT NULL,
+ route TEXT NOT NULL,
+ capability TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ allowed INTEGER NOT NULL,
+ code TEXT NOT NULL DEFAULT '',
+ count INTEGER NOT NULL DEFAULT 0,
+ first_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (principal, route, auth, allowed, code)
+ );
+ `);
+ const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");
+ for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {
+ if (db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) seed.run(client, cap, aud, JSON.stringify(ns));
+ }
+}
+
+function grantsFor(db, clientId, audience) {
+ return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL ORDER BY capability')
+ .all(clientId, audience).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));
+}
+
+function sameSecret(a, b) {
+ const x = Buffer.from(String(a || ''));
+ const y = Buffer.from(String(b || ''));
+ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
+}
+
+/**
+ * grant_type=client_credentials. Returns { status, body } (OAuth error shapes on failure).
+ * `scope` (space-separated capability ids) narrows the token; omitted = every grant for the audience.
+ */
+function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {
+ const client = db.prepare('SELECT client_id, client_secret FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
+ if (!client || !sameSecret(client.client_secret, clientSecret)) return { status: 401, body: { error: 'invalid_client', error_description: 'Invalid client credentials' } };
+ if (!/^[a-z][a-z0-9-]{1,39}$/.test(client.client_id)) return { status: 400, body: { error: 'unauthorized_client', error_description: 'client id is not a service principal' } };
+ const aud = String(audience || '').trim();
+ if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };
+ const grants = grantsFor(db, client.client_id, aud);
+ const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
+ const chosen = wanted ? grants.filter(g => wanted.includes(g.capability)) : grants;
+ if (wanted && wanted.some(w => !chosen.find(g => g.capability === w))) {
+ return { status: 400, body: { error: 'invalid_scope', error_description: `not granted: ${wanted.filter(w => !chosen.find(g => g.capability === w)).join(' ')}` } };
+ }
+ if (!chosen.length) return { status: 400, body: { error: 'invalid_scope', error_description: `no grants for audience ${aud}` } };
+ const now = Math.floor(Date.now() / 1000);
+ const claims = {
+ iss: issuer, sub: `svc:${client.client_id}`, actor_type: 'service', aud: [aud],
+ cap: chosen.map(g => g.capability),
+ ns: [...new Set(chosen.flatMap(g => g.namespaces))],
+ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
+ };
+ assertValid('identity.service-token-claims@1', claims);
+ return { status: 200, body: { access_token: serviceAuth.signServiceToken(claims, privateKey), token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: claims.cap.join(' ') } };
+}
+
+/** Audit hook for requireCapability: one counter row per (principal, route, auth, outcome). */
+function recordDecision(db) {
+ const up = db.prepare(`INSERT INTO principal_usage (principal, route, capability, auth, allowed, code, count) VALUES (?, ?, ?, ?, ?, ?, 1)
+ ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);
+ return ({ req, capability, principal, allowed, code }) => {
+ const who = principal ? (principal.legacy ? 'legacy-key' : principal.sub) : 'unknown';
+ const auth = principal && principal.legacy ? 'internal-key' : 'service-token';
+ try { up.run(who, `${req.method} ${req.baseUrl || ''}${req.route ? req.route.path : req.path}`, capability, auth, allowed ? 1 : 0, code || ''); } catch { /* best effort */ }
+ };
+}
+
+/**
+ * Guard for a Network internal route. `ownApp(req)` returns the app id the request acts for; a
+ * service token may only act for its own app (svc:live -> app_id 'live'). Legacy-key callers are
+ * unchanged.
+ */
+function guard(capability, { ownApp } = {}) {
+ if (!capabilities.get(capability)) throw new Error(`unknown capability ${capability}`);
+ let check = null;
+ let record = null;
+ return function principalGuard(req, res, next) {
+ if (!check) {
+ record = recordDecision(req.app.locals.db);
+ check = serviceAuth.requireCapability(capability, {
+ getPublicKey: (r) => r.app.locals.publicKey,
+ issuer: req.app.locals.config.jwt && req.app.locals.config.jwt.issuer,
+ audience: SELF_AUDIENCE,
+ legacy: (r) => r.internalKeyOk === true,
+ // Denials are final here; an allow is recorded below, after the ownership check.
+ onDecision: (d) => { if (!d.allowed) record(d); },
+ });
+ }
+ check(req, res, () => {
+ const principal = req.principal;
+ if (ownApp && principal && !principal.legacy) {
+ const app = ownApp(req);
+ const self = String(principal.sub).replace(/^svc:/, '');
+ if (app !== undefined && app !== self) {
+ record({ req, capability, principal, allowed: false, code: 'capability.owner_denied' });
+ return http.sendProblem(res, 403, 'capability.owner_denied', { detail: `${principal.sub} may only act for app_id '${self}'` });
+ }
+ }
+ record({ req, capability, principal, allowed: true, code: null });
+ next();
+ });
+ };
+}
+
+module.exports = { ensureSchema, issueToken, guard, grantsFor, recordDecision, DEFAULT_GRANTS, TOKEN_TTL_S, SELF_AUDIENCE };
@@ -20,11 +20,19 @@ function getConfig(req) { return req.app.locals.config; }
@@ -20,11 +20,19 @@ function getConfig(req) { return req.app.locals.config; }
function requireInternalKey(req, res, next) {
function requireInternalKey(req, res, next) {
const key = req.headers['x-internal-key'];
const key = req.headers['x-internal-key'];
const config = getConfig(req);
const config = getConfig(req);
- if (!key || key !== config.internalKey) {
- return res.status(403).json({ error: 'Invalid or missing internal key' });
+ if (key && key === config.internalKey) {
+ req.internalKeyOk = true;
+ return next();
}
}
- next();
+ // Routes guarded by a capability also accept a service-principal token instead of the key;
+ // principals.guard() on the route verifies it (and refuses anything it can't verify).
+ if (String(req.headers.authorization || '').startsWith('Bearer ') && TOKEN_ROUTES.has(`${req.method} ${req.path}`)) return next();
+ return res.status(403).json({ error: 'Invalid or missing internal key' });
}
}
+const principals = require('../identity/principals');
+const TOKEN_ROUTES = new Set(['POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk']);
+const forApp = (req) => (req.body && req.body.app_id !== undefined ? String(req.body.app_id) : undefined);
+const forService = (req) => (req.body && req.body.service !== undefined ? String(req.body.service) : undefined);
router.use(requireInternalKey);
router.use(requireInternalKey);
router.use('/identity', require('../identity/internal-routes'));
router.use('/identity', require('../identity/internal-routes'));
@@ -249,7 +257,7 @@ router.get('/coins/stats', (req, res) => {
@@ -249,7 +257,7 @@ router.get('/coins/stats', (req, res) => {
// ── POST /internal/coins/credit ──────────────────────────────
// ── POST /internal/coins/credit ──────────────────────────────
// Body: { user_id, app_id, amount (positive int), reason, ref?, idempotency_key }
// Body: { user_id, app_id, amount (positive int), reason, ref?, idempotency_key }
// → { balance }
// → { balance }
-router.post('/coins/credit', (req, res) => {
+router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), (req, res) => {
try {
try {
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const result = wallet.credit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
const result = wallet.credit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
@@ -261,7 +269,7 @@ router.post('/coins/credit', (req, res) => {
@@ -261,7 +269,7 @@ router.post('/coins/credit', (req, res) => {
// ── POST /internal/coins/debit ───────────────────────────────
// ── POST /internal/coins/debit ───────────────────────────────
// Same body → { balance }; insufficient funds → 409 { error: 'insufficient_funds', balance }
// Same body → { balance }; insufficient funds → 409 { error: 'insufficient_funds', balance }
-router.post('/coins/debit', (req, res) => {
+router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: forApp }), (req, res) => {
try {
try {
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const result = wallet.debit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
const result = wallet.debit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
@@ -274,7 +282,7 @@ router.post('/coins/debit', (req, res) => {
@@ -274,7 +282,7 @@ router.post('/coins/debit', (req, res) => {
// ── POST /internal/coins/transfer ────────────────────────────
// ── POST /internal/coins/transfer ────────────────────────────
// Body: { from_user_id, to_user_id, app_id, amount, reason, ref?, idempotency_key }
// Body: { from_user_id, to_user_id, app_id, amount, reason, ref?, idempotency_key }
// → { from_balance, to_balance } (atomic)
// → { from_balance, to_balance } (atomic)
-router.post('/coins/transfer', (req, res) => {
+router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownApp: forApp }), (req, res) => {
try {
try {
const { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const result = wallet.transfer(getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });
const result = wallet.transfer(getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });
@@ -418,7 +426,7 @@ router.post('/events/stream-live', async (req, res) => {
@@ -418,7 +426,7 @@ router.post('/events/stream-live', async (req, res) => {
// ── Push Single Notification ─────────────────────────────────
// ── Push Single Notification ─────────────────────────────────
// POST /internal/notifications/push
// POST /internal/notifications/push
// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }
// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }
-router.post('/notifications/push', (req, res) => {
+router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
@@ -438,7 +446,7 @@ router.post('/notifications/push', (req, res) => {
@@ -438,7 +446,7 @@ router.post('/notifications/push', (req, res) => {
// ── Push Bulk Notifications ──────────────────────────────────
// ── Push Bulk Notifications ──────────────────────────────────
// POST /internal/notifications/push-bulk
// POST /internal/notifications/push-bulk
// Body: { user_ids: [], type, title, message, ... }
// Body: { user_ids: [], type, title, message, ... }
-router.post('/notifications/push-bulk', (req, res) => {
+router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+// Service principals (server/identity/principals.js): client_credentials tokens, capability guards on
+// /internal routes, the legacy-key compatibility path and the usage audit. Roadmap Wave 1, ADR-003.
+// node test/principals.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const { serviceAuth, validate } = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+
+db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
+db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const ISSUER = 'https://openvibe.network';
+const app = express();
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+app.locals.db = db;
+app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.use('/oauth', require('../server/auth/oauth-routes'));
+app.use('/internal', require('../server/internal/routes'));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const token = (form) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', ...form }) })
+ .then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }));
+ const post = (p, body, headers) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
+ .then(async r => ({ status: r.status, type: r.headers.get('content-type') || '', body: await r.json() }));
+ const credit = (extra = {}) => ({ user_id: 7, app_id: 'live', amount: 5, reason: 'test', idempotency_key: `k-${crypto.randomBytes(4).toString('hex')}`, ...extra });
+
+ // ── Issuing ──
+ let t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });
+ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
+ assert.strictEqual(t.cache, 'no-store');
+ const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
+ assert.ok(claims.ok, claims.reason);
+ assert.strictEqual(claims.claims.sub, 'svc:live');
+ assert.deepStrictEqual(claims.claims.cap, ['network.coins.credit', 'network.coins.debit', 'network.coins.transfer', 'network.notifications.push']);
+ assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
+ assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
+ const full = t.body.access_token;
+
+ t = await token({ client_id: 'live', client_secret: 'wrong', audience: 'openvibe.network' });
+ assert.strictEqual(t.status, 401); assert.strictEqual(t.body.error, 'invalid_client');
+ t = await token({ client_id: 'live', client_secret: 'live-secret' });
+ assert.strictEqual(t.status, 400, 'audience required');
+ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit' });
+ assert.strictEqual(t.status, 200); assert.strictEqual(t.body.scope, 'network.coins.credit', 'scope narrows the token');
+ const creditOnly = t.body.access_token;
+ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit identity.subject.resolve' });
+ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'asking for an ungranted capability fails');
+ t = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.network' });
+ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'a client with no grants gets no token');
+ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.media' });
+ assert.strictEqual(t.status, 400, 'no grants for that audience');
+
+ // ── Guarded routes ──
+ let r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${full}` });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body)); assert.strictEqual(r.body.balance, 5);
+ r = await post('/internal/coins/credit', credit({ app_id: 'games' }), { authorization: `Bearer ${full}` });
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token acts only for its own app');
+ assert.ok(r.type.startsWith('application/problem+json'));
+ r = await post('/internal/coins/debit', credit({ amount: 1 }), { authorization: `Bearer ${creditOnly}` });
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');
+ r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
+ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');
+ r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });
+ assert.strictEqual(r.status, 401, 'a bad token is not rescued by the legacy key');
+ const forged = serviceAuth.signServiceToken({ ...claims.claims, jti: 'tok_forged000' }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey);
+ r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${forged}` });
+ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.bad_signature');
+ const expired = serviceAuth.signServiceToken({ ...claims.claims, iat: 1000, exp: 1300 }, keys.privateKey);
+ r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${expired}` });
+ assert.strictEqual(r.body.code, 'token.expired');
+ const wrongAud = serviceAuth.signServiceToken({ ...claims.claims, aud: ['openvibe.media'] }, keys.privateKey);
+ r = await post('/internal/coins/credit', credit(), { authorization: `Bearer ${wrongAud}` });
+ assert.strictEqual(r.body.code, 'token.wrong_audience');
+ r = await post('/internal/notifications/push', { user_id: 7, service: 'games', title: 'x' }, { authorization: `Bearer ${full}` });
+ assert.strictEqual(r.status, 403, 'notifications are sent only as the principal\'s own service');
+ r = await fetch(`${base}/internal/coins/stats`, { headers: { authorization: `Bearer ${full}` } }).then(x => ({ status: x.status }));
+ assert.strictEqual(r.status, 403, 'unguarded internal routes still need the key');
+ r = await post('/internal/identity/legacy-map', { entries: [] }, { authorization: `Bearer ${full}` });
+ assert.strictEqual(r.status, 403, 'token routes are an explicit list');
+
+ // ── Legacy key keeps working ──
+ r = await post('/internal/coins/credit', credit(), { 'x-internal-key': 'legacy-key' });
+ assert.strictEqual(r.status, 200);
+ r = await post('/internal/coins/credit', credit(), {});
+ assert.strictEqual(r.status, 403);
+
+ // ── Revocation: new tokens stop carrying a revoked grant ──
+ db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = 'live' AND capability = 'network.coins.debit'").run();
+ t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });
+ assert.ok(!t.body.scope.split(' ').includes('network.coins.debit'));
+
+ // ── Usage audit ──
+ const usage = db.prepare('SELECT principal, route, auth, allowed, code, count FROM principal_usage ORDER BY principal, route, allowed').all();
+ assert.ok(usage.some(u => u.principal === 'svc:live' && u.route === 'POST /internal/coins/credit' && u.allowed === 1 && u.count === 1));
+ assert.ok(usage.some(u => u.principal === 'legacy-key' && u.auth === 'internal-key' && u.allowed === 1));
+ assert.ok(usage.some(u => u.code === 'capability.denied' && u.allowed === 0));
+ assert.ok(usage.some(u => u.code === 'capability.owner_denied' && u.allowed === 0), 'ownership denials are audited as denials');
+ assert.ok(usage.some(u => u.code === 'token.bad_signature'));
+
+ server.close(); db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('principals: all checks passed');
+})().catch(err => { console.error(err); process.exit(1); });