@mcp-z/oauth-google 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,24 @@
3
3
  *
4
4
  * Implements OAuth 2.0 Authorization Code Flow with PKCE using loopback interface redirection.
5
5
  * Uses ephemeral local server with OS-assigned port (RFC 8252 Section 8.3).
6
+ *
7
+ * CHANGE (2026-01-03):
8
+ * - Non-headless mode now opens the auth URL AND blocks (polls) until tokens are available,
9
+ * for BOTH redirectUri (persistent) and ephemeral (loopback) modes.
10
+ * - Ephemeral flow no longer calls `open()` itself. Instead it:
11
+ * 1) starts the loopback callback server
12
+ * 2) throws AuthRequiredError(auth_url)
13
+ * - Middleware catches AuthRequiredError(auth_url):
14
+ * - if not headless: open(url) once + poll pending state until callback completes (or timeout)
15
+ * - then retries token acquisition and injects authContext in the SAME tool call.
6
16
  */ import { addAccount, generatePKCE, getActiveAccount, getErrorTemplate, getSuccessTemplate, getToken, setAccountInfo, setActiveAccount, setToken } from '@mcp-z/oauth';
7
17
  import { randomUUID } from 'crypto';
8
18
  import { OAuth2Client } from 'google-auth-library';
9
19
  import * as http from 'http';
10
20
  import open from 'open';
11
21
  import { AuthRequiredError } from '../types.js';
