openvibers/openvibe.search Commits
MCP auth: the RFC 9728 protected-resource metadata and a WWW-Authenticate challenge on 401 (#133)
Changes
@@@ -94,6 +94,8 @@ server {# The agent layer (openvibe-shared/agent-layer): the manifest an agent reads first, and the Markdown twins of# the front page and /updates (a result page's twin answers on / with ?format=markdown, already proxied).location = /.well-known/agent.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }++ # RFC 9728: the protected-resource metadata for the MCP endpoint (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:4710; }# The API's own description (SEO-3): the document, and the 308 the /api/v1 spelling answers with.location = /openapi.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }location = /api/v1/openapi.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }@@@ -14,17 +14,26 @@* and output schemas come from the published contracts at boot, so the enums cannot drift from the* fabric's request. There are no sessions and no Mcp-Session-Id: every call answers once as JSON.* A query is never echoed into any answer, a log line or an error.++ *++ * 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/protected-resource.js).*/const fs = require('fs');const express = require('express');++const { http } = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');const pkg = require('../../package.json');--const { withViewer, QueryError } = require('./query');++const { QueryError } = require('./query');++const { AuthError } = require('../auth');++const protectedResource = require('../protected-resource');const { SEARCH_LIMITS } = require('./fabric');const PROTOCOL_VERSION = '2025-06-18';const SUPPORTED_VERSIONS = Object.freeze(new Set(['2025-06-18', '2025-03-26']));const BODY_LIMIT = '64kb';const CONTRACT_DIR = 'openvibe-contracts/contracts/search';++// The scopes a call gets when it names none: the same default the tool's schema and the fabric use.++const DEFAULT_SCOPES = Object.freeze(['network']);/** The request/result contracts as JSON Schema objects, read from the package at startup. */function loadContracts() {@@@ -235,12 +244,67 @@ function sendRpc(res, status, body) {return res.status(status).json(body);}++/** Does this call ask for the caller's own material? The "mine" scope does; it needs a person. */++function wantsTheCallersOwn(args) {++ const scopes = Array.isArray(args.scopes) && args.scopes.length ? args.scopes : DEFAULT_SCOPES;++ return scopes.includes('mine');++}++++/** 401 and the challenge: the call needs a token, and this is where to read how to get one. */++function unauthorized(res, id, config) {++ protectedResource.challenge(res, config);++ return res.status(401).json({++ jsonrpc: '2.0',++ id: id === undefined ? null : id,++ error: {++ code: -32001,++ message: 'Unauthorized: this call needs a person. Send a Network user token as a Bearer.',++ data: { resource_metadata: protectedResource.metadataUrl(config) },++ },++ });++}++++/**++ * The viewer, with the RFC 9728 challenge on a 401: a client whose token does not verify is told where the++ * protected-resource metadata is. The rest is the query route's withViewer (server/api/query.js).++ */++function withMcpViewer(auth, config, handler) {++ return async (req, res, next) => {++ const ctx = req.ov;++ let viewer;++ try {++ viewer = await auth.viewer(req);++ } catch (err) {++ if (err instanceof AuthError) {++ if (err.status === 401) protectedResource.challenge(res, config);++ return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx });++ }++ return next(err);++ }++ // Never cached: a visibility change or a deletion must leave results immediately.++ res.setHeader('Cache-Control', cache.htmlHeaders({ private: true }));++ res.setHeader('Vary', 'Authorization, Cookie, X-OV-Subject, X-OV-Groups, X-OV-Entitlements');++ req.viewer = viewer;++ return handler(req, res, viewer);++ };++}++async function callTool(id, params, req, viewer, { fabric, searchLimit }) {const name = params.name;if (name !== SEARCH_TOOL.name && name !== PROVIDERS_TOOL.name) {return { response: error(id, -32602, 'Unknown tool') };}++ const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)++ ? params.arguments : {};++++ // The "mine" scope is the caller's own private material, so it needs a person. An anonymous caller is told how++ // to authenticate (401 and the RFC 9728 challenge) instead of getting an empty answer; a service token keeps++ // its old answer, and with no X-OV-Subject the fabric says the scope needs a signed-in person.++ if (name === SEARCH_TOOL.name && wantsTheCallersOwn(args) && (!viewer || viewer.kind === 'anonymous')) {++ return { unauthorized: true, id };++ }++const gate = await takeBudget(searchLimit, req);if (gate.limited) return { limited: gate };@@@ -255,8 +319,6 @@ async function callTool(id, params, req, viewer, { fabric, searchLimit }) {};}-- const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)-- ? params.arguments : {};let answer;try {answer = await fabric.query(fabricBody(args), viewer);@@@ -316,7 +378,7 @@ async function handleMessage(msg, req, viewer, deps) {}}--function mcpRouter({ fabric, auth, limits }) {++function mcpRouter({ fabric, auth, limits, config }) {const router = express.Router();const searchLimit = limits('search.fabric', SEARCH_LIMITS);// strict: false parses a scalar as JSON too, so "valid JSON, not a JSON-RPC message" is -32600@@@ -334,7 +396,7 @@ function mcpRouter({ fabric, auth, limits }) {res.status(204).end();});-- router.post('/mcp', json, withViewer(auth, async (req, res, viewer) => {++ router.post('/mcp', json, withMcpViewer(auth, config, async (req, res, viewer) => {const version = req.get('mcp-protocol-version');if (version && !SUPPORTED_VERSIONS.has(version)) {const body = req.body;@@@ -355,6 +417,7 @@ function mcpRouter({ fabric, auth, limits }) {const responses = [];for (const message of messages) {const out = await handleMessage(message, req, viewer, { fabric, searchLimit });++ if (out.unauthorized) return unauthorized(res, out.id, config);if (out.limited) return sendLimited(res, out.limited);if (out.response) responses.push(out.response);}@@@ -10,6 +10,7 @@ const { documentsRouter } = require('./api/documents');const { queryRouter, createSearcher } = require('./api/query');const { fabricRouter } = require('./api/fabric');const { mcpRouter } = require('./api/mcp');++const protectedResource = require('./protected-resource');const { createQueryCache } = require('./fabric/cache');const { createWebProviders } = require('./fabric/web');const { createFabric } = require('./fabric');@@@ -90,7 +91,7 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, saapp.use(queryRouter({ config, store, engine, auth, searcher, limits }));app.use(fabricRouter({ fabric, auth, limits }));// MCP over Streamable HTTP: the same fabric, viewer and search.fabric limit as the query route above.-- app.use(mcpRouter({ fabric, auth, limits }));++ app.use(mcpRouter({ fabric, auth, limits, config }));app.use(savedRouter({ config, saved, searcher, auth, limits }));// GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and// /sitemap.xml (whose lastmods come from the index and the deployed release, never from the clock).@@@ -98,6 +99,13 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, saconst frontData = createFrontData({ store, searcher, log });const webPages = pageRouter({ searcher, auth, baseUrl: config.baseUrl, newestPublic: () => store.newestPublic(), releasedAt: release.full().released_at, owners: config.events.owners, frontData });app.use(webPages);++ // RFC 9728: the protected-resource metadata for the MCP endpoint (server/protected-resource.js). The document a++ // client reads to learn that openvibe.network signs the tokens /mcp accepts, and the URL the 401 challenge names.++ app.get('/.well-known/oauth-protected-resource', (_req, res) => {++ res.set('Cache-Control', 'public, max-age=3600');++ res.json(protectedResource.document(config));++ });++// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the public// pages. Mounted after the page router, so a browser gets the page's own head and cache and only a twin request// reaches it; before the 404, so an unknown .md is a 404.nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * RFC 9728 protected-resource metadata for Search'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 "mine" scope of the Search Fabric (or a++ * service token with the fabric capability). A client that has no token has to learn where to get one, so Search++ * 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.issuer) is the only authorization server: it signs every token Search 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.issuer) || 'https://openvibe.network').replace(/\/+$/, '');++ return {++ resource: base(config),++ authorization_servers: [issuer],++ scopes_supported: [...SCOPES],++ bearer_methods_supported: ['header'],++ resource_documentation: `${base(config)}/openapi.json`,++ };++}++++/** 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 };nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++++/**++ * RFC 9728 on Search'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++ * (the "mine" scope) and has none answers 401 and names the metadata; a call that needs no token is still answered.++ */++++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, request, userToken, serviceToken, suite } = require('./helpers');++const protectedResource = require('../server/protected-resource');++++const t = suite('protected-resource');++const METADATA_PATH = '/.well-known/oauth-protected-resource';++const BASE_URL = 'https://search.openvibe.network';++const alice = ids.newId('user');++const asAlice = { token: userToken({ subjectId: alice, aud: ['openvibe.search'] }) };++const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) });++++let svc;++const call = (method, p, opts = {}) => request(svc.base, method, p, opts);++const post = (body, opts = {}) => call('POST', '/mcp', { body, ...opts });++++t('boot with the public base URL', async () => {++ svc = await boot({ env: { BASE_URL } });++});++++t('the metadata document names the resource, the authorization server and the bearer method', async () => {++ const r = await call('GET', METADATA_PATH);++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.headers.get('content-type'), /application\/json/);++ const doc = r.body;++ assert.strictEqual(doc.resource, BASE_URL);++ assert.deepStrictEqual(doc.authorization_servers, ['https://openvibe.network']);++ 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']);++ assert.strictEqual(doc.resource_documentation, `${BASE_URL}/openapi.json`);++});++++t('the document is the one the challenge names', async () => {++ const doc = protectedResource.document({ baseUrl: BASE_URL, issuer: 'https://openvibe.network' });++ assert.strictEqual(protectedResource.metadataUrl({ baseUrl: BASE_URL }), `${BASE_URL}${METADATA_PATH}`);++ assert.deepStrictEqual(doc.authorization_servers, ['https://openvibe.network']);++});++++t('a call that needs a person answers 401 and names the metadata', async () => {++ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice', scopes: ['mine'] } }, 10));++ assert.strictEqual(r.status, 401, r.text);++ assert.strictEqual(r.headers.get('www-authenticate'), `Bearer resource_metadata="${BASE_URL}${METADATA_PATH}"`);++ assert.strictEqual(r.body.error.code, -32001);++ assert.strictEqual(r.body.error.data.resource_metadata, `${BASE_URL}${METADATA_PATH}`);++});++++t('a call that needs no token is still answered', async () => {++ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice', scopes: ['network'] } }, 11));++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.result.isError, false);++ assert.strictEqual(r.headers.get('www-authenticate'), null);++});++++t('a token that does not verify answers 401 with the challenge', async () => {++ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice' } }, 12), { token: 'not-a-token' });++ assert.strictEqual(r.status, 401, r.text);++ assert.strictEqual(r.headers.get('www-authenticate'), `Bearer resource_metadata="${BASE_URL}${METADATA_PATH}"`);++});++++t('a person with a token gets the "mine" scope', async () => {++ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice', scopes: ['mine'] } }, 13), asAlice);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.result.isError, false, r.text);++ assert.strictEqual(r.headers.get('www-authenticate'), null);++});++++t('a service token asking for the "mine" scope keeps its answer, not the sign-in challenge', async () => {++ const token = serviceToken('wiki', ['search.query.delegate']);++ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice', scopes: ['mine'] } }, 14), { token });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.result.isError, false, r.text);++ assert.strictEqual(r.headers.get('www-authenticate'), null);++});++++t('stop', async () => { await svc.stop(); });++++t.run();
@@ -94,6 +94,8 @@ server {
@@ -94,6 +94,8 @@ server {
# The agent layer (openvibe-shared/agent-layer): the manifest an agent reads first, and the Markdown twins of
# The agent layer (openvibe-shared/agent-layer): the manifest an agent reads first, and the Markdown twins of
# the front page and /updates (a result page's twin answers on / with ?format=markdown, already proxied).
# the front page and /updates (a result page's twin answers on / with ?format=markdown, already proxied).
location = /.well-known/agent.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
location = /.well-known/agent.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
+ # RFC 9728: the protected-resource metadata for the MCP endpoint (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:4710; }
# The API's own description (SEO-3): the document, and the 308 the /api/v1 spelling answers with.
# The API's own description (SEO-3): the document, and the 308 the /api/v1 spelling answers with.
location = /openapi.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
location = /openapi.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
location = /api/v1/openapi.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
location = /api/v1/openapi.json { limit_except GET { deny all; } proxy_pass http://127.0.0.1:4710; }
@@ -14,17 +14,26 @@
@@ -14,17 +14,26 @@
* and output schemas come from the published contracts at boot, so the enums cannot drift from the
* and output schemas come from the published contracts at boot, so the enums cannot drift from the
* fabric's request. There are no sessions and no Mcp-Session-Id: every call answers once as JSON.
* fabric's request. There are no sessions and no Mcp-Session-Id: every call answers once as JSON.
* A query is never echoed into any answer, a log line or an error.
* A query is never echoed into any answer, a log line or an error.
+ *
+ * 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/protected-resource.js).
*/
*/
const fs = require('fs');
const fs = require('fs');
const express = require('express');
const express = require('express');
+const { http } = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
const pkg = require('../../package.json');
const pkg = require('../../package.json');
-const { withViewer, QueryError } = require('./query');
+const { QueryError } = require('./query');
+const { AuthError } = require('../auth');
+const protectedResource = require('../protected-resource');
const { SEARCH_LIMITS } = require('./fabric');
const { SEARCH_LIMITS } = require('./fabric');
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']));
const BODY_LIMIT = '64kb';
const BODY_LIMIT = '64kb';
const CONTRACT_DIR = 'openvibe-contracts/contracts/search';
const CONTRACT_DIR = 'openvibe-contracts/contracts/search';
+// The scopes a call gets when it names none: the same default the tool's schema and the fabric use.
+const DEFAULT_SCOPES = Object.freeze(['network']);
/** The request/result contracts as JSON Schema objects, read from the package at startup. */
/** The request/result contracts as JSON Schema objects, read from the package at startup. */
function loadContracts() {
function loadContracts() {
@@ -235,12 +244,67 @@ function sendRpc(res, status, body) {
@@ -235,12 +244,67 @@ function sendRpc(res, status, body) {
return res.status(status).json(body);
return res.status(status).json(body);
}
}
+/** Does this call ask for the caller's own material? The "mine" scope does; it needs a person. */
+function wantsTheCallersOwn(args) {
+ const scopes = Array.isArray(args.scopes) && args.scopes.length ? args.scopes : DEFAULT_SCOPES;
+ return scopes.includes('mine');
+}
+
+/** 401 and the challenge: the call needs a token, and this is where to read how to get one. */
+function unauthorized(res, id, config) {
+ protectedResource.challenge(res, config);
+ return res.status(401).json({
+ jsonrpc: '2.0',
+ id: id === undefined ? null : id,
+ error: {
+ code: -32001,
+ message: 'Unauthorized: this call needs a person. Send a Network user token as a Bearer.',
+ data: { resource_metadata: protectedResource.metadataUrl(config) },
+ },
+ });
+}
+
+/**
+ * The viewer, with the RFC 9728 challenge on a 401: a client whose token does not verify is told where the
+ * protected-resource metadata is. The rest is the query route's withViewer (server/api/query.js).
+ */
+function withMcpViewer(auth, config, handler) {
+ return async (req, res, next) => {
+ const ctx = req.ov;
+ let viewer;
+ try {
+ viewer = await auth.viewer(req);
+ } catch (err) {
+ if (err instanceof AuthError) {
+ if (err.status === 401) protectedResource.challenge(res, config);
+ return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx });
+ }
+ return next(err);
+ }
+ // Never cached: a visibility change or a deletion must leave results immediately.
+ res.setHeader('Cache-Control', cache.htmlHeaders({ private: true }));
+ res.setHeader('Vary', 'Authorization, Cookie, X-OV-Subject, X-OV-Groups, X-OV-Entitlements');
+ req.viewer = viewer;
+ return handler(req, res, viewer);
+ };
+}
+
async function callTool(id, params, req, viewer, { fabric, searchLimit }) {
async function callTool(id, params, req, viewer, { fabric, searchLimit }) {
const name = params.name;
const name = params.name;
if (name !== SEARCH_TOOL.name && name !== PROVIDERS_TOOL.name) {
if (name !== SEARCH_TOOL.name && name !== PROVIDERS_TOOL.name) {
return { response: error(id, -32602, 'Unknown tool') };
return { response: error(id, -32602, 'Unknown tool') };
}
}
+ const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
+ ? params.arguments : {};
+
+ // The "mine" scope is the caller's own private material, so it needs a person. An anonymous caller is told how
+ // to authenticate (401 and the RFC 9728 challenge) instead of getting an empty answer; a service token keeps
+ // its old answer, and with no X-OV-Subject the fabric says the scope needs a signed-in person.
+ if (name === SEARCH_TOOL.name && wantsTheCallersOwn(args) && (!viewer || viewer.kind === 'anonymous')) {
+ return { unauthorized: true, id };
+ }
+
const gate = await takeBudget(searchLimit, req);
const gate = await takeBudget(searchLimit, req);
if (gate.limited) return { limited: gate };
if (gate.limited) return { limited: gate };
@@ -255,8 +319,6 @@ async function callTool(id, params, req, viewer, { fabric, searchLimit }) {
@@ -255,8 +319,6 @@ async function callTool(id, params, req, viewer, { fabric, searchLimit }) {
};
};
}
}
- const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
- ? params.arguments : {};
let answer;
let answer;
try {
try {
answer = await fabric.query(fabricBody(args), viewer);
answer = await fabric.query(fabricBody(args), viewer);
@@ -316,7 +378,7 @@ async function handleMessage(msg, req, viewer, deps) {
@@ -316,7 +378,7 @@ async function handleMessage(msg, req, viewer, deps) {
}
}
}
}
-function mcpRouter({ fabric, auth, limits }) {
+function mcpRouter({ fabric, auth, limits, config }) {
const router = express.Router();
const router = express.Router();
const searchLimit = limits('search.fabric', SEARCH_LIMITS);
const searchLimit = limits('search.fabric', SEARCH_LIMITS);
// strict: false parses a scalar as JSON too, so "valid JSON, not a JSON-RPC message" is -32600
// strict: false parses a scalar as JSON too, so "valid JSON, not a JSON-RPC message" is -32600
@@ -334,7 +396,7 @@ function mcpRouter({ fabric, auth, limits }) {
@@ -334,7 +396,7 @@ function mcpRouter({ fabric, auth, limits }) {
res.status(204).end();
res.status(204).end();
});
});
- router.post('/mcp', json, withViewer(auth, async (req, res, viewer) => {
+ router.post('/mcp', json, withMcpViewer(auth, config, async (req, res, viewer) => {
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;
@@ -355,6 +417,7 @@ function mcpRouter({ fabric, auth, limits }) {
@@ -355,6 +417,7 @@ function mcpRouter({ fabric, auth, limits }) {
const responses = [];
const responses = [];
for (const message of messages) {
for (const message of messages) {
const out = await handleMessage(message, req, viewer, { fabric, searchLimit });
const out = await handleMessage(message, req, viewer, { fabric, searchLimit });
+ if (out.unauthorized) return unauthorized(res, out.id, config);
if (out.limited) return sendLimited(res, out.limited);
if (out.limited) return sendLimited(res, out.limited);
if (out.response) responses.push(out.response);
if (out.response) responses.push(out.response);
}
}
@@ -10,6 +10,7 @@ const { documentsRouter } = require('./api/documents');
@@ -10,6 +10,7 @@ const { documentsRouter } = require('./api/documents');
const { queryRouter, createSearcher } = require('./api/query');
const { queryRouter, createSearcher } = require('./api/query');
const { fabricRouter } = require('./api/fabric');
const { fabricRouter } = require('./api/fabric');
const { mcpRouter } = require('./api/mcp');
const { mcpRouter } = require('./api/mcp');
+const protectedResource = require('./protected-resource');
const { createQueryCache } = require('./fabric/cache');
const { createQueryCache } = require('./fabric/cache');
const { createWebProviders } = require('./fabric/web');
const { createWebProviders } = require('./fabric/web');
const { createFabric } = require('./fabric');
const { createFabric } = require('./fabric');
@@ -90,7 +91,7 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa
@@ -90,7 +91,7 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa
app.use(queryRouter({ config, store, engine, auth, searcher, limits }));
app.use(queryRouter({ config, store, engine, auth, searcher, limits }));
app.use(fabricRouter({ fabric, auth, limits }));
app.use(fabricRouter({ fabric, auth, limits }));
// MCP over Streamable HTTP: the same fabric, viewer and search.fabric limit as the query route above.
// MCP over Streamable HTTP: the same fabric, viewer and search.fabric limit as the query route above.
- app.use(mcpRouter({ fabric, auth, limits }));
+ app.use(mcpRouter({ fabric, auth, limits, config }));
app.use(savedRouter({ config, saved, searcher, auth, limits }));
app.use(savedRouter({ config, saved, searcher, auth, limits }));
// GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and
// GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and
// /sitemap.xml (whose lastmods come from the index and the deployed release, never from the clock).
// /sitemap.xml (whose lastmods come from the index and the deployed release, never from the clock).
@@ -98,6 +99,13 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa
@@ -98,6 +99,13 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa
const frontData = createFrontData({ store, searcher, log });
const frontData = createFrontData({ store, searcher, log });
const webPages = pageRouter({ searcher, auth, baseUrl: config.baseUrl, newestPublic: () => store.newestPublic(), releasedAt: release.full().released_at, owners: config.events.owners, frontData });
const webPages = pageRouter({ searcher, auth, baseUrl: config.baseUrl, newestPublic: () => store.newestPublic(), releasedAt: release.full().released_at, owners: config.events.owners, frontData });
app.use(webPages);
app.use(webPages);
+ // RFC 9728: the protected-resource metadata for the MCP endpoint (server/protected-resource.js). The document a
+ // client reads to learn that openvibe.network signs the tokens /mcp accepts, and the URL the 401 challenge names.
+ app.get('/.well-known/oauth-protected-resource', (_req, res) => {
+ res.set('Cache-Control', 'public, max-age=3600');
+ res.json(protectedResource.document(config));
+ });
+
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the public
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the public
// pages. Mounted after the page router, so a browser gets the page's own head and cache and only a twin request
// pages. Mounted after the page router, so a browser gets the page's own head and cache and only a twin request
// reaches it; before the 404, so an unknown .md is a 404.
// reaches it; before the 404, so an unknown .md is a 404.
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * RFC 9728 protected-resource metadata for Search'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 "mine" scope of the Search Fabric (or a
+ * service token with the fabric capability). A client that has no token has to learn where to get one, so Search
+ * 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.issuer) is the only authorization server: it signs every token Search 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.issuer) || 'https://openvibe.network').replace(/\/+$/, '');
+ return {
+ resource: base(config),
+ authorization_servers: [issuer],
+ scopes_supported: [...SCOPES],
+ bearer_methods_supported: ['header'],
+ resource_documentation: `${base(config)}/openapi.json`,
+ };
+}
+
+/** 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * RFC 9728 on Search'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
+ * (the "mine" scope) and has none answers 401 and names the metadata; a call that needs no token is still answered.
+ */
+
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, request, userToken, serviceToken, suite } = require('./helpers');
+const protectedResource = require('../server/protected-resource');
+
+const t = suite('protected-resource');
+const METADATA_PATH = '/.well-known/oauth-protected-resource';
+const BASE_URL = 'https://search.openvibe.network';
+const alice = ids.newId('user');
+const asAlice = { token: userToken({ subjectId: alice, aud: ['openvibe.search'] }) };
+const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) });
+
+let svc;
+const call = (method, p, opts = {}) => request(svc.base, method, p, opts);
+const post = (body, opts = {}) => call('POST', '/mcp', { body, ...opts });
+
+t('boot with the public base URL', async () => {
+ svc = await boot({ env: { BASE_URL } });
+});
+
+t('the metadata document names the resource, the authorization server and the bearer method', async () => {
+ const r = await call('GET', METADATA_PATH);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.headers.get('content-type'), /application\/json/);
+ const doc = r.body;
+ assert.strictEqual(doc.resource, BASE_URL);
+ assert.deepStrictEqual(doc.authorization_servers, ['https://openvibe.network']);
+ 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']);
+ assert.strictEqual(doc.resource_documentation, `${BASE_URL}/openapi.json`);
+});
+
+t('the document is the one the challenge names', async () => {
+ const doc = protectedResource.document({ baseUrl: BASE_URL, issuer: 'https://openvibe.network' });
+ assert.strictEqual(protectedResource.metadataUrl({ baseUrl: BASE_URL }), `${BASE_URL}${METADATA_PATH}`);
+ assert.deepStrictEqual(doc.authorization_servers, ['https://openvibe.network']);
+});
+
+t('a call that needs a person answers 401 and names the metadata', async () => {
+ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice', scopes: ['mine'] } }, 10));
+ assert.strictEqual(r.status, 401, r.text);
+ assert.strictEqual(r.headers.get('www-authenticate'), `Bearer resource_metadata="${BASE_URL}${METADATA_PATH}"`);
+ assert.strictEqual(r.body.error.code, -32001);
+ assert.strictEqual(r.body.error.data.resource_metadata, `${BASE_URL}${METADATA_PATH}`);
+});
+
+t('a call that needs no token is still answered', async () => {
+ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice', scopes: ['network'] } }, 11));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.result.isError, false);
+ assert.strictEqual(r.headers.get('www-authenticate'), null);
+});
+
+t('a token that does not verify answers 401 with the challenge', async () => {
+ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice' } }, 12), { token: 'not-a-token' });
+ assert.strictEqual(r.status, 401, r.text);
+ assert.strictEqual(r.headers.get('www-authenticate'), `Bearer resource_metadata="${BASE_URL}${METADATA_PATH}"`);
+});
+
+t('a person with a token gets the "mine" scope', async () => {
+ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice', scopes: ['mine'] } }, 13), asAlice);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.result.isError, false, r.text);
+ assert.strictEqual(r.headers.get('www-authenticate'), null);
+});
+
+t('a service token asking for the "mine" scope keeps its answer, not the sign-in challenge', async () => {
+ const token = serviceToken('wiki', ['search.query.delegate']);
+ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'solstice', scopes: ['mine'] } }, 14), { token });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.result.isError, false, r.text);
+ assert.strictEqual(r.headers.get('www-authenticate'), null);
+});
+
+t('stop', async () => { await svc.stop(); });
+
+t.run();