Skip to content

Commit 70b1f7b

Browse files
feat(auth): require refresh token in OAuth flow
Co-Authored-By: codex
1 parent 9fa8339 commit 70b1f7b

File tree

3 files changed

+64
-0
lines changed

3 files changed

+64
-0
lines changed

packages/mcp-server-utils/src/auth/auth.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,12 @@ async function authorize(config: GleanOAuthConfig): Promise<Tokens | null> {
554554
if (cause !== undefined) {
555555
throw cause;
556556
}
557+
if (tokenResponse.refresh_token === undefined) {
558+
throw new AuthError(
559+
`Your OAuth Authorization Server issued an access token but not a refresh token. Please configure your OAuth application with id: ${config.clientId} to issue refresh tokens.`,
560+
{ code: AuthErrorCode.RefreshTokenNotIssued },
561+
);
562+
}
557563
return Tokens.buildFromTokenResponse(tokenResponse as TokenResponse);
558564
} catch (cause: any) {
559565
// Clean up the readline interface on error as well

packages/mcp-server-utils/src/auth/error.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export enum AuthErrorCode {
4545
MissingOAuthMetadata = 'ERR_A_21',
4646
/** Missing OAuth tokens required for MCP remote setup */
4747
MissingOAuthTokens = 'ERR_A_22',
48+
/** Refresh token not issued by authorization server */
49+
RefreshTokenNotIssued = 'ERR_A_23',
4850
}
4951

5052
/**

packages/mcp-server-utils/src/test/auth/authorize.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,62 @@ describe('authorize (device flow)', () => {
312312
);
313313
});
314314

315+
it('should error when no refresh token is issued', async () => {
316+
const baseUrl = 'https://glean.example.com';
317+
const issuer = 'https://auth.example.com';
318+
const clientId = 'client-123';
319+
const deviceAuthorizationEndpoint = 'https://auth.example.com/device';
320+
const tokenEndpoint = 'https://auth.example.com/token';
321+
const deviceCode = 'device-code-abc';
322+
const userCode = 'user-code-xyz';
323+
const verificationUri = 'https://auth.example.com/verify';
324+
const interval = 5;
325+
const expiresIn = 3600;
326+
const accessToken = 'access-token-123';
327+
328+
server.use(
329+
http.get(`${baseUrl}/.well-known/oauth-protected-resource`, () =>
330+
HttpResponse.json({
331+
authorization_servers: [issuer],
332+
glean_device_flow_client_id: clientId,
333+
}),
334+
),
335+
http.get(`${issuer}/.well-known/openid-configuration`, () =>
336+
HttpResponse.json({
337+
device_authorization_endpoint: deviceAuthorizationEndpoint,
338+
token_endpoint: tokenEndpoint,
339+
}),
340+
),
341+
http.post(deviceAuthorizationEndpoint, () =>
342+
HttpResponse.json({
343+
device_code: deviceCode,
344+
user_code: userCode,
345+
verification_uri: verificationUri,
346+
expires_in: 600,
347+
interval,
348+
}),
349+
),
350+
http.post(tokenEndpoint, async ({ request }) => {
351+
const body = await request.text();
352+
if (body.includes(`device_code=${deviceCode}`)) {
353+
return HttpResponse.json({
354+
token_type: 'Bearer',
355+
access_token: accessToken,
356+
expires_in: expiresIn,
357+
});
358+
}
359+
return HttpResponse.json({
360+
error: 'authorization_pending',
361+
error_description: 'pending',
362+
});
363+
}),
364+
);
365+
366+
await expect(forceAuthorize()).rejects.toThrowErrorMatchingInlineSnapshot(
367+
`[AuthError: ERR_A_23: Your OAuth Authorization Server issued an access token but not a refresh token. Please configure your OAuth application with id: client-123 to issue refresh tokens.]`,
368+
);
369+
});
370+
315371
it('should poll until user enters code, respecting interval, then succeed', async () => {
316372
const baseUrl = 'https://glean.example.com';
317373
const issuer = 'https://auth.example.com';

0 commit comments

Comments
 (0)