Skip to content

Commit 4b174af

Browse files
committed
fix(client): avoid expired OAuth access tokens
1 parent db83829 commit 4b174af

5 files changed

Lines changed: 145 additions & 13 deletions

File tree

.changeset/fresh-tokens-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Avoid returning expired OAuth access tokens from adapted auth providers.

packages/client/src/client/auth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx
113113
}
114114
}
115115

116+
const OAUTH_TOKEN_EXPIRY_BUFFER_SECONDS = 60;
117+
118+
function hasUsableOAuthAccessToken(tokens: OAuthTokens | undefined): tokens is OAuthTokens {
119+
return tokens !== undefined && (tokens.expires_in === undefined || tokens.expires_in > OAUTH_TOKEN_EXPIRY_BUFFER_SECONDS);
120+
}
121+
116122
/**
117123
* Adapts an `OAuthClientProvider` to the minimal `AuthProvider` interface that
118124
* transports consume. Called once at transport construction — the transport stores
@@ -123,7 +129,7 @@ export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider
123129
return {
124130
token: async () => {
125131
const tokens = await provider.tokens();
126-
return tokens?.access_token;
132+
return hasUsableOAuthAccessToken(tokens) ? tokens.access_token : undefined;
127133
},
128134
onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx)
129135
};

packages/client/src/client/authExtensions.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,36 @@ import type { CryptoKey, JWK } from 'jose';
1010

1111
import type { AddClientAuthentication, OAuthClientProvider } from './auth.js';
1212

13+
type SavedOAuthTokens = {
14+
tokens: OAuthTokens;
15+
expiresAt?: number;
16+
};
17+
18+
function saveOAuthTokens(tokens: OAuthTokens): SavedOAuthTokens {
19+
const savedTokens: SavedOAuthTokens = { tokens };
20+
21+
if (tokens.expires_in !== undefined) {
22+
savedTokens.expiresAt = Date.now() + tokens.expires_in * 1000;
23+
}
24+
25+
return savedTokens;
26+
}
27+
28+
function readOAuthTokens(savedTokens: SavedOAuthTokens | undefined): OAuthTokens | undefined {
29+
if (savedTokens === undefined) {
30+
return undefined;
31+
}
32+
33+
if (savedTokens.expiresAt === undefined) {
34+
return savedTokens.tokens;
35+
}
36+
37+
return {
38+
...savedTokens.tokens,
39+
expires_in: Math.max(0, Math.ceil((savedTokens.expiresAt - Date.now()) / 1000))
40+
};
41+
}
42+
1343
/**
1444
* Helper to produce a `private_key_jwt` client authentication function.
1545
*
@@ -138,7 +168,7 @@ export interface ClientCredentialsProviderOptions {
138168
* ```
139169
*/
140170
export class ClientCredentialsProvider implements OAuthClientProvider {
141-
private _tokens?: OAuthTokens;
171+
private _tokens?: SavedOAuthTokens;
142172
private _clientInfo: OAuthClientInformation;
143173
private _clientMetadata: OAuthClientMetadata;
144174

@@ -173,11 +203,11 @@ export class ClientCredentialsProvider implements OAuthClientProvider {
173203
}
174204

175205
tokens(): OAuthTokens | undefined {
176-
return this._tokens;
206+
return readOAuthTokens(this._tokens);
177207
}
178208

179209
saveTokens(tokens: OAuthTokens): void {
180-
this._tokens = tokens;
210+
this._tokens = saveOAuthTokens(tokens);
181211
}
182212

183213
redirectToAuthorization(): void {
@@ -266,7 +296,7 @@ export interface PrivateKeyJwtProviderOptions {
266296
* ```
267297
*/
268298
export class PrivateKeyJwtProvider implements OAuthClientProvider {
269-
private _tokens?: OAuthTokens;
299+
private _tokens?: SavedOAuthTokens;
270300
private _clientInfo: OAuthClientInformation;
271301
private _clientMetadata: OAuthClientMetadata;
272302
addClientAuthentication: AddClientAuthentication;
@@ -309,11 +339,11 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider {
309339
}
310340

311341
tokens(): OAuthTokens | undefined {
312-
return this._tokens;
342+
return readOAuthTokens(this._tokens);
313343
}
314344

315345
saveTokens(tokens: OAuthTokens): void {
316-
this._tokens = tokens;
346+
this._tokens = saveOAuthTokens(tokens);
317347
}
318348

319349
redirectToAuthorization(): void {
@@ -371,7 +401,7 @@ export interface StaticPrivateKeyJwtProviderOptions {
371401
* uses it directly for authentication.
372402
*/
373403
export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
374-
private _tokens?: OAuthTokens;
404+
private _tokens?: SavedOAuthTokens;
375405
private _clientInfo: OAuthClientInformation;
376406
private _clientMetadata: OAuthClientMetadata;
377407
addClientAuthentication: AddClientAuthentication;
@@ -412,11 +442,11 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
412442
}
413443

414444
tokens(): OAuthTokens | undefined {
415-
return this._tokens;
445+
return readOAuthTokens(this._tokens);
416446
}
417447

418448
saveTokens(tokens: OAuthTokens): void {
419-
this._tokens = tokens;
449+
this._tokens = saveOAuthTokens(tokens);
420450
}
421451

422452
redirectToAuthorization(): void {
@@ -569,7 +599,7 @@ export interface CrossAppAccessProviderOptions {
569599
* ```
570600
*/
571601
export class CrossAppAccessProvider implements OAuthClientProvider {
572-
private _tokens?: OAuthTokens;
602+
private _tokens?: SavedOAuthTokens;
573603
private _clientInfo: OAuthClientInformation;
574604
private _clientMetadata: OAuthClientMetadata;
575605
private _assertionCallback: AssertionCallback;
@@ -610,11 +640,11 @@ export class CrossAppAccessProvider implements OAuthClientProvider {
610640
}
611641

612642
tokens(): OAuthTokens | undefined {
613-
return this._tokens;
643+
return readOAuthTokens(this._tokens);
614644
}
615645

616646
saveTokens(tokens: OAuthTokens): void {
617-
this._tokens = tokens;
647+
this._tokens = saveOAuthTokens(tokens);
618648
}
619649

620650
redirectToAuthorization(): void {

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { expect, vi } from 'vitest';
55

66
import type { OAuthClientProvider } from '../../src/client/auth.js';
77
import {
8+
adaptOAuthProvider,
89
auth,
910
buildDiscoveryUrls,
1011
determineScope,
@@ -64,6 +65,49 @@ describe('OAuth Authorization', () => {
6465
mockedCorsIsPossible = false;
6566
});
6667

68+
describe('adaptOAuthProvider', () => {
69+
function providerWithTokens(tokens: OAuthTokens | undefined): OAuthClientProvider {
70+
return {
71+
get redirectUrl() {
72+
return undefined;
73+
},
74+
get clientMetadata(): OAuthClientMetadata {
75+
return { redirect_uris: [] };
76+
},
77+
clientInformation: () => ({ client_id: 'client-id' }),
78+
tokens: () => tokens,
79+
saveTokens: vi.fn(),
80+
redirectToAuthorization: vi.fn(),
81+
saveCodeVerifier: vi.fn(),
82+
codeVerifier: vi.fn()
83+
};
84+
}
85+
86+
it('returns the access token when no expiration is supplied', async () => {
87+
const provider = adaptOAuthProvider(providerWithTokens({ access_token: 'access-token', token_type: 'Bearer' }));
88+
89+
await expect(provider.token()).resolves.toBe('access-token');
90+
});
91+
92+
it('returns the access token when it is outside the expiration buffer', async () => {
93+
const provider = adaptOAuthProvider(providerWithTokens({ access_token: 'access-token', token_type: 'Bearer', expires_in: 61 }));
94+
95+
await expect(provider.token()).resolves.toBe('access-token');
96+
});
97+
98+
it('does not return an access token that is expired or inside the expiration buffer', async () => {
99+
const nearExpiryProvider = adaptOAuthProvider(
100+
providerWithTokens({ access_token: 'near-expiry-token', token_type: 'Bearer', expires_in: 60 })
101+
);
102+
const expiredProvider = adaptOAuthProvider(
103+
providerWithTokens({ access_token: 'expired-token', token_type: 'Bearer', expires_in: 0 })
104+
);
105+
106+
await expect(nearExpiryProvider.token()).resolves.toBeUndefined();
107+
await expect(expiredProvider.token()).resolves.toBeUndefined();
108+
});
109+
});
110+
67111
describe('extractWWWAuthenticateParams', () => {
68112
it('returns resource metadata url when present', async () => {
69113
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource';

packages/client/test/client/authExtensions.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,53 @@ describe('auth-extensions providers (end-to-end with auth())', () => {
247247
});
248248
});
249249

250+
describe('auth-extensions token expiration tracking', () => {
251+
afterEach(() => {
252+
vi.useRealTimers();
253+
});
254+
255+
it('returns expires_in relative to when tokens were saved', () => {
256+
vi.useFakeTimers();
257+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
258+
259+
const provider = new ClientCredentialsProvider({
260+
clientId: 'my-client',
261+
clientSecret: 'my-secret'
262+
});
263+
264+
provider.saveTokens({
265+
access_token: 'access-token',
266+
token_type: 'Bearer',
267+
expires_in: 120
268+
});
269+
270+
expect(provider.tokens()?.expires_in).toBe(120);
271+
272+
vi.advanceTimersByTime(61_000);
273+
expect(provider.tokens()?.expires_in).toBe(59);
274+
275+
vi.advanceTimersByTime(60_000);
276+
expect(provider.tokens()?.expires_in).toBe(0);
277+
});
278+
279+
it('preserves tokens without expiration metadata', () => {
280+
const provider = new ClientCredentialsProvider({
281+
clientId: 'my-client',
282+
clientSecret: 'my-secret'
283+
});
284+
285+
provider.saveTokens({
286+
access_token: 'access-token',
287+
token_type: 'Bearer'
288+
});
289+
290+
expect(provider.tokens()).toEqual({
291+
access_token: 'access-token',
292+
token_type: 'Bearer'
293+
});
294+
});
295+
});
296+
250297
describe('createPrivateKeyJwtAuth', () => {
251298
const baseOptions = {
252299
issuer: 'client-id',

0 commit comments

Comments
 (0)