Skip to content

Commit 73f5150

Browse files
Merge pull request #223 from gleanwork/hjdivad/handle-no-browser
Enable users to manually open device flow verification page
2 parents 00de311 + 2d6d756 commit 73f5150

File tree

2 files changed

+269
-7
lines changed

2 files changed

+269
-7
lines changed

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

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -532,19 +532,33 @@ async function authorize(config: GleanOAuthConfig): Promise<Tokens | null> {
532532
);
533533
}
534534

535+
const abortController = new AbortController();
535536
let cause: any = undefined;
536537
try {
537538
const authResponse = await fetchDeviceAuthorization(config);
538539
const tokenPoller = pollForToken(authResponse, config).catch((e) => {
539540
cause = e;
540541
});
541-
await promptUserAndOpenVerificationPage(authResponse);
542+
// Don't wait for this; if the user copies the URL manually and enters the code that's fine.
543+
promptUserAndOpenVerificationPage(
544+
authResponse,
545+
abortController.signal,
546+
).catch((e) => {
547+
error('prompting user for verification page', e);
548+
});
542549
const tokenResponse = await tokenPoller;
550+
551+
// Clean up the readline interface now that we have the token
552+
abortController.abort();
553+
543554
if (cause !== undefined) {
544555
throw cause;
545556
}
546557
return Tokens.buildFromTokenResponse(tokenResponse as TokenResponse);
547558
} catch (cause: any) {
559+
// Clean up the readline interface on error as well
560+
abortController.abort();
561+
548562
if (cause instanceof AuthError) {
549563
throw cause;
550564
}
@@ -554,30 +568,53 @@ async function authorize(config: GleanOAuthConfig): Promise<Tokens | null> {
554568
}
555569
}
556570

