@opengeni/api-router 0.7.3 → 0.11.1
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/dist/app.js +1 -1
- package/dist/{chunk-EYYTFA7N.js → chunk-7PQKPKW5.js} +4117 -1863
- package/dist/chunk-7PQKPKW5.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +12 -11
- package/src/app.ts +37 -15
- package/src/codex-redemption-security.ts +96 -0
- package/src/integrations/oauth-client.ts +147 -61
- package/src/mcp/server.ts +151 -83
- package/src/mcp/session-view.ts +102 -1
- package/src/mcp/toolspace.ts +389 -124
- package/src/model-catalog.ts +337 -0
- package/src/routes/codex.ts +866 -14
- package/src/routes/files.ts +153 -0
- package/src/routes/machines.ts +22 -2
- package/src/routes/sessions.ts +637 -152
- package/src/routes/workspaces.ts +36 -2
- package/src/sandbox/channel-a.ts +54 -59
- package/src/sandbox/machines.ts +7 -0
- package/src/sandbox/rematerialize.ts +287 -0
- package/src/sandbox/viewer.ts +207 -129
- package/dist/chunk-EYYTFA7N.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/api-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,17 +43,18 @@
|
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
44
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
45
|
"@opengeni/agent-proto": "^0.3.0",
|
|
46
|
-
"@opengeni/codex": "^0.2.
|
|
47
|
-
"@opengeni/config": "^0.
|
|
48
|
-
"@opengeni/contracts": "^0.
|
|
49
|
-
"@opengeni/core": "^0.
|
|
50
|
-
"@opengeni/db": "^0.
|
|
51
|
-
"@opengeni/documents": "^0.2.
|
|
52
|
-
"@opengeni/events": "^0.3.
|
|
53
|
-
"@opengeni/github": "^0.3.
|
|
46
|
+
"@opengeni/codex": "^0.2.7",
|
|
47
|
+
"@opengeni/config": "^0.7.0",
|
|
48
|
+
"@opengeni/contracts": "^0.19.0",
|
|
49
|
+
"@opengeni/core": "^0.11.1",
|
|
50
|
+
"@opengeni/db": "^0.12.0",
|
|
51
|
+
"@opengeni/documents": "^0.2.30",
|
|
52
|
+
"@opengeni/events": "^0.3.21",
|
|
53
|
+
"@opengeni/github": "^0.3.12",
|
|
54
|
+
"@opengeni/network": "^0.1.1",
|
|
54
55
|
"@opengeni/observability": "^0.3.0",
|
|
55
|
-
"@opengeni/runtime": "^0.
|
|
56
|
-
"@opengeni/storage": "^0.2.
|
|
56
|
+
"@opengeni/runtime": "^0.13.3",
|
|
57
|
+
"@opengeni/storage": "^0.2.24",
|
|
57
58
|
"@temporalio/client": "^1.17.0",
|
|
58
59
|
"better-auth": "^1.6.14",
|
|
59
60
|
"hono": "^4.12.18",
|
package/src/app.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
canonicalizeConfiguredModelId,
|
|
2
3
|
configuredAllowedModels,
|
|
3
4
|
configuredAllowedReasoningEfforts,
|
|
4
5
|
configuredModels,
|
|
@@ -37,6 +38,7 @@ import { createApiSandboxClient, makeResumeBoxById } from "./sandbox/access";
|
|
|
37
38
|
import { requireLimit } from "@opengeni/core";
|
|
38
39
|
import { buildOpenGeniMcpServer } from "./mcp/server";
|
|
39
40
|
import { isToolspaceGrant, prepareToolspaceMcpSurface } from "./mcp/toolspace";
|
|
41
|
+
import { boundedMcpRequest, McpPayloadTooLargeError } from "@opengeni/runtime/mcp-network";
|
|
40
42
|
import { requireAccessKey } from "./http/auth";
|
|
41
43
|
import { registerCapabilityRoutes } from "./routes/capabilities";
|
|
42
44
|
import { registerCatalogAssetRoutes } from "./routes/catalog-assets";
|
|
@@ -57,6 +59,7 @@ import { registerScheduledTaskRoutes } from "./routes/scheduled-tasks";
|
|
|
57
59
|
import { registerSessionRoutes } from "./routes/sessions";
|
|
58
60
|
import { registerSocialRoutes } from "./routes/social";
|
|
59
61
|
import { registerWorkspaceRoutes } from "./routes/workspaces";
|
|
62
|
+
import { projectClientModel } from "./model-catalog";
|
|
60
63
|
|
|
61
64
|
export type {
|
|
62
65
|
ApiRouteDeps,
|
|
@@ -292,22 +295,13 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
292
295
|
deploymentRevision: deps.settings.deploymentRevision,
|
|
293
296
|
apiContractRevision: OPENGENI_API_CONTRACT_REVISION,
|
|
294
297
|
...(deps.settings.serverVersion ? { serverVersion: deps.settings.serverVersion } : {}),
|
|
295
|
-
defaultModel: deps.settings.openaiModel,
|
|
298
|
+
defaultModel: canonicalizeConfiguredModelId(deps.settings, deps.settings.openaiModel),
|
|
296
299
|
allowedModels: configuredAllowedModels(deps.settings),
|
|
297
300
|
// Provider-grouped model list for the picker. configuredModels() carries the
|
|
298
301
|
// union of the built-in allow-list and every registry provider's models, in
|
|
299
302
|
// selection order (default model first); project each to the client-safe
|
|
300
303
|
// ClientModel shape (ConfiguredModel.providerId → ClientModel.provider).
|
|
301
|
-
models: configuredModels(deps.settings).map(
|
|
302
|
-
id: model.id,
|
|
303
|
-
label: model.label,
|
|
304
|
-
provider: model.providerId,
|
|
305
|
-
providerLabel: model.providerLabel,
|
|
306
|
-
api: model.api,
|
|
307
|
-
...(model.contextWindowTokens === undefined
|
|
308
|
-
? {}
|
|
309
|
-
: { contextWindowTokens: model.contextWindowTokens }),
|
|
310
|
-
})),
|
|
304
|
+
models: configuredModels(deps.settings).map(projectClientModel),
|
|
311
305
|
defaultReasoningEffort: deps.settings.openaiReasoningEffort,
|
|
312
306
|
allowedReasoningEfforts: configuredAllowedReasoningEfforts(deps.settings),
|
|
313
307
|
mcpServers: deps.settings.mcpServers.map((server) => ({
|
|
@@ -330,6 +324,15 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
330
324
|
|
|
331
325
|
app.all("/v1/workspaces/:workspaceId/mcp", async (c) => {
|
|
332
326
|
const workspaceId = c.req.param("workspaceId");
|
|
327
|
+
let boundedRequest: Request;
|
|
328
|
+
try {
|
|
329
|
+
boundedRequest = await boundedMcpRequest(c.req.raw);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error instanceof McpPayloadTooLargeError) {
|
|
332
|
+
throw new HTTPException(413, { message: "MCP request body exceeds the safety limit" });
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
333
336
|
const grant = await requireMcpAccessGrant(c, routeDeps, workspaceId);
|
|
334
337
|
const toolspaceGrant = isToolspaceGrant(routeDeps.settings, grant);
|
|
335
338
|
const boundSessionId = grant.metadata?.sessionId;
|
|
@@ -353,9 +356,17 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
353
356
|
throw error;
|
|
354
357
|
}
|
|
355
358
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
+
let toolspace: Awaited<ReturnType<typeof prepareToolspaceMcpSurface>> = null;
|
|
360
|
+
if (toolspaceGrant) {
|
|
361
|
+
try {
|
|
362
|
+
toolspace = await prepareToolspaceMcpSurface({ deps: routeDeps, grant });
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error instanceof McpPayloadTooLargeError) {
|
|
365
|
+
throw new HTTPException(413, { message: "MCP tool list exceeds the safety limit" });
|
|
366
|
+
}
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
359
370
|
const workspace = await getWorkspace(routeDeps.db, workspaceId);
|
|
360
371
|
const workspaceMemoryEnabled = resolveWorkspaceMemoryEnabled(workspace?.settings);
|
|
361
372
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
@@ -368,7 +379,7 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
368
379
|
});
|
|
369
380
|
try {
|
|
370
381
|
await mcp.connect(transport);
|
|
371
|
-
return await transport.handleRequest(
|
|
382
|
+
return await transport.handleRequest(boundedRequest);
|
|
372
383
|
} finally {
|
|
373
384
|
await toolspace?.close().catch(() => undefined);
|
|
374
385
|
}
|
|
@@ -450,6 +461,9 @@ export function httpStatusForError(error: unknown): number {
|
|
|
450
461
|
if (error instanceof HTTPException) {
|
|
451
462
|
return error.status;
|
|
452
463
|
}
|
|
464
|
+
if (error instanceof McpPayloadTooLargeError) {
|
|
465
|
+
return 413;
|
|
466
|
+
}
|
|
453
467
|
return 500;
|
|
454
468
|
}
|
|
455
469
|
|
|
@@ -662,6 +676,14 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
|
|
|
662
676
|
pattern: /^\/v1\/workspaces\/[^/]+\/files\/[^/]+$/,
|
|
663
677
|
label: "/v1/workspaces/:workspaceId/files/:id",
|
|
664
678
|
},
|
|
679
|
+
{
|
|
680
|
+
pattern: /^\/v1\/workspaces\/[^/]+\/artifacts\/[^/]+\/content$/,
|
|
681
|
+
label: "/v1/workspaces/:workspaceId/artifacts/:id/content",
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
pattern: /^\/v1\/workspaces\/[^/]+\/artifacts\/[^/]+$/,
|
|
685
|
+
label: "/v1/workspaces/:workspaceId/artifacts/:id",
|
|
686
|
+
},
|
|
665
687
|
{
|
|
666
688
|
pattern: /^\/v1\/workspaces\/[^/]+\/api-keys$/,
|
|
667
689
|
label: "/v1/workspaces/:workspaceId/api-keys",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
type CodexRedemptionConfirmationClaims = {
|
|
2
|
+
version: 1;
|
|
3
|
+
attemptId: string;
|
|
4
|
+
workspaceId: string;
|
|
5
|
+
credentialId: string;
|
|
6
|
+
creditId: string;
|
|
7
|
+
subjectId: string;
|
|
8
|
+
browserSessionHash: string;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
|
|
14
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
15
|
+
return Buffer.from(bytes).toString("base64url");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function base64UrlDecode(value: string): Uint8Array | null {
|
|
19
|
+
try {
|
|
20
|
+
return new Uint8Array(Buffer.from(value, "base64url"));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function hmac(secret: string, payload: string): Promise<Uint8Array> {
|
|
27
|
+
const key = await crypto.subtle.importKey(
|
|
28
|
+
"raw",
|
|
29
|
+
encoder.encode(secret),
|
|
30
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
31
|
+
false,
|
|
32
|
+
["sign"],
|
|
33
|
+
);
|
|
34
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
|
|
38
|
+
if (left.length !== right.length) return false;
|
|
39
|
+
let diff = 0;
|
|
40
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
41
|
+
diff |= left[index]! ^ right[index]!;
|
|
42
|
+
}
|
|
43
|
+
return diff === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function hashCodexBrowserSession(sessionId: string): Promise<string> {
|
|
47
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(sessionId));
|
|
48
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Five-minute, session-bound, HMAC-confirmed browser mutation token. */
|
|
52
|
+
export async function signCodexRedemptionConfirmation(
|
|
53
|
+
secret: string,
|
|
54
|
+
claims: CodexRedemptionConfirmationClaims,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
const payload = base64UrlEncode(encoder.encode(JSON.stringify(claims)));
|
|
57
|
+
return `${payload}.${base64UrlEncode(await hmac(secret, payload))}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function verifyCodexRedemptionConfirmation(
|
|
61
|
+
secret: string,
|
|
62
|
+
token: string,
|
|
63
|
+
now = Date.now(),
|
|
64
|
+
): Promise<CodexRedemptionConfirmationClaims | null> {
|
|
65
|
+
const [payload, signature, extra] = token.split(".");
|
|
66
|
+
if (!payload || !signature || extra !== undefined) return null;
|
|
67
|
+
const supplied = base64UrlDecode(signature);
|
|
68
|
+
const encodedClaims = base64UrlDecode(payload);
|
|
69
|
+
if (!supplied || !encodedClaims) return null;
|
|
70
|
+
if (!constantTimeEqual(supplied, await hmac(secret, payload))) return null;
|
|
71
|
+
let claims: unknown;
|
|
72
|
+
try {
|
|
73
|
+
claims = JSON.parse(new TextDecoder().decode(encodedClaims));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (!claims || typeof claims !== "object") return null;
|
|
78
|
+
const value = claims as Record<string, unknown>;
|
|
79
|
+
if (
|
|
80
|
+
value.version !== 1 ||
|
|
81
|
+
typeof value.attemptId !== "string" ||
|
|
82
|
+
typeof value.workspaceId !== "string" ||
|
|
83
|
+
typeof value.credentialId !== "string" ||
|
|
84
|
+
typeof value.creditId !== "string" ||
|
|
85
|
+
typeof value.subjectId !== "string" ||
|
|
86
|
+
typeof value.browserSessionHash !== "string" ||
|
|
87
|
+
typeof value.expiresAt !== "number" ||
|
|
88
|
+
!Number.isFinite(value.expiresAt) ||
|
|
89
|
+
value.expiresAt * 1000 <= now
|
|
90
|
+
) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return value as CodexRedemptionConfirmationClaims;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type { CodexRedemptionConfirmationClaims };
|
|
@@ -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
|
-
|
|
390
|
-
|
|
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
|
|
443
|
+
const safeAuthorizationServer = oauthEndpointUrl(
|
|
433
444
|
authorizationServer,
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
453
|
-
issuer: stringValue(payload.issuer) ?? authorizationServer.replace(/\/+$/, ""),
|
|
454
|
-
authorizationServer: authorizationServer.replace(/\/+$/, ""),
|
|
468
|
+
const safeAuthorizationEndpoint = oauthEndpointUrl(
|
|
455
469
|
authorizationEndpoint,
|
|
456
|
-
|
|
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
|
-
...(
|
|
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 =
|
|
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
|
|
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:
|
|
779
|
-
|
|
780
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
|
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
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
1212
|
-
const
|
|
1213
|
-
if (
|
|
1214
|
-
|
|
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 {
|