Ctrl K
ComunidadGuías…

__tests__/mcp-oauth.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * The claude.ai connector login, end to end: discovery from a 401, open registration, the owner's
 * consent, PKCE code exchange, and an MCP call made with the token that came back. Plus every door
 * that must stay shut — a consent POST from another origin or without the page's token, a wrong
 * verifier, a code spent twice, a redirect that is not https.
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import mcp from '../src/mcp.js';
import mcpOauth from '../src/mcpOauth.js';
import deviceTokens from '../src/deviceTokens.js';
import { parseKeys } from '../src/keys.js';

const REDIRECT = 'https://claude.ai/api/mcp/auth_callback';
const b64url = (b) => Buffer.from(b).toString('base64url');

describe('MCP OAuth for claude.ai connectors', () => {
  let http, base, keys, dir, signedIn;
  const accounts = {
    readCookie: (req) => (req.headers.cookie || '').includes('gb_session=ok') ? 'ok' : null,
    verifyToken: (t) => (t === 'ok' && signedIn ? { username: 'carla' } : null),
  };
  const form = (obj) => new URLSearchParams(obj).toString();

  beforeAll(async () => {
    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gb-oauth-'));
    process.env.PROFILE_DIR = dir;
    keys = parseKeys('env-key:solo');
    const app = express();
    app.use(express.json());
    mcp.mount(app, { keys, challenge: mcpOauth.challengeFor, requestApi: async (req) => ({ ok: true, data: { sessions: [], auth: req.authorization } }) });
    mcpOauth.mount(app, {
      accounts, log: { info() {}, warn() {}, error() {} },
      mintToken: (owner, client) => deviceTokens.mint(keys, { owner, ...mcpOauth.connectorDevice(client) }).token,
    });
    http = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
    base = `http://127.0.0.1:${http.address().port}`;
    process.env.TOOL_PUBLIC_URL = base;
  });
  afterAll(async () => {
    delete process.env.TOOL_PUBLIC_URL; delete process.env.PROFILE_DIR;
    await new Promise((resolve) => http.close(resolve));
    fs.rmSync(dir, { recursive: true, force: true });
  });

  const register = async (body = { client_name: 'Claude', redirect_uris: [REDIRECT] }) =>
    fetch(`${base}/oauth/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });

  /** Walk the browser part: open /authorize signed in, read the page's hidden fields, press a button. */
  async function consent(clientId, { verifier = 'v'.repeat(50), decision = 'allow', origin = base, csrf } = {}) {
    const q = { client_id: clientId, redirect_uri: REDIRECT, response_type: 'code', state: 'st8',
      code_challenge: b64url(crypto.createHash('sha256').update(verifier).digest()), code_challenge_method: 'S256',
      resource: `${base}/mcp`, scope: 'browser' };
    const html = await (await fetch(`${base}/oauth/authorize?${form(q)}`, { headers: { cookie: 'gb_session=ok' } })).text();
    const token = csrf ?? (html.match(/name="csrf" value="([^"]+)"/) || [])[1];
    const res = await fetch(`${base}/oauth/authorize`, {
      method: 'POST', redirect: 'manual',
      headers: { cookie: 'gb_session=ok', 'content-type': 'application/x-www-form-urlencoded', ...(origin ? { origin } : {}) },
      body: form({ ...q, csrf: token || '', decision }),
    });
    return { res, html, location: res.headers.get('location') };
  }

  it('a 401 on /mcp points at the metadata, and discovery describes this server', async () => {
    const res = await fetch(`${base}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
    expect(res.status).toBe(401);
    expect(res.headers.get('www-authenticate')).toContain(`resource_metadata="${base}/.well-known/oauth-protected-resource"`);
    const bad = await fetch(`${base}/mcp`, { method: 'POST', headers: { authorization: 'Bearer nope', 'content-type': 'application/json' }, body: '{}' });
    expect(bad.status).toBe(401);
    expect(bad.headers.get('www-authenticate')).toContain('error="invalid_token"');

    const prm = await (await fetch(`${base}/.well-known/oauth-protected-resource`)).json();
    expect(prm).toMatchObject({ resource: `${base}/mcp`, authorization_servers: [base] });
    const as = await (await fetch(`${base}/.well-known/oauth-authorization-server`)).json();
    expect(as).toMatchObject({ issuer: base, code_challenge_methods_supported: ['S256'], registration_endpoint: `${base}/oauth/register` });
  });

  it('registers clients but refuses non-https redirects', async () => {
    expect((await register({ redirect_uris: ['http://evil.example/cb'] })).status).toBe(400);
    expect((await register({ redirect_uris: ['http://localhost:33418/callback'] })).status).toBe(201);
    const ok = await register();
    expect(ok.status).toBe(201);
    expect((await ok.json()).client_id).toMatch(/^mcp_[0-9a-f]{32}$/);
  });

  it('asks the owner to sign in first, and validates the request before any consent screen', async () => {
    const { client_id } = await (await register()).json();
    signedIn = false;
    const q = form({ client_id, redirect_uri: REDIRECT, response_type: 'code', code_challenge: 'x', code_challenge_method: 'S256' });
    expect(await (await fetch(`${base}/oauth/authorize?${q}`)).text()).toContain('Sign in to your Ghost Browser first');
    const plain = form({ client_id, redirect_uri: REDIRECT, response_type: 'code', code_challenge: 'x', code_challenge_method: 'plain' });
    expect((await fetch(`${base}/oauth/authorize?${plain}`)).status).toBe(400);
    const other = form({ client_id, redirect_uri: 'https://elsewhere.example/cb', response_type: 'code', code_challenge: 'x', code_challenge_method: 'S256' });
    expect((await fetch(`${base}/oauth/authorize?${other}`)).status).toBe(400);
  });

  it('the whole connector login: consent, exchange, then a real MCP call with the token', async () => {
    signedIn = true;
    const { client_id } = await (await register()).json();
    const verifier = crypto.randomBytes(32).toString('base64url');
    const { html, location } = await consent(client_id, { verifier });
    expect(html).toContain('Connect Claude to your Ghost Browser');
    const back = new URL(location);
    expect(back.origin + back.pathname).toBe(REDIRECT);
    expect(back.searchParams.get('state')).toBe('st8');
    expect(back.searchParams.get('iss')).toBe(base);
    const code = back.searchParams.get('code');

    const exchange = (v) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: form({ grant_type: 'authorization_code', code, client_id, redirect_uri: REDIRECT, code_verifier: v }) });
    expect((await exchange('wrong-verifier')).status).toBe(400);
    const tok = await exchange(verifier);
    expect(tok.status).toBe(200);
    const body = await tok.json();
    expect(body).toMatchObject({ token_type: 'Bearer', scope: 'browser' });
    expect(body.access_token).toMatch(/^gbd_/);
    expect((await exchange(verifier)).status).toBe(400);           // single use

    // the token is the owner's device login, listed and revocable like any other
    expect(keys.get(body.access_token)).toMatchObject({ owner: 'carla', console: true, device: true });
    const device = deviceTokens.list('carla').find((d) => d.name === 'Claude (connector)');
    expect(device).toBeTruthy();

    const client = new Client({ name: 'claude-ai', version: '1' });
    await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { authorization: `Bearer ${body.access_token}` } } }));
    const listed = await client.callTool({ name: 'browser_list', arguments: {} });
    expect(JSON.parse(listed.content[0].text).auth).toBe(`Bearer ${body.access_token}`);
    await client.close();

    deviceTokens.revoke(keys, 'carla', device.deviceId);
    const revoked = await fetch(`${base}/mcp`, { method: 'POST', headers: { authorization: `Bearer ${body.access_token}`, 'content-type': 'application/json' }, body: '{}' });
    expect(revoked.status).toBe(401);
  });

  it('refuses a consent POST from another origin or without the screen\'s token, and honours Cancel', async () => {
    signedIn = true;
    const { client_id } = await (await register()).json();
    expect((await consent(client_id, { origin: 'https://tenant-app.my-app.engineer' })).res.status).toBe(403);
    expect((await consent(client_id, { csrf: 'forged.token' })).res.status).toBe(403);
    const denied = new URL((await consent(client_id, { decision: 'deny' })).location);
    expect(denied.searchParams.get('error')).toBe('access_denied');
    expect(denied.searchParams.get('code')).toBeNull();
  });
});