openvibers/openvibe.connect Commits
MCP OAuth 2.1 for shares (CON-16): Claude and other MCP clients sign in with OpenVibe (#24)
Changes
@@@ -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 toolsit called and the models it asked for, by name and count (migration `0009_share_api_key_usage`).@@@ -124,6 +124,28 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mothe 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`.++## APIWith a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):nnew file mode 100644@@@ -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;@@@ -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));@@@ -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 = consconst 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 = consconst 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 = consif (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 = consif (!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() } });}nnew file mode 100644@@@ -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 };@@@ -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);@@@ -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',nnew file mode 100644@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -4,6 +4,11 @@ What changed in OpenVibe Connect, newest first.
@@ -4,6 +4,11 @@ What changed in OpenVibe Connect, newest first.
## Unreleased — 2026-11-03
## 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
- 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
`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`).
it called and the models it asked for, by name and count (migration `0009_share_api_key_usage`).
@@ -124,6 +124,28 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mo
@@ -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:
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`.
`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
## API
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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;
@@ -33,6 +33,8 @@ const { createNodeAuth } = require('./auth/node-auth');
@@ -33,6 +33,8 @@ const { createNodeAuth } = require('./auth/node-auth');
const { createMachines } = require('./machines');
const { createMachines } = require('./machines');
const { createHooks } = require('./hooks');
const { createHooks } = require('./hooks');
const { createApiKeys } = require('./api-keys');
const { createApiKeys } = require('./api-keys');
+const { createOAuth } = require('./oauth');
+const { createOAuthRoutes } = require('./http/oauth-routes');
const { createProbe } = require('./probe');
const { createProbe } = require('./probe');
const { createHookRoutes } = require('./http/hooks');
const { createHookRoutes } = require('./http/hooks');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
@@ -86,7 +88,8 @@ async function createApp(opts = {}) {
@@ -86,7 +88,8 @@ async function createApp(opts = {}) {
const gate = createGate({ config, now: s.now });
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.
// 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 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 accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
@@ -103,7 +106,7 @@ async function createApp(opts = {}) {
@@ -103,7 +106,7 @@ async function createApp(opts = {}) {
});
});
const hooks = createHooks(s, { config, outbox, now: s.now });
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.probe = createProbe({ shares, proxy, config, log, now: s.now });
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
@@ -242,6 +245,7 @@ async function createApp(opts = {}) {
@@ -242,6 +245,7 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CONNECT_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
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(createPageRoutes(ctx));
app.use((req, res) => sendNotFound(req, res, ctx));
app.use((req, res) => sendNotFound(req, res, ctx));
@@ -37,8 +37,9 @@ function privateCache(value) {
@@ -37,8 +37,9 @@ function privateCache(value) {
}
}
const AUDIT_MAX = 64 * 1024;
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).
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
const inspector = require('./inspector').createInspector({ now });
const inspector = require('./inspector').createInspector({ now });
const domain = config.shares.domain;
const domain = config.shares.domain;
@@ -49,6 +50,8 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
@@ -49,6 +50,8 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
const isShareHost = (host) => Boolean(parseHost(host, domain));
const isShareHost = (host) => Boolean(parseHost(host, domain));
const today = () => new Date(now()).toISOString().slice(0, 10);
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))}`;
const grantUrl = (share, next) => `${config.baseUrl}/grant?share=${encodeURIComponent(share.id)}&next=${encodeURIComponent(gate.safeNext(next))}`;
async function lookup(host) {
async function lookup(host) {
@@ -107,7 +110,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
@@ -107,7 +110,7 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
const name = String(pairs[i]).toLowerCase();
const name = String(pairs[i]).toLowerCase();
if (DROP_IN.test(name)) continue;
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>.
// 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;
if (HOP.has(name) && !(raw && (name === 'connection' || name === 'upgrade'))) continue;
out.push([name, String(pairs[i + 1])]);
out.push([name, String(pairs[i + 1])]);
}
}
@@ -198,6 +201,11 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
@@ -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; }
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.
// 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; }
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 {
} else {
const { ours } = gate.splitCookies(req.headers.cookie);
const { ours } = gate.splitCookies(req.headers.cookie);
who = ours ? gate.verify(ours, share) : null;
who = ours ? gate.verify(ours, share) : null;
@@ -205,7 +213,8 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
@@ -205,7 +213,8 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
if (!who) {
if (!who) {
const url = grantUrl(share, parsedPath);
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; }
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;
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; }
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
@@ -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.`
? `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 });
: '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') {
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() } });
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() } });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -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 };
@@ -25,6 +25,7 @@ const views = require('../views');
@@ -25,6 +25,7 @@ const views = require('../views');
const { ShareError } = require('../shares');
const { ShareError } = require('../shares');
const { HookError } = require('../hooks');
const { HookError } = require('../hooks');
const { ApiKeyError } = require('../api-keys');
const { ApiKeyError } = require('../api-keys');
+const { OAuthError } = require('../oauth');
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
const { config, shares, tunnels, gate, proxy, machines, hooks, apiKeys } = ctx;
const { config, shares, tunnels, gate, proxy, machines, hooks, apiKeys } = ctx;
@@ -86,13 +87,14 @@ function createPageRoutes(ctx) {
@@ -86,13 +87,14 @@ function createPageRoutes(ctx) {
async function showShare(req, res, share, extra = {}, status = 200) {
async function showShare(req, res, share, extra = {}, status = 200) {
const tunnel = tunnels.get(share.id);
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),
shares.visits(share.id),
share.status === 'active' ? machines.list(req.viewer.token) : Promise.resolve({ ok: true, machines: [] }),
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' ? hooks.list(share.id) : Promise.resolve([]),
share.status === 'active' && apiKeys ? apiKeys.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) => {
r.get('/shares/:id', async (req, res) => {
@@ -180,6 +182,20 @@ function createPageRoutes(ctx) {
@@ -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) => {
r.post('/shares/:id/api-keys/:key/revoke', async (req, res) => {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
const share = await mine(req, res);
const share = await mine(req, res);
@@ -24,7 +24,11 @@ const TABLES = [
@@ -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'] },
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.
// 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',
{ 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',
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',
columns: ['id', 'owner_username', 'label', 'host', 'local_port', 'mode', 'named', 'status', 'policy_version', 'key_prefix', 'note',
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -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 };
@@ -278,6 +278,39 @@ ${rows}
@@ -278,6 +278,39 @@ ${rows}
</form></section>`;
</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.
* 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}
@@ -315,7 +348,7 @@ ${fresh}${rows}
</form></section>`;
</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 closed = share.status !== 'active';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
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><
@@ -360,6 +393,7 @@ ${!key && !closed && !share.node ? `<section class="cn-run"><h2>Connect it</h2><
${closed ? '' : nodeBlock(share, machines, now)}
${closed ? '' : nodeBlock(share, machines, now)}
${closed ? '' : hooksBlock(share, hooks, now)}
${closed ? '' : hooksBlock(share, hooks, now)}
${closed ? '' : apiKeysBlock(share, apiKeys, newApiKey, 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>
<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>
${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>
<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 }) {
@@ -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' } }); }
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -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); });