fix(mcp): eager-auth flag so claude.ai's connector dialog detects OAuth, not "None" (#2159)
claude.ai's two-step "Add custom connector" dialog probes the server URL without credentials and pre-fills the Authentication choice from the answer. Our lazy-auth endpoint (issue #1814) answers 200 on an anonymous initialize, which the dialog reads as an authless server: it suggests "None", and a connector added with that default never opens the sign-in when the challenge arrives later. Per Anthropic's connector docs a 401 is the only answer it reads as OAuth ("Claude does not honor a WWW-Authenticate header on a 200 response"). - `auth=required` on the endpoint URL (extensions/general/mcp-server/ auth-mode.ts) turns lazy auth off for that URL: every tokenless request, initialize included, answers the 401 + WWW-Authenticate challenge. Callers with a token are unaffected; the bare URL keeps lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing connector records are untouched. - The links we control carry the flag: Settings -> API & MCP (install link and copy block), the onboarding checklist, both docs pages and claude-plugin/CONNECTORS.md (plugin 1.2.3). The docs' Path A now describes the eager flow (sign-in opens on Add) instead of telling users to override the dialog's "None". - Tests: eager-auth.test.ts (401 on initialize/tools/list/public tools, namespaced metadata pointer, token no-op, exact-flag only); checklist link shape updated. Companion: gnubok-website PR (Kom igång connector link + regenerated connect-claude / anslut-claude pages). Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
6a85efb00a
commit
678acfe7ef
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
// Eager authentication (auth-mode.ts): `auth=required` on the endpoint URL
|
||||
// turns lazy auth off for that URL, so a tokenless caller is challenged on
|
||||
// every request, initialize included. claude.ai's Add-custom-connector dialog
|
||||
// probes the URL without credentials and only reads a 401 as OAuth; a 200
|
||||
// makes it pre-fill "None", which blocks the sign-in later.
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
validateApiKey: vi.fn(),
|
||||
checkRateLimit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: (...args: unknown[]) => mocks.validateApiKey(...args),
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: vi.fn(() => {
|
||||
throw new Error('anonymous requests must not touch tenant tables')
|
||||
}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/rate-limit-http', () => ({
|
||||
checkRateLimit: (...args: unknown[]) => mocks.checkRateLimit(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../skills', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../skills')>()
|
||||
return {
|
||||
...actual,
|
||||
loadAllSkills: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
import { isEagerAuthRequested } from '../auth-mode'
|
||||
|
||||
const ENDPOINT = 'http://localhost:3000/api/extensions/ext/mcp-server/mcp'
|
||||
const CHALLENGE_RE =
|
||||
/^Bearer resource_metadata="http:\/\/localhost:3000\/\.well-known\/oauth-protected-resource(\?tool_namespace=accounted)?"$/
|
||||
|
||||
function rpc(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
opts: { token?: string; query?: string } = {}
|
||||
): Request {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (opts.token) headers.Authorization = `Bearer ${opts.token}`
|
||||
const url = opts.query ? `${ENDPOINT}?${opts.query}` : ENDPOINT
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 7, method, ...(params ? { params } : {}) }),
|
||||
})
|
||||
}
|
||||
|
||||
describe('MCP eager authentication (auth=required)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mocks.checkRateLimit.mockResolvedValue({ ok: true })
|
||||
mocks.validateApiKey.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['companies:read'],
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Test key',
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
it('challenges a tokenless initialize with a transport-level 401 + WWW-Authenticate', async () => {
|
||||
const response = await handleMcpRequest(
|
||||
rpc('initialize', { protocolVersion: '2025-06-18' }, { query: 'auth=required' })
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get('WWW-Authenticate')).toMatch(CHALLENGE_RE)
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled()
|
||||
expect(mocks.checkRateLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the namespaced metadata pointer on the challenge', async () => {
|
||||
const response = await handleMcpRequest(
|
||||
rpc('initialize', { protocolVersion: '2025-06-18' }, { query: 'tool_namespace=accounted&client=claude-connector&auth=required' })
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get('WWW-Authenticate')).toContain(
|
||||
'/.well-known/oauth-protected-resource?tool_namespace=accounted"'
|
||||
)
|
||||
})
|
||||
|
||||
it('challenges the catalog and the public documentation tools as well', async () => {
|
||||
const list = await handleMcpRequest(rpc('tools/list', undefined, { query: 'auth=required' }))
|
||||
expect(list.status).toBe(401)
|
||||
const call = await handleMcpRequest(
|
||||
rpc('tools/call', { name: 'gnubok_list_skills', arguments: {} }, { query: 'auth=required' })
|
||||
)
|
||||
expect(call.status).toBe(401)
|
||||
expect(call.headers.get('WWW-Authenticate')).toMatch(CHALLENGE_RE)
|
||||
})
|
||||
|
||||
it('is a no-op for a caller that holds a token', async () => {
|
||||
const response = await handleMcpRequest(
|
||||
rpc('tools/call', { name: 'gnubok_list_skills', arguments: {} }, { token: 'gnubok_sk_x', query: 'auth=required' })
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(mocks.validateApiKey).toHaveBeenCalledWith('gnubok_sk_x')
|
||||
})
|
||||
|
||||
it('only the exact flag opts out of lazy authentication', async () => {
|
||||
for (const query of ['auth=optional', 'auth=Required', 'authx=required']) {
|
||||
const response = await handleMcpRequest(
|
||||
rpc('initialize', { protocolVersion: '2025-06-18' }, { query })
|
||||
)
|
||||
expect(response.status, query).toBe(200)
|
||||
}
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isEagerAuthRequested reads the flag off the request URL', () => {
|
||||
expect(isEagerAuthRequested(new Request(`${ENDPOINT}?auth=required`))).toBe(true)
|
||||
expect(isEagerAuthRequested(new Request(`${ENDPOINT}?tool_namespace=accounted&auth=required`))).toBe(true)
|
||||
expect(isEagerAuthRequested(new Request(ENDPOINT))).toBe(false)
|
||||
expect(isEagerAuthRequested(new Request(`${ENDPOINT}?auth=`))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Per-URL authentication mode for the MCP endpoint.
|
||||
*
|
||||
* The default is lazy (issue #1814): a client with no token may initialize,
|
||||
* list the catalog and call the public documentation tools (public-tools.ts);
|
||||
* the first protected call answers 401 + WWW-Authenticate, which the client
|
||||
* turns into its Connect prompt.
|
||||
*
|
||||
* `auth=required` on the endpoint URL makes that URL eager instead: EVERY
|
||||
* tokenless request answers the 401 challenge, `initialize` included. It has
|
||||
* one consumer. claude.ai's two-step "Add custom connector" dialog probes the
|
||||
* URL without credentials and pre-fills the Authentication choice from the
|
||||
* answer (Anthropic: "Claude checks the URL and pre-fills the authentication
|
||||
* settings it detects"). A 200 on that probe is read as "None", an authless
|
||||
* server, and a connector added with that default never opens the sign-in
|
||||
* when the challenge arrives later. A 401 is the only answer the dialog reads
|
||||
* as OAuth (Anthropic: "Claude does not honor a WWW-Authenticate header on a
|
||||
* 200 response"), so the links we control (Settings -> API & MCP, the
|
||||
* onboarding checklist, both docs pages, the website) carry the flag.
|
||||
*
|
||||
* The bare URL keeps lazy authentication for Claude Code, the plugin, Cursor,
|
||||
* ChatGPT developer mode and hand-typed adds, and connector records created
|
||||
* before the flag existed are unaffected. Either way the flag only changes the
|
||||
* answer to tokenless requests: a caller that holds a token never notices it.
|
||||
*/
|
||||
export const AUTH_MODE_QUERY_PARAM = 'auth'
|
||||
export const AUTH_MODE_REQUIRED = 'required'
|
||||
|
||||
export function isEagerAuthRequested(request: Request): boolean {
|
||||
return new URL(request.url).searchParams.get(AUTH_MODE_QUERY_PARAM) === AUTH_MODE_REQUIRED
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
anonymousRateLimitIdentifier,
|
||||
isPublicTool,
|
||||
} from './public-tools'
|
||||
import { isEagerAuthRequested } from './auth-mode'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre, sumOre } from '@/lib/money'
|
||||
import { currentAppVersion } from '@/lib/reports/app-version'
|
||||
@@ -20228,6 +20229,11 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
// be parsed keeps the pre-lazy-auth answer for a tokenless caller (401), so
|
||||
// probing the endpoint without credentials learns nothing new.
|
||||
const token = extractBearerToken(request)
|
||||
// Eager authentication (auth-mode.ts): the URL opted out of lazy auth, so a
|
||||
// tokenless caller is challenged before the body is even looked at. This is
|
||||
// what makes claude.ai's Add-custom-connector probe detect OAuth instead of
|
||||
// "None"; a caller with a token is unaffected.
|
||||
if (!token && isEagerAuthRequested(request)) return unauthorized()
|
||||
let body: JsonRpcRequest
|
||||
try {
|
||||
body = await request.json()
|
||||
|
||||
Reference in New Issue
Block a user