Skip to content

Commit 7ea6fde

Browse files
test(client): cover refresh retry after invalid token 401 (#2367)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent ee732d6 commit 7ea6fde

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

packages/client/test/client/auth.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,24 @@ describe('OAuth Authorization', () => {
134134

135135
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' });
136136
});
137+
138+
it('parses invalid_token challenges with protected resource metadata', async () => {
139+
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource/mcp';
140+
const mockResponse = {
141+
headers: {
142+
get: vi.fn(name =>
143+
name === 'WWW-Authenticate'
144+
? `Bearer resource_metadata="${resourceUrl}", error="invalid_token", error_description="The access token expired"`
145+
: null
146+
)
147+
}
148+
} as unknown as Response;
149+
150+
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({
151+
resourceMetadataUrl: new URL(resourceUrl),
152+
error: 'invalid_token'
153+
});
154+
});
137155
});
138156

139157
describe('discoverOAuthProtectedResourceMetadata', () => {

packages/client/test/client/streamableHttp.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core';
1+
import type { JSONRPCMessage, JSONRPCRequest, OAuthTokens } from '@modelcontextprotocol/core';
22
import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core';
33
import type { Mock, Mocked } from 'vitest';
44

@@ -784,6 +784,105 @@ describe('StreamableHTTPClientTransport', () => {
784784
expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1);
785785
});
786786

787+
it('silently refreshes and retries when a POST returns 401 invalid_token', async () => {
788+
const message: JSONRPCMessage = {
789+
jsonrpc: '2.0',
790+
method: 'tools/call',
791+
params: {
792+
name: 'power-bi-query',
793+
arguments: {}
794+
},
795+
id: 'tool-use-1'
796+
};
797+
const resourceMetadataUrl = 'http://localhost:1234/.well-known/oauth-protected-resource/mcp';
798+
let currentTokens: OAuthTokens = {
799+
access_token: 'expired-access-token',
800+
token_type: 'Bearer',
801+
refresh_token: 'refresh-token'
802+
};
803+
804+
mockAuthProvider.tokens.mockImplementation(() => currentTokens);
805+
mockAuthProvider.saveTokens.mockImplementation(tokens => {
806+
currentTokens = tokens;
807+
});
808+
809+
const fetchMock = globalThis.fetch as Mock;
810+
fetchMock.mockImplementation(async (url, init) => {
811+
const urlString = url.toString();
812+
813+
if (urlString === 'http://localhost:1234/mcp' && init?.method === 'POST') {
814+
const headers = new Headers(init.headers);
815+
const authorization = headers.get('authorization');
816+
817+
if (authorization === 'Bearer expired-access-token') {
818+
return new Response('expired', {
819+
status: 401,
820+
statusText: 'Unauthorized',
821+
headers: {
822+
'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token", error_description="The access token expired"`
823+
}
824+
});
825+
}
826+
827+
if (authorization === 'Bearer new-access-token') {
828+
return new Response(null, { status: 202 });
829+
}
830+
831+
return new Response('unexpected bearer', { status: 401, statusText: 'Unauthorized' });
832+
}
833+
834+
if (urlString === resourceMetadataUrl) {
835+
return Response.json({
836+
resource: 'http://localhost:1234/mcp',
837+
authorization_servers: ['http://localhost:1234']
838+
});
839+
}
840+
841+
if (urlString === 'http://localhost:1234/.well-known/oauth-authorization-server') {
842+
return Response.json({
843+
issuer: 'http://localhost:1234',
844+
authorization_endpoint: 'http://localhost:1234/authorize',
845+
token_endpoint: 'http://localhost:1234/token',
846+
response_types_supported: ['code'],
847+
code_challenge_methods_supported: ['S256']
848+
});
849+
}
850+
851+
if (urlString === 'http://localhost:1234/token' && init?.method === 'POST') {
852+
const params = new URLSearchParams(init.body as string);
853+
expect(params.get('grant_type')).toBe('refresh_token');
854+
expect(params.get('refresh_token')).toBe('refresh-token');
855+
856+
return Response.json({
857+
access_token: 'new-access-token',
858+
token_type: 'Bearer',
859+
refresh_token: 'new-refresh-token'
860+
});
861+
}
862+
863+
return new Response('not found', { status: 404 });
864+
});
865+
866+
await transport.send(message);
867+
868+
const mcpPostCalls = fetchMock.mock.calls.filter(
869+
([url, init]) => url.toString() === 'http://localhost:1234/mcp' && init?.method === 'POST'
870+
);
871+
expect(mcpPostCalls).toHaveLength(2);
872+
const firstPost = mcpPostCalls[0]!;
873+
const secondPost = mcpPostCalls[1]!;
874+
expect(new Headers(firstPost[1]?.headers).get('authorization')).toBe('Bearer expired-access-token');
875+
expect(new Headers(secondPost[1]?.headers).get('authorization')).toBe('Bearer new-access-token');
876+
expect(firstPost[1]?.body).toBe(JSON.stringify(message));
877+
expect(secondPost[1]?.body).toBe(JSON.stringify(message));
878+
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
879+
access_token: 'new-access-token',
880+
token_type: 'Bearer',
881+
refresh_token: 'new-refresh-token'
882+
});
883+
expect(mockAuthProvider.redirectToAuthorization).not.toHaveBeenCalled();
884+
});
885+
787886
it('attempts upscoping on 403 with WWW-Authenticate header', async () => {
788887
const message: JSONRPCMessage = {
789888
jsonrpc: '2.0',

0 commit comments

Comments
 (0)