@decocms/runtime 1.2.4 → 1.2.5

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/oauth.ts +101 -18
  3. package/src/tools.ts +46 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
package/src/oauth.ts CHANGED
@@ -56,11 +56,16 @@ interface PendingAuthState {
56
56
  clientState?: string;
57
57
  codeChallenge?: string;
58
58
  codeChallengeMethod?: string;
59
+ /** The clean callback URL used for OAuth (without state param) - used in token exchange */
60
+ oauthCallbackUri?: string;
59
61
  }
60
62
 
61
63
  interface CodePayload {
62
64
  accessToken: string;
63
65
  tokenType: string;
66
+ refreshToken?: string;
67
+ expiresIn?: number;
68
+ scope?: string;
64
69
  codeChallenge?: string;
65
70
  codeChallengeMethod?: string;
66
71
  }
@@ -154,17 +159,22 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
154
159
  );
155
160
  }
156
161
 
157
- // Encode pending auth state
162
+ // Build callback URL pointing to our internal callback (without state yet)
163
+ const callbackUrl = forceHttps(new URL(`${url.origin}/oauth/callback`));
164
+ // Store the clean callback URL for token exchange
165
+ const oauthCallbackUri = callbackUrl.toString();
166
+
167
+ // Encode pending auth state (including the clean callback URL)
158
168
  const pendingState: PendingAuthState = {
159
169
  redirectUri,
160
170
  clientState: clientState ?? undefined,
161
171
  codeChallenge: codeChallenge ?? undefined,
162
172
  codeChallengeMethod: codeChallengeMethod ?? undefined,
173
+ oauthCallbackUri,
163
174
  };
164
175
  const encodedState = encodeState(pendingState);
165
176
 
166
- // Build callback URL pointing to our internal callback
167
- const callbackUrl = forceHttps(new URL(`${url.origin}/oauth/callback`));
177
+ // Add state to callback URL
168
178
  callbackUrl.searchParams.set("state", encodedState);
169
179
 
170
180
  // Get the external authorization URL from the config
@@ -217,14 +227,26 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
217
227
  }
218
228
 
219
229
  try {
230
+ // Use the clean redirect_uri from the state (same URL used in authorization request)
231
+ // This ensures the exact same URL is used for token exchange
232
+ const cleanRedirectUri =
233
+ pending.oauthCallbackUri ??
234
+ forceHttps(new URL(`${url.origin}/oauth/callback`)).toString();
235
+
220
236
  // Exchange code with external provider
221
- const oauthParams: OAuthParams = { code };
237
+ const oauthParams: OAuthParams = {
238
+ code,
239
+ redirect_uri: cleanRedirectUri,
240
+ };
222
241
  const tokenResponse = await oauth.exchangeCode(oauthParams);
223
242
 
224
243
  // Encode the token in our own code (stateless)
225
244
  const codePayload: CodePayload = {
226
245
  accessToken: tokenResponse.access_token,
227
246
  tokenType: tokenResponse.token_type,
247
+ refreshToken: tokenResponse.refresh_token,
248
+ expiresIn: tokenResponse.expires_in,
249
+ scope: tokenResponse.scope,
228
250
  codeChallenge: pending.codeChallenge,
229
251
  codeChallengeMethod: pending.codeChallengeMethod,
230
252
  };
@@ -257,6 +279,7 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
257
279
 
258
280
  /**
259
281
  * Handle token exchange - decodes our code to get the actual token
282
+ * Supports both authorization_code and refresh_token grant types
260
283
  * Stateless: token is encoded in the code
261
284
  */
262
285
  const handleToken = async (req: Request): Promise<Response> => {
@@ -271,13 +294,63 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
271
294
  body = await req.json();
272
295
  }
273
296
 
274
- const { code, code_verifier, grant_type } = body;
297
+ const { code, code_verifier, grant_type, refresh_token } = body;
298
+
299
+ // Handle refresh_token grant type
300
+ if (grant_type === "refresh_token") {
301
+ if (!refresh_token) {
302
+ return Response.json(
303
+ {
304
+ error: "invalid_request",
305
+ error_description: "refresh_token is required",
306
+ },
307
+ { status: 400 },
308
+ );
309
+ }
310
+
311
+ if (!oauth.refreshToken) {
312
+ return Response.json(
313
+ {
314
+ error: "unsupported_grant_type",
315
+ error_description: "refresh_token grant not supported",
316
+ },
317
+ { status: 400 },
318
+ );
319
+ }
320
+
321
+ // Call the external provider to refresh the token
322
+ const newTokenResponse = await oauth.refreshToken(refresh_token);
323
+
324
+ const tokenResponse: Record<string, unknown> = {
325
+ access_token: newTokenResponse.access_token,
326
+ token_type: newTokenResponse.token_type,
327
+ };
328
+
329
+ if (newTokenResponse.refresh_token) {
330
+ tokenResponse.refresh_token = newTokenResponse.refresh_token;
331
+ }
332
+ if (newTokenResponse.expires_in !== undefined) {
333
+ tokenResponse.expires_in = newTokenResponse.expires_in;
334
+ }
335
+ if (newTokenResponse.scope) {
336
+ tokenResponse.scope = newTokenResponse.scope;
337
+ }
338
+
339
+ return Response.json(tokenResponse, {
340
+ headers: {
341
+ "Cache-Control": "no-store",
342
+ Pragma: "no-cache",
343
+ },
344
+ });
345
+ }
275
346
 
347
+ // Handle authorization_code grant type
276
348
  if (grant_type !== "authorization_code") {
277
349
  return Response.json(
278
350
  {
279
351
  error: "unsupported_grant_type",
280
- error_description: "Only authorization_code supported",
352
+ error_description:
353
+ "Only authorization_code and refresh_token supported",
281
354
  },
282
355
  { status: 400 },
283
356
  );
@@ -339,19 +412,29 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
339
412
  }
340
413
  }
