From 1307d4db2efbfe441c81d59dae80de0cc38bac95 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 09:24:11 +0200 Subject: [PATCH] fix(oauth): serve RFC 9728 resource metadata at the path-based locations Claude.ai fetches (#1915) Claude.ai's connector setup derives the protected-resource metadata URL from the MCP server URL and fetches it before any 401 challenge: /.well-known/oauth-protected-resource/api/extensions/ext/mcp-server/mcp /api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource Both were 404 (only the root document our WWW-Authenticate header points at existed), which the dialog reported as "Authorization with Accounted failed". One shared builder now serves all three locations; the path-based route answers 404 for any path other than the MCP endpoint. Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../[...path]/__tests__/route.test.ts | 62 +++++++++++++++++++ .../[...path]/route.ts | 27 ++++++++ .../oauth-protected-resource/route.ts | 30 ++------- .../protected-resource-discovery.test.ts | 50 +++++++++++++++ extensions/general/mcp-server/index.ts | 16 +++++ lib/auth/protected-resource-metadata.ts | 46 ++++++++++++++ 7 files changed, 208 insertions(+), 24 deletions(-) create mode 100644 app/.well-known/oauth-protected-resource/[...path]/__tests__/route.test.ts create mode 100644 app/.well-known/oauth-protected-resource/[...path]/route.ts create mode 100644 extensions/general/mcp-server/__tests__/protected-resource-discovery.test.ts create mode 100644 lib/auth/protected-resource-metadata.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4a326179..cabb749f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1245,3 +1245,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] Payslip YTD ("Ackumulerat") stays a stored snapshot on salary_run_employees, refreshed at approve + book, rather than being recomputed at PDF-render time: an employee who re-downloads a lonebesked must see the figures it had when it was issued, and a render-time sum would silently restate delivered payslips after any backdated correction. The same change widens the counted prior-run statuses from booked-only to approved/paid/booked (corrected stays excluded: its correction run replaces the whole month), because the original snapshot-at-calculate-time rule froze a YTD that was missing every month not yet booked when next month's run was prepared. [2026-08-25] ROT/RUT BegartBelopp truncates to whole kronor (truncateToWholeKronor), not half-up: the deduction is capped at 50%/30% of arbetskostnaden. At the RUT cap, half-up manufactured begart > betalt and blocked correct invoices; for ROT below the cap it over-requested past the cap and the 1513 fordran, so those files now ask 1 kr less (skeptic-verified on PR #1910). [2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent. +[2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/, and /.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised. diff --git a/app/.well-known/oauth-protected-resource/[...path]/__tests__/route.test.ts b/app/.well-known/oauth-protected-resource/[...path]/__tests__/route.test.ts new file mode 100644 index 00000000..37e728e0 --- /dev/null +++ b/app/.well-known/oauth-protected-resource/[...path]/__tests__/route.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { GET } from '../route' + +// RFC 9728 path-based discovery: Claude.ai's connector setup fetches +// /.well-known/oauth-protected-resource/ before any 401 and treats +// a 404 as "Authorization failed" (seen in production 2026-08-26). + +function call(url: string, path: string[]) { + return GET(new Request(url, { headers: { host: new URL(url).host } }), { + params: Promise.resolve({ path }), + }) +} + +describe('path-based MCP protected-resource discovery', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('serves the MCP endpoint metadata at the RFC 9728 path-based location', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se') + const response = await call( + 'https://app.accounted.se/.well-known/oauth-protected-resource/api/extensions/ext/mcp-server/mcp', + ['api', 'extensions', 'ext', 'mcp-server', 'mcp'] + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.resource).toBe('https://app.accounted.se/api/extensions/ext/mcp-server/mcp') + expect(body.authorization_servers).toEqual(['https://app.accounted.se']) + expect(body.scopes_supported).toEqual(['mcp']) + }) + + it('reflects the accounted namespace exactly like the root document', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se') + const response = await call( + 'https://app.accounted.se/.well-known/oauth-protected-resource/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted', + ['api', 'extensions', 'ext', 'mcp-server', 'mcp'] + ) + const body = await response.json() + expect(body.resource).toBe( + 'https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted' + ) + }) + + it('never echoes an arbitrary namespace value', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se') + const response = await call( + 'https://app.accounted.se/.well-known/oauth-protected-resource/api/extensions/ext/mcp-server/mcp?tool_namespace=evil%22', + ['api', 'extensions', 'ext', 'mcp-server', 'mcp'] + ) + const body = await response.json() + expect(body.resource).toBe('https://app.accounted.se/api/extensions/ext/mcp-server/mcp') + }) + + it('answers 404 for any other path so no phantom resource is advertised', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se') + const response = await call( + 'https://app.accounted.se/.well-known/oauth-protected-resource/api/v1/companies', + ['api', 'v1', 'companies'] + ) + expect(response.status).toBe(404) + }) +}) diff --git a/app/.well-known/oauth-protected-resource/[...path]/route.ts b/app/.well-known/oauth-protected-resource/[...path]/route.ts new file mode 100644 index 00000000..b739812e --- /dev/null +++ b/app/.well-known/oauth-protected-resource/[...path]/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' +import { + MCP_RESOURCE_PATH, + buildProtectedResourceMetadata, +} from '@/lib/auth/protected-resource-metadata' + +/** + * RFC 9728 §3.1 path-based Protected Resource Metadata: + * `/.well-known/oauth-protected-resource{resource-path}`. + * + * Claude.ai's connector setup derives this URL from the MCP server URL and + * fetches it before any 401 challenge; without it the dialog reports + * "Authorization with Accounted failed". Only the MCP endpoint is a protected + * resource here, so every other path is a 404 rather than a generic answer + * that would advertise resources this server does not serve. + */ +export async function GET( + request: Request, + context: { params: Promise<{ path: string[] }> } +) { + const { path } = await context.params + const resourcePath = '/' + (path ?? []).join('/') + if (resourcePath !== MCP_RESOURCE_PATH) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + return NextResponse.json(buildProtectedResourceMetadata(request)) +} diff --git a/app/.well-known/oauth-protected-resource/route.ts b/app/.well-known/oauth-protected-resource/route.ts index a4ea7171..c3d260c8 100644 --- a/app/.well-known/oauth-protected-resource/route.ts +++ b/app/.well-known/oauth-protected-resource/route.ts @@ -1,30 +1,12 @@ import { NextResponse } from 'next/server' -import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url' +import { buildProtectedResourceMetadata } from '@/lib/auth/protected-resource-metadata' /** - * RFC 9728: Protected Resource Metadata. - * Tells MCP clients which authorization server to use. - * - * The resource/AS URLs reflect the (allowlisted) request host: MCP clients - * validate the advertised resource against the server URL they were - * configured with, and existing connectors point at the legacy - * app.gnubok.se domain after the app.accounted.se cutover. + * RFC 9728: Protected Resource Metadata, root location. This is the URL the + * MCP endpoint's 401 `WWW-Authenticate` header points at. The same document + * is also served at the path-based and endpoint-appended locations (see + * lib/auth/protected-resource-metadata.ts for why all three exist). */ export async function GET(request: Request) { - const appUrl = resolveDiscoveryBaseUrl(request) - const resource = new URL('/api/extensions/ext/mcp-server/mcp', appUrl) - // `accounted` is the COMPLETE allow-list of reflectable namespaces. We never - // echo the inbound parameter value: on an exact match we set the fixed - // literal, so a crafted tool_namespace (URL-special chars, other values) can - // never reach the advertised resource URL. Do not loosen this to a broader - // match without re-checking every downstream consumer that parses `resource`. - if (new URL(request.url).searchParams.get('tool_namespace') === 'accounted') { - resource.searchParams.set('tool_namespace', 'accounted') - } - - return NextResponse.json({ - resource: resource.toString(), - authorization_servers: [appUrl], - scopes_supported: ['mcp'], - }) + return NextResponse.json(buildProtectedResourceMetadata(request)) } diff --git a/extensions/general/mcp-server/__tests__/protected-resource-discovery.test.ts b/extensions/general/mcp-server/__tests__/protected-resource-discovery.test.ts new file mode 100644 index 00000000..0e85e8c4 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/protected-resource-discovery.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mcpServerExtension } from '../index' + +// Endpoint-appended RFC 9728 discovery: Claude.ai's connector setup tries +// /.well-known/oauth-protected-resource before any 401 and turns +// a 404 into "Authorization failed" (production, 2026-08-26). + +function findRoute(method: string, path: string) { + const route = mcpServerExtension.apiRoutes?.find((r) => r.method === method && r.path === path) + if (!route) throw new Error(`route ${method} ${path} not declared`) + return route +} + +describe('GET /mcp/.well-known/oauth-protected-resource (dispatcher route)', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('is declared unauthenticated and answers the same document as the root location', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se') + const route = findRoute('GET', '/mcp/.well-known/oauth-protected-resource') + expect(route.skipAuth).toBe(true) + const response = await route.handler( + new Request( + 'https://app.accounted.se/api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource?tool_namespace=accounted', + { headers: { host: 'app.accounted.se' } } + ), + undefined as never + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.resource).toBe( + 'https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted' + ) + expect(body.authorization_servers).toEqual(['https://app.accounted.se']) + }) + + it('still refuses a foreign browser origin (DNS-rebinding defense)', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se') + const route = findRoute('GET', '/mcp/.well-known/oauth-protected-resource') + const response = await route.handler( + new Request( + 'https://app.accounted.se/api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource', + { headers: { host: 'app.accounted.se', origin: 'https://evil.example' } } + ), + undefined as never + ) + expect(response.status).toBe(403) + }) +}) diff --git a/extensions/general/mcp-server/index.ts b/extensions/general/mcp-server/index.ts index fd4d2e5c..59432342 100644 --- a/extensions/general/mcp-server/index.ts +++ b/extensions/general/mcp-server/index.ts @@ -1,4 +1,6 @@ +import { NextResponse } from 'next/server' import type { Extension } from '@/lib/extensions/types' +import { buildProtectedResourceMetadata } from '@/lib/auth/protected-resource-metadata' import { handleMcpRequest, tools as mcpTools } from './server' import { isForbiddenOrigin, forbiddenOriginResponse } from './origin-guard' import { registerAgentTools } from '@/lib/agent/tools/registry' @@ -61,6 +63,20 @@ export const mcpServerExtension: Extension = { return new Response(null, { status: 204 }) }, }, + { + method: 'GET', + path: '/mcp/.well-known/oauth-protected-resource', + skipAuth: true, + // Endpoint-appended RFC 9728 discovery. Claude.ai's connector setup + // derives the metadata URL from the server URL and tries both the + // path-based root form and this one before any 401; a 404 here reads + // as "Authorization failed". Public by nature: it names the + // authorization server and nothing tenant-specific. + handler: async (request: Request) => { + if (isForbiddenOrigin(request)) return forbiddenOriginResponse() + return NextResponse.json(buildProtectedResourceMetadata(request)) + }, + }, ], eventHandlers: [], diff --git a/lib/auth/protected-resource-metadata.ts b/lib/auth/protected-resource-metadata.ts new file mode 100644 index 00000000..465d1711 --- /dev/null +++ b/lib/auth/protected-resource-metadata.ts @@ -0,0 +1,46 @@ +import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url' + +/** Path of the one protected resource this server advertises: the MCP endpoint. */ +export const MCP_RESOURCE_PATH = '/api/extensions/ext/mcp-server/mcp' + +/** + * RFC 9728 Protected Resource Metadata for the MCP endpoint. + * + * Served from three URLs, because clients derive the location differently: + * - `/.well-known/oauth-protected-resource` (root): what our 401 + * `WWW-Authenticate: resource_metadata=` header points at; Claude Code + * and the stdio bridges follow that hint. + * - `/.well-known/oauth-protected-resource/api/extensions/ext/mcp-server/mcp` + * (RFC 9728 §3.1 path-based form): Claude.ai's connector setup derives + * this from the server URL and fetches it BEFORE any 401, so a 404 here + * reads as "Authorization failed" in the connector dialog. + * - `/api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource` + * (endpoint-appended form, tried by the same client as a fallback). + * + * The resource/AS URLs reflect the (allowlisted) request host: MCP clients + * validate the advertised resource against the server URL they were + * configured with, and existing connectors point at the legacy + * app.gnubok.se domain after the app.accounted.se cutover. + */ +export function buildProtectedResourceMetadata(request: Request): { + resource: string + authorization_servers: string[] + scopes_supported: string[] +} { + const appUrl = resolveDiscoveryBaseUrl(request) + const resource = new URL(MCP_RESOURCE_PATH, appUrl) + // `accounted` is the COMPLETE allow-list of reflectable namespaces. We never + // echo the inbound parameter value: on an exact match we set the fixed + // literal, so a crafted tool_namespace (URL-special chars, other values) can + // never reach the advertised resource URL. Do not loosen this to a broader + // match without re-checking every downstream consumer that parses `resource`. + if (new URL(request.url).searchParams.get('tool_namespace') === 'accounted') { + resource.searchParams.set('tool_namespace', 'accounted') + } + + return { + resource: resource.toString(), + authorization_servers: [appUrl], + scopes_supported: ['mcp'], + } +}