From e4d4d8e4dcd800c1102c0dfc9a18617f6ddb7215 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 20 May 2026 13:39:02 +0200 Subject: [PATCH] feat: enhance OAuth scopes and UI for agent-driven approval process (#544) * feat: enhance OAuth scopes and UI for agent-driven approval process * refactor: update OAuth scopes to enforce explicit user consent for write and approval actions --- .../authorize/__tests__/route.test.ts | 32 +++++++++++-------- app/api/mcp-oauth/authorize/route.ts | 19 ++++++++--- .../mcp-oauth/token/__tests__/route.test.ts | 27 ++++++++++++---- extensions/general/mcp-server/server.ts | 2 +- lib/auth/api-keys.ts | 25 +++++++++++---- 5 files changed, 73 insertions(+), 32 deletions(-) diff --git a/app/api/mcp-oauth/authorize/__tests__/route.test.ts b/app/api/mcp-oauth/authorize/__tests__/route.test.ts index c008c414..ace03945 100644 --- a/app/api/mcp-oauth/authorize/__tests__/route.test.ts +++ b/app/api/mcp-oauth/authorize/__tests__/route.test.ts @@ -103,10 +103,10 @@ describe('GET /api/mcp-oauth/authorize — CSP', () => { it('renders both read and write rows when client passes only the legacy `mcp` scope marker', async () => { // Claude's connector sends scope=mcp today. The consent UI must render - // every scope group so the user can opt into :write grants — only the - // :read rows are pre-checked (Art. 25(2) data-protection-by-default). - // Regression: commit 04c097c2 hid all write rows by clamping the - // ceiling to DEFAULT_OAUTH_SCOPES. + // every scope group so the user can opt into write/approval rows if they + // want — but each write/approve row MUST start unchecked. Affirmative + // opt-in is the access-control gate (GDPR Art. 25(2), ISO 27001:2022 + // A.5.18 / A.8.2, SOC 2 CC6.3, ASVS V10.2.2 / V2.3.1). const request = new Request( buildAuthorizeUrl({ response_type: 'code', @@ -120,24 +120,28 @@ describe('GET /api/mcp-oauth/authorize — CSP', () => { expect(response.status).toBe(200) const html = await response.text() - // Write scopes must render as checkboxes (un-pre-checked). + // Every scope row is rendered so the user can opt into / out of each one. expect(html).toMatch(/value="transactions:write"/) expect(html).toMatch(/value="bookkeeping:write"/) expect(html).toMatch(/value="invoices:write"/) expect(html).toMatch(/value="pending_operations:approve"/) - // The :write checkbox must NOT be pre-checked when the client passed - // no explicit scope request — the user has to opt in deliberately. - const writeRow = html.match( - /]*value="transactions:write"[^>]*>/ - )?.[0] + // Write and approval scopes MUST render unchecked. Users have to make an + // affirmative, deliberate selection for each destructive permission. + const writeRow = html.match(/]*value="transactions:write"[^>]*>/)?.[0] expect(writeRow).toBeDefined() expect(writeRow!).not.toContain('checked') - // The :read counterpart must still be pre-checked (safe default). - const readRow = html.match( - /]*value="transactions:read"[^>]*>/ - )?.[0] + const approveRow = html.match(/]*value="pending_operations:approve"[^>]*>/)?.[0] + expect(approveRow).toBeDefined() + expect(approveRow!).not.toContain('checked') + + const bookkeepingRow = html.match(/]*value="bookkeeping:write"[^>]*>/)?.[0] + expect(bookkeepingRow).toBeDefined() + expect(bookkeepingRow!).not.toContain('checked') + + // The :read counterpart is pre-checked (safe default). + const readRow = html.match(/]*value="transactions:read"[^>]*>/)?.[0] expect(readRow).toBeDefined() expect(readRow!).toContain('checked') }) diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index 21a8a0f1..e948e904 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -267,9 +267,20 @@ export async function GET(request: Request) { border: 1px solid var(--border); border-radius: 12px; padding: 2.5rem; - max-width: 560px; + max-width: 960px; width: 100%; } + .scope-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 1.5rem; + row-gap: 0; + align-items: start; + } + @media (max-width: 720px) { + .card { padding: 1.5rem; } + .scope-groups { grid-template-columns: 1fr; column-gap: 0; } + } .eyebrow { display: inline-flex; align-items: center; @@ -369,10 +380,8 @@ export async function GET(request: Request) { outline-offset: 2px; } .scope-group { - padding: 0.375rem 0; - border-bottom: 1px solid var(--border); + padding: 0.375rem 0 0.75rem; } - .scope-group:last-of-type { border-bottom: none; padding-bottom: 0; } .scope-group-title { font-size: 0.6875rem; font-weight: 500; @@ -530,7 +539,7 @@ export async function GET(request: Request) { - ${scopeCheckboxesHtml} +
${scopeCheckboxesHtml}