22
+ const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
23
+ const OAUTH_POLL_MS = 500;
12
24
  /**
13
25
  * Loopback OAuth Client (RFC 8252 Section 7.3)
14
26
  *
@@ -74,61 +86,36 @@ import { AuthRequiredError } from '../types.js';
74
86
  const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
75
87
  const stateId = randomUUID();
76
88
  // Store PKCE verifier for callback (5 minute TTL)
77
- await tokenStore.set(`${service}:pending:${stateId}`, {
78
- codeVerifier,
79
- createdAt: Date.now()
80
- }, 5 * 60 * 1000);
89
+ await this.createPendingAuth({
90
+ state: stateId,
91
+ codeVerifier
92
+ });
81
93
  // Build auth URL with configured redirect_uri
82
- const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
83
- authUrl.searchParams.set('client_id', clientId);
84
- authUrl.searchParams.set('redirect_uri', redirectUri);
85
- authUrl.searchParams.set('response_type', 'code');
86
- authUrl.searchParams.set('scope', scope);
87
- authUrl.searchParams.set('access_type', 'offline');
88
- authUrl.searchParams.set('code_challenge', codeChallenge);
89
- authUrl.searchParams.set('code_challenge_method', 'S256');
90
- authUrl.searchParams.set('state', stateId);
91
- authUrl.searchParams.set('prompt', 'consent');
94
+ const authUrl = this.buildAuthUrl({
95
+ redirectUri,
96
+ codeChallenge,
97
+ state: stateId
98
+ });
92
99
  logger.info('OAuth required - persistent callback mode', {
93
100
  service,
94
- redirectUri
101
+ redirectUri,
102
+ clientId,
103
+ scope
95
104
  });
96
105
  throw new AuthRequiredError({
97
106
  kind: 'auth_url',
98
107
  provider: service,
99
- url: authUrl.toString()
108
+ url: authUrl
100
109
  });
101
110
  }
102
111
  // Ephemeral callback mode (local development)
112
+ // IMPORTANT: do NOT open here anymore; we throw auth_url and the middleware will open+poll.
103
113
  logger.info('Starting ephemeral OAuth flow', {
104
114
  service,
105
115
  headless: this.config.headless
106
116
  });
107
- const { token, email } = await this.performEphemeralOAuthFlow();
108
- await setToken(tokenStore, {
109
- accountId: email,
110
- service
111
- }, token);
112
- await addAccount(tokenStore, {
113
- service,
114
- accountId: email
115
- });
116
- await setActiveAccount(tokenStore, {
117
- service,
118
- accountId: email
119
- });
120
- await setAccountInfo(tokenStore, {
121
- service,
122
- accountId: email
123
- }, {
124
- email,
125
- addedAt: new Date().toISOString()
126
- });
127
- logger.info('OAuth flow completed', {
128
- service,
129
- accountId: email
130
- });
131
- return token.accessToken;
117
+ const descriptor = await this.startEphemeralOAuthFlow();
118
+ throw new AuthRequiredError(descriptor);
132
119
  }
133
120
  /**
134
121
  * Convert to googleapis-compatible OAuth2Client
@@ -210,8 +197,275 @@ import { AuthRequiredError } from '../types.js';
210
197
  });
211
198
  return email;
212
199
  }
213
- async performEphemeralOAuthFlow() {
214
- const { clientId, scope, headless, logger, redirectUri: configRedirectUri } = this.config;
200
+ // ---------------------------------------------------------------------------
201
+ // Shared OAuth helpers
202
+ // ---------------------------------------------------------------------------
203
+ /**
204
+ * Build OAuth authorization URL with the "most parameters" baseline.
205
+ * This is shared by BOTH persistent (redirectUri) and ephemeral (loopback) modes.
206
+ */ buildAuthUrl(args) {
207
+ const { clientId, scope } = this.config;
208
+ const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
209
+ authUrl.searchParams.set('client_id', clientId);
210
+ authUrl.searchParams.set('redirect_uri', args.redirectUri);
211
+ authUrl.searchParams.set('response_type', 'code');
212
+ authUrl.searchParams.set('scope', scope);
213
+ authUrl.searchParams.set('access_type', 'offline'); // always
214
+ authUrl.searchParams.set('prompt', 'consent'); // always
215
+ authUrl.searchParams.set('code_challenge', args.codeChallenge);
216
+ authUrl.searchParams.set('code_challenge_method', 'S256');
217
+ authUrl.searchParams.set('state', args.state);
218
+ return authUrl.toString();
219
+ }
220
+ /**
221
+ * Create a cached token + email from an authorization code.
222
+ * This is the shared callback handler for BOTH persistent and ephemeral modes.
223
+ */ async handleAuthorizationCode(args) {
224
+ // Exchange code for token (must use same redirect_uri as in authorization request)
225
+ const tokenResponse = await this.exchangeCodeForToken(args.code, args.codeVerifier, args.redirectUri);
226
+ // Build cached token
227
+ const cachedToken = {
228
+ accessToken: tokenResponse.access_token,
229
+ ...tokenResponse.refresh_token !== undefined && {
230
+ refreshToken: tokenResponse.refresh_token
231
+ },
232
+ ...tokenResponse.expires_in !== undefined && {
233
+ expiresAt: Date.now() + tokenResponse.expires_in * 1000
234
+ },
235
+ ...tokenResponse.scope !== undefined && {
236
+ scope: tokenResponse.scope
237
+ }
238
+ };
239
+ // Fetch user email immediately using the new access token
240
+ const email = await this.fetchUserEmailFromToken(tokenResponse.access_token);
241
+ return {
242
+ email,
243
+ token: cachedToken
244
+ };
245
+ }
246
+ /**
247
+ * Store token + account metadata. Shared by BOTH persistent and ephemeral modes.
248
+ */ async persistAuthResult(args) {
249
+ const { tokenStore, service } = this.config;
250
+ await setToken(tokenStore, {
251
+ accountId: args.email,
252
+ service
253
+ }, args.token);
254
+ await addAccount(tokenStore, {
255
+ service,
256
+ accountId: args.email
257
+ });
258
+ await setActiveAccount(tokenStore, {
259
+ service,
260
+ accountId: args.email
261
+ });
262
+ await setAccountInfo(tokenStore, {
263
+ service,
264
+ accountId: args.email
265
+ }, {
266
+ email: args.email,
267
+ addedAt: new Date().toISOString()
268
+ });
269
+ }
270
+ /**
271
+ * Pending auth (PKCE verifier) key format.
272
+ * Keep as-is (confirmed), even though it's not a public contract.
273
+ */ pendingKey(state) {
274
+ return `${this.config.service}:pending:${state}`;
275
+ }
276
+ /**
277
+ * Store PKCE verifier for callback (5 minute TTL).
278
+ * Shared by BOTH persistent and ephemeral modes.
279
+ */ async createPendingAuth(args) {
280
+ const { tokenStore } = this.config;
281
+ const record = {
282
+ codeVerifier: args.codeVerifier,
283
+ createdAt: Date.now()
284
+ };
285
+ await tokenStore.set(this.pendingKey(args.state), record, OAUTH_TIMEOUT_MS);
286
+ }
287
+ /**
288
+ * Load and validate pending auth state (5 minute TTL).
289
+ * Shared by BOTH persistent and ephemeral modes.
290
+ */ async readAndValidatePendingAuth(state) {
291
+ const { tokenStore } = this.config;
292
+ const pendingAuth = await tokenStore.get(this.pendingKey(state));
293
+ if (!pendingAuth) {
294
+ throw new Error('Invalid or expired OAuth state. Please try again.');
295
+ }
296
+ // Check TTL (5 minutes)
297
+ if (Date.now() - pendingAuth.createdAt > OAUTH_TIMEOUT_MS) {
298
+ await tokenStore.delete(this.pendingKey(state));
299
+ throw new Error('OAuth state expired. Please try again.');
300
+ }
301
+ return pendingAuth;
302
+ }
303
+ /**
304
+ * Mark pending auth as completed (used by middleware polling).
305
+ */ async markPendingComplete(args) {
306
+ const { tokenStore } = this.config;
307
+ const updated = {
308
+ ...args.pending,
309
+ completedAt: Date.now(),
310
+ email: args.email
311
+ };
312
+ await tokenStore.set(this.pendingKey(args.state), updated, OAUTH_TIMEOUT_MS);
313
+ }
314
+ /**
315
+ * Clean up pending auth state.
316
+ */ async deletePendingAuth(state) {
317
+ const { tokenStore } = this.config;
318
+ await tokenStore.delete(this.pendingKey(state));
319
+ }
320
+ /**
321
+ * Wait until pending auth is marked completed (or timeout).
322
+ * Used by middleware after opening auth URL in non-headless mode.
323
+ */ async waitForOAuthCompletion(state) {
324
+ const { tokenStore } = this.config;
325
+ const key = this.pendingKey(state);
326
+ const start = Date.now();
327
+ while(Date.now() - start < OAUTH_TIMEOUT_MS){
328
+ const pending = await tokenStore.get(key);
329
+ if (pending === null || pending === void 0 ? void 0 : pending.completedAt) {
330
+ return {
331
+ email: pending.email
332
+ };
333
+ }
334
+ await new Promise((r)=>setTimeout(r, OAUTH_POLL_MS));
335
+ }
336
+ throw new Error('OAuth flow timed out after 5 minutes');
337
+ }
338
+ /**
339
+ * Process an OAuth callback using shared state validation + token exchange + persistence.
340
+ * Used by BOTH:
341
+ * - ephemeral loopback server callback handler
342
+ * - persistent redirectUri callback handler
343
+ *
344
+ * IMPORTANT CHANGE:
345
+ * - We do NOT delete pending state here anymore.
346
+ * - We mark it completed so middleware can poll and then clean it up.
347
+ */ async processOAuthCallback(args) {
348
+ const { logger, service } = this.config;
349
+ const pending = await this.readAndValidatePendingAuth(args.state);
350
+ logger.info('Processing OAuth callback', {
351
+ service,
352
+ state: args.state
353
+ });
354
+ const result = await this.handleAuthorizationCode({
355
+ code: args.code,
356
+ codeVerifier: pending.codeVerifier,
357
+ redirectUri: args.redirectUri
358
+ });
359
+ await this.persistAuthResult(result);
360
+ await this.markPendingComplete({
361
+ state: args.state,
362
+ email: result.email,
363
+ pending
364
+ });
365
+ logger.info('OAuth callback completed', {
366
+ service,
367
+ email: result.email
368
+ });
369
+ return result;
370
+ }
371
+ // ---------------------------------------------------------------------------
372
+ // Ephemeral loopback server + flow
373
+ // ---------------------------------------------------------------------------
374
+ /**
375
+ * Loopback OAuth server helper (RFC 8252 Section 7.3)
376
+ *
377
+ * Implements ephemeral local server with OS-assigned port (RFC 8252 Section 8.3).
378
+ * Shared callback handling uses:
379
+ * - the same authUrl builder as redirectUri mode
380
+ * - the same pending PKCE verifier storage as redirectUri mode
381
+ * - the same callback processor as redirectUri mode
382
+ */ createOAuthCallbackServer(args) {
383
+ const { logger } = this.config;
384
+ // Create ephemeral server with OS-assigned port (RFC 8252)
385
+ return http.createServer(async (req, res)=>{
386
+ try {
387
+ if (!req.url) {
388
+ res.writeHead(400, {
389
+ 'Content-Type': 'text/html'
390
+ });
391
+ res.end(getErrorTemplate('Invalid request'));
392
+ args.onError(new Error('Invalid request: missing URL'));
393
+ return;
394
+ }
395
+ // Use loopback base for URL parsing (port is not important for parsing path/query)
396
+ const url = new URL(req.url, 'http://127.0.0.1');
397
+ if (url.pathname !== args.callbackPath) {
398
+ res.writeHead(404, {
399
+ 'Content-Type': 'text/plain'
400
+ });
401
+ res.end('Not Found');
402
+ return;
403
+ }
404
+ const code = url.searchParams.get('code');
405
+ const error = url.searchParams.get('error');
406
+ const state = url.searchParams.get('state');
407
+ if (error) {
408
+ res.writeHead(400, {
409
+ 'Content-Type': 'text/html'
410
+ });
411
+ res.end(getErrorTemplate(error));
412
+ args.onError(new Error(`OAuth error: ${error}`));
413
+ return;
414
+ }
415
+ if (!code) {
416
+ res.writeHead(400, {
417
+ 'Content-Type': 'text/html'
418
+ });
419
+ res.end(getErrorTemplate('No authorization code received'));
420
+ args.onError(new Error('No authorization code received'));
421
+ return;
422
+ }
423
+ if (!state) {
424
+ res.writeHead(400, {
425
+ 'Content-Type': 'text/html'
426
+ });
427
+ res.end(getErrorTemplate('Missing state parameter in OAuth callback'));
428
+ args.onError(new Error('Missing state parameter in OAuth callback'));
429
+ return;
430
+ }
431
+ try {
432
+ await this.processOAuthCallback({
433
+ code,
434
+ state,
435
+ redirectUri: args.finalRedirectUri()
436
+ });
437
+ res.writeHead(200, {
438
+ 'Content-Type': 'text/html'
439
+ });
440
+ res.end(getSuccessTemplate());
441
+ args.onDone();
442
+ } catch (exchangeError) {
443
+ logger.error('Token exchange failed', {
444
+ error: exchangeError instanceof Error ? exchangeError.message : String(exchangeError)
445
+ });
446
+ res.writeHead(500, {
447
+ 'Content-Type': 'text/html'
448
+ });
449
+ res.end(getErrorTemplate('Token exchange failed'));
450
+ args.onError(exchangeError);
451
+ }
452
+ } catch (outerError) {
453
+ logger.error('OAuth callback server error', {
454
+ error: outerError instanceof Error ? outerError.message : String(outerError)
455
+ });
456
+ res.writeHead(500, {
457
+ 'Content-Type': 'text/html'
458
+ });
459
+ res.end(getErrorTemplate('Internal server error'));
460
+ args.onError(outerError);
461
+ }
462
+ });
463
+ }
464
+ /**
465
+ * Starts the ephemeral loopback server and returns an AuthRequiredError(auth_url).
466
+ * Middleware will open+poll and then retry in the same call.
467
+ */ async startEphemeralOAuthFlow() {
468
+ const { headless, logger, redirectUri: configRedirectUri, service, tokenStore } = this.config;
215
469
  // Server listen configuration (where ephemeral server binds)
216
470
  let listenHost = 'localhost'; // Default: localhost for ephemeral loopback
217
471
  let listenPort = 0; // Default: OS-assigned ephemeral port
@@ -254,92 +508,34 @@ import { AuthRequiredError } from '../types.js';
254
508
  // Continue with defaults (localhost, port 0, http, /callback)
255
509
  }
