Skip to content

Commit b4c6090

Browse files
coerce 'expires_in' to be a number (#1111)
Co-authored-by: Konstantin Konstantinov <[email protected]> Co-authored-by: Konstantin Konstantinov <[email protected]>
1 parent 6b90e1a commit b4c6090

File tree

2 files changed

+41
-3
lines changed

2 files changed

+41
-3
lines changed

src/client/auth.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
isHttpsUrl
1616
} from './auth.js';
1717
import { InvalidClientMetadataError, ServerError } from '../server/auth/errors.js';
18-
import { AuthorizationServerMetadata } from '../shared/auth.js';
18+
import { AuthorizationServerMetadata, OAuthTokens } from '../shared/auth.js';
1919
import { expect, vi, type Mock } from 'vitest';
2020

2121
// Mock pkce-challenge
@@ -1093,7 +1093,7 @@ describe('OAuth Authorization', () => {
10931093
});
10941094

10951095
describe('exchangeAuthorization', () => {
1096-
const validTokens = {
1096+
const validTokens: OAuthTokens = {
10971097
access_token: 'access123',
10981098
token_type: 'Bearer',
10991099
expires_in: 3600,
@@ -1154,6 +1154,44 @@ describe('OAuth Authorization', () => {
11541154
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
11551155
});
11561156

1157+
it('allows for string "expires_in" values', async () => {
1158+
mockFetch.mockResolvedValueOnce({
1159+
ok: true,
1160+
status: 200,
1161+
json: async () => ({ ...validTokens, expires_in: '3600' })
1162+
});
1163+
1164+
const tokens = await exchangeAuthorization('https://auth.example.com', {
1165+
clientInformation: validClientInfo,
1166+
authorizationCode: 'code123',
1167+
codeVerifier: 'verifier123',
1168+
redirectUri: 'http://localhost:3000/callback',
1169+
resource: new URL('https://api.example.com/mcp-server')
1170+
});
1171+
1172+
expect(tokens).toEqual(validTokens);
1173+
expect(mockFetch).toHaveBeenCalledWith(
1174+
expect.objectContaining({
1175+
href: 'https://auth.example.com/token'
1176+
}),
1177+
expect.objectContaining({
1178+
method: 'POST'
1179+
})
1180+
);
1181+
1182+
const options = mockFetch.mock.calls[0][1];
1183+
expect(options.headers).toBeInstanceOf(Headers);
1184+
expect(options.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
1185+
1186+
const body = options.body as URLSearchParams;
1187+
expect(body.get('grant_type')).toBe('authorization_code');
1188+
expect(body.get('code')).toBe('code123');
1189+
expect(body.get('code_verifier')).toBe('verifier123');
1190+
expect(body.get('client_id')).toBe('client123');
1191+
expect(body.get('client_secret')).toBe('secret123');
1192+
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
1193+
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
1194+
});
11571195
it('exchanges code for tokens with auth', async () => {
11581196
mockFetch.mockResolvedValueOnce({
11591197
ok: true,

src/shared/auth.ts

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

0 commit comments

Comments
 (0)