341
414
 
342
- // Return the actual token
343
- return Response.json(
344
- {
345
- access_token: payload.accessToken,
346
- token_type: payload.tokenType,
347
- },
348
- {
349
- headers: {
350
- "Cache-Control": "no-store",
351
- Pragma: "no-cache",
352
- },
415
+ // Return the actual token with all fields
416
+ const tokenResponse: Record<string, unknown> = {
417
+ access_token: payload.accessToken,
418
+ token_type: payload.tokenType,
419
+ };
420
+
421
+ // Include optional fields if present
422
+ if (payload.refreshToken) {
423
+ tokenResponse.refresh_token = payload.refreshToken;
424
+ }
425
+ if (payload.expiresIn !== undefined) {
426
+ tokenResponse.expires_in = payload.expiresIn;
427
+ }
428
+ if (payload.scope) {
429
+ tokenResponse.scope = payload.scope;
430
+ }
431
+
432
+ return Response.json(tokenResponse, {
433
+ headers: {
434
+ "Cache-Control": "no-store",
435
+ Pragma: "no-cache",
353
436
  },
354
- );
437
+ });
355
438
  } catch (err) {
356
439
  console.error("Token exchange error:", err);
357
440
  return Response.json(
package/src/tools.ts CHANGED
@@ -349,6 +349,11 @@ export interface OAuthParams {
349
349
  code: string;
350
350
  code_verifier?: string;
351
351
  code_challenge_method?: "S256" | "plain";
352
+ /**
353
+ * The redirect_uri used in the authorization request (without state/extra params).
354
+ * This is the clean callback URL that should be used for token exchange.
355
+ */
356
+ redirect_uri?: string;
352
357
  }
353
358
 
354
359
  export interface OAuthTokenResponse {
@@ -398,6 +403,11 @@ export interface OAuthConfig {
398
403
  * Called when the OAuth callback is received with a code
399
404
  */
400
405
  exchangeCode: (oauthParams: OAuthParams) => Promise<OAuthTokenResponse>;
406
+ /**
407
+ * Refreshes the access token using a refresh token
408
+ * Called when the client requests a new access token with grant_type=refresh_token
409
+ */
410
+ refreshToken?: (refreshToken: string) => Promise<OAuthTokenResponse>;
401
411
  /**
402
412
  * Optional: persistence for dynamic client registration (RFC7591)
403
413
  * If not provided, clients are accepted without validation
@@ -850,16 +860,46 @@ export const createMCPServer = <
850
860
  await server.connect(transport);
851
861
 
852
862
  try {
853
- return await transport.handleRequest(req);
854
- } finally {
855
- // CRITICAL: Close transport to prevent memory leaks
856
- // Without this, ReadableStream/WritableStream controllers accumulate
857
- // causing thousands of stream objects to be retained in memory
863
+ const response = await transport.handleRequest(req);
864
+
865
+ // Check if this is a streaming response (SSE or streamable tool)
866
+ // SSE responses have text/event-stream content-type
867
+ // Note: response.body is always non-null for all HTTP responses, so we can't use it to detect streaming
868
+ const contentType = response.headers.get("content-type");
869
+ const isStreaming =
870
+ contentType?.includes("text/event-stream") ||
871
+ contentType?.includes("application/json-rpc");
872
+
873
+ // Only close transport for non-streaming responses
874
+ if (!isStreaming) {
875
+ console.debug(
876
+ "[MCP Transport] Closing transport for non-streaming response",
877
+ );
878
+ try {
879
+ await transport.close?.();
880
+ } catch {
881
+ // Ignore close errors
882
+ }
883
+ } else {
884
+ console.debug(
885
+ "[MCP Transport] Keeping transport open for streaming response (Content-Type: %s)",
886
+ contentType,
887
+ );
888
+ }
889
+
890
+ return response;
891
+ } catch (error) {
892
+ // On error, always try to close transport to prevent leaks
893
+ console.debug(
894
+ "[MCP Transport] Closing transport due to error:",
895
+ error instanceof Error ? error.message : error,
896
+ );
858
897
  try {
859
898
  await transport.close?.();
860
899
  } catch {
861
- // Ignore close errors - transport may already be closed
900
+ // Ignore close errors
862
901
  }
902
+ throw error;
863
903
  }
864
904
  };
865
905