@mcp-z/oauth-google 1.0.0

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/cjs/index.d.cts +16 -0
  4. package/dist/cjs/index.d.ts +16 -0
  5. package/dist/cjs/index.js +112 -0
  6. package/dist/cjs/index.js.map +1 -0
  7. package/dist/cjs/lib/dcr-router.d.cts +44 -0
  8. package/dist/cjs/lib/dcr-router.d.ts +44 -0
  9. package/dist/cjs/lib/dcr-router.js +1189 -0
  10. package/dist/cjs/lib/dcr-router.js.map +1 -0
  11. package/dist/cjs/lib/dcr-utils.d.cts +160 -0
  12. package/dist/cjs/lib/dcr-utils.d.ts +160 -0
  13. package/dist/cjs/lib/dcr-utils.js +860 -0
  14. package/dist/cjs/lib/dcr-utils.js.map +1 -0
  15. package/dist/cjs/lib/dcr-verify.d.cts +53 -0
  16. package/dist/cjs/lib/dcr-verify.d.ts +53 -0
  17. package/dist/cjs/lib/dcr-verify.js +193 -0
  18. package/dist/cjs/lib/dcr-verify.js.map +1 -0
  19. package/dist/cjs/lib/fetch-with-timeout.d.cts +14 -0
  20. package/dist/cjs/lib/fetch-with-timeout.d.ts +14 -0
  21. package/dist/cjs/lib/fetch-with-timeout.js +257 -0
  22. package/dist/cjs/lib/fetch-with-timeout.js.map +1 -0
  23. package/dist/cjs/lib/token-verifier.d.cts +44 -0
  24. package/dist/cjs/lib/token-verifier.d.ts +44 -0
  25. package/dist/cjs/lib/token-verifier.js +253 -0
  26. package/dist/cjs/lib/token-verifier.js.map +1 -0
  27. package/dist/cjs/package.json +1 -0
  28. package/dist/cjs/providers/dcr.d.cts +107 -0
  29. package/dist/cjs/providers/dcr.d.ts +107 -0
  30. package/dist/cjs/providers/dcr.js +584 -0
  31. package/dist/cjs/providers/dcr.js.map +1 -0
  32. package/dist/cjs/providers/loopback-oauth.d.cts +119 -0
  33. package/dist/cjs/providers/loopback-oauth.d.ts +119 -0
  34. package/dist/cjs/providers/loopback-oauth.js +1334 -0
  35. package/dist/cjs/providers/loopback-oauth.js.map +1 -0
  36. package/dist/cjs/providers/service-account.d.cts +131 -0
  37. package/dist/cjs/providers/service-account.d.ts +131 -0
  38. package/dist/cjs/providers/service-account.js +800 -0
  39. package/dist/cjs/providers/service-account.js.map +1 -0
  40. package/dist/cjs/schemas/index.d.cts +20 -0
  41. package/dist/cjs/schemas/index.d.ts +20 -0
  42. package/dist/cjs/schemas/index.js +37 -0
  43. package/dist/cjs/schemas/index.js.map +1 -0
  44. package/dist/cjs/setup/config.d.cts +112 -0
  45. package/dist/cjs/setup/config.d.ts +112 -0
  46. package/dist/cjs/setup/config.js +236 -0
  47. package/dist/cjs/setup/config.js.map +1 -0
  48. package/dist/cjs/types.d.cts +173 -0
  49. package/dist/cjs/types.d.ts +173 -0
  50. package/dist/cjs/types.js +16 -0
  51. package/dist/cjs/types.js.map +1 -0
  52. package/dist/esm/index.d.ts +16 -0
  53. package/dist/esm/index.js +16 -0
  54. package/dist/esm/index.js.map +1 -0
  55. package/dist/esm/lib/dcr-router.d.ts +44 -0
  56. package/dist/esm/lib/dcr-router.js +515 -0
  57. package/dist/esm/lib/dcr-router.js.map +1 -0
  58. package/dist/esm/lib/dcr-utils.d.ts +160 -0
  59. package/dist/esm/lib/dcr-utils.js +270 -0
  60. package/dist/esm/lib/dcr-utils.js.map +1 -0
  61. package/dist/esm/lib/dcr-verify.d.ts +53 -0
  62. package/dist/esm/lib/dcr-verify.js +53 -0
  63. package/dist/esm/lib/dcr-verify.js.map +1 -0
  64. package/dist/esm/lib/fetch-with-timeout.d.ts +14 -0
  65. package/dist/esm/lib/fetch-with-timeout.js +30 -0
  66. package/dist/esm/lib/fetch-with-timeout.js.map +1 -0
  67. package/dist/esm/lib/token-verifier.d.ts +44 -0
  68. package/dist/esm/lib/token-verifier.js +53 -0
  69. package/dist/esm/lib/token-verifier.js.map +1 -0
  70. package/dist/esm/package.json +1 -0
  71. package/dist/esm/providers/dcr.d.ts +107 -0
  72. package/dist/esm/providers/dcr.js +242 -0
  73. package/dist/esm/providers/dcr.js.map +1 -0
  74. package/dist/esm/providers/loopback-oauth.d.ts +119 -0
  75. package/dist/esm/providers/loopback-oauth.js +639 -0
  76. package/dist/esm/providers/loopback-oauth.js.map +1 -0
  77. package/dist/esm/providers/service-account.d.ts +131 -0
  78. package/dist/esm/providers/service-account.js +353 -0
  79. package/dist/esm/providers/service-account.js.map +1 -0
  80. package/dist/esm/schemas/index.d.ts +20 -0
  81. package/dist/esm/schemas/index.js +18 -0
  82. package/dist/esm/schemas/index.js.map +1 -0
  83. package/dist/esm/setup/config.d.ts +112 -0
  84. package/dist/esm/setup/config.js +258 -0
  85. package/dist/esm/setup/config.js.map +1 -0
  86. package/dist/esm/types.d.ts +173 -0
  87. package/dist/esm/types.js +6 -0
  88. package/dist/esm/types.js.map +1 -0
  89. package/package.json +89 -0
