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}