openvibers/openvibe.trade Commits
App and module tokens act only for their on_behalf_of person; sandbox tokens refused
Changes
@@@ -13,6 +13,8 @@* X-OV-Subject names the person it acts for (watchlists and alert rules need one);* X-OV-Origin: ai marks AI output (OpenVibe.AI's trade.summarize_market_context), which is* never attributed to a person and stays a draft until a person reviews it.++ * Developer-app (app:…) and module (mod:…) tokens act only for the person in their++ * on_behalf_of claim: X-OV-Subject naming anyone else is 403, and sandbox tokens are 401.** Identity never comes from a request body or query. A request presenting a service token is judged* on that token alone: a bad one is refused (problem+json), never downgraded to anonymous.@@@ -39,15 +41,28 @@ function createViewerResolver({ auth, config }) {if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });if (!r.ok) throw new ViewerError(401, r.code, r.reason);++ // Developer apps (app:…) and modules (mod:…) are third parties: they act only for the person++ // who authorized them (on_behalf_of), never for whoever X-OV-Subject names. Only first-party++ // service principals (svc:…) are trusted to name the acting person.++ const claims = r.claims;++ const firstParty = claims.actor_type === 'service' && String(claims.sub).startsWith('svc:');++ if (!firstParty && claims.env !== undefined && claims.env !== 'production') {++ throw new ViewerError(401, 'token.sandbox_refused', 'sandbox tokens are not accepted by openvibe.trade');++ }const originHeader = req.get('x-ov-origin');if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');const subjectHeader = req.get('x-ov-subject');let subject = null;if (subjectHeader) {if (!ids.isSubjectId('user', subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');++ if (!firstParty && subjectHeader !== claims.on_behalf_of) {++ throw new ViewerError(403, 'subject.not_delegated', 'an app acts only for the person who authorized it (on_behalf_of)');++ }subject = subjectHeader;++ } else if (!firstParty && ids.isSubjectId('user', claims.on_behalf_of)) {++ subject = claims.on_behalf_of;}-- return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', editor: false };++ return { kind: 'service', service: claims.sub, claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', editor: false };}async function fromUserToken(token) {nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * Third-party principals: a developer app (app:…) or module (mod:…) token acts only for the person++ * in its on_behalf_of claim. X-OV-Subject naming anyone else is refused (403 subject.not_delegated),++ * and so are sandbox tokens (401 token.sandbox_refused). First-party services (svc:…) still name the++ * person they act for.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const APP = 'app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';++const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';++++(async () => {++ const t = await boot();++ const caps = ['trade.watchlist.read', 'trade.watchlist.create'];++ const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.trade'], cap: caps, extra });++ const secret = t.ctx.watchlists.create(t.alice.subject, { name: 'Alice secret plans' });++++ await check('an app or module cannot read or write someone else\'s watchlists by naming them in X-OV-Subject', async () => {++ for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {++ for (const extra of [{ on_behalf_of: t.bob.subject }, {}]) {++ const read = await t.get('/api/v1/watchlists', { as: token(sub, type, extra), headers: { 'x-ov-subject': t.alice.subject } });++ assert.strictEqual(read.status, 403, `${type} ${JSON.stringify(extra)}: ${read.text}`);++ assert.strictEqual(read.json().code, 'subject.not_delegated');++ assert.doesNotMatch(read.text, /secret plans/);++ const one = await t.get(`/api/v1/watchlists/${secret.id}`, { as: token(sub, type, extra), headers: { 'x-ov-subject': t.alice.subject } });++ assert.strictEqual(one.status, 403);++ const write = await t.get('/api/v1/watchlists', { as: token(sub, type, extra), headers: { 'x-ov-subject': t.alice.subject }, json: { name: 'Planted' } });++ assert.strictEqual(write.status, 403);++ }++ }++ assert.deepStrictEqual(t.ctx.watchlists.forOwner(t.alice.subject).map((w) => w.name), ['Alice secret plans']);++ });++++ await check('an app acts for its on_behalf_of person (the header is optional and must match)', async () => {++ const bobApp = token(APP, 'app', { on_behalf_of: t.bob.subject });++ const c = await t.get('/api/v1/watchlists', { as: bobApp, json: { name: 'Bob via app' } });++ assert.strictEqual(c.status, 201, c.text);++ const list = await t.get('/api/v1/watchlists', { as: bobApp, headers: { 'x-ov-subject': t.bob.subject } });++ assert.strictEqual(list.status, 200, list.text);++ assert.deepStrictEqual(list.json().watchlists.map((w) => w.name), ['Bob via app']);++ assert.strictEqual((await t.get(`/api/v1/watchlists/${secret.id}`, { as: bobApp })).status, 404, 'not bob\'s');++ });++++ await check('sandbox app tokens are refused', async () => {++ const r = await t.get('/api/v1/watchlists', { as: token(APP, 'app', { on_behalf_of: t.alice.subject, env: 'sandbox' }) });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.json().code, 'token.sandbox_refused');++ });++++ await check('first-party services still name the person they act for', async () => {++ const r = await t.get('/api/v1/watchlists', { as: t.network.serviceToken('tools', caps), headers: { 'x-ov-subject': t.alice.subject } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json().watchlists.map((w) => w.name), ['Alice secret plans']);++ });++++ await t.close();++ done();++})();@@@ -52,8 +52,8 @@ async function startNetwork() {return json(404, { error: 'not found' });});issuer = srv.url;-- function signService({ sub, aud, cap }) {-- return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);++ function signService({ sub, aud, cap, actorType = 'service', extra = {} }) {++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);}function addUser(username, extra = {}) {return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
* X-OV-Subject names the person it acts for (watchlists and alert rules need one);
* X-OV-Subject names the person it acts for (watchlists and alert rules need one);
* X-OV-Origin: ai marks AI output (OpenVibe.AI's trade.summarize_market_context), which is
* X-OV-Origin: ai marks AI output (OpenVibe.AI's trade.summarize_market_context), which is
* never attributed to a person and stays a draft until a person reviews it.
* never attributed to a person and stays a draft until a person reviews it.
+ * Developer-app (app:…) and module (mod:…) tokens act only for the person in their
+ * on_behalf_of claim: X-OV-Subject naming anyone else is 403, and sandbox tokens are 401.
*
*
* Identity never comes from a request body or query. A request presenting a service token is judged
* Identity never comes from a request body or query. A request presenting a service token is judged
* on that token alone: a bad one is refused (problem+json), never downgraded to anonymous.
* on that token alone: a bad one is refused (problem+json), never downgraded to anonymous.
@@ -39,15 +41,28 @@ function createViewerResolver({ auth, config }) {
@@ -39,15 +41,28 @@ function createViewerResolver({ auth, config }) {
if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
if (!r.ok) throw new ViewerError(401, r.code, r.reason);
if (!r.ok) throw new ViewerError(401, r.code, r.reason);
+ // Developer apps (app:…) and modules (mod:…) are third parties: they act only for the person
+ // who authorized them (on_behalf_of), never for whoever X-OV-Subject names. Only first-party
+ // service principals (svc:…) are trusted to name the acting person.
+ const claims = r.claims;
+ const firstParty = claims.actor_type === 'service' && String(claims.sub).startsWith('svc:');
+ if (!firstParty && claims.env !== undefined && claims.env !== 'production') {
+ throw new ViewerError(401, 'token.sandbox_refused', 'sandbox tokens are not accepted by openvibe.trade');
+ }
const originHeader = req.get('x-ov-origin');
const originHeader = req.get('x-ov-origin');
if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
const subjectHeader = req.get('x-ov-subject');
const subjectHeader = req.get('x-ov-subject');
let subject = null;
let subject = null;
if (subjectHeader) {
if (subjectHeader) {
if (!ids.isSubjectId('user', subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
if (!ids.isSubjectId('user', subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
+ if (!firstParty && subjectHeader !== claims.on_behalf_of) {
+ throw new ViewerError(403, 'subject.not_delegated', 'an app acts only for the person who authorized it (on_behalf_of)');
+ }
subject = subjectHeader;
subject = subjectHeader;
+ } else if (!firstParty && ids.isSubjectId('user', claims.on_behalf_of)) {
+ subject = claims.on_behalf_of;
}
}
- return { kind: 'service', service: r.claims.sub, claims: r.claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', editor: false };
+ return { kind: 'service', service: claims.sub, claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', editor: false };
}
}
async function fromUserToken(token) {
async function fromUserToken(token) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * Third-party principals: a developer app (app:…) or module (mod:…) token acts only for the person
+ * in its on_behalf_of claim. X-OV-Subject naming anyone else is refused (403 subject.not_delegated),
+ * and so are sandbox tokens (401 token.sandbox_refused). First-party services (svc:…) still name the
+ * person they act for.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const APP = 'app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
+const MOD = 'mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C';
+
+(async () => {
+ const t = await boot();
+ const caps = ['trade.watchlist.read', 'trade.watchlist.create'];
+ const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.trade'], cap: caps, extra });
+ const secret = t.ctx.watchlists.create(t.alice.subject, { name: 'Alice secret plans' });
+
+ await check('an app or module cannot read or write someone else\'s watchlists by naming them in X-OV-Subject', async () => {
+ for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
+ for (const extra of [{ on_behalf_of: t.bob.subject }, {}]) {
+ const read = await t.get('/api/v1/watchlists', { as: token(sub, type, extra), headers: { 'x-ov-subject': t.alice.subject } });
+ assert.strictEqual(read.status, 403, `${type} ${JSON.stringify(extra)}: ${read.text}`);
+ assert.strictEqual(read.json().code, 'subject.not_delegated');
+ assert.doesNotMatch(read.text, /secret plans/);
+ const one = await t.get(`/api/v1/watchlists/${secret.id}`, { as: token(sub, type, extra), headers: { 'x-ov-subject': t.alice.subject } });
+ assert.strictEqual(one.status, 403);
+ const write = await t.get('/api/v1/watchlists', { as: token(sub, type, extra), headers: { 'x-ov-subject': t.alice.subject }, json: { name: 'Planted' } });
+ assert.strictEqual(write.status, 403);
+ }
+ }
+ assert.deepStrictEqual(t.ctx.watchlists.forOwner(t.alice.subject).map((w) => w.name), ['Alice secret plans']);
+ });
+
+ await check('an app acts for its on_behalf_of person (the header is optional and must match)', async () => {
+ const bobApp = token(APP, 'app', { on_behalf_of: t.bob.subject });
+ const c = await t.get('/api/v1/watchlists', { as: bobApp, json: { name: 'Bob via app' } });
+ assert.strictEqual(c.status, 201, c.text);
+ const list = await t.get('/api/v1/watchlists', { as: bobApp, headers: { 'x-ov-subject': t.bob.subject } });
+ assert.strictEqual(list.status, 200, list.text);
+ assert.deepStrictEqual(list.json().watchlists.map((w) => w.name), ['Bob via app']);
+ assert.strictEqual((await t.get(`/api/v1/watchlists/${secret.id}`, { as: bobApp })).status, 404, 'not bob\'s');
+ });
+
+ await check('sandbox app tokens are refused', async () => {
+ const r = await t.get('/api/v1/watchlists', { as: token(APP, 'app', { on_behalf_of: t.alice.subject, env: 'sandbox' }) });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.json().code, 'token.sandbox_refused');
+ });
+
+ await check('first-party services still name the person they act for', async () => {
+ const r = await t.get('/api/v1/watchlists', { as: t.network.serviceToken('tools', caps), headers: { 'x-ov-subject': t.alice.subject } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json().watchlists.map((w) => w.name), ['Alice secret plans']);
+ });
+
+ await t.close();
+ done();
+})();
@@ -52,8 +52,8 @@ async function startNetwork() {
@@ -52,8 +52,8 @@ async function startNetwork() {
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
- function signService({ sub, aud, cap }) {
- return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);
+ function signService({ sub, aud, cap, actorType = 'service', extra = {} }) {
+ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);
}
}
function addUser(username, extra = {}) {
function addUser(username, extra = {}) {
return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };