@mcp-z/oauth-google 1.0.0 → 1.0.2

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.
Files changed (48) hide show
  1. package/README.md +8 -0
  2. package/dist/cjs/index.d.cts +2 -1
  3. package/dist/cjs/index.d.ts +2 -1
  4. package/dist/cjs/index.js +4 -0
  5. package/dist/cjs/index.js.map +1 -1
  6. package/dist/cjs/lib/dcr-router.js.map +1 -1
  7. package/dist/cjs/lib/dcr-utils.js.map +1 -1
  8. package/dist/cjs/lib/dcr-verify.js.map +1 -1
  9. package/dist/cjs/lib/fetch-with-timeout.js.map +1 -1
  10. package/dist/cjs/lib/loopback-router.d.cts +8 -0
  11. package/dist/cjs/lib/loopback-router.d.ts +8 -0
  12. package/dist/cjs/lib/loopback-router.js +219 -0
  13. package/dist/cjs/lib/loopback-router.js.map +1 -0
  14. package/dist/cjs/lib/token-verifier.js.map +1 -1
  15. package/dist/cjs/providers/dcr.js.map +1 -1
  16. package/dist/cjs/providers/loopback-oauth.d.cts +94 -27
  17. package/dist/cjs/providers/loopback-oauth.d.ts +94 -27
  18. package/dist/cjs/providers/loopback-oauth.js +868 -498
  19. package/dist/cjs/providers/loopback-oauth.js.map +1 -1
  20. package/dist/cjs/providers/service-account.js.map +1 -1
  21. package/dist/cjs/schemas/index.js.map +1 -1
  22. package/dist/cjs/setup/config.d.cts +6 -1
  23. package/dist/cjs/setup/config.d.ts +6 -1
  24. package/dist/cjs/setup/config.js +6 -3
  25. package/dist/cjs/setup/config.js.map +1 -1
  26. package/dist/cjs/types.js.map +1 -1
  27. package/dist/esm/index.d.ts +2 -1
  28. package/dist/esm/index.js +1 -0
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/lib/dcr-router.js.map +1 -1
  31. package/dist/esm/lib/dcr-utils.js.map +1 -1
  32. package/dist/esm/lib/dcr-verify.js.map +1 -1
  33. package/dist/esm/lib/fetch-with-timeout.js.map +1 -1
  34. package/dist/esm/lib/loopback-router.d.ts +8 -0
  35. package/dist/esm/lib/loopback-router.js +32 -0
  36. package/dist/esm/lib/loopback-router.js.map +1 -0
  37. package/dist/esm/lib/token-verifier.js.map +1 -1
  38. package/dist/esm/providers/dcr.js.map +1 -1
  39. package/dist/esm/providers/loopback-oauth.d.ts +94 -27
  40. package/dist/esm/providers/loopback-oauth.js +461 -296
  41. package/dist/esm/providers/loopback-oauth.js.map +1 -1
  42. package/dist/esm/providers/service-account.js.map +1 -1
  43. package/dist/esm/schemas/index.js.map +1 -1
  44. package/dist/esm/setup/config.d.ts +6 -1
  45. package/dist/esm/setup/config.js +7 -3
  46. package/dist/esm/setup/config.js.map +1 -1
  47. package/dist/esm/types.js.map +1 -1
  48. package/package.json +1 -1
@@ -3,11 +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
- */ import { addAccount, generatePKCE, getActiveAccount, getErrorTemplate, getSuccessTemplate, getToken, listAccountIds, setAccountInfo, setActiveAccount, setToken } from '@mcp-z/oauth';
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.
16
+ */ import { addAccount, generatePKCE, getActiveAccount, getErrorTemplate, getSuccessTemplate, getToken, setAccountInfo, setActiveAccount, setToken } from '@mcp-z/oauth';
17
+ import { randomUUID } from 'crypto';
7
18
  import { OAuth2Client } from 'google-auth-library';
8
19
  import * as http from 'http';
9
20
  import open from 'open';
10
21
  import { AuthRequiredError } from '../types.js';