557-
async function waitForUserEnter() {
571+
async function waitForUserEnter(signal?: AbortSignal) {
558572
const rl = readline.createInterface({
559573
input: process.stdin,
560574
output: process.stdout,
561575
});
562576

563-
await new Promise<void>((resolve) => {
564-
rl.once('line', () => {
577+
await new Promise<void>((resolve, reject) => {
578+
const cleanup = () => {
565579
rl.close();
580+
};
581+
582+
rl.once('line', () => {
583+
cleanup();
566584
resolve();
567585
});
586+
587+
if (signal) {
588+
signal.addEventListener('abort', () => {
589+
cleanup();
590+
resolve(); // Resolve silently when aborted
591+
});
592+
}
568593
});
569594
}
570595

571-
async function promptUserAndOpenVerificationPage(authResponse: AuthResponse) {
596+
async function promptUserAndOpenVerificationPage(
597+
authResponse: AuthResponse,
598+
signal?: AbortSignal,
599+
) {
572600
console.log(`
573601
Authorizing Glean MCP-server. Please log in to Glean.
574602
575603
! First copy your one-time code: ${authResponse.user_code}
576604
577-
Press Enter continue.
605+
Press Enter to open the following URL where the code is needed:
606+
607+
${authResponse.verification_uri}
578608
`);
579609

580-
await waitForUserEnter();
610+
await waitForUserEnter(signal);
611+
612+
// signal is aborted if the user manually opened the URL and entered the
613+
// code, in which case we shouldn't then open the URL again ourselves.
614+
if (signal?.aborted) {
615+
return;
616+
}
617+
581618
await open(authResponse.verification_uri);
582619
}
583620

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

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,4 +576,229 @@ describe('authorize (device flow)', () => {
576576
`[AuthError: ERR_A_16: Unexpected error requesting authorization grant]`,
577577
);
578578
});
579+
580+
describe('AbortController integration', () => {
581+
// Common test constants
582+
const baseUrl = 'https://glean.example.com';
583+
const issuer = 'https://auth.example.com';
584+
const clientId = 'client-123';
585+
const deviceAuthorizationEndpoint = 'https://auth.example.com/device';
586+
const tokenEndpoint = 'https://auth.example.com/token';
587+
const deviceCode = 'device-code-abc';
588+
const userCode = 'user-code-xyz';
589+
const verificationUri = 'https://auth.example.com/verify';
590+
const interval = 5;
591+
const accessToken = 'access-token-123';
592+
const refreshToken = 'refresh-token-456';
593+
const expiresIn = 3600;
594+
595+
// Helper function to mock readline interface
596+
async function mockReadlineInterface(
597+
behavior:
598+
| 'user-opened-browser-manually'
599+
| 'user-pressed-enter' = 'user-opened-browser-manually',
600+
) {
601+
const mockClose = vi.fn();
602+
const mockOnce = vi
603+
.fn()
604+
.mockImplementation((_event: string, cb: () => void) => {
605+
if (behavior === 'user-pressed-enter') {
606+
setTimeout(cb, 0);
607+
}
608+
// For 'user-opened-browser-manually', don't call cb - simulate user opening browser manually
609+
});
610+
611+
vi.mocked(await import('node:readline')).default.createInterface = vi
612+
.fn()
613+
.mockReturnValue({
614+
once: mockOnce,
615+
close: mockClose,
616+
});
617+
618+
return { mockClose, mockOnce };
619+
}
620+
621+
// Helper function to set up common OAuth metadata endpoints
622+
function setupOAuthMetadataEndpoints() {
623+
return [
624+
http.get(`${baseUrl}/.well-known/oauth-protected-resource`, () =>
625+
HttpResponse.json({
626+
authorization_servers: [issuer],
627+
glean_device_flow_client_id: clientId,
628+
}),
629+
),
630+
http.get(`${issuer}/.well-known/openid-configuration`, () =>
631+
HttpResponse.json({
632+
device_authorization_endpoint: deviceAuthorizationEndpoint,
633+
token_endpoint: tokenEndpoint,
634+
}),
635+
),
636+
http.post(deviceAuthorizationEndpoint, () =>
637+
HttpResponse.json({
638+
device_code: deviceCode,
639+
user_code: userCode,
640+
verification_uri: verificationUri,
641+
expires_in: 600,
642+
interval,
643+
}),
644+
),
645+
];
646+
}
647+
648+
it('should abort readline interface when token polling succeeds', async () => {
649+
const { mockClose } = await mockReadlineInterface(
650+
'user-opened-browser-manually',
651+
);
652+
653+
// Mock OAuth endpoints with immediate success
654+
server.use(
655+
...setupOAuthMetadataEndpoints(),
656+
http.post(tokenEndpoint, async ({ request }) => {
657+
const body = await request.text();
658+
if (body.includes(`device_code=${deviceCode}`)) {
659+
return HttpResponse.json({
660+
token_type: 'Bearer',
661+
access_token: accessToken,
662+
refresh_token: refreshToken,
663+
expires_in: expiresIn,
664+
});
665+
}
666+
return HttpResponse.json({
667+
error: 'authorization_pending',
668+
error_description: 'pending',
669+
});
670+
}),
671+
);
672+
673+
// Act
674+
const resultPromise = forceAuthorize();
675+
await vi.runAllTimersAsync();
676+
const tokens = await resultPromise;
677+
678+
// Assert
679+
expect(tokens).not.toBeNull();
680+
expect(tokens?.accessToken).toBe(accessToken);
681+
expect(mockClose).toHaveBeenCalled();
682+
});
683+
684+
it('should not open browser when AbortController is aborted', async () => {
685+
const { mockClose } = await mockReadlineInterface(
686+
'user-opened-browser-manually',
687+
);
688+
689+
// Mock OAuth endpoints with immediate success
690+
server.use(
691+
...setupOAuthMetadataEndpoints(),
692+
http.post(tokenEndpoint, async ({ request }) => {
693+
const body = await request.text();
694+
if (body.includes(`device_code=${deviceCode}`)) {
695+
return HttpResponse.json({
696+
token_type: 'Bearer',
697+
access_token: accessToken,
698+
refresh_token: refreshToken,
699+
expires_in: expiresIn,
700+
});
701+
}
702+
return HttpResponse.json({
703+
error: 'authorization_pending',
704+
error_description: 'pending',
705+
});
706+
}),
707+
);
708+
709+
// Act
710+
const open = (await import('open')).default;
711+
const resultPromise = forceAuthorize();
712+
await vi.runAllTimersAsync();
713+
const tokens = await resultPromise;
714+
715+
// Assert
716+
expect(tokens).not.toBeNull();
717+
expect(tokens?.accessToken).toBe(accessToken);
718+
expect(mockClose).toHaveBeenCalled();
719+
// The browser should not be opened because the AbortController aborted
720+
// before the user pressed Enter (since token polling succeeded immediately)
721+
expect(open).not.toHaveBeenCalled();
722+
});
723+
724+
it('should still open browser when user presses Enter before token polling succeeds', async () => {
725+
let pollCount = 0;
726+
const { mockClose } = await mockReadlineInterface('user-pressed-enter');
727+
728+
// Mock OAuth endpoints with delayed success
729+
server.use(
730+
...setupOAuthMetadataEndpoints(),
731+
http.post(tokenEndpoint, async ({ request }) => {
732+
const body = await request.text();
733+
if (body.includes(`device_code=${deviceCode}`)) {
734+
pollCount++;
735+
if (pollCount >= 3) {
736+
return HttpResponse.json({
737+
token_type: 'Bearer',
738+
access_token: accessToken,
739+
refresh_token: refreshToken,
740+
expires_in: expiresIn,
741+
});
742+
}
743+
return HttpResponse.json({
744+
error: 'authorization_pending',
745+
error_description: 'pending',
746+
});
747+
}
748+
return HttpResponse.json({
749+
error: 'authorization_pending',
750+
error_description: 'pending',
751+
});
752+
}),
753+
);
754+
755+
// Act
756+
const open = (await import('open')).default;
757+
const resultPromise = forceAuthorize();
758+
await vi.runAllTimersAsync();
759+
const tokens = await resultPromise;
760+
761+
// Assert
762+
expect(tokens).not.toBeNull();
763+
expect(tokens?.accessToken).toBe(accessToken);
764+
expect(mockClose).toHaveBeenCalled();
765+
// The browser should be opened because the user pressed Enter before token polling succeeded
766+
expect(open).toHaveBeenCalledWith(verificationUri);
767+
});
768+
769+
it('should clean up readline interface on error', async () => {
770+
const { mockClose } = await mockReadlineInterface(
771+
'user-opened-browser-manually',
772+
);
773+
774+
// Mock OAuth endpoints with error in token polling
775+
server.use(
776+
...setupOAuthMetadataEndpoints(),
777+
http.post(tokenEndpoint, async ({ request }) => {
778+
const body = await request.text();
779+
if (body.includes(`device_code=${deviceCode}`)) {
780+
return HttpResponse.json(
781+
{
782+
error: 'invalid_grant',
783+
error_description: 'The device code is invalid',
784+
},
785+
{ status: 400 },
786+
);
787+
}
788+
return HttpResponse.json({
789+
error: 'authorization_pending',
790+
error_description: 'pending',
791+
});
792+
}),
793+
);
794+
795+
// Act & Assert
796+
await expect(forceAuthorize()).rejects.toThrowErrorMatchingInlineSnapshot(
797+
`[AuthError: ERR_A_16: Unexpected error requesting authorization grant]`,
798+
);
799+
800+
// Assert that readline interface was cleaned up even on error
801+
expect(mockClose).toHaveBeenCalled();
802+
});
803+
});
579804
});

0 commit comments

Comments
 (0)