@mcp-z/oauth-microsoft 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 +98 -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 +1227 -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 +110 -0
  29. package/dist/cjs/providers/dcr.d.ts +110 -0
  30. package/dist/cjs/providers/dcr.js +600 -0
  31. package/dist/cjs/providers/dcr.js.map +1 -0
  32. package/dist/cjs/providers/device-code.d.cts +179 -0
  33. package/dist/cjs/providers/device-code.d.ts +179 -0
  34. package/dist/cjs/providers/device-code.js +896 -0
  35. package/dist/cjs/providers/device-code.js.map +1 -0
  36. package/dist/cjs/providers/loopback-oauth.d.cts +125 -0
  37. package/dist/cjs/providers/loopback-oauth.d.ts +125 -0
  38. package/dist/cjs/providers/loopback-oauth.js +1325 -0
  39. package/dist/cjs/providers/loopback-oauth.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 +113 -0
  45. package/dist/cjs/setup/config.d.ts +113 -0
  46. package/dist/cjs/setup/config.js +246 -0
  47. package/dist/cjs/setup/config.js.map +1 -0
  48. package/dist/cjs/types.d.cts +188 -0
  49. package/dist/cjs/types.d.ts +188 -0
  50. package/dist/cjs/types.js +18 -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 +556 -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 +110 -0
  72. package/dist/esm/providers/dcr.js +235 -0
  73. package/dist/esm/providers/dcr.js.map +1 -0
  74. package/dist/esm/providers/device-code.d.ts +179 -0
  75. package/dist/esm/providers/device-code.js +417 -0
  76. package/dist/esm/providers/device-code.js.map +1 -0
  77. package/dist/esm/providers/loopback-oauth.d.ts +125 -0
  78. package/dist/esm/providers/loopback-oauth.js +643 -0
  79. package/dist/esm/providers/loopback-oauth.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 +113 -0
  84. package/dist/esm/setup/config.js +268 -0
  85. package/dist/esm/setup/config.js.map +1 -0
  86. package/dist/esm/types.d.ts +188 -0
  87. package/dist/esm/types.js +8 -0
  88. package/dist/esm/types.js.map +1 -0
  89. package/package.json +87 -0
