openvibers/openvibe.deals Commits
App and module tokens act only for their on_behalf_of person; sandbox tokens refused
Changes
@@@ -39,15 +39,28 @@ function createViewerResolver({ auth, config, people }) {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.deals');++ }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', staff: false };++ return { kind: 'service', service: claims.sub, claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', staff: false };}async function fromUserToken(token) {@@@ -65,8 +65,8 @@ async function startNetwork() {return json(404, { error: 'not found' });});issuer = srv.url;-- function signService({ sub, aud, cap, ns }) {-- return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, ...(ns ? { ns } : {}), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);++ function signService({ sub, aud, cap, ns, actorType = 'service', extra = {} }) {++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, ...(ns ? { ns } : {}), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);}/** extra.mintedAt: the time embedded in the usr_ ULID (default: long ago, i.e. an established account). */function addUser(username, extra = {}) {@@@ -122,6 +122,25 @@ const { sourceItem } = require('./helpers/mocks');assert.strictEqual(denied.status, 403);});++ await check('API: an app or module token reads and changes watches only for its on_behalf_of person; sandbox tokens are refused', async () => {++ const app = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.deals'], cap: ['deals.watch.read', 'deals.watch.create', 'deals.watch.delete'], extra });++ for (const [sub, type] of [['app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app'], ['mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'mod']]) {++ const token = app(sub, type, { on_behalf_of: other.subject });++ const spy = await t.get('/api/v1/watches', { as: token, headers: { 'X-OV-Subject': watcher.subject } });++ assert.strictEqual(spy.status, 403, `${type}: ${spy.text}`);++ assert.strictEqual(spy.json().code, 'subject.not_delegated');++ assert.strictEqual((await t.get(`/api/v1/watches/${w.price}`, { as: token, method: 'DELETE', headers: { 'X-OV-Subject': watcher.subject } })).status, 403);++ assert.strictEqual((await t.get('/api/v1/watches', { as: app(sub, type, {}), headers: { 'X-OV-Subject': watcher.subject } })).status, 403, 'no on_behalf_of: nobody');++ const own = await t.get('/api/v1/watches', { as: token });++ assert.strictEqual(own.status, 200, 'without the header it acts for on_behalf_of');++ assert.deepStrictEqual(own.json().watches, []);++ }++ const sandbox = await t.get('/api/v1/watches', { as: app('app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app', { on_behalf_of: other.subject, env: 'sandbox' }) });++ assert.strictEqual(sandbox.status, 401);++ assert.strictEqual(sandbox.json().code, 'token.sandbox_refused');++ assert.strictEqual(t.ctx.watches.list(watcher.subject).length, 2, 'the watcher\'s watches are untouched');++ });++await t.close();done();})();
@@ -39,15 +39,28 @@ function createViewerResolver({ auth, config, people }) {
@@ -39,15 +39,28 @@ function createViewerResolver({ auth, config, people }) {
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.deals');
+ }
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', staff: false };
+ return { kind: 'service', service: claims.sub, claims, subject, origin: originHeader === 'ai' ? 'ai' : 'user', staff: false };
}
}
async function fromUserToken(token) {
async function fromUserToken(token) {
@@ -65,8 +65,8 @@ async function startNetwork() {
@@ -65,8 +65,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, ns }) {
- return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, ...(ns ? { ns } : {}), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID() }, privatePem);
+ function signService({ sub, aud, cap, ns, actorType = 'service', extra = {} }) {
+ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: actorType, aud, cap, ...(ns ? { ns } : {}), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);
}
}
/** extra.mintedAt: the time embedded in the usr_ ULID (default: long ago, i.e. an established account). */
/** extra.mintedAt: the time embedded in the usr_ ULID (default: long ago, i.e. an established account). */
function addUser(username, extra = {}) {
function addUser(username, extra = {}) {
@@ -122,6 +122,25 @@ const { sourceItem } = require('./helpers/mocks');
@@ -122,6 +122,25 @@ const { sourceItem } = require('./helpers/mocks');
assert.strictEqual(denied.status, 403);
assert.strictEqual(denied.status, 403);
});
});
+ await check('API: an app or module token reads and changes watches only for its on_behalf_of person; sandbox tokens are refused', async () => {
+ const app = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.deals'], cap: ['deals.watch.read', 'deals.watch.create', 'deals.watch.delete'], extra });
+ for (const [sub, type] of [['app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app'], ['mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'mod']]) {
+ const token = app(sub, type, { on_behalf_of: other.subject });
+ const spy = await t.get('/api/v1/watches', { as: token, headers: { 'X-OV-Subject': watcher.subject } });
+ assert.strictEqual(spy.status, 403, `${type}: ${spy.text}`);
+ assert.strictEqual(spy.json().code, 'subject.not_delegated');
+ assert.strictEqual((await t.get(`/api/v1/watches/${w.price}`, { as: token, method: 'DELETE', headers: { 'X-OV-Subject': watcher.subject } })).status, 403);
+ assert.strictEqual((await t.get('/api/v1/watches', { as: app(sub, type, {}), headers: { 'X-OV-Subject': watcher.subject } })).status, 403, 'no on_behalf_of: nobody');
+ const own = await t.get('/api/v1/watches', { as: token });
+ assert.strictEqual(own.status, 200, 'without the header it acts for on_behalf_of');
+ assert.deepStrictEqual(own.json().watches, []);
+ }
+ const sandbox = await t.get('/api/v1/watches', { as: app('app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app', { on_behalf_of: other.subject, env: 'sandbox' }) });
+ assert.strictEqual(sandbox.status, 401);
+ assert.strictEqual(sandbox.json().code, 'token.sandbox_refused');
+ assert.strictEqual(t.ctx.watches.list(watcher.subject).length, 2, 'the watcher\'s watches are untouched');
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();