@@ -0,0 +1,639 @@
1
+ /**
2
+ * Loopback OAuth Implementation (RFC 8252)
3
+ *
4
+ * Implements OAuth 2.0 Authorization Code Flow with PKCE using loopback interface redirection.
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';
7
+ import { OAuth2Client } from 'google-auth-library';
8
+ import * as http from 'http';
9
+ import open from 'open';
10
+ import { AuthRequiredError } from '../types.js';
11
+ /**
12
+ * Loopback OAuth Client (RFC 8252 Section 7.3)
13
+ *
14
+ * Implements OAuth 2.0 Authorization Code Flow with PKCE for native applications
15
+ * using loopback interface redirection. Manages ephemeral OAuth flows and token persistence
16
+ * with Keyv for key-based token storage using compound keys.
17
+ *
18
+ * Token key format: {accountId}:{service}:token (e.g., "user@example.com:gmail:token")
19
+ */ export class LoopbackOAuthProvider {
20
+ /**
21
+ * Get access token from Keyv using compound key
22
+ *
23
+ * @param accountId - Account identifier (email address). Required for loopback OAuth.
24
+ * @returns Access token for API requests
25
+ */ async getAccessToken(accountId) {
26
+ const { logger, service, tokenStore } = this.config;
27
+ // Use active account if no accountId specified
28
+ const effectiveAccountId = accountId !== null && accountId !== void 0 ? accountId : await getActiveAccount(tokenStore, {
29
+ service
30
+ });
31
+ // If we have an accountId, try to use existing token
32
+ if (effectiveAccountId) {
33
+ logger.debug('Getting access token', {
34
+ service,
35
+ accountId: effectiveAccountId
36
+ });
37
+ // Check Keyv for token using new key format
38
+ const storedToken = await getToken(tokenStore, {
39
+ accountId: effectiveAccountId,
40
+ service
41
+ });
42
+ if (storedToken && this.isTokenValid(storedToken)) {
43
+ logger.debug('Using stored access token', {
44
+ accountId: effectiveAccountId
45
+ });
46
+ return storedToken.accessToken;
47
+ }
48
+ // If stored token expired but has refresh token, try refresh
49
+ if (storedToken === null || storedToken === void 0 ? void 0 : storedToken.refreshToken) {
50
+ try {
51
+ logger.info('Refreshing expired access token', {
52
+ accountId: effectiveAccountId
53
+ });
54
+ const refreshedToken = await this.refreshAccessToken(storedToken.refreshToken);
55
+ await setToken(tokenStore, {
56
+ accountId: effectiveAccountId,
57
+ service
58
+ }, refreshedToken);
59
+ return refreshedToken.accessToken;
60
+ } catch (error) {
61
+ logger.info('Token refresh failed, starting new OAuth flow', {
62
+ accountId: effectiveAccountId,
63
+ error: error instanceof Error ? error.message : String(error)
64
+ });
65
+ // Fall through to new OAuth flow
66
+ }
67
+ }
68
+ }
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 = {
95
+ 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);
105
+ }
106
+ // Interactive mode - start ephemeral OAuth flow
107
+ logger.info('Starting ephemeral OAuth flow', {
108
+ 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()
134
+ });
135
+ logger.info('OAuth flow completed', {
136
+ service,
137
+ accountId: email
138
+ });
139
+ return token.accessToken;
140
+ }
141
+ /**
142
+ * Convert to googleapis-compatible OAuth2Client
143
+ *
144
+ * @param accountId - Account identifier for multi-account support (e.g., 'user@example.com')
145
+ * @returns OAuth2Client configured for the specified account
146
+ */ toAuth(accountId) {
147
+ const { clientId, clientSecret } = this.config;
148
+ const client = new OAuth2Client({
149
+ clientId,
150
+ ...clientSecret && {
151
+ clientSecret
152
+ }
153
+ });
154
+ // @ts-expect-error - Override protected method to inject fresh token
155
+ client.getRequestMetadataAsync = async (_url)=>{
156
+ // Get token from FileAuthAdapter (not from client to avoid recursion)
157
+ const token = await this.getAccessToken(accountId);
158
+ // Update client credentials for googleapis compatibility
159
+ client.credentials = {
160
+ access_token: token,
161
+ token_type: 'Bearer'
162
+ };
163
+ // Return headers as Map (required by authclient.js addUserProjectAndAuthHeaders)
164
+ const headers = new Map();
165
+ headers.set('authorization', `Bearer ${token}`);
166
+ return {
167
+ headers
168
+ };
169
+ };
170
+ return client;
171
+ }
172
+ /**
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
+ * Get user email from Google's userinfo endpoint (pure query)
219
+ * Used to query email for existing authenticated account
220
+ *
221
+ * @param accountId - Account identifier to get email for
222
+ * @returns User's email address
223
+ */ async getUserEmail(accountId) {
224
+ // Get token for existing account
225
+ const token = await this.getAccessToken(accountId);
226
+ // Fetch email from Google userinfo
227
+ const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
228
+ headers: {
229
+ Authorization: `Bearer ${token}`
230
+ }
231
+ });
232
+ if (!response.ok) {
233
+ throw new Error(`Failed to get user info: ${response.status} ${await response.text()}`);
234
+ }
235
+ const userInfo = await response.json();
236
+ return userInfo.email;
237
+ }
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
+ isTokenValid(token) {
249
+ if (!token.expiresAt) return true; // No expiry = assume valid
250
+ return Date.now() < token.expiresAt - 60000; // 1 minute buffer
251
+ }
252
+ /**
253
+ * Fetch user email from Google OAuth2 userinfo endpoint
254
+ * Called during OAuth flow to get email for accountId
255
+ *
256
+ * @param accessToken - Fresh access token from OAuth exchange
257
+ * @returns User's email address
258
+ */ async fetchUserEmailFromToken(accessToken) {
259
+ const { logger } = this.config;
260
+ const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
261
+ headers: {
262
+ Authorization: `Bearer ${accessToken}`
263
+ }
264
+ });
265
+ if (!response.ok) {
266
+ const errorText = await response.text();
267
+ throw new Error(`Failed to fetch user email: HTTP ${response.status} - ${errorText}`);
268
+ }
269
+ const userInfo = await response.json();
270
+ const email = userInfo.email;
271
+ logger.debug('Fetched user email from Google userinfo API', {
272
+ email
273
+ });
274
+ return email;
275
+ }
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
282
+ let callbackPath = '/callback'; // Default callback path
283
+ let useConfiguredUri = false;
284
+ if (configRedirectUri) {
285
+ try {
286
+ 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);
293
+ } else {
294
+ targetPort = parsed.protocol === 'https:' ? 443 : 80;
295
+ }
296
+ // Extract path (default to /callback if URL has no path or just '/')
297
+ if (parsed.pathname && parsed.pathname !== '/') {
298
+ callbackPath = parsed.pathname;
299
+ }
300
+ useConfiguredUri = true;
301
+ logger.debug('Using configured redirect URI', {
302
+ host: targetHost,
303
+ protocol: targetProtocol,
304
+ port: targetPort,
305
+ path: callbackPath,
306
+ redirectUri: configRedirectUri
307
+ });
308
+ } catch (error) {
309
+ logger.warn('Failed to parse redirectUri, using ephemeral defaults', {
310
+ redirectUri: configRedirectUri,
311
+ error: error instanceof Error ? error.message : String(error)
312
+ });
313
+ // Continue with defaults (127.0.0.1, port 0, http, /callback)
314
+ }
315
+ }
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, ()=>{
402
+ const address = server === null || server === void 0 ? void 0 : server.address();
403
+ if (!address || typeof address === 'string') {
404
+ server === null || server === void 0 ? void 0 : server.close();
405
+ reject(new Error('Failed to start ephemeral server'));
406
+ return;
407
+ }
408
+ serverPort = address.port;
409
+ // Construct final redirect URI
410
+ if (useConfiguredUri && configRedirectUri) {
411
+ // Use configured redirect URI as-is for production
412
+ finalRedirectUri = configRedirectUri;
413
+ } else {
414
+ // Construct ephemeral redirect URI with actual server port
415
+ finalRedirectUri = `${targetProtocol}//${targetHost}:${serverPort}${callbackPath}`;
416
+ }
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
+ logger.info('Ephemeral OAuth server started', {
428
+ port: serverPort,
429
+ headless
430
+ });
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
+ }
448
+ });
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
+ });
457
+ }
458
+ async exchangeCodeForToken(code, codeVerifier, redirectUri) {
459
+ const { clientId, clientSecret } = this.config;
460
+ const tokenUrl = 'https://oauth2.googleapis.com/token';
461
+ const params = {
462
+ code,
463
+ client_id: clientId,
464
+ redirect_uri: redirectUri,
465
+ grant_type: 'authorization_code',
466
+ code_verifier: codeVerifier
467
+ };
468
+ if (clientSecret) {
469
+ params.client_secret = clientSecret;
470
+ }
471
+ const body = new URLSearchParams(params);
472
+ const response = await fetch(tokenUrl, {
473
+ method: 'POST',
474
+ headers: {
475
+ 'Content-Type': 'application/x-www-form-urlencoded'
476
+ },
477
+ body: body.toString()
478
+ });
479
+ if (!response.ok) {
480
+ const errorText = await response.text();
481
+ throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
482
+ }
483
+ return await response.json();
484
+ }
485
+ async refreshAccessToken(refreshToken) {
486
+ const { clientId, clientSecret } = this.config;
487
+ const tokenUrl = 'https://oauth2.googleapis.com/token';
488
+ const params = {
489
+ refresh_token: refreshToken,
490
+ client_id: clientId,
491
+ grant_type: 'refresh_token'
492
+ };
493
+ if (clientSecret) {
494
+ params.client_secret = clientSecret;
495
+ }
496
+ const body = new URLSearchParams(params);
497
+ const response = await fetch(tokenUrl, {
498
+ method: 'POST',
499
+ headers: {
500
+ 'Content-Type': 'application/x-www-form-urlencoded'
501
+ },
502
+ body: body.toString()
503
+ });
504
+ if (!response.ok) {
505
+ const errorText = await response.text();
506
+ throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
507
+ }
508
+ const tokenResponse = await response.json();
509
+ return {
510
+ accessToken: tokenResponse.access_token,
511
+ refreshToken: refreshToken,
512
+ ...tokenResponse.expires_in !== undefined && {
513
+ expiresAt: Date.now() + tokenResponse.expires_in * 1000
514
+ },
515
+ ...tokenResponse.scope !== undefined && {
516
+ scope: tokenResponse.scope
517
+ }
518
+ };
519
+ }
520
+ /**
521
+ * Create authentication middleware for MCP tools, resources, and prompts
522
+ *
523
+ * Returns position-aware middleware wrappers that enrich handlers with authentication context.
524
+ * The middleware handles token retrieval, refresh, and AuthRequiredError automatically.
525
+ *
526
+ * Single-user middleware for desktop/CLI apps where ONE user runs the entire process:
527
+ * - Desktop applications (Claude Desktop)
528
+ * - CLI tools (Gmail CLI)
529
+ * - Personal automation scripts
530
+ *
531
+ * All requests use token lookups based on the active account or account override.
532
+ *
533
+ * @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
+ */ authMiddleware() {
544
+ const { service, tokenStore, logger } = this.config;
545
+ // Shared wrapper logic - extracts extra parameter from specified position
546
+ // Generic T captures the actual module type; handler is cast from unknown to callable
547
+ const wrapAtPosition = (module, extraPosition)=>{
548
+ const operation = module.name;
549
+ const originalHandler = module.handler;
550
+ const wrappedHandler = async (...allArgs)=>{
551
+ // Extract extra from the correct position
552
+ const extra = allArgs[extraPosition];
553
+ try {
554
+ // Check for backchannel override via _meta.accountId
555
+ let accountId;
556
+ 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, {
560
+ service
561
+ });
562
+ } catch (error) {
563
+ if (error instanceof Error && (error.code === 'REQUIRES_AUTHENTICATION' || error.name === 'AccountManagerError')) {
564
+ accountId = undefined;
565
+ } else {
566
+ throw error;
567
+ }
568
+ }
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
+ }
578
+ const auth = this.toAuth(effectiveAccountId);
579
+ // Inject authContext and logger into extra
580
+ extra.authContext = {
581
+ auth,
582
+ accountId: effectiveAccountId
583
+ };
584
+ extra.logger = logger;
585
+ // Call original handler with all args
586
+ return await originalHandler(...allArgs);
587
+ } catch (error) {
588
+ if (error instanceof AuthRequiredError) {
589
+ logger.info('Authentication required', {
590
+ service,
591
+ tool: operation,
592
+ descriptor: error.descriptor
593
+ });
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
+ const authRequiredResponse = {
597
+ type: 'auth_required',
598
+ provider: service,
599
+ message: `Authentication required for ${operation}. Please authenticate with ${service}.`,
600
+ url: error.descriptor.kind === 'auth_url' ? error.descriptor.url : undefined
601
+ };
602
+ return {
603
+ content: [
604
+ {
605
+ type: 'text',
606
+ text: JSON.stringify({
607
+ result: authRequiredResponse
608
+ })
609
+ }
610
+ ],
611
+ structuredContent: {
612
+ result: authRequiredResponse
613
+ }
614
+ };
615
+ }
616
+ throw error;
617
+ }
618
+ };
619
+ return {
620
+ ...module,
621
+ handler: wrappedHandler
622
+ };
623
+ };
624
+ return {
625
+ withToolAuth: (module)=>wrapAtPosition(module, 1),
626
+ withResourceAuth: (module)=>wrapAtPosition(module, 2),
627
+ withPromptAuth: (module)=>wrapAtPosition(module, 0)
628
+ };
629
+ }
630
+ constructor(config){
631
+ this.config = config;
632
+ }
633
+ }
634
+ /**
635
+ * Create a loopback OAuth client for Google services
636
+ * Works for both stdio and HTTP transports
637
+ */ export function createGoogleFileAuth(config) {
638
+ return new LoopbackOAuthProvider(config);
639
+ }