Skip to content

Commit db6bea6

Browse files
committed
coerce 'expires_in' to be a number
1 parent 324d471 commit db6bea6

File tree

2 files changed

+40
-3
lines changed

2 files changed

+40
-3
lines changed

src/client/auth.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
selectClientAuthMethod
1515
} from './auth.js';
1616
import { ServerError } from '../server/auth/errors.js';
17-
import { AuthorizationServerMetadata } from '../shared/auth.js';
17+
import { AuthorizationServerMetadata, OAuthTokens } from '../shared/auth.js';
1818

1919
// Mock fetch globally
2020
const mockFetch = jest.fn();
@@ -1073,7 +1073,7 @@ describe('OAuth Authorization', () => {
10731073
});
10741074

10751075
describe('exchangeAuthorization', () => {
1076-
const validTokens = {
1076+
const validTokens: OAuthTokens = {
10771077
access_token: 'access123',
10781078
token_type: 'Bearer',
10791079
expires_in: 3600,
@@ -1132,6 +1132,43 @@ describe('OAuth Authorization', () => {
11321132
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
11331133
});
11341134

1135+
it('allows for string "expires_in" values', async () => {
1136+
mockFetch.mockResolvedValueOnce({
1137+
ok: true,
1138+
status: 200,
1139+
json: async () => ({ ...validTokens, expires_in: '3600' })
1140+
});
1141+
1142+
const tokens = await exchangeAuthorization('https://auth.example.com', {
1143+
clientInformation: validClientInfo,
1144+
authorizationCode: 'code123',
1145+
codeVerifier: 'verifier123',
1146+
redirectUri: 'http://localhost:3000/callback',
1147+
resource: new URL('https://api.example.com/mcp-server')
1148+
});
1149+
1150+
expect(tokens).toEqual(validTokens);
1151+
expect(mockFetch).toHaveBeenCalledWith(
1152+
expect.objectContaining({
1153+
href: 'https://auth.example.com/token'
1154+
}),
1155+
expect.objectContaining({
1156+
method: 'POST',
1157+
headers: new Headers({
1158+
'Content-Type': 'application/x-www-form-urlencoded'
1159+
})
1160+
})
1161+
);
1162+
1163+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
1164+
expect(body.get('grant_type')).toBe('authorization_code');
1165+
expect(body.get('code')).toBe('code123');
1166+
expect(body.get('code_verifier')).toBe('verifier123');
1167+
expect(body.get('client_id')).toBe('client123');
1168+
expect(body.get('client_secret')).toBe('secret123');
1169+
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
1170+
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
1171+
});
11351172
it('exchanges code for tokens with auth', async () => {
11361173
mockFetch.mockResolvedValueOnce({
11371174
ok: true,

src/shared/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export const OAuthTokensSchema = z
136136
access_token: z.string(),
137137
id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect
138138
token_type: z.string(),
139-
expires_in: z.number().optional(),
139+
expires_in: z.coerce.number().optional(),
140140
scope: z.string().optional(),
141141
refresh_token: z.string().optional()
142142
})

0 commit comments

Comments
 (0)