@@ -0,0 +1,643 @@
1
+ /**
2
+ * Loopback OAuth Implementation for Microsoft (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
+ * Eliminates port conflicts by using port 0, allowing unlimited concurrent servers.
7
+ *
8
+ * Flow:
9
+ * 1. Check token cache (memory + storage)
10
+ * 2. If cache miss: Start ephemeral server on port 0
11
+ * 3. Generate auth URL with dynamic redirect (localhost:{assigned-port})
12
+ * 4. Open browser or print URL (headless flag controls behavior)
13
+ * 5. Handle callback, exchange code for token
14
+ * 6. Cache token to storage
15
+ * 7. Close ephemeral server
16
+ */ import { addAccount, generatePKCE, getActiveAccount, getErrorTemplate, getSuccessTemplate, getToken, listAccountIds, setAccountInfo, setActiveAccount, setToken } from '@mcp-z/oauth';
17
+ import * as http from 'http';
18
+ import open from 'open';
19
+ import { fetchWithTimeout } from '../lib/fetch-with-timeout.js';
20
+ import { AuthRequiredError } from '../types.js';
21
+ /**
22
+ * Loopback OAuth Client (RFC 8252 Section 7.3)
23
+ *
24
+ * Implements OAuth 2.0 Authorization Code Flow with PKCE for native applications
25
+ * using loopback interface redirection. Manages ephemeral OAuth flows and token persistence
26
+ * with Keyv for key-based token storage using compound keys.
27
+ *
28
+ * Token key format: {accountId}:{service}:token (e.g., "user@example.com:outlook:token")
29
+ */ export class LoopbackOAuthProvider {
30
+ /**
31
+ * Get access token from Keyv using compound key
32
+ *
33
+ * @param accountId - Account identifier (email address). Required for loopback OAuth.
34
+ * @returns Access token for API requests
35
+ */ async getAccessToken(accountId) {
36
+ const { logger, service, tokenStore } = this.config;
37
+ // Use active account if no accountId specified
38
+ const effectiveAccountId = accountId !== null && accountId !== void 0 ? accountId : await getActiveAccount(tokenStore, {
39
+ service
40
+ });
41
+ // If we have an accountId, try to use existing token
42
+ if (effectiveAccountId) {
43
+ logger.debug('Getting access token', {
44
+ service,
45
+ accountId: effectiveAccountId
46
+ });
47
+ // Check Keyv for token using new key format
48
+ const storedToken = await getToken(tokenStore, {
49
+ accountId: effectiveAccountId,
50
+ service
51
+ });
52
+ if (storedToken && this.isTokenValid(storedToken)) {
53
+ logger.debug('Using stored access token', {
54
+ accountId: effectiveAccountId
55
+ });
56
+ return storedToken.accessToken;
57
+ }
58
+ // If stored token expired but has refresh token, try refresh
59
+ if (storedToken === null || storedToken === void 0 ? void 0 : storedToken.refreshToken) {
60
+ try {
61
+ logger.info('Refreshing expired access token', {
62
+ accountId: effectiveAccountId
63
+ });
64
+ const refreshedToken = await this.refreshAccessToken(storedToken.refreshToken);
65
+ await setToken(tokenStore, {
66
+ accountId: effectiveAccountId,
67
+ service
68
+ }, refreshedToken);
69
+ return refreshedToken.accessToken;
70
+ } catch (error) {
71
+ logger.info('Token refresh failed, starting new OAuth flow', {
72
+ accountId: effectiveAccountId,
73
+ error: error instanceof Error ? error.message : String(error)
74
+ });
75
+ // Fall through to new OAuth flow
76
+ }
77
+ }
78
+ }
79
+ // No valid token or no account - check if we can start OAuth flow
80
+ const { headless } = this.config;
81
+ if (headless) {
82
+ // In headless mode (production), cannot start OAuth flow
83
+ // Throw AuthRequiredError with auth_url descriptor for MCP tool response
84
+ const { clientId, tenantId, scope } = this.config;
85
+ // Incremental OAuth detection: Check if other accounts exist
86
+ const existingAccounts = await this.getExistingAccounts();
87
+ const hasOtherAccounts = effectiveAccountId ? existingAccounts.length > 0 && !existingAccounts.includes(effectiveAccountId) : existingAccounts.length > 0;
88
+ // Build informational OAuth URL for headless mode
89
+ // Note: No redirect_uri included - user must use account-add tool which starts proper ephemeral server
90
+ const authUrl = new URL(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`);
91
+ authUrl.searchParams.set('client_id', clientId);
92
+ authUrl.searchParams.set('response_type', 'code');
93
+ authUrl.searchParams.set('scope', scope);
94
+ authUrl.searchParams.set('response_mode', 'query');
95
+ authUrl.searchParams.set('prompt', 'select_account');
96
+ // Provide context-aware hint based on existing accounts
97
+ let hint;
98
+ if (hasOtherAccounts) {
99
+ hint = `Existing ${service} accounts found. Use account-list to view, account-switch to change account, or account-add to add new account`;
100
+ } else if (effectiveAccountId) {
101
+ hint = `Use account-add to authenticate ${effectiveAccountId}`;
102
+ } else {
103
+ hint = 'Use account-add to authenticate interactively';
104
+ }
105
+ const baseDescriptor = {
106
+ kind: 'auth_url',
107
+ provider: 'microsoft',
108
+ url: authUrl.toString(),
109
+ hint
110
+ };
111
+ const descriptor = effectiveAccountId ? {
112
+ ...baseDescriptor,
113
+ accountId: effectiveAccountId
114
+ } : baseDescriptor;
115
+ throw new AuthRequiredError(descriptor);
116
+ }
117
+ // Interactive mode - start ephemeral OAuth flow
118
+ logger.info('Starting ephemeral OAuth flow', {
119
+ service,
120
+ headless
121
+ });
122
+ const { token, email } = await this.performEphemeralOAuthFlow();
123
+ // Store token with email as accountId
124
+ await setToken(tokenStore, {
125
+ accountId: email,
126
+ service
127
+ }, token);
128
+ // Register account in account management system
129
+ await addAccount(tokenStore, {
130
+ service,
131
+ accountId: email
132
+ });
133
+ // Set as active account so subsequent getAccessToken() calls find it
134
+ await setActiveAccount(tokenStore, {
135
+ service,
136
+ accountId: email
137
+ });
138
+ // Store account metadata (email, added timestamp)
139
+ await setAccountInfo(tokenStore, {
140
+ service,
141
+ accountId: email
142
+ }, {
143
+ email,
144
+ addedAt: new Date().toISOString()
145
+ });
146
+ logger.info('OAuth flow completed', {
147
+ service,
148
+ accountId: email
149
+ });
150
+ return token.accessToken;
151
+ }
152
+ /**
153
+ * Convert to Microsoft Graph-compatible auth provider
154
+ *
155
+ * @param accountId - Account identifier for multi-account support (e.g., 'user@example.com')
156
+ * @returns Auth provider configured for the specified account
157
+ */ toAuthProvider(accountId) {
158
+ // Capture accountId in closure to pass to getAccessToken
159
+ const getToken = ()=>this.getAccessToken(accountId);
160
+ return {
161
+ getAccessToken: getToken
162
+ };
163
+ }
164
+ /**
165
+ * Authenticate new account with OAuth flow
166
+ * Triggers account selection, stores token, registers account
167
+ *
168
+ * @returns Email address of newly authenticated account
169
+ * @throws Error in headless mode (cannot open browser for OAuth)
170
+ */ async authenticateNewAccount() {
171
+ const { logger, headless, service, tokenStore } = this.config;
172
+ if (headless) {
173
+ throw new Error('Cannot authenticate new account in headless mode - interactive OAuth required');
174
+ }
175
+ logger.info('Starting new account authentication', {
176
+ service
177
+ });
178
+ // Trigger OAuth with account selection
179
+ const { token, email } = await this.performEphemeralOAuthFlow();
180
+ // Store token
181
+ await setToken(tokenStore, {
182
+ accountId: email,
183
+ service
184
+ }, token);
185
+ // Register account
186
+ await addAccount(tokenStore, {
187
+ service,
188
+ accountId: email
189
+ });
190
+ // Set as active account
191
+ await setActiveAccount(tokenStore, {
192
+ service,
193
+ accountId: email
194
+ });
195
+ // Store account metadata
196
+ await setAccountInfo(tokenStore, {
197
+ service,
198
+ accountId: email
199
+ }, {
200
+ email,
201
+ addedAt: new Date().toISOString()
202
+ });
203
+ logger.info('New account authenticated', {
204
+ service,
205
+ email
206
+ });
207
+ return email;
208
+ }
209
+ /**
210
+ * Get user email from Microsoft Graph API (pure query)
211
+ * Used to query email for existing authenticated account
212
+ *
213
+ * @param accountId - Account identifier to get email for
214
+ * @returns User's email address
215
+ */ async getUserEmail(accountId) {
216
+ var _userInfo_mail;
217
+ // Get token for existing account
218
+ const token = await this.getAccessToken(accountId);
219
+ // Fetch email from Microsoft Graph
220
+ const response = await fetchWithTimeout('https://graph.microsoft.com/v1.0/me', {
221
+ headers: {
222
+ Authorization: `Bearer ${token}`
223
+ }
224
+ });
225
+ if (!response.ok) {
226
+ throw new Error(`Failed to get user info: ${response.status} ${await response.text()}`);
227
+ }
228
+ const userInfo = await response.json();
229
+ return (_userInfo_mail = userInfo.mail) !== null && _userInfo_mail !== void 0 ? _userInfo_mail : userInfo.userPrincipalName;
230
+ }
231
+ /**
232
+ * Check for existing accounts in token storage (incremental OAuth detection)
233
+ *
234
+ * Uses key-utils helper for forward compatibility with key format changes.
235
+ *
236
+ * @returns Array of account IDs that have tokens for this service
237
+ */ async getExistingAccounts() {
238
+ const { service, tokenStore } = this.config;
239
+ return listAccountIds(tokenStore, service);
240
+ }
241
+ isTokenValid(token) {
242
+ if (!token.expiresAt) return true; // No expiry = assume valid
243
+ return Date.now() < token.expiresAt - 60000; // 1 minute buffer
244
+ }
245
+ /**
246
+ * Fetch user email from Microsoft Graph using access token
247
+ * Called during OAuth flow to get email for accountId
248
+ *
249
+ * @param accessToken - Fresh access token from OAuth exchange
250
+ * @returns User's email address (mail field or userPrincipalName fallback)
251
+ */ async fetchUserEmailFromToken(accessToken) {
252
+ var _userInfo_mail;
253
+ const { logger } = this.config;
254
+ const response = await fetchWithTimeout('https://graph.microsoft.com/v1.0/me', {
255
+ headers: {
256
+ Authorization: `Bearer ${accessToken}`
257
+ }
258
+ });
259
+ if (!response.ok) {
260
+ const errorText = await response.text();
261
+ throw new Error(`Failed to fetch user email: HTTP ${response.status} - ${errorText}`);
262
+ }
263
+ const userInfo = await response.json();
264
+ const email = (_userInfo_mail = userInfo.mail) !== null && _userInfo_mail !== void 0 ? _userInfo_mail : userInfo.userPrincipalName;
265
+ logger.debug('Fetched user email from Graph API', {
266
+ email
267
+ });
268
+ return email;
269
+ }
270
+ async performEphemeralOAuthFlow() {
271
+ const { clientId, tenantId, scope, headless, logger, redirectUri: configRedirectUri } = this.config;
272
+ // Parse redirectUri if provided to extract host, protocol, port, and path
273
+ let targetHost = 'localhost'; // Default: localhost (Microsoft requires exact match with registered redirect URI)
274
+ let targetPort = 0; // Default: OS-assigned ephemeral port
275
+ let targetProtocol = 'http:'; // Default: http
276
+ let callbackPath = '/callback'; // Default callback path
277
+ let useConfiguredUri = false;
278
+ if (configRedirectUri) {
279
+ try {
280
+ const parsed = new URL(configRedirectUri);
281
+ // Use configured redirect URI as-is for production deployments
282
+ targetHost = parsed.hostname;
283
+ targetProtocol = parsed.protocol;
284
+ // Extract port from URL (use default ports if not specified)
285
+ if (parsed.port) {
286
+ targetPort = Number.parseInt(parsed.port, 10);
287
+ } else {
288
+ targetPort = parsed.protocol === 'https:' ? 443 : 80;
289
+ }
290
+ // Extract path (default to /callback if URL has no path or just '/')
291
+ if (parsed.pathname && parsed.pathname !== '/') {
292
+ callbackPath = parsed.pathname;
293
+ }
294
+ useConfiguredUri = true;
295
+ logger.debug('Using configured redirect URI', {
296
+ host: targetHost,
297
+ protocol: targetProtocol,
298
+ port: targetPort,
299
+ path: callbackPath,
300
+ redirectUri: configRedirectUri
301
+ });
302
+ } catch (error) {
303
+ logger.warn('Failed to parse redirectUri, using ephemeral defaults', {
304
+ redirectUri: configRedirectUri,
305
+ error: error instanceof Error ? error.message : String(error)
306
+ });
307
+ // Continue with defaults (127.0.0.1, port 0, http, /callback)
308
+ }
309
+ }
310
+ return new Promise((resolve, reject)=>{
311
+ // Generate PKCE challenge
312
+ const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
313
+ let server = null;
314
+ let serverPort;
315
+ let finalRedirectUri; // Will be set in server.listen callback
316
+ // Create ephemeral server with OS-assigned port (RFC 8252)
317
+ server = http.createServer(async (req, res)=>{
318
+ if (!req.url) {
319
+ res.writeHead(400, {
320
+ 'Content-Type': 'text/html'
321
+ });
322
+ res.end(getErrorTemplate('Invalid request'));
323
+ server === null || server === void 0 ? void 0 : server.close();
324
+ reject(new Error('Invalid request: missing URL'));
325
+ return;
326
+ }
327
+ const url = new URL(req.url, `http://localhost:${serverPort}`);
328
+ if (url.pathname === callbackPath) {
329
+ const code = url.searchParams.get('code');
330
+ const error = url.searchParams.get('error');
331
+ if (error) {
332
+ res.writeHead(400, {
333
+ 'Content-Type': 'text/html'
334
+ });
335
+ res.end(getErrorTemplate(error));
336
+ server === null || server === void 0 ? void 0 : server.close();
337
+ reject(new Error(`OAuth error: ${error}`));
338
+ return;
339
+ }
340
+ if (!code) {
341
+ res.writeHead(400, {
342
+ 'Content-Type': 'text/html'
343
+ });
344
+ res.end(getErrorTemplate('No authorization code received'));
345
+ server === null || server === void 0 ? void 0 : server.close();
346
+ reject(new Error('No authorization code received'));
347
+ return;
348
+ }
349
+ try {
350
+ // Exchange code for token (must use same redirect_uri as in authorization request)
351
+ const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, finalRedirectUri);
352
+ // Build cached token
353
+ const cachedToken = {
354
+ accessToken: tokenResponse.access_token,
355
+ ...tokenResponse.refresh_token !== undefined && {
356
+ refreshToken: tokenResponse.refresh_token
357
+ },
358
+ ...tokenResponse.expires_in !== undefined && {
359
+ expiresAt: Date.now() + tokenResponse.expires_in * 1000
360
+ },
361
+ ...tokenResponse.scope !== undefined && {
362
+ scope: tokenResponse.scope
363
+ }
364
+ };
365
+ // Fetch user email immediately using the new access token
366
+ const email = await this.fetchUserEmailFromToken(tokenResponse.access_token);
367
+ res.writeHead(200, {
368
+ 'Content-Type': 'text/html'
369
+ });
370
+ res.end(getSuccessTemplate());
371
+ server === null || server === void 0 ? void 0 : server.close();
372
+ resolve({
373
+ token: cachedToken,
374
+ email
375
+ });
376
+ } catch (exchangeError) {
377
+ logger.error('Token exchange failed', {
378
+ error: exchangeError instanceof Error ? exchangeError.message : String(exchangeError)
379
+ });
380
+ res.writeHead(500, {
381
+ 'Content-Type': 'text/html'
382
+ });
383
+ res.end(getErrorTemplate('Token exchange failed'));
384
+ server === null || server === void 0 ? void 0 : server.close();
385
+ reject(exchangeError);
386
+ }
387
+ } else {
388
+ res.writeHead(404, {
389
+ 'Content-Type': 'text/plain'
390
+ });
391
+ res.end('Not Found');
392
+ }
393
+ });
394
+ // Listen on targetPort (0 for OS assignment, or custom port from redirectUri)
395
+ server.listen(targetPort, targetHost, ()=>{
396
+ const address = server === null || server === void 0 ? void 0 : server.address();
397
+ if (!address || typeof address === 'string') {
398
+ server === null || server === void 0 ? void 0 : server.close();
399
+ reject(new Error('Failed to start ephemeral server'));
400
+ return;
401
+ }
402
+ serverPort = address.port;
403
+ // Construct final redirect URI
404
+ if (useConfiguredUri && configRedirectUri) {
405
+ // Use configured redirect URI as-is for production
406
+ finalRedirectUri = configRedirectUri;
407
+ } else {
408
+ // Construct ephemeral redirect URI with actual server port
409
+ finalRedirectUri = `${targetProtocol}//${targetHost}:${serverPort}${callbackPath}`;
410
+ }
411
+ // Build Microsoft auth URL
412
+ const authUrl = new URL(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`);
413
+ authUrl.searchParams.set('client_id', clientId);
414
+ authUrl.searchParams.set('redirect_uri', finalRedirectUri);
415
+ authUrl.searchParams.set('response_type', 'code');
416
+ authUrl.searchParams.set('scope', scope);
417
+ authUrl.searchParams.set('response_mode', 'query');
418
+ authUrl.searchParams.set('code_challenge', codeChallenge);
419
+ authUrl.searchParams.set('code_challenge_method', 'S256');
420
+ authUrl.searchParams.set('prompt', 'select_account');
421
+ logger.info('Ephemeral OAuth server started', {
422
+ port: serverPort,
423
+ headless
424
+ });
425
+ if (headless) {
426
+ // Headless mode: Print auth URL to stderr (stdout is MCP protocol)
427
+ console.error('\n🔐 OAuth Authorization Required');
428
+ console.error('📋 Please visit this URL in your browser:\n');
429
+ console.error(` ${authUrl.toString()}\n`);
430
+ console.error('⏳ Waiting for authorization...\n');
431
+ } else {
432
+ // Interactive mode: Open browser automatically
433
+ logger.info('Opening browser for OAuth authorization');
434
+ open(authUrl.toString()).catch((error)=>{
435
+ logger.info('Failed to open browser automatically', {
436
+ error: error.message
437
+ });
438
+ console.error('\n🔐 OAuth Authorization Required');
439
+ console.error(` ${authUrl.toString()}\n`);
440
+ });
441
+ }
442
+ });
443
+ // Timeout after 5 minutes
444
+ setTimeout(()=>{
445
+ if (server) {
446
+ server.close();
447
+ reject(new Error('OAuth flow timed out after 5 minutes'));
448
+ }
449
+ }, 5 * 60 * 1000);
450
+ });
451
+ }
452
+ async exchangeCodeForToken(code, codeVerifier, redirectUri) {
453
+ const { clientId, clientSecret, tenantId } = this.config;
454
+ const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
455
+ const params = {
456
+ code,
457
+ client_id: clientId,
458
+ redirect_uri: redirectUri,
459
+ grant_type: 'authorization_code',
460
+ code_verifier: codeVerifier
461
+ };
462
+ // Only include client_secret for confidential clients
463
+ if (clientSecret) {
464
+ params.client_secret = clientSecret;
465
+ }
466
+ const body = new URLSearchParams(params);
467
+ const response = await fetchWithTimeout(tokenUrl, {
468
+ method: 'POST',
469
+ headers: {
470
+ 'Content-Type': 'application/x-www-form-urlencoded'
471
+ },
472
+ body: body.toString()
473
+ });
474
+ if (!response.ok) {
475
+ const errorText = await response.text();
476
+ throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
477
+ }
478
+ return await response.json();
479
+ }
480
+ async refreshAccessToken(refreshToken) {
481
+ const { clientId, clientSecret, tenantId, scope } = this.config;
482
+ const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
483
+ const params = {
484
+ refresh_token: refreshToken,
485
+ client_id: clientId,
486
+ grant_type: 'refresh_token',
487
+ scope
488
+ };
489
+ // Only include client_secret for confidential clients
490
+ if (clientSecret) {
491
+ params.client_secret = clientSecret;
492
+ }
493
+ const body = new URLSearchParams(params);
494
+ const response = await fetchWithTimeout(tokenUrl, {
495
+ method: 'POST',
496
+ headers: {
497
+ 'Content-Type': 'application/x-www-form-urlencoded'
498
+ },
499
+ body: body.toString()
500
+ });
501
+ if (!response.ok) {
502
+ const errorText = await response.text();
503
+ throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
504
+ }
505
+ const tokenResponse = await response.json();
506
+ return {
507
+ accessToken: tokenResponse.access_token,
508
+ refreshToken: refreshToken,
509
+ ...tokenResponse.expires_in !== undefined && {
510
+ expiresAt: Date.now() + tokenResponse.expires_in * 1000
511
+ },
512
+ ...tokenResponse.scope !== undefined && {
513
+ scope: tokenResponse.scope
514
+ }
515
+ };
516
+ }
517
+ /**
518
+ * Create auth middleware for single-user context (single active account per service)
519
+ *
520
+ * Single-user mode:
521
+ * - Maintains per-service active accounts in storage
522
+ * - Supports backchannel account override via extra._meta.accountId
523
+ * - Automatically enhances output schemas with auth_required branch
524
+ *
525
+ * Example:
526
+ * ```typescript
527
+ * const loopback = new LoopbackOAuthProvider({ service: 'outlook', ... });
528
+ * const middleware = loopback.authMiddleware();
529
+ * const tools = toolFactories.map(f => f()).map(middleware.withToolAuth);
530
+ * const resources = resourceFactories.map(f => f()).map(middleware.withResourceAuth);
531
+ * const prompts = promptFactories.map(f => f()).map(middleware.withPromptAuth);
532
+ * ```
533
+ *
534
+ * @returns Object with withToolAuth, withResourceAuth, withPromptAuth methods
535
+ */ authMiddleware() {
536
+ const { service, tokenStore, logger } = this.config;
537
+ // Shared wrapper logic - extracts extra parameter from specified position
538
+ // Generic T captures the actual module type; handler is cast from unknown to callable
539
+ const wrapAtPosition = (module, extraPosition)=>{
540
+ const operation = module.name;
541
+ const originalHandler = module.handler;
542
+ const wrappedHandler = async (...allArgs)=>{
543
+ // Extract extra from the correct position (defensive: handle arg-less tool pattern)
544
+ let extra;
545
+ if (allArgs.length <= extraPosition) {
546
+ // Arg-less tool pattern: keep args as-is, create separate extra object
547
+ extra = allArgs[0] && typeof allArgs[0] === 'object' ? {} : {};
548
+ allArgs[extraPosition] = extra;
549
+ } else {
550
+ extra = allArgs[extraPosition] || {};
551
+ allArgs[extraPosition] = extra;
552
+ }
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.toAuthProvider(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
+ // Token retrieval/refresh failed - return auth required
589
+ if (error instanceof AuthRequiredError) {
590
+ logger.info('Authentication required', {
591
+ service,
592
+ tool: operation,
593
+ descriptor: error.descriptor
594
+ });
595
+ // Return auth_required response wrapped in { result } to match tool outputSchema pattern
596
+ // Tools define outputSchema: z.object({ result: discriminatedUnion(...) }) where auth_required is a branch
597
+ const authRequiredResponse = {
598
+ type: 'auth_required',
599
+ provider: service,
600
+ message: `Authentication required for ${operation}. Please authenticate with ${service}.`,
601
+ url: error.descriptor.kind === 'auth_url' ? error.descriptor.url : undefined
602
+ };
603
+ return {
604
+ content: [
605
+ {
606
+ type: 'text',
607
+ text: JSON.stringify({
608
+ result: authRequiredResponse
609
+ })
610
+ }
611
+ ],
612
+ structuredContent: {
613
+ result: authRequiredResponse
614
+ }
615
+ };
616
+ }
617
+ // Other errors - propagate
618
+ throw error;
619
+ }
620
+ };
621
+ return {
622
+ ...module,
623
+ handler: wrappedHandler
624
+ };
625
+ };
626
+ return {
627
+ // Use structural constraints to avoid contravariance check on handler type.
628
+ // wrapAtPosition is now generic and returns T directly.
629
+ withToolAuth: (module)=>wrapAtPosition(module, 1),
630
+ withResourceAuth: (module)=>wrapAtPosition(module, 2),
631
+ withPromptAuth: (module)=>wrapAtPosition(module, 0)
632
+ };
633
+ }
634
+ constructor(config){
635
+ this.config = config;
636
+ }
637
+ }
638
+ /**
639
+ * Create a loopback OAuth client for Microsoft services
640
+ * Works for both stdio and HTTP transports
641
+ */ export function createMicrosoftFileAuth(config) {
642
+ return new LoopbackOAuthProvider(config);
643
+ }