tiny-http-mcp-server 0.1.24 → 0.1.26

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.
@@ -18,7 +18,7 @@
18
18
  },
19
19
  {
20
20
  "name": "tiny-http-mcp-server",
21
- "version": "0.1.24",
21
+ "version": "0.1.26",
22
22
  "license": "MIT"
23
23
  },
24
24
  {
@@ -41,6 +41,7 @@ const verifier = createJwksTokenVerifier({
41
41
  - `mode: "dynamic"` with optional `metadata`
42
42
  - `mode: "static"` with `clientId`, optional `clientSecret`, optional `metadata`
43
43
  - `allowInteractive: false` prevents interactive login while retaining cached tokens and silent refresh
44
+ - `sessionLockTimeoutMs` limits acquisition waits for a session transaction lock (default 30,000 ms; integer from 1 to 2147483647)
44
45
  - `initialGrant: { resource, tokens }` optionally imports an existing Bearer grant for one HTTP resource; requires the original client ID
45
46
  - `browser.openBrowser(url)` optional
46
47
  - `browser.readLine()` optional
@@ -84,6 +85,15 @@ registration, token requests and bounded token-body reads. Cancellation retains
84
85
  its original reason and does not retry authorization. Custom providers should
85
86
  observe the supplied signal and pass it to any work they start.
86
87
 
88
+ `authorizeRequest` may return an owned token snapshot for the request it
89
+ authorized. The HTTP client supplies that snapshot as `presentedTokens`, along
90
+ with the actual request's `requestHeaders`, to `handleUnauthorized`. Providers
91
+ that return `void` remain supported. The native provider compares the rejected
92
+ snapshot with persisted credentials: delayed 401s retry with a newer grant
93
+ without redeeming its refresh token again. A proven current token is refreshed
94
+ on 401 even when the server omits `error="invalid_token"`. Invalid provenance
95
+ fails without quoting token values.
96
+
87
97
  Configure `client.metadata.scope` to request a precise scope set; broader
88
98
  discovery metadata does not override it.
89
99
 
@@ -97,6 +107,16 @@ Input tokens are copied and invalid expiry values fail before authorization.
97
107
 
98
108
  `createAuthStoreSessionStore(options)` accepts the standard `auth-store` config.
99
109
 
110
+ Providers sharing the same `sessionStore` object serialize the complete session
111
+ read, refresh/authorization and persistence transaction for each resource.
112
+ Waiting requests can cancel or time out independently; they cannot release an
113
+ active owner's lock. Custom stores may implement
114
+ `withLock(resource, operation, { signal, timeoutMs })` to serialize the same
115
+ transaction across store instances or processes. The hook must honor acquisition
116
+ cancellation and keep the lock until the operation settles. The timeout bounds
117
+ acquisition, while token and browser operations retain their own deadlines.
118
+ Cross-process locking for the native secret-store backend is under development.
119
+
100
120
  ## Environment Variables
101
121
 
102
122
  This package exposes no direct environment variables. When `authStore` is used,
@@ -7,6 +7,7 @@ import { createAuthorizationState } from "./authorization-state.js";
7
7
  import { generateCodeChallenge, generateCodeVerifier } from "./pkce.js";
8
8
  import { exchangeAuthorizationCode, OAuthError, refreshAccessToken, isRetryableOAuthError, readOAuthJsonObjectResponse } from "./token-endpoint.js";
9
9
  import { canonicalizeResourceIndicator } from "../resource-indicator.js";
10
+ import { withOAuthSessionTransaction } from "./session-transaction.js";
10
11
  const MAX_JS_DATE_MS = 8_640_000_000_000_000;
11
12
  export function createOAuthClientProvider(options) {
12
13
  if (isProviderOptions(options)) {
@@ -20,8 +21,6 @@ export function createDefaultOAuthClientProvider(options) {
20
21
  const clientStore = options.authStore === undefined ? null : createAuthStoreClientStore(options.authStore);
21
22
  const now = options.now ?? Date.now;
22
23
  const registeredClients = new Map();
23
- const refreshPromises = new Map();
24
- const authorizationPromises = new Map();
25
24
  if (options.initialGrant !== undefined) {
26
25
  let resource;
27
26
  try {
@@ -58,7 +57,7 @@ export function createDefaultOAuthClientProvider(options) {
58
57
  if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl &&
59
58
  initialGrant.tokens !== undefined && !isExpired(initialGrant.tokens, now)) {
60
59
  input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
61
- return;
60
+ return { ...initialGrant.tokens };
62
61
  }
63
62
  if (session === null ||
64
63
  accessToken === undefined ||
@@ -68,6 +67,7 @@ export function createDefaultOAuthClientProvider(options) {
68
67
  }
69
68
  assertRequestMatchesResource(requestUrl, session.resource);
70
69
  input.headers.set("Authorization", `Bearer ${accessToken}`);
70
+ return { ...session.tokens };
71
71
  },
72
72
  async handleUnauthorized(input) {
73
73
  try {
@@ -75,12 +75,28 @@ export function createDefaultOAuthClientProvider(options) {
75
75
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
76
76
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
77
77
  assertRequestMatchesResource(requestUrl, resource);
78
- const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || (!initialGrantConsumed && initialGrant?.resource === resource)) &&
79
- input.challenge?.params.error === "invalid_token";
78
+ const cached = await loadSession(resource);
79
+ const currentTokens = cached?.tokens ?? (!initialGrantConsumed && initialGrant?.resource === resource ? initialGrant.tokens : undefined);
80
+ let rejectedCurrentGrant = hasCachedAccessToken(cached) || (!initialGrantConsumed && initialGrant?.resource === resource);
81
+ let presentedTokens = input.presentedTokens;
82
+ if (input.presentedTokens !== undefined) {
83
+ rejectedCurrentGrant = false;
84
+ if (input.presentedTokens !== null) {
85
+ const presented = normalizeStoredTokens(input.presentedTokens);
86
+ const header = input.requestHeaders?.get("Authorization") ?? "";
87
+ const separator = header.indexOf(" ");
88
+ if (presented === undefined || header.slice(0, separator).toLowerCase() !== "bearer" || header.slice(separator + 1).trim() !== presented.accessToken)
89
+ throw new Error("OAuth rejected-request provenance does not match its authorization header");
90
+ presentedTokens = presented;
91
+ rejectedCurrentGrant = currentTokens !== undefined && sameTokenGrant(currentTokens, presented);
92
+ }
93
+ }
94
+ const challengeError = input.challenge?.params.error;
95
+ const forceRefresh = rejectedCurrentGrant && (challengeError === "invalid_token" || (input.presentedTokens !== undefined && challengeError === undefined));
80
96
  const session = await ensureAuthorizedSession(resource, {
81
97
  ...input.discovery,
82
98
  resource
83
- }, input.fetch, true, forceRefresh, input.signal);
99
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
84
100
  if (session?.tokens?.accessToken === undefined) {
85
101
  return { action: "fail" };
86
102
  }
@@ -95,206 +111,186 @@ export function createDefaultOAuthClientProvider(options) {
95
111
  }
96
112
  }
97
113
  };
98
- async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
114
+ async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
99
115
  signal?.throwIfAborted();
100
116
  const canonicalResource = canonicalizeResourceIndicator(resource);
101
- let session = await loadSession(canonicalResource);
102
- if (session !== null && initialGrant?.resource === canonicalResource)
103
- initialGrantConsumed = true;
104
- signal?.throwIfAborted();
105
- if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
106
- throw new Error("OAuth discovery authorization-server issuer mismatch");
107
- }
108
- if (session !== null && (canonicalizeResourceIndicator(session.resource) !== canonicalResource
109
- || getOwnString(session.discovery.authorizationServerMetadata, "issuer") !== session.authorizationServer
110
- || (discovery !== undefined && discovery.authorizationServer !== session.authorizationServer))) {
111
- await clearSession(canonicalResource);
112
- session = null;
113
- }
114
- if (session === null && discovery !== undefined && !initialGrantConsumed && initialGrant?.resource === canonicalResource &&
115
- initialGrant.tokens !== undefined && initialGrant.client !== null) {
116
- assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
117
- session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
118
- client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
119
- await saveSession(canonicalResource, session);
120
- initialGrantConsumed = true;
117
+ return withOAuthSessionTransaction(sessionStore, canonicalResource, async () => {
118
+ let session = await loadSession(canonicalResource);
119
+ if (session !== null && initialGrant?.resource === canonicalResource)
120
+ initialGrantConsumed = true;
121
121
  signal?.throwIfAborted();
122
- }
123
- const sessionDiscovery = resolveDiscovery(discovery, session);
124
- if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
125
- return session;
126
- }
127
- if (session?.tokens?.refreshToken !== undefined &&
128
- sessionDiscovery !== undefined &&
129
- (forceRefresh || isExpired(session.tokens, now))) {
130
- session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
131
- if (session?.tokens !== undefined && !isExpired(session.tokens, now)) {
122
+ if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
123
+ throw new Error("OAuth discovery authorization-server issuer mismatch");
124
+ }
125
+ if (session !== null && (canonicalizeResourceIndicator(session.resource) !== canonicalResource
126
+ || getOwnString(session.discovery.authorizationServerMetadata, "issuer") !== session.authorizationServer
127
+ || (discovery !== undefined && discovery.authorizationServer !== session.authorizationServer))) {
128
+ await clearSession(canonicalResource);
129
+ session = null;
130
+ }
131
+ if (session === null && discovery !== undefined && !initialGrantConsumed && initialGrant?.resource === canonicalResource &&
132
+ initialGrant.tokens !== undefined && initialGrant.client !== null) {
133
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
134
+ session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
135
+ client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
136
+ await saveSession(canonicalResource, session);
137
+ initialGrantConsumed = true;
138
+ signal?.throwIfAborted();
139
+ }
140
+ if (forceRefresh && rejectedTokens !== undefined && (rejectedTokens === null || session?.tokens === undefined || !sameTokenGrant(session.tokens, rejectedTokens)))
141
+ forceRefresh = false;
142
+ const sessionDiscovery = resolveDiscovery(discovery, session);
143
+ if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
132
144
  return session;
133
145
  }
134
- }
135
- if (forceRefresh && session?.tokens !== undefined) {
136
- session = clearSessionTokens(session);
137
- await saveSession(canonicalResource, session);
138
- }
139
- if (!allowInteractive || sessionDiscovery === undefined) {
140
- return session;
141
- }
142
- if (options.allowInteractive === false)
143
- throw new Error("OAuth interactive authorization is disabled");
144
- return authorizeSession(canonicalResource, session, sessionDiscovery, fetch, signal);
146
+ if (session?.tokens?.refreshToken !== undefined &&
147
+ sessionDiscovery !== undefined &&
148
+ (forceRefresh || isExpired(session.tokens, now))) {
149
+ session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
150
+ if (session?.tokens !== undefined && !isExpired(session.tokens, now)) {
151
+ return session;
152
+ }
153
+ }
154
+ if (forceRefresh && session?.tokens !== undefined) {
155
+ session = clearSessionTokens(session);
156
+ await saveSession(canonicalResource, session);
157
+ }
158
+ if (!allowInteractive || sessionDiscovery === undefined) {
159
+ return session;
160
+ }
161
+ if (options.allowInteractive === false)
162
+ throw new Error("OAuth interactive authorization is disabled");
163
+ return authorizeSession(canonicalResource, session, sessionDiscovery, fetch, signal);
164
+ }, { signal, timeoutMs: options.sessionLockTimeoutMs });
145
165
  }
146
166
  async function refreshSession(resource, session, discovery, fetch, signal) {
147
167
  signal?.throwIfAborted();
148
168
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
149
- const inFlight = refreshPromises.get(resource);
150
- if (inFlight !== undefined) {
151
- return inFlight;
169
+ if (session.tokens?.refreshToken === undefined) {
170
+ return session;
152
171
  }
153
- const promise = (async () => {
172
+ let refreshAttempted = false;
173
+ let refreshedTokens;
174
+ while (true) {
154
175
  try {
155
- if (session.tokens?.refreshToken === undefined) {
156
- return session;
176
+ refreshedTokens = await refreshAccessToken({
177
+ tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
178
+ clientId: session.client.clientId,
179
+ clientSecret: session.client.clientSecret,
180
+ refreshToken: session.tokens.refreshToken,
181
+ resource,
182
+ fetch, signal,
183
+ now
184
+ });
185
+ break;
186
+ }
187
+ catch (error) {
188
+ signal?.throwIfAborted();
189
+ if (error instanceof OAuthError && error.error === "invalid_grant") {
190
+ const clearedSession = clearSessionTokens(session);
191
+ await saveSession(resource, clearedSession);
192
+ return clearedSession;
157
193
  }
158
- let refreshAttempted = false;
159
- let refreshedTokens;
160
- while (true) {
161
- try {
162
- refreshedTokens = await refreshAccessToken({
163
- tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
164
- clientId: session.client.clientId,
165
- clientSecret: session.client.clientSecret,
166
- refreshToken: session.tokens.refreshToken,
167
- resource,
168
- fetch, signal,
169
- now
170
- });
171
- break;
172
- }
173
- catch (error) {
174
- signal?.throwIfAborted();
175
- if (error instanceof OAuthError && error.error === "invalid_grant") {
176
- const clearedSession = clearSessionTokens(session);
177
- await saveSession(resource, clearedSession);
178
- return clearedSession;
179
- }
180
- if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
181
- await clearRegisteredClient(discovery.authorizationServer);
182
- await clearSession(resource);
183
- return null;
184
- }
185
- if (!refreshAttempted && isRetryableOAuthError(error)) {
186
- refreshAttempted = true;
187
- continue;
188
- }
189
- throw error;
190
- }
194
+ if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
195
+ await clearRegisteredClient(discovery.authorizationServer);
196
+ await clearSession(resource);
197
+ return null;
191
198
  }
192
- const updatedSession = {
193
- ...session,
194
- tokens: {
195
- ...refreshedTokens,
196
- refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
197
- },
198
- discovery: toStoredDiscovery(discovery)
199
- };
200
- await saveSession(resource, updatedSession);
201
- return updatedSession;
202
- }
203
- finally {
204
- refreshPromises.delete(resource);
199
+ if (!refreshAttempted && isRetryableOAuthError(error)) {
200
+ refreshAttempted = true;
201
+ continue;
202
+ }
203
+ throw error;
205
204
  }
206
- })();
207
- refreshPromises.set(resource, promise);
208
- return promise;
205
+ }
206
+ const updatedSession = {
207
+ ...session,
208
+ tokens: {
209
+ ...refreshedTokens,
210
+ refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
211
+ },
212
+ discovery: toStoredDiscovery(discovery)
213
+ };
214
+ await saveSession(resource, updatedSession);
215
+ return updatedSession;
209
216
  }
210
217
  async function authorizeSession(resource, existingSession, discovery, fetch, signal) {
211
218
  signal?.throwIfAborted();
212
- const inFlight = authorizationPromises.get(resource);
213
- if (inFlight !== undefined) {
214
- return inFlight;
215
- }
216
- const promise = (async () => {
217
- assertS256PkceSupport(discovery.authorizationServerMetadata);
218
- assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
219
- let currentSession = existingSession;
220
- let transientRetryAttempted = false;
221
- let reRegistrationAttempted = false;
222
- while (true) {
223
- const loopback = await createLoopbackAuthorizationSession({
224
- openBrowser: options.browser.openBrowser,
225
- readLine: options.browser.readLine,
226
- createServer: options.browser.createServer,
227
- landingPage: options.browser.landingPage,
228
- redirectUri: options.browser.redirectUri,
229
- signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
230
- timeoutMs: options.browser.timeoutMs
219
+ assertS256PkceSupport(discovery.authorizationServerMetadata);
220
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
221
+ let currentSession = existingSession;
222
+ let transientRetryAttempted = false;
223
+ let reRegistrationAttempted = false;
224
+ while (true) {
225
+ const loopback = await createLoopbackAuthorizationSession({
226
+ openBrowser: options.browser.openBrowser,
227
+ readLine: options.browser.readLine,
228
+ createServer: options.browser.createServer,
229
+ landingPage: options.browser.landingPage,
230
+ redirectUri: options.browser.redirectUri,
231
+ signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
232
+ timeoutMs: options.browser.timeoutMs
233
+ });
234
+ let resolvedClient = null;
235
+ try {
236
+ resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
237
+ const sessionWithoutTokens = {
238
+ resource,
239
+ authorizationServer: discovery.authorizationServer,
240
+ client: resolvedClient.client,
241
+ discovery: toStoredDiscovery(discovery)
242
+ };
243
+ await saveSession(resource, sessionWithoutTokens);
244
+ const verifier = generateCodeVerifier();
245
+ const challenge = generateCodeChallenge(verifier);
246
+ const authorizationUrl = buildAuthorizationUrl({
247
+ metadata: discovery.authorizationServerMetadata,
248
+ resource,
249
+ clientId: resolvedClient.client.clientId,
250
+ redirectUri: loopback.redirectUri,
251
+ codeChallenge: challenge,
252
+ clientMetadata: getClientMetadata(options.client)
231
253
  });
232
- let resolvedClient = null;
233
- try {
234
- resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
235
- const sessionWithoutTokens = {
236
- resource,
237
- authorizationServer: discovery.authorizationServer,
238
- client: resolvedClient.client,
239
- discovery: toStoredDiscovery(discovery)
240
- };
241
- await saveSession(resource, sessionWithoutTokens);
242
- const verifier = generateCodeVerifier();
243
- const challenge = generateCodeChallenge(verifier);
244
- const authorizationUrl = buildAuthorizationUrl({
245
- metadata: discovery.authorizationServerMetadata,
246
- resource,
247
- clientId: resolvedClient.client.clientId,
248
- redirectUri: loopback.redirectUri,
249
- codeChallenge: challenge,
250
- clientMetadata: getClientMetadata(options.client)
251
- });
252
- const code = await loopback.waitForCode(authorizationUrl);
253
- const tokens = await exchangeAuthorizationCode({
254
- tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
255
- clientId: resolvedClient.client.clientId,
256
- clientSecret: resolvedClient.client.clientSecret,
257
- code,
258
- codeVerifier: verifier,
259
- redirectUri: loopback.redirectUri,
260
- resource,
261
- fetch, signal,
262
- now
263
- });
264
- const session = {
265
- ...sessionWithoutTokens,
266
- tokens
267
- };
268
- await saveSession(resource, session);
269
- return session;
270
- }
271
- catch (error) {
272
- signal?.throwIfAborted();
273
- if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
274
- reRegistrationAttempted = true;
275
- await clearRegisteredClient(discovery.authorizationServer);
276
- await clearSession(resource);
277
- currentSession = null;
278
- continue;
279
- }
280
- if (!transientRetryAttempted && isRetryableOAuthError(error)) {
281
- transientRetryAttempted = true;
282
- await clearSession(resource);
283
- currentSession = null;
284
- continue;
285
- }
286
- throw error;
254
+ const code = await loopback.waitForCode(authorizationUrl);
255
+ const tokens = await exchangeAuthorizationCode({
256
+ tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
257
+ clientId: resolvedClient.client.clientId,
258
+ clientSecret: resolvedClient.client.clientSecret,
259
+ code,
260
+ codeVerifier: verifier,
261
+ redirectUri: loopback.redirectUri,
262
+ resource,
263
+ fetch, signal,
264
+ now
265
+ });
266
+ const session = {
267
+ ...sessionWithoutTokens,
268
+ tokens
269
+ };
270
+ await saveSession(resource, session);
271
+ return session;
272
+ }
273
+ catch (error) {
274
+ signal?.throwIfAborted();
275
+ if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
276
+ reRegistrationAttempted = true;
277
+ await clearRegisteredClient(discovery.authorizationServer);
278
+ await clearSession(resource);
279
+ currentSession = null;
280
+ continue;
287
281
  }
288
- finally {
289
- loopback.close();
282
+ if (!transientRetryAttempted && isRetryableOAuthError(error)) {
283
+ transientRetryAttempted = true;
284
+ await clearSession(resource);
285
+ currentSession = null;
286
+ continue;
290
287
  }
288
+ throw error;
291
289
  }
292
- })();
293
- const finalPromise = promise.finally(() => {
294
- authorizationPromises.delete(resource);
295
- });
296
- authorizationPromises.set(resource, finalPromise);
297
- return finalPromise;
290
+ finally {
291
+ loopback.close();
292
+ }
293
+ }
298
294
  }
299
295
  async function resolveClient(existingSession, discovery, redirectUri, fetch, parentSignal) {
300
296
  parentSignal?.throwIfAborted();
@@ -454,6 +450,10 @@ function resolveDiscovery(discovery, session) {
454
450
  authorizationServerMetadata: metadata
455
451
  };
456
452
  }
453
+ function sameTokenGrant(left, right) {
454
+ return left.accessToken === right.accessToken && left.refreshToken === right.refreshToken &&
455
+ left.tokenType === right.tokenType && left.expiresAt === right.expiresAt && left.scope === right.scope;
456
+ }
457
457
  function clearSessionTokens(session) {
458
458
  const nextSession = { ...session };
459
459
  delete nextSession.tokens;
@@ -0,0 +1,6 @@
1
+ import type { OAuthSessionStore } from "./types.js";
2
+ /** Serialize the complete read/redeem/write operation, with independent cancellation for waiters. */
3
+ export declare function withOAuthSessionTransaction<T>(store: OAuthSessionStore, resource: string, operation: () => Promise<T>, options?: {
4
+ signal?: AbortSignal;
5
+ timeoutMs?: number;
6
+ }): Promise<T>;
@@ -0,0 +1,42 @@
1
+ const queues = new WeakMap();
2
+ /** Serialize the complete read/redeem/write operation, with independent cancellation for waiters. */
3
+ export async function withOAuthSessionTransaction(store, resource, operation, options = {}) {
4
+ const timeoutMs = options.timeoutMs ?? 30_000;
5
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647)
6
+ throw new Error("sessionLockTimeoutMs must be an integer from 1 to 2147483647 milliseconds");
7
+ options.signal?.throwIfAborted();
8
+ const started = performance.now();
9
+ const pending = queues.get(store) ?? new Map();
10
+ queues.set(store, pending);
11
+ const previous = pending.get(resource) ?? Promise.resolve();
12
+ let release;
13
+ const current = new Promise(resolve => { release = resolve; });
14
+ const tail = previous.then(() => current);
15
+ pending.set(resource, tail);
16
+ try {
17
+ let timer;
18
+ let rejectWait;
19
+ const waiting = new Promise((resolve, reject) => { rejectWait = reject; previous.then(resolve, reject); });
20
+ const abort = () => rejectWait(options.signal?.reason);
21
+ try {
22
+ timer = setTimeout(() => rejectWait(new Error("Timed out waiting for OAuth session transaction lock")), timeoutMs);
23
+ options.signal?.addEventListener("abort", abort, { once: true });
24
+ if (options.signal?.aborted)
25
+ abort();
26
+ await waiting;
27
+ }
28
+ finally {
29
+ clearTimeout(timer);
30
+ options.signal?.removeEventListener("abort", abort);
31
+ }
32
+ options.signal?.throwIfAborted();
33
+ return store.withLock === undefined ? await operation() : await store.withLock(resource, operation, {
34
+ signal: options.signal, timeoutMs: Math.max(0, timeoutMs - (performance.now() - started))
35
+ });
36
+ }
37
+ finally {
38
+ release();
39
+ void tail.then(() => { if (pending.get(resource) === tail)
40
+ pending.delete(resource); });
41
+ }
42
+ }
@@ -33,7 +33,7 @@ export interface OAuthClientProvider {
33
33
  headers: Headers;
34
34
  fetch: OAuthMetadataFetch;
35
35
  signal?: AbortSignal;
36
- }): Promise<void> | void;
36
+ }): Promise<StoredOAuthTokens | void> | StoredOAuthTokens | void;
37
37
  handleUnauthorized(input: {
38
38
  requestUrl: URL;
39
39
  response: Response;
@@ -41,6 +41,10 @@ export interface OAuthClientProvider {
41
41
  discovery: OAuthDiscoveryResult;
42
42
  fetch: OAuthMetadataFetch;
43
43
  signal?: AbortSignal;
44
+ /** Headers actually attached to this rejected request. */
45
+ requestHeaders?: Headers;
46
+ /** Owned snapshot returned when this request was authorized, or null if absent. */
47
+ presentedTokens?: StoredOAuthTokens | null;
44
48
  }): Promise<{
45
49
  action: "retry";
46
50
  } | {
@@ -84,6 +88,11 @@ export interface OAuthSessionStore {
84
88
  load(resource: string): Promise<StoredOAuthSession | null>;
85
89
  save(resource: string, session: StoredOAuthSession): Promise<void>;
86
90
  clear(resource: string): Promise<void>;
91
+ /** Backend-wide lock covering a complete read/redeem/write transaction. */
92
+ withLock?<T>(resource: string, operation: () => Promise<T>, options: {
93
+ signal?: AbortSignal;
94
+ timeoutMs: number;
95
+ }): Promise<T>;
87
96
  }
88
97
  export interface DefaultOAuthClientProviderOptions {
89
98
  client: {
@@ -99,6 +108,8 @@ export interface DefaultOAuthClientProviderOptions {
99
108
  };
100
109
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
101
110
  allowInteractive?: boolean;
111
+ /** Maximum wait to acquire a session transaction lock (default 30,000 ms). */
112
+ sessionLockTimeoutMs?: number;
102
113
  /** Import an existing grant for one resource. Persisted sessions take precedence. */
103
114
  initialGrant?: {
104
115
  resource: string;
@@ -110,7 +110,7 @@ interface OAuthClientProvider {
110
110
  headers: Headers;
111
111
  fetch: OAuthMetadataFetch;
112
112
  signal?: AbortSignal;
113
- }): Promise<void> | void;
113
+ }): Promise<StoredOAuthTokens | void> | StoredOAuthTokens | void;
114
114
  handleUnauthorized(input: {
115
115
  requestUrl: URL;
116
116
  response: Response;
@@ -118,6 +118,10 @@ interface OAuthClientProvider {
118
118
  discovery: OAuthDiscoveryResult;
119
119
  fetch: OAuthMetadataFetch;
120
120
  signal?: AbortSignal;
121
+ /** Headers actually attached to this rejected request. */
122
+ requestHeaders?: Headers;
123
+ /** Owned snapshot returned when this request was authorized, or null if absent. */
124
+ presentedTokens?: StoredOAuthTokens | null;
121
125
  }): Promise<{
122
126
  action: "retry";
123
127
  } | {
@@ -161,6 +165,11 @@ interface OAuthSessionStore {
161
165
  load(resource: string): Promise<StoredOAuthSession | null>;
162
166
  save(resource: string, session: StoredOAuthSession): Promise<void>;
163
167
  clear(resource: string): Promise<void>;
168
+ /** Backend-wide lock covering a complete read/redeem/write transaction. */
169
+ withLock?<T>(resource: string, operation: () => Promise<T>, options: {
170
+ signal?: AbortSignal;
171
+ timeoutMs: number;
172
+ }): Promise<T>;
164
173
  }
165
174
  interface DefaultOAuthClientProviderOptions {
166
175
  client: {
@@ -176,6 +185,8 @@ interface DefaultOAuthClientProviderOptions {
176
185
  };
177
186
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
178
187
  allowInteractive?: boolean;
188
+ /** Maximum wait to acquire a session transaction lock (default 30,000 ms). */
189
+ sessionLockTimeoutMs?: number;
179
190
  /** Import an existing grant for one resource. Persisted sessions take precedence. */
180
191
  initialGrant?: {
181
192
  resource: string;
@@ -649,6 +660,7 @@ declare class HttpTransport implements McpTransport {
649
660
  private readonly oauthMetadataDiscovery;
650
661
  private readonly inFlightFetchAbortControllers;
651
662
  private readonly inFlightOAuthAbortControllers;
663
+ private readonly oauthRequestTokens;
652
664
  private readonly openResponseReaders;
653
665
  private readonly modernRequests;
654
666
  private modernMode;
@@ -4585,6 +4585,55 @@ function normalizeBearerTokenType(value) {
4585
4585
  return value.toLowerCase() === "bearer" ? "Bearer" : null;
4586
4586
  }
4587
4587
 
4588
+ // ../mcp-oauth/dist/client/session-transaction.js
4589
+ var queues = /* @__PURE__ */ new WeakMap();
4590
+ async function withOAuthSessionTransaction(store, resource, operation, options = {}) {
4591
+ const timeoutMs = options.timeoutMs ?? 3e4;
4592
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647)
4593
+ throw new Error("sessionLockTimeoutMs must be an integer from 1 to 2147483647 milliseconds");
4594
+ options.signal?.throwIfAborted();
4595
+ const started = performance.now();
4596
+ const pending = queues.get(store) ?? /* @__PURE__ */ new Map();
4597
+ queues.set(store, pending);
4598
+ const previous = pending.get(resource) ?? Promise.resolve();
4599
+ let release;
4600
+ const current = new Promise((resolve) => {
4601
+ release = resolve;
4602
+ });
4603
+ const tail = previous.then(() => current);
4604
+ pending.set(resource, tail);
4605
+ try {
4606
+ let timer;
4607
+ let rejectWait;
4608
+ const waiting = new Promise((resolve, reject) => {
4609
+ rejectWait = reject;
4610
+ previous.then(resolve, reject);
4611
+ });
4612
+ const abort = () => rejectWait(options.signal?.reason);
4613
+ try {
4614
+ timer = setTimeout(() => rejectWait(new Error("Timed out waiting for OAuth session transaction lock")), timeoutMs);
4615
+ options.signal?.addEventListener("abort", abort, { once: true });
4616
+ if (options.signal?.aborted)
4617
+ abort();
4618
+ await waiting;
4619
+ } finally {
4620
+ clearTimeout(timer);
4621
+ options.signal?.removeEventListener("abort", abort);
4622
+ }
4623
+ options.signal?.throwIfAborted();
4624
+ return store.withLock === void 0 ? await operation() : await store.withLock(resource, operation, {
4625
+ signal: options.signal,
4626
+ timeoutMs: Math.max(0, timeoutMs - (performance.now() - started))
4627
+ });
4628
+ } finally {
4629
+ release();
4630
+ void tail.then(() => {
4631
+ if (pending.get(resource) === tail)
4632
+ pending.delete(resource);
4633
+ });
4634
+ }
4635
+ }
4636
+
4588
4637
  // ../mcp-oauth/dist/client/default-oauth-client-provider.js
4589
4638
  var MAX_JS_DATE_MS3 = 864e13;
4590
4639
  function createOAuthClientProvider(options) {
@@ -4599,8 +4648,6 @@ function createDefaultOAuthClientProvider(options) {
4599
4648
  const clientStore = options.authStore === void 0 ? null : createAuthStoreClientStore(options.authStore);
4600
4649
  const now = options.now ?? Date.now;
4601
4650
  const registeredClients = /* @__PURE__ */ new Map();
4602
- const refreshPromises = /* @__PURE__ */ new Map();
4603
- const authorizationPromises = /* @__PURE__ */ new Map();
4604
4651
  if (options.initialGrant !== void 0) {
4605
4652
  let resource;
4606
4653
  try {
@@ -4634,13 +4681,14 @@ function createDefaultOAuthClientProvider(options) {
4634
4681
  const accessToken = session?.tokens?.accessToken;
4635
4682
  if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl && initialGrant.tokens !== void 0 && !isExpired(initialGrant.tokens, now)) {
4636
4683
  input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
4637
- return;
4684
+ return { ...initialGrant.tokens };
4638
4685
  }
4639
4686
  if (session === null || accessToken === void 0 || session.tokens === void 0 || isExpired(session.tokens, now)) {
4640
4687
  return;
4641
4688
  }
4642
4689
  assertRequestMatchesResource(requestUrl, session.resource);
4643
4690
  input.headers.set("Authorization", `Bearer ${accessToken}`);
4691
+ return { ...session.tokens };
4644
4692
  },
4645
4693
  async handleUnauthorized(input) {
4646
4694
  try {
@@ -4648,11 +4696,28 @@ function createDefaultOAuthClientProvider(options) {
4648
4696
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
4649
4697
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
4650
4698
  assertRequestMatchesResource(requestUrl, resource);
4651
- const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || !initialGrantConsumed && initialGrant?.resource === resource) && input.challenge?.params.error === "invalid_token";
4699
+ const cached = await loadSession(resource);
4700
+ const currentTokens = cached?.tokens ?? (!initialGrantConsumed && initialGrant?.resource === resource ? initialGrant.tokens : void 0);
4701
+ let rejectedCurrentGrant = hasCachedAccessToken(cached) || !initialGrantConsumed && initialGrant?.resource === resource;
4702
+ let presentedTokens = input.presentedTokens;
4703
+ if (input.presentedTokens !== void 0) {
4704
+ rejectedCurrentGrant = false;
4705
+ if (input.presentedTokens !== null) {
4706
+ const presented = normalizeStoredTokens(input.presentedTokens);
4707
+ const header = input.requestHeaders?.get("Authorization") ?? "";
4708
+ const separator = header.indexOf(" ");
4709
+ if (presented === void 0 || header.slice(0, separator).toLowerCase() !== "bearer" || header.slice(separator + 1).trim() !== presented.accessToken)
4710
+ throw new Error("OAuth rejected-request provenance does not match its authorization header");
4711
+ presentedTokens = presented;
4712
+ rejectedCurrentGrant = currentTokens !== void 0 && sameTokenGrant(currentTokens, presented);
4713
+ }
4714
+ }
4715
+ const challengeError = input.challenge?.params.error;
4716
+ const forceRefresh = rejectedCurrentGrant && (challengeError === "invalid_token" || input.presentedTokens !== void 0 && challengeError === void 0);
4652
4717
  const session = await ensureAuthorizedSession(resource, {
4653
4718
  ...input.discovery,
4654
4719
  resource
4655
- }, input.fetch, true, forceRefresh, input.signal);
4720
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
4656
4721
  if (session?.tokens?.accessToken === void 0) {
4657
4722
  return { action: "fail" };
4658
4723
  }
@@ -4666,204 +4731,185 @@ function createDefaultOAuthClientProvider(options) {
4666
4731
  }
4667
4732
  }
4668
4733
  };
4669
- async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal) {
4734
+ async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
4670
4735
  signal?.throwIfAborted();
4671
4736
  const canonicalResource = canonicalizeResourceIndicator(resource);
4672
- let session = await loadSession(canonicalResource);
4673
- if (session !== null && initialGrant?.resource === canonicalResource)
4674
- initialGrantConsumed = true;
4675
- signal?.throwIfAborted();
4676
- if (discovery !== void 0 && getOwnString2(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
4677
- throw new Error("OAuth discovery authorization-server issuer mismatch");
4678
- }
4679
- if (session !== null && (canonicalizeResourceIndicator(session.resource) !== canonicalResource || getOwnString2(session.discovery.authorizationServerMetadata, "issuer") !== session.authorizationServer || discovery !== void 0 && discovery.authorizationServer !== session.authorizationServer)) {
4680
- await clearSession(canonicalResource);
4681
- session = null;
4682
- }
4683
- if (session === null && discovery !== void 0 && !initialGrantConsumed && initialGrant?.resource === canonicalResource && initialGrant.tokens !== void 0 && initialGrant.client !== null) {
4684
- assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
4685
- session = {
4686
- resource: canonicalResource,
4687
- authorizationServer: discovery.authorizationServer,
4688
- client: initialGrant.client,
4689
- tokens: initialGrant.tokens,
4690
- discovery: toStoredDiscovery(discovery)
4691
- };
4692
- await saveSession(canonicalResource, session);
4693
- initialGrantConsumed = true;
4737
+ return withOAuthSessionTransaction(sessionStore, canonicalResource, async () => {
4738
+ let session = await loadSession(canonicalResource);
4739
+ if (session !== null && initialGrant?.resource === canonicalResource)
4740
+ initialGrantConsumed = true;
4694
4741
  signal?.throwIfAborted();
4695
- }
4696
- const sessionDiscovery = resolveDiscovery(discovery, session);
4697
- if (session?.tokens !== void 0 && !forceRefresh && !isExpired(session.tokens, now)) {
4698
- return session;
4699
- }
4700
- if (session?.tokens?.refreshToken !== void 0 && sessionDiscovery !== void 0 && (forceRefresh || isExpired(session.tokens, now))) {
4701
- session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch2, signal);
4702
- if (session?.tokens !== void 0 && !isExpired(session.tokens, now)) {
4742
+ if (discovery !== void 0 && getOwnString2(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
4743
+ throw new Error("OAuth discovery authorization-server issuer mismatch");
4744
+ }
4745
+ if (session !== null && (canonicalizeResourceIndicator(session.resource) !== canonicalResource || getOwnString2(session.discovery.authorizationServerMetadata, "issuer") !== session.authorizationServer || discovery !== void 0 && discovery.authorizationServer !== session.authorizationServer)) {
4746
+ await clearSession(canonicalResource);
4747
+ session = null;
4748
+ }
4749
+ if (session === null && discovery !== void 0 && !initialGrantConsumed && initialGrant?.resource === canonicalResource && initialGrant.tokens !== void 0 && initialGrant.client !== null) {
4750
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
4751
+ session = {
4752
+ resource: canonicalResource,
4753
+ authorizationServer: discovery.authorizationServer,
4754
+ client: initialGrant.client,
4755
+ tokens: initialGrant.tokens,
4756
+ discovery: toStoredDiscovery(discovery)
4757
+ };
4758
+ await saveSession(canonicalResource, session);
4759
+ initialGrantConsumed = true;
4760
+ signal?.throwIfAborted();
4761
+ }
4762
+ if (forceRefresh && rejectedTokens !== void 0 && (rejectedTokens === null || session?.tokens === void 0 || !sameTokenGrant(session.tokens, rejectedTokens)))
4763
+ forceRefresh = false;
4764
+ const sessionDiscovery = resolveDiscovery(discovery, session);
4765
+ if (session?.tokens !== void 0 && !forceRefresh && !isExpired(session.tokens, now)) {
4703
4766
  return session;
4704
4767
  }
4705
- }
4706
- if (forceRefresh && session?.tokens !== void 0) {
4707
- session = clearSessionTokens(session);
4708
- await saveSession(canonicalResource, session);
4709
- }
4710
- if (!allowInteractive || sessionDiscovery === void 0) {
4711
- return session;
4712
- }
4713
- if (options.allowInteractive === false)
4714
- throw new Error("OAuth interactive authorization is disabled");
4715
- return authorizeSession(canonicalResource, session, sessionDiscovery, fetch2, signal);
4768
+ if (session?.tokens?.refreshToken !== void 0 && sessionDiscovery !== void 0 && (forceRefresh || isExpired(session.tokens, now))) {
4769
+ session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch2, signal);
4770
+ if (session?.tokens !== void 0 && !isExpired(session.tokens, now)) {
4771
+ return session;
4772
+ }
4773
+ }
4774
+ if (forceRefresh && session?.tokens !== void 0) {
4775
+ session = clearSessionTokens(session);
4776
+ await saveSession(canonicalResource, session);
4777
+ }
4778
+ if (!allowInteractive || sessionDiscovery === void 0) {
4779
+ return session;
4780
+ }
4781
+ if (options.allowInteractive === false)
4782
+ throw new Error("OAuth interactive authorization is disabled");
4783
+ return authorizeSession(canonicalResource, session, sessionDiscovery, fetch2, signal);
4784
+ }, { signal, timeoutMs: options.sessionLockTimeoutMs });
4716
4785
  }
4717
4786
  async function refreshSession(resource, session, discovery, fetch2, signal) {
4718
4787
  signal?.throwIfAborted();
4719
4788
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
4720
- const inFlight = refreshPromises.get(resource);
4721
- if (inFlight !== void 0) {
4722
- return inFlight;
4789
+ if (session.tokens?.refreshToken === void 0) {
4790
+ return session;
4723
4791
  }
4724
- const promise = (async () => {
4792
+ let refreshAttempted = false;
4793
+ let refreshedTokens;
4794
+ while (true) {
4725
4795
  try {
4726
- if (session.tokens?.refreshToken === void 0) {
4727
- return session;
4728
- }
4729
- let refreshAttempted = false;
4730
- let refreshedTokens;
4731
- while (true) {
4732
- try {
4733
- refreshedTokens = await refreshAccessToken({
4734
- tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
4735
- clientId: session.client.clientId,
4736
- clientSecret: session.client.clientSecret,
4737
- refreshToken: session.tokens.refreshToken,
4738
- resource,
4739
- fetch: fetch2,
4740
- signal,
4741
- now
4742
- });
4743
- break;
4744
- } catch (error) {
4745
- signal?.throwIfAborted();
4746
- if (error instanceof OAuthError && error.error === "invalid_grant") {
4747
- const clearedSession = clearSessionTokens(session);
4748
- await saveSession(resource, clearedSession);
4749
- return clearedSession;
4750
- }
4751
- if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
4752
- await clearRegisteredClient(discovery.authorizationServer);
4753
- await clearSession(resource);
4754
- return null;
4755
- }
4756
- if (!refreshAttempted && isRetryableOAuthError(error)) {
4757
- refreshAttempted = true;
4758
- continue;
4759
- }
4760
- throw error;
4761
- }
4796
+ refreshedTokens = await refreshAccessToken({
4797
+ tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
4798
+ clientId: session.client.clientId,
4799
+ clientSecret: session.client.clientSecret,
4800
+ refreshToken: session.tokens.refreshToken,
4801
+ resource,
4802
+ fetch: fetch2,
4803
+ signal,
4804
+ now
4805
+ });
4806
+ break;
4807
+ } catch (error) {
4808
+ signal?.throwIfAborted();
4809
+ if (error instanceof OAuthError && error.error === "invalid_grant") {
4810
+ const clearedSession = clearSessionTokens(session);
4811
+ await saveSession(resource, clearedSession);
4812
+ return clearedSession;
4813
+ }
4814
+ if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
4815
+ await clearRegisteredClient(discovery.authorizationServer);
4816
+ await clearSession(resource);
4817
+ return null;
4818
+ }
4819
+ if (!refreshAttempted && isRetryableOAuthError(error)) {
4820
+ refreshAttempted = true;
4821
+ continue;
4762
4822
  }
4763
- const updatedSession = {
4764
- ...session,
4765
- tokens: {
4766
- ...refreshedTokens,
4767
- refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
4768
- },
4769
- discovery: toStoredDiscovery(discovery)
4770
- };
4771
- await saveSession(resource, updatedSession);
4772
- return updatedSession;
4773
- } finally {
4774
- refreshPromises.delete(resource);
4823
+ throw error;
4775
4824
  }
4776
- })();
4777
- refreshPromises.set(resource, promise);
4778
- return promise;
4825
+ }
4826
+ const updatedSession = {
4827
+ ...session,
4828
+ tokens: {
4829
+ ...refreshedTokens,
4830
+ refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
4831
+ },
4832
+ discovery: toStoredDiscovery(discovery)
4833
+ };
4834
+ await saveSession(resource, updatedSession);
4835
+ return updatedSession;
4779
4836
  }
4780
4837
  async function authorizeSession(resource, existingSession, discovery, fetch2, signal) {
4781
4838
  signal?.throwIfAborted();
4782
- const inFlight = authorizationPromises.get(resource);
4783
- if (inFlight !== void 0) {
4784
- return inFlight;
4785
- }
4786
- const promise = (async () => {
4787
- assertS256PkceSupport(discovery.authorizationServerMetadata);
4788
- assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
4789
- let currentSession = existingSession;
4790
- let transientRetryAttempted = false;
4791
- let reRegistrationAttempted = false;
4792
- while (true) {
4793
- const loopback = await createLoopbackAuthorizationSession({
4794
- openBrowser: options.browser.openBrowser,
4795
- readLine: options.browser.readLine,
4796
- createServer: options.browser.createServer,
4797
- landingPage: options.browser.landingPage,
4798
- redirectUri: options.browser.redirectUri,
4799
- signal: options.browser.signal === void 0 ? signal : signal === void 0 ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
4800
- timeoutMs: options.browser.timeoutMs
4839
+ assertS256PkceSupport(discovery.authorizationServerMetadata);
4840
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
4841
+ let currentSession = existingSession;
4842
+ let transientRetryAttempted = false;
4843
+ let reRegistrationAttempted = false;
4844
+ while (true) {
4845
+ const loopback = await createLoopbackAuthorizationSession({
4846
+ openBrowser: options.browser.openBrowser,
4847
+ readLine: options.browser.readLine,
4848
+ createServer: options.browser.createServer,
4849
+ landingPage: options.browser.landingPage,
4850
+ redirectUri: options.browser.redirectUri,
4851
+ signal: options.browser.signal === void 0 ? signal : signal === void 0 ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
4852
+ timeoutMs: options.browser.timeoutMs
4853
+ });
4854
+ let resolvedClient = null;
4855
+ try {
4856
+ resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch2, signal);
4857
+ const sessionWithoutTokens = {
4858
+ resource,
4859
+ authorizationServer: discovery.authorizationServer,
4860
+ client: resolvedClient.client,
4861
+ discovery: toStoredDiscovery(discovery)
4862
+ };
4863
+ await saveSession(resource, sessionWithoutTokens);
4864
+ const verifier = generateCodeVerifier();
4865
+ const challenge = generateCodeChallenge(verifier);
4866
+ const authorizationUrl = buildAuthorizationUrl({
4867
+ metadata: discovery.authorizationServerMetadata,
4868
+ resource,
4869
+ clientId: resolvedClient.client.clientId,
4870
+ redirectUri: loopback.redirectUri,
4871
+ codeChallenge: challenge,
4872
+ clientMetadata: getClientMetadata(options.client)
4801
4873
  });
4802
- let resolvedClient = null;
4803
- try {
4804
- resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch2, signal);
4805
- const sessionWithoutTokens = {
4806
- resource,
4807
- authorizationServer: discovery.authorizationServer,
4808
- client: resolvedClient.client,
4809
- discovery: toStoredDiscovery(discovery)
4810
- };
4811
- await saveSession(resource, sessionWithoutTokens);
4812
- const verifier = generateCodeVerifier();
4813
- const challenge = generateCodeChallenge(verifier);
4814
- const authorizationUrl = buildAuthorizationUrl({
4815
- metadata: discovery.authorizationServerMetadata,
4816
- resource,
4817
- clientId: resolvedClient.client.clientId,
4818
- redirectUri: loopback.redirectUri,
4819
- codeChallenge: challenge,
4820
- clientMetadata: getClientMetadata(options.client)
4821
- });
4822
- const code = await loopback.waitForCode(authorizationUrl);
4823
- const tokens = await exchangeAuthorizationCode({
4824
- tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
4825
- clientId: resolvedClient.client.clientId,
4826
- clientSecret: resolvedClient.client.clientSecret,
4827
- code,
4828
- codeVerifier: verifier,
4829
- redirectUri: loopback.redirectUri,
4830
- resource,
4831
- fetch: fetch2,
4832
- signal,
4833
- now
4834
- });
4835
- const session = {
4836
- ...sessionWithoutTokens,
4837
- tokens
4838
- };
4839
- await saveSession(resource, session);
4840
- return session;
4841
- } catch (error) {
4842
- signal?.throwIfAborted();
4843
- if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
4844
- reRegistrationAttempted = true;
4845
- await clearRegisteredClient(discovery.authorizationServer);
4846
- await clearSession(resource);
4847
- currentSession = null;
4848
- continue;
4849
- }
4850
- if (!transientRetryAttempted && isRetryableOAuthError(error)) {
4851
- transientRetryAttempted = true;
4852
- await clearSession(resource);
4853
- currentSession = null;
4854
- continue;
4855
- }
4856
- throw error;
4857
- } finally {
4858
- loopback.close();
4874
+ const code = await loopback.waitForCode(authorizationUrl);
4875
+ const tokens = await exchangeAuthorizationCode({
4876
+ tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
4877
+ clientId: resolvedClient.client.clientId,
4878
+ clientSecret: resolvedClient.client.clientSecret,
4879
+ code,
4880
+ codeVerifier: verifier,
4881
+ redirectUri: loopback.redirectUri,
4882
+ resource,
4883
+ fetch: fetch2,
4884
+ signal,
4885
+ now
4886
+ });
4887
+ const session = {
4888
+ ...sessionWithoutTokens,
4889
+ tokens
4890
+ };
4891
+ await saveSession(resource, session);
4892
+ return session;
4893
+ } catch (error) {
4894
+ signal?.throwIfAborted();
4895
+ if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
4896
+ reRegistrationAttempted = true;
4897
+ await clearRegisteredClient(discovery.authorizationServer);
4898
+ await clearSession(resource);
4899
+ currentSession = null;
4900
+ continue;
4901
+ }
4902
+ if (!transientRetryAttempted && isRetryableOAuthError(error)) {
4903
+ transientRetryAttempted = true;
4904
+ await clearSession(resource);
4905
+ currentSession = null;
4906
+ continue;
4859
4907
  }
4908
+ throw error;
4909
+ } finally {
4910
+ loopback.close();
4860
4911
  }
4861
- })();
4862
- const finalPromise = promise.finally(() => {
4863
- authorizationPromises.delete(resource);
4864
- });
4865
- authorizationPromises.set(resource, finalPromise);
4866
- return finalPromise;
4912
+ }
4867
4913
  }
4868
4914
  async function resolveClient(existingSession, discovery, redirectUri, fetch2, parentSignal) {
4869
4915
  parentSignal?.throwIfAborted();
@@ -5014,6 +5060,9 @@ function resolveDiscovery(discovery, session) {
5014
5060
  authorizationServerMetadata: metadata
5015
5061
  };
5016
5062
  }
5063
+ function sameTokenGrant(left, right) {
5064
+ return left.accessToken === right.accessToken && left.refreshToken === right.refreshToken && left.tokenType === right.tokenType && left.expiresAt === right.expiresAt && left.scope === right.scope;
5065
+ }
5017
5066
  function clearSessionTokens(session) {
5018
5067
  const nextSession = { ...session };
5019
5068
  delete nextSession.tokens;
@@ -6789,6 +6838,7 @@ var HttpTransport = class {
6789
6838
  oauthMetadataDiscovery;
6790
6839
  inFlightFetchAbortControllers = /* @__PURE__ */ new Set();
6791
6840
  inFlightOAuthAbortControllers = /* @__PURE__ */ new Set();
6841
+ oauthRequestTokens = /* @__PURE__ */ new WeakMap();
6792
6842
  openResponseReaders = /* @__PURE__ */ new Set();
6793
6843
  modernRequests = /* @__PURE__ */ new Map();
6794
6844
  modernMode = false;
@@ -7059,7 +7109,7 @@ var HttpTransport = class {
7059
7109
  }
7060
7110
  async authorizeRequestHeaders(headers, signal) {
7061
7111
  signal?.throwIfAborted();
7062
- await this.oauthProvider?.authorizeRequest?.({
7112
+ const tokens = await this.oauthProvider?.authorizeRequest?.({
7063
7113
  requestUrl: new URL(this.url),
7064
7114
  headers,
7065
7115
  signal,
@@ -7068,6 +7118,7 @@ var HttpTransport = class {
7068
7118
  signal: signal === void 0 ? init?.signal : init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
7069
7119
  })
7070
7120
  });
7121
+ if (tokens !== void 0) this.oauthRequestTokens.set(headers, { ...tokens });
7071
7122
  signal?.throwIfAborted();
7072
7123
  return headers;
7073
7124
  }
@@ -7211,7 +7262,7 @@ var HttpTransport = class {
7211
7262
  const message = responseBody.length === 0 ? `HTTP transport POST failed (${statusDescriptor})` : `HTTP transport POST failed (${statusDescriptor}): ${responseBody}`;
7212
7263
  throw new HttpTransportError(message, response.status, "POST");
7213
7264
  }
7214
- async maybeHandleUnauthorizedResponse(response, signal) {
7265
+ async maybeHandleUnauthorizedResponse(response, signal, requestHeaders, presentedTokens) {
7215
7266
  if (response.status !== 401 || this.oauthProvider === void 0) {
7216
7267
  return false;
7217
7268
  }
@@ -7232,6 +7283,8 @@ var HttpTransport = class {
7232
7283
  challenge,
7233
7284
  discovery,
7234
7285
  signal,
7286
+ requestHeaders: new Headers(requestHeaders),
7287
+ presentedTokens,
7235
7288
  fetch: (url, init) => fetchMcpResponse(this.fetchImpl, url, {
7236
7289
  ...init,
7237
7290
  signal: init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
@@ -7372,16 +7425,20 @@ var HttpTransport = class {
7372
7425
  const request = async () => {
7373
7426
  controller.signal.throwIfAborted();
7374
7427
  const headers = await input.createHeaders(controller.signal);
7428
+ const headerSnapshot = new Headers(headers);
7429
+ const tokens = this.oauthRequestTokens.get(headers) ?? null;
7375
7430
  controller.signal.throwIfAborted();
7376
- return this.fetchWithAbort(input.url ?? this.url, {
7431
+ const response = await this.fetchWithAbort(input.url ?? this.url, {
7377
7432
  method: input.method,
7378
7433
  headers,
7379
7434
  body: input.body
7380
7435
  }, controller);
7436
+ return { response, headers: headerSnapshot, tokens };
7381
7437
  };
7382
7438
  try {
7383
- let response = await request();
7384
- if (await this.maybeHandleUnauthorizedResponse(response, controller.signal)) response = await request();
7439
+ let attempt = await request();
7440
+ if (await this.maybeHandleUnauthorizedResponse(attempt.response, controller.signal, attempt.headers, attempt.tokens)) attempt = await request();
7441
+ const response = attempt.response;
7385
7442
  const oauthError = this.oauthProvider === void 0 ? null : this.readOAuthChallengeError(response);
7386
7443
  if (oauthError !== null) {
7387
7444
  void response.body?.cancel().catch(() => void 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiny-http-mcp-server",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Minimal MCP server over HTTP built on tiny-stdio-mcp-server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",