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.
@@ -2,18 +2,22 @@
2
2
  /* SPDX-License-Identifier: MIT */
3
3
 
4
4
  import { randomBytes } from "node:crypto";
5
- import type {
6
- BrowserAuthOptions,
7
- TokenStore,
8
- OAuthStore,
9
- Tokens,
10
- ClientInfo,
11
- OAuthSession,
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
- const clientInfo = await this._store.getClient(this._storeKey);
124
- if (clientInfo?.clientId) {
125
- this._clientInfo = {
126
- client_id: clientInfo.clientId,
127
- client_secret: clientInfo.clientSecret,
128
- client_id_issued_at: clientInfo.clientIdIssuedAt,
129
- client_secret_expires_at: clientInfo.clientSecretExpiresAt,
130
- redirect_uris: [this.redirectUrl],
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 session state
135
- const session = await this._store.getSession(this._storeKey);
136
- if (session?.codeVerifier) {
137
- this._codeVerifier = session.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: any): store is OAuthStore {
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 `http://${this._hostname}:${this._port}${this._callbackPath}`;
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", "refresh_token"],
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
- // Check expiry using stored expiresAt from initial token response
222
- const stored = await this._store.get(this._storeKey);
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: tokens.expires_in
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
- /** Serialize concurrent auth attempts to prevent race conditions. */
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._doAuthorization(authorizationUrl);
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 _doAuthorization(authorizationUrl: URL): Promise<void> {
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
- /** Cache auth code for SDK's separate token exchange call. */
285
- this._pendingAuthCode = result.code;
286
- this._pendingAuthState = result.state;
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
- /** Auto-cleanup stale auth codes after timeout to prevent leaks. */
289
- setTimeout(() => {
290
- if (this._pendingAuthCode === result.code) {
291
- this._pendingAuthCode = undefined;
292
- this._pendingAuthState = undefined;
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
- }, this._authTimeout);
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
- const session: OAuthSession = {
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.clear();
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
- // Empty clientId signals deletion (OAuthStore has no deleteClient method)
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
- // Empty session signals deletion (OAuthStore has no deleteSession method)
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 = 3000,
48
- hostname = "localhost",
49
- callbackPath = "/callback",
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 = 3000,
98
- hostname = "localhost",
101
+ port = DEFAULT_PORT,
102
+ hostname = DEFAULT_HOSTNAME,
99
103
  timeout = 30000,
100
- callbackPath = "/callback",
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
- typeof (options as any).launch === "function"
126
+ options.authorizationUrl &&
127
+ "launch" in options &&
128
+ typeof options.launch === "function"
123
129
  ) {
124
- const { authorizationUrl, launch } = options as any;
125
- void Promise.resolve(launch(authorizationUrl)).catch(() => {});
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
- * Full OAuth state storage including client registration and session.
49
- * Enables recovery from crashes mid-flow and reuse of dynamic registration.
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
- getSession(key: string): Promise<OAuthSession | null>;
55
- setSession(key: string, session: OAuthSession): Promise<void>;
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
- /** Pre-registered OAuth client credentials. Omit for dynamic registration. */
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";
@@ -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
- // TODO: Atomic write via temp file + rename
33
- await fs.writeFile(file, JSON.stringify(data, null, 2), "utf-8");
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
  }
@@ -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. Safe for concurrent access within process.
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
  }