openvibers/openvibe.connect Commits

MCP OAuth 2.1 for shares (CON-16): Claude and other MCP clients sign in with OpenVibe (#24)

OP OpenVibers committed 10 days ago · cd94772114 · 11 files, +633 −10

A share's address works as an MCP connector URL. A program without credentials gets 401
with WWW-Authenticate resource_metadata; the share serves its protected-resource
document (RFC 9728) naming Connect as the authorization server (RFC 8414 metadata).
Clients register themselves (RFC 7591, public, https or loopback redirect URIs), the
person signs in, the share's own policy decides, and they consent on Connect (the
consent page's form-action allows the app's callback origin). Codes are PKCE S256,
60 s, used once; tokens are bound to the share (ovat_ an hour, ovrt_ 30 days, rotated).
The edge takes ovat_ as the person and drops it before the app. A policy change, the
owner's sign-out or the share closing stops them; only hashes are kept; account
erasure deletes a person's sign-ins.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 417fa89..aa6f0b0 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,11 @@ What changed in OpenVibe Connect, newest first.
## Unreleased — 2026-11-03
++- MCP OAuth 2.1 (CON-16): a share's address works as a connector in Claude or any MCP client. The client is sent to
++ Connect to sign the person in with OpenVibe: protected-resource metadata on the share, then registration, authorize
++ with PKCE S256 and consent, then tokens bound to that share and rotated on refresh. The owner sees and signs out each
++ app (`server/oauth.js`, `server/http/oauth-routes.js`, migration `0010_share_oauth`).
++
- API keys, v1 (CON-16): a key may have a daily quota (`per_day`, UTC days, kept across restarts; over it, 429
`key_daily_quota` until midnight UTC), and the share page shows what each key did today: its requests, the MCP tools
it called and the models it asked for, by name and count (migration `0009_share_api_key_usage`).
ddiff --git a/README.md b/README.md
iindex 28a72db..aef0809 100644
---- a/README.md
++++ b/README.md
@@@ -124,6 +124,28 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mo
the list adds `today` and `used: [{ what, n }]`) and `DELETE /api/v1/shares/:id/api-keys/:sak_id`. Cases:
`test/api-keys.test.js`, `test/api-key-quotas.test.js`.
++### Claude and other MCP clients (CON-16, MCP OAuth 2.1)
++
++If what you share is an MCP server, its address works as a connector URL in Claude or any MCP client
++(`https://tools--you.openvibe.website/mcp`). The client signs the person in with OpenVibe, with no key to paste:
++
++1. The share answers a program that has no credentials `401` with
++ `WWW-Authenticate: Bearer resource_metadata="https://<share>/.well-known/oauth-protected-resource"`. That document
++ (RFC 9728) names Connect as the authorization server. Connect's metadata (RFC 8414) is at
++ `/.well-known/oauth-authorization-server`.
++2. The client registers itself (`POST /oauth/register`, RFC 7591). Only public clients with PKCE are accepted, and their
++ redirect URIs must be https, or http on a loopback address for a desktop app.
++3. `GET /oauth/authorize` with `resource=<the share's address>` (RFC 8707) and an S256 challenge. The person signs in,
++ the share's own rule decides whether they may, and they consent on Connect. An unknown client or redirect URI gets
++ a page, never a redirect.
++4. `POST /oauth/token` swaps the code (60 seconds, used once) and verifier for an access token (`ovat_…`, an hour) bound
++ to that share, and a refresh token (`ovrt_…`, 30 days). Each refresh rotates both; the old pair stops.
++
++The edge takes `Authorization: Bearer ovat_…` as the person who consented. The app sees `X-OpenVibe-Subject` and
++`X-OpenVibe-User` as for a signed-in visitor, never the token. A policy change, the owner signing the app out (on the
++share's page, under "Use it from Claude and other MCP clients") or the share closing stops every token. Only hashes are
++kept. Cases: `test/mcp-oauth.test.js`.
++
## API
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
ddiff --git a/migrations/0010_share_oauth.sql b/migrations/0010_share_oauth.sql
nnew file mode 100644
iindex 0000000..5aa9d18
---- /dev/null
++++ b/migrations/0010_share_oauth.sql
@@@ -0,0 +1,29 @@
++-- phase: expand
++-- MCP OAuth 2.1 for shares (CON-16): an MCP client (Claude, an editor) signs a person in to a share with OpenVibe instead
++-- of a pasted key. Connect is the authorization server (connect.openvibe.website/oauth/*): clients register themselves
++-- (RFC 7591, public clients with PKCE), the person signs in, the share's own policy is checked and they consent, and the
++-- client gets an access token bound to that one share (an hour) and a refresh token (30 days, rotated on each use).
++-- Only token hashes are kept. A policy change, the owner's revoke or the share closing stops them.
++CREATE TABLE IF NOT EXISTS oauth_clients (
++ id text PRIMARY KEY, -- the client_id, occ_<ulid>
++ name text NOT NULL,
++ redirect_uris jsonb NOT NULL, -- exact matches only
++ created_at timestamptz NOT NULL DEFAULT now(),
++ last_used_at timestamptz
++);
++CREATE TABLE IF NOT EXISTS share_oauth_tokens (
++ id text PRIMARY KEY, -- sot_<ulid>
++ share_id text NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
++ client_id text NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE,
++ subject text NOT NULL, -- usr_… (the person who consented)
++ username text,
++ policy_version integer NOT NULL,
++ access_hash text NOT NULL UNIQUE,
++ access_expires_at timestamptz NOT NULL,
++ refresh_hash text UNIQUE,
++ refresh_expires_at timestamptz,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ last_used_at timestamptz,
++ revoked_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS share_oauth_tokens_share ON share_oauth_tokens (share_id) WHERE revoked_at IS NULL;
ddiff --git a/server/app.js b/server/app.js
iindex c64394c..8456f45 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -33,6 +33,8 @@ const { createNodeAuth } = require('./auth/node-auth');
const { createMachines } = require('./machines');
const { createHooks } = require('./hooks');
const { createApiKeys } = require('./api-keys');
++const { createOAuth } = require('./oauth');
++const { createOAuthRoutes } = require('./http/oauth-routes');
const { createProbe } = require('./probe');
const { createHookRoutes } = require('./http/hooks');
const { createServiceOutbox } = require('openvibe-sdk/events');
@@@ -86,7 +88,8 @@ async function createApp(opts = {}) {
const gate = createGate({ config, now: s.now });
// API keys for a share (CON-16 v0): a program passes the gate with one; it opens the share's API, never its pages.
const apiKeys = createApiKeys(s, { now: s.now });
-- const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, log, now: s.now });
++ const oauth = createOAuth(s, { shares, scheme: config.shares.scheme, now: s.now });
++ const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, oauth, log, now: s.now });
const accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
@@@ -103,7 +106,7 @@ async function createApp(opts = {}) {
});
const hooks = createHooks(s, { config, outbox, now: s.now });
-- const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, apiKeys, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, apiKeys, oauth, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };
ctx.probe = createProbe({ shares, proxy, config, log, now: s.now });
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
@@@ -242,6 +245,7 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CONNECT_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
++ app.use(createOAuthRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => sendNotFound(req, res, ctx));
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex 33bdb86..b880070 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -37,8 +37,9 @@ function privateCache(value) {
}
const AUDIT_MAX = 64 * 1024;
++const PRM = '/.well-known/oauth-protected-resource';
--function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = console, now = () => Date.now() }) {
++function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now() }) {
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
const inspector = require('./inspector').createInspector({ now });
const domain = config.shares.domain;
@@@ -49,6 +50,8 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
const isShareHost = (host) => Boolean(parseHost(host, domain));
const today = () => new Date(now()).toISOString().slice(0, 10);
++ const shareUrl = (share) => share.url || `https://${share.host}`;
++ const resourceMetadata = (share) => `${shareUrl(share)}${PRM}`;
const grantUrl = (share, next) => `${config.baseUrl}/grant?share=${encodeURIComponent(share.id)}&next=${encodeURIComponent(gate.safeNext(next))}`;
async function lookup(host) {
@@@ -107,7 +110,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
const name = String(pairs[i]).toLowerCase();
if (DROP_IN.test(name)) continue;
// An API key (CON-16) is for the edge alone: the app never sees it, only X-OpenVibe-Subject key:<id>.
-- if (who.apiKey && name === 'authorization') continue;
++ if ((who.apiKey || who.oauth) && name === 'authorization') continue;
if (HOP.has(name) && !(raw && (name === 'connection' || name === 'upgrade'))) continue;
out.push([name, String(pairs[i + 1])]);
}
@@@ -198,6 +201,11 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
if (apiKeys.overRate(key)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'key_rate_limited', 'Too many requests', `This key allows ${key.per_minute} requests a minute.`, { headers: { 'retry-after': '60' } }); return null; }
// Its daily quota (CON-16 v1), in UTC days: spent, it opens again at midnight UTC.
if (await apiKeys.overDay(key)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'key_daily_quota', 'Over today\'s quota', `This key allows ${key.per_day} requests a day. It opens again at midnight UTC.`, { headers: { 'retry-after': String(apiKeys.untilTomorrow()) }, json: { per_day: key.per_day } }); return null; }
++ } else if (oauth && /^Bearer\s+ovat_/i.test(String(req.headers.authorization || ''))) {
++ // An MCP client signed in with OpenVibe (CON-16, server/oauth.js): the person who consented, for this share.
++ const g = await oauth.check(share, String(req.headers.authorization).replace(/^Bearer\s+/i, '').trim());
++ if (!g) { sendProblem(req, res, 401, 'invalid_token', 'Sign in again', 'That sign-in is out of date or was signed out. The app should sign in again.', { json: { error: 'invalid_token' }, headers: { 'www-authenticate': `Bearer error="invalid_token", resource_metadata="${resourceMetadata(share)}"` } }); return null; }
++ who = { subject: g.subject, username: g.username, oauth: g.id, client: g.client };
} else {
const { ours } = gate.splitCookies(req.headers.cookie);
who = ours ? gate.verify(ours, share) : null;
@@@ -205,7 +213,8 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
if (!who) {
const url = grantUrl(share, parsedPath);
if (wantsHtml(req)) { res.writeHead(302, { location: url, 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store' }); res.end(); return null; }
-- sendProblem(req, res, 401, 'sign_in_required', 'Sign in first', 'This share asks every visitor to sign in with OpenVibe.', { json: { sign_in: url } });
++ // A program is told where to sign in: an MCP client follows resource_metadata to Connect's OAuth (RFC 9728).
++ sendProblem(req, res, 401, 'sign_in_required', 'Sign in first', 'This share asks every visitor to sign in with OpenVibe.', { json: { sign_in: url }, headers: oauth ? { 'www-authenticate': `Bearer resource_metadata="${resourceMetadata(share)}"` } : {} });
return null;
}
if (overRate(share)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'rate_limited', 'Too many requests', 'This share had too many requests in the last minute. Try again shortly.', { headers: { 'retry-after': '30' } }); return null; }
@@@ -270,6 +279,17 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
? `You get one OpenVibe notification when ${share.owner_username}'s computer connects again. This page keeps asking too.`
: 'Too many people are waiting on this share already. This page keeps asking every 10 seconds.', { refresh: 10 });
}
++ // MCP OAuth (CON-16): the share says who signs its programs in (RFC 9728), to anyone, before the gate.
++ if (oauth && (path === PRM || path.startsWith(`${PRM}/`))) {
++ const headers = { 'content-type': 'application/json', 'cache-control': 'public, max-age=300', 'access-control-allow-origin': '*', 'access-control-allow-headers': 'mcp-protocol-version' };
++ if (req.method === 'OPTIONS') { res.writeHead(204, headers); return res.end(); }
++ res.writeHead(200, headers);
++ return res.end(JSON.stringify({
++ resource: `${shareUrl(share)}${path.slice(PRM.length)}`, resource_name: share.host,
++ authorization_servers: [config.baseUrl], bearer_methods_supported: ['header'], scopes_supported: ['share'],
++ resource_documentation: `${config.baseUrl}/#mcp`,
++ }));
++ }
if (path === '/__ovc/signout') {
return sendPage(res, 200, 'Signed out of this share', `You are signed out of ${share.host}. Your OpenVibe sign-in is untouched.`, { headers: { 'set-cookie': gate.clearCookieHeader() } });
}
ddiff --git a/server/http/oauth-routes.js b/server/http/oauth-routes.js
nnew file mode 100644
iindex 0000000..4db6834
---- /dev/null
++++ b/server/http/oauth-routes.js
@@@ -0,0 +1,140 @@
++'use strict';
++
++/**
++ * Connect as an OAuth 2.1 authorization server for MCP clients (CON-16; server/oauth.js holds the rules):
++ *
++ * GET /.well-known/oauth-authorization-server RFC 8414 metadata
++ * POST /oauth/register RFC 7591, public clients only (20 an hour from one address)
++ * GET /oauth/authorize sign in, the share's policy, then the consent page
++ * POST /oauth/authorize the person's answer (from this site only) → back to the client
++ * POST /oauth/token code + PKCE verifier, or a refresh token → tokens
++ *
++ * The metadata, registration and token answers carry CORS for any origin (MCP clients run in browsers too); the
++ * consent is a page on connect.openvibe.website and cannot be posted from elsewhere.
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const { sameOrigin } = require('./principal');
++const { send } = require('../render/layout');
++const views = require('../views');
++const { OAuthError } = require('../oauth');
++
++function createOAuthRoutes(ctx) {
++ const { config, oauth, shares, proxy } = ctx;
++ const r = asyncRouter();
++ const issuer = config.baseUrl;
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
++ const cors = (res) => { res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Headers', 'content-type, authorization, mcp-protocol-version'); res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); };
++ const oauthError = (res, err) => { res.set('Cache-Control', 'no-store'); return res.status(err.status || 400).json({ error: err.error || 'invalid_request', error_description: err.message }); };
++ const back = (redirectUri, params) => {
++ const u = new URL(redirectUri);
++ for (const [k, v] of Object.entries({ ...params, iss: issuer })) if (v != null && v !== '') u.searchParams.set(k, String(v));
++ return u.toString();
++ };
++
++ const metadata = () => ({
++ issuer,
++ authorization_endpoint: `${issuer}/oauth/authorize`,
++ token_endpoint: `${issuer}/oauth/token`,
++ registration_endpoint: `${issuer}/oauth/register`,
++ response_types_supported: ['code'],
++ grant_types_supported: ['authorization_code', 'refresh_token'],
++ code_challenge_methods_supported: ['S256'],
++ token_endpoint_auth_methods_supported: ['none'],
++ scopes_supported: ['share'],
++ authorization_response_iss_parameter_supported: true,
++ service_documentation: `${issuer}/#mcp`,
++ });
++ for (const p of ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']) {
++ r.options(p, (req, res) => { cors(res); res.status(204).end(); });
++ r.get(p, (req, res) => { cors(res); res.set('Cache-Control', 'public, max-age=3600'); res.json(metadata()); });
++ }
++
++ const registered = new Map(); // address → times this hour (memory only)
++ const overRegister = (req) => {
++ const key = String(req.ip || ''), cut = Date.now() - 3600_000;
++ const hits = (registered.get(key) || []).filter((t) => t > cut);
++ if (hits.length >= 20) return true;
++ hits.push(Date.now());
++ registered.set(key, hits);
++ if (registered.size > 10_000) registered.delete(registered.keys().next().value);
++ return false;
++ };
++ for (const p of ['/oauth/register', '/oauth/token']) r.options(p, (req, res) => { cors(res); res.status(204).end(); });
++ r.post('/oauth/register', express.json({ limit: '16kb' }), async (req, res) => {
++ cors(res);
++ if (overRegister(req)) return oauthError(res, new OAuthError(429, 'slow_down', 'Too many registrations from this address; try again later.'));
++ try {
++ const out = await oauth.register(req.body || {});
++ res.set('Cache-Control', 'no-store');
++ return res.status(201).json(out);
++ } catch (err) {
++ if (err instanceof OAuthError) return oauthError(res, err);
++ throw err;
++ }
++ });
++
++ const FIELDS = ['client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'state', 'resource', 'scope'];
++ const pick = (src) => Object.fromEntries(FIELDS.map((k) => [k, typeof src[k] === 'string' ? src[k].slice(0, 2000) : '']));
++
++ /** The checked request, or an answer already sent (a page for a bad client, a redirect for the rest). */
++ async function checked(req, res, q) {
++ try {
++ return await oauth.checkRequest(q);
++ } catch (err) {
++ if (!(err instanceof OAuthError)) throw err;
++ if (err.direct) { page(req, res, { title: 'That sign-in link is not right', body: views.message({ title: 'That sign-in link is not right', text: `${err.message} Start again from the app.`, link: { href: '/', label: 'OpenVibe Connect' } }) }, 400); return null; }
++ res.redirect(303, back(q.redirect_uri, { error: err.error, error_description: err.message, state: q.state }));
++ return null;
++ }
++ }
++
++ r.get('/oauth/authorize', async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ res.set('Referrer-Policy', 'no-referrer');
++ const q = pick(req.query);
++ const ok = await checked(req, res, q);
++ if (!ok) return undefined;
++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ if (!shares.allows(ok.share, req.viewer)) {
++ if (proxy) proxy.denied(ok.share, req.viewer);
++ return page(req, res, { title: 'Not for you', body: views.grantDenied({ share: ok.share, viewer: req.viewer }) }, 403);
++ }
++ // The answer redirects to the app: this page's form-action must allow that one origin, or the browser refuses
++ // the redirect after the form is sent (CSP applies form-action to the redirects of a submission).
++ const csp = res.get('Content-Security-Policy');
++ if (csp) res.set('Content-Security-Policy', csp.replace(/form-action ([^;]*)/, (_, list) => `form-action ${list} ${new URL(q.redirect_uri).origin}`));
++ return page(req, res, { title: `Let ${ok.client.name} in?`, body: views.oauthConsent({ client: ok.client, share: ok.share, viewer: req.viewer, fields: q }) });
++ });
++
++ r.post('/oauth/authorize', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ res.set('Referrer-Policy', 'no-referrer');
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2F');
++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Answer on connect.openvibe.website itself.', link: { href: '/', label: 'OpenVibe Connect' } }) }, 403);
++ const q = pick(req.body || {});
++ const ok = await checked(req, res, q);
++ if (!ok) return undefined;
++ if (req.body.decision !== 'allow') return res.redirect(303, back(q.redirect_uri, { error: 'access_denied', error_description: 'The person said no.', state: q.state }));
++ if (!shares.allows(ok.share, req.viewer)) return page(req, res, { title: 'Not for you', body: views.grantDenied({ share: ok.share, viewer: req.viewer }) }, 403);
++ const code = oauth.issueCode({ client: ok.client, share: ok.share, viewer: req.viewer, redirectUri: q.redirect_uri, challenge: q.code_challenge });
++ return res.redirect(303, back(q.redirect_uri, { code, state: q.state }));
++ });
++
++ r.post('/oauth/token', express.urlencoded({ extended: false, limit: '16kb' }), express.json({ limit: '16kb' }), async (req, res) => {
++ cors(res);
++ res.set('Cache-Control', 'no-store');
++ res.set('Pragma', 'no-cache');
++ try {
++ return res.json(await oauth.token(req.body || {}));
++ } catch (err) {
++ if (err instanceof OAuthError) return oauthError(res, err);
++ throw err;
++ }
++ });
++
++ return r;
++}
++
++module.exports = { createOAuthRoutes };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8857870..5a3656b 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -25,6 +25,7 @@ const views = require('../views');
const { ShareError } = require('../shares');
const { HookError } = require('../hooks');
const { ApiKeyError } = require('../api-keys');
++const { OAuthError } = require('../oauth');
function createPageRoutes(ctx) {
const { config, shares, tunnels, gate, proxy, machines, hooks, apiKeys } = ctx;
@@@ -86,13 +87,14 @@ function createPageRoutes(ctx) {
async function showShare(req, res, share, extra = {}, status = 200) {
const tunnel = tunnels.get(share.id);
-- const [visits, mine, hookList, keyList] = await Promise.all([
++ const [visits, mine, hookList, keyList, oauthSessions] = await Promise.all([
shares.visits(share.id),
share.status === 'active' ? machines.list(req.viewer.token) : Promise.resolve({ ok: true, machines: [] }),
share.status === 'active' ? hooks.list(share.id) : Promise.resolve([]),
share.status === 'active' && apiKeys ? apiKeys.list(share.id) : Promise.resolve([]),
++ share.status === 'active' && ctx.oauth ? ctx.oauth.list(share.id) : Promise.resolve([]),
]);
-- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', machines: mine.ok ? mine.machines : null, hooks: hookList, apiKeys: keyList, ...extra }) }, status);
++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', machines: mine.ok ? mine.machines : null, hooks: hookList, apiKeys: keyList, oauthSessions, ...extra }) }, status);
}
r.get('/shares/:id', async (req, res) => {
@@@ -180,6 +182,20 @@ function createPageRoutes(ctx) {
}
});
++ // Sign an MCP client out of the share (CON-16): its tokens stop at once.
++ r.post('/shares/:id/oauth/:token/revoke', async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const share = await mine(req, res);
++ if (!share) return undefined;
++ try {
++ await ctx.oauth.revoke(owner(req), share, req.params.token);
++ } catch (err) {
++ if (!(err instanceof OAuthError)) throw err;
++ return showShare(req, res, share, { error: err.message }, err.status || 404);
++ }
++ return showShare(req, res, share, { notice: 'Signed out. That app opens nothing here until someone signs it in again.' });
++ });
++
r.post('/shares/:id/api-keys/:key/revoke', async (req, res) => {
if (!guard(req, res)) return undefined;
const share = await mine(req, res);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 4810a07..39e1127 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -24,7 +24,11 @@ const TABLES = [
columns: ['id', 'share_id', 'label', 'local_path', 'status', 'created_at', 'removed_at', 'received', 'delivered', 'last_received_at', 'last_delivered_at', 'last_status'] },
// API keys on a person's shares (CON-16): the export names each key, its start and its rate, never its hash.
{ table: 'share_api_keys', subject: 'owner_subject', value: (usr) => usr, file: 'api-keys.json', erase: 'delete',
-- columns: ['id', 'share_id', 'name', 'key_prefix', 'per_minute', 'created_at', 'last_used_at', 'revoked_at'] },
++ columns: ['id', 'share_id', 'name', 'key_prefix', 'per_minute', 'per_day', 'created_at', 'last_used_at', 'revoked_at'] },
++ // MCP clients a person signed in to shares (CON-16 OAuth): which app, which share, when; never a token or its hash.
++ // Erasing deletes them, so those apps are signed out of every share at once.
++ { table: 'share_oauth_tokens', subject: 'subject', value: (usr) => usr, file: 'mcp-sign-ins.json', erase: 'delete',
++ columns: ['id', 'share_id', 'client_id', 'username', 'created_at', 'last_used_at', 'revoked_at'] },
{
table: 'shares', subject: 'owner_subject', value: (usr) => usr, file: 'shares.json', erase: 'delete',
columns: ['id', 'owner_username', 'label', 'host', 'local_port', 'mode', 'named', 'status', 'policy_version', 'key_prefix', 'note',
ddiff --git a/server/oauth.js b/server/oauth.js
nnew file mode 100644
iindex 0000000..3482f0f
---- /dev/null
++++ b/server/oauth.js
@@@ -0,0 +1,186 @@
++'use strict';
++
++/**
++ * MCP OAuth 2.1 for shares (CON-16): an MCP client signs a person in to a share with OpenVibe, so a model or tools on
++ * someone's computer can be used from Claude, an editor or any MCP client without a pasted key.
++ *
++ * Connect is the authorization server; each share host is a protected resource (RFC 9728):
++ *
++ * https://<share>/.well-known/oauth-protected-resource { resource, authorization_servers: [Connect] }
++ * https://connect…/.well-known/oauth-authorization-server (RFC 8414)
++ * POST /oauth/register a public client registers itself (RFC 7591): a name and its exact redirect URIs (https, or
++ * http on a loopback address for a desktop app)
++ * GET /oauth/authorize the person signs in; the share (named by `resource`, RFC 8707) must let them in by its own
++ * policy; they see who asks and consent (POST); the client gets a code (60 s, used once,
++ * bound to the client, the redirect URI, the PKCE S256 challenge, the share and the person)
++ * POST /oauth/token code + verifier → an access token (ovat_, an hour) bound to that share and a refresh token
++ * (ovrt_, 30 days, rotated on each use; an old one used again revokes the grant)
++ *
++ * The edge (server/edge/proxy.js) takes `Authorization: Bearer ovat_…` as the person who consented. Only hashes are kept.
++ * A policy change (policy_version), the owner's revoke, the person no longer let in, or the share closing stops it.
++ */
++const crypto = require('crypto');
++
++const ACCESS_TTL_MS = 3600_000;
++const REFRESH_TTL_MS = 30 * 86_400_000;
++const CODE_TTL_MS = 60_000;
++const ACCESS_RE = /^ovat_[A-Za-z0-9_-]{43}$/;
++const REFRESH_RE = /^ovrt_[A-Za-z0-9_-]{43}$/;
++const CLIENT_RE = /^occ_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TOKEN_ID = /^sot_[0-9A-HJKMNP-TV-Z]{26}$/;
++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++const b64url = (buf) => Buffer.from(buf).toString('base64url');
++
++class OAuthError extends Error {
++ /** `error` is the RFC 6749 code a client reads; `status` the HTTP status. */
++ constructor(status, error, description) { super(description); this.status = status; this.error = error; }
++}
++
++/** A redirect URI a public client may register: https anywhere, or http on a loopback address (a desktop app). */
++function okRedirect(uri) {
++ let u;
++ try { u = new URL(String(uri)); } catch { return false; }
++ if (u.hash || u.username || u.password) return false;
++ if (u.protocol === 'https:') return true;
++ return u.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(u.hostname);
++}
++
++function createOAuth(s, { shares, scheme = 'https', now = () => Date.now() } = {}) {
++ const { db } = s;
++ const codes = new Map(); // code → { clientId, redirectUri, challenge, shareId, subject, username, policyVersion, exp }
++ const sweep = () => { const t = now(); for (const [k, v] of codes) if (v.exp <= t) codes.delete(k); };
++
++ async function register(input = {}) {
++ const name = String(input.client_name || '').replace(/\s+/g, ' ').trim().slice(0, 80) || 'An MCP client';
++ const uris = Array.isArray(input.redirect_uris) ? input.redirect_uris.map(String) : [];
++ if (!uris.length || uris.length > 10 || !uris.every(okRedirect) || uris.some((u) => u.length > 500)) {
++ throw new OAuthError(400, 'invalid_redirect_uri', 'redirect_uris: 1 to 10 exact addresses, https or http on a loopback address');
++ }
++ const method = input.token_endpoint_auth_method || 'none';
++ if (method !== 'none') throw new OAuthError(400, 'invalid_client_metadata', 'only public clients (token_endpoint_auth_method "none", with PKCE)');
++ const grants = input.grant_types || ['authorization_code', 'refresh_token'];
++ if (!Array.isArray(grants) || grants.some((g) => !['authorization_code', 'refresh_token'].includes(g))) throw new OAuthError(400, 'invalid_client_metadata', 'grant_types: authorization_code, refresh_token');
++ const id = s.newId('occ');
++ await db.exec('INSERT INTO oauth_clients (id, name, redirect_uris) VALUES ($1, $2, $3)', [id, name, JSON.stringify(uris)]);
++ return {
++ client_id: id, client_id_issued_at: Math.floor(now() / 1000), client_name: name, redirect_uris: uris,
++ grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none',
++ };
++ }
++
++ async function client(id) {
++ if (!CLIENT_RE.test(String(id || ''))) return null;
++ const r = await db.maybe('SELECT * FROM oauth_clients WHERE id = $1', [id]);
++ if (!r) return null;
++ return { id: r.id, name: r.name, redirect_uris: typeof r.redirect_uris === 'string' ? JSON.parse(r.redirect_uris) : r.redirect_uris };
++ }
++
++ /** The share a `resource` names (its origin, with any path), or null. */
++ async function shareFor(resource) {
++ let u;
++ try { u = new URL(String(resource || '')); } catch { return null; }
++ if (u.protocol !== `${scheme}:`) return null; // https in production (CONNECT_SHARE_SCHEME)
++ const share = await shares.byHost(u.host.toLowerCase());
++ return share && share.status === 'active' ? share : null;
++ }
++
++ /**
++ * Check an authorization request (before sign-in and consent). Errors that must not redirect (an unknown client, a
++ * redirect URI it did not register) throw with `direct: true`; the rest go back to the client.
++ */
++ async function checkRequest(q) {
++ const c = await client(q.client_id);
++ if (!c) throw Object.assign(new OAuthError(400, 'invalid_client', 'Unknown client_id: the app must register first.'), { direct: true });
++ if (!q.redirect_uri || !c.redirect_uris.includes(String(q.redirect_uri))) throw Object.assign(new OAuthError(400, 'invalid_request', 'That redirect_uri is not one the app registered.'), { direct: true });
++ if (q.response_type !== 'code') throw new OAuthError(400, 'unsupported_response_type', 'response_type must be code');
++ if (q.code_challenge_method !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(String(q.code_challenge || ''))) throw new OAuthError(400, 'invalid_request', 'PKCE with S256 is required');
++ const share = await shareFor(q.resource);
++ if (!share) throw new OAuthError(400, 'invalid_target', 'resource must be the address of an open share');
++ return { client: c, share };
++ }
++
++ function issueCode({ client: c, share, viewer, redirectUri, challenge }) {
++ sweep();
++ const code = b64url(crypto.randomBytes(32));
++ codes.set(code, { clientId: c.id, redirectUri, challenge, shareId: share.id, subject: viewer.subject, username: viewer.username || null, policyVersion: share.policy_version, exp: now() + CODE_TTL_MS });
++ return code;
++ }
++
++ async function mintTokens({ shareId, clientId, subject, username, policyVersion }) {
++ const access = `ovat_${b64url(crypto.randomBytes(32))}`;
++ const refresh = `ovrt_${b64url(crypto.randomBytes(32))}`;
++ const id = s.newId('sot');
++ await db.exec(`INSERT INTO share_oauth_tokens (id, share_id, client_id, subject, username, policy_version, access_hash, access_expires_at, refresh_hash, refresh_expires_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
++ [id, shareId, clientId, subject, username, policyVersion, sha256(access), new Date(now() + ACCESS_TTL_MS), sha256(refresh), new Date(now() + REFRESH_TTL_MS)]);
++ db.exec('UPDATE oauth_clients SET last_used_at = now() WHERE id = $1', [clientId]).catch(() => {});
++ return { access_token: access, token_type: 'Bearer', expires_in: Math.round(ACCESS_TTL_MS / 1000), refresh_token: refresh, scope: 'share' };
++ }
++
++ /** POST /oauth/token. */
++ async function token(b = {}) {
++ if (b.grant_type === 'authorization_code') {
++ sweep();
++ const g = codes.get(String(b.code || ''));
++ if (g) codes.delete(String(b.code));
++ if (!g || g.exp <= now()) throw new OAuthError(400, 'invalid_grant', 'That code is unknown, used or out of date.');
++ if (b.client_id !== g.clientId || b.redirect_uri !== g.redirectUri) throw new OAuthError(400, 'invalid_grant', 'The code was given to another client or redirect_uri.');
++ const verifier = String(b.code_verifier || '');
++ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier) || b64url(crypto.createHash('sha256').update(verifier).digest()) !== g.challenge) throw new OAuthError(400, 'invalid_grant', 'The PKCE code_verifier does not match.');
++ if (b.resource) { const share = await shareFor(b.resource); if (!share || share.id !== g.shareId) throw new OAuthError(400, 'invalid_target', 'resource is not the share the code was for'); }
++ return mintTokens(g);
++ }
++ if (b.grant_type === 'refresh_token') {
++ const rt = String(b.refresh_token || '');
++ if (!REFRESH_RE.test(rt)) throw new OAuthError(400, 'invalid_grant', 'That refresh token is not one of ours.');
++ const r = await db.maybe('SELECT * FROM share_oauth_tokens WHERE refresh_hash = $1', [sha256(rt)]);
++ if (!r || r.client_id !== b.client_id) throw new OAuthError(400, 'invalid_grant', 'That refresh token is unknown.');
++ if (r.revoked_at || new Date(r.refresh_expires_at).getTime() <= now()) {
++ throw new OAuthError(400, 'invalid_grant', 'That refresh token was used, revoked or is out of date: sign in again.');
++ }
++ const share = await shares.get(r.share_id);
++ if (!share || share.status !== 'active' || share.policy_version !== Number(r.policy_version) || !shares.allows(share, { subject: r.subject, username: r.username })) {
++ await db.exec('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1', [r.id]);
++ throw new OAuthError(400, 'invalid_grant', 'The share changed who it lets in: sign in again.');
++ }
++ // Rotation: this row ends, a new one starts; the old refresh token used again finds a revoked row.
++ await db.exec('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1', [r.id]);
++ return mintTokens({ shareId: r.share_id, clientId: r.client_id, subject: r.subject, username: r.username, policyVersion: Number(r.policy_version) });
++ }
++ throw new OAuthError(400, 'unsupported_grant_type', 'grant_type is authorization_code or refresh_token');
++ }
++
++ const touched = new Map();
++ /** The edge's check of `Authorization: Bearer ovat_…` for this share: { subject, username, client } or null. */
++ async function check(share, accessToken) {
++ if (!share || share.status !== 'active' || !ACCESS_RE.test(String(accessToken || ''))) return null;
++ const r = await db.maybe(`SELECT t.*, c.name AS client_name FROM share_oauth_tokens t JOIN oauth_clients c ON c.id = t.client_id
++ WHERE t.access_hash = $1 AND t.share_id = $2 AND t.revoked_at IS NULL`, [sha256(accessToken), share.id]);
++ if (!r || new Date(r.access_expires_at).getTime() <= now() || Number(r.policy_version) !== share.policy_version) return null;
++ if (!shares.allows(share, { subject: r.subject, username: r.username })) return null;
++ const t = now();
++ if (!touched.has(r.id) || t - touched.get(r.id) > 60_000) {
++ touched.set(r.id, t);
++ db.exec('UPDATE share_oauth_tokens SET last_used_at = now() WHERE id = $1', [r.id]).catch(() => {});
++ }
++ return { id: r.id, subject: r.subject, username: r.username, client: r.client_name };
++ }
++
++ /** The share's live sign-ins, for its owner: one per app and person. */
++ async function list(shareId) {
++ const rows = await db.many(`SELECT t.id, t.subject, t.username, t.created_at, t.last_used_at, c.name AS client_name FROM share_oauth_tokens t
++ JOIN oauth_clients c ON c.id = t.client_id
++ WHERE t.share_id = $1 AND t.revoked_at IS NULL AND t.refresh_expires_at > $2 ORDER BY t.created_at DESC LIMIT 50`, [shareId, new Date(now())]);
++ return rows.map((r) => ({ id: r.id, client: r.client_name, username: r.username, created_at: new Date(r.created_at).toISOString(), last_used_at: r.last_used_at ? new Date(r.last_used_at).toISOString() : null }));
++ }
++
++ async function revoke(owner, share, id) {
++ if (!share || !owner || share.owner_subject !== owner.subject || !TOKEN_ID.test(String(id || ''))) throw new OAuthError(404, 'not_found', 'No such sign-in.');
++ const r = await db.maybe('UPDATE share_oauth_tokens SET revoked_at = now() WHERE id = $1 AND share_id = $2 AND revoked_at IS NULL RETURNING id', [id, share.id]);
++ if (!r) throw new OAuthError(404, 'not_found', 'No such sign-in.');
++ }
++
++ return { register, client, shareFor, checkRequest, issueCode, token, check, list, revoke };
++}
++
++module.exports = { createOAuth, OAuthError, okRedirect, ACCESS_RE };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 1968e86..354739d 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -278,6 +278,39 @@ ${rows}
</form></section>`;
}
++/**
++ * MCP clients (CON-16): the share's address works as an MCP server URL in Claude or any MCP client, which signs the
++ * person in with OpenVibe (OAuth). The owner sees each app and person signed in, and signs one out.
++ */
++function mcpBlock(share, sessions, now) {
++ const url = `${share.url || `https://${share.host}`}/mcp`;
++ const rows = sessions.length ? `<ul class="cn-hooks">${sessions.map((x) => `<li><div class="cn-hook-main"><b>${esc(x.client)}</b>
++<small>as ${x.username ? `@${esc(x.username)}` : 'a person'} · signed in ${esc(ago(x.created_at, now))} · ${x.last_used_at ? `last used ${esc(ago(x.last_used_at, now))}` : 'not used yet'}</small></div>
++<form method="post" action="/shares/${esc(share.id)}/oauth/${esc(x.id)}/revoke"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('close')} Sign out</button></form></li>`).join('')}</ul>`
++ : '<p class="cn-empty">No app has signed in yet.</p>';
++ return `<section class="cn-hooks-sec" id="mcp" aria-labelledby="cn-mcp-h"><h2 id="cn-mcp-h">Use it from Claude and other MCP clients</h2>
++<p>If this share is an MCP server, add <code>${esc(url)}</code> (or its own path) as a connector in Claude or any MCP client. The app asks the person to sign in with OpenVibe; this share's rule decides who may, and the app gets access to this share only.</p>
++${rows}</section>`;
++}
++
++/** The consent page of an MCP client's sign-in (CON-16): who asks, for which share, as whom. */
++function oauthConsent({ client, share, viewer, fields }) {
++ const hidden = Object.entries(fields).map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
++ let to = '';
++ try { to = new URL(fields.redirect_uri).host; } catch { /* checked before */ }
++ return `<div class="cn-wrap cn-page cn-narrow"><header class="cn-head"><h1>Let ${esc(client.name)} use ${esc(share.host)}?</h1>
++<p><b>${esc(client.name)}</b> (it goes back to <code>${esc(to)}</code>) asks to use <b>${esc(share.host)}</b>, shared by ${esc(share.owner_username)}, as ${viewer.username ? `@${esc(viewer.username)}` : 'you'}.</p></header>
++<ul class="cn-fine">
++<li>It can use what this share answers, as you would. Nothing else of your OpenVibe account.</li>
++<li>It stays signed in until the share changes who it lets in, its owner signs it out, or 30 days pass unused.</li>
++<li>Only say yes to an app you just added yourself.</li>
++</ul>
++<form class="cn-form" method="post" action="/oauth/authorize">${hidden}
++<button class="cn-btn" type="submit" name="decision" value="allow">Allow</button>
++<button class="cn-btn cn-btn-quiet" type="submit" name="decision" value="deny">No</button>
++</form></div>`;
++}
++
/**
* A key's use today (CON-16 v1): its requests against its daily quota, and the MCP tools and models it used, by count.
*/
@@@ -315,7 +348,7 @@ ${fresh}${rows}
</form></section>`;
}
--function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, machines = [], hooks = [], apiKeys = [], newApiKey = null, now = Date.now() }) {
++function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, machines = [], hooks = [], apiKeys = [], newApiKey = null, oauthSessions = [], now = Date.now() }) {
const closed = share.status !== 'active';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
@@@ -360,6 +393,7 @@ ${!key && !closed && !share.node ? `<section class="cn-run"><h2>Connect it</h2><
${closed ? '' : nodeBlock(share, machines, now)}
${closed ? '' : hooksBlock(share, hooks, now)}
${closed ? '' : apiKeysBlock(share, apiKeys, newApiKey, now)}
++${closed ? '' : mcpBlock(share, oauthSessions, now)}
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
${closed ? '' : `<section class="cn-inspector" aria-labelledby="cn-reqs-h" data-cn-reqs="${esc(share.id)}"><h2 id="cn-reqs-h">Requests</h2>
<p class="cn-fine">The last 100 through this share, newest first, added as they happen. Kept in memory only, never written down: no headers, no bodies, and query values show as "…".</p>
@@@ -414,4 +448,4 @@ function message({ title, text, link = null }) {
function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
--module.exports = { home, sharePage, grantDenied, reportForm, reportsAdmin, message, notFound, MODE_TEXT, fmtBytes, ago, until, requestRow, size };
++module.exports = { home, sharePage, grantDenied, oauthConsent, reportForm, reportsAdmin, message, notFound, MODE_TEXT, fmtBytes, ago, until, requestRow, size };
ddiff --git a/test/mcp-oauth.test.js b/test/mcp-oauth.test.js
nnew file mode 100644
iindex 0000000..3e26c66
---- /dev/null
++++ b/test/mcp-oauth.test.js
@@@ -0,0 +1,163 @@
++'use strict';
++/**
++ * MCP OAuth 2.1 for shares (CON-16): an MCP client signs a person in to a share with OpenVibe.
++ * - the share answers a program without credentials 401 + WWW-Authenticate resource_metadata, and serves its
++ * protected-resource document (RFC 9728) naming Connect as the authorization server (RFC 8414 metadata there);
++ * - a public client registers (RFC 7591; https or loopback redirect URIs only);
++ * - authorize: unknown client or redirect URI is a page, never a redirect; no PKCE goes back with an error; the person
++ * signs in, the share's own policy decides, they consent (the consent page's form-action allows the app's origin);
++ * "no" goes back with access_denied;
++ * - token: PKCE S256 checked, a code works once, the access token opens this share only, as the person, and the app
++ * never sees it; refresh rotates (the old refresh token and access token stop);
++ * - the owner sees the app signed in and signs it out; a policy change stops every token.
++ * node test/mcp-oauth.test.js
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, startLocalApp } = require('./helpers/boot');
++const { check, done } = require('./helpers/check');
++
++const b64url = (b) => Buffer.from(b).toString('base64url');
++const pkce = () => { const verifier = b64url(crypto.randomBytes(48)); return { verifier, challenge: b64url(crypto.createHash('sha256').update(verifier).digest()) }; };
++const CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
++
++(async () => {
++ const t = await boot();
++ const app = await startLocalApp();
++ const alex = t.network.addUser('alex');
++ const bob = t.network.addUser('bob');
++ const made = await t.makeShare(alex, { label: 'mcp', port: app.port });
++ const other = await t.makeShare(alex, { label: 'other', port: app.port });
++ const c = t.client({ key: made.key, port: app.port });
++ await c.ready();
++ const host = made.share.host;
++ const resource = made.share.url;
++ const mcp = (headers = {}) => t.share(host, '/mcp', { method: 'POST', body: '{"jsonrpc":"2.0","id":1,"method":"tools/list"}', headers: { accept: 'application/json, text/event-stream', 'content-type': 'application/json', ...headers } });
++ const query = (o) => `/oauth/authorize?${new URLSearchParams(o).toString()}`;
++ let clientId;
++
++ await check('a program without credentials is told where to sign in (RFC 9728), and Connect publishes its metadata', async () => {
++ const r = await mcp();
++ assert.strictEqual(r.status, 401, r.text);
++ assert.strictEqual(r.headers['www-authenticate'], `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource"`);
++ const prm = await t.share(host, '/.well-known/oauth-protected-resource', { headers: { accept: 'application/json' } });
++ assert.strictEqual(prm.status, 200);
++ assert.deepStrictEqual(prm.json().authorization_servers, [t.config.baseUrl]);
++ assert.strictEqual(prm.json().resource, resource);
++ assert.strictEqual((await t.share(host, '/.well-known/oauth-protected-resource/mcp', { headers: { accept: 'application/json' } })).json().resource, `${resource}/mcp`);
++ const meta = await t.get('/.well-known/oauth-authorization-server');
++ assert.strictEqual(meta.status, 200);
++ const m = meta.json();
++ assert.strictEqual(m.issuer, t.config.baseUrl);
++ assert.strictEqual(m.authorization_endpoint, `${t.config.baseUrl}/oauth/authorize`);
++ assert.deepStrictEqual(m.code_challenge_methods_supported, ['S256']);
++ assert.strictEqual(meta.headers.get('access-control-allow-origin'), '*');
++ });
++
++ await check('a public client registers; a bad redirect URI or a confidential client is refused', async () => {
++ const r = await t.get('/oauth/register', { json: { client_name: 'Claude', redirect_uris: [CALLBACK, 'http://127.0.0.1:33418/callback'], token_endpoint_auth_method: 'none' } });
++ assert.strictEqual(r.status, 201, r.text);
++ clientId = r.json().client_id;
++ assert.match(clientId, /^occ_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual((await t.get('/oauth/register', { json: { client_name: 'x', redirect_uris: ['http://evil.example/cb'] } })).json().error, 'invalid_redirect_uri');
++ assert.strictEqual((await t.get('/oauth/register', { json: { client_name: 'x', redirect_uris: ['https://a.example/cb#frag'] } })).status, 400);
++ assert.strictEqual((await t.get('/oauth/register', { json: { client_name: 'x', redirect_uris: [CALLBACK], token_endpoint_auth_method: 'client_secret_basic' } })).json().error, 'invalid_client_metadata');
++ });
++
++ const grant = async (as, p = pkce(), extra = {}) => {
++ const q = { response_type: 'code', client_id: clientId, redirect_uri: CALLBACK, code_challenge: p.challenge, code_challenge_method: 'S256', state: 'st-1', resource, ...extra };
++ const ok = await t.get('/oauth/authorize', { as, method: 'POST', form: { ...q, decision: 'allow' } });
++ assert.strictEqual(ok.status, 303, ok.text.slice(0, 300));
++ const back = new URL(ok.headers.get('location'));
++ return { code: back.searchParams.get('code'), back, p };
++ };
++ const token = (form) => t.get('/oauth/token', { form });
++
++ await check('authorize: bad client and redirect URI are pages; no PKCE goes back with an error; sign-in and the share\'s policy come first', async () => {
++ const p = pkce();
++ const q = { response_type: 'code', client_id: clientId, redirect_uri: CALLBACK, code_challenge: p.challenge, code_challenge_method: 'S256', state: 'st-1', resource };
++ const unknown = await t.get(query({ ...q, client_id: 'occ_01M6FWGEVYZYQTP5ZPZ75CWB7P' }), { as: alex });
++ assert.strictEqual(unknown.status, 400);
++ assert.ok(!unknown.headers.get('location'), 'never redirected to an address nobody registered');
++ assert.strictEqual((await t.get(query({ ...q, redirect_uri: 'https://evil.example/cb' }), { as: alex })).status, 400);
++ const nopkce = await t.get(query({ ...q, code_challenge: '' }), { as: alex });
++ assert.strictEqual(nopkce.status, 303);
++ assert.strictEqual(new URL(nopkce.headers.get('location')).searchParams.get('error'), 'invalid_request');
++ const notshare = await t.get(query({ ...q, resource: 'https://example.com' }), { as: alex });
++ assert.strictEqual(new URL(notshare.headers.get('location')).searchParams.get('error'), 'invalid_target');
++ const anon = await t.get(query(q));
++ assert.strictEqual(anon.status, 303);
++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);
++ assert.strictEqual((await t.get(query(q), { as: bob })).status, 403, 'the share lets only alex in');
++ const consent = await t.get(query(q), { as: alex });
++ assert.strictEqual(consent.status, 200);
++ assert.match(consent.text, new RegExp(`Let Claude use ${host.replace(/\./g, '\\.')}\\?`));
++ assert.match(consent.text, /name="decision" value="allow">Allow/);
++ assert.match(consent.headers.get('content-security-policy'), /form-action [^;]*https:\/\/claude\.ai/, 'the browser may follow the answer back to the app');
++ const no = await t.get('/oauth/authorize', { as: alex, method: 'POST', form: { ...q, decision: 'deny' } });
++ assert.strictEqual(new URL(no.headers.get('location')).searchParams.get('error'), 'access_denied');
++ const cross = await t.get('/oauth/authorize', { as: alex, method: 'POST', form: { ...q, decision: 'allow' }, origin: 'https://evil.example' });
++ assert.strictEqual(cross.status, 403, 'consent is given on Connect itself');
++ });
++
++ let tokens;
++ await check('token: PKCE checked, a code works once; the token opens this share as alex, and the app never sees it', async () => {
++ const bad = await grant(alex);
++ assert.strictEqual(bad.back.searchParams.get('state'), 'st-1');
++ assert.strictEqual(bad.back.searchParams.get('iss'), t.config.baseUrl);
++ assert.strictEqual((await token({ grant_type: 'authorization_code', code: bad.code, client_id: clientId, redirect_uri: CALLBACK, code_verifier: pkce().verifier })).json().error, 'invalid_grant');
++ const g = await grant(alex);
++ const r = await token({ grant_type: 'authorization_code', code: g.code, client_id: clientId, redirect_uri: CALLBACK, code_verifier: g.p.verifier, resource });
++ assert.strictEqual(r.status, 200, r.text);
++ tokens = r.json();
++ assert.match(tokens.access_token, /^ovat_/);
++ assert.match(tokens.refresh_token, /^ovrt_/);
++ assert.strictEqual(tokens.expires_in, 3600);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ assert.strictEqual((await token({ grant_type: 'authorization_code', code: g.code, client_id: clientId, redirect_uri: CALLBACK, code_verifier: g.p.verifier })).json().error, 'invalid_grant', 'once');
++ const call = await mcp({ authorization: `Bearer ${tokens.access_token}` });
++ assert.strictEqual(call.status, 200, call.text);
++ const seen = app.seen[app.seen.length - 1];
++ assert.strictEqual(seen.headers['x-openvibe-subject'], alex.subject);
++ assert.strictEqual(seen.headers['x-openvibe-user'], 'alex');
++ assert.strictEqual(seen.headers.authorization, undefined, 'the token is dropped at the edge');
++ const elsewhere = await t.share(other.share.host, '/mcp', { method: 'POST', body: '{}', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${tokens.access_token}` } });
++ assert.strictEqual(elsewhere.status, 401, 'bound to its own share');
++ assert.match(elsewhere.headers['www-authenticate'], /error="invalid_token"/);
++ });
++
++ await check('refresh rotates: new tokens work, the old refresh and access tokens stop', async () => {
++ const r = await token({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, client_id: clientId });
++ assert.strictEqual(r.status, 200, r.text);
++ const next = r.json();
++ assert.notStrictEqual(next.access_token, tokens.access_token);
++ assert.strictEqual((await mcp({ authorization: `Bearer ${next.access_token}` })).status, 200);
++ assert.strictEqual((await mcp({ authorization: `Bearer ${tokens.access_token}` })).status, 401);
++ assert.strictEqual((await token({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, client_id: clientId })).json().error, 'invalid_grant');
++ tokens = next;
++ });
++
++ await check('the owner sees the app signed in and signs it out; a policy change stops every token', async () => {
++ const page = await t.get(`/shares/${made.share.id}`, { as: alex });
++ assert.match(page.text, /Use it from Claude and other MCP clients/);
++ assert.match(page.text, /<b>Claude<\/b>\n<small>as @alex/);
++ const id = /\/oauth\/(sot_[0-9A-HJKMNP-TV-Z]{26})\/revoke/.exec(page.text)[1];
++ assert.strictEqual((await t.get(`/shares/${made.share.id}/oauth/${id}/revoke`, { as: bob, method: 'POST', form: {} })).status, 404);
++ const out = await t.get(`/shares/${made.share.id}/oauth/${id}/revoke`, { as: alex, method: 'POST', form: {} });
++ assert.strictEqual(out.status, 200);
++ assert.strictEqual((await mcp({ authorization: `Bearer ${tokens.access_token}` })).status, 401);
++ const g = await grant(alex);
++ const fresh = (await token({ grant_type: 'authorization_code', code: g.code, client_id: clientId, redirect_uri: CALLBACK, code_verifier: g.p.verifier })).json();
++ assert.strictEqual((await mcp({ authorization: `Bearer ${fresh.access_token}` })).status, 200);
++ const policy = await t.get(`/shares/${made.share.id}/policy`, { as: alex, method: 'POST', form: { mode: 'named', named: 'bob' } });
++ assert.strictEqual(policy.status, 303);
++ assert.strictEqual((await mcp({ authorization: `Bearer ${fresh.access_token}` })).status, 401, 'the policy changed: sign in again');
++ assert.strictEqual((await token({ grant_type: 'refresh_token', refresh_token: fresh.refresh_token, client_id: clientId })).json().error, 'invalid_grant');
++ const stored = JSON.stringify(await t.ctx.s.db.many('SELECT * FROM share_oauth_tokens'));
++ assert.ok(!stored.includes(fresh.access_token) && !stored.includes(fresh.refresh_token), 'only hashes are kept');
++ });
++
++ c.stop();
++ app.close();
++ await done(t);
++})().catch((e) => { console.error(e); process.exit(1); });