Skip to content

Commit 6f313a3

Browse files
Merge pull request #232 from gleanwork/codex/hjdivad/add-error-for-missing-refresh-tokens
Add error when refresh token not issued
2 parents 9fa8339 + 21eed88 commit 6f313a3

File tree

4 files changed

+74
-3
lines changed

4 files changed

+74
-3
lines changed

packages/configure-mcp-server/src/configure/client/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { RemoteMcpTargets } from '@gleanwork/mcp-server-utils/util';
1212
import { isOAuthEnabled } from '../../common/env.js';
1313

1414
import connectMcpPackageJson from '@gleanwork/connect-mcp-server/package.json' with { type: 'json' };
15-
let connectMcpServerVersion = connectMcpPackageJson.version;
15+
const connectMcpServerVersion = connectMcpPackageJson.version;
1616

1717
export interface MCPConfigPath {
1818
configDir: string;

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -546,15 +546,28 @@ async function authorize(config: GleanOAuthConfig): Promise<Tokens | null> {
546546
).catch((e) => {
547547
error('prompting user for verification page', e);
548548
});
549-
const tokenResponse = await tokenPoller;
549+
const polledTokenResponse = await tokenPoller;
550550

551551
// Clean up the readline interface now that we have the token
552552
abortController.abort();
553553

554554
if (cause !== undefined) {
555555
throw cause;
556556
}
557-
return Tokens.buildFromTokenResponse(tokenResponse as TokenResponse);
557+
558+
// tokenResponse is void | TokenResponse because of the catch handler
559+
// attached to pollForToken, which sets cause and resolves to void. But
560+
// right above here we throw if cause !== undefined so we can guarantee
561+
// tokenResponse is TokenResponse and not void.
562+
const tokenResponse = polledTokenResponse as TokenResponse;
563+
564+
if (tokenResponse.refresh_token === undefined) {
565+
throw new AuthError(
566+
`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.`,
567+
{ code: AuthErrorCode.RefreshTokenNotIssued },
568+
);
569+
}
570+
return Tokens.buildFromTokenResponse(tokenResponse);
558571
} catch (cause: any) {
559572
// Clean up the readline interface on error as well
560573
abortController.abort();

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)