From d355746d467cb98a689d81d452fc0686eb3bafbb Mon Sep 17 00:00:00 2001 From: Windsander Date: Mon, 14 Sep 2026 21:43:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(oauth):=20=E6=96=B0=E5=A2=9E=20OAuth?= =?UTF-8?q?=20AS=20=E7=A1=AC=E5=8C=96=E7=BA=A2=E7=81=AF=E5=A5=97=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E4=BF=AE=E5=A4=8D=E5=89=8D=201=E2=80=935=20=E7=A1=AE?= =?UTF-8?q?=E5=AE=9E=E4=B8=BA=E7=BA=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/verify-mcp-oauth-hardening.mjs:断言匿名 client_credentials、匿名 /authorize、 未注册 redirect_uri、超界 scope、匿名 /register 均被拒;合法同意码路径、撤销/轮换跨重启持久、限流 429。 - 修复前实测:1–5 及撤销持久/限流为红(见 PR 描述)。 --- scripts/verify-mcp-oauth-hardening.mjs | 310 +++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 scripts/verify-mcp-oauth-hardening.mjs diff --git a/scripts/verify-mcp-oauth-hardening.mjs b/scripts/verify-mcp-oauth-hardening.mjs new file mode 100644 index 0000000..0c4d903 --- /dev/null +++ b/scripts/verify-mcp-oauth-hardening.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node +// G6.3 补丁:内置 OAuth AS 鉴权缺口修复验证(E1) +// +// 红灯 1–5(修复前必须为红): +// 1. 匿名 POST /token grant_type=client_credentials&scope=memory.admin → 非 2xx、无 access_token +// 2. 匿名 GET /authorize(无口令/无同意码,scope=memory.admin)→ 401/403,绝不 302 带 code +// 3. /authorize 未注册 redirect_uri → 拒绝、无 code +// 4. /authorize 请求超出客户端 allowed_scopes → 403/insufficient_scope +// 5. 匿名 POST /register(无 bootstrap secret)→ 拒绝/404,无 client_id +// 绿灯 6–8: +// 6. 预注册客户端 + 用户同意/口令 → code → token → /mcp tools/list 成功 +// 7. 撤销跨重启仍生效 +// 8. refresh 轮换后旧 refresh 失效(跨重启亦然);限流触发 429 +// 另验:metadata 不再广告 client_credentials、端点与实现一致。 +// 干净环境退出码 0。前置:npm run build。 + +import { createHash, randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); +const bin = join(rootDir, 'packages', 'mcp', 'bin', 'mebular.mjs'); +const ADMIN_SECRET = 'admin-secret-for-hardening-test'; +const REDIRECT = 'http://127.0.0.1:9911/callback'; +const REDIRECT_UNREGISTERED = 'http://127.0.0.1:9912/evil'; + +let passed = true; +const check = (label, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${label}${detail ? `(${detail})` : ''}`); + if (!ok) passed = false; +}; +const base64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +const pkce = () => { + const verifier = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, ''); + return { verifier, challenge: base64url(createHash('sha256').update(verifier).digest()) }; +}; + +const home = await mkdtemp(join(tmpdir(), 'mebular-oauth-hardening-')); +const mainHome = join(home, 'main'); +const servers = []; +// issuer 固定,使跨重启的 token 校验不受随机端口影响(才能真正验证撤销/轮换持久) +const FIXED_ISSUER = 'https://mebular.hardening.test'; + +function spawnServe({ subdir, args = [], env = {} }) { + const h = join(home, subdir); + const proc = spawn(process.execPath, [bin, 'serve', '--port', '0', '--auth', 'oauth', ...args], { + env: { ...process.env, MEBULAR_HOME: h, MEBULAR_STORAGE_PATH: join(h, 'store.jsonl'), MEBULAR_DEVICE_ID: 'device-hardening', ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let err = ''; + proc.stdout.on('data', (d) => { out += d.toString(); }); + proc.stderr.on('data', (d) => { err += d.toString(); }); + const handle = { proc, home: h, getOut: () => out, getErr: () => err }; + servers.push(handle); + return handle; +} + +function waitReady(handle, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + const started = Date.now(); + const timer = setInterval(() => { + const m = handle.getOut().match(/SERVE_READY (\{.*\})/); + if (m) { clearInterval(timer); resolve(JSON.parse(m[1])); } + else if (Date.now() - started > timeoutMs) { clearInterval(timer); reject(new Error(`serve 未就绪:${handle.getErr()}`)); } + }, 100); + }); +} + +async function stop(handle) { + if (handle?.proc && handle.proc.exitCode === null) { + handle.proc.kill('SIGTERM'); + await new Promise((r) => handle.proc.on('exit', r)); + } +} + +function cli(args, env = {}, storageHome) { + return new Promise((resolve) => { + const proc = spawn(process.execPath, [bin, ...args], { + env: { ...process.env, MEBULAR_HOME: storageHome ?? home, MEBULAR_STORAGE_PATH: join(storageHome ?? home, 'store.jsonl'), ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let err = ''; + proc.stdout.on('data', (d) => { out += d.toString(); }); + proc.stderr.on('data', (d) => { err += d.toString(); }); + proc.on('exit', (code) => resolve({ code, out, err })); + }); +} + +async function httpJson(url, init) { + const res = await fetch(url, init); + return { status: res.status, headers: res.headers, json: await res.json().catch(() => null), location: res.headers.get('location') }; +} + +async function getAuthorize(base, params) { + const res = await fetch(`${base}/authorize?${new URLSearchParams(params)}`, { redirect: 'manual' }); + const location = res.headers.get('location'); + let code = null; + if (location) { try { code = new URL(location).searchParams.get('code'); } catch { code = null; } } + return { status: res.status, location, code, json: await res.json().catch(() => null) }; +} + +async function postToken(base, params) { + return httpJson(`${base}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params).toString(), + }); +} + +async function mcpListTools(base, token) { + const transport = new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { authorization: `Bearer ${token}` } } }); + const client = new Client({ name: 'mebular-oauth-hardening', version: '0.1.0' }); + await client.connect(transport); + try { + const { tools } = await client.listTools(); + return tools.length; + } finally { + await client.close().catch(() => undefined); + } +} + +async function mcpRejected(base, token) { + try { + await mcpListTools(base, token); + return false; + } catch { + return true; + } +} + +console.log('Mebular G6.3 补丁 OAuth 鉴权硬化验证'); +console.log('====================================='); + +try { + // ---------- 预注册客户端 + 同意码(修复后 CLI;修复前不存在 → null) ---------- + const addRead = await cli(['token', 'client', 'add', '--redirect', REDIRECT, '--scope', 'memory.read'], {}, mainHome); + const addWrite = await cli(['token', 'client', 'add', '--redirect', REDIRECT, '--scope', 'memory.read,memory.write'], {}, mainHome); + const addAdmin = await cli(['token', 'client', 'add', '--redirect', REDIRECT, '--scope', 'memory.admin'], {}, mainHome); + const clientRead = JSON.parse(addRead.out || '{}').clientId ?? null; + const clientWrite = JSON.parse(addWrite.out || '{}').clientId ?? null; + const clientAdmin = JSON.parse(addAdmin.out || '{}').clientId ?? null; + check('CLI token client add 预注册客户端', Boolean(clientRead && clientWrite && clientAdmin), `read=${clientRead ? 'ok' : 'null'}`); + const consent = await cli(['token', 'consent', '--scope', 'memory.read,memory.write', '--ttl', '600'], {}, mainHome); + const consentCode = JSON.parse(consent.out || '{}').code ?? null; + check('CLI token consent 生成一次性同意码', typeof consentCode === 'string', consentCode ? 'ok' : String(consent.err ?? '').split('\n')[0]); + + // 等待:客户端/同意码文件已落盘(serve 每个请求现读) + await mkdir(join(mainHome, 'auth'), { recursive: true }); + + const h = spawnServe({ subdir: 'main', env: { MEBULAR_OAUTH_ADMIN_SECRET: ADMIN_SECRET, MEBULAR_OAUTH_ISSUER: FIXED_ISSUER } }); + const ready = await waitReady(h); + const base = `http://127.0.0.1:${ready.port}`; + check('serve 启动(oauth)', Number.isInteger(ready.port) && ready.port > 0, `port=${ready.port}`); + + // ---------- metadata ---------- + const as = await httpJson(`${base}/.well-known/oauth-authorization-server`); + const grants = as.json?.grant_types_supported ?? []; + check('metadata 不再广告 client_credentials', !grants.includes('client_credentials'), grants.join(',')); + check('metadata grant_types 与实现一致', JSON.stringify([...grants].sort()) === JSON.stringify(['authorization_code', 'refresh_token']), grants.join(',')); + check('metadata 默认不含 registration_endpoint', !as.json?.registration_endpoint, as.json?.registration_endpoint ?? '(none)'); + + // ---------- 红灯 1:匿名 client_credentials ---------- + const credRes = await postToken(base, { grant_type: 'client_credentials', scope: 'memory.admin' }); + check('1 匿名 client_credentials 被拒且无 token', credRes.status >= 400 && !credRes.json?.access_token, `status=${credRes.status}`); + + // ---------- 红灯 2:匿名 /authorize ---------- + const p2 = pkce(); + const anonAuthorize = await getAuthorize(base, { + response_type: 'code', client_id: clientAdmin ?? 'verify-client', redirect_uri: REDIRECT, + code_challenge: p2.challenge, code_challenge_method: 'S256', scope: 'memory.admin', state: 's2', + }); + check('2 匿名 /authorize 被拒且无 code', (anonAuthorize.status === 401 || anonAuthorize.status === 403) && !anonAuthorize.code, `status=${anonAuthorize.status}`); + + // ---------- 红灯 3:未注册 redirect_uri ---------- + const p3 = pkce(); + const badRedirect = await getAuthorize(base, { + response_type: 'code', client_id: clientRead ?? 'verify-client', redirect_uri: REDIRECT_UNREGISTERED, + code_challenge: p3.challenge, code_challenge_method: 'S256', scope: 'memory.read', admin_secret: ADMIN_SECRET, + }); + check('3 未注册 redirect_uri 被拒且无 code', badRedirect.status >= 400 && !badRedirect.code, `status=${badRedirect.status}`); + + // ---------- 红灯 4:超出客户端 allowed_scopes ---------- + const p4 = pkce(); + const overScope = await getAuthorize(base, { + response_type: 'code', client_id: clientRead ?? 'verify-client', redirect_uri: REDIRECT, + code_challenge: p4.challenge, code_challenge_method: 'S256', scope: 'memory.admin', admin_secret: ADMIN_SECRET, + }); + check('4 超出 allowed_scopes → 403(insufficient_scope)', overScope.status === 403 && !overScope.code, `status=${overScope.status}`); + + // ---------- 红灯 5:匿名 /register ---------- + const reg = await httpJson(`${base}/register`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ redirect_uris: [REDIRECT], scope: 'memory.read' }), + }); + check('5 匿名 /register 被拒且无 client_id', reg.status >= 400 && !reg.json?.client_id, `status=${reg.status}`); + + // ---------- 绿灯 6:合法路径(同意码 → code → token → /mcp) ---------- + let access = null; + let refresh = null; + { + const p = pkce(); + const authz = await getAuthorize(base, { + response_type: 'code', client_id: clientWrite ?? 'verify-client', redirect_uri: REDIRECT, + code_challenge: p.challenge, code_challenge_method: 'S256', scope: 'memory.read memory.write', state: 'legit', consent_code: consentCode, + }); + check('6a 预注册客户端 + 同意码 → 302 带 code', authz.status === 302 && !!authz.code && new URL(authz.location).searchParams.get('state') === 'legit', `status=${authz.status}`); + const tok = await postToken(base, { + grant_type: 'authorization_code', code: authz.code ?? '', redirect_uri: REDIRECT, client_id: clientWrite ?? 'verify-client', code_verifier: p.verifier, + }); + access = tok.json?.access_token ?? null; + refresh = tok.json?.refresh_token ?? null; + check('6b /token 颁发 access+refresh', tok.status === 200 && typeof access === 'string' && typeof refresh === 'string', `status=${tok.status}`); + const count = access ? await mcpListTools(base, access) : -1; + check('6c oauth token → /mcp tools/list=11', count === 11, `count=${count}`); + } + // 6d 管理员口令路径(与同意码并存) + { + const p = pkce(); + const authz = await getAuthorize(base, { + response_type: 'code', client_id: clientRead ?? 'verify-client', redirect_uri: REDIRECT, + code_challenge: p.challenge, code_challenge_method: 'S256', scope: 'memory.read', admin_secret: ADMIN_SECRET, + }); + check('6d 管理员口令 → 302 带 code', authz.status === 302 && !!authz.code, `status=${authz.status}`); + } + + // ---------- 绿灯 8:refresh 轮换 + 持久 ---------- + let rotatedRefresh = null; + if (refresh) { + const r1 = await postToken(base, { grant_type: 'refresh_token', refresh_token: refresh, client_id: clientWrite ?? 'verify-client' }); + rotatedRefresh = r1.json?.refresh_token ?? null; + check('8a refresh 轮换成功', r1.status === 200 && typeof r1.json?.access_token === 'string', `status=${r1.status}`); + const reuse = await postToken(base, { grant_type: 'refresh_token', refresh_token: refresh, client_id: clientWrite ?? 'verify-client' }); + check('8b 旧 refresh 复用被拒', reuse.status === 400, `status=${reuse.status}`); + } else { + check('8a refresh 轮换成功', false, '无 refresh(6 未通过)'); + check('8b 旧 refresh 复用被拒', false, '无 refresh(6 未通过)'); + } + + // ---------- 绿灯 7:撤销跨重启 ---------- + if (access) { + await httpJson(`${base}/token/revoke`, { + method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: access }).toString(), + }); + check('7a revoke 后当前实例拒绝', await mcpRejected(base, access)); + } else { + check('7a revoke 后当前实例拒绝', false, '无 access(6 未通过)'); + } + + // 重启同一 home + await stop(h); + const h2 = spawnServe({ subdir: 'main', env: { MEBULAR_OAUTH_ADMIN_SECRET: ADMIN_SECRET, MEBULAR_OAUTH_ISSUER: FIXED_ISSUER } }); + const ready2 = await waitReady(h2); + const base2 = `http://127.0.0.1:${ready2.port}`; + if (access) check('7b 重启后已撤销 token 仍被拒(持久)', await mcpRejected(base2, access)); + else check('7b 重启后已撤销 token 仍被拒(持久)', false, '无 access(6 未通过)'); + if (refresh || rotatedRefresh) { + const oldRefresh = refresh; + const afterRestart = oldRefresh + ? await postToken(base2, { grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: clientWrite ?? 'verify-client' }) + : null; + check('8c 重启后旧 refresh 仍失效(轮换持久)', afterRestart?.status === 400, `status=${afterRestart?.status}`); + } else { + check('8c 重启后旧 refresh 仍失效(轮换持久)', false, '无 refresh(6 未通过)'); + } + await stop(h2); + + // ---------- 绿灯 8d:限流 429 ---------- + { + const h3 = spawnServe({ subdir: 'ratelimit', env: { MEBULAR_OAUTH_RATE_LIMIT: '3' } }); + const ready3 = await waitReady(h3); + const b3 = `http://127.0.0.1:${ready3.port}`; + let saw429 = false; + for (let i = 0; i < 6; i++) { + const r = await postToken(b3, { grant_type: 'authorization_code', code: 'none', redirect_uri: REDIRECT, client_id: 'x', code_verifier: 'y' }); + if (r.status === 429) { saw429 = true; break; } + } + check('8d 限流触发 429', saw429); + await stop(h3); + } + + // ---------- 撤销文件持久化存在性 ---------- + const revokedFile = join(home, 'main', 'auth', 'revoked.json'); + const revokedRaw = await readFile(revokedFile, 'utf-8').catch(() => ''); + let revokedCount = 0; + try { revokedCount = (JSON.parse(revokedRaw).jti ?? []).length; } catch { revokedCount = 0; } + check('撤销落盘 .mebular/auth/revoked.json', revokedCount >= 1, `count=${revokedCount}`); +} catch (error) { + check('OAuth 硬化端到端', false, String(error?.message ?? error).substring(0, 400)); +} finally { + for (const s of servers) await stop(s); + await rm(home, { recursive: true, force: true }).catch(() => undefined); +} + +console.log('====================================='); +if (passed) { + console.log('✓ G6.3 补丁 OAuth 鉴权硬化验证通过(E1)'); + process.exit(0); +} else { + console.log('✗ G6.3 补丁 OAuth 鉴权硬化验证失败'); + process.exit(1); +} From 5e2cdd94ce4d3673067b5383eac4303ca2f04fd9 Mon Sep 17 00:00:00 2001 From: Windsander Date: Mon, 14 Sep 2026 21:43:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(oauth):=20G6.3=20=E8=A1=A5=E4=B8=81?= =?UTF-8?q?=E2=80=94=E2=80=94OAuth=20AS=20=E7=9C=9F=E5=AE=9E=E8=AE=A4?= =?UTF-8?q?=E8=AF=81+=E6=8E=88=E6=9D=83=E8=BE=B9=E7=95=8C=EF=BC=88D45?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(oauth): /authorize 强制「管理员口令(env MEBULAR_OAUTH_ADMIN_SECRET)或一次性本地同意码」, 缺一即 401、绝不 302;预注册 client_id 校验;redirect_uri 精确匹配;scope=请求∩allowed∩同意,超界 403 - fix(oauth): 移除 client_credentials(metadata.grant_types_supported 与 /token 分支一致) - fix(oauth): /register 默认禁用(404);仅 MEBULAR_OAUTH_REGISTER_SECRET 时开放,scope 上限 memory.read - fix(oauth): jti 撤销/refresh 轮换落盘 .mebular/auth/revoked.json(0600),跨重启仍生效 - feat(oauth): /authorize、/token 按 IP 限流(MEBULAR_OAUTH_RATE_LIMIT,默认 60/min)→ 429 + Retry-After - feat(cli): token client add|list|remove、token consent(一次性短时同意码) - test(http): verify:mcp:http 合法 PKCE 路径更新为预注册+同意码,不回归 - test(oauth): verify:mcp:oauth 修复后 1–8 全绿并接入 CI build-test-lint 对應 goal-v0.1.md G6.3 補丁與 project-status.md D45。 --- .github/workflows/ci.yml | 3 + package.json | 1 + packages/mcp/bin/mebular.mjs | 93 +++++++++++++- packages/mcp/src/serve.mjs | 234 ++++++++++++++++++++++++++++++----- scripts/verify-mcp-http.mjs | 29 +++-- 5 files changed, 321 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c57f70..4ea5453 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - name: HTTP MCP (auth / PKCE / lock / fail-closed) run: npm run verify:mcp:http + - name: OAuth hardening (no anonymous admin token) + run: npm run verify:mcp:oauth + - name: Skill package + CLI (init/keygen/print-config/status) run: npm run verify:mcp:skill diff --git a/package.json b/package.json index 03728c3..ca454f8 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "bench:snapshot": "node scripts/bench-snapshot.mjs", "verify:mcp:stdio": "node scripts/verify-mcp-stdio.mjs", "verify:mcp:http": "node scripts/verify-mcp-http.mjs", + "verify:mcp:oauth": "node scripts/verify-mcp-oauth-hardening.mjs", "verify:mcp:skill": "node scripts/verify-mcp-skill.mjs", "verify:mcp:publish": "node scripts/verify-mcp-publish.mjs", "dev": "node --loader ts-node/esm src/index.ts" diff --git a/packages/mcp/bin/mebular.mjs b/packages/mcp/bin/mebular.mjs index 5ed0722..7b2cc14 100755 --- a/packages/mcp/bin/mebular.mjs +++ b/packages/mcp/bin/mebular.mjs @@ -34,8 +34,91 @@ function homeDir() { function tokensPath(flagValue) { return flagValue ?? process.env.MEBULAR_TOKENS_FILE ?? join(homeDir(), 'auth', 'tokens.json'); } +function clientsPath(flagValue) { + return flagValue ?? process.env.MEBULAR_OAUTH_CLIENTS_FILE ?? join(homeDir(), 'auth', 'clients.json'); +} +function consentPath(flagValue) { + return flagValue ?? process.env.MEBULAR_OAUTH_CONSENT_FILE ?? join(homeDir(), 'auth', 'consent.json'); +} + +function parseScopes(value, fallback = 'memory.read') { + return String(value ?? fallback).split(',').map((s) => s.trim()).filter(Boolean); +} -async function runToken(action, flags) { +async function readJsonFile(file, shape) { + if (!existsSync(file)) return shape; + try { + const data = JSON.parse(await readFile(file, 'utf-8')); + return data; + } catch (error) { + console.error(`✗ JSON 文件损坏:${file}(${error.message})`); + process.exit(2); + } +} + +async function runTokenClient(sub, flags) { + const file = clientsPath(typeof flags['clients-file'] === 'string' ? flags['clients-file'] : undefined); + const data = await readJsonFile(file, { clients: [] }); + if (!Array.isArray(data.clients)) data.clients = []; + + if (sub === 'add') { + const redirect = flags.redirect; + if (typeof redirect !== 'string' || redirect.length === 0) { + console.error('用法:mebular token client add --redirect [--scope memory.read,memory.write]'); + process.exit(2); + } + const record = { clientId: randomUUID(), redirectUris: [redirect], allowedScopes: parseScopes(flags.scope), createdAt: new Date().toISOString() }; + data.clients.push(record); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(data, null, 2), 'utf-8'); + await chmod(file, 0o600); + console.log(JSON.stringify(record, null, 2)); + return; + } + if (sub === 'list') { + console.log(JSON.stringify(data.clients, null, 2)); + return; + } + if (sub === 'remove') { + const before = data.clients.length; + data.clients = data.clients.filter((c) => c.clientId !== flags.id); + if (data.clients.length === before) { + console.error(`✗ 未找到 client:${flags.id}`); + process.exit(2); + } + await writeFile(file, JSON.stringify(data, null, 2), 'utf-8'); + console.log(`已移除 client ${flags.id}`); + return; + } + console.error('用法:mebular token client [--redirect uri] [--scope a,b] [--id x]'); + process.exit(2); +} + +async function runTokenConsent(flags) { + const ttl = flags.ttl !== undefined ? Number(flags.ttl) : 300; + const scopes = parseScopes(flags.scope); + const file = consentPath(typeof flags['consent-file'] === 'string' ? flags['consent-file'] : undefined); + const data = await readJsonFile(file, { codes: [] }); + const now = Date.now(); + const codes = (Array.isArray(data.codes) ? data.codes : []).filter((c) => c.exp > now && !c.usedAt); + const code = `meb_consent_${randomUUID().replace(/-/g, '')}`; + codes.push({ code, scopes, createdAt: new Date(now).toISOString(), exp: now + ttl * 1000 }); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify({ codes }, null, 2), 'utf-8'); + await chmod(file, 0o600); + console.log(JSON.stringify({ code, scopes, expiresAt: new Date(now + ttl * 1000).toISOString(), consentFile: file }, null, 2)); + console.error('(将同意码提交到 /authorize 表示授权;一次性、短时有效)'); +} + +async function runToken(action, flags, subArg) { + if (action === 'consent') { + await runTokenConsent(flags); + return; + } + if (action === 'client') { + await runTokenClient(subArg, flags); + return; + } const path = tokensPath(typeof flags['tokens-file'] === 'string' ? flags['tokens-file'] : undefined); let data = { tokens: [] }; if (existsSync(path)) { @@ -197,7 +280,7 @@ async function main() { return; } case 'token': { - await runToken(argv[1], flags); + await runToken(argv[1], flags, argv[2]); return; } case '--help': @@ -210,8 +293,10 @@ async function main() { '命令:', ' mcp 启动 stdio MCP server', ' serve [--host --port --auth --tls-key --tls-cert --tokens-file] Streamable HTTP server', - ' token grant|list|revoke [--scope a,b] [--id x] [--tokens-file p] 访问令牌管理', - ' init / keygen / print-config / status (G6.4+ 计划)', + ' token grant|list|revoke [--scope a,b] [--id x] [--tokens-file p] bearer 令牌管理', + ' token client add|list|remove [--redirect uri] [--scope a,b] [--id x] OAuth 客户端预注册', + ' token consent [--scope a,b] [--ttl sec] 生成一次性本地同意码(/authorize 用)', + ' init / keygen / print-config / status 初始化与状态', ].join('\n'), ); process.exit(0); diff --git a/packages/mcp/src/serve.mjs b/packages/mcp/src/serve.mjs index e13e77d..ac4a5c9 100644 --- a/packages/mcp/src/serve.mjs +++ b/packages/mcp/src/serve.mjs @@ -5,7 +5,7 @@ // - fail closed:非环回必须 TLS 且 auth != none,否则拒绝启动。 // - 单实例:/lock O_EXCL + PID 存活检测 + 陈旧回收。 -import { createHash, randomUUID } from 'node:crypto'; +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; import { existsSync } from 'node:fs'; import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import http from 'node:http'; @@ -16,8 +16,99 @@ import { TOOL_SCOPES } from './tools.mjs'; const SCOPES = ['memory.read', 'memory.write', 'memory.admin']; const SCOPE_RANK = { 'memory.read': 0, 'memory.write': 1, 'memory.admin': 2 }; +const DEFAULT_SCOPES = ['memory.read']; const ACCESS_TTL = 900; // 15min const REFRESH_TTL = 30 * 24 * 3600; // 30d +const RATE_WINDOW_MS = 60_000; + +function safeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +} + +function basicPassword(header) { + if (typeof header !== 'string' || !header.startsWith('Basic ')) return null; + try { + const decoded = Buffer.from(header.slice('Basic '.length), 'base64').toString('utf-8'); + const idx = decoded.indexOf(':'); + return idx === -1 ? null : decoded.slice(idx + 1); + } catch { + return null; + } +} + +function makeRateLimiter(max) { + const hits = new Map(); + return (key) => { + const now = Date.now(); + const list = (hits.get(key) ?? []).filter((t) => now - t < RATE_WINDOW_MS); + list.push(now); + hits.set(key, list); + if (list.length > max) { + return { limited: true, retryAfter: Math.max(1, Math.ceil((RATE_WINDOW_MS - (now - list[0])) / 1000)) }; + } + return { limited: false }; + }; +} + +// ---------- 客户端注册 / 同意码 / 撤销持久(D45) ---------- + +async function loadClientRegistry(clientsFile) { + if (!existsSync(clientsFile)) return []; + try { + const data = JSON.parse(await readFile(clientsFile, 'utf-8')); + return Array.isArray(data.clients) ? data.clients : []; + } catch (error) { + throw new Error(`clientsFile 损坏:${clientsFile}(${error.message})`); + } +} + +async function findClient(clientsFile, clientId) { + if (!clientId) return null; + return (await loadClientRegistry(clientsFile)).find((c) => c.clientId === clientId) ?? null; +} + +async function saveClientRegistry(clientsFile, clients) { + await mkdir(dirname(clientsFile), { recursive: true }); + await writeFile(clientsFile, JSON.stringify({ clients }, null, 2), 'utf-8'); + await chmod(clientsFile, 0o600); +} + +async function consumeConsent(consentFile, code) { + if (!code || !existsSync(consentFile)) return null; + let data; + try { + data = JSON.parse(await readFile(consentFile, 'utf-8')); + } catch { + return null; + } + const codes = Array.isArray(data.codes) ? data.codes : []; + const idx = codes.findIndex((c) => c.code === code && !c.usedAt && c.exp > Date.now()); + if (idx === -1) return null; + codes[idx].usedAt = new Date().toISOString(); + await writeFile(consentFile, JSON.stringify({ codes }, null, 2), 'utf-8'); + await chmod(consentFile, 0o600); + return codes[idx]; +} + +async function loadRevoked(revokedFile) { + if (!existsSync(revokedFile)) return new Set(); + try { + const data = JSON.parse(await readFile(revokedFile, 'utf-8')); + return new Set(Array.isArray(data.jti) ? data.jti : []); + } catch { + return new Set(); + } +} + +async function persistRevoked(revokedFile, revoked) { + await mkdir(dirname(revokedFile), { recursive: true }); + await writeFile(revokedFile, JSON.stringify({ jti: [...revoked], updatedAt: new Date().toISOString() }, null, 2), 'utf-8'); + await chmod(revokedFile, 0o600); +} // ---------- 单实例锁 ---------- @@ -141,7 +232,7 @@ async function verifyJwt(signingKey, token, { issuer, resource, revoked }) { return payload; } -function metadataFor(issuer) { +function metadataFor(issuer, { registrationEnabled = false } = {}) { const resource = `${issuer}/mcp`; return { protectedResource: { @@ -154,10 +245,10 @@ function metadataFor(issuer) { issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, - registration_endpoint: `${issuer}/register`, + ...(registrationEnabled ? { registration_endpoint: `${issuer}/register` } : {}), jwks_uri: `${issuer}/jwks`, response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'], + grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'], scopes_supported: SCOPES, @@ -258,13 +349,22 @@ export async function startHttpServer({ home, service, buildServer, host = '127. } const scheme = tls ? 'https' : 'http'; + // D45:OAuth 硬化配置(密钥/secret 不落 config,仅 env) + const adminSecret = process.env.MEBULAR_OAUTH_ADMIN_SECRET ?? null; + const registerSecret = process.env.MEBULAR_OAUTH_REGISTER_SECRET ?? null; + const registrationEnabled = Boolean(registerSecret); + const clientsFile = process.env.MEBULAR_OAUTH_CLIENTS_FILE ?? join(home, 'auth', 'clients.json'); + const consentFile = process.env.MEBULAR_OAUTH_CONSENT_FILE ?? join(home, 'auth', 'consent.json'); + const revokedFile = process.env.MEBULAR_OAUTH_REVOKED_FILE ?? join(home, 'auth', 'revoked.json'); + const rateLimit = makeRateLimiter(Number(process.env.MEBULAR_OAUTH_RATE_LIMIT ?? 60)); + // origin/issuer 在 listen 后按实际端口重算(支持 --port 0) let origin = `${scheme}://${isLoopback ? '127.0.0.1' : host}:${port}`; let issuer = process.env.MEBULAR_OAUTH_ISSUER ?? origin; - let metadata = metadataFor(issuer); + let metadata = metadataFor(issuer, { registrationEnabled }); const signingKey = auth === 'oauth' ? await loadOrCreateSigningKey(home) : null; const codes = new Map(); - const revoked = new Set(); + const revoked = await loadRevoked(revokedFile); // 单实例 transport(stateful + JSON 响应) const mcpServer = buildServer(service); @@ -320,55 +420,130 @@ export async function startHttpServer({ home, service, buildServer, host = '127. }); } if (path === '/register' && req.method === 'POST') { - return sendJson(res, 201, { client_id: randomUUID(), token_endpoint_auth_method: 'none', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'] }); + // D45:默认禁用;仅 bootstrap secret 匹配时允许,且 scope 上限 memory.read + if (!registrationEnabled) return sendJson(res, 404, { error: 'not_found' }); + const provided = req.headers['x-mebular-register-secret']; + const bearer = typeof req.headers['authorization'] === 'string' && req.headers['authorization'].startsWith('Bearer ') + ? req.headers['authorization'].slice('Bearer '.length) + : null; + if (!safeEqual(typeof provided === 'string' ? provided : '', registerSecret) && !safeEqual(bearer ?? '', registerSecret)) { + return sendJson(res, 401, { error: 'invalid_client' }); + } + let meta = {}; + try { + meta = JSON.parse(body.toString('utf-8') || '{}'); + } catch { + meta = {}; + } + const redirectUris = Array.isArray(meta.redirect_uris) ? meta.redirect_uris.filter((s) => typeof s === 'string') : []; + if (redirectUris.length === 0) return sendJson(res, 400, { error: 'invalid_redirect_uri' }); + const clientId = randomUUID(); + const clients = await loadClientRegistry(clientsFile); + clients.push({ clientId, redirectUris, allowedScopes: [...DEFAULT_SCOPES], source: 'dcr', createdAt: new Date().toISOString() }); + await saveClientRegistry(clientsFile, clients); + return sendJson(res, 201, { client_id: clientId, redirect_uris: redirectUris, scope: DEFAULT_SCOPES.join(' '), token_endpoint_auth_method: 'none', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'] }); } if (path === '/authorize' && (req.method === 'GET' || req.method === 'POST')) { + const ip = req.socket?.remoteAddress ?? 'unknown'; + const rl = rateLimit(ip); + if (rl.limited) { + res.setHeader('retry-after', String(rl.retryAfter)); + return sendJson(res, 429, { error: 'too_many_requests' }); + } const params = req.method === 'GET' ? url.searchParams : new URLSearchParams(body.toString('utf-8')); + if (params.get('response_type') !== 'code') { + return sendJson(res, 400, { error: 'unsupported_response_type' }); + } + const client = await findClient(clientsFile, params.get('client_id')); + if (!client) return sendJson(res, 401, { error: 'invalid_client', error_description: 'client_id 未注册' }); const redirectUri = params.get('redirect_uri'); + if (!redirectUri || !client.redirectUris.includes(redirectUri)) { + return sendJson(res, 400, { error: 'invalid_request', error_description: 'redirect_uri 未注册(需精确匹配)' }); + } const codeChallenge = params.get('code_challenge'); - const method = params.get('code_challenge_method'); - const state = params.get('state'); - if (!redirectUri || !codeChallenge || method !== 'S256') { + if (!codeChallenge || params.get('code_challenge_method') !== 'S256') { return sendJson(res, 400, { error: 'invalid_request', error_description: 'PKCE S256 required' }); } + // 用户认证/同意:管理员口令 或 一次性本地同意码,二者缺一即拒(绝不 302) + const headerSecret = req.headers['x-mebular-admin-secret']; + const suppliedSecret = + params.get('admin_secret') ?? + (typeof headerSecret === 'string' ? headerSecret : null) ?? + basicPassword(req.headers['authorization']); + let userAuthorized = false; + let consentScopes = null; + if (adminSecret && suppliedSecret && safeEqual(String(suppliedSecret), adminSecret)) { + userAuthorized = true; + } else { + const consent = await consumeConsent(consentFile, params.get('consent_code')); + if (consent) { + userAuthorized = true; + consentScopes = Array.isArray(consent.scopes) ? consent.scopes : DEFAULT_SCOPES; + } + } + if (!userAuthorized) { + return sendJson(res, 401, { error: 'access_denied', error_description: '需要管理员口令或本地同意码' }); + } + const requested = (params.get('scope') ?? '').split(/\s+/).filter(Boolean); + const requestedScopes = requested.length > 0 ? requested : [...DEFAULT_SCOPES]; + const unknown = requestedScopes.filter((s) => !SCOPES.includes(s)); + if (unknown.length > 0) return sendJson(res, 400, { error: 'invalid_scope', error_description: unknown.join(' ') }); + const notAllowed = requestedScopes.filter((s) => !client.allowedScopes.includes(s)); + if (notAllowed.length > 0) { + return sendJson(res, 403, { error: 'insufficient_scope', error_description: `超出客户端 allowed_scopes: ${notAllowed.join(' ')}` }); + } + const granted = consentScopes ? requestedScopes.filter((s) => consentScopes.includes(s)) : requestedScopes; + if (granted.length === 0) return sendJson(res, 403, { error: 'insufficient_scope', error_description: '同意范围不含请求 scope' }); const code = randomUUID(); - codes.set(code, { - redirectUri, - codeChallenge, - scope: (params.get('scope') ?? 'memory.read').split(/\s+/).filter(Boolean), - exp: Date.now() + 300_000, - }); + const state = params.get('state'); + codes.set(code, { clientId: client.clientId, redirectUri, codeChallenge, scope: granted, exp: Date.now() + 300_000 }); const location = `${redirectUri}?code=${encodeURIComponent(code)}${state ? `&state=${encodeURIComponent(state)}` : ''}`; res.statusCode = 302; res.setHeader('location', location); return res.end(); } if (path === '/token' && req.method === 'POST') { + const ip = req.socket?.remoteAddress ?? 'unknown'; + const rl = rateLimit(ip); + if (rl.limited) { + res.setHeader('retry-after', String(rl.retryAfter)); + return sendJson(res, 429, { error: 'too_many_requests' }); + } const params = new URLSearchParams(body.toString('utf-8')); const grantType = params.get('grant_type'); const now = Math.floor(Date.now() / 1000); - let scope = ['memory.read']; + let scope = [...DEFAULT_SCOPES]; + let clientId = params.get('client_id'); if (grantType === 'authorization_code') { - const record = codes.get(params.get('code') ?? ''); + const code = params.get('code') ?? ''; + const record = codes.get(code); if (!record || record.exp < Date.now()) return sendJson(res, 400, { error: 'invalid_grant' }); + const client = await findClient(clientsFile, clientId); + if (!client || client.clientId !== record.clientId) return sendJson(res, 400, { error: 'invalid_client' }); + if (params.get('redirect_uri') !== record.redirectUri) { + return sendJson(res, 400, { error: 'invalid_grant', error_description: 'redirect_uri mismatch' }); + } const verifier = params.get('code_verifier') ?? ''; const challenge = b64url(createHash('sha256').update(verifier).digest()); if (challenge !== record.codeChallenge) return sendJson(res, 400, { error: 'invalid_grant', error_description: 'PKCE verification failed' }); - codes.delete(params.get('code')); + codes.delete(code); scope = record.scope; + clientId = record.clientId; } else if (grantType === 'refresh_token') { const payload = await verifyJwt(signingKey, params.get('refresh_token') ?? '', { issuer, resource: `${issuer}/mcp`, revoked }); if (!payload || payload.type !== 'refresh') return sendJson(res, 400, { error: 'invalid_grant' }); - revoked.add(payload.jti); // 轮换:旧 refresh 失效 - scope = payload.scope ?? ['memory.read']; - } else if (grantType === 'client_credentials') { - scope = (params.get('scope') ?? 'memory.read').split(/\s+/).filter((s) => SCOPES.includes(s)); - if (scope.length === 0) return sendJson(res, 400, { error: 'invalid_scope' }); + if (clientId && payload.client_id && clientId !== payload.client_id) return sendJson(res, 400, { error: 'invalid_grant' }); + revoked.add(payload.jti); // 轮换:旧 refresh 失效,持久化 + await persistRevoked(revokedFile, revoked); + scope = Array.isArray(payload.scope) ? payload.scope : DEFAULT_SCOPES; + clientId = payload.client_id ?? clientId; } else { + // D45:client_credentials 已移除 return sendJson(res, 400, { error: 'unsupported_grant_type' }); } - const access = await signJwt(signingKey, { iss: issuer, aud: `${issuer}/mcp`, sub: 'user', scope, type: 'access', jti: randomUUID(), iat: now, exp: now + ACCESS_TTL }); - const refresh = await signJwt(signingKey, { iss: issuer, aud: `${issuer}/mcp`, sub: 'user', scope, type: 'refresh', jti: randomUUID(), iat: now, exp: now + REFRESH_TTL }); + const claims = { iss: issuer, aud: `${issuer}/mcp`, sub: 'user', client_id: clientId ?? null, scope }; + const access = await signJwt(signingKey, { ...claims, type: 'access', jti: randomUUID(), iat: now, exp: now + ACCESS_TTL }); + const refresh = await signJwt(signingKey, { ...claims, type: 'refresh', jti: randomUUID(), iat: now, exp: now + REFRESH_TTL }); return sendJson(res, 200, { access_token: access, token_type: 'Bearer', expires_in: ACCESS_TTL, refresh_token: refresh, scope: scope.join(' ') }); } if (path === '/token/revoke' && req.method === 'POST') { @@ -378,7 +553,10 @@ export async function startHttpServer({ home, service, buildServer, host = '127. if (parts.length === 3) { try { const payload = JSON.parse(Buffer.from(b64urlToBytes(parts[1]), 'utf8').toString('utf-8')); - if (payload.jti) revoked.add(payload.jti); + if (payload.jti) { + revoked.add(payload.jti); + await persistRevoked(revokedFile, revoked); + } } catch { // ignore malformed } @@ -417,7 +595,7 @@ export async function startHttpServer({ home, service, buildServer, host = '127. const actualPort = server.address().port; origin = `${scheme}://${isLoopback ? '127.0.0.1' : host}:${actualPort}`; issuer = process.env.MEBULAR_OAUTH_ISSUER ?? origin; - metadata = metadataFor(issuer); + metadata = metadataFor(issuer, { registrationEnabled }); const close = async () => { await new Promise((resolve) => server.close(() => resolve())); await mcpServer.close().catch(() => undefined); diff --git a/scripts/verify-mcp-http.mjs b/scripts/verify-mcp-http.mjs index 2d07ed4..c7c6990 100644 --- a/scripts/verify-mcp-http.mjs +++ b/scripts/verify-mcp-http.mjs @@ -6,7 +6,7 @@ // 干净环境退出码 0。前置:npm run build(core dist)。 import { createHash, randomUUID } from 'node:crypto'; -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -169,31 +169,46 @@ try { const jwks = await httpJson(`${base}/jwks`); check('/jwks 提供 EdDSA 公钥', jwks.status === 200 && jwks.json?.keys?.[0]?.alg === 'EdDSA' && jwks.json.keys[0].crv === 'Ed25519'); + // D45:预注册客户端 + 本地同意码(不再有匿名 DCR / client_credentials) + const redirectUri = 'http://127.0.0.1/callback'; + const cliEnv = { ...process.env, MEBULAR_HOME: oauthHome, MEBULAR_STORAGE_PATH: join(oauthHome, 's.jsonl') }; + const addClient = JSON.parse( + execFileSync(process.execPath, [bin, 'token', 'client', 'add', '--redirect', redirectUri, '--scope', 'memory.read,memory.write'], { env: cliEnv, encoding: 'utf-8' }), + ); + const clientId = addClient.clientId; + check('token client add 预注册客户端', typeof clientId === 'string' && clientId.length > 0); + const consent = JSON.parse( + execFileSync(process.execPath, [bin, 'token', 'consent', '--scope', 'memory.read,memory.write', '--ttl', '600'], { env: cliEnv, encoding: 'utf-8' }), + ); + + // 匿名 /authorize 不得颁发 code + const anon = await fetch(`${base}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=x&code_challenge_method=S256&scope=memory.read`, { redirect: 'manual' }); + check('匿名 /authorize 被拒且无 code', anon.status === 401 && !anon.headers.get('location'), `status=${anon.status}`); + // PKCE 授权码流程 const verifier = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, ''); const challenge = base64url(createHash('sha256').update(verifier).digest()); - const redirectUri = 'http://127.0.0.1/callback'; const authzRes = await fetch( - `${base}/authorize?response_type=code&client_id=verify-client&redirect_uri=${encodeURIComponent(redirectUri)}` + - `&code_challenge=${challenge}&code_challenge_method=S256&scope=${encodeURIComponent('memory.read memory.write')}&state=xyz`, + `${base}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&code_challenge=${challenge}&code_challenge_method=S256&scope=${encodeURIComponent('memory.read memory.write')}&state=xyz&consent_code=${consent.code}`, { redirect: 'manual' }, ); const location = authzRes.headers.get('location') ?? ''; const code = new URL(location).searchParams.get('code'); const state = new URL(location).searchParams.get('state'); - check('PKCE /authorize 302 带 code', authzRes.status === 302 && !!code && state === 'xyz', `status=${authzRes.status}`); + check('PKCE /authorize(同意码)302 带 code', authzRes.status === 302 && !!code && state === 'xyz', `status=${authzRes.status}`); const badVerifier = await httpJson(`${base}/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: 'verify-client', code_verifier: 'wrong' }).toString(), + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, code_verifier: 'wrong' }).toString(), }); check('PKCE 错误 verifier → invalid_grant', badVerifier.status === 400 && badVerifier.json?.error === 'invalid_grant'); const tokenRes = await httpJson(`${base}/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: 'verify-client', code_verifier: verifier }).toString(), + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, code_verifier: verifier }).toString(), }); check('PKCE /token 颁发 access_token', tokenRes.status === 200 && typeof tokenRes.json?.access_token === 'string' && tokenRes.json?.token_type === 'Bearer');