256
510
  }
257
- return new Promise((resolve, reject)=>{
258
- // Generate PKCE challenge
259
- const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
260
- let server = null;
261
- let serverPort;
262
- let finalRedirectUri; // Will be set in server.listen callback
263
- // Create ephemeral server with OS-assigned port (RFC 8252)
264
- server = http.createServer(async (req, res)=>{
265
- if (!req.url) {
266
- res.writeHead(400, {
267
- 'Content-Type': 'text/html'
268
- });
269
- res.end(getErrorTemplate('Invalid request'));
270
- server === null || server === void 0 ? void 0 : server.close();
271
- reject(new Error('Invalid request: missing URL'));
272
- return;
273
- }
274
- const url = new URL(req.url, `http://127.0.0.1:${serverPort}`);
275
- if (url.pathname === callbackPath) {
276
- const code = url.searchParams.get('code');
277
- const error = url.searchParams.get('error');
278
- if (error) {
279
- res.writeHead(400, {
280
- 'Content-Type': 'text/html'
281
- });
282
- res.end(getErrorTemplate(error));
283
- server === null || server === void 0 ? void 0 : server.close();
284
- reject(new Error(`OAuth error: ${error}`));
285
- return;
286
- }
287
- if (!code) {
288
- res.writeHead(400, {
289
- 'Content-Type': 'text/html'
290
- });
291
- res.end(getErrorTemplate('No authorization code received'));
292
- server === null || server === void 0 ? void 0 : server.close();
293
- reject(new Error('No authorization code received'));
294
- return;
295
- }
296
- try {
297
- // Exchange code for token (must use same redirect_uri as in authorization request)
298
- const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, finalRedirectUri);
299
- // Build cached token
300
- const cachedToken = {
301
- accessToken: tokenResponse.access_token,
302
- ...tokenResponse.refresh_token !== undefined && {
303
- refreshToken: tokenResponse.refresh_token
304
- },
305
- ...tokenResponse.expires_in !== undefined && {
306
- expiresAt: Date.now() + tokenResponse.expires_in * 1000
307
- },
308
- ...tokenResponse.scope !== undefined && {
309
- scope: tokenResponse.scope
310
- }
311
- };
312
- // Fetch user email immediately using the new access token
313
- const email = await this.fetchUserEmailFromToken(tokenResponse.access_token);
314
- res.writeHead(200, {
315
- 'Content-Type': 'text/html'
316
- });
317
- res.end(getSuccessTemplate());
318
- server === null || server === void 0 ? void 0 : server.close();
319
- resolve({
320
- token: cachedToken,
321
- email
322
- });
323
- } catch (exchangeError) {
324
- logger.error('Token exchange failed', {
325
- error: exchangeError instanceof Error ? exchangeError.message : String(exchangeError)
326
- });
327
- res.writeHead(500, {
328
- 'Content-Type': 'text/html'
329
- });
330
- res.end(getErrorTemplate('Token exchange failed'));
331
- server === null || server === void 0 ? void 0 : server.close();
332
- reject(exchangeError);
333
- }
334
- } else {
335
- res.writeHead(404, {
336
- 'Content-Type': 'text/plain'
337
- });
338
- res.end('Not Found');
339
- }
340
- });
341
- // Listen on configured host/port
342
- server.listen(listenPort, listenHost, ()=>{
511
+ // Generate PKCE challenge + state
512
+ const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
513
+ const stateId = randomUUID();
514
+ // Store PKCE verifier for callback (5 minute TTL)
515
+ await this.createPendingAuth({
516
+ state: stateId,
517
+ codeVerifier
518
+ });
519
+ let server = null;
520
+ let serverPort;
521
+ let finalRedirectUri; // set after listen
522
+ // Create ephemeral server with OS-assigned port (RFC 8252)
523
+ server = this.createOAuthCallbackServer({
524
+ callbackPath,
525
+ finalRedirectUri: ()=>finalRedirectUri,
526
+ onDone: ()=>{
527
+ server === null || server === void 0 ? void 0 : server.close();
528
+ },
529
+ onError: (err)=>{
530
+ logger.error('Ephemeral OAuth server error', {
531
+ error: err instanceof Error ? err.message : String(err)
532
+ });
533
+ server === null || server === void 0 ? void 0 : server.close();
534
+ }
535
+ });
536
+ // Start listening
537
+ await new Promise((resolve, reject)=>{
538
+ server === null || server === void 0 ? void 0 : server.listen(listenPort, listenHost, ()=>{
343
539
  const address = server === null || server === void 0 ? void 0 : server.address();
344
540
  if (!address || typeof address === 'string') {
345
541
  server === null || server === void 0 ? void 0 : server.close();
@@ -349,52 +545,38 @@ import { AuthRequiredError } from '../types.js';
349
545
  serverPort = address.port;
350
546
  // Construct final redirect URI
351
547
  if (useConfiguredUri && configRedirectUri) {
352
- // Use configured redirect URI as-is (public URL for cloud, or specific local URL)
353
548
  finalRedirectUri = configRedirectUri;
354
549
  } else {
355
- // Construct ephemeral redirect URI with actual server port (default local behavior)
356
550
  finalRedirectUri = `http://localhost:${serverPort}${callbackPath}`;
357
551
  }
358
- // Build auth URL
359
- const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
360
- authUrl.searchParams.set('client_id', clientId);
361
- authUrl.searchParams.set('redirect_uri', finalRedirectUri);
362
- authUrl.searchParams.set('response_type', 'code');
363
- authUrl.searchParams.set('scope', scope);
364
- authUrl.searchParams.set('access_type', 'offline');
365
- authUrl.searchParams.set('prompt', 'consent');
366
- authUrl.searchParams.set('code_challenge', codeChallenge);
367
- authUrl.searchParams.set('code_challenge_method', 'S256');
368
552
  logger.info('Ephemeral OAuth server started', {
369
553
  port: serverPort,
370
- headless
554
+ headless,
555
+ service
371
556
  });
372
- if (headless) {
373
- // Headless mode: Print auth URL to stderr (stdout is MCP protocol)
374
- console.error('\n🔐 OAuth Authorization Required');
375
- console.error('📋 Please visit this URL in your browser:\n');
376
- console.error(` ${authUrl.toString()}\n`);
377
- console.error('⏳ Waiting for authorization...\n');
378
- } else {
379
- // Interactive mode: Open browser automatically
380
- logger.info('Opening browser for OAuth authorization');
381
- open(authUrl.toString()).catch((error)=>{
382
- logger.info('Failed to open browser automatically', {
383
- error: error.message
384
- });
385
- console.error('\n🔐 OAuth Authorization Required');
386
- console.error(` ${authUrl.toString()}\n`);
387
- });
388
- }
557
+ resolve();
389
558
  });
390
- // Timeout after 5 minutes
391
- setTimeout(()=>{
392
- if (server) {
393
- server.close();
394
- reject(new Error('OAuth flow timed out after 5 minutes'));
395
- }
396
- }, 5 * 60 * 1000);
397
559
  });
560
+ // Timeout after 5 minutes (match middleware polling timeout)
561
+ setTimeout(()=>{
562
+ if (server) {
563
+ server.close();
564
+ // Best-effort cleanup if user never completes flow:
565
+ // delete pending so a future attempt can restart cleanly.
566
+ void tokenStore.delete(this.pendingKey(stateId));
567
+ }
568
+ }, OAUTH_TIMEOUT_MS);
569
+ // Build auth URL - SAME helper as persistent mode
570
+ const authUrl = this.buildAuthUrl({
571
+ redirectUri: finalRedirectUri,
572
+ codeChallenge,
573
+ state: stateId
574
+ });
575
+ return {
576
+ kind: 'auth_url',
577
+ provider: service,
578
+ url: authUrl
579
+ };
398
580
  }
399
581
  async exchangeCodeForToken(code, codeVerifier, redirectUri) {
400
582
  const { clientId, clientSecret } = this.config;
@@ -466,94 +648,19 @@ import { AuthRequiredError } from '../types.js';
466
648
  * @returns Email and cached token
467
649
  */ async handleOAuthCallback(params) {
468
650
  const { code, state } = params;
469
- const { logger, service, tokenStore, clientId, clientSecret, redirectUri } = this.config;
651
+ const { redirectUri } = this.config;
470
652
  if (!state) {
471
653
  throw new Error('Missing state parameter in OAuth callback');
472
654
  }
473
655
  if (!redirectUri) {
474
656
  throw new Error('handleOAuthCallback requires configured redirectUri');
475
657
  }
476
- // Load pending auth (includes PKCE verifier)
477
- const pendingKey = `${service}:pending:${state}`;
478
- const pendingAuth = await tokenStore.get(pendingKey);
479
- if (!pendingAuth) {
480
- throw new Error('Invalid or expired OAuth state. Please try again.');
481
- }
482
- // Check TTL (5 minutes)
483
- if (Date.now() - pendingAuth.createdAt > 5 * 60 * 1000) {
484
- await tokenStore.delete(pendingKey);
485
- throw new Error('OAuth state expired. Please try again.');
486
- }
487
- logger.info('Processing OAuth callback', {
488
- service,
489
- state
490
- });
491
- // Exchange code for token
492
- const body = new URLSearchParams({
658
+ // Shared callback processor (same code path as ephemeral)
659
+ return await this.processOAuthCallback({
493
660
  code,
494
- client_id: clientId,
495
- ...clientSecret && {
496
- client_secret: clientSecret
497
- },
498
- redirect_uri: redirectUri,
499
- grant_type: 'authorization_code',
500
- code_verifier: pendingAuth.codeVerifier
501
- });
502
- const response = await fetch('https://oauth2.googleapis.com/token', {
503
- method: 'POST',
504
- headers: {
505
- 'Content-Type': 'application/x-www-form-urlencoded'
506
- },
507
- body: body.toString()
661
+ state,
662
+ redirectUri
508
663
  });
509
- if (!response.ok) {
510
- const errorText = await response.text();
511
- throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
512
- }
513
- const tokenResponse = await response.json();
514
- // Fetch user email
515
- const email = await this.fetchUserEmailFromToken(tokenResponse.access_token);
516
- // Create cached token
517
- const cachedToken = {
518
- accessToken: tokenResponse.access_token,
519
- refreshToken: tokenResponse.refresh_token,
520
- expiresAt: tokenResponse.expires_in ? Date.now() + tokenResponse.expires_in * 1000 : undefined,
521
- ...tokenResponse.scope !== undefined && {
522
- scope: tokenResponse.scope
523
- }
524
- };
525
- // Store token
526
- await setToken(tokenStore, {
527
- accountId: email,
528
- service
529
- }, cachedToken);
530
- // Add account and set as active
531
- await addAccount(tokenStore, {
532
- service,
533
- accountId: email
534
- });
535
- await setActiveAccount(tokenStore, {
536
- service,
537
- accountId: email
538
- });
539
- // Store account metadata
540
- await setAccountInfo(tokenStore, {
541
- service,
542
- accountId: email
543
- }, {
544
- email,
545
- addedAt: new Date().toISOString()
546
- });
547
- // Clean up pending auth
548
- await tokenStore.delete(pendingKey);
549
- logger.info('OAuth callback completed', {
550
- service,
551
- email
552
- });
553
- return {
554
- email,
555
- token: cachedToken
556
- };
557
664
  }
558
665
  /**
559
666
  * Create authentication middleware for MCP tools, resources, and prompts
@@ -569,15 +676,6 @@ import { AuthRequiredError } from '../types.js';
569
676
  * All requests use token lookups based on the active account or account override.
570
677
  *
571
678
  * @returns Object with withToolAuth, withResourceAuth, withPromptAuth methods
572
- *
573
- * @example
574
- * ```typescript
575
- * const loopback = new LoopbackOAuthProvider({ service: 'gmail', ... });
576
- * const authMiddleware = loopback.authMiddleware();
577
- * const tools = toolFactories.map(f => f()).map(authMiddleware.withToolAuth);
578
- * const resources = resourceFactories.map(f => f()).map(authMiddleware.withResourceAuth);
579
- * const prompts = promptFactories.map(f => f()).map(authMiddleware.withPromptAuth);
580
- * ```
581
679
  */ authMiddleware() {
582
680
  const { service, tokenStore, logger } = this.config;
583
681
  // Shared wrapper logic - extracts extra parameter from specified position
@@ -588,31 +686,60 @@ import { AuthRequiredError } from '../types.js';
588
686
  const wrappedHandler = async (...allArgs)=>{
589
687
  // Extract extra from the correct position
590
688
  const extra = allArgs[extraPosition];
591
- try {
592
- // Check for backchannel override via _meta.accountId
593
- let accountId;
689
+ // Helper: retry once after open+poll completes
690
+ const ensureAuthenticatedOrThrow = async ()=>{
594
691
  try {
595
- var _ref;
596
- var _extra__meta;
597
- accountId = (_ref = (_extra__meta = extra._meta) === null || _extra__meta === void 0 ? void 0 : _extra__meta.accountId) !== null && _ref !== void 0 ? _ref : await getActiveAccount(tokenStore, {
692
+ // Check for backchannel override via _meta.accountId
693
+ let accountId;
694
+ try {
695
+ var _ref;
696
+ var _extra__meta;
697
+ accountId = (_ref = (_extra__meta = extra._meta) === null || _extra__meta === void 0 ? void 0 : _extra__meta.accountId) !== null && _ref !== void 0 ? _ref : await getActiveAccount(tokenStore, {
698
+ service
699
+ });
700
+ } catch (error) {
701
+ if (error instanceof Error && (error.code === 'REQUIRES_AUTHENTICATION' || error.name === 'AccountManagerError')) {
702
+ accountId = undefined;
703
+ } else {
704
+ throw error;
705
+ }
706
+ }
707
+ await this.getAccessToken(accountId);
708
+ const effectiveAccountId = accountId !== null && accountId !== void 0 ? accountId : await getActiveAccount(tokenStore, {
598
709
  service
599
710
  });
711
+ if (!effectiveAccountId) {
712
+ throw new Error(`No account found after OAuth flow for service ${service}`);
713
+ }
714
+ return effectiveAccountId;
600
715
  } catch (error) {
601
- if (error instanceof Error && (error.code === 'REQUIRES_AUTHENTICATION' || error.name === 'AccountManagerError')) {
602
- accountId = undefined;
603
- } else {
604
- throw error;
716
+ if (error instanceof AuthRequiredError && error.descriptor.kind === 'auth_url') {
717
+ // Headless: don't open/poll; just propagate to outer handler to return auth_required.
718
+ if (this.config.headless) throw error;
719
+ // Non-headless: open once + poll until callback completes, then retry token acquisition.
720
+ const authUrl = new URL(error.descriptor.url);
721
+ const state = authUrl.searchParams.get('state');
722
+ if (!state) throw new Error('Auth URL missing state parameter');
723
+ if (!this.openedStates.has(state)) {
724
+ this.openedStates.add(state);
725
+ open(error.descriptor.url).catch((e)=>{
726
+ logger.info('Failed to open browser automatically', {
727
+ error: e instanceof Error ? e.message : String(e)
728
+ });
729
+ });
730
+ }
731
+ // Block until callback completes (or timeout)
732
+ await this.waitForOAuthCompletion(state);
733
+ // Cleanup pending state after we observe completion
734
+ await this.deletePendingAuth(state);
735
+ // Retry after completion
736
+ return await ensureAuthenticatedOrThrow();
605
737
  }
738
+ throw error;
606
739
  }
607
- // Eagerly validate token exists or trigger OAuth flow
608
- await this.getAccessToken(accountId);
609
- // After OAuth flow completes, get the actual accountId (email) that was set
610
- const effectiveAccountId = accountId !== null && accountId !== void 0 ? accountId : await getActiveAccount(tokenStore, {
611
- service
612
- });
613
- if (!effectiveAccountId) {
614
- throw new Error(`No account found after OAuth flow for service ${service}`);
615
- }
740
+ };
741
+ try {
742
+ const effectiveAccountId = await ensureAuthenticatedOrThrow();
616
743
  const auth = this.toAuth(effectiveAccountId);
617
744
  // Inject authContext and logger into extra
618
745
  extra.authContext = {
@@ -629,8 +756,6 @@ import { AuthRequiredError } from '../types.js';
629
756
  tool: operation,
630
757
  descriptor: error.descriptor
631
758
  });
632
- // Return auth_required response wrapped in { result } to match tool outputSchema pattern
633
- // Tools define outputSchema: z.object({ result: discriminatedUnion(...) }) where auth_required is a branch
634
759
  const authRequiredResponse = {
635
760
  type: 'auth_required',
636
761
  provider: service,
@@ -666,6 +791,8 @@ import { AuthRequiredError } from '../types.js';
666
791
  };
667
792
  }
668
793
  constructor(config){
794
+ // Track URLs we've already opened for a given state within this process (prevents tab spam).
795
+ this.openedStates = new Set();
669
796
  this.config = config;
670
797
  }
671
798
  }