From daf9b458af2d68a877407919221a273cc83d156f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Mon, 5 Oct 2026 21:35:37 +0800 Subject: [PATCH] Allow three OAuth consent levels and cache agent permission checks --- README.md | 2 + apps/api/scripts/test-isolated.cjs | 1 + apps/api/src/mcp/management.ts | 6 +- apps/api/src/mcp/oauth.ts | 16 +- apps/api/test/oauth-permissions.test.ts | 157 ++++++++++++++++++++ apps/web/src/AgentConnections.tsx | 8 +- apps/web/src/AgentReview.tsx | 42 ++++-- docs/mcp.md | 6 +- docs/update-oauth-permissions-2026-10-05.md | 27 ++++ update.md | 2 + 10 files changed, 245 insertions(+), 22 deletions(-) create mode 100644 apps/api/test/oauth-permissions.test.ts create mode 100644 docs/update-oauth-permissions-2026-10-05.md diff --git a/README.md b/README.md index 184f0e0..d750fcb 100644 --- a/README.md +++ b/README.md @@ -150,3 +150,5 @@ MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审 独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。 已授权的 OAuth 与个人令牌可在“我的连接”修改只读、草稿或直接写入权限和隐藏账户权限,验证当前密码后生效;不延长授权期限,实际变更会取消该连接未确认草稿。MCP `movement_create(operation=repay)` 支持信用卡等负债账户,并保留真正还款类型及优惠、编辑和撤销语义。详见 [连接权限、还款与账号删除更新](docs/update-connections-repayment-users-2026-10-05.md)。 + +OAuth 授权页始终提供只读、草稿和直接写入三档,普通用户可明确选择最终权限。Agent 教程和 Codex 配置提示词要求连接时核对一次当前权限并缓存,发生重连、权限变更通知、授权错误或缓存不足时才再次检查。详见 [OAuth 三档选择与权限缓存](docs/update-oauth-permissions-2026-10-05.md)。 diff --git a/apps/api/scripts/test-isolated.cjs b/apps/api/scripts/test-isolated.cjs index 2596b2e..d8ed220 100644 --- a/apps/api/scripts/test-isolated.cjs +++ b/apps/api/scripts/test-isolated.cjs @@ -82,6 +82,7 @@ async function main() { 'test/admin-integration.test.ts', 'test/mcp.test.ts', 'test/oauth-duration.test.ts', + 'test/oauth-permissions.test.ts', 'test/database-migrations.test.ts', 'test/connection-repayment-integration.test.ts', ]; diff --git a/apps/api/src/mcp/management.ts b/apps/api/src/mcp/management.ts index c9f5bc1..2481fa5 100644 --- a/apps/api/src/mcp/management.ts +++ b/apps/api/src/mcp/management.ts @@ -155,8 +155,10 @@ export class AgentManagementController { }); return { ok: true }; } - @Get('authorizations/:id') pending(@Param('id') id: string) { - return this.oauth.pending(z.string().uuid().parse(id)); + @Get('authorizations/:id') async pending(@Req() r: UserRequest, @Param('id') id: string) { + const result = await this.oauth.pending(z.string().uuid().parse(id)); + const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); + return { ...result, canWrite: user.role !== 'readonly' }; } @Post('authorizations/:id') async consent( @Req() r: UserRequest, diff --git a/apps/api/src/mcp/oauth.ts b/apps/api/src/mcp/oauth.ts index 4c1f9aa..870b600 100644 --- a/apps/api/src/mcp/oauth.ts +++ b/apps/api/src/mcp/oauth.ts @@ -162,6 +162,12 @@ export class AgentOAuth implements OAuthServerProvider { name: client?.client_name, redirectUri: p.redirectUri, scopes: p.scopes, + availableScopes: [ + 'read', + 'draft', + 'write', + ...p.scopes.filter((s: string) => s.startsWith('hidden_')), + ], resource: p.resource, }; } @@ -188,8 +194,14 @@ export class AgentOAuth implements OAuthServerProvider { allowed.some((s: string) => ['draft', 'write', 'hidden_write'].includes(s)) ) throw new ForbiddenException('只读账号只能授予查询权限'); - if (allowed.some((scope: string) => !parameters.scopes.includes(scope))) - throw new BadRequestException('不能授予客户端未请求的权限'); + // The owner explicitly chooses the ordinary permission level on the consent page. + // Hidden access still requires a corresponding client request and separate consent. + if ( + allowed.some( + (scope: string) => scope.startsWith('hidden_') && !parameters.scopes.includes(scope), + ) + ) + throw new BadRequestException('不能授予客户端未请求的隐藏权限'); const code = secret(); const changed = await this.db.agentAuthorization.updateMany({ where: { id, status: 'pending', expiresAt: { gt: new Date() } }, diff --git a/apps/api/test/oauth-permissions.test.ts b/apps/api/test/oauth-permissions.test.ts new file mode 100644 index 0000000..3e7a492 --- /dev/null +++ b/apps/api/test/oauth-permissions.test.ts @@ -0,0 +1,157 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID, createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; + +test( + 'OAuth read-only requests offer all three ordinary levels; explicit owner consent determines token scopes', + { skip: process.env.TEST_ISOLATED !== 'true' }, + async () => { + const db = new PrismaClient(), + ids: string[] = []; + const base = process.env.TEST_API_URL!, + root = base.replace(/\/api$/, ''); + const resource = process.env.MCP_PUBLIC_URL!, + redirect = 'http://127.0.0.1:47891/callback'; + const clientId = randomUUID(); + async function web(cookie: string, id: string, body?: unknown) { + const r = await fetch(base + '/agent/authorizations/' + id, { + method: body ? 'POST' : 'GET', + headers: { + Cookie: cookie, + Origin: process.env.WEB_ORIGIN!, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: r.status, data: await r.json() }; + } + async function request(scopes = ['read']) { + const id = randomUUID(), + verifier = randomBytes(32).toString('base64url'); + await db.agentAuthorization.create({ + data: { + id, + clientId, + expiresAt: new Date(Date.now() + 600000), + parameters: { + redirectUri: redirect, + resource, + scopes, + codeChallenge: createHash('sha256').update(verifier).digest('base64url'), + }, + }, + }); + return { id, verifier }; + } + try { + await db.agentClient.create({ + data: { + id: clientId, + metadata: { + client_id: clientId, + client_name: 'Three permissions regression', + redirect_uris: [redirect], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + }, + }, + }); + for (const role of ['user', 'readonly'] as const) { + const u = await db.user.create({ + data: { username: 'oauth_levels_' + randomUUID(), passwordHash: 'unused', role }, + }); + ids.push(u.id); + const session = randomBytes(32).toString('hex'); + await db.session.create({ + data: { + id: createHash('sha256').update(session).digest('hex'), + userId: u.id, + expiresAt: new Date(Date.now() + 3600000), + }, + }); + const cookie = 'wp_session=' + session; + for (const level of ['read', 'draft', 'write']) { + const a = await request(); + const preview = await web(cookie, a.id); + assert.equal(preview.status, 200); + assert.deepEqual(preview.data.scopes, ['read']); + assert.deepEqual(preview.data.availableScopes, ['read', 'draft', 'write']); + assert.equal(preview.data.canWrite, role !== 'readonly'); + const selected = level === 'read' ? ['read'] : ['read', level]; + const approved = await web(cookie, a.id, { approve: true, scopes: selected, days: 7 }); + if (role === 'readonly' && level !== 'read') { + assert.equal(approved.status, 403); + assert.equal( + (await db.agentAuthorization.findUniqueOrThrow({ where: { id: a.id } })).status, + 'pending', + ); + continue; + } + assert.equal(approved.status, 201, JSON.stringify(approved.data)); + const code = new URL(approved.data.redirect).searchParams.get('code')!; + const response = await fetch(root + '/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + grant_type: 'authorization_code', + code, + code_verifier: a.verifier, + redirect_uri: redirect, + resource, + }), + }); + const token = await response.json(); + assert.equal(response.status, 200, JSON.stringify(token)); + assert.equal(token.scope, selected.join(' ')); + const row = await db.agentGrant.findFirstOrThrow({ + where: { + userId: u.id, + accessDigest: createHash('sha256').update(token.access_token).digest('hex'), + }, + }); + assert.deepEqual(row.scopes, selected); + } + if (role === 'user') { + const noHidden = await request(); + assert.equal( + ( + await web(cookie, noHidden.id, { + approve: true, + scopes: ['read', 'write', 'hidden_read'], + }) + ).status, + 400, + ); + const requestedHidden = await request(['read', 'hidden_read', 'hidden_write']); + assert.equal( + ( + await web(cookie, requestedHidden.id, { + approve: true, + scopes: ['read', 'write', 'hidden_read', 'hidden_write'], + }) + ).status, + 201, + ); + const defaults = await request(); + assert.equal((await web(cookie, defaults.id, { approve: true })).status, 201); + assert.deepEqual( + (await db.agentAuthorization.findUniqueOrThrow({ where: { id: defaults.id } })) + .parameters && + ( + (await db.agentAuthorization.findUniqueOrThrow({ where: { id: defaults.id } })) + .parameters as any + ).scopes, + ['read'], + ); + } + } + } finally { + await db.user.deleteMany({ where: { id: { in: ids } } }); + await db.agentClient.deleteMany({ where: { id: clientId } }); + await db.$disconnect(); + } + }, +); diff --git a/apps/web/src/AgentConnections.tsx b/apps/web/src/AgentConnections.tsx index 3330fb5..815ddef 100644 --- a/apps/web/src/AgentConnections.tsx +++ b/apps/web/src/AgentConnections.tsx @@ -57,7 +57,7 @@ function instructions(url: string) { '以下是配置 MCP 连接后的操作说明,不会自动添加连接或授予工具。若本会话没有 WorthPath 工具,请先告知我,并指导我在客户端配置连接;不要仅凭网址或聊天中的令牌声称已连接。', '请使用已连接的 WorthPath MCP 服务处理我的资产与负债,服务地址:' + url, '接入使用 Streamable HTTP。支持 OAuth 的客户端使用该地址发现授权服务,并由我在 WorthPath 网页登录授权;个人令牌仅用于明确支持 Bearer 认证的客户端,按其文档配置环境变量或凭据存储。不要假设客户端有自定义请求头界面,不要让我把密码或令牌粘贴到对话中。localhost 仅适用于 MCP 请求在 WorthPath 所在电脑执行的客户端;云端客户端需要可访问的 HTTPS 地址。', - '先 tools/list 发现工具,调用 connection_info 核对权限:read 只读,draft 创建待网页确认的草稿,write 直接普通写入。隐藏账户读写以连接附加授权为准。', + '连接时先 tools/list 发现真实工具,再调用一次 connection_info 核对当前 resource、permission、scopes 和隐藏账户读写权限,并在本次连接中缓存结果。权限可能被我在网页修改,以服务器返回的当前权限为准,不把以前申请或聊天中的权限当作永久授权。read 只读,draft 创建待网页确认的草稿,write 直接普通写入。无需每次查询或写入前重复检查;仅在重新连接或授权、我告知权限已变更、遇到权限/授权错误,或当前缓存不足以判断能否执行任务时,再检查一次并更新缓存。不得自动扩大权限或绕过确认;不匹配预期时报告实际差异。', '查询:用 positions_list 搜索对象;需要选定对象且遇到同名时先让我选择稳定 UUID;按 limit/offset 或 cursor 完整翻页。金额和克数使用十进制字符串,业务时间是 UTC+8 的 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm。不同币种不能直接相加;明确折算币种、汇率日期、统计纳入范围及缺失汇率或估值。不擅自排除测试项目或其他项目;若提供剔除后的参考汇总,同时说明系统实际汇总与剔除项,不修改纳入设置。', '写入:先 state_get 取得 state,使用 expectedState 与唯一 idempotencyKey 调用所需工具。网络重试保持键和全部参数一致;状态冲突则重新查询并换键。不要把估值更新、转账、还款或负债变化互相替代。', '若返回 pending,把 confirmationUrl 给我,由我在网页审阅并确认;用 operation_get 轮询最终结果,不能用 confirmed=true 代替人类确认,也不能把草稿称为已完成。', @@ -97,7 +97,7 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden selected + ' --oauth-client-registration dcr 发起网页登录。我会自行登录并确认权限。等待回调时保留登录进程;需要等待我完成网页步骤时,明确告诉我当前状态。', '若授权链接包含 read,draft,write,hidden_read,hidden_write 等较宽范围,这是客户端候选范围,不代表最终授权。WorthPath 网页支持收窄,服务端按网页选择发行令牌。打开正确 WorthPath 资源的授权页供我审阅;由我选择本次指定的权限等级、隐藏账户读写选项和授权期限并确认。不要仅因候选范围较宽就终止登录;最终以 connection_info 返回的实际权限为准。', - '分别验证连接配置正确且启用、OAuth 登录成功、真实 MCP 工具调用成功,三者不能互相替代。先通过 tools/list 或客户端暴露的工具目录发现真实工具,再调用 connection_info。resource 必须为 ' + + '分别验证连接配置正确且启用、OAuth 登录成功、真实 MCP 工具调用成功,三者不能互相替代。首次连接通过 tools/list 或客户端暴露的工具目录发现真实工具,再调用一次 connection_info 核对并缓存当前授权。resource 必须为 ' + url + ',permission 必须为 ' + level + @@ -107,9 +107,9 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden hiddenRead + '、writeHidden=' + requestedHiddenWrite + - '。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。', + '。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。此处申请范围用于首次配置验收;之后权限可能由我在网页更改,按当前服务器授权判断可执行范围。无需每次操作前重复检查,仅在重新连接/授权、我告知变更、权限错误或缓存不足时再检查一次。', '保存跨对话使用规则:在实际 CODEX_HOME(默认 ~/.codex)的全局指令中,先检查 AGENTS.override.md 与 AGENTS.md 的加载优先级,在会被加载的文件中新增或更新 与 标记块。保留已有内容,只更新此块;不要创建会遮蔽其他全局指令的 override 文件,不重复添加规则。', - '全局规则应保存本次服务地址、申请的权限等级、scopes 和隐藏账户读写选项,以及下面的查询和写入操作规则。每个新会话先发现真实工具,再通过 connection_info 核对资源及本次授权,若不符停止使用并报告差异。用户提出个人余额、账户、资产、负债、净资产、历史、转账、借贷、还款或其他 WorthPath 任务时主动优先调用 worthpath MCP,不要求重复指定,不把旧聊天余额当作当前数据。这项偏好仅决定工具选择;只有用户提出具体写入任务后才执行,不自动授权账目修改。', + '全局规则应保存本次服务地址、申请的权限等级、scopes 和隐藏账户读写选项,以及下面的查询和写入操作规则。每个新会话连接时先发现真实工具,再调用一次 connection_info 核对并缓存当前资源、权限、scopes 与隐藏读写;与本次明确预期不符时停止使用并报告差异。权限可能在网页被更改,旧申请和旧聊天不代表当前授权。无需每次操作前重复检查;只在重新连接/授权、用户告知权限变更、权限错误或缓存不足时再检查一次并更新缓存。用户提出个人余额、账户、资产、负债、净资产、历史、转账、借贷、还款或其他 WorthPath 任务时主动优先调用 worthpath MCP,不要求重复指定,不把旧聊天余额当作当前数据。这项偏好仅决定工具选择;只有用户提出具体写入任务后才执行,不自动授权账目修改。', '工具未加载、服务不可达或授权失效时如实说明,必要时提示重新授权、重启客户端或新建本机会话,不擅自改用 UI/API 写入。不记录密码、令牌、账户余额或其他财务明细到全局指令中。保存后验证文件内容与加载条件,说明规则适用于这台电脑使用同一 Codex 配置的本机会话,不声称自动适用于其他电脑或云端会话。', ...instructions(url).split('\n\n').slice(3), '完成后简要报告连接配置、OAuth、真实工具验证和全局规则保存的实际结果及剩余步骤。在我提出具体账目任务前,不修改任何数据。', diff --git a/apps/web/src/AgentReview.tsx b/apps/web/src/AgentReview.tsx index 4b24420..c47bb07 100644 --- a/apps/web/src/AgentReview.tsx +++ b/apps/web/src/AgentReview.tsx @@ -13,6 +13,8 @@ type Consent = { id: string; name: string; scopes: string[]; + availableScopes: string[]; + canWrite: boolean; redirectUri: string; resource: string; }; @@ -75,14 +77,20 @@ export function AgentAuthorization({ id }: { id: string | null }) { active = false; }; }, [id, reload]); - const selected = - consent?.scopes.filter( - (s) => - s === 'read' || - s === level || - (s === 'hidden_read' && hiddenRead) || - (s === 'hidden_write' && hiddenWrite && hiddenRead && level !== 'read'), - ) || []; + const selected = consent + ? [ + 'read', + ...(level !== 'read' && consent.canWrite ? [level] : []), + ...(hiddenRead && consent.scopes.includes('hidden_read') ? ['hidden_read'] : []), + ...(hiddenWrite && + hiddenRead && + level !== 'read' && + consent.canWrite && + consent.scopes.includes('hidden_write') + ? ['hidden_write'] + : []), + ] + : []; async function decide(approve: boolean) { if (!consent || busy) return; setBusy(true); @@ -116,12 +124,12 @@ export function AgentAuthorization({ id }: { id: string | null }) { ['draft', '草稿修改', '提出修改,由你逐项确认后执行。'], ['write', '直接写入', '普通账目修改立即执行,无需逐次确认。'], ] - .filter(([key]) => consent.scopes.includes(key)) + .filter(([key]) => consent.availableScopes.includes(key)) .map(([key, title, detail]) => ( ))} + {level === 'write' && ( +

+ 直接写入会立即修改账目,无需逐次网页确认。请仅授予你信任的助手。 +

+ )} + {!consent.canWrite &&

你的账号为只读用户,只能授予查询权限。

}