@opengeni/api-router 0.5.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  resolveNatsControlPlaneAuth,
6
6
  retryStartupDependency,
7
7
  startupRetryOptions,
8
+ temporalConnectionOptions,
8
9
  } from "@opengeni/config";
9
10
  import type {
10
11
  ScheduledTask,
@@ -61,7 +62,7 @@ export async function createTemporalWorkflowClient(
61
62
  documentIndexer: DocumentIndexClient;
62
63
  close: () => Promise<void>;
63
64
  }> {
64
- const connection = await Connection.connect({ address: settings.temporalHost });
65
+ const connection = await Connection.connect(temporalConnectionOptions(settings));
65
66
  const temporal = new TemporalClient({
66
67
  connection,
67
68
  namespace: settings.temporalNamespace,
@@ -11,7 +11,6 @@ import {
11
11
  decryptEnvironmentValue,
12
12
  encryptEnvironmentValue,
13
13
  getConnectionMetadata,
14
- isPrivateAddress,
15
14
  listConnectionsMetadata,
16
15
  loadIntegrationOAuthClient,
17
16
  normalizeBearerScheme,
@@ -20,14 +19,21 @@ import {
20
19
  type Database,
21
20
  } from "@opengeni/db";
22
21
  import { createSignedState, readSignedState } from "@opengeni/github";
22
+ import {
23
+ DestinationPolicyError,
24
+ OAUTH_MAX_RESPONSE_BYTES,
25
+ isLocalTestEnvironment,
26
+ pinnedFetch,
27
+ readResponseJsonBounded,
28
+ validateHttpUrl,
29
+ } from "@opengeni/network";
23
30
  import { Buffer } from "node:buffer";
24
31
  import { createHash, randomBytes } from "node:crypto";
25
- import { lookup } from "node:dns/promises";
26
- import { isIP } from "node:net";
27
32
  import { HTTPException } from "hono/http-exception";
28
33
  import { canonicalProviderDomain } from "./provider-domain";
29
34
 
30
35
  export const oauthStateTtlMs = 10 * 60 * 1000;
36
+ export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
31
37
 
32
38
  type OAuthClientDeps = {
33
39
  db: Database;
@@ -192,6 +198,7 @@ export async function startMcpOAuth(
192
198
  });
193
199
  const authorizationUrl = buildAuthorizationUrl({
194
200
  endpoint: discovery.as.authorizationEndpoint,
201
+ settings,
195
202
  clientId: client.clientId,
196
203
  redirectUri,
197
204
  state,
@@ -386,10 +393,14 @@ async function probeMcpChallenge(
386
393
  method: "GET",
387
394
  headers: { accept: "application/json" },
388
395
  });
389
- if (response.status !== 401) {
390
- return {};
396
+ try {
397
+ if (response.status !== 401) {
398
+ return {};
399
+ }
400
+ return parseWwwAuthenticate(response.headers.get("www-authenticate"));
401
+ } finally {
402
+ await cancelResponseBody(response);
391
403
  }
392
- return parseWwwAuthenticate(response.headers.get("www-authenticate"));
393
404
  }
394
405
 
395
406
  async function discoverProtectedResourceMetadata(
@@ -429,10 +440,15 @@ async function discoverAuthorizationServerMetadata(
429
440
  authorizationServer: string,
430
441
  settings: Settings,
431
442
  ): Promise<AuthorizationServerMetadata> {
432
- const candidates = uniqueStrings([
443
+ const safeAuthorizationServer = oauthEndpointUrl(
433
444
  authorizationServer,
434
- ...wellKnownCandidates(authorizationServer, "oauth-authorization-server"),
435
- ...wellKnownCandidates(authorizationServer, "openid-configuration"),
445
+ settings,
446
+ "OAuth authorization server",
447
+ ).replace(/\/+$/, "");
448
+ const candidates = uniqueStrings([
449
+ safeAuthorizationServer,
450
+ ...wellKnownCandidates(safeAuthorizationServer, "oauth-authorization-server"),
451
+ ...wellKnownCandidates(safeAuthorizationServer, "openid-configuration"),
436
452
  ]);
437
453
  for (const candidate of candidates) {
438
454
  const payload = await fetchJsonObject(candidate, settings).catch((error) => {
@@ -449,18 +465,31 @@ async function discoverAuthorizationServerMetadata(
449
465
  if (!authorizationEndpoint || !tokenEndpoint) {
450
466
  continue;
451
467
  }
452
- return {
453
- issuer: stringValue(payload.issuer) ?? authorizationServer.replace(/\/+$/, ""),
454
- authorizationServer: authorizationServer.replace(/\/+$/, ""),
468
+ const safeAuthorizationEndpoint = oauthEndpointUrl(
455
469
  authorizationEndpoint,
456
- tokenEndpoint,
470
+ settings,
471
+ "OAuth authorization endpoint",
472
+ );
473
+ const safeTokenEndpoint = oauthEndpointUrl(tokenEndpoint, settings, "OAuth token endpoint");
474
+ const registrationEndpoint = stringValue(payload.registration_endpoint);
475
+ const issuer = oauthEndpointUrl(
476
+ stringValue(payload.issuer) ?? safeAuthorizationServer,
477
+ settings,
478
+ "OAuth issuer",
479
+ );
480
+ const safeRegistrationEndpoint = registrationEndpoint
481
+ ? oauthEndpointUrl(registrationEndpoint, settings, "OAuth registration endpoint")
482
+ : undefined;
483
+ return {
484
+ issuer,
485
+ authorizationServer: safeAuthorizationServer,
486
+ authorizationEndpoint: safeAuthorizationEndpoint,
487
+ tokenEndpoint: safeTokenEndpoint,
457
488
  clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
458
489
  tokenEndpointAuthMethodsSupported: stringArray(payload.token_endpoint_auth_methods_supported),
459
490
  codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
460
491
  raw: payload,
461
- ...(stringValue(payload.registration_endpoint)
462
- ? { registrationEndpoint: stringValue(payload.registration_endpoint)! }
463
- : {}),
492
+ ...(safeRegistrationEndpoint ? { registrationEndpoint: safeRegistrationEndpoint } : {}),
464
493
  };
465
494
  }
466
495
  throw new HTTPException(422, {
@@ -661,7 +690,6 @@ async function dynamicClientRegistration(
661
690
  message: "authorization server does not support dynamic client registration",
662
691
  });
663
692
  }
664
- await assertOAuthFetchAllowed(as.registrationEndpoint, settings);
665
693
  const response = await fetchOAuth(as.registrationEndpoint, settings, {
666
694
  method: "POST",
667
695
  headers: { "content-type": "application/json", accept: "application/json" },
@@ -675,11 +703,16 @@ async function dynamicClientRegistration(
675
703
  }),
676
704
  });
677
705
  if (!response.ok) {
706
+ await cancelResponseBody(response);
678
707
  throw new HTTPException(422, {
679
708
  message: `dynamic client registration failed with HTTP ${response.status}`,
680
709
  });
681
710
  }
682
- const payload = (await response.json()) as Record<string, unknown>;
711
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
712
+ response,
713
+ OAUTH_MAX_RESPONSE_BYTES,
714
+ "OAuth dynamic registration response",
715
+ );
683
716
  const clientId = stringValue(payload.client_id);
684
717
  if (!clientId) {
685
718
  throw new HTTPException(422, {
@@ -726,6 +759,7 @@ async function existingOAuthConnectionForStart(
726
759
 
727
760
  function buildAuthorizationUrl(input: {
728
761
  endpoint: string;
762
+ settings: Settings;
729
763
  clientId: string;
730
764
  redirectUri: string;
731
765
  state: string;
@@ -733,7 +767,8 @@ function buildAuthorizationUrl(input: {
733
767
  verifier: string;
734
768
  scopes: string[];
735
769
  }): string {
736
- const url = new URL(input.endpoint);
770
+ const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint");
771
+ const url = new URL(endpoint);
737
772
  url.searchParams.set("response_type", "code");
738
773
  url.searchParams.set("client_id", input.clientId);
739
774
  url.searchParams.set("redirect_uri", input.redirectUri);
@@ -775,9 +810,21 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
775
810
  "state.encryptedPkceVerifier",
776
811
  ),
777
812
  clientId: requiredString(payload.clientId, "state.clientId"),
778
- tokenEndpoint: requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
779
- authorizationServer: requiredString(payload.authorizationServer, "state.authorizationServer"),
780
- issuer: requiredString(payload.issuer, "state.issuer"),
813
+ tokenEndpoint: oauthEndpointUrl(
814
+ requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
815
+ settings,
816
+ "OAuth token endpoint",
817
+ ),
818
+ authorizationServer: oauthEndpointUrl(
819
+ requiredString(payload.authorizationServer, "state.authorizationServer"),
820
+ settings,
821
+ "OAuth authorization server",
822
+ ).replace(/\/+$/, ""),
823
+ issuer: oauthEndpointUrl(
824
+ requiredString(payload.issuer, "state.issuer"),
825
+ settings,
826
+ "OAuth issuer",
827
+ ),
781
828
  clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
782
829
  tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
783
830
  ...(stringValue(payload.encryptedClientSecret)
@@ -825,7 +872,12 @@ async function clientForState(
825
872
  }
826
873
  if (state.clientRegistrationMethod === "dcr") {
827
874
  const stored = await loadIntegrationOAuthClient(db, settings, state.issuer);
828
- if (!stored || stored.clientId !== state.clientId) {
875
+ if (
876
+ !stored ||
877
+ stored.clientId !== state.clientId ||
878
+ stored.issuer !== state.issuer ||
879
+ stored.authorizationServer !== state.authorizationServer
880
+ ) {
829
881
  throw new HTTPException(400, { message: "OAuth client registration is no longer available" });
830
882
  }
831
883
  return {
@@ -870,7 +922,6 @@ async function exchangeAuthorizationCode(
870
922
  client: OAuthClientRegistration;
871
923
  },
872
924
  ): Promise<TokenResponse> {
873
- await assertOAuthFetchAllowed(input.tokenEndpoint, settings);
874
925
  const body = new URLSearchParams();
875
926
  body.set("grant_type", "authorization_code");
876
927
  body.set("code", input.code);
@@ -905,7 +956,11 @@ async function exchangeAuthorizationCode(
905
956
  new Error(`OAuth token endpoint returned HTTP ${response.status}`),
906
957
  );
907
958
  }
908
- const payload = (await response.json()) as Record<string, unknown>;
959
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
960
+ response,
961
+ OAUTH_MAX_RESPONSE_BYTES,
962
+ "OAuth token response",
963
+ );
909
964
  const accessToken = stringValue(payload.access_token);
910
965
  if (!accessToken) {
911
966
  throw new Error("OAuth token response did not include access_token");
@@ -997,12 +1052,17 @@ function safeHost(rawUrl: string): string | undefined {
997
1052
  async function oauthErrorFromResponse(response: Response): Promise<string | null> {
998
1053
  const contentType = response.headers.get("content-type") ?? "";
999
1054
  if (!contentType.toLowerCase().includes("application/json")) {
1055
+ await cancelResponseBody(response);
1000
1056
  return null;
1001
1057
  }
1002
- const payload = (await response
1003
- .clone()
1004
- .json()
1005
- .catch(() => null)) as Record<string, unknown> | null;
1058
+ // Consume the original response, not a clone. The pinned transport owns a
1059
+ // per-response dispatcher, so leaving the original body unread would retain
1060
+ // its socket pool after a token endpoint error.
1061
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
1062
+ response,
1063
+ OAUTH_MAX_RESPONSE_BYTES,
1064
+ "OAuth token error response",
1065
+ ).catch(() => null);
1006
1066
  const error = stringValue(payload?.error);
1007
1067
  if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
1008
1068
  return null;
@@ -1015,7 +1075,6 @@ async function verifyMcpToolsList(
1015
1075
  resource: string,
1016
1076
  token: TokenResponse,
1017
1077
  ): Promise<Array<{ name: string; description?: string }>> {
1018
- await assertOAuthFetchAllowed(resource, settings);
1019
1078
  const client = new Client(
1020
1079
  { name: "opengeni-integration-verify", version: "0.1.0" },
1021
1080
  { capabilities: {} },
@@ -1158,6 +1217,20 @@ function canonicalOAuthResource(value: string): string {
1158
1217
  }
1159
1218
  }
1160
1219
 
1220
+ function oauthEndpointUrl(rawUrl: string, settings: Settings, label: string): string {
1221
+ try {
1222
+ return validateHttpUrl(rawUrl, {
1223
+ label,
1224
+ allowLoopbackHttp: isLocalTestEnvironment(settings.environment),
1225
+ });
1226
+ } catch (error) {
1227
+ if (error instanceof DestinationPolicyError) {
1228
+ throw new HTTPException(422, { message: error.message });
1229
+ }
1230
+ throw error;
1231
+ }
1232
+ }
1233
+
1161
1234
  function safeReturnPath(value: string): string {
1162
1235
  if (!value.startsWith("/") || value.startsWith("//")) {
1163
1236
  throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
@@ -1172,9 +1245,14 @@ function safeReturnPath(value: string): string {
1172
1245
  async function fetchJsonObject(url: string, settings: Settings): Promise<Record<string, unknown>> {
1173
1246
  const response = await fetchOAuth(url, settings, { headers: { accept: "application/json" } });
1174
1247
  if (!response.ok) {
1248
+ await cancelResponseBody(response);
1175
1249
  throw new Error(`HTTP ${response.status}`);
1176
1250
  }
1177
- const payload = await response.json();
1251
+ const payload = await readResponseJsonBounded<unknown>(
1252
+ response,
1253
+ OAUTH_MAX_RESPONSE_BYTES,
1254
+ "OAuth metadata response",
1255
+ );
1178
1256
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1179
1257
  throw new Error("metadata response was not a JSON object");
1180
1258
  }
@@ -1187,56 +1265,64 @@ async function fetchOAuth(
1187
1265
  init: RequestInit = {},
1188
1266
  hop = 0,
1189
1267
  ): Promise<Response> {
1190
- await assertOAuthFetchAllowed(rawUrl, settings);
1191
- const response = await fetch(rawUrl, { ...init, redirect: "manual" });
1268
+ let response: Response;
1269
+ try {
1270
+ const endpoint = oauthEndpointUrl(rawUrl, settings, "OAuth endpoint");
1271
+ response = await pinnedFetch(endpoint, init, settings, {
1272
+ label: "OAuth discovery",
1273
+ requireHttpsOutsideLocalTest: true,
1274
+ });
1275
+ } catch (error) {
1276
+ if (error instanceof DestinationPolicyError) {
1277
+ throw new HTTPException(422, { message: error.message });
1278
+ }
1279
+ throw error;
1280
+ }
1192
1281
  if (response.status < 300 || response.status >= 400) {
1193
1282
  return response;
1194
1283
  }
1284
+ // Discovery is the only redirectable OAuth traffic. Replaying a token
1285
+ // exchange, dynamic registration, or authenticated MCP request would send
1286
+ // its body and/or credential headers to a provider-controlled Location.
1287
+ // Keep this allowlist deliberately narrow so future credential headers fail
1288
+ // closed instead of silently becoming redirectable.
1289
+ if (!oauthRequestMayFollowRedirect(init)) {
1290
+ await cancelResponseBody(response);
1291
+ throw new HTTPException(422, {
1292
+ message: "OAuth credential-bearing requests may not follow redirects",
1293
+ });
1294
+ }
1195
1295
  if (hop >= 3) {
1296
+ await cancelResponseBody(response);
1196
1297
  throw new HTTPException(422, { message: "OAuth fetch exceeded maximum redirect hops" });
1197
1298
  }
1198
1299
  const location = response.headers.get("location");
1199
1300
  if (!location) {
1301
+ await cancelResponseBody(response);
1200
1302
  throw new HTTPException(422, { message: "OAuth fetch redirect was missing Location" });
1201
1303
  }
1202
1304
  let nextUrl: string;
1203
1305
  try {
1204
1306
  nextUrl = new URL(location, rawUrl).toString();
1205
1307
  } catch {
1308
+ await cancelResponseBody(response);
1206
1309
  throw new HTTPException(422, { message: "OAuth fetch redirect Location was invalid" });
1207
1310
  }
1311
+ await cancelResponseBody(response);
1208
1312
  return await fetchOAuth(nextUrl, settings, init, hop + 1);
1209
1313
  }
1210
1314
 
1211
- async function assertOAuthFetchAllowed(rawUrl: string, settings: Settings): Promise<void> {
1212
- const url = new URL(rawUrl);
1213
- if (!["https:", "http:"].includes(url.protocol)) {
1214
- throw new HTTPException(422, { message: "OAuth discovery only supports http and https URLs" });
1215
- }
1216
- if (
1217
- settings.integrationsAllowPrivateNetworkTargets ||
1218
- ["local", "test"].includes(settings.environment)
1219
- ) {
1220
- return;
1221
- }
1222
- if (url.protocol !== "https:") {
1223
- throw new HTTPException(422, {
1224
- message: "OAuth discovery targets must use https outside local/test",
1225
- });
1226
- }
1227
- const hostname = url.hostname.toLowerCase();
1228
- if (hostname === "localhost" || hostname.endsWith(".localhost")) {
1229
- throw new HTTPException(422, { message: "OAuth discovery may not target localhost" });
1230
- }
1231
- const literal = isIP(hostname);
1232
- const addresses = literal
1233
- ? [hostname]
1234
- : (await lookup(hostname, { all: true })).map((entry) => entry.address);
1235
- if (addresses.some(isPrivateAddress)) {
1236
- throw new HTTPException(422, {
1237
- message: "OAuth discovery may not target private network addresses",
1238
- });
1315
+ function oauthRequestMayFollowRedirect(init: RequestInit): boolean {
1316
+ const method = (init.method ?? "GET").toUpperCase();
1317
+ if ((method !== "GET" && method !== "HEAD") || init.body != null) {
1318
+ return false;
1239
1319
  }
1320
+ const headers = new Headers(init.headers);
1321
+ return [...headers.keys()].every((name) => name === "accept");
1322
+ }
1323
+
1324
+ async function cancelResponseBody(response: Response): Promise<void> {
1325
+ await response.body?.cancel().catch(() => undefined);
1240
1326
  }
1241
1327
 
1242
1328
  function parseWwwAuthenticate(header: string | null): WwwAuthenticateChallenge {