@mcp-z/oauth-microsoft 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 (51) 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/device-code.js.map +1 -1
  17. package/dist/cjs/providers/loopback-oauth.d.cts +93 -18
  18. package/dist/cjs/providers/loopback-oauth.d.ts +93 -18
  19. package/dist/cjs/providers/loopback-oauth.js +877 -491
  20. package/dist/cjs/providers/loopback-oauth.js.map +1 -1
  21. package/dist/cjs/schemas/index.js +1 -1
  22. package/dist/cjs/schemas/index.js.map +1 -1
  23. package/dist/cjs/setup/config.d.cts +4 -1
  24. package/dist/cjs/setup/config.d.ts +4 -1
  25. package/dist/cjs/setup/config.js +7 -4
  26. package/dist/cjs/setup/config.js.map +1 -1
  27. package/dist/cjs/types.js.map +1 -1
  28. package/dist/esm/index.d.ts +2 -1
  29. package/dist/esm/index.js +1 -0
  30. package/dist/esm/index.js.map +1 -1
  31. package/dist/esm/lib/dcr-router.js.map +1 -1
  32. package/dist/esm/lib/dcr-utils.js.map +1 -1
  33. package/dist/esm/lib/dcr-verify.js.map +1 -1
  34. package/dist/esm/lib/fetch-with-timeout.js.map +1 -1
  35. package/dist/esm/lib/loopback-router.d.ts +8 -0
  36. package/dist/esm/lib/loopback-router.js +32 -0
  37. package/dist/esm/lib/loopback-router.js.map +1 -0
  38. package/dist/esm/lib/token-verifier.js.map +1 -1
  39. package/dist/esm/providers/dcr.js.map +1 -1
  40. package/dist/esm/providers/device-code.js +2 -2
  41. package/dist/esm/providers/device-code.js.map +1 -1
  42. package/dist/esm/providers/loopback-oauth.d.ts +93 -18
  43. package/dist/esm/providers/loopback-oauth.js +470 -289
  44. package/dist/esm/providers/loopback-oauth.js.map +1 -1
  45. package/dist/esm/schemas/index.js +1 -1
  46. package/dist/esm/schemas/index.js.map +1 -1
  47. package/dist/esm/setup/config.d.ts +4 -1
  48. package/dist/esm/setup/config.js +7 -4
  49. package/dist/esm/setup/config.js.map +1 -1
  50. package/dist/esm/types.js.map +1 -1
  51. package/package.json +1 -1
@@ -13,11 +13,24 @@
13
13
  * 5. Handle callback, exchange code for token
14
14
  * 6. Cache token to storage
15
15
  * 7. Close ephemeral server
16
- */ import { addAccount, generatePKCE, getActiveAccount, getErrorTemplate, getSuccessTemplate, getToken, listAccountIds, setAccountInfo, setActiveAccount, setToken } from '@mcp-z/oauth';
16
+ *
17
+ * CHANGE (2026-01-03):
18
+ * - Non-headless mode now opens the auth URL AND blocks (polls) until tokens are available,
19
+ * for BOTH redirectUri (persistent) and ephemeral (loopback) modes.
20
+ * - Ephemeral flow no longer calls `open()` itself. Instead it:
21
+ * 1) starts the loopback callback server
22
+ * 2) throws AuthRequiredError(auth_url)
23
+ * - Middleware catches AuthRequiredError(auth_url):
24
+ * - if not headless: open(url) once + poll pending state until callback completes (or timeout)
25
+ * - then retries token acquisition and injects authContext in the SAME tool call.
26
+ */ import { addAccount, generatePKCE, getActiveAccount, getErrorTemplate, getSuccessTemplate, getToken, setAccountInfo, setActiveAccount, setToken } from '@mcp-z/oauth';
27
+ import { randomUUID } from 'crypto';
17
28
  import * as http from 'http';
18
29
  import open from 'open';
19
30
  import { fetchWithTimeout } from '../lib/fetch-with-timeout.js';
20
31
  import { AuthRequiredError } from '../types.js';
