openvibers/openvibe.bench Commits

MCP auth: the RFC 9728 protected-resource metadata and a WWW-Authenticate challenge on 401 (#7)

OP OpenVibers committed 15 days ago · 56a8a260c2 · 6 files, +179 −4

POST /mcp answers a call that needs a token and has none with 401 and WWW-Authenticate: Bearer resource_metadata="…", and the service publishes its metadata at /.well-known/oauth-protected-resource: the resource, openvibe.network as the authorization server, the scopes and bearer_methods_supported header. The vhost routes the new path.

Changes

ddiff --git a/deploy/nginx/bench.openvibe.services.conf b/deploy/nginx/bench.openvibe.services.conf
iindex d8453dd..dbe6ce7 100644
---- a/deploy/nginx/bench.openvibe.services.conf
++++ b/deploy/nginx/bench.openvibe.services.conf
@@@ -79,6 +79,12 @@ server {
proxy_pass http://127.0.0.1:5230;
}
++ # ── RFC 9728: the MCP endpoint's protected-resource metadata (openvibe.network signs the tokens /mcp takes) ──
++ location = /.well-known/oauth-protected-resource {
++ limit_except GET { deny all; }
++ proxy_pass http://127.0.0.1:5230;
++ }
++
# ── Forms and other writes: the vote, the caption generator, the report ──
location = /mcp {
limit_except POST { deny all; }
ddiff --git a/server/app.js b/server/app.js
iindex d995b2f..45a97b8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -36,6 +36,7 @@ const { createBenchAi } = require('./bench/ai');
const { createBenchJobs } = require('./bench/jobs');
const { createPublisher } = require('./events/publisher');
const { mcpRouter } = require('./mcp');
++const protectedResource = require('./http/protected-resource');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@@ -149,6 +150,14 @@ async function createApp(opts = {}) {
app.use(mcpRouter(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++ // ── RFC 9728: the MCP endpoint's protected-resource metadata ──
++ // The document a client reads to learn that openvibe.network signs the tokens /mcp accepts, and the URL the
++ // 401 challenge names (server/http/protected-resource.js).
++ app.get('/.well-known/oauth-protected-resource', (_req, res) => {
++ res.set('Cache-Control', 'public, max-age=3600');
++ res.json(protectedResource.document(config));
++ });
++
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(createPageRoutes(ctx));
ddiff --git a/server/http/protected-resource.js b/server/http/protected-resource.js
nnew file mode 100644
iindex 0000000..ef646cd
---- /dev/null
++++ b/server/http/protected-resource.js
@@@ -0,0 +1,45 @@
++'use strict';
++
++/**
++ * RFC 9728 protected-resource metadata for Bench's MCP endpoint (POST /mcp), and the challenge that points at it.
++ *
++ * The endpoint takes a Network token as a Bearer: a person's token for the person-only tools (the bench_at session
++ * cookie is the browser's way in). A client that has no token has to learn where to get one, so Bench publishes the
++ * metadata at /.well-known/oauth-protected-resource and answers a call that needs a token with 401 and
++ * WWW-Authenticate: Bearer resource_metadata="<that URL>" (RFC 9728 §5.1).
++ *
++ * OpenVibe.Network (config.networkIssuer) is the only authorization server: it signs every token Bench verifies.
++ */
++
++const METADATA_PATH = '/.well-known/oauth-protected-resource';
++
++// The Network's first-party scopes: the ones a person grants when a client asks for them at sign-in.
++const SCOPES = Object.freeze(['openid', 'profile', 'email', 'theme']);
++
++function base(config) {
++ return String((config && config.baseUrl) || '').replace(/\/+$/, '');
++}
++
++/** Where the metadata document lives — the URL the WWW-Authenticate challenge names. */
++function metadataUrl(config) {
++ return `${base(config)}${METADATA_PATH}`;
++}
++
++/** The document RFC 9728 §3 asks for. */
++function document(config) {
++ const issuer = String((config && config.networkIssuer) || 'https://openvibe.network').replace(/\/+$/, '');
++ return {
++ resource: base(config),
++ authorization_servers: [issuer],
++ scopes_supported: [...SCOPES],
++ bearer_methods_supported: ['header'],
++ resource_documentation: `${base(config)}/docs`,
++ };
++}
++
++/** The challenge header of a 401: the client is told where the metadata is. */
++function challenge(res, config) {
++ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${metadataUrl(config)}"`);
++}
++
++module.exports = { METADATA_PATH, SCOPES, base, metadataUrl, document, challenge };
ddiff --git a/server/mcp.js b/server/mcp.js
iindex adfac90..842207c 100644
---- a/server/mcp.js
++++ b/server/mcp.js
@@@ -6,10 +6,15 @@
* and a JSON-RPC parse error for malformed bodies.
*
* The six tools are the §4.12 list. A person is a Network user token as a Bearer (or the bench_at session cookie).
++ *
++ * A call that needs a token and has none answers 401 with WWW-Authenticate: Bearer resource_metadata="…", so the
++ * client learns where the protected-resource metadata is (RFC 9728; server/http/protected-resource.js).
*/
const express = require('express');
++const { http } = require('openvibe-contracts');
const pkg = require('../package.json');
++const protectedResource = require('./http/protected-resource');
const PROTOCOL_VERSION = '2025-06-18';
const SUPPORTED_VERSIONS = Object.freeze(new Set(['2025-06-18', '2025-03-26']));
@@@ -86,8 +91,14 @@ function subjectOf(req) {
return req.principal && req.principal.kind === 'user' && req.principal.subject ? req.principal.subject : null;
}
++/**
++ * A tool that needs a person. An anonymous caller is told how to authenticate (401 and the RFC 9728 challenge); a
++ * service token is a caller, not a person, and keeps the tool error.
++ */
function requirePersonTool(id, req) {
if (subjectOf(req)) return null;
++ const kind = req.principal && req.principal.kind;
++ if (!kind || kind === 'anonymous') return { unauthorized: true, id };
return { response: result(id, { content: [{ type: 'text', text: 'This tool needs a person: sign in, or send a Network user token as a Bearer.' }], isError: true }) };
}
@@@ -178,6 +189,41 @@ async function handleMessage(msg, req, ctx) {
}
}
++/**
++ * The caller, with the RFC 9728 challenge on a 401: a client whose token does not verify is told where the
++ * protected-resource metadata is.
++ */
++function mcpAuth(ctx) {
++ return async (req, res, next) => {
++ let r;
++ try {
++ r = await ctx.principal.resolve(req);
++ } catch (err) {
++ return next(err);
++ }
++ if (r.error) {
++ if (r.error[0] === 401) protectedResource.challenge(res, ctx.config);
++ return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
++ }
++ req.principal = r.principal;
++ return next();
++ };
++}
++
++/** 401 and the challenge: the call needs a token, and this is where to read how to get one. */
++function unauthorized(res, id, ctx) {
++ protectedResource.challenge(res, ctx.config);
++ return res.status(401).json({
++ jsonrpc: '2.0',
++ id: id === undefined ? null : id,
++ error: {
++ code: -32001,
++ message: 'Unauthorized: this tool needs a person. Send a Network user token as a Bearer.',
++ data: { resource_metadata: protectedResource.metadataUrl(ctx.config) },
++ },
++ });
++}
++
function mcpRouter(ctx) {
const router = express.Router();
const json = express.json({ limit: BODY_LIMIT, type: ['application/json', 'application/*+json'], strict: false });
@@@ -190,7 +236,7 @@ function mcpRouter(ctx) {
res.status(204).end();
});
-- router.post('/mcp', json, ctx.principal.middleware, async (req, res) => {
++ router.post('/mcp', json, mcpAuth(ctx), async (req, res) => {
const version = req.get('mcp-protocol-version');
if (version && !SUPPORTED_VERSIONS.has(version)) {
const body = req.body;
@@@ -205,6 +251,7 @@ function mcpRouter(ctx) {
const responses = [];
for (const message of messages) {
const out = await handleMessage(message, req, ctx);
++ if (out.unauthorized) return unauthorized(res, out.id, ctx);
if (out.response) responses.push(out.response);
}
if (!responses.length) return res.status(202).end();
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
iindex 2c6db86..ccbee8d 100644
---- a/test/mcp.test.js
++++ b/test/mcp.test.js
@@@ -73,7 +73,7 @@ const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(param
assert.match(r.json().result.content[0].text, /No such kind/);
});
-- await check('a person-only tool is a tool error when anonymous', async () => {
++ await check('a person-only tool answers 401 when anonymous', async () => {
for (const call of [
{ name: 'bench.next_pair', arguments: { kind: 'design.page' } },
{ name: 'bench.cast_judgement', arguments: { round: 'brd_nope', left: 'bte_1', right: 'bte_2', outcome: 'left' } },
@@@ -81,8 +81,9 @@ const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(param
{ name: 'bench.run_eval', arguments: { kind: 'vision.answer', test_suite: [{ prompt: 'x' }] } },
]) {
const r = await t.get('/mcp', { json: rpc('tools/call', call, 11) });
-- assert.strictEqual(r.json().result.isError, true, `${call.name} should be a person error`);
-- assert.match(r.json().result.content[0].text, /person/);
++ assert.strictEqual(r.status, 401, `${call.name} should ask for a token: ${r.text}`);
++ assert.match(r.headers.get('www-authenticate'), /^Bearer resource_metadata="/);
++ assert.strictEqual(r.json().error.code, -32001);
}
});
ddiff --git a/test/protected-resource.test.js b/test/protected-resource.test.js
nnew file mode 100644
iindex 0000000..8e612a0
---- /dev/null
++++ b/test/protected-resource.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++
++/**
++ * RFC 9728 on Bench's MCP endpoint: the protected-resource metadata at
++ * /.well-known/oauth-protected-resource, and the WWW-Authenticate challenge on a 401. A call that needs a token
++ * and has none answers 401 and names the metadata; a call that needs no token is still answered.
++ */
++
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const protectedResource = require('../server/http/protected-resource');
++
++const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) });
++const METADATA_PATH = '/.well-known/oauth-protected-resource';
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++
++ await check('the metadata document names the resource, the authorization server and the bearer method', async () => {
++ const r = await t.get(METADATA_PATH);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.headers.get('content-type'), /application\/json/);
++ const doc = r.json();
++ assert.strictEqual(doc.resource, 'https://bench.openvibe.services');
++ assert.deepStrictEqual(doc.authorization_servers, [t.config.networkIssuer]);
++ assert.ok(Array.isArray(doc.scopes_supported) && doc.scopes_supported.length > 0, 'scopes_supported is a non-empty list');
++ assert.deepStrictEqual(doc.bearer_methods_supported, ['header']);
++ });
++
++ await check('the issuer is the Network when the configuration says so', async () => {
++ const doc = protectedResource.document({ baseUrl: 'https://bench.openvibe.services', networkIssuer: 'https://openvibe.network' });
++ assert.deepStrictEqual(doc.authorization_servers, ['https://openvibe.network']);
++ assert.strictEqual(doc.resource_documentation, 'https://bench.openvibe.services/docs');
++ });
++
++ await check('a tool that needs a person answers 401 and names the metadata', async () => {
++ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.next_pair', arguments: { kind: 'design.page' } }, 7) });
++ assert.strictEqual(r.status, 401, r.text);
++ assert.strictEqual(r.headers.get('www-authenticate'), `Bearer resource_metadata="https://bench.openvibe.services${METADATA_PATH}"`);
++ assert.strictEqual(r.json().error.code, -32001);
++ assert.strictEqual(r.json().error.data.resource_metadata, `https://bench.openvibe.services${METADATA_PATH}`);
++ });
++
++ await check('a tool that needs no token is still answered', async () => {
++ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.list_kinds', arguments: {} }, 8) });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().result.isError, false);
++ assert.strictEqual(r.headers.get('www-authenticate'), null);
++ });
++
++ await check('a token that does not verify answers 401 with the challenge', async () => {
++ const r = await t.get('/mcp', { bearer: 'not-a-token', json: rpc('tools/call', { name: 'bench.next_pair', arguments: { kind: 'design.page' } }, 9) });
++ assert.strictEqual(r.status, 401, r.text);
++ assert.strictEqual(r.headers.get('www-authenticate'), `Bearer resource_metadata="https://bench.openvibe.services${METADATA_PATH}"`);
++ });
++
++ await check('a person with a token gets the tool', async () => {
++ const r = await t.get('/mcp', { bearer: t.network.userToken(alice), json: rpc('tools/call', { name: 'bench.next_pair', arguments: { kind: 'design.page' } }, 10) });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().result.isError, false, r.text);
++ });
++
++ await check('stop', async () => { await t.close(); });
++
++ done();
++})();