@mcp-z/oauth-microsoft 1.0.1 → 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.
@@ -13,12 +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
+ *
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.
16
26
  */ import { addAccount, generatePKCE, getActiveAccount, getErrorTemplate, getSuccessTemplate, getToken, setAccountInfo, setActiveAccount, setToken } from '@mcp-z/oauth';
17
27
  import { randomUUID } from 'crypto';
18
28
  import * as http from 'http';
19
29
  import open from 'open';
20
30
  import { fetchWithTimeout } from '../lib/fetch-with-timeout.js';
21
31
  import { AuthRequiredError } from '../types.js';
32
+ const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
33
+ const OAUTH_POLL_MS = 500;
22
34
  /**
23
35
  * Loopback OAuth Client (RFC 8252 Section 7.3)
24
36
  *
@@ -83,21 +95,19 @@ import { AuthRequiredError } from '../types.js';
83
95
  const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
84
96
  const stateId = randomUUID();
85
97
  // Store PKCE verifier for callback (5 minute TTL)
86
- await tokenStore.set(`${service}:pending:${stateId}`, {
87
- codeVerifier,
88
- createdAt: Date.now()
89
- }, 5 * 60 * 1000);
98
+ await this.createPendingAuth({
99
+ state: stateId,
100
+ codeVerifier
101
+ });
90
102
  // Build auth URL with configured redirect_uri
91
- const authUrl = new URL(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`);
92
- authUrl.searchParams.set('client_id', clientId);
93
- authUrl.searchParams.set('redirect_uri', redirectUri);
94
- authUrl.searchParams.set('response_type', 'code');
95
- authUrl.searchParams.set('scope', scope);
96
- authUrl.searchParams.set('response_mode', 'query');
97
- authUrl.searchParams.set('code_challenge', codeChallenge);
98
- authUrl.searchParams.set('code_challenge_method', 'S256');
99
- authUrl.searchParams.set('state', stateId);
100
- authUrl.searchParams.set('prompt', 'select_account');
103
+ const authUrl = this.buildAuthUrl({
104
+ tenantId,
105
+ clientId,
106
+ redirectUri,
107
+ scope,
108
+ codeChallenge,
109
+ state: stateId
110
+ });
101
111
  logger.info('OAuth required - persistent callback mode', {
102
112
  service,
103
113
  redirectUri
@@ -105,7 +115,7 @@ import { AuthRequiredError } from '../types.js';
105
115
  throw new AuthRequiredError({
106
116
  kind: 'auth_url',
107
117
  provider: service,
108
- url: authUrl.toString()
118
+ url: authUrl
109
119
  });
110
120
  }
111
121
  // Ephemeral callback mode (local development)
@@ -113,31 +123,8 @@ import { AuthRequiredError } from '../types.js';
113
123
  service,
114
124
  headless: this.config.headless
115
125
  });
116
- const { token, email } = await this.performEphemeralOAuthFlow();
117
- await setToken(tokenStore, {
118
- accountId: email,
119
- service
120
- }, token);
121
- await addAccount(tokenStore, {
122
- service,
123
- accountId: email
124
- });
125
- await setActiveAccount(tokenStore, {
126
- service,
127
- accountId: email
128
- });
129
- await setAccountInfo(tokenStore, {
130
- service,
131
- accountId: email
132
- }, {
133
- email,
134
- addedAt: new Date().toISOString()
135
- });
136
- logger.info('OAuth flow completed', {
137
- service,
138
- accountId: email
139
- });
140
- return token.accessToken;
126
+ const descriptor = await this.startEphemeralOAuthFlow();
127
+ throw new AuthRequiredError(descriptor);
141
128
  }
142
129
  /**
143
130
  * Convert to Microsoft Graph-compatible auth provider
@@ -202,8 +189,277 @@ import { AuthRequiredError } from '../types.js';
202
189
  });
203
190
  return email;
204
191
  }
205
- async performEphemeralOAuthFlow() {
206
- const { clientId, tenantId, scope, headless, logger, redirectUri: configRedirectUri } = this.config;
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;
207
463
  // Server listen configuration (where ephemeral server binds)
208
464
  let listenHost = 'localhost'; // Default: localhost for ephemeral loopback
209
465
  let listenPort = 0; // Default: OS-assigned ephemeral port
@@ -246,95 +502,34 @@ import { AuthRequiredError } from '../types.js';
246
502
  // Continue with defaults (localhost, port 0, http, /callback)
247
503
  }
248
504
  }
249
- return new Promise((resolve, reject)=>{
250
- // Generate PKCE challenge
251
- const { verifier: codeVerifier, challenge: codeChallenge } = generatePKCE();
252
- let server = null;
253
- let serverPort;
254
- let finalRedirectUri; // Will be set in server.listen callback
255
- // Create ephemeral server with OS-assigned port (RFC 8252)
256
- server = http.createServer(async (req, res)=>{
257
- if (!req.url) {
258
- res.writeHead(400, {
259
- 'Content-Type': 'text/html'
260
- });
261
- res.end(getErrorTemplate('Invalid request'));
262
- server === null || server === void 0 ? void 0 : server.close();
263
- reject(new Error('Invalid request: missing URL'));
264
- return;
265
- }
266
- const url = new URL(req.url, `http://localhost:${serverPort}`);
267
- if (url.pathname === callbackPath) {
268
- const code = url.searchParams.get('code');
269
- const error = url.searchParams.get('error');
270
- if (error) {
271
- res.writeHead(400, {
272
- 'Content-Type': 'text/html'
273
- });
274
- res.end(getErrorTemplate(error));
275
- server === null || server === void 0 ? void 0 : server.close();
276
- reject(new Error(`OAuth error: ${error}`));
277
- return;
278
- }
279
- if (!code) {
280
- res.writeHead(400, {
281
- 'Content-Type': 'text/html'
282
- });
283
- res.end(getErrorTemplate('No authorization code received'));
284
- server === null || server === void 0 ? void 0 : server.close();
285
- reject(new Error('No authorization code received'));
286
- return;
287
- }
288
- try {
289
- // Exchange code for token (must use same redirect_uri as in authorization request)
290
- const tokenResponse = await this.exchangeCodeForToken(code, codeVerifier, finalRedirectUri);
291
- // Build cached token
292
- const cachedToken = {
293
- accessToken: tokenResponse.access_token,
294
- ...tokenResponse.refresh_token !== undefined && {
295
- refreshToken: tokenResponse.refresh_token
296
- },
297
- ...tokenResponse.expires_in !== undefined && {
298
- expiresAt: Date.now() + tokenResponse.expires_in * 1000
299
- },
300
- ...tokenResponse.scope !== undefined && {
301
- scope: tokenResponse.scope
302
- }
303
- };
304
- // Fetch user email immediately using the new access token
305
- const email = await this.fetchUserEmailFromToken(tokenResponse.access_token);
306
- res.writeHead(200, {
307
- 'Content-Type': 'text/html'
308
- });
309
- res.end(getSuccessTemplate());
310
- server === null || server === void 0 ? void 0 : server.close();
311
- resolve({
312
- token: cachedToken,
313
- email
314
- });
315
- } catch (exchangeError) {
316
- logger.error('Token exchange failed', {
317
- error: exchangeError instanceof Error ? exchangeError.message : String(exchangeError)
318
- });
319
- res.writeHead(500, {
320
- 'Content-Type': 'text/html'
321
- });
322
- res.end(getErrorTemplate('Token exchange failed'));
323
- server === null || server === void 0 ? void 0 : server.close();
324
- reject(exchangeError);
325
- }
326
- } else {
327
- res.writeHead(404, {
328
- 'Content-Type': 'text/plain'
329
- });
330
- res.end('Not Found');
331
- }
332
- });
333
- // Listen on configured host/port
334
- // - For loopback (default): localhost with OS-assigned port
335
- // - For configured loopback: specific localhost port from redirectUri
336
- // - For cloud deployment: 0.0.0.0:${PORT} from environment
337
- server.listen(listenPort, listenHost, ()=>{
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, ()=>{
338
533
  const address = server === null || server === void 0 ? void 0 : server.address();
339
534
  if (!address || typeof address === 'string') {
340
535
  server === null || server === void 0 ? void 0 : server.close();
@@ -344,52 +539,41 @@ import { AuthRequiredError } from '../types.js';
344
539
  serverPort = address.port;
345
540
  // Construct final redirect URI
346
541
  if (useConfiguredUri && configRedirectUri) {
347
- // Use configured redirect URI as-is (public URL for cloud, or specific local URL)
348
542
  finalRedirectUri = configRedirectUri;
349
543
  } else {
350
- // Construct ephemeral redirect URI with actual server port (default local behavior)
351
544
  finalRedirectUri = `http://localhost:${serverPort}${callbackPath}`;
352
545
  }
353
- // Build Microsoft auth URL
354
- const authUrl = new URL(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`);
355
- authUrl.searchParams.set('client_id', clientId);
356
- authUrl.searchParams.set('redirect_uri', finalRedirectUri);
357
- authUrl.searchParams.set('response_type', 'code');
358
- authUrl.searchParams.set('scope', scope);
359
- authUrl.searchParams.set('response_mode', 'query');
360
- authUrl.searchParams.set('code_challenge', codeChallenge);
361
- authUrl.searchParams.set('code_challenge_method', 'S256');
362
- authUrl.searchParams.set('prompt', 'select_account');
363
546
  logger.info('Ephemeral OAuth server started', {
364
547
  port: serverPort,
365
- headless
548
+ headless,
549
+ service
366
550
  });
367
- if (headless) {
368
- // Headless mode: Print auth URL to stderr (stdout is MCP protocol)
369
- console.error('\n🔐 OAuth Authorization Required');
370
- console.error('📋 Please visit this URL in your browser:\n');
371
- console.error(` ${authUrl.toString()}\n`);
372
- console.error('⏳ Waiting for authorization...\n');
373
- } else {
374
- // Interactive mode: Open browser automatically
375
- logger.info('Opening browser for OAuth authorization');
376
- open(authUrl.toString()).catch((error)=>{
377
- logger.info('Failed to open browser automatically', {
378
- error: error.message
379
- });
380
- console.error('\n🔐 OAuth Authorization Required');
381
- console.error(` ${authUrl.toString()}\n`);
382
- });
383
- }
551
+ resolve();
384
552
  });
385
- // Timeout after 5 minutes
386
- setTimeout(()=>{
387
- if (server) {
388
- server.close();
389
- reject(new Error('OAuth flow timed out after 5 minutes'));
390
- }
391
- }, 5 * 60 * 1000);
392
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
+ };
393
577
  }
394
578
  async exchangeCodeForToken(code, codeVerifier, redirectUri) {
395
579
  const { clientId, clientSecret, tenantId } = this.config;
@@ -420,18 +604,18 @@ import { AuthRequiredError } from '../types.js';
420
604
  return await response.json();
421
605
  }
422
606
  async refreshAccessToken(refreshToken) {
423
- const { clientId, clientSecret, tenantId, scope } = this.config;
607
+ const { clientId, clientSecret, tenantId } = this.config;
424
608
  const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
425
609
  const params = {
426
610
  refresh_token: refreshToken,
427
611
  client_id: clientId,
428
- grant_type: 'refresh_token',
429
- scope
612
+ grant_type: 'refresh_token'
430
613
  };
431
614
  // Only include client_secret for confidential clients
432
615
  if (clientSecret) {
433
616
  params.client_secret = clientSecret;
434
617
  }
618
+ // NOTE: We intentionally do NOT include "scope" in refresh requests.
435
619
  const body = new URLSearchParams(params);
436
620
  const response = await fetchWithTimeout(tokenUrl, {
437
621
  method: 'POST',
@@ -464,73 +648,19 @@ import { AuthRequiredError } from '../types.js';
464
648
  * @returns Email and cached token
465
649
  */ async handleOAuthCallback(params) {
466
650
  const { code, state } = params;
467
- const { logger, service, tokenStore, redirectUri } = this.config;
651
+ const { redirectUri } = this.config;
468
652
  if (!state) {
469
653
  throw new Error('Missing state parameter in OAuth callback');
470
654
  }
471
655
  if (!redirectUri) {
472
656
  throw new Error('handleOAuthCallback requires configured redirectUri');
473
657
  }
474
- // Load pending auth (includes PKCE verifier)
475
- const pendingKey = `${service}:pending:${state}`;
476
- const pendingAuth = await tokenStore.get(pendingKey);
477
- if (!pendingAuth) {
478
- throw new Error('Invalid or expired OAuth state. Please try again.');
479
- }
480
- // Check TTL (5 minutes)
481
- if (Date.now() - pendingAuth.createdAt > 5 * 60 * 1000) {
482
- await tokenStore.delete(pendingKey);
483
- throw new Error('OAuth state expired. Please try again.');
484
- }
485
- logger.info('Processing OAuth callback', {
486
- service,
487
- state
488
- });
489
- // Exchange code for token
490
- const tokenResponse = await this.exchangeCodeForToken(code, pendingAuth.codeVerifier, redirectUri);
491
- // Create cached token
492
- const cachedToken = {
493
- accessToken: tokenResponse.access_token,
494
- refreshToken: tokenResponse.refresh_token,
495
- expiresAt: tokenResponse.expires_in ? Date.now() + tokenResponse.expires_in * 1000 : undefined,
496
- ...tokenResponse.scope !== undefined && {
497
- scope: tokenResponse.scope
498
- }
499
- };
500
- // Fetch user email
501
- const email = await this.fetchUserEmailFromToken(tokenResponse.access_token);
502
- // Store token
503
- await setToken(tokenStore, {
504
- accountId: email,
505
- service
506
- }, cachedToken);
507
- // Add account and set as active
508
- await addAccount(tokenStore, {
509
- service,
510
- accountId: email
511
- });
512
- await setActiveAccount(tokenStore, {
513
- service,
514
- accountId: email
515
- });
516
- // Store account metadata
517
- await setAccountInfo(tokenStore, {
518
- service,
519
- accountId: email
520
- }, {
521
- email,
522
- addedAt: new Date().toISOString()
523
- });
524
- // Clean up pending auth
525
- await tokenStore.delete(pendingKey);
526
- logger.info('OAuth callback completed', {
527
- service,
528
- email
658
+ // Shared callback processor (same code path as ephemeral)
659
+ return await this.processOAuthCallback({
660
+ code,
661
+ state,
662
+ redirectUri
529
663
  });
530
- return {
531
- email,
532
- token: cachedToken
533
- };
534
664
  }
535
665
  /**
536
666
  * Create auth middleware for single-user context (single active account per service)
@@ -568,31 +698,62 @@ import { AuthRequiredError } from '../types.js';
568
698
  extra = allArgs[extraPosition] || {};
569
699
  allArgs[extraPosition] = extra;
570
700
  }
571
- try {
572
- // Check for backchannel override via _meta.accountId
573
- let accountId;
701
+ // Helper: retry once after open+poll completes
702
+ const ensureAuthenticatedOrThrow = async ()=>{
574
703
  try {
575
- var _ref;
576
- var _extra__meta;
577
- 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, {
578
723
  service
579
724
  });
725
+ if (!effectiveAccountId) {
726
+ throw new Error(`No account found after OAuth flow for service ${service}`);
727
+ }
728
+ return effectiveAccountId;
580
729
  } catch (error) {
581
- if (error instanceof Error && (error.code === 'REQUIRES_AUTHENTICATION' || error.name === 'AccountManagerError')) {
582
- accountId = undefined;
583
- } else {
584
- 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();
585
751
  }
752
+ throw error;
586
753
  }
587
- // Eagerly validate token exists or trigger OAuth flow
588
- await this.getAccessToken(accountId);
589
- // After OAuth flow completes, get the actual accountId (email) that was set
590
- const effectiveAccountId = accountId !== null && accountId !== void 0 ? accountId : await getActiveAccount(tokenStore, {
591
- service
592
- });
593
- if (!effectiveAccountId) {
594
- throw new Error(`No account found after OAuth flow for service ${service}`);
595
- }
754
+ };
755
+ try {
756
+ const effectiveAccountId = await ensureAuthenticatedOrThrow();
596
757
  const auth = this.toAuthProvider(effectiveAccountId);
597
758
  // Inject authContext and logger into extra
598
759
  extra.authContext = {
@@ -650,6 +811,8 @@ import { AuthRequiredError } from '../types.js';
650
811
  };
651
812
  }
652
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();
653
816
  this.config = config;
654
817
  }
655
818
  }