openvibers/openvibe.blog Commits

App and module tokens act only for their on_behalf_of person; sandbox tokens refused

OP OpenVibers committed 23 Sept 2026 · e7b4986ac0 · 3 files, +78 −3

A developer-app (app:) or module (mod:) token granted a Blog capability could send X-OV-Subject
naming any usr_ id and read that person's private and members-only posts or write, publish and
delete posts as them. Third-party tokens now act for their on_behalf_of subject only (a different
X-OV-Subject is 403 subject.not_delegated) and non-production tokens are 401
token.sandbox_refused. First-party svc: principals are unchanged. Same fix as
OpenVibe.Community cac1df9.

Changes

ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex 7558353..be53fdd 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -12,6 +12,8 @@
* X-OV-Subject names the person it acts for; X-OV-Origin: ai marks AI output (OpenVibe.AI's
* blog.draft_post), which is never attributed to that person as author — it is AI-generated,
* starts as a draft and needs a person's review before it can be published.
++ * 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.
@@@ -37,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.blog');
++ }
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) {
ddiff --git a/test/delegation.test.js b/test/delegation.test.js
nnew file mode 100644
iindex 0000000..8e2f6cc
---- /dev/null
++++ b/test/delegation.test.js
@@@ -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';
++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
++
++(async () => {
++ const t = await boot();
++ const carol = t.network.addUser('carol');
++ const appUser = t.network.addUser('appuser');
++ await t.get('/api/v1/blogs', { as: carol, json: {} });
++ const secret = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'Private minutes', body: `Minutes ${LONG}`, visibility: 'private' } })).json().post;
++ const caps = ['blog.post.read', 'blog.post.create'];
++ const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.blog'], cap: caps, extra });
++ const postsOf = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n;
++
++ await check('an app or module cannot act for someone else by naming them in X-OV-Subject', async () => {
++ const before = postsOf();
++ for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
++ for (const extra of [{ on_behalf_of: appUser.subject }, {}]) {
++ const read = await t.get(`/api/v1/posts/${secret.id}`, { as: token(sub, type, extra), headers: { 'x-ov-subject': carol.subject } });
++ assert.strictEqual(read.status, 403, `${type} ${JSON.stringify(extra)}: ${read.text}`);
++ assert.strictEqual(read.json().code, 'subject.not_delegated');
++ assert.doesNotMatch(read.text, /Minutes/);
++ const write = await t.get('/api/v1/blogs/carol/posts', { as: token(sub, type, extra), headers: { 'x-ov-subject': carol.subject }, json: { title: 'Planted', body: LONG } });
++ assert.strictEqual(write.status, 403, `${type} write: ${write.text}`);
++ }
++ }
++ assert.strictEqual(postsOf(), before, 'nothing written as carol');
++ });
++
++ await check('an app acts for its on_behalf_of person, with that person\'s rights', async () => {
++ const r = await t.get(`/api/v1/posts/${secret.id}`, { as: token(APP, 'app', { on_behalf_of: appUser.subject }) });
++ assert.strictEqual(r.status, 404, r.text);
++ const own = await t.get(`/api/v1/posts/${secret.id}`, { as: token(APP, 'app', { on_behalf_of: carol.subject }), headers: { 'x-ov-subject': carol.subject } });
++ assert.strictEqual(own.status, 200, own.text);
++ });
++
++ await check('sandbox app tokens are refused', async () => {
++ const r = await t.get(`/api/v1/posts/${secret.id}`, { as: token(APP, 'app', { on_behalf_of: carol.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/posts/${secret.id}`, { as: t.network.serviceToken('tools', caps), headers: { 'x-ov-subject': carol.subject } });
++ assert.strictEqual(r.status, 200, r.text);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex cdbc9dd..363268e 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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);
}
function addUser(username, extra = {}) {
const subject = ids.newId('user');