Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/web/src/lib/connectors/__tests__/oauth-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,31 @@ describe('oauth-metadata', () => {
tokenEndpoint: 'https://example.com/token',
registrationEndpoint: 'https://example.com/register',
})
// RFC 8414: server URLs with a path component are discovered through the
// path-aware well-known document first.
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith('https://example.com/.well-known/oauth-authorization-server/mcp', {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
})

vi.unstubAllGlobals()
})

it('queries the root well-known document directly when the server URL has no path', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
authorization_endpoint: 'https://example.com/auth',
token_endpoint: 'https://example.com/token',
}),
})
vi.stubGlobal('fetch', mockFetch)

const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata')
await discoverOAuthMetadata('https://example.com/')
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith('https://example.com/.well-known/oauth-authorization-server', {
method: 'GET',
headers: { Accept: 'application/json' },
Expand All @@ -102,6 +127,57 @@ describe('oauth-metadata', () => {
vi.unstubAllGlobals()
})

it('falls back to the root well-known document when the path-aware document is missing', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce({ ok: false, status: 404 })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
issuer: 'https://example.com',
authorization_endpoint: 'https://example.com/auth',
token_endpoint: 'https://example.com/token',
}),
})
vi.stubGlobal('fetch', mockFetch)

const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata')
const result = await discoverOAuthMetadata('https://example.com/mcp')
expect(result.authorizationEndpoint).toBe('https://example.com/auth')
expect(mockFetch).toHaveBeenNthCalledWith(1, 'https://example.com/.well-known/oauth-authorization-server/mcp', expect.any(Object))
expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://example.com/.well-known/oauth-authorization-server', expect.any(Object))

vi.unstubAllGlobals()
})

it('uses path-aware metadata for servers hosted under a path (Meta Ads MCP shape)', async () => {
const mockFetch = vi.fn(async (url: string) => {
if (url === 'https://mcp.facebook.com/.well-known/oauth-authorization-server/ads') {
return {
ok: true,
json: async () => ({
issuer: 'https://www.facebook.com',
authorization_endpoint: 'https://www.facebook.com/v26.0/dialog/oauth',
token_endpoint: 'https://graph.facebook.com/v26.0/oauth/access_token',
registration_endpoint: 'https://mcp.facebook.com/.well-known/register/ads',
}),
}
}
return { ok: false, status: 404 }
})
vi.stubGlobal('fetch', mockFetch)

const { discoverOAuthMetadata } = await import('@/lib/connectors/oauth-metadata')
const result = await discoverOAuthMetadata('https://mcp.facebook.com/ads')
expect(result).toEqual({
issuer: 'https://www.facebook.com',
authorizationEndpoint: 'https://www.facebook.com/v26.0/dialog/oauth',
tokenEndpoint: 'https://graph.facebook.com/v26.0/oauth/access_token',
registrationEndpoint: 'https://mcp.facebook.com/.well-known/register/ads',
})

vi.unstubAllGlobals()
})

it('throws when metadata response is missing required fields', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
Expand Down
68 changes: 45 additions & 23 deletions apps/web/src/lib/connectors/oauth-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,37 +30,59 @@ export async function sanitizeOAuthMetadata(metadata: OAuthServerMetadata): Prom
}
}

function buildWellKnownMetadataUrls(serverUrl: URL): string[] {
const authorizationBase = `${serverUrl.protocol}//${serverUrl.host}`
const rootMetadataUrl = `${authorizationBase}/.well-known/oauth-authorization-server`
const serverPath = serverUrl.pathname.replace(/\/+$/, '')

// RFC 8414 §3.1: when the issuer has a path component, the well-known suffix
// is inserted between the host and the path (e.g. https://mcp.facebook.com/ads
// publishes metadata at /.well-known/oauth-authorization-server/ads).
// The root document is kept as a fallback for servers that only publish there.
if (serverPath) {
return [`${rootMetadataUrl}${serverPath}`, rootMetadataUrl]
}

return [rootMetadataUrl]
}

export async function discoverOAuthMetadata(mcpServerUrl: string): Promise<OAuthServerMetadata> {
const serverUrl = new URL(mcpServerUrl)
const authorizationBase = `${serverUrl.protocol}//${serverUrl.host}`
const metadataUrl = `${authorizationBase}/.well-known/oauth-authorization-server`
const metadataUrls = buildWellKnownMetadataUrls(serverUrl)

const metadataResponse = await fetch(metadataUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
},
cache: 'no-store',
}).catch(() => null)
for (const [index, metadataUrl] of metadataUrls.entries()) {
const isLastCandidate = index === metadataUrls.length - 1

if (metadataResponse && metadataResponse.ok) {
const data = (await metadataResponse.json().catch(() => null)) as Record<string, unknown> | null
const authorizationEndpoint = getString(data?.authorization_endpoint)
const tokenEndpoint = getString(data?.token_endpoint)
if (!authorizationEndpoint || !tokenEndpoint) {
throw new Error('oauth_discovery_failed:invalid_metadata')
}
const metadataResponse = await fetch(metadataUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
},
cache: 'no-store',
}).catch(() => null)

if (metadataResponse && metadataResponse.ok) {
const data = (await metadataResponse.json().catch(() => null)) as Record<string, unknown> | null
const authorizationEndpoint = getString(data?.authorization_endpoint)
const tokenEndpoint = getString(data?.token_endpoint)
if (!authorizationEndpoint || !tokenEndpoint) {
throw new Error('oauth_discovery_failed:invalid_metadata')
}

return {
issuer: getString(data?.issuer),
authorizationEndpoint,
tokenEndpoint,
registrationEndpoint: getString(data?.registration_endpoint),
return {
issuer: getString(data?.issuer),
authorizationEndpoint,
tokenEndpoint,
registrationEndpoint: getString(data?.registration_endpoint),
}
}
}

if (metadataResponse && metadataResponse.status !== 404) {
throw new Error(`oauth_discovery_failed:${metadataResponse.status}`)
// Non-404 errors on intermediate candidates fall through to the next
// candidate; only the final (root) document keeps strict error semantics.
if (isLastCandidate && metadataResponse && metadataResponse.status !== 404) {
throw new Error(`oauth_discovery_failed:${metadataResponse.status}`)
}
}

return {
Expand Down