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,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/<mcp path> 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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user