32
+ const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
33
+ const OAUTH_POLL_MS = 500;
21
34
  /**
22
35
  * Loopback OAuth Client (RFC 8252 Section 7.3)
23
36
  *
@@ -76,78 +89,42 @@ import { AuthRequiredError } from '../types.js';
76
89
  }
77
90
  }
78
91
  }
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 = {
92
+ const { clientId, tenantId, scope, redirectUri } = this.config;
93
+ if (redirectUri) {
94
+ // Persistent callback mode (cloud deployment with configured redirect_uri)
95
+ const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
96
+ const stateId = randomUUID();
97
+ // Store PKCE verifier for callback (5 minute TTL)
98
+ await this.createPendingAuth({
99
+ state: stateId,
100
+ codeVerifier
101
+ });
102
+ // Build auth URL with configured redirect_uri
103
+ const authUrl = this.buildAuthUrl({
104
+ tenantId,
105
+ clientId,
106
+ redirectUri,
107
+ scope,
108
+ codeChallenge,
109
+ state: stateId
110
+ });
111
+ logger.info('OAuth required - persistent callback mode', {
112
+ service,
113
+ redirectUri
114
+ });
115
+ throw new AuthRequiredError({
106
116
  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);
117
+ provider: service,
118
+ url: authUrl
119
+ });
116
120
  }
117
- // Interactive mode - start ephemeral OAuth flow
121
+ // Ephemeral callback mode (local development)
118
122
  logger.info('Starting ephemeral OAuth flow', {
119
123
  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()
124
+ headless: this.config.headless
145
125
  });
146
- logger.info('OAuth flow completed', {
147
- service,
148
- accountId: email
149
- });
150
- return token.accessToken;
126
+ const descriptor = await this.startEphemeralOAuthFlow();
127
+ throw new AuthRequiredError(descriptor);
151
128
  }
152
129
  /**
153
130
  * Convert to Microsoft Graph-compatible auth provider
@@ -162,51 +139,6 @@ import { AuthRequiredError } from '../types.js';
162
139
  };
163
140
  }
164
141
  /**
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
142
  * Get user email from Microsoft Graph API (pure query)
211
143
  * Used to query email for existing authenticated account
212
144
  *
@@ -228,16 +160,6 @@ import { AuthRequiredError } from '../types.js';
228
160
  const userInfo = await response.json();
229
161
  return (_userInfo_mail = userInfo.mail) !== null && _userInfo_mail !== void 0 ? _userInfo_mail : userInfo.userPrincipalName;
230
162
  }
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
163
  isTokenValid(token) {
242
164
  if (!token.expiresAt) return true; // No expiry = assume valid
243
165
  return Date.now() < token.expiresAt - 60000; // 1 minute buffer
@@ -267,132 +189,347 @@ import { AuthRequiredError } from '../types.js';
267
189
  });
268
190
  return email;
269
191
  }
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
192
+ // ---------------------------------------------------------------------------
193
+ // Shared OAuth helpers
194
+ // ---------------------------------------------------------------------------
195
+ /**
196
+ * Build Microsoft OAuth authorization URL with the "most parameters" baseline.
197
+ * This is shared by BOTH persistent (redirectUri) and ephemeral (loopback) modes.
198
+ */ buildAuthUrl(args) {
199
+ const authUrl = new URL(`https://login.microsoftonline.com/${args.tenantId}/oauth2/v2.0/authorize`);
200
+ authUrl.searchParams.set('client_id', args.clientId);
201
+ authUrl.searchParams.set('redirect_uri', args.redirectUri);
202
+ authUrl.searchParams.set('response_type', 'code');
203
+ authUrl.searchParams.set('scope', args.scope);
204
+ // Keep response_mode consistent across both modes (most-params baseline)
205
+ authUrl.searchParams.set('response_mode', 'query');
206
+ // PKCE
207
+ authUrl.searchParams.set('code_challenge', args.codeChallenge);
208
+ authUrl.searchParams.set('code_challenge_method', 'S256');
209
+ // State (required in both modes)
210
+ authUrl.searchParams.set('state', args.state);
211
+ // Keep current behavior
212
+ authUrl.searchParams.set('prompt', 'select_account');
213
+ return authUrl.toString();
214
+ }
215
+ /**
216
+ * Create a cached token + email from an authorization code.
217
+ * This is the shared callback handler for BOTH persistent and ephemeral modes.
218
+ */ async handleAuthorizationCode(args) {
219
+ // Exchange code for token (must use same redirect_uri as in authorization request)
220
+ const tokenResponse = await this.exchangeCodeForToken(args.code, args.codeVerifier, args.redirectUri);
221
+ // Build cached token
222
+ const cachedToken = {
223
+ accessToken: tokenResponse.access_token,
224
+ ...tokenResponse.refresh_token !== undefined && {
225
+ refreshToken: tokenResponse.refresh_token
226
+ },
227
+ ...tokenResponse.expires_in !== undefined && {
228
+ expiresAt: Date.now() + tokenResponse.expires_in * 1000
229
+ },
230
+ ...tokenResponse.scope !== undefined && {
231
+ scope: tokenResponse.scope
232
+ }
233
+ };
234
+ // Fetch user email immediately using the new access token
235
+ const email = await this.fetchUserEmailFromToken(tokenResponse.access_token);
236
+ return {
237
+ email,
238
+ token: cachedToken
239
+ };
240
+ }
241
+ /**
242
+ * Store token + account metadata. Shared by BOTH persistent and ephemeral modes.
243
+ */ async persistAuthResult(args) {
244
+ const { tokenStore, service } = this.config;
245
+ await setToken(tokenStore, {
246
+ accountId: args.email,
247
+ service
248
+ }, args.token);
249
+ await addAccount(tokenStore, {
250
+ service,
251
+ accountId: args.email
252
+ });
253
+ await setActiveAccount(tokenStore, {
254
+ service,
255
+ accountId: args.email
256
+ });
257
+ await setAccountInfo(tokenStore, {
258
+ service,
259
+ accountId: args.email
260
+ }, {
261
+ email: args.email,
262
+ addedAt: new Date().toISOString()
263
+ });
264
+ }
265
+ /**
266
+ * Pending auth (PKCE verifier) key format.
267
+ */ pendingKey(state) {
268
+ return `${this.config.service}:pending:${state}`;
269
+ }
270
+ /**
271
+ * Store PKCE verifier for callback (5 minute TTL).
272
+ * Shared by BOTH persistent and ephemeral modes.
273
+ */ async createPendingAuth(args) {
274
+ const { tokenStore } = this.config;
275
+ const record = {
276
+ codeVerifier: args.codeVerifier,
277
+ createdAt: Date.now()
278
+ };
279
+ await tokenStore.set(this.pendingKey(args.state), record, OAUTH_TIMEOUT_MS);
280
+ }
281
+ /**
282
+ * Load and validate pending auth state (5 minute TTL).
283
+ * Shared by BOTH persistent and ephemeral modes.
284
+ */ async readAndValidatePendingAuth(state) {
285
+ const { tokenStore } = this.config;
286
+ const pendingAuth = await tokenStore.get(this.pendingKey(state));
287
+ if (!pendingAuth) {
288
+ throw new Error('Invalid or expired OAuth state. Please try again.');
289
+ }
290
+ // Check TTL (5 minutes)
291
+ if (Date.now() - pendingAuth.createdAt > OAUTH_TIMEOUT_MS) {
292
+ await tokenStore.delete(this.pendingKey(state));
293
+ throw new Error('OAuth state expired. Please try again.');
294
+ }
295
+ return pendingAuth;
296
+ }
297
+ /**
298
+ * Mark pending auth as completed (used by middleware polling).
299
+ */ async markPendingComplete(args) {
300
+ const { tokenStore } = this.config;
301
+ const updated = {
302
+ ...args.pending,
303
+ completedAt: Date.now(),
304
+ email: args.email
305
+ };
306
+ await tokenStore.set(this.pendingKey(args.state), updated, OAUTH_TIMEOUT_MS);
307
+ }
308
+ /**
309
+ * Clean up pending auth state.
310
+ */ async deletePendingAuth(state) {
311
+ const { tokenStore } = this.config;
312
+ await tokenStore.delete(this.pendingKey(state));
313
+ }
314
+ /**
315
+ * Wait until pending auth is marked completed (or timeout).
316
+ * Used by middleware after opening auth URL in non-headless mode.
317
+ */ async waitForOAuthCompletion(state) {
318
+ const { tokenStore } = this.config;
319
+ const key = this.pendingKey(state);
320
+ const start = Date.now();
321
+ while(Date.now() - start < OAUTH_TIMEOUT_MS){
322
+ const pending = await tokenStore.get(key);
323
+ if (pending === null || pending === void 0 ? void 0 : pending.completedAt) {
324
+ return {
325
+ email: pending.email
326
+ };
327
+ }
328
+ await new Promise((r)=>setTimeout(r, OAUTH_POLL_MS));
329
+ }
330
+ throw new Error('OAuth flow timed out after 5 minutes');
331
+ }
332
+ /**
333
+ * Process an OAuth callback using shared state validation + token exchange + persistence.
334
+ * Used by BOTH:
335
+ * - ephemeral loopback server callback handler
336
+ * - persistent redirectUri callback handler
337
+ *
338
+ * IMPORTANT CHANGE:
339
+ * - We do NOT delete pending state here anymore.
340
+ * - We mark it completed so middleware can poll and then clean it up.
341
+ */ async processOAuthCallback(args) {
342
+ const { logger, service } = this.config;
343
+ const pending = await this.readAndValidatePendingAuth(args.state);
344
+ logger.info('Processing OAuth callback', {
345
+ service,
346
+ state: args.state
347
+ });
348
+ const result = await this.handleAuthorizationCode({
349
+ code: args.code,
350
+ codeVerifier: pending.codeVerifier,
351
+ redirectUri: args.redirectUri
352
+ });
353
+ await this.persistAuthResult(result);
354
+ await this.markPendingComplete({
355
+ state: args.state,
356
+ email: result.email,
357
+ pending
358
+ });
359
+ logger.info('OAuth callback completed', {
360
+ service,
361
+ email: result.email
362
+ });
363
+ return result;
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Ephemeral loopback server + flow
367
+ // ---------------------------------------------------------------------------
368
+ /**
369
+ * Loopback OAuth server helper (RFC 8252 Section 7.3)
370
+ *
371
+ * Implements ephemeral local server with OS-assigned port (RFC 8252 Section 8.3).
372
+ * Shared callback handling uses:
373
+ * - the same authUrl builder as redirectUri mode
374
+ * - the same pending PKCE verifier storage as redirectUri mode
375
+ * - the same callback processor as redirectUri mode
376
+ */ createOAuthCallbackServer(args) {
377
+ const { logger } = this.config;
378
+ // Create ephemeral server with OS-assigned port (RFC 8252)
379
+ return http.createServer(async (req, res)=>{
380
+ try {
381
+ if (!req.url) {
382
+ res.writeHead(400, {
383
+ 'Content-Type': 'text/html'
384
+ });
385
+ res.end(getErrorTemplate('Invalid request'));
386
+ args.onError(new Error('Invalid request: missing URL'));
387
+ return;
388
+ }
389
+ // Use loopback base for URL parsing (port is not important for parsing path/query)
390
+ const url = new URL(req.url, 'http://127.0.0.1');
391
+ if (url.pathname !== args.callbackPath) {
392
+ res.writeHead(404, {
393
+ 'Content-Type': 'text/plain'
394
+ });
395
+ res.end('Not Found');
396
+ return;
397
+ }
398
+ const code = url.searchParams.get('code');
399
+ const error = url.searchParams.get('error');
400
+ const state = url.searchParams.get('state');
401
+ if (error) {
402
+ res.writeHead(400, {
403
+ 'Content-Type': 'text/html'
404
+ });
405
+ res.end(getErrorTemplate(error));
406
+ args.onError(new Error(`OAuth error: ${error}`));
407
+ return;
408
+ }
409
+ if (!code) {
410
+ res.writeHead(400, {
411
+ 'Content-Type': 'text/html'
412
+ });
413
+ res.end(getErrorTemplate('No authorization code received'));
414
+ args.onError(new Error('No authorization code received'));
415
+ return;
416
+ }
417
+ if (!state) {
418
+ res.writeHead(400, {
419
+ 'Content-Type': 'text/html'
420
+ });
421
+ res.end(getErrorTemplate('Missing state parameter in OAuth callback'));
422
+ args.onError(new Error('Missing state parameter in OAuth callback'));
423
+ return;
424
+ }
425
+ try {
426
+ await this.processOAuthCallback({
427
+ code,
428
+ state,
429
+ redirectUri: args.finalRedirectUri()
430
+ });
431
+ res.writeHead(200, {
432
+ 'Content-Type': 'text/html'
433
+ });
434
+ res.end(getSuccessTemplate());
435
+ args.onDone();
436
+ } catch (exchangeError) {
437
+ logger.error('Token exchange failed', {
438
+ error: exchangeError instanceof Error ? exchangeError.message : String(exchangeError)
439
+ });
440
+ res.writeHead(500, {
441
+ 'Content-Type': 'text/html'
442
+ });
443
+ res.end(getErrorTemplate('Token exchange failed'));
444
+ args.onError(exchangeError);
445
+ }
446
+ } catch (outerError) {
447
+ logger.error('OAuth callback server error', {
448
+ error: outerError instanceof Error ? outerError.message : String(outerError)
449
+ });
450
+ res.writeHead(500, {
451
+ 'Content-Type': 'text/html'
452
+ });
453
+ res.end(getErrorTemplate('Internal server error'));
454
+ args.onError(outerError);
455
+ }
456
+ });
457
+ }
458
+ /**
459
+ * Starts the ephemeral loopback server and returns an AuthRequiredError(auth_url).
460
+ * Middleware will open+poll and then retry in the same call.
461
+ */ async startEphemeralOAuthFlow() {
462
+ const { clientId, tenantId, scope, headless, logger, redirectUri: configRedirectUri, service, tokenStore } = this.config;
463
+ // Server listen configuration (where ephemeral server binds)
464
+ let listenHost = 'localhost'; // Default: localhost for ephemeral loopback
465
+ let listenPort = 0; // Default: OS-assigned ephemeral port
466
+ // Redirect URI configuration (what goes in auth URL and token exchange)
276
467
  let callbackPath = '/callback'; // Default callback path
277
468
  let useConfiguredUri = false;
278
469
  if (configRedirectUri) {
279
470
  try {
280
471
  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);
472
+ const isLoopback = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
473
+ if (isLoopback) {
474
+ // Local development: Listen on specific loopback address/port
475
+ listenHost = parsed.hostname;
476
+ listenPort = parsed.port ? Number.parseInt(parsed.port, 10) : 0;
287
477
  } else {
288
- targetPort = parsed.protocol === 'https:' ? 443 : 80;
478
+ // Cloud deployment: Listen on 0.0.0.0 with PORT from environment
479
+ // The redirectUri is the PUBLIC URL (e.g., https://example.com/oauth/callback)
480
+ // The server listens on 0.0.0.0:PORT and the load balancer routes to it
481
+ listenHost = '0.0.0.0';
482
+ const envPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : undefined;
483
+ listenPort = envPort && Number.isFinite(envPort) ? envPort : 8080;
289
484
  }
290
- // Extract path (default to /callback if URL has no path or just '/')
485
+ // Extract callback path from URL
291
486
  if (parsed.pathname && parsed.pathname !== '/') {
292
487
  callbackPath = parsed.pathname;
293
488
  }
294
489
  useConfiguredUri = true;
295
490
  logger.debug('Using configured redirect URI', {
296
- host: targetHost,
297
- protocol: targetProtocol,
298
- port: targetPort,
299
- path: callbackPath,
300
- redirectUri: configRedirectUri
491
+ listenHost,
492
+ listenPort,
493
+ callbackPath,
494
+ redirectUri: configRedirectUri,
495
+ isLoopback
301
496
  });
302
497
  } catch (error) {
303
498
  logger.warn('Failed to parse redirectUri, using ephemeral defaults', {
304
499
  redirectUri: configRedirectUri,
305
500
  error: error instanceof Error ? error.message : String(error)
306
501
  });
307
- // Continue with defaults (127.0.0.1, port 0, http, /callback)
502
+ // Continue with defaults (localhost, port 0, http, /callback)
308
503
  }
309
504
  }
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, ()=>{
505
+ // Generate PKCE challenge + state
506
+ const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
507
+ const stateId = randomUUID();
508
+ // Store PKCE verifier for callback (5 minute TTL)
509
+ await this.createPendingAuth({
510
+ state: stateId,
511
+ codeVerifier
512
+ });
513
+ let server = null;
514
+ let serverPort;
515
+ let finalRedirectUri; // set after listen
516
+ // Create ephemeral server with OS-assigned port (RFC 8252)
517
+ server = this.createOAuthCallbackServer({
518
+ callbackPath,
519
+ finalRedirectUri: ()=>finalRedirectUri,
520
+ onDone: ()=>{
521
+ server === null || server === void 0 ? void 0 : server.close();
522
+ },
523
+ onError: (err)=>{
524
+ logger.error('Ephemeral OAuth server error', {
525
+ error: err instanceof Error ? err.message : String(err)
526
+ });
527
+ server === null || server === void 0 ? void 0 : server.close();
528
+ }
529
+ });
530
+ // Start listening
531
+ await new Promise((resolve, reject)=>{
532
+ server === null || server === void 0 ? void 0 : server.listen(listenPort, listenHost, ()=>{
396
533
  const address = server === null || server === void 0 ? void 0 : server.address();
397
534
  if (!address || typeof address === 'string') {
398
535
  server === null || server === void 0 ? void 0 : server.close();
@@ -402,52 +539,41 @@ import { AuthRequiredError } from '../types.js';
402
539
  serverPort = address.port;
403
540
  // Construct final redirect URI
404
541
  if (useConfiguredUri && configRedirectUri) {
405
- // Use configured redirect URI as-is for production
406
542
  finalRedirectUri = configRedirectUri;
407
543
  } else {
408
- // Construct ephemeral redirect URI with actual server port
409
- finalRedirectUri = `${targetProtocol}//${targetHost}:${serverPort}${callbackPath}`;
544
+ finalRedirectUri = `http://localhost:${serverPort}${callbackPath}`;
410
545
  }
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
546
  logger.info('Ephemeral OAuth server started', {
422
547
  port: serverPort,
423
- headless
548
+ headless,
549
+ service
424
550
  });
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
- }
551
+ resolve();
442
552
  });
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
553
  });
554
+ // Timeout after 5 minutes (match middleware polling timeout)
555
+ setTimeout(()=>{
556
+ if (server) {
557
+ server.close();
558
+ // Best-effort cleanup if user never completes flow:
559
+ // delete pending so a future attempt can restart cleanly.
560
+ void tokenStore.delete(this.pendingKey(stateId));
561
+ }
562
+ }, OAUTH_TIMEOUT_MS);
563
+ // Build auth URL - SAME helper as persistent mode
564
+ const authUrl = this.buildAuthUrl({
565
+ tenantId,
566
+ clientId,
567
+ redirectUri: finalRedirectUri,
568
+ scope,
569
+ codeChallenge,
570
+ state: stateId
571
+ });
572
+ return {
573
+ kind: 'auth_url',
574
+ provider: service,
575
+ url: authUrl
576
+ };
451
577
  }
452
578
  async exchangeCodeForToken(code, codeVerifier, redirectUri) {
453
579
  const { clientId, clientSecret, tenantId } = this.config;
@@ -478,18 +604,18 @@ import { AuthRequiredError } from '../types.js';
478
604
  return await response.json();
479
605
  }
480
606
  async refreshAccessToken(refreshToken) {
481
- const { clientId, clientSecret, tenantId, scope } = this.config;
607
+ const { clientId, clientSecret, tenantId } = this.config;
482
608
  const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
483
609
  const params = {
484
610
  refresh_token: refreshToken,
485
611
  client_id: clientId,
486
- grant_type: 'refresh_token',
487
- scope
612
+ grant_type: 'refresh_token'
488
613
  };
489
614
  // Only include client_secret for confidential clients
490
615
  if (clientSecret) {
491
616
  params.client_secret = clientSecret;
492
617
  }
618
+ // NOTE: We intentionally do NOT include "scope" in refresh requests.
493
619
  const body = new URLSearchParams(params);
494
620
  const response = await fetchWithTimeout(tokenUrl, {
495
621
  method: 'POST',
@@ -515,6 +641,28 @@ import { AuthRequiredError } from '../types.js';
515
641
  };
516
642
  }
517
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
+ /**
518
666
  * Create auth middleware for single-user context (single active account per service)
519
667
  *
520
668
  * Single-user mode:
@@ -550,31 +698,62 @@ import { AuthRequiredError } from '../types.js';
550
698
  extra = allArgs[extraPosition] || {};
551
699
  allArgs[extraPosition] = extra;
552
700
  }
553
- try {
554
- // Check for backchannel override via _meta.accountId
555
- let accountId;
701
+ // Helper: retry once after open+poll completes
702
+ const ensureAuthenticatedOrThrow = async ()=>{
556
703
  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, {
704
+ // Check for backchannel override via _meta.accountId
705
+ let accountId;
706
+ try {
707
+ var _ref;
708
+ var _extra__meta;
709
+ accountId = (_ref = (_extra__meta = extra._meta) === null || _extra__meta === void 0 ? void 0 : _extra__meta.accountId) !== null && _ref !== void 0 ? _ref : await getActiveAccount(tokenStore, {
710
+ service
711
+ });
712
+ } catch (error) {
713
+ if (error instanceof Error && (error.code === 'REQUIRES_AUTHENTICATION' || error.name === 'AccountManagerError')) {
714
+ accountId = undefined;
715
+ } else {
716
+ throw error;
717
+ }
718
+ }
719
+ // Eagerly validate token exists or trigger OAuth flow
720
+ await this.getAccessToken(accountId);
721
+ // After OAuth flow completes, get the actual accountId (email) that was set
722
+ const effectiveAccountId = accountId !== null && accountId !== void 0 ? accountId : await getActiveAccount(tokenStore, {
560
723
  service
561
724
  });
725
+ if (!effectiveAccountId) {
726
+ throw new Error(`No account found after OAuth flow for service ${service}`);
727
+ }
728
+ return effectiveAccountId;
562
729
  } catch (error) {
563
- if (error instanceof Error && (error.code === 'REQUIRES_AUTHENTICATION' || error.name === 'AccountManagerError')) {
564
- accountId = undefined;
565
- } else {
566
- throw error;
730
+ if (error instanceof AuthRequiredError && error.descriptor.kind === 'auth_url') {
731
+ // Headless: don't open/poll; just propagate to outer handler to return auth_required.
732
+ if (this.config.headless) throw error;
733
+ // Non-headless: open once + poll until callback completes, then retry token acquisition.
734
+ const authUrl = new URL(error.descriptor.url);
735
+ const state = authUrl.searchParams.get('state');
736
+ if (!state) throw new Error('Auth URL missing state parameter');
737
+ if (!this.openedStates.has(state)) {
738
+ this.openedStates.add(state);
739
+ open(error.descriptor.url).catch((e)=>{
740
+ logger.info('Failed to open browser automatically', {
741
+ error: e instanceof Error ? e.message : String(e)
742
+ });
743
+ });
744
+ }
745
+ // Block until callback completes (or timeout)
746
+ await this.waitForOAuthCompletion(state);
747
+ // Cleanup pending state after we observe completion
748
+ await this.deletePendingAuth(state);
749
+ // Retry after completion
750
+ return await ensureAuthenticatedOrThrow();
567
751
  }
752
+ throw error;
568
753
  }
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
- }
754
+ };
755
+ try {
756
+ const effectiveAccountId = await ensureAuthenticatedOrThrow();
578
757
  const auth = this.toAuthProvider(effectiveAccountId);
579
758
  // Inject authContext and logger into extra
580
759
  extra.authContext = {
@@ -632,6 +811,8 @@ import { AuthRequiredError } from '../types.js';
632
811
  };
633
812
  }
634
813
  constructor(config){
814
+ // Track URLs we've already opened for a given state within this process (prevents tab spam).
815
+ this.openedStates = new Set();
635
816
  this.config = config;
636
817
  }
637
818
  }