openvibers/openvibe.bench Commits
MCP auth: the RFC 9728 protected-resource metadata and a WWW-Authenticate challenge on 401 (#7)
Changes
@@@ -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; }@@@ -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));nnew file mode 100644@@@ -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 };@@@ -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();@@@ -73,7 +73,7 @@ const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(paramassert.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);}});nnew file mode 100644@@@ -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();++})();
@@ -79,6 +79,12 @@ server {
@@ -79,6 +79,12 @@ server {
proxy_pass http://127.0.0.1:5230;
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 ──
# ── Forms and other writes: the vote, the caption generator, the report ──
location = /mcp {
location = /mcp {
limit_except POST { deny all; }
limit_except POST { deny all; }
@@ -36,6 +36,7 @@ const { createBenchAi } = require('./bench/ai');
@@ -36,6 +36,7 @@ const { createBenchAi } = require('./bench/ai');
const { createBenchJobs } = require('./bench/jobs');
const { createBenchJobs } = require('./bench/jobs');
const { createPublisher } = require('./events/publisher');
const { createPublisher } = require('./events/publisher');
const { mcpRouter } = require('./mcp');
const { mcpRouter } = require('./mcp');
+const protectedResource = require('./http/protected-resource');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@ -149,6 +150,14 @@ async function createApp(opts = {}) {
@@ -149,6 +150,14 @@ async function createApp(opts = {}) {
app.use(mcpRouter(ctx));
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 }));
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 ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -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 };
@@ -6,10 +6,15 @@
@@ -6,10 +6,15 @@
* and a JSON-RPC parse error for malformed bodies.
* 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).
* 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 express = require('express');
+const { http } = require('openvibe-contracts');
const pkg = require('../package.json');
const pkg = require('../package.json');
+const protectedResource = require('./http/protected-resource');
const PROTOCOL_VERSION = '2025-06-18';
const PROTOCOL_VERSION = '2025-06-18';
const SUPPORTED_VERSIONS = Object.freeze(new Set(['2025-06-18', '2025-03-26']));
const SUPPORTED_VERSIONS = Object.freeze(new Set(['2025-06-18', '2025-03-26']));
@@ -86,8 +91,14 @@ function subjectOf(req) {
@@ -86,8 +91,14 @@ function subjectOf(req) {
return req.principal && req.principal.kind === 'user' && req.principal.subject ? req.principal.subject : null;
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) {
function requirePersonTool(id, req) {
if (subjectOf(req)) return null;
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 }) };
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) {
@@ -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) {
function mcpRouter(ctx) {
const router = express.Router();
const router = express.Router();
const json = express.json({ limit: BODY_LIMIT, type: ['application/json', 'application/*+json'], strict: false });
const json = express.json({ limit: BODY_LIMIT, type: ['application/json', 'application/*+json'], strict: false });
@@ -190,7 +236,7 @@ function mcpRouter(ctx) {
@@ -190,7 +236,7 @@ function mcpRouter(ctx) {
res.status(204).end();
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');
const version = req.get('mcp-protocol-version');
if (version && !SUPPORTED_VERSIONS.has(version)) {
if (version && !SUPPORTED_VERSIONS.has(version)) {
const body = req.body;
const body = req.body;
@@ -205,6 +251,7 @@ function mcpRouter(ctx) {
@@ -205,6 +251,7 @@ function mcpRouter(ctx) {
const responses = [];
const responses = [];
for (const message of messages) {
for (const message of messages) {
const out = await handleMessage(message, req, ctx);
const out = await handleMessage(message, req, ctx);
+ if (out.unauthorized) return unauthorized(res, out.id, ctx);
if (out.response) responses.push(out.response);
if (out.response) responses.push(out.response);
}
}
if (!responses.length) return res.status(202).end();
if (!responses.length) return res.status(202).end();
@@ -73,7 +73,7 @@ const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(param
@@ -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/);
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 [
for (const call of [
{ name: 'bench.next_pair', arguments: { kind: 'design.page' } },
{ name: 'bench.next_pair', arguments: { kind: 'design.page' } },
{ name: 'bench.cast_judgement', arguments: { round: 'brd_nope', left: 'bte_1', right: 'bte_2', outcome: 'left' } },
{ 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
@@ -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' }] } },
{ name: 'bench.run_eval', arguments: { kind: 'vision.answer', test_suite: [{ prompt: 'x' }] } },
]) {
]) {
const r = await t.get('/mcp', { json: rpc('tools/call', call, 11) });
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);
}
}
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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();
+})();