22
+ const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
23
+ const OAUTH_POLL_MS = 500;
11
24
  /**
12
25
  * Loopback OAuth Client (RFC 8252 Section 7.3)
13
26
  *
@@ -66,77 +79,43 @@ import { AuthRequiredError } from '../types.js';
66
79
  }
67
80
  }
68
81
  }
69
- // No valid token or no account - check if we can start OAuth flow
70
- const { headless } = this.config;
71
- if (headless) {
72
- // In headless mode (production), cannot start OAuth flow
73
- // Throw AuthRequiredError with auth_url descriptor for MCP tool response
74
- const { clientId, scope } = this.config;
75
- // Incremental OAuth detection: Check if other accounts exist
76
- const existingAccounts = await this.getExistingAccounts();
77
- const hasOtherAccounts = effectiveAccountId ? existingAccounts.length > 0 && !existingAccounts.includes(effectiveAccountId) : existingAccounts.length > 0;
78
- // Build informational OAuth URL for headless mode
79
- // Note: No redirect_uri included - user must use account-add tool which starts proper ephemeral server
80
- const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
81
- authUrl.searchParams.set('client_id', clientId);
82
- authUrl.searchParams.set('response_type', 'code');
83
- authUrl.searchParams.set('scope', scope);
84
- authUrl.searchParams.set('access_type', 'offline');
85
- authUrl.searchParams.set('prompt', 'consent');
86
- let hint;
87
- if (hasOtherAccounts) {
88
- hint = `Existing ${service} accounts found. Use account-list to view, account-switch to change account, or account-add to add new account`;
89
- } else if (effectiveAccountId) {
90
- hint = `Use account-add to authenticate ${effectiveAccountId}`;
91
- } else {
92
- hint = 'Use account-add to authenticate interactively';
93
- }
94
- const baseDescriptor = {
82
+ // No valid token or no account - need OAuth authentication
83
+ const { clientId, scope, redirectUri } = this.config;
84
+ if (redirectUri) {
85
+ // Persistent callback mode (cloud deployment with configured redirect_uri)
86
+ const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
87
+ const stateId = randomUUID();
88
+ // Store PKCE verifier for callback (5 minute TTL)
89
+ await this.createPendingAuth({
90
+ state: stateId,
91
+ codeVerifier
92
+ });
93
+ // Build auth URL with configured redirect_uri
94
+ const authUrl = this.buildAuthUrl({
95
+ redirectUri,
96
+ codeChallenge,
97
+ state: stateId
98
+ });
99
+ logger.info('OAuth required - persistent callback mode', {
100
+ service,
101
+ redirectUri,
102
+ clientId,
103
+ scope
104
+ });
105
+ throw new AuthRequiredError({
95
106
  kind: 'auth_url',
96
- provider: 'google',
97
- url: authUrl.toString(),
98
- hint
99
- };
100
- const descriptor = effectiveAccountId ? {
101
- ...baseDescriptor,
102
- accountId: effectiveAccountId
103
- } : baseDescriptor;
104
- throw new AuthRequiredError(descriptor);
107
+ provider: service,
108
+ url: authUrl
109
+ });
105
110
  }
106
- // Interactive mode - start ephemeral OAuth flow
111
+ // Ephemeral callback mode (local development)
112
+ // IMPORTANT: do NOT open here anymore; we throw auth_url and the middleware will open+poll.
107
113
  logger.info('Starting ephemeral OAuth flow', {
108
114
  service,
109
- headless
110
- });
111
- const { token, email } = await this.performEphemeralOAuthFlow();
112
- // Store token with email as accountId
113
- await setToken(tokenStore, {
114
- accountId: email,
115
- service
116
- }, token);
117
- // Register account in account management system
118
- await addAccount(tokenStore, {
119
- service,
120
- accountId: email
121
- });
122
- // Set as active account so subsequent getAccessToken() calls find it
123
- await setActiveAccount(tokenStore, {
124
- service,
125
- accountId: email
126
- });
127
- // Store account metadata (email, added timestamp)
128
- await setAccountInfo(tokenStore, {
129
- service,
130
- accountId: email
131
- }, {
132
- email,
133
- addedAt: new Date().toISOString()
115
+ headless: this.config.headless
134
116
  });
135
- logger.info('OAuth flow completed', {
136
- service,
137
- accountId: email
138
- });
139
- return token.accessToken;
117
+ const descriptor = await this.startEphemeralOAuthFlow();
118
+ throw new AuthRequiredError(descriptor);
140
119
  }
141
120
  /**
142
121
  * Convert to googleapis-compatible OAuth2Client
@@ -170,51 +149,6 @@ import { AuthRequiredError } from '../types.js';
170
149
  return client;
171
150
  }
172
151
  /**
173
- * Authenticate new account with OAuth flow
174
- * Triggers account selection, stores token, registers account
175
- *
176
- * @returns Email address of newly authenticated account
177
- * @throws Error in headless mode (cannot open browser for OAuth)
178
- */ async authenticateNewAccount() {
179
- const { logger, headless, service, tokenStore } = this.config;
180
- if (headless) {
181
- throw new Error('Cannot authenticate new account in headless mode - interactive OAuth required');
182
- }
183
- logger.info('Starting new account authentication', {
184
- service
185
- });
186
- // Trigger OAuth with account selection
187
- const { token, email } = await this.performEphemeralOAuthFlow();
188
- // Store token
189
- await setToken(tokenStore, {
190
- accountId: email,
191
- service
192
- }, token);
193
- // Register account
194
- await addAccount(tokenStore, {
195
- service,
196
- accountId: email
197
- });
198
- // Set as active account
199
- await setActiveAccount(tokenStore, {
200
- service,
201
- accountId: email
202
- });
203
- // Store account metadata
204
- await setAccountInfo(tokenStore, {
205
- service,
206
- accountId: email
207
- }, {
208
- email,
209
- addedAt: new Date().toISOString()
210
- });
211
- logger.info('New account authenticated', {
212
- service,
213
- email
214
- });
215
- return email;
216
- }
217
- /**
218
152
  * Get user email from Google's userinfo endpoint (pure query)
219
153
  * Used to query email for existing authenticated account
220
154
  *
@@ -235,16 +169,6 @@ import { AuthRequiredError } from '../types.js';
235
169
  const userInfo = await response.json();
236
170
  return userInfo.email;
237
171
  }
238
- /**
239
- * Check for existing accounts in token storage (incremental OAuth detection)
240
- *
241
- * Uses key-utils helper for forward compatibility with key format changes.
242
- *
243
- * @returns Array of account IDs that have tokens for this service
244
- */ async getExistingAccounts() {
245
- const { service, tokenStore } = this.config;
246
- return listAccountIds(tokenStore, service);
247
- }
248
172
  isTokenValid(token) {
249
173
  if (!token.expiresAt) return true; // No expiry = assume valid
250
174
  return Date.now() < token.expiresAt - 60000; // 1 minute buffer
@@ -273,132 +197,345 @@ import { AuthRequiredError } from '../types.js';
273
197
  });
274
198
  return email;
275
199
  }
276
- async performEphemeralOAuthFlow() {
277
- const { clientId, scope, headless, logger, redirectUri: configRedirectUri } = this.config;
278
- // Parse redirectUri if provided to extract host, protocol, port, and path
279
- let targetHost = 'localhost'; // Default: localhost (match registered redirect URI)
280
- let targetPort = 0; // Default: OS-assigned ephemeral port
281
- let targetProtocol = 'http:'; // Default: http
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;
469
+ // Server listen configuration (where ephemeral server binds)
470
+ let listenHost = 'localhost'; // Default: localhost for ephemeral loopback
471
+ let listenPort = 0; // Default: OS-assigned ephemeral port
472
+ // Redirect URI configuration (what goes in auth URL and token exchange)
282
473
  let callbackPath = '/callback'; // Default callback path
283
474
  let useConfiguredUri = false;
284
475
  if (configRedirectUri) {
285
476
  try {
286
477
  const parsed = new URL(configRedirectUri);
287
- // Use configured redirect URI as-is for production deployments
288
- targetHost = parsed.hostname;
289
- targetProtocol = parsed.protocol;
290
- // Extract port from URL (use default ports if not specified)
291
- if (parsed.port) {
292
- targetPort = Number.parseInt(parsed.port, 10);
478
+ const isLoopback = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
479
+ if (isLoopback) {
480
+ // Local development: Listen on specific loopback address/port
481
+ listenHost = parsed.hostname;
482
+ listenPort = parsed.port ? Number.parseInt(parsed.port, 10) : 0;
293
483
  } else {
294
- targetPort = parsed.protocol === 'https:' ? 443 : 80;
484
+ // Cloud deployment: Listen on 0.0.0.0 with PORT from environment
485
+ // The redirectUri is the PUBLIC URL (e.g., https://example.com/oauth/callback)
486
+ // The server listens on 0.0.0.0:PORT and the load balancer routes to it
487
+ listenHost = '0.0.0.0';
488
+ const envPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : undefined;
489
+ listenPort = envPort && Number.isFinite(envPort) ? envPort : 8080;
295
490
  }
296
- // Extract path (default to /callback if URL has no path or just '/')
491
+ // Extract callback path from URL
297
492
  if (parsed.pathname && parsed.pathname !== '/') {
298
493
  callbackPath = parsed.pathname;
299
494
  }
300
495
  useConfiguredUri = true;
301
496
  logger.debug('Using configured redirect URI', {
302
- host: targetHost,
303
- protocol: targetProtocol,
304
- port: targetPort,
305
- path: callbackPath,
306
- redirectUri: configRedirectUri
497
+ listenHost,
498
+ listenPort,
499
+ callbackPath,
500
+ redirectUri: configRedirectUri,
501
+ isLoopback
307
502
  });
308
503
  } catch (error) {
309
504
  logger.warn('Failed to parse redirectUri, using ephemeral defaults', {
310
505
  redirectUri: configRedirectUri,
311
506
  error: error instanceof Error ? error.message : String(error)
312
507
  });
313
- // Continue with defaults (127.0.0.1, port 0, http, /callback)
508
+ // Continue with defaults (localhost, port 0, http, /callback)
314
509
  }
315
510
  }
316
- return new Promise((resolve, reject)=>{
317
- // Generate PKCE challenge
318
- const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
319
- let server = null;
320
- let serverPort;
321
- let finalRedirectUri; // Will be set in server.listen callback
322
- // Create ephemeral server with OS-assigned port (RFC 8252)
323
- server = http.createServer(async (req, res)=>{
324
- if (!req.url) {
325
- res.writeHead(400, {
326
- 'Content-Type': 'text/html'
327
- });
328
- res.end(getErrorTemplate('Invalid request'));
329
- server === null || server === void 0 ? void 0 : server.close();
330
- reject(new Error('Invalid request: missing URL'));
331
- return;
332
- }
333
- const url = new URL(req.url, `http://127.0.0.1:${serverPort}`);
334
- if (url.pathname === callbackPath) {
335
- const code = url.searchParams.get('code');
336
- const error = url.searchParams.get('error');
337
- if (error) {
338
- res.writeHead(400, {
339
- 'Content-Type': 'text/html'
340
- });
341
- res.end(getErrorTemplate(error));
342
- server === null || server === void 0 ? void 0 : server.close();
343
- reject(new Error(`OAuth error: ${error}`));
344
- return;
345
- }
346
- if (!code) {
347
- res.writeHead(400, {
348
- 'Content-Type': 'text/html'
349
- });
350
- res.end(getErrorTemplate('No authorization code received'));
351
- server === null || server === void 0 ? void 0 : server.close();
352
- reject(new Error('No authorization code received'));
353
- return;
354
- }
355
- try {
356
- // Exchange code for token (must use same redirect_uri as in authorization request)
357
- const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, finalRedirectUri);
358
- // Build cached token
359
- const cachedToken = {
360
- accessToken: tokenResponse.access_token,
361
- ...tokenResponse.refresh_token !== undefined && {
362
- refreshToken: tokenResponse.refresh_token
363
- },
364
- ...tokenResponse.expires_in !== undefined && {
365
- expiresAt: Date.now() + tokenResponse.expires_in * 1000
366
- },
367
- ...tokenResponse.scope !== undefined && {
368
- scope: tokenResponse.scope
369
- }
370
- };
371
- // Fetch user email immediately using the new access token
372
- const email = await this.fetchUserEmailFromToken(tokenResponse.access_token);
373
- res.writeHead(200, {
374
- 'Content-Type': 'text/html'
375
- });
376
- res.end(getSuccessTemplate());
377
- server === null || server === void 0 ? void 0 : server.close();
378
- resolve({
379
- token: cachedToken,
380
- email
381
- });
382
- } catch (exchangeError) {
383
- logger.error('Token exchange failed', {
384
- error: exchangeError instanceof Error ? exchangeError.message : String(exchangeError)
385
- });
386
- res.writeHead(500, {
387
- 'Content-Type': 'text/html'
388
- });
389
- res.end(getErrorTemplate('Token exchange failed'));
390
- server === null || server === void 0 ? void 0 : server.close();
391
- reject(exchangeError);
392
- }
393
- } else {
394
- res.writeHead(404, {
395
- 'Content-Type': 'text/plain'
396
- });
397
- res.end('Not Found');
398
- }
399
- });
400
- // Listen on targetPort (0 for OS assignment, or custom port from redirectUri)
401
- server.listen(targetPort, targetHost, ()=>{
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, ()=>{
402
539
  const address = server === null || server === void 0 ? void 0 : server.address();
403
540
  if (!address || typeof address === 'string') {
404
541
  server === null || server === void 0 ? void 0 : server.close();
@@ -408,52 +545,38 @@ import { AuthRequiredError } from '../types.js';
408
545
  serverPort = address.port;
409
546
  // Construct final redirect URI
410
547
  if (useConfiguredUri && configRedirectUri) {
411
- // Use configured redirect URI as-is for production
412
548
  finalRedirectUri = configRedirectUri;
413
549
  } else {
414
- // Construct ephemeral redirect URI with actual server port
415
- finalRedirectUri = `${targetProtocol}//${targetHost}:${serverPort}${callbackPath}`;
550
+ finalRedirectUri = `http://localhost:${serverPort}${callbackPath}`;
416
551
  }
417
- // Build auth URL
418
- const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
419
- authUrl.searchParams.set('client_id', clientId);
420
- authUrl.searchParams.set('redirect_uri', finalRedirectUri);
421
- authUrl.searchParams.set('response_type', 'code');
422
- authUrl.searchParams.set('scope', scope);
423
- authUrl.searchParams.set('access_type', 'offline');
424
- authUrl.searchParams.set('prompt', 'consent');
425
- authUrl.searchParams.set('code_challenge', codeChallenge);
426
- authUrl.searchParams.set('code_challenge_method', 'S256');
427
552
  logger.info('Ephemeral OAuth server started', {
428
553
  port: serverPort,
429
- headless
554
+ headless,
555
+ service
430
556
  });
431
- if (headless) {
432
- // Headless mode: Print auth URL to stderr (stdout is MCP protocol)
433
- console.error('\n🔐 OAuth Authorization Required');
434
- console.error('📋 Please visit this URL in your browser:\n');
435
- console.error(` ${authUrl.toString()}\n`);
436
- console.error('⏳ Waiting for authorization...\n');
437
- } else {
438
- // Interactive mode: Open browser automatically
439
- logger.info('Opening browser for OAuth authorization');
440
- open(authUrl.toString()).catch((error)=>{
441
- logger.info('Failed to open browser automatically', {
442
- error: error.message
443
- });
444
- console.error('\n🔐 OAuth Authorization Required');
445
- console.error(` ${authUrl.toString()}\n`);
446
- });
447
- }
557
+ resolve();
448
558
  });
449
- // Timeout after 5 minutes
450
- setTimeout(()=>{
451
- if (server) {
452
- server.close();
453
- reject(new Error('OAuth flow timed out after 5 minutes'));
454
- }
455
- }, 5 * 60 * 1000);
456
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
+ };
457
580
  }
458
581
  async exchangeCodeForToken(code, codeVerifier, redirectUri) {
459
582
  const { clientId, clientSecret } = this.config;
@@ -518,6 +641,28 @@ import { AuthRequiredError } from '../types.js';
518
641
  };
519
642
  }
520
643
  /**
644
+ * Handle OAuth callback from persistent endpoint.
645
+ * Used by HTTP servers with configured redirectUri.
646
+ *
647
+ * @param params - OAuth callback parameters
648
+ * @returns Email and cached token
649
+ */ async handleOAuthCallback(params) {
650
+ const { code, state } = params;
651
+ const { redirectUri } = this.config;
652
+ if (!state) {
653
+ throw new Error('Missing state parameter in OAuth callback');
654
+ }
655
+ if (!redirectUri) {
656
+ throw new Error('handleOAuthCallback requires configured redirectUri');
657
+ }
658
+ // Shared callback processor (same code path as ephemeral)
659
+ return await this.processOAuthCallback({
660
+ code,
661
+ state,
662
+ redirectUri
663
+ });
664
+ }
665
+ /**
521
666
  * Create authentication middleware for MCP tools, resources, and prompts
522
667
  *
523
668
  * Returns position-aware middleware wrappers that enrich handlers with authentication context.
@@ -531,15 +676,6 @@ import { AuthRequiredError } from '../types.js';
531
676
  * All requests use token lookups based on the active account or account override.
532
677
  *
533
678
  * @returns Object with withToolAuth, withResourceAuth, withPromptAuth methods
534
- *
535
- * @example
536
- * ```typescript
537
- * const loopback = new LoopbackOAuthProvider({ service: 'gmail', ... });
538
- * const authMiddleware = loopback.authMiddleware();
539
- * const tools = toolFactories.map(f => f()).map(authMiddleware.withToolAuth);
540
- * const resources = resourceFactories.map(f => f()).map(authMiddleware.withResourceAuth);
541
- * const prompts = promptFactories.map(f => f()).map(authMiddleware.withPromptAuth);
542
- * ```
543
679
  */ authMiddleware() {
544
680
  const { service, tokenStore, logger } = this.config;
545
681
  // Shared wrapper logic - extracts extra parameter from specified position
@@ -550,31 +686,60 @@ import { AuthRequiredError } from '../types.js';
550
686
  const wrappedHandler = async (...allArgs)=>{
551
687
  // Extract extra from the correct position
552
688
  const extra = allArgs[extraPosition];
553
- try {
554
- // Check for backchannel override via _meta.accountId
555
- let accountId;
689
+ // Helper: retry once after open+poll completes
690
+ const ensureAuthenticatedOrThrow = async ()=>{
556
691
  try {
557
- var _ref;
558
- var _extra__meta;
559
- 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, {
560
709
  service
561
710
  });
711
+ if (!effectiveAccountId) {
712
+ throw new Error(`No account found after OAuth flow for service ${service}`);
713
+ }
714
+ return effectiveAccountId;
562
715
  } catch (error) {
563
- if (error instanceof Error && (error.code === 'REQUIRES_AUTHENTICATION' || error.name === 'AccountManagerError')) {
564
- accountId = undefined;
565
- } else {
566
- 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();
567
737
  }
738
+ throw error;
568
739
  }
569
- // Eagerly validate token exists or trigger OAuth flow
570
- await this.getAccessToken(accountId);
571
- // After OAuth flow completes, get the actual accountId (email) that was set
572
- const effectiveAccountId = accountId !== null && accountId !== void 0 ? accountId : await getActiveAccount(tokenStore, {
573
- service
574
- });
575
- if (!effectiveAccountId) {
576
- throw new Error(`No account found after OAuth flow for service ${service}`);
577
- }
740
+ };
741
+ try {
742
+ const effectiveAccountId = await ensureAuthenticatedOrThrow();
578
743
  const auth = this.toAuth(effectiveAccountId);
579
744
  // Inject authContext and logger into extra
580
745
  extra.authContext = {
@@ -591,8 +756,6 @@ import { AuthRequiredError } from '../types.js';
591
756
  tool: operation,
592
757
  descriptor: error.descriptor
593
758
  });
594
- // Return auth_required response wrapped in { result } to match tool outputSchema pattern
595
- // Tools define outputSchema: z.object({ result: discriminatedUnion(...) }) where auth_required is a branch
596
759
  const authRequiredResponse = {
597
760
  type: 'auth_required',
598
761
  provider: service,
@@ -628,6 +791,8 @@ import { AuthRequiredError } from '../types.js';
628
791
  };
629
792
  }
630
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();
631
796
  this.config = config;
632
797
  }
633
798
  }