@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.
- package/README.md +8 -0
- package/dist/cjs/index.d.cts +2 -1
- package/dist/cjs/index.d.ts +2 -1
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/lib/dcr-router.js.map +1 -1
- package/dist/cjs/lib/dcr-utils.js.map +1 -1
- package/dist/cjs/lib/dcr-verify.js.map +1 -1
- package/dist/cjs/lib/fetch-with-timeout.js.map +1 -1
- package/dist/cjs/lib/loopback-router.d.cts +8 -0
- package/dist/cjs/lib/loopback-router.d.ts +8 -0
- package/dist/cjs/lib/loopback-router.js +219 -0
- package/dist/cjs/lib/loopback-router.js.map +1 -0
- package/dist/cjs/lib/token-verifier.js.map +1 -1
- package/dist/cjs/providers/dcr.js.map +1 -1
- package/dist/cjs/providers/device-code.js.map +1 -1
- package/dist/cjs/providers/loopback-oauth.d.cts +93 -18
- package/dist/cjs/providers/loopback-oauth.d.ts +93 -18
- package/dist/cjs/providers/loopback-oauth.js +877 -491
- package/dist/cjs/providers/loopback-oauth.js.map +1 -1
- package/dist/cjs/schemas/index.js +1 -1
- package/dist/cjs/schemas/index.js.map +1 -1
- package/dist/cjs/setup/config.d.cts +4 -1
- package/dist/cjs/setup/config.d.ts +4 -1
- package/dist/cjs/setup/config.js +7 -4
- package/dist/cjs/setup/config.js.map +1 -1
- package/dist/cjs/types.js.map +1 -1
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/lib/dcr-router.js.map +1 -1
- package/dist/esm/lib/dcr-utils.js.map +1 -1
- package/dist/esm/lib/dcr-verify.js.map +1 -1
- package/dist/esm/lib/fetch-with-timeout.js.map +1 -1
- package/dist/esm/lib/loopback-router.d.ts +8 -0
- package/dist/esm/lib/loopback-router.js +32 -0
- package/dist/esm/lib/loopback-router.js.map +1 -0
- package/dist/esm/lib/token-verifier.js.map +1 -1
- package/dist/esm/providers/dcr.js.map +1 -1
- package/dist/esm/providers/device-code.js +2 -2
- package/dist/esm/providers/device-code.js.map +1 -1
- package/dist/esm/providers/loopback-oauth.d.ts +93 -18
- package/dist/esm/providers/loopback-oauth.js +470 -289
- package/dist/esm/providers/loopback-oauth.js.map +1 -1
- package/dist/esm/schemas/index.js +1 -1
- package/dist/esm/schemas/index.js.map +1 -1
- package/dist/esm/setup/config.d.ts +4 -1
- package/dist/esm/setup/config.js +7 -4
- package/dist/esm/setup/config.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
const authUrl =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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:
|
|
108
|
-
url: authUrl
|
|
109
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 (
|
|
502
|
+
// Continue with defaults (localhost, port 0, http, /callback)
|
|
308
503
|
}
|
|
309
504
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
let accountId;
|
|
701
|
+
// Helper: retry once after open+poll completes
|
|
702
|
+
const ensureAuthenticatedOrThrow = async ()=>{
|
|
556
703
|
try {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
}
|