openvibers/openvibe.search Commits

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

OP OpenVibers committed 15 days ago · 86f6c8c3de · 5 files, +209 −6

POST /mcp answers an anonymous call that needs a person (the mine scope) with 401 and WWW-Authenticate: Bearer resource_metadata="…", and the service publishes /.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/search.openvibe.network.conf b/deploy/nginx/search.openvibe.network.conf
iindex fd440df..27f82c7 100644
---- a/deploy/nginx/search.openvibe.network.conf
++++ b/deploy/nginx/search.openvibe.network.conf
@@@ -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; }
ddiff --git a/server/api/mcp.js b/server/api/mcp.js
iindex 302343b..f55f333 100644
---- a/server/api/mcp.js
++++ b/server/api/mcp.js
@@@ -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);
}
ddiff --git a/server/app.js b/server/app.js
iindex 8e07d7d..17d9f79 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, sa
app.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, sa
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 });
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.
ddiff --git a/server/protected-resource.js b/server/protected-resource.js
nnew file mode 100644
iindex 0000000..10ee46f
---- /dev/null
++++ b/server/protected-resource.js
@@@ -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 };
ddiff --git a/test/protected-resource.test.js b/test/protected-resource.test.js
nnew file mode 100644
iindex 0000000..815c5e9
---- /dev/null
++++ b/test/protected-resource.test.js
@@@ -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();