Files
accounted/lib/auth/protected-resource-metadata.ts
T
Jakob Wennberg 1307d4db2e 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>
2026-08-26 09:24:11 +02:00

47 lines
2.2 KiB
TypeScript

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'],
}
}