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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 09:24:11 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent a1af9adb05
commit 1307d4db2e
7 changed files with 208 additions and 24 deletions
@@ -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
// <server url>/.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)
})
})
+16
View File
@@ -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: [],