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
@@ -1245,3 +1245,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
|||||||
[2026-08-25] Payslip YTD ("Ackumulerat") stays a stored snapshot on salary_run_employees, refreshed at approve + book, rather than being recomputed at PDF-render time: an employee who re-downloads a lonebesked must see the figures it had when it was issued, and a render-time sum would silently restate delivered payslips after any backdated correction. The same change widens the counted prior-run statuses from booked-only to approved/paid/booked (corrected stays excluded: its correction run replaces the whole month), because the original snapshot-at-calculate-time rule froze a YTD that was missing every month not yet booked when next month's run was prepared.
|
[2026-08-25] Payslip YTD ("Ackumulerat") stays a stored snapshot on salary_run_employees, refreshed at approve + book, rather than being recomputed at PDF-render time: an employee who re-downloads a lonebesked must see the figures it had when it was issued, and a render-time sum would silently restate delivered payslips after any backdated correction. The same change widens the counted prior-run statuses from booked-only to approved/paid/booked (corrected stays excluded: its correction run replaces the whole month), because the original snapshot-at-calculate-time rule froze a YTD that was missing every month not yet booked when next month's run was prepared.
|
||||||
[2026-08-25] ROT/RUT BegartBelopp truncates to whole kronor (truncateToWholeKronor), not half-up: the deduction is capped at 50%/30% of arbetskostnaden. At the RUT cap, half-up manufactured begart > betalt and blocked correct invoices; for ROT below the cap it over-requested past the cap and the 1513 fordran, so those files now ask 1 kr less (skeptic-verified on PR #1910).
|
[2026-08-25] ROT/RUT BegartBelopp truncates to whole kronor (truncateToWholeKronor), not half-up: the deduction is capped at 50%/30% of arbetskostnaden. At the RUT cap, half-up manufactured begart > betalt and blocked correct invoices; for ROT below the cap it over-requested past the cap and the 1513 fordran, so those files now ask 1 kr less (skeptic-verified on PR #1910).
|
||||||
[2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent.
|
[2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent.
|
||||||
|
[2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/<mcp path>, and <mcp url>/.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised.
|
||||||
|
|||||||
@@ -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 { 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.
|
* RFC 9728: Protected Resource Metadata, root location. This is the URL the
|
||||||
* Tells MCP clients which authorization server to use.
|
* MCP endpoint's 401 `WWW-Authenticate` header points at. The same document
|
||||||
*
|
* is also served at the path-based and endpoint-appended locations (see
|
||||||
* The resource/AS URLs reflect the (allowlisted) request host: MCP clients
|
* lib/auth/protected-resource-metadata.ts for why all three exist).
|
||||||
* 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 async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const appUrl = resolveDiscoveryBaseUrl(request)
|
return NextResponse.json(buildProtectedResourceMetadata(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'],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { Extension } from '@/lib/extensions/types'
|
||||||
|
import { buildProtectedResourceMetadata } from '@/lib/auth/protected-resource-metadata'
|
||||||
import { handleMcpRequest, tools as mcpTools } from './server'
|
import { handleMcpRequest, tools as mcpTools } from './server'
|
||||||
import { isForbiddenOrigin, forbiddenOriginResponse } from './origin-guard'
|
import { isForbiddenOrigin, forbiddenOriginResponse } from './origin-guard'
|
||||||
import { registerAgentTools } from '@/lib/agent/tools/registry'
|
import { registerAgentTools } from '@/lib/agent/tools/registry'
|
||||||
@@ -61,6 +63,20 @@ export const mcpServerExtension: Extension = {
|
|||||||
return new Response(null, { status: 204 })
|
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: [],
|
eventHandlers: [],
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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'],
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user