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,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))
}
@@ -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))
}