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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
a1af9adb05
commit
1307d4db2e
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user