fix(client): let OAuth-derived Authorization override caller-supplied header (#2475)
Signed-off-by: sanjibani <18418553+sanjibani@users.noreply.github.com> Co-authored-by: sanjibani <18418553+sanjibani@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <konstantin@mach5technology.com>
This commit is contained in:
parent
5ecc791d81
commit
b65426158e
2 changed files with 438 additions and 28 deletions
|
|
@ -2917,6 +2917,13 @@ export const REQUIREMENTS: Record<string, Requirement> = {
|
|||
transports: ['streamableHttp'],
|
||||
note: "This exercises the HTTP client transport's auth hook; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs."
|
||||
},
|
||||
'client-auth:authprovider:token-overrides-requestinit': {
|
||||
source: 'sdk',
|
||||
behavior:
|
||||
'When an AuthProvider yields a token, the Authorization header the transport derives from it takes precedence over a same-named header configured in requestInit.headers on every HTTP request, so a stale static credential falls back to the provider token; other configured headers still pass through.',
|
||||
transports: ['streamableHttp'],
|
||||
note: "This exercises the HTTP client transport's header merge order (#2208); the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs."
|
||||
},
|
||||
'client-auth:authprovider:onunauthorized-retry': {
|
||||
source: 'sdk',
|
||||
behavior:
|
||||
|
|
|
|||
|
|
@ -2066,6 +2066,73 @@ verifies('client-auth:authprovider:token-attached', async (_args: TestArgs) => {
|
|||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:authprovider:token-overrides-requestinit', async (_args: TestArgs) => {
|
||||
const TOKEN = 'provider-bearer-token';
|
||||
const STALE = 'stale-configured-token';
|
||||
|
||||
// Minimal AuthProvider: token() only. Mirrors a config that pins a static API key in
|
||||
// requestInit.headers but must defer to the provider once it has a token (#2208).
|
||||
const authProvider: AuthProvider = {
|
||||
token: async () => TOKEN
|
||||
};
|
||||
|
||||
const seenByServer: Array<{ authorization: string | null; custom: string | null }> = [];
|
||||
const mcpHost = hostPerSession(() => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('probe', { inputSchema: z.object({}) }, (_a, ctx) => {
|
||||
seenByServer.push({
|
||||
authorization: ctx.http?.req?.headers.get('authorization') ?? null,
|
||||
custom: ctx.http?.req?.headers.get('x-caller-header') ?? null
|
||||
});
|
||||
return { content: [{ type: 'text', text: 'ok' }] };
|
||||
});
|
||||
return s;
|
||||
});
|
||||
|
||||
const requests: Array<{ method: string; authorization: string | null; custom: string | null }> = [];
|
||||
const recordingFetch = async (url: URL | string, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
requests.push({
|
||||
method: init?.method ?? 'GET',
|
||||
authorization: headers.get('authorization'),
|
||||
custom: headers.get('x-caller-header')
|
||||
});
|
||||
return mcpHost.handleRequest(new Request(url, init));
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
|
||||
authProvider,
|
||||
fetch: recordingFetch,
|
||||
requestInit: { headers: { Authorization: `Bearer ${STALE}`, 'X-Caller-Header': 'preserved' } }
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const result = await client.callTool({ name: 'probe', arguments: {} });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'ok' }]);
|
||||
|
||||
// The standalone SSE GET is opened fire-and-forget after initialize; wait for it so it is checked too.
|
||||
await vi.waitFor(() => expect(requests.some(r => r.method === 'GET')).toBe(true));
|
||||
|
||||
// Exactly three POSTs (initialize, notifications/initialized, tools/call) plus the standalone SSE GET,
|
||||
// every one carrying the provider token rather than the configured placeholder, with the other
|
||||
// configured header passing through untouched.
|
||||
expect(requests.filter(r => r.method === 'POST')).toHaveLength(3);
|
||||
expect(requests.filter(r => r.method === 'GET')).toHaveLength(1);
|
||||
for (const req of requests) {
|
||||
expect(req.authorization).toBe(`Bearer ${TOKEN}`);
|
||||
expect(req.custom).toBe('preserved');
|
||||
}
|
||||
|
||||
// The provider token, not the placeholder, is what reached the server on tools/call.
|
||||
expect(seenByServer).toEqual([{ authorization: `Bearer ${TOKEN}`, custom: 'preserved' }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mcpHost.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:authprovider:onunauthorized-retry', async (_args: TestArgs) => {
|
||||
const STALE = 'stale-bearer-token';
|
||||
const FRESH = 'fresh-bearer-token';
|
||||
|
|
|
|||
Loading…
Reference in a new issue