@opengeni/api-router 0.5.1 → 0.5.3
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-DQ5TIRDZ.js → chunk-3HIA43CC.js} +512 -274
- package/dist/chunk-3HIA43CC.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/app.ts +3 -0
- package/src/http/auth.ts +6 -0
- package/src/integrations/oauth-client.ts +217 -38
- package/src/integrations/provider-domain.ts +15 -0
- package/src/routes/catalog-assets.ts +105 -0
- package/src/routes/connections.ts +6 -5
- package/dist/chunk-DQ5TIRDZ.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-3HIA43CC.js";
|
|
4
4
|
|
|
5
5
|
// src/index.ts
|
|
6
6
|
import { dbSearchPath, getSettings, resolveNatsCalloutConfig, resolveNatsControlPlaneAuth, retryStartupDependency, startupRetryOptions } from "@opengeni/config";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/api-router",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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": {
|
|
@@ -47,16 +47,16 @@
|
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
48
|
"@opengeni/agent-proto": "^0.2.1",
|
|
49
49
|
"@opengeni/codex": "^0.2.1",
|
|
50
|
-
"@opengeni/config": "^0.
|
|
50
|
+
"@opengeni/config": "^0.4.0",
|
|
51
51
|
"@opengeni/contracts": "^0.9.0",
|
|
52
|
-
"@opengeni/core": "^0.4.
|
|
53
|
-
"@opengeni/db": "^0.6.
|
|
54
|
-
"@opengeni/documents": "^0.2.
|
|
55
|
-
"@opengeni/events": "^0.2.
|
|
56
|
-
"@opengeni/github": "^0.2.
|
|
57
|
-
"@opengeni/observability": "^0.
|
|
58
|
-
"@opengeni/runtime": "^0.
|
|
59
|
-
"@opengeni/storage": "^0.2.
|
|
52
|
+
"@opengeni/core": "^0.4.6",
|
|
53
|
+
"@opengeni/db": "^0.6.1",
|
|
54
|
+
"@opengeni/documents": "^0.2.8",
|
|
55
|
+
"@opengeni/events": "^0.2.8",
|
|
56
|
+
"@opengeni/github": "^0.2.8",
|
|
57
|
+
"@opengeni/observability": "^0.3.0",
|
|
58
|
+
"@opengeni/runtime": "^0.6.1",
|
|
59
|
+
"@opengeni/storage": "^0.2.8",
|
|
60
60
|
"@temporalio/client": "^1.17.0",
|
|
61
61
|
"better-auth": "^1.6.14",
|
|
62
62
|
"hono": "^4.12.18",
|
package/src/app.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { buildOpenGeniMcpServer } from "./mcp/server";
|
|
|
21
21
|
import { isToolspaceGrant, prepareToolspaceMcpSurface } from "./mcp/toolspace";
|
|
22
22
|
import { requireAccessKey } from "./http/auth";
|
|
23
23
|
import { registerCapabilityRoutes } from "./routes/capabilities";
|
|
24
|
+
import { registerCatalogAssetRoutes } from "./routes/catalog-assets";
|
|
24
25
|
import { registerCodexRoutes } from "./routes/codex";
|
|
25
26
|
import { registerConnectionRoutes } from "./routes/connections";
|
|
26
27
|
import { registerDocumentRoutes } from "./routes/documents";
|
|
@@ -246,6 +247,7 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
246
247
|
registerSocialRoutes(app, routeDeps);
|
|
247
248
|
registerConnectionRoutes(app, routeDeps);
|
|
248
249
|
registerCapabilityRoutes(app, routeDeps);
|
|
250
|
+
registerCatalogAssetRoutes(app, routeDeps);
|
|
249
251
|
registerEnrollmentRoutes(app, routeDeps);
|
|
250
252
|
registerMachineRoutes(app, routeDeps);
|
|
251
253
|
registerEnvironmentRoutes(app, routeDeps);
|
|
@@ -426,6 +428,7 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
|
|
|
426
428
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/connections$/, label: "/v1/workspaces/:workspaceId/connections" },
|
|
427
429
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/connections\/oauth\/start$/, label: "/v1/workspaces/:workspaceId/connections/oauth/start" },
|
|
428
430
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/, label: "/v1/workspaces/:workspaceId/connections/:connectionId" },
|
|
431
|
+
{ pattern: /^\/v1\/catalog-assets\/.+$/, label: "/v1/catalog-assets/*" },
|
|
429
432
|
{ pattern: /^\/v1\/integrations\/oauth\/callback$/, label: "/v1/integrations/oauth/callback" },
|
|
430
433
|
{ pattern: /^\/v1\/integrations\/oauth\/client-metadata\.json$/, label: "/v1/integrations/oauth/client-metadata.json" },
|
|
431
434
|
{ pattern: /^\/v1\/enrollments\/device\/start$/, label: "/v1/enrollments/device/start" },
|
package/src/http/auth.ts
CHANGED
|
@@ -52,6 +52,12 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
52
52
|
) {
|
|
53
53
|
return true;
|
|
54
54
|
}
|
|
55
|
+
// Catalog logos are rendered via bare <img> tags, which carry no credentials;
|
|
56
|
+
// the images are public vendor logos, digest-keyed by content, and the route
|
|
57
|
+
// itself enforces the catalog-assets/ prefix lock and extension whitelist.
|
|
58
|
+
if (path.startsWith("/v1/catalog-assets/")) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
55
61
|
// Browser entry for MCP-issued GitHub install links: opened in a browser
|
|
56
62
|
// that holds no API credentials, like the callbacks above. The route itself
|
|
57
63
|
// verifies the signed workspace-bound state before doing anything.
|
|
@@ -4,6 +4,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
|
4
4
|
import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
|
|
5
5
|
import { OAuthStartResponse, type OAuthStartRequest } from "@opengeni/contracts";
|
|
6
6
|
import { requireEnvironmentEncryption } from "@opengeni/core";
|
|
7
|
+
import type { Observability } from "@opengeni/observability";
|
|
7
8
|
import {
|
|
8
9
|
consumeIntegrationOAuthStateNonce,
|
|
9
10
|
createConnection,
|
|
@@ -22,12 +23,14 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
22
23
|
import { lookup } from "node:dns/promises";
|
|
23
24
|
import { isIP } from "node:net";
|
|
24
25
|
import { HTTPException } from "hono/http-exception";
|
|
26
|
+
import { canonicalProviderDomain } from "./provider-domain";
|
|
25
27
|
|
|
26
28
|
export const oauthStateTtlMs = 10 * 60 * 1000;
|
|
27
29
|
|
|
28
30
|
type OAuthClientDeps = {
|
|
29
31
|
db: Database;
|
|
30
32
|
settings: Settings;
|
|
33
|
+
observability?: Observability | undefined;
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
export type OAuthStartContext = {
|
|
@@ -80,6 +83,7 @@ type OAuthStatePayload = {
|
|
|
80
83
|
workspaceId: string;
|
|
81
84
|
subjectId: string;
|
|
82
85
|
providerDomain: string;
|
|
86
|
+
mcpUrl: string;
|
|
83
87
|
resource: string;
|
|
84
88
|
requestedScopes: string[];
|
|
85
89
|
authorizeScopes: string[];
|
|
@@ -106,13 +110,26 @@ type TokenResponse = {
|
|
|
106
110
|
raw: Record<string, unknown>;
|
|
107
111
|
};
|
|
108
112
|
|
|
113
|
+
type OAuthCallbackStage = "state_verify" | "token_exchange" | "tools_list" | "persist";
|
|
114
|
+
|
|
115
|
+
class OAuthCallbackStageError extends Error {
|
|
116
|
+
constructor(
|
|
117
|
+
readonly stage: OAuthCallbackStage,
|
|
118
|
+
readonly reason: string,
|
|
119
|
+
readonly cause: unknown,
|
|
120
|
+
) {
|
|
121
|
+
super(errorMessage(cause));
|
|
122
|
+
this.name = "OAuthCallbackStageError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
export async function startMcpOAuth(
|
|
110
127
|
deps: OAuthClientDeps,
|
|
111
128
|
context: OAuthStartContext,
|
|
112
129
|
): Promise<OAuthStartResponse> {
|
|
113
130
|
const { db, settings } = deps;
|
|
114
|
-
const
|
|
115
|
-
const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(
|
|
131
|
+
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
132
|
+
const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
|
|
116
133
|
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
117
134
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
118
135
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
@@ -124,7 +141,8 @@ export async function startMcpOAuth(
|
|
|
124
141
|
throw new HTTPException(404, { message: "connection not found" });
|
|
125
142
|
}
|
|
126
143
|
|
|
127
|
-
const discovery = await discoverMcpOAuth(
|
|
144
|
+
const discovery = await discoverMcpOAuth(mcpUrl, settings);
|
|
145
|
+
const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
|
|
128
146
|
const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
|
|
129
147
|
const verifier = randomPkceVerifier();
|
|
130
148
|
const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
|
|
@@ -134,6 +152,7 @@ export async function startMcpOAuth(
|
|
|
134
152
|
workspaceId: context.workspaceId,
|
|
135
153
|
subjectId: context.subjectId,
|
|
136
154
|
providerDomain,
|
|
155
|
+
mcpUrl,
|
|
137
156
|
resource,
|
|
138
157
|
requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
|
|
139
158
|
authorizeScopes,
|
|
@@ -167,24 +186,33 @@ export async function completeMcpOAuthCallback(
|
|
|
167
186
|
deps: OAuthClientDeps,
|
|
168
187
|
input: { code?: string | undefined; state?: string | undefined; requestUrl: string },
|
|
169
188
|
): Promise<OAuthCallbackResult> {
|
|
170
|
-
const { db, settings } = deps;
|
|
189
|
+
const { db, settings, observability } = deps;
|
|
190
|
+
let state: OAuthStatePayload | null = null;
|
|
171
191
|
if (!input.state) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
192
|
+
const error = new OAuthCallbackStageError("state_verify", "state_invalid", new Error("missing OAuth state"));
|
|
193
|
+
logOAuthCallbackFailure(observability, error, state);
|
|
194
|
+
return { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
state = readOAuthState(input.state, settings);
|
|
198
|
+
if (!input.code) {
|
|
199
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
|
|
200
|
+
}
|
|
201
|
+
const consumed = await consumeIntegrationOAuthStateNonce(db, {
|
|
202
|
+
accountId: state.accountId,
|
|
203
|
+
workspaceId: state.workspaceId,
|
|
204
|
+
subjectId: state.subjectId,
|
|
205
|
+
nonce: state.nonce,
|
|
206
|
+
expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
|
|
207
|
+
now: new Date(),
|
|
208
|
+
});
|
|
209
|
+
if (!consumed) {
|
|
210
|
+
throw new HTTPException(400, { message: "OAuth state has already been used" });
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
|
|
214
|
+
logOAuthCallbackFailure(observability, staged, state);
|
|
215
|
+
return { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", { reason: staged.reason }) };
|
|
188
216
|
}
|
|
189
217
|
|
|
190
218
|
try {
|
|
@@ -193,29 +221,31 @@ export async function completeMcpOAuthCallback(
|
|
|
193
221
|
const key = requireEnvironmentEncryption(settings);
|
|
194
222
|
const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
|
|
195
223
|
const client = await clientForState(db, settings, state);
|
|
196
|
-
const token = await exchangeAuthorizationCode(settings, {
|
|
197
|
-
code: input.code
|
|
224
|
+
const token = await stage("token_exchange", "token_exchange_failed", () => exchangeAuthorizationCode(settings, {
|
|
225
|
+
code: input.code!,
|
|
198
226
|
verifier,
|
|
199
227
|
redirectUri,
|
|
200
228
|
resource: state.resource,
|
|
201
229
|
tokenEndpoint: state.tokenEndpoint,
|
|
202
230
|
client,
|
|
203
|
-
});
|
|
204
|
-
const
|
|
231
|
+
}));
|
|
232
|
+
const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
|
|
205
233
|
const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
|
|
206
234
|
const credential = credentialBundle(token, state, client);
|
|
207
235
|
const metadata = {
|
|
208
236
|
resource: state.resource,
|
|
237
|
+
mcpUrl: state.mcpUrl,
|
|
209
238
|
authorizationServer: state.authorizationServer,
|
|
210
239
|
authorizationServerIssuer: state.issuer,
|
|
211
240
|
tokenEndpoint: state.tokenEndpoint,
|
|
212
241
|
clientId: client.clientId,
|
|
213
242
|
clientRegistrationMethod: state.clientRegistrationMethod,
|
|
214
|
-
|
|
243
|
+
mcpToolsVerification: verification.metadata,
|
|
244
|
+
...(verification.tools ? { mcpTools: verification.tools } : {}),
|
|
215
245
|
};
|
|
216
246
|
const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
217
|
-
const connection = state.connectionId
|
|
218
|
-
?
|
|
247
|
+
const connection = await stage("persist", "persist_failed", () => state.connectionId
|
|
248
|
+
? updateConnection(db, {
|
|
219
249
|
workspaceId: state.workspaceId,
|
|
220
250
|
connectionId: state.connectionId,
|
|
221
251
|
visibleToSubjectId: state.subjectId,
|
|
@@ -229,7 +259,7 @@ export async function completeMcpOAuthCallback(
|
|
|
229
259
|
metadata,
|
|
230
260
|
updatedBySubjectId: state.subjectId,
|
|
231
261
|
})
|
|
232
|
-
:
|
|
262
|
+
: createConnection(db, {
|
|
233
263
|
accountId: state.accountId,
|
|
234
264
|
workspaceId: state.workspaceId,
|
|
235
265
|
subjectId: null,
|
|
@@ -240,16 +270,27 @@ export async function completeMcpOAuthCallback(
|
|
|
240
270
|
expiresAt: token.expiresAt,
|
|
241
271
|
metadata,
|
|
242
272
|
createdBySubjectId: state.subjectId,
|
|
243
|
-
});
|
|
273
|
+
}));
|
|
244
274
|
if (!connection) {
|
|
245
275
|
throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again" });
|
|
246
276
|
}
|
|
247
|
-
|
|
277
|
+
// Carry the canonical providerDomain (not just the id) so the SPA can build
|
|
278
|
+
// the enable connectionRef straight from the redirect, without a listConnections
|
|
279
|
+
// round-trip that could fail (transient, or a grant lacking connections:read)
|
|
280
|
+
// and leave the connection created but the capability un-enabled.
|
|
281
|
+
return {
|
|
282
|
+
redirectTo: callbackReturnPath(state.returnPath, "success", {
|
|
283
|
+
connectionId: connection.id,
|
|
284
|
+
providerDomain: connection.providerDomain,
|
|
285
|
+
...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
|
|
286
|
+
}),
|
|
287
|
+
};
|
|
248
288
|
} catch (error) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
289
|
+
const staged = error instanceof OAuthCallbackStageError
|
|
290
|
+
? error
|
|
291
|
+
: new OAuthCallbackStageError("persist", "persist_failed", error);
|
|
292
|
+
logOAuthCallbackFailure(observability, staged, state);
|
|
293
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
|
|
253
294
|
}
|
|
254
295
|
}
|
|
255
296
|
|
|
@@ -550,12 +591,14 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
550
591
|
if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
|
|
551
592
|
throw new HTTPException(400, { message: "invalid or expired OAuth state" });
|
|
552
593
|
}
|
|
594
|
+
const resource = requiredString(payload.resource, "state.resource");
|
|
553
595
|
const parsed = {
|
|
554
596
|
accountId: requiredString(payload.accountId, "state.accountId"),
|
|
555
597
|
workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
|
|
556
598
|
subjectId: requiredString(payload.subjectId, "state.subjectId"),
|
|
557
599
|
providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
|
|
558
|
-
|
|
600
|
+
mcpUrl: stringValue(payload.mcpUrl) ?? resource,
|
|
601
|
+
resource,
|
|
559
602
|
requestedScopes: stringArray(payload.requestedScopes),
|
|
560
603
|
authorizeScopes: stringArray(payload.authorizeScopes),
|
|
561
604
|
encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
|
|
@@ -643,7 +686,8 @@ async function exchangeAuthorizationCode(
|
|
|
643
686
|
}
|
|
644
687
|
const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
|
|
645
688
|
if (!response.ok) {
|
|
646
|
-
|
|
689
|
+
const oauthError = await oauthErrorFromResponse(response);
|
|
690
|
+
throw new OAuthCallbackStageError("token_exchange", oauthError ?? "token_exchange_failed", new Error(`OAuth token endpoint returned HTTP ${response.status}`));
|
|
647
691
|
}
|
|
648
692
|
const payload = await response.json() as Record<string, unknown>;
|
|
649
693
|
const accessToken = stringValue(payload.access_token);
|
|
@@ -660,6 +704,91 @@ async function exchangeAuthorizationCode(
|
|
|
660
704
|
};
|
|
661
705
|
}
|
|
662
706
|
|
|
707
|
+
async function stage<T>(
|
|
708
|
+
stage: OAuthCallbackStage,
|
|
709
|
+
fallbackReason: string,
|
|
710
|
+
fn: () => Promise<T>,
|
|
711
|
+
): Promise<T> {
|
|
712
|
+
try {
|
|
713
|
+
return await fn();
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (error instanceof OAuthCallbackStageError) {
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
throw new OAuthCallbackStageError(stage, fallbackReason, error);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function logOAuthCallbackFailure(
|
|
723
|
+
observability: Observability | undefined,
|
|
724
|
+
error: OAuthCallbackStageError,
|
|
725
|
+
state: OAuthStatePayload | null,
|
|
726
|
+
): void {
|
|
727
|
+
observability?.error("MCP OAuth callback failed", {
|
|
728
|
+
"opengeni.oauth.stage": error.stage,
|
|
729
|
+
"opengeni.oauth.reason": error.reason,
|
|
730
|
+
"opengeni.oauth.provider_domain": state?.providerDomain,
|
|
731
|
+
"opengeni.oauth.resource_host": state ? safeHost(state.resource) : undefined,
|
|
732
|
+
"opengeni.oauth.authorization_server": state?.authorizationServer,
|
|
733
|
+
"opengeni.oauth.issuer": state?.issuer,
|
|
734
|
+
"opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
|
|
735
|
+
error: sanitizedError(error.cause),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function logOAuthVerificationWarning(
|
|
740
|
+
observability: Observability | undefined,
|
|
741
|
+
error: OAuthCallbackStageError,
|
|
742
|
+
state: OAuthStatePayload,
|
|
743
|
+
): void {
|
|
744
|
+
observability?.warn("MCP OAuth tools/list verification failed after token exchange", {
|
|
745
|
+
"opengeni.oauth.stage": error.stage,
|
|
746
|
+
"opengeni.oauth.reason": error.reason,
|
|
747
|
+
"opengeni.oauth.provider_domain": state.providerDomain,
|
|
748
|
+
"opengeni.oauth.resource_host": safeHost(state.resource),
|
|
749
|
+
"opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
|
|
750
|
+
"opengeni.oauth.authorization_server": state.authorizationServer,
|
|
751
|
+
"opengeni.oauth.issuer": state.issuer,
|
|
752
|
+
"opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
|
|
753
|
+
error: sanitizedError(error.cause),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function sanitizedError(error: unknown): string {
|
|
758
|
+
if (error instanceof HTTPException) {
|
|
759
|
+
return `HTTPException ${error.status}: ${error.message}`;
|
|
760
|
+
}
|
|
761
|
+
if (error instanceof Error) {
|
|
762
|
+
return `${error.name}: ${error.message}`;
|
|
763
|
+
}
|
|
764
|
+
return String(error);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function errorMessage(error: unknown): string {
|
|
768
|
+
return error instanceof Error ? error.message : String(error);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function safeHost(rawUrl: string): string | undefined {
|
|
772
|
+
try {
|
|
773
|
+
return new URL(rawUrl).host;
|
|
774
|
+
} catch {
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function oauthErrorFromResponse(response: Response): Promise<string | null> {
|
|
780
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
781
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const payload = await response.clone().json().catch(() => null) as Record<string, unknown> | null;
|
|
785
|
+
const error = stringValue(payload?.error);
|
|
786
|
+
if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
return error;
|
|
790
|
+
}
|
|
791
|
+
|
|
663
792
|
async function verifyMcpToolsList(settings: Settings, resource: string, token: TokenResponse): Promise<Array<{ name: string; description?: string }>> {
|
|
664
793
|
await assertOAuthFetchAllowed(resource, settings);
|
|
665
794
|
const client = new Client({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
|
|
@@ -681,6 +810,42 @@ async function verifyMcpToolsList(settings: Settings, resource: string, token: T
|
|
|
681
810
|
}
|
|
682
811
|
}
|
|
683
812
|
|
|
813
|
+
async function verifyMcpToolsListNonFatal(
|
|
814
|
+
observability: Observability | undefined,
|
|
815
|
+
settings: Settings,
|
|
816
|
+
state: OAuthStatePayload,
|
|
817
|
+
token: TokenResponse,
|
|
818
|
+
): Promise<{
|
|
819
|
+
metadata:
|
|
820
|
+
| { status: "ok"; checkedAt: string; toolCount: number }
|
|
821
|
+
| { status: "failed"; checkedAt: string; reason: string };
|
|
822
|
+
tools?: Array<{ name: string; description?: string }>;
|
|
823
|
+
}> {
|
|
824
|
+
try {
|
|
825
|
+
const tools = await stage("tools_list", "tools_list_failed", () => verifyMcpToolsList(settings, state.mcpUrl, token));
|
|
826
|
+
return {
|
|
827
|
+
metadata: {
|
|
828
|
+
status: "ok",
|
|
829
|
+
checkedAt: new Date().toISOString(),
|
|
830
|
+
toolCount: tools.length,
|
|
831
|
+
},
|
|
832
|
+
tools,
|
|
833
|
+
};
|
|
834
|
+
} catch (error) {
|
|
835
|
+
const staged = error instanceof OAuthCallbackStageError
|
|
836
|
+
? error
|
|
837
|
+
: new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
|
|
838
|
+
logOAuthVerificationWarning(observability, staged, state);
|
|
839
|
+
return {
|
|
840
|
+
metadata: {
|
|
841
|
+
status: "failed",
|
|
842
|
+
checkedAt: new Date().toISOString(),
|
|
843
|
+
reason: staged.reason,
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
684
849
|
function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client: OAuthClientRegistration): Record<string, unknown> {
|
|
685
850
|
return {
|
|
686
851
|
access_token: token.accessToken,
|
|
@@ -688,6 +853,7 @@ function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client
|
|
|
688
853
|
token_type: token.tokenType,
|
|
689
854
|
...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
|
|
690
855
|
resource: state.resource,
|
|
856
|
+
mcp_url: state.mcpUrl,
|
|
691
857
|
...(token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {}),
|
|
692
858
|
token_endpoint: state.tokenEndpoint,
|
|
693
859
|
client_id: client.clientId,
|
|
@@ -718,8 +884,21 @@ function canonicalMcpResource(value: string | undefined): string {
|
|
|
718
884
|
return url.toString();
|
|
719
885
|
}
|
|
720
886
|
|
|
721
|
-
function
|
|
722
|
-
|
|
887
|
+
function canonicalOAuthResource(value: string): string {
|
|
888
|
+
const trimmed = value.trim();
|
|
889
|
+
if (!trimmed) {
|
|
890
|
+
throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource" });
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
const url = new URL(trimmed);
|
|
894
|
+
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
895
|
+
url.hash = "";
|
|
896
|
+
return url.toString();
|
|
897
|
+
}
|
|
898
|
+
return trimmed;
|
|
899
|
+
} catch {
|
|
900
|
+
throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource" });
|
|
901
|
+
}
|
|
723
902
|
}
|
|
724
903
|
|
|
725
904
|
function safeReturnPath(value: string): string {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical form of a connection's providerDomain: trimmed, lowercased, no
|
|
5
|
+
* leading "www.". Rejects a value that canonicalizes to empty (whitespace-only,
|
|
6
|
+
* or a bare "www.") — `min(1)` validation passes such input, but an empty stored
|
|
7
|
+
* domain silently breaks the enable-time connectionRef domain match.
|
|
8
|
+
*/
|
|
9
|
+
export function canonicalProviderDomain(value: string): string {
|
|
10
|
+
const canonical = value.trim().toLowerCase().replace(/^www\./, "");
|
|
11
|
+
if (!canonical) {
|
|
12
|
+
throw new HTTPException(400, { message: "providerDomain must not be empty" });
|
|
13
|
+
}
|
|
14
|
+
return canonical;
|
|
15
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
4
|
+
|
|
5
|
+
const CATALOG_ASSET_PREFIX = "catalog-assets/";
|
|
6
|
+
const MAX_KEY_LENGTH = 512;
|
|
7
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]+$/;
|
|
8
|
+
|
|
9
|
+
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
|
10
|
+
png: "image/png",
|
|
11
|
+
jpg: "image/jpeg",
|
|
12
|
+
jpeg: "image/jpeg",
|
|
13
|
+
svg: "image/svg+xml",
|
|
14
|
+
webp: "image/webp",
|
|
15
|
+
gif: "image/gif",
|
|
16
|
+
ico: "image/x-icon",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function registerCatalogAssetRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
20
|
+
const { settings, objectStorage } = deps;
|
|
21
|
+
|
|
22
|
+
app.get("/v1/catalog-assets/*", async (c) => {
|
|
23
|
+
if (!settings.integrationsEnabled) {
|
|
24
|
+
throw new HTTPException(404, { message: "integrations are not enabled for this deployment" });
|
|
25
|
+
}
|
|
26
|
+
if (!objectStorage) {
|
|
27
|
+
throw new HTTPException(404, { message: "asset not found" });
|
|
28
|
+
}
|
|
29
|
+
const key = catalogAssetKeyFromPath(new URL(c.req.url).pathname);
|
|
30
|
+
if (!key) {
|
|
31
|
+
throw new HTTPException(404, { message: "asset not found" });
|
|
32
|
+
}
|
|
33
|
+
const contentType = contentTypeForKey(key);
|
|
34
|
+
if (!contentType) {
|
|
35
|
+
throw new HTTPException(404, { message: "asset not found" });
|
|
36
|
+
}
|
|
37
|
+
const object = await objectStorage.getObjectBytes(key);
|
|
38
|
+
if (!object) {
|
|
39
|
+
throw new HTTPException(404, { message: "asset not found" });
|
|
40
|
+
}
|
|
41
|
+
const etag = etagForKey(key);
|
|
42
|
+
const headers = {
|
|
43
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
44
|
+
ETag: etag,
|
|
45
|
+
"X-Content-Type-Options": "nosniff",
|
|
46
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
47
|
+
};
|
|
48
|
+
if (ifNoneMatchSatisfied(c.req.header("if-none-match"), etag)) {
|
|
49
|
+
return c.body(null, 304, headers);
|
|
50
|
+
}
|
|
51
|
+
// Copy narrows the AWS/GCS/Azure SDKs' ArrayBufferLike-backed Uint8Array to the
|
|
52
|
+
// ArrayBuffer-backed one Hono's body() type expects.
|
|
53
|
+
return c.body(new Uint8Array(object.bytes), 200, { ...headers, "Content-Type": contentType });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Decoded, validated storage key from a `/v1/catalog-assets/...` request path, or null if malformed/unsafe. */
|
|
58
|
+
export function catalogAssetKeyFromPath(pathname: string): string | null {
|
|
59
|
+
const prefix = "/v1/";
|
|
60
|
+
if (!pathname.startsWith(prefix)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
let key: string;
|
|
64
|
+
try {
|
|
65
|
+
key = decodeURIComponent(pathname.slice(prefix.length));
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
key.length === 0 ||
|
|
71
|
+
key.length > MAX_KEY_LENGTH ||
|
|
72
|
+
!key.startsWith(CATALOG_ASSET_PREFIX) ||
|
|
73
|
+
key.includes("..") ||
|
|
74
|
+
key.includes("\\") ||
|
|
75
|
+
key.includes("//") ||
|
|
76
|
+
!PRINTABLE_ASCII.test(key)
|
|
77
|
+
) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return key;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function contentTypeForKey(key: string): string | null {
|
|
84
|
+
const match = /\.([a-zA-Z0-9]+)$/.exec(key);
|
|
85
|
+
const ext = match?.[1]?.toLowerCase();
|
|
86
|
+
return ext ? CONTENT_TYPE_BY_EXTENSION[ext] ?? null : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Digest-keyed filenames (`{domain}/{digest24}.{ext}`) make the basename itself a stable ETag. */
|
|
90
|
+
function etagForKey(key: string): string {
|
|
91
|
+
const filename = key.slice(key.lastIndexOf("/") + 1);
|
|
92
|
+
const dot = filename.lastIndexOf(".");
|
|
93
|
+
const digest = dot === -1 ? filename : filename.slice(0, dot);
|
|
94
|
+
return `"${digest}"`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ifNoneMatchSatisfied(header: string | undefined, etag: string): boolean {
|
|
98
|
+
if (!header) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (header.trim() === "*") {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
return header.split(",").map((value) => value.trim()).includes(etag);
|
|
105
|
+
}
|
|
@@ -24,9 +24,10 @@ import {
|
|
|
24
24
|
integrationBaseUrl,
|
|
25
25
|
startMcpOAuth,
|
|
26
26
|
} from "../integrations/oauth-client";
|
|
27
|
+
import { canonicalProviderDomain } from "../integrations/provider-domain";
|
|
27
28
|
|
|
28
29
|
export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
29
|
-
const { db, settings } = deps;
|
|
30
|
+
const { db, settings, observability } = deps;
|
|
30
31
|
|
|
31
32
|
function assertIntegrationsEnabled(): void {
|
|
32
33
|
if (!settings.integrationsEnabled) {
|
|
@@ -52,7 +53,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
52
53
|
accountId: grant.accountId,
|
|
53
54
|
workspaceId,
|
|
54
55
|
subjectId,
|
|
55
|
-
providerDomain: payload.providerDomain,
|
|
56
|
+
providerDomain: canonicalProviderDomain(payload.providerDomain),
|
|
56
57
|
kind: payload.kind,
|
|
57
58
|
credentialEncrypted: encryptCredentialBundle(key, payload.credential),
|
|
58
59
|
grantedScopes: payload.grantedScopes,
|
|
@@ -96,7 +97,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
96
97
|
connectionId: c.req.param("connectionId"),
|
|
97
98
|
visibleToSubjectId: grant.subjectId,
|
|
98
99
|
updatedBySubjectId: grant.subjectId,
|
|
99
|
-
...(payload.providerDomain !== undefined ? { providerDomain: payload.providerDomain } : {}),
|
|
100
|
+
...(payload.providerDomain !== undefined ? { providerDomain: canonicalProviderDomain(payload.providerDomain) } : {}),
|
|
100
101
|
...(subjectId !== undefined ? { subjectId } : {}),
|
|
101
102
|
...(payload.kind !== undefined ? { kind: payload.kind } : {}),
|
|
102
103
|
...(payload.status !== undefined ? { status: payload.status } : {}),
|
|
@@ -130,7 +131,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
130
131
|
throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
|
|
131
132
|
}
|
|
132
133
|
const payload = parsed.data;
|
|
133
|
-
const result = await startMcpOAuth({ db, settings }, {
|
|
134
|
+
const result = await startMcpOAuth({ db, settings, observability }, {
|
|
134
135
|
accountId: grant.accountId,
|
|
135
136
|
workspaceId,
|
|
136
137
|
subjectId: grant.subjectId,
|
|
@@ -142,7 +143,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
142
143
|
|
|
143
144
|
app.get("/v1/integrations/oauth/callback", async (c) => {
|
|
144
145
|
assertIntegrationsEnabled();
|
|
145
|
-
const result = await completeMcpOAuthCallback({ db, settings }, {
|
|
146
|
+
const result = await completeMcpOAuthCallback({ db, settings, observability }, {
|
|
146
147
|
code: c.req.query("code"),
|
|
147
148
|
state: c.req.query("state"),
|
|
148
149
|
requestUrl: c.req.url,
|