oauth-callback 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -8
- package/dist/auth/browser-auth.d.ts +1 -1
- package/dist/auth/browser-auth.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5368 -241
- package/dist/mcp-types.d.ts +25 -15
- package/dist/mcp-types.d.ts.map +1 -1
- package/dist/mcp.d.ts +2 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +113 -92
- package/dist/storage/file.d.ts +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +1 -1
- package/dist/storage/memory.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/auth/browser-auth.test.ts +175 -65
- package/src/auth/browser-auth.ts +160 -116
- package/src/index.ts +16 -9
- package/src/mcp-types.ts +29 -16
- package/src/mcp.ts +1 -1
- package/src/storage/file.ts +4 -7
- package/src/storage/memory.ts +1 -5
package/src/auth/browser-auth.ts
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
/* SPDX-License-Identifier: MIT */
|
|
3
3
|
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
import {
|
|
6
|
+
OAuthStoreBrand,
|
|
7
|
+
type BrowserAuthOptions,
|
|
8
|
+
type TokenStore,
|
|
9
|
+
type OAuthStore,
|
|
10
|
+
type Tokens,
|
|
11
|
+
type ClientInfo,
|
|
12
12
|
} from "../mcp-types";
|
|
13
13
|
import { calculateExpiry } from "../utils/token";
|
|
14
14
|
import { inMemoryStore } from "../storage/memory";
|
|
15
15
|
import { getAuthCode } from "../index";
|
|
16
16
|
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
17
|
+
import {
|
|
18
|
+
exchangeAuthorization,
|
|
19
|
+
discoverAuthorizationServerMetadata,
|
|
20
|
+
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
17
21
|
import type {
|
|
18
22
|
OAuthClientInformation,
|
|
19
23
|
OAuthClientInformationFull,
|
|
@@ -56,6 +60,7 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
56
60
|
private readonly _hostname: string;
|
|
57
61
|
private readonly _callbackPath: string;
|
|
58
62
|
private readonly _authTimeout: number;
|
|
63
|
+
private readonly _redirectUrl: string;
|
|
59
64
|
private readonly _launch?: (url: string) => unknown;
|
|
60
65
|
private readonly _clientId?: string;
|
|
61
66
|
private readonly _clientSecret?: string;
|
|
@@ -63,14 +68,13 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
63
68
|
private readonly _successHtml?: string;
|
|
64
69
|
private readonly _errorHtml?: string;
|
|
65
70
|
private readonly _onRequest?: (req: Request) => void;
|
|
71
|
+
private readonly _authServerUrl?: URL;
|
|
66
72
|
|
|
67
73
|
/** Mutable OAuth state. Protected by serialization locks. */
|
|
68
74
|
private _clientInfo?: OAuthClientInformationFull;
|
|
69
75
|
private _tokens?: OAuthTokens;
|
|
76
|
+
private _expiresAt?: number; // Absolute expiry time in ms
|
|
70
77
|
private _codeVerifier?: string;
|
|
71
|
-
private _pendingAuthCode?: string;
|
|
72
|
-
private _pendingAuthState?: string;
|
|
73
|
-
private _isExchangingCode = false;
|
|
74
78
|
private _tokensLoaded = false;
|
|
75
79
|
private _loadingTokens?: Promise<void>;
|
|
76
80
|
private _authInProgress?: Promise<void>;
|
|
@@ -82,6 +86,7 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
82
86
|
this._hostname = options.hostname ?? "localhost";
|
|
83
87
|
this._callbackPath = options.callbackPath ?? "/callback";
|
|
84
88
|
this._authTimeout = options.authTimeout ?? 300000;
|
|
89
|
+
this._redirectUrl = `http://${this._hostname}:${this._port}${this._callbackPath}`;
|
|
85
90
|
this._launch = options.launch;
|
|
86
91
|
this._clientId = options.clientId;
|
|
87
92
|
this._clientSecret = options.clientSecret;
|
|
@@ -90,6 +95,9 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
90
95
|
this._successHtml = options.successHtml;
|
|
91
96
|
this._errorHtml = options.errorHtml;
|
|
92
97
|
this._onRequest = options.onRequest;
|
|
98
|
+
this._authServerUrl = options.authServerUrl
|
|
99
|
+
? new URL(options.authServerUrl)
|
|
100
|
+
: undefined;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
private async _ensureTokensLoaded(): Promise<void> {
|
|
@@ -107,34 +115,36 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
107
115
|
// Load tokens
|
|
108
116
|
const stored = await this._store.get(this._storeKey);
|
|
109
117
|
if (stored) {
|
|
118
|
+
this._expiresAt = stored.expiresAt;
|
|
119
|
+
// SDK doesn't inspect expires_in from tokens() - we handle expiry via expiresAt
|
|
110
120
|
this._tokens = {
|
|
111
121
|
access_token: stored.accessToken,
|
|
112
122
|
token_type: "Bearer",
|
|
113
123
|
refresh_token: stored.refreshToken,
|
|
114
|
-
expires_in: stored.expiresAt
|
|
115
|
-
? Math.floor((stored.expiresAt - Date.now()) / 1000)
|
|
116
|
-
: undefined,
|
|
117
124
|
scope: stored.scope,
|
|
118
125
|
};
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
// Load client info if using extended store
|
|
122
128
|
if (this._isOAuthStore(this._store)) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
// Load DCR client only if no static clientId is configured.
|
|
130
|
+
// Static clientId takes precedence; persisted DCR client is ignored.
|
|
131
|
+
if (!this._clientId) {
|
|
132
|
+
const clientInfo = await this._store.getClient(this._storeKey);
|
|
133
|
+
if (clientInfo?.clientId) {
|
|
134
|
+
this._clientInfo = {
|
|
135
|
+
client_id: clientInfo.clientId,
|
|
136
|
+
client_secret: clientInfo.clientSecret,
|
|
137
|
+
client_id_issued_at: clientInfo.clientIdIssuedAt,
|
|
138
|
+
client_secret_expires_at: clientInfo.clientSecretExpiresAt,
|
|
139
|
+
redirect_uris: [this.redirectUrl],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
132
142
|
}
|
|
133
143
|
|
|
134
|
-
// Load
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
this._codeVerifier =
|
|
144
|
+
// Load PKCE verifier for crash recovery
|
|
145
|
+
const verifier = await this._store.getCodeVerifier(this._storeKey);
|
|
146
|
+
if (verifier) {
|
|
147
|
+
this._codeVerifier = verifier;
|
|
138
148
|
}
|
|
139
149
|
}
|
|
140
150
|
|
|
@@ -145,24 +155,22 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
145
155
|
}
|
|
146
156
|
}
|
|
147
157
|
|
|
148
|
-
private _isOAuthStore(store:
|
|
149
|
-
return
|
|
150
|
-
typeof store.getClient === "function" &&
|
|
151
|
-
typeof store.setClient === "function" &&
|
|
152
|
-
typeof store.getSession === "function"
|
|
153
|
-
);
|
|
158
|
+
private _isOAuthStore(store: TokenStore): store is OAuthStore {
|
|
159
|
+
return OAuthStoreBrand in store;
|
|
154
160
|
}
|
|
155
161
|
|
|
156
162
|
get redirectUrl(): string {
|
|
157
|
-
return
|
|
163
|
+
return this._redirectUrl;
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
get clientMetadata(): OAuthClientMetadata {
|
|
167
|
+
// Auth method is fixed based on whether clientSecret was provided at construction.
|
|
168
|
+
// Don't check _clientInfo.client_secret here - metadata must be stable for DCR.
|
|
161
169
|
return {
|
|
162
170
|
client_name: "OAuth Callback Handler",
|
|
163
171
|
client_uri: "https://github.com/kriasoft/oauth-callback",
|
|
164
172
|
redirect_uris: [this.redirectUrl],
|
|
165
|
-
grant_types: ["authorization_code"
|
|
173
|
+
grant_types: ["authorization_code"],
|
|
166
174
|
response_types: ["code"],
|
|
167
175
|
scope: this._scope,
|
|
168
176
|
token_endpoint_auth_method: this._clientSecret
|
|
@@ -218,10 +226,8 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
218
226
|
return undefined;
|
|
219
227
|
}
|
|
220
228
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
if (stored?.expiresAt && Date.now() >= stored.expiresAt - 60000) {
|
|
224
|
-
// Token expired (with 60s buffer). Refresh not yet implemented — trigger re-auth.
|
|
229
|
+
// Return undefined when expired (with 60s buffer) to signal MCP SDK to re-authenticate
|
|
230
|
+
if (this._expiresAt && Date.now() >= this._expiresAt - 60000) {
|
|
225
231
|
return undefined;
|
|
226
232
|
}
|
|
227
233
|
|
|
@@ -230,28 +236,41 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
230
236
|
|
|
231
237
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
232
238
|
this._tokens = tokens;
|
|
239
|
+
this._expiresAt = tokens.expires_in
|
|
240
|
+
? calculateExpiry(tokens.expires_in)
|
|
241
|
+
: undefined;
|
|
233
242
|
this._tokensLoaded = true;
|
|
234
243
|
|
|
235
244
|
const storedTokens: Tokens = {
|
|
236
245
|
accessToken: tokens.access_token,
|
|
237
246
|
refreshToken: tokens.refresh_token,
|
|
238
|
-
expiresAt:
|
|
239
|
-
? calculateExpiry(tokens.expires_in)
|
|
240
|
-
: undefined,
|
|
247
|
+
expiresAt: this._expiresAt,
|
|
241
248
|
scope: tokens.scope,
|
|
242
249
|
};
|
|
243
250
|
|
|
244
251
|
await this._store.set(this._storeKey, storedTokens);
|
|
245
252
|
}
|
|
246
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Completes the full OAuth authorization flow synchronously.
|
|
256
|
+
*
|
|
257
|
+
* Despite the name (dictated by the MCP SDK interface), this method does more
|
|
258
|
+
* than redirect: it launches the browser, captures the callback, validates state,
|
|
259
|
+
* exchanges the authorization code for tokens, and persists them to storage.
|
|
260
|
+
*
|
|
261
|
+
* Concurrent calls are serialized: subsequent callers wait for and share the
|
|
262
|
+
* result (or error) of the in-flight attempt.
|
|
263
|
+
*
|
|
264
|
+
* @see ADR-002 for rationale on immediate token exchange
|
|
265
|
+
*/
|
|
247
266
|
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
|
248
|
-
|
|
267
|
+
// Concurrent callers share both success and failure of the in-flight attempt.
|
|
249
268
|
if (this._authInProgress) {
|
|
250
269
|
await this._authInProgress;
|
|
251
270
|
return;
|
|
252
271
|
}
|
|
253
272
|
|
|
254
|
-
this._authInProgress = this.
|
|
273
|
+
this._authInProgress = this._completeAuthorizationFlow(authorizationUrl);
|
|
255
274
|
try {
|
|
256
275
|
await this._authInProgress;
|
|
257
276
|
} finally {
|
|
@@ -259,7 +278,9 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
259
278
|
}
|
|
260
279
|
}
|
|
261
280
|
|
|
262
|
-
private async
|
|
281
|
+
private async _completeAuthorizationFlow(
|
|
282
|
+
authorizationUrl: URL,
|
|
283
|
+
): Promise<void> {
|
|
263
284
|
// Use managed mode (with launch) or headless mode based on _launch presence
|
|
264
285
|
const baseOptions = {
|
|
265
286
|
port: this._port,
|
|
@@ -281,29 +302,97 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
281
302
|
: baseOptions,
|
|
282
303
|
);
|
|
283
304
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
305
|
+
// getAuthCode() throws OAuthError if result.error exists; this is a defensive
|
|
306
|
+
// check for the edge case where neither code nor error is present.
|
|
307
|
+
if (!result.code) {
|
|
308
|
+
throw new Error("No authorization code received");
|
|
309
|
+
}
|
|
287
310
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
311
|
+
// Validate state from callback against the URL we were given (CSRF protection).
|
|
312
|
+
// Works regardless of whether state() was used - validates whatever is in the URL.
|
|
313
|
+
const expectedState = authorizationUrl.searchParams.get("state");
|
|
314
|
+
if (expectedState && result.state !== expectedState) {
|
|
315
|
+
throw new Error("OAuth state mismatch - possible CSRF attack");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Exchange auth code for tokens immediately after capture.
|
|
320
|
+
*
|
|
321
|
+
* The MCP SDK's auth() returns 'REDIRECT' after redirectToAuthorization()
|
|
322
|
+
* without re-checking for tokens. By exchanging now, subsequent auth calls
|
|
323
|
+
* will find valid tokens and return 'AUTHORIZED'.
|
|
324
|
+
*
|
|
325
|
+
* This enables synchronous browser flows for CLI/desktop apps where the
|
|
326
|
+
* callback is captured in-process rather than via page redirect.
|
|
327
|
+
*/
|
|
328
|
+
await this._exchangeCodeForTokens(authorizationUrl, result.code);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Exchange authorization code for tokens and persist them.
|
|
333
|
+
*
|
|
334
|
+
* The MCP SDK's auth() returns 'REDIRECT' after redirectToAuthorization() without
|
|
335
|
+
* re-checking for tokens, causing the transport to throw UnauthorizedError. However,
|
|
336
|
+
* tokens are now saved, so a subsequent connect() attempt will succeed.
|
|
337
|
+
*
|
|
338
|
+
* @see ADR-002 for the recommended retry pattern with a fresh transport
|
|
339
|
+
*/
|
|
340
|
+
private async _exchangeCodeForTokens(
|
|
341
|
+
authorizationUrl: URL,
|
|
342
|
+
code: string,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
// Derive auth server URL from authorization endpoint origin.
|
|
345
|
+
// If the token endpoint is on a different origin, authServerUrl must be explicitly configured.
|
|
346
|
+
const authServerUrl =
|
|
347
|
+
this._authServerUrl ?? new URL("/", authorizationUrl.origin);
|
|
348
|
+
|
|
349
|
+
// Discover token endpoint; non-fatal if .well-known is unavailable
|
|
350
|
+
const metadata = await discoverAuthorizationServerMetadata(
|
|
351
|
+
authServerUrl,
|
|
352
|
+
).catch(() => undefined);
|
|
353
|
+
|
|
354
|
+
const clientInfo = await this.clientInformation();
|
|
355
|
+
if (!clientInfo) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
"Client information required for token exchange. " +
|
|
358
|
+
"Provide clientId in options or ensure DCR succeeded.",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!this._codeVerifier) {
|
|
363
|
+
throw new Error("Code verifier required for token exchange");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let tokens: OAuthTokens;
|
|
367
|
+
try {
|
|
368
|
+
tokens = await exchangeAuthorization(authServerUrl, {
|
|
369
|
+
metadata,
|
|
370
|
+
clientInformation: clientInfo,
|
|
371
|
+
authorizationCode: code,
|
|
372
|
+
codeVerifier: this._codeVerifier,
|
|
373
|
+
redirectUri: this.redirectUrl,
|
|
374
|
+
});
|
|
375
|
+
} catch (error) {
|
|
376
|
+
// Improve error message when discovery failed and authServerUrl wasn't explicitly set.
|
|
377
|
+
// This helps users diagnose cases where auth and token endpoints are on different origins.
|
|
378
|
+
if (!this._authServerUrl && !metadata) {
|
|
379
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Token exchange failed: ${msg}. ` +
|
|
382
|
+
`If the token endpoint differs from ${authorizationUrl.origin}, set authServerUrl explicitly.`,
|
|
383
|
+
);
|
|
293
384
|
}
|
|
294
|
-
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await this.saveTokens(tokens);
|
|
295
389
|
}
|
|
296
390
|
|
|
297
391
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
|
298
392
|
this._codeVerifier = codeVerifier;
|
|
299
393
|
|
|
300
|
-
// Persist session state if using extended store
|
|
301
394
|
if (this._isOAuthStore(this._store)) {
|
|
302
|
-
|
|
303
|
-
codeVerifier,
|
|
304
|
-
state: this._pendingAuthState,
|
|
305
|
-
};
|
|
306
|
-
await this._store.setSession(this._storeKey, session);
|
|
395
|
+
await this._store.setCodeVerifier(this._storeKey, codeVerifier);
|
|
307
396
|
}
|
|
308
397
|
}
|
|
309
398
|
|
|
@@ -317,90 +406,45 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
317
406
|
async invalidateCredentials(
|
|
318
407
|
scope: "all" | "client" | "tokens" | "verifier",
|
|
319
408
|
): Promise<void> {
|
|
320
|
-
/**
|
|
321
|
-
* SDK behavioral dependency: The MCP SDK may call invalidate("all") during
|
|
322
|
-
* token exchange if the authorization server returns an error. The call
|
|
323
|
-
* sequence we protect against:
|
|
324
|
-
*
|
|
325
|
-
* 1. SDK calls getPendingAuthCode() → sets _isExchangingCode = true
|
|
326
|
-
* 2. SDK calls codeVerifier() to build token request
|
|
327
|
-
* 3. SDK sends token exchange request to authorization server
|
|
328
|
-
* 4. Server returns error → SDK calls invalidateCredentials("all")
|
|
329
|
-
* 5. SDK retries from step 2, but verifier is gone → permanent failure
|
|
330
|
-
*
|
|
331
|
-
* Without this guard, step 4 would clear the verifier needed for step 5.
|
|
332
|
-
* The flag is reset when SDK calls invalidate("client") after exchange.
|
|
333
|
-
*/
|
|
334
|
-
if (scope === "all" && this._isExchangingCode) {
|
|
335
|
-
/** Only clear tokens; preserve client and verifier for ongoing exchange. */
|
|
336
|
-
this._tokens = undefined;
|
|
337
|
-
await this._store.delete(this._storeKey);
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (this._isExchangingCode && (scope === "client" || scope === "all")) {
|
|
342
|
-
this._isExchangingCode = false;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
409
|
switch (scope) {
|
|
346
410
|
case "all":
|
|
411
|
+
// Scoped deletion: only clear data for this storeKey, not the entire store
|
|
347
412
|
this._clientInfo = undefined;
|
|
348
413
|
this._tokens = undefined;
|
|
414
|
+
this._expiresAt = undefined;
|
|
349
415
|
this._codeVerifier = undefined;
|
|
350
416
|
this._tokensLoaded = false;
|
|
351
|
-
await this._store.
|
|
417
|
+
await this._store.delete(this._storeKey);
|
|
418
|
+
if (this._isOAuthStore(this._store)) {
|
|
419
|
+
await this._store.deleteClient(this._storeKey);
|
|
420
|
+
await this._store.deleteCodeVerifier(this._storeKey);
|
|
421
|
+
}
|
|
352
422
|
break;
|
|
353
423
|
case "client":
|
|
354
424
|
this._clientInfo = undefined;
|
|
355
425
|
if (this._isOAuthStore(this._store)) {
|
|
356
|
-
|
|
357
|
-
await this._store.setClient(this._storeKey, { clientId: "" });
|
|
426
|
+
await this._store.deleteClient(this._storeKey);
|
|
358
427
|
}
|
|
359
428
|
break;
|
|
360
429
|
case "tokens":
|
|
361
430
|
this._tokens = undefined;
|
|
431
|
+
this._expiresAt = undefined;
|
|
362
432
|
await this._store.delete(this._storeKey);
|
|
363
433
|
break;
|
|
364
434
|
case "verifier":
|
|
365
435
|
this._codeVerifier = undefined;
|
|
366
436
|
if (this._isOAuthStore(this._store)) {
|
|
367
|
-
|
|
368
|
-
await this._store.setSession(this._storeKey, {});
|
|
437
|
+
await this._store.deleteCodeVerifier(this._storeKey);
|
|
369
438
|
}
|
|
370
439
|
break;
|
|
371
440
|
}
|
|
372
441
|
}
|
|
373
442
|
|
|
443
|
+
/** Delegates RFC 8707 resource validation to SDK default behavior. */
|
|
374
444
|
async validateResourceURL(
|
|
375
445
|
_serverUrl: string | URL,
|
|
376
446
|
_resource?: string,
|
|
377
447
|
): Promise<URL | undefined> {
|
|
378
448
|
return undefined;
|
|
379
449
|
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Retrieves pending auth code from browser callback.
|
|
383
|
-
* @returns Auth code and state, or undefined if none pending
|
|
384
|
-
* @sideeffect Marks exchange in progress for invalidate() workaround
|
|
385
|
-
* @security Single-use: clears code after retrieval
|
|
386
|
-
*/
|
|
387
|
-
getPendingAuthCode(): { code?: string; state?: string } | undefined {
|
|
388
|
-
if (this._pendingAuthCode) {
|
|
389
|
-
const result = {
|
|
390
|
-
code: this._pendingAuthCode,
|
|
391
|
-
state: this._pendingAuthState,
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
/** Protect verifier from SDK's invalidate("all") during exchange. */
|
|
395
|
-
this._isExchangingCode = true;
|
|
396
|
-
|
|
397
|
-
this._pendingAuthCode = undefined;
|
|
398
|
-
this._pendingAuthState = undefined;
|
|
399
|
-
|
|
400
|
-
return result;
|
|
401
|
-
}
|
|
402
|
-
return undefined;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/** SDK constraint: addClientAuthentication() must not exist on this class. */
|
|
406
450
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,10 @@ import { OAuthError } from "./errors";
|
|
|
10
10
|
import { createCallbackServer, type CallbackResult } from "./server";
|
|
11
11
|
import type { GetAuthCodeOptions } from "./types";
|
|
12
12
|
|
|
13
|
+
const DEFAULT_PORT = 3000;
|
|
14
|
+
const DEFAULT_HOSTNAME = "localhost";
|
|
15
|
+
const DEFAULT_CALLBACK_PATH = "/callback";
|
|
16
|
+
|
|
13
17
|
export type { CallbackResult, CallbackServer, ServerOptions } from "./server";
|
|
14
18
|
export { OAuthError, TimeoutError } from "./errors";
|
|
15
19
|
export type { GetAuthCodeOptions } from "./types";
|
|
@@ -44,9 +48,9 @@ export function getRedirectUrl(
|
|
|
44
48
|
} = {},
|
|
45
49
|
): string {
|
|
46
50
|
const {
|
|
47
|
-
port =
|
|
48
|
-
hostname =
|
|
49
|
-
callbackPath =
|
|
51
|
+
port = DEFAULT_PORT,
|
|
52
|
+
hostname = DEFAULT_HOSTNAME,
|
|
53
|
+
callbackPath = DEFAULT_CALLBACK_PATH,
|
|
50
54
|
} = options;
|
|
51
55
|
return `http://${hostname}:${port}${callbackPath}`;
|
|
52
56
|
}
|
|
@@ -94,10 +98,10 @@ export async function getAuthCode(
|
|
|
94
98
|
typeof input === "string" ? await authorizationUrlToOptions(input) : input;
|
|
95
99
|
|
|
96
100
|
const {
|
|
97
|
-
port =
|
|
98
|
-
hostname =
|
|
101
|
+
port = DEFAULT_PORT,
|
|
102
|
+
hostname = DEFAULT_HOSTNAME,
|
|
99
103
|
timeout = 30000,
|
|
100
|
-
callbackPath =
|
|
104
|
+
callbackPath = DEFAULT_CALLBACK_PATH,
|
|
101
105
|
successHtml,
|
|
102
106
|
errorHtml,
|
|
103
107
|
signal,
|
|
@@ -119,10 +123,13 @@ export async function getAuthCode(
|
|
|
119
123
|
// Best-effort launch: fire-and-forget, swallow errors (managed mode only)
|
|
120
124
|
if (
|
|
121
125
|
"authorizationUrl" in options &&
|
|
122
|
-
|
|
126
|
+
options.authorizationUrl &&
|
|
127
|
+
"launch" in options &&
|
|
128
|
+
typeof options.launch === "function"
|
|
123
129
|
) {
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
void Promise.resolve(options.launch(options.authorizationUrl)).catch(
|
|
131
|
+
() => {},
|
|
132
|
+
);
|
|
126
133
|
}
|
|
127
134
|
|
|
128
135
|
const result = await server.waitForCallback(callbackPath, timeout);
|
package/src/mcp-types.ts
CHANGED
|
@@ -23,36 +23,34 @@ export interface ClientInfo {
|
|
|
23
23
|
clientSecretExpiresAt?: number;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* Active OAuth flow state for crash recovery.
|
|
28
|
-
* Preserves PKCE verifier and state across process restarts.
|
|
29
|
-
*/
|
|
30
|
-
export interface OAuthSession {
|
|
31
|
-
codeVerifier?: string;
|
|
32
|
-
state?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
26
|
/**
|
|
36
27
|
* Minimal storage interface for OAuth tokens.
|
|
37
|
-
* @invariant Implementations must be thread-safe within process.
|
|
38
28
|
* @invariant Keys are scoped to avoid collisions between multiple OAuth flows.
|
|
39
29
|
*/
|
|
40
30
|
export interface TokenStore {
|
|
41
31
|
get(key: string): Promise<Tokens | null>;
|
|
42
32
|
set(key: string, tokens: Tokens): Promise<void>;
|
|
43
33
|
delete(key: string): Promise<void>;
|
|
44
|
-
clear(): Promise<void>;
|
|
45
34
|
}
|
|
46
35
|
|
|
36
|
+
/** Brand symbol for OAuthStore type detection. */
|
|
37
|
+
export const OAuthStoreBrand: unique symbol = Symbol("OAuthStore");
|
|
38
|
+
|
|
47
39
|
/**
|
|
48
|
-
*
|
|
49
|
-
* Enables recovery
|
|
40
|
+
* Extended storage with client registration and PKCE verifier persistence.
|
|
41
|
+
* Enables crash recovery mid-flow and reuse of dynamic registration.
|
|
42
|
+
* @invariant Implementations must include `[OAuthStoreBrand]: true` property.
|
|
50
43
|
*/
|
|
51
44
|
export interface OAuthStore extends TokenStore {
|
|
45
|
+
readonly [OAuthStoreBrand]: true;
|
|
46
|
+
|
|
52
47
|
getClient(key: string): Promise<ClientInfo | null>;
|
|
53
48
|
setClient(key: string, client: ClientInfo): Promise<void>;
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
deleteClient(key: string): Promise<void>;
|
|
50
|
+
|
|
51
|
+
getCodeVerifier(key: string): Promise<string | null>;
|
|
52
|
+
setCodeVerifier(key: string, verifier: string): Promise<void>;
|
|
53
|
+
deleteCodeVerifier(key: string): Promise<void>;
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
/**
|
|
@@ -61,8 +59,16 @@ export interface OAuthStore extends TokenStore {
|
|
|
61
59
|
* @see https://datatracker.ietf.org/doc/html/rfc8252
|
|
62
60
|
*/
|
|
63
61
|
export interface BrowserAuthOptions {
|
|
64
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Pre-registered OAuth client ID. Omit to use dynamic client registration.
|
|
64
|
+
* When provided, takes precedence over any DCR-obtained client.
|
|
65
|
+
*/
|
|
65
66
|
clientId?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Pre-registered client secret (for confidential clients).
|
|
69
|
+
* Determines auth method for token requests: `client_secret_post` if set, `none` otherwise.
|
|
70
|
+
* This is fixed at construction - DCR-obtained secrets don't change the auth method.
|
|
71
|
+
*/
|
|
66
72
|
clientSecret?: string;
|
|
67
73
|
|
|
68
74
|
scope?: string;
|
|
@@ -87,4 +93,11 @@ export interface BrowserAuthOptions {
|
|
|
87
93
|
|
|
88
94
|
/** Request inspection callback for debugging OAuth flows. */
|
|
89
95
|
onRequest?: (req: Request) => void;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Authorization server base URL (issuer) for token endpoint discovery.
|
|
99
|
+
* Pass the origin (e.g., `https://auth.example.com`), not `/token`.
|
|
100
|
+
* Defaults to the authorization URL origin. Discovery failures are non-fatal.
|
|
101
|
+
*/
|
|
102
|
+
authServerUrl?: string | URL;
|
|
90
103
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -13,11 +13,11 @@ export { browserAuth } from "./auth/browser-auth";
|
|
|
13
13
|
export { inMemoryStore } from "./storage/memory";
|
|
14
14
|
export { fileStore } from "./storage/file";
|
|
15
15
|
|
|
16
|
+
export { OAuthStoreBrand } from "./mcp-types";
|
|
16
17
|
export type {
|
|
17
18
|
BrowserAuthOptions,
|
|
18
19
|
Tokens,
|
|
19
20
|
TokenStore,
|
|
20
21
|
ClientInfo,
|
|
21
|
-
OAuthSession,
|
|
22
22
|
OAuthStore,
|
|
23
23
|
} from "./mcp-types";
|
package/src/storage/file.ts
CHANGED
|
@@ -8,8 +8,8 @@ import type { TokenStore, Tokens } from "../mcp-types";
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Persistent file-based token storage.
|
|
11
|
+
* Not safe for concurrent access across multiple processes.
|
|
11
12
|
* Default: ~/.mcp/tokens.json
|
|
12
|
-
* WARNING: Not safe for concurrent access across processes.
|
|
13
13
|
*/
|
|
14
14
|
export function fileStore(filepath?: string): TokenStore {
|
|
15
15
|
const file = filepath ?? path.join(os.homedir(), ".mcp", "tokens.json");
|
|
@@ -29,8 +29,9 @@ export function fileStore(filepath?: string): TokenStore {
|
|
|
29
29
|
|
|
30
30
|
async function writeStore(data: Record<string, Tokens>) {
|
|
31
31
|
await ensureDir();
|
|
32
|
-
|
|
33
|
-
await fs.writeFile(
|
|
32
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
33
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
34
|
+
await fs.rename(tmp, file);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
return {
|
|
@@ -50,9 +51,5 @@ export function fileStore(filepath?: string): TokenStore {
|
|
|
50
51
|
delete store[key];
|
|
51
52
|
await writeStore(store);
|
|
52
53
|
},
|
|
53
|
-
|
|
54
|
-
async clear(): Promise<void> {
|
|
55
|
-
await writeStore({});
|
|
56
|
-
},
|
|
57
54
|
};
|
|
58
55
|
}
|
package/src/storage/memory.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { TokenStore, Tokens } from "../mcp-types";
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Ephemeral in-memory token storage.
|
|
8
|
-
* Tokens lost on process restart.
|
|
8
|
+
* Tokens lost on process restart.
|
|
9
9
|
*/
|
|
10
10
|
export function inMemoryStore(): TokenStore {
|
|
11
11
|
const store = new Map<string, Tokens>();
|
|
@@ -22,9 +22,5 @@ export function inMemoryStore(): TokenStore {
|
|
|
22
22
|
async delete(key: string): Promise<void> {
|
|
23
23
|
store.delete(key);
|
|
24
24
|
},
|
|
25
|
-
|
|
26
|
-
async clear(): Promise<void> {
|
|
27
|
-
store.clear();
|
|
28
|
-
},
|
|
29
25
|
};
|
|
30
26
|
}
|