@opengeni/api-router 3.0.1 → 4.0.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/dist/admission-trace.d.ts +8 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-attempt-context.d.ts +2 -0
- package/dist/auth/managed-auth-session-adapter.d.ts +2 -0
- package/dist/auth/managed-auth.d.ts +1 -0
- package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
- package/dist/{chunk-AJ62T5QN.js → chunk-E7TZFJ65.js} +22300 -21113
- package/dist/{chunk-AJ62T5QN.js.map → chunk-E7TZFJ65.js.map} +1 -1
- package/dist/connection-ownership.d.ts +1 -2
- package/dist/fatal-process-boundary.d.ts +1 -1
- package/dist/index.js +24 -6
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +8 -3
- package/dist/integrations/fiken.d.ts +3 -1
- package/dist/integrations/github-lens-connect.d.ts +5 -65
- package/dist/integrations/google-drive.d.ts +2 -0
- package/dist/integrations/integration-commit-authority.d.ts +13 -0
- package/dist/integrations/oauth-client.d.ts +119 -11
- package/dist/integrations/oauth-profiles.d.ts +8 -13
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/capability-account-readiness.d.ts +7 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +4 -0
- package/dist/mcp-oauth.d.ts +22 -1
- package/dist/routes/api-integrations.d.ts +1 -0
- package/dist/routes/codex.d.ts +28 -0
- package/dist/routes/managed-sign-in-methods.d.ts +6 -0
- package/dist/routes/organization-integration-policy.d.ts +3 -0
- package/dist/routes/sessions.d.ts +24 -2
- package/dist/scheduled-task-deletion.d.ts +3 -4
- package/dist/workspace-tool-gateway.d.ts +3 -1
- package/package.json +15 -15
- package/src/admission-trace.ts +14 -0
- package/src/app.ts +141 -94
- package/src/auth/managed-auth-attempt-context.ts +9 -0
- package/src/auth/managed-auth-session-adapter.ts +11 -0
- package/src/auth/managed-auth.ts +39 -2
- package/src/auth/managed-sign-in-notifications.ts +81 -0
- package/src/connection-ownership.ts +2 -9
- package/src/editable-artifact-production.ts +6 -0
- package/src/fatal-process-boundary.ts +17 -2
- package/src/index.ts +3 -4
- package/src/integrations/atlassian.ts +184 -76
- package/src/integrations/fiken.ts +57 -35
- package/src/integrations/github-app-connect.ts +51 -15
- package/src/integrations/github-lens-connect.ts +7 -28
- package/src/integrations/google-drive.ts +296 -125
- package/src/integrations/integration-commit-authority.ts +69 -0
- package/src/integrations/oauth-client.ts +231 -132
- package/src/integrations/oauth-profiles.ts +10 -45
- package/src/integrations/personal-github.ts +55 -31
- package/src/integrations/provider-oauth.ts +57 -82
- package/src/integrations/social-oauth.ts +83 -56
- package/src/mcp/capability-account-readiness.ts +17 -0
- package/src/mcp/editable-artifacts.ts +7 -2
- package/src/mcp/first-party-tool-permissions.ts +1 -0
- package/src/mcp/knowledge-entries.ts +25 -8
- package/src/mcp/server.ts +135 -43
- package/src/mcp-oauth.ts +336 -34
- package/src/routes/api-integrations.ts +116 -4
- package/src/routes/automations.ts +5 -13
- package/src/routes/billing.ts +52 -0
- package/src/routes/canonical-human-identities.ts +61 -0
- package/src/routes/capabilities.ts +51 -1
- package/src/routes/codex.ts +4 -2
- package/src/routes/connect.ts +90 -31
- package/src/routes/connections.ts +487 -307
- package/src/routes/files.ts +21 -12
- package/src/routes/github.ts +48 -19
- package/src/routes/insights.ts +17 -7
- package/src/routes/integration-facets.ts +66 -25
- package/src/routes/knowledge.ts +48 -1
- package/src/routes/managed-sign-in-methods.ts +433 -0
- package/src/routes/organization-integration-policy.ts +113 -0
- package/src/routes/organization-memberships.ts +22 -2
- package/src/routes/plugins.ts +42 -4
- package/src/routes/pr-review-github.ts +67 -57
- package/src/routes/pr-review.ts +74 -71
- package/src/routes/rigs.ts +32 -13
- package/src/routes/scheduled-tasks.ts +6 -6
- package/src/routes/sessions.ts +233 -11
- package/src/routes/skill-content.ts +19 -3
- package/src/routes/social.ts +24 -13
- package/src/sandbox/channel-a.ts +7 -1
- package/src/sandbox/rematerialize.ts +2 -1
- package/src/scheduled-task-deletion.ts +13 -3
- package/src/workspace-tool-gateway.ts +31 -31
- package/dist/connection-authority-owner.d.ts +0 -59
- package/dist/routes/connection-authorities.d.ts +0 -3
- package/dist/routes/host-mcp-bindings.d.ts +0 -5
- package/dist/routes/host-mcp-resolvers.d.ts +0 -3
- package/dist/routes/packs.d.ts +0 -3
- package/src/connection-authority-owner.ts +0 -61
- package/src/routes/connection-authorities.ts +0 -139
- package/src/routes/host-mcp-bindings.ts +0 -122
- package/src/routes/host-mcp-resolvers.ts +0 -41
- package/src/routes/packs.ts +0 -784
package/src/mcp-oauth.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
McpOAuthClientRegistrationResponse,
|
|
11
11
|
McpOAuthProtectedResourceMetadata,
|
|
12
12
|
McpOAuthTokenResponse,
|
|
13
|
+
type AccessContext,
|
|
13
14
|
type AccessGrant,
|
|
14
15
|
type ToolGatewayCatalog,
|
|
15
16
|
type ToolGatewayIdentity,
|
|
@@ -19,15 +20,25 @@ import {
|
|
|
19
20
|
createMcpOAuthAuthorizationRequest,
|
|
20
21
|
deleteMcpOAuthAuthorizationRequest,
|
|
21
22
|
exchangeMcpOAuthAuthorizationCode,
|
|
23
|
+
getManagedAccount,
|
|
22
24
|
getMcpOAuthAuthorizationRequest,
|
|
23
25
|
getMcpOAuthClient,
|
|
26
|
+
getWorkspace,
|
|
27
|
+
listSharedWorkspacesForAccount,
|
|
28
|
+
listWorkspacesForSubject,
|
|
24
29
|
McpOAuthClientRegistrationRateLimitError,
|
|
30
|
+
rebindMcpOAuthAuthorizationRequest,
|
|
25
31
|
registerMcpOAuthClient,
|
|
26
32
|
resolveLiveMcpOAuthGrant,
|
|
27
33
|
resolveMcpOAuthAccessToken,
|
|
28
34
|
rotateMcpOAuthRefreshToken,
|
|
29
35
|
} from "@opengeni/db";
|
|
30
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
hasPermission,
|
|
38
|
+
requireAccessContext,
|
|
39
|
+
requireAccessGrantAuthorization,
|
|
40
|
+
type ApiRouteDeps,
|
|
41
|
+
} from "@opengeni/core";
|
|
31
42
|
import { Hono, type Context } from "hono";
|
|
32
43
|
import { HTTPException } from "hono/http-exception";
|
|
33
44
|
import { trustedRequestSourceAddress } from "./http/request-source";
|
|
@@ -148,10 +159,17 @@ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
148
159
|
const query = new URL(c.req.url).searchParams;
|
|
149
160
|
const client = await requireAuthorizationClient(deps, query);
|
|
150
161
|
const resource = requireAuthorizationResource(deps, query);
|
|
162
|
+
const context = await requireAccessContext(c, deps);
|
|
163
|
+
const workspaces = await listConsentWorkspaces(deps, context);
|
|
164
|
+
if (workspaces.length === 0) {
|
|
165
|
+
throw new HTTPException(403, { message: "no workspace is available for MCP OAuth" });
|
|
166
|
+
}
|
|
167
|
+
const selectedWorkspace =
|
|
168
|
+
workspaces.find((workspace) => workspace.id === resource.workspaceId) ?? workspaces[0]!;
|
|
151
169
|
const authorization = await requireAccessGrantAuthorization(
|
|
152
170
|
c,
|
|
153
171
|
deps,
|
|
154
|
-
|
|
172
|
+
selectedWorkspace.id,
|
|
155
173
|
"workspace:read",
|
|
156
174
|
);
|
|
157
175
|
const grant = requireWorkspaceToolGatewayAuthorization(authorization);
|
|
@@ -175,42 +193,101 @@ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
175
193
|
} finally {
|
|
176
194
|
await prepared.close();
|
|
177
195
|
}
|
|
196
|
+
const accounts = await consentAccountsForWorkspaces(deps, workspaces);
|
|
178
197
|
c.header(
|
|
179
198
|
"content-security-policy",
|
|
180
|
-
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
|
|
199
|
+
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
|
|
181
200
|
);
|
|
182
201
|
c.header("cache-control", "no-store");
|
|
183
|
-
return c.html(
|
|
202
|
+
return c.html(
|
|
203
|
+
renderMcpOAuthConsentPage({
|
|
204
|
+
clientName: client.clientName ?? "MCP client",
|
|
205
|
+
requestToken,
|
|
206
|
+
accounts,
|
|
207
|
+
workspaces: workspaces.map((workspace) => ({
|
|
208
|
+
id: workspace.id,
|
|
209
|
+
accountId: workspace.accountId,
|
|
210
|
+
name: workspace.name,
|
|
211
|
+
kind: workspace.kind,
|
|
212
|
+
})),
|
|
213
|
+
selectedWorkspaceId: grant.workspaceId,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
184
216
|
});
|
|
185
217
|
|
|
186
218
|
app.post("/oauth/authorize", async (c) => {
|
|
187
219
|
requireMcpOAuthEnabled(deps);
|
|
188
220
|
const form = new URLSearchParams(await c.req.text());
|
|
189
221
|
const requestToken = form.get("request") ?? "";
|
|
190
|
-
if (!requestToken.startsWith(REQUEST_PREFIX))
|
|
222
|
+
if (!requestToken.startsWith(REQUEST_PREFIX)) {
|
|
223
|
+
return oauthAuthorizeBrowserError(
|
|
224
|
+
c,
|
|
225
|
+
"This authorization request is missing or already used.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
191
228
|
const request = await getMcpOAuthAuthorizationRequest(deps.db, tokenHash(requestToken));
|
|
192
|
-
if (!request)
|
|
229
|
+
if (!request) {
|
|
230
|
+
return oauthAuthorizeBrowserError(
|
|
231
|
+
c,
|
|
232
|
+
"This authorization request expired or was already approved.",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (form.get("decision") !== "approve") {
|
|
236
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
237
|
+
c,
|
|
238
|
+
deps,
|
|
239
|
+
request.workspaceId,
|
|
240
|
+
"workspace:read",
|
|
241
|
+
);
|
|
242
|
+
if (requireWorkspaceToolGatewayAuthorization(authorization).subjectId !== request.subjectId) {
|
|
243
|
+
throw new HTTPException(403, { message: "OAuth consent authority changed" });
|
|
244
|
+
}
|
|
245
|
+
await deleteMcpOAuthAuthorizationRequest(deps.db, request.requestHash);
|
|
246
|
+
return completeAuthorizationRedirect(
|
|
247
|
+
c,
|
|
248
|
+
authorizationRedirect(request.redirectUri, {
|
|
249
|
+
error: "access_denied",
|
|
250
|
+
iss: mcpOAuthIssuer(deps),
|
|
251
|
+
...(request.state ? { state: request.state } : {}),
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const selectedWorkspaceId = form.get("workspace_id") || request.workspaceId;
|
|
256
|
+
if (!isWorkspaceId(selectedWorkspaceId)) {
|
|
257
|
+
return oauthAuthorizeBrowserError(c, "Choose a workspace before authorizing this client.");
|
|
258
|
+
}
|
|
193
259
|
const authorization = await requireAccessGrantAuthorization(
|
|
194
260
|
c,
|
|
195
261
|
deps,
|
|
196
|
-
|
|
262
|
+
selectedWorkspaceId,
|
|
197
263
|
"workspace:read",
|
|
198
264
|
);
|
|
199
265
|
const grant = requireWorkspaceToolGatewayAuthorization(authorization);
|
|
200
|
-
if (grant.subjectId !== request.subjectId
|
|
266
|
+
if (grant.subjectId !== request.subjectId) {
|
|
201
267
|
throw new HTTPException(403, {
|
|
202
268
|
message: "OAuth consent authority changed",
|
|
203
269
|
});
|
|
204
270
|
}
|
|
205
|
-
if (
|
|
206
|
-
await
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
271
|
+
if (grant.workspaceId !== request.workspaceId || grant.accountId !== request.accountId) {
|
|
272
|
+
const prepared = await prepareWorkspaceToolGateway(deps, authorization);
|
|
273
|
+
try {
|
|
274
|
+
const rebound = await rebindMcpOAuthAuthorizationRequest(deps.db, {
|
|
275
|
+
requestHash: request.requestHash,
|
|
276
|
+
subjectId: grant.subjectId,
|
|
277
|
+
accountId: grant.accountId,
|
|
278
|
+
workspaceId: grant.workspaceId,
|
|
279
|
+
permissions: grant.permissions,
|
|
280
|
+
toolIdentities: mcpOAuthConsentToolIdentities(prepared.toolGatewayCatalog),
|
|
281
|
+
});
|
|
282
|
+
if (!rebound) {
|
|
283
|
+
return oauthAuthorizeBrowserError(
|
|
284
|
+
c,
|
|
285
|
+
"This authorization request expired or was already approved.",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
} finally {
|
|
289
|
+
await prepared.close();
|
|
290
|
+
}
|
|
214
291
|
}
|
|
215
292
|
const code = opaque(CODE_PREFIX);
|
|
216
293
|
const consumed = await consumeMcpOAuthAuthorizationRequest(deps.db, {
|
|
@@ -219,8 +296,14 @@ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
219
296
|
codeHash: tokenHash(code),
|
|
220
297
|
codeExpiresAt: expiresIn(MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS),
|
|
221
298
|
});
|
|
222
|
-
if (!consumed)
|
|
223
|
-
|
|
299
|
+
if (!consumed) {
|
|
300
|
+
return oauthAuthorizeBrowserError(
|
|
301
|
+
c,
|
|
302
|
+
"This authorization request expired or was already approved.",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return completeAuthorizationRedirect(
|
|
306
|
+
c,
|
|
224
307
|
authorizationRedirect(consumed.redirectUri, {
|
|
225
308
|
code,
|
|
226
309
|
iss: mcpOAuthIssuer(deps),
|
|
@@ -305,9 +388,11 @@ export async function resolveMcpOAuthRouteAccess(
|
|
|
305
388
|
deps,
|
|
306
389
|
`${mcpOAuthIssuer(deps)}${new URL(request.url).pathname}`,
|
|
307
390
|
);
|
|
391
|
+
// The client URL is the resource indicator. Consent may bind a different
|
|
392
|
+
// workspace than the path UUID; the token grant is the authorized workspace.
|
|
308
393
|
if (
|
|
309
394
|
!access ||
|
|
310
|
-
|
|
395
|
+
requestResource.workspaceId !== workspaceId ||
|
|
311
396
|
access.resource !== requestResource.resource
|
|
312
397
|
) {
|
|
313
398
|
throw new HTTPException(401, { message: "invalid MCP OAuth access token" });
|
|
@@ -441,17 +526,26 @@ async function exchangeAuthorizationCode(
|
|
|
441
526
|
if (!code.startsWith(CODE_PREFIX) || !PKCE_VERIFIER.test(verifier)) return null;
|
|
442
527
|
const redirectUri = form.get("redirect_uri");
|
|
443
528
|
if (!redirectUri || !redirectUris.includes(redirectUri)) return null;
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
529
|
+
for (const candidate of mcpOAuthRedirectUriCandidates(redirectUris, redirectUri)) {
|
|
530
|
+
const access = await exchangeMcpOAuthAuthorizationCode(deps.db, {
|
|
531
|
+
codeHash: tokenHash(code),
|
|
532
|
+
clientId: input.clientId,
|
|
533
|
+
redirectUri: candidate,
|
|
534
|
+
resource: input.resource,
|
|
535
|
+
codeChallenge: pkceChallenge(verifier),
|
|
536
|
+
accessTokenHash: input.accessTokenHash,
|
|
537
|
+
refreshTokenHash: input.refreshTokenHash,
|
|
538
|
+
accessExpiresAt: input.accessExpiresAt,
|
|
539
|
+
refreshExpiresAt: input.refreshExpiresAt,
|
|
540
|
+
});
|
|
541
|
+
if (access) return access;
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function mcpOAuthRedirectUriCandidates(registered: string[], requested: string): string[] {
|
|
547
|
+
if (!registered.includes(requested)) return [];
|
|
548
|
+
return [requested, ...registered.filter((uri) => uri !== requested)];
|
|
455
549
|
}
|
|
456
550
|
|
|
457
551
|
async function rotateRefreshToken(
|
|
@@ -474,11 +568,37 @@ async function rotateRefreshToken(
|
|
|
474
568
|
});
|
|
475
569
|
}
|
|
476
570
|
|
|
571
|
+
const BLOCKED_REDIRECT_PROTOCOLS = new Set([
|
|
572
|
+
"javascript:",
|
|
573
|
+
"data:",
|
|
574
|
+
"file:",
|
|
575
|
+
"about:",
|
|
576
|
+
"blob:",
|
|
577
|
+
"vbscript:",
|
|
578
|
+
]);
|
|
579
|
+
|
|
580
|
+
function isNativeAppRedirect(url: URL): boolean {
|
|
581
|
+
return (
|
|
582
|
+
/^[a-z][a-z0-9+.-]*:$/u.test(url.protocol) &&
|
|
583
|
+
!BLOCKED_REDIRECT_PROTOCOLS.has(url.protocol) &&
|
|
584
|
+
url.protocol !== "http:" &&
|
|
585
|
+
url.protocol !== "https:" &&
|
|
586
|
+
Boolean(url.hostname) &&
|
|
587
|
+
url.pathname.startsWith("/") &&
|
|
588
|
+
url.pathname !== "/"
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
477
592
|
function validateRedirectUri(value: string): string {
|
|
478
593
|
const url = new URL(value);
|
|
479
594
|
const loopback =
|
|
480
595
|
url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
481
|
-
if (
|
|
596
|
+
if (
|
|
597
|
+
url.username ||
|
|
598
|
+
url.password ||
|
|
599
|
+
url.hash ||
|
|
600
|
+
(url.protocol !== "https:" && !loopback && !isNativeAppRedirect(url))
|
|
601
|
+
) {
|
|
482
602
|
throw new HTTPException(400, {
|
|
483
603
|
message: "invalid public OAuth redirect URI",
|
|
484
604
|
});
|
|
@@ -520,8 +640,190 @@ function authorizationRedirect(redirectUri: string, params: Record<string, strin
|
|
|
520
640
|
return url.toString();
|
|
521
641
|
}
|
|
522
642
|
|
|
523
|
-
function
|
|
524
|
-
|
|
643
|
+
export function completeAuthorizationRedirect(c: Context, redirectTo: string) {
|
|
644
|
+
// Consent posts under CSP form-action 'self'. A 302 to the client's
|
|
645
|
+
// registered redirect_uri (loopback HTTP or a custom scheme) is blocked,
|
|
646
|
+
// so the waiter never receives the code. Return 200 HTML that navigates
|
|
647
|
+
// to the exact requested URI from script and Refresh.
|
|
648
|
+
c.header("cache-control", "no-store");
|
|
649
|
+
c.header(
|
|
650
|
+
"content-security-policy",
|
|
651
|
+
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'none'",
|
|
652
|
+
);
|
|
653
|
+
c.header("refresh", `0;url=${redirectTo}`);
|
|
654
|
+
return c.html(renderMcpOAuthContinuePage(redirectTo));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function oauthAuthorizeBrowserError(c: Context, message: string) {
|
|
658
|
+
c.header("cache-control", "no-store");
|
|
659
|
+
c.header("pragma", "no-cache");
|
|
660
|
+
c.header(
|
|
661
|
+
"content-security-policy",
|
|
662
|
+
"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
|
|
663
|
+
);
|
|
664
|
+
return c.html(renderMcpOAuthExpiredPage(message), 400);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export type McpOAuthConsentAccount = {
|
|
668
|
+
id: string;
|
|
669
|
+
name: string;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
export type McpOAuthConsentWorkspace = {
|
|
673
|
+
id: string;
|
|
674
|
+
accountId: string;
|
|
675
|
+
name: string;
|
|
676
|
+
kind: "personal" | "shared";
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
export function renderMcpOAuthConsentPage(input: {
|
|
680
|
+
clientName: string;
|
|
681
|
+
requestToken: string;
|
|
682
|
+
accounts: McpOAuthConsentAccount[];
|
|
683
|
+
workspaces: McpOAuthConsentWorkspace[];
|
|
684
|
+
selectedWorkspaceId: string;
|
|
685
|
+
}): string {
|
|
686
|
+
const selectedWorkspace =
|
|
687
|
+
input.workspaces.find((workspace) => workspace.id === input.selectedWorkspaceId) ??
|
|
688
|
+
input.workspaces[0];
|
|
689
|
+
if (!selectedWorkspace) {
|
|
690
|
+
throw new Error("MCP OAuth consent requires at least one workspace");
|
|
691
|
+
}
|
|
692
|
+
const selectedAccountId = selectedWorkspace.accountId;
|
|
693
|
+
const accountOptions = input.accounts
|
|
694
|
+
.map(
|
|
695
|
+
(account) =>
|
|
696
|
+
`<option value="${escapeHtml(account.id)}"${account.id === selectedAccountId ? " selected" : ""}>${escapeHtml(account.name)}</option>`,
|
|
697
|
+
)
|
|
698
|
+
.join("");
|
|
699
|
+
const workspaceOptions = input.workspaces
|
|
700
|
+
.filter((workspace) => workspace.accountId === selectedAccountId)
|
|
701
|
+
.map(
|
|
702
|
+
(workspace) =>
|
|
703
|
+
`<option value="${escapeHtml(workspace.id)}"${workspace.id === selectedWorkspace.id ? " selected" : ""}>${escapeHtml(consentWorkspaceLabel(workspace))}</option>`,
|
|
704
|
+
)
|
|
705
|
+
.join("");
|
|
706
|
+
const workspacePayload = input.workspaces.map((workspace) => ({
|
|
707
|
+
id: workspace.id,
|
|
708
|
+
accountId: workspace.accountId,
|
|
709
|
+
label: consentWorkspaceLabel(workspace),
|
|
710
|
+
}));
|
|
711
|
+
const body = `<p class="lede"><strong>${escapeHtml(input.clientName)}</strong> wants MCP access to the organization and workspace you choose.</p>
|
|
712
|
+
<form method="post" action="/oauth/authorize" onsubmit="var submitter=event.submitter;if(submitter&&submitter.name){var input=document.createElement('input');input.type='hidden';input.name=submitter.name;input.value=submitter.value;this.appendChild(input)}this.querySelectorAll('button').forEach(function(button){button.disabled=true})">
|
|
713
|
+
<input type="hidden" name="request" value="${escapeHtml(input.requestToken)}">
|
|
714
|
+
<label class="field"><span>Organization</span><select id="organization" name="organization" autocomplete="off">${accountOptions}</select></label>
|
|
715
|
+
<label class="field"><span>Workspace</span><select id="workspace_id" name="workspace_id" autocomplete="off">${workspaceOptions}</select></label>
|
|
716
|
+
<p class="note">The grant is limited to tools available in the workspace you authorize. OpenGeni rechecks live authority on every request.</p>
|
|
717
|
+
<div class="actions"><button class="approve" name="decision" value="approve">Authorize</button><button class="deny" name="decision" value="deny">Deny</button></div>
|
|
718
|
+
</form>
|
|
719
|
+
<script type="application/json" id="mcp-oauth-workspaces">${jsonForScript(workspacePayload)}</script>
|
|
720
|
+
<script>
|
|
721
|
+
(function () {
|
|
722
|
+
var data = JSON.parse(document.getElementById("mcp-oauth-workspaces").textContent);
|
|
723
|
+
var account = document.getElementById("organization");
|
|
724
|
+
var workspace = document.getElementById("workspace_id");
|
|
725
|
+
function sync() {
|
|
726
|
+
var selected = workspace.value;
|
|
727
|
+
var items = data.filter(function (item) { return item.accountId === account.value; });
|
|
728
|
+
workspace.replaceChildren();
|
|
729
|
+
items.forEach(function (item) {
|
|
730
|
+
var option = document.createElement("option");
|
|
731
|
+
option.value = item.id;
|
|
732
|
+
option.textContent = item.label;
|
|
733
|
+
workspace.appendChild(option);
|
|
734
|
+
});
|
|
735
|
+
if (items.some(function (item) { return item.id === selected; })) workspace.value = selected;
|
|
736
|
+
}
|
|
737
|
+
account.addEventListener("change", sync);
|
|
738
|
+
})();
|
|
739
|
+
</script>`;
|
|
740
|
+
return oauthDocument({
|
|
741
|
+
title: "Authorize MCP access",
|
|
742
|
+
heading: "Authorize this client",
|
|
743
|
+
body,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export function renderMcpOAuthContinuePage(redirectTo: string): string {
|
|
748
|
+
return oauthDocument({
|
|
749
|
+
title: "Returning to the app",
|
|
750
|
+
heading: "Returning to the app",
|
|
751
|
+
head: `<meta http-equiv="refresh" content="0;url=${escapeHtml(redirectTo)}">`,
|
|
752
|
+
body: `<p class="lede">Authorization succeeded. You can close this window.</p><script>(function (url) { try { location.replace(url); } catch (error) {} try { location.href = url; } catch (error) {} var frame = document.createElement("iframe"); frame.src = url; frame.style.display = "none"; document.body.appendChild(frame); })(${JSON.stringify(redirectTo)});</script>`,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function renderMcpOAuthExpiredPage(message: string): string {
|
|
757
|
+
return oauthDocument({
|
|
758
|
+
title: "Authorization expired",
|
|
759
|
+
heading: "Authorization expired",
|
|
760
|
+
body: `<p class="lede">${escapeHtml(message)}</p><p class="note">Close this window and start authorization again from the client.</p>`,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function listConsentWorkspaces(deps: ApiRouteDeps, context: AccessContext) {
|
|
765
|
+
const readableWorkspaceIds = [
|
|
766
|
+
...new Set(
|
|
767
|
+
context.workspaceGrants
|
|
768
|
+
.filter((grant) => hasPermission(grant.permissions, "workspace:read"))
|
|
769
|
+
.map((grant) => grant.workspaceId),
|
|
770
|
+
),
|
|
771
|
+
];
|
|
772
|
+
const readableAccountIds = context.accountGrants
|
|
773
|
+
.filter((grant) => hasPermission(grant.permissions, "workspace:read"))
|
|
774
|
+
.map((grant) => grant.accountId);
|
|
775
|
+
const [fromGrants, memberships, shared] = await Promise.all([
|
|
776
|
+
Promise.all(readableWorkspaceIds.map((workspaceId) => getWorkspace(deps.db, workspaceId))),
|
|
777
|
+
listWorkspacesForSubject(deps.db, context.subjectId),
|
|
778
|
+
Promise.all(
|
|
779
|
+
readableAccountIds.map((accountId) => listSharedWorkspacesForAccount(deps.db, accountId)),
|
|
780
|
+
),
|
|
781
|
+
]);
|
|
782
|
+
const byId = new Map<string, NonNullable<(typeof fromGrants)[number]>>();
|
|
783
|
+
for (const workspace of [...fromGrants, ...memberships, ...shared.flat()]) {
|
|
784
|
+
if (workspace) byId.set(workspace.id, workspace);
|
|
785
|
+
}
|
|
786
|
+
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function consentAccountsForWorkspaces(
|
|
790
|
+
deps: ApiRouteDeps,
|
|
791
|
+
workspaces: Array<{ accountId: string }>,
|
|
792
|
+
): Promise<McpOAuthConsentAccount[]> {
|
|
793
|
+
const accountIds = [...new Set(workspaces.map((workspace) => workspace.accountId))];
|
|
794
|
+
return (
|
|
795
|
+
await Promise.all(
|
|
796
|
+
accountIds.map(async (accountId) => {
|
|
797
|
+
const account = await getManagedAccount(deps.db, accountId);
|
|
798
|
+
return { id: accountId, name: account?.name.trim() || "Organization" };
|
|
799
|
+
}),
|
|
800
|
+
)
|
|
801
|
+
).sort((left, right) => left.name.localeCompare(right.name));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function consentWorkspaceLabel(workspace: McpOAuthConsentWorkspace): string {
|
|
805
|
+
const name = workspace.name.trim() || "Workspace";
|
|
806
|
+
return workspace.kind === "personal" ? `${name} (Personal)` : name;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function isWorkspaceId(value: string): boolean {
|
|
810
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function oauthDocument(input: {
|
|
814
|
+
title: string;
|
|
815
|
+
heading: string;
|
|
816
|
+
body: string;
|
|
817
|
+
head?: string;
|
|
818
|
+
}): string {
|
|
819
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title>${input.head ?? ""}<style>${OAUTH_PAGE_CSS}</style></head><body><main><p class="mark">OpenGeni</p><h1>${escapeHtml(input.heading)}</h1>${input.body}</main></body></html>`;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const OAUTH_PAGE_CSS =
|
|
823
|
+
'html,body{margin:0}body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;background:#f4f4f5;color:#18181b;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased}main{width:min(100%,28rem);background:#fff;border:1px solid #e4e4e7;border-radius:20px;padding:24px}h1{margin:8px 0 0;font-size:22px;line-height:1.3;font-weight:650}p{margin:12px 0 0}strong{font-weight:600}.mark{margin:0;font-size:12px;font-weight:600;color:#71717a}.lede,.note{color:#52525b}.field{display:flex;flex-direction:column;gap:6px;margin-top:16px;font-size:13px;color:#71717a}select{width:100%;border:1px solid #e4e4e7;border-radius:10px;background:#f4f4f5;color:#18181b;padding:10px 12px;font:inherit}.actions{display:flex;gap:12px;margin-top:20px}button{flex:1;min-height:40px;border:0;border-radius:10px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}.approve{background:#18181b;color:#fff}.deny{background:#fff;color:#18181b;border:1px solid #e4e4e7}button:disabled{opacity:0.6;cursor:wait}@media(prefers-color-scheme:dark){body{background:#09090b;color:#fafafa}main{background:#18181b;border-color:#3f3f46}h1{color:#fafafa}.mark{color:#a1a1aa}.lede,.note{color:#a1a1aa}select{background:#27272a;border-color:#3f3f46;color:#fafafa}.approve{background:#fafafa;color:#18181b}.deny{background:#27272a;color:#fafafa;border-color:#3f3f46}}';
|
|
824
|
+
|
|
825
|
+
function jsonForScript(value: unknown): string {
|
|
826
|
+
return JSON.stringify(value).replace(/</gu, "\\u003c");
|
|
525
827
|
}
|
|
526
828
|
|
|
527
829
|
function escapeHtml(value: string): string {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "@opengeni/capabilities";
|
|
8
8
|
import {
|
|
9
9
|
ApiIntegrationPreview,
|
|
10
|
+
OrganizationIntegrationDeniedError,
|
|
10
11
|
ListIntegrationDefinitionsResponse,
|
|
11
12
|
ApiIntegrationUninstallPreview,
|
|
12
13
|
InstallApiIntegrationRequest,
|
|
@@ -17,12 +18,18 @@ import {
|
|
|
17
18
|
UninstallApiIntegrationResult,
|
|
18
19
|
type AccessGrant,
|
|
19
20
|
} from "@opengeni/contracts";
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
requireAccessGrant,
|
|
23
|
+
integrationSourceForOrganizationPolicy,
|
|
24
|
+
type ApiRouteDeps,
|
|
25
|
+
} from "@opengeni/core";
|
|
26
|
+
import { withOrganizationIntegrationPolicyFence } from "@opengeni/db/organization-integration-policy";
|
|
21
27
|
import {
|
|
22
28
|
ApiIntegrationInstallationVersionConflictError,
|
|
23
29
|
buildConnectionTokenResolver,
|
|
24
30
|
getApiIntegrationUninstallPreview,
|
|
25
31
|
getConnectionMetadata,
|
|
32
|
+
getApiIntegrationReconciliationSnapshot,
|
|
26
33
|
installApiIntegration,
|
|
27
34
|
IntegrationFacetBindingOwnershipConflictError,
|
|
28
35
|
IntegrationFacetBindingVersionConflictError,
|
|
@@ -209,6 +216,7 @@ export function registerApiIntegrationRoutes(
|
|
|
209
216
|
const resolved = await resolveForRoute({
|
|
210
217
|
deps,
|
|
211
218
|
transport,
|
|
219
|
+
accountId: grant.accountId,
|
|
212
220
|
workspaceId,
|
|
213
221
|
subjectId: grant.subjectId,
|
|
214
222
|
payload,
|
|
@@ -223,6 +231,7 @@ export function registerApiIntegrationRoutes(
|
|
|
223
231
|
const resolved = await resolveForRoute({
|
|
224
232
|
deps,
|
|
225
233
|
transport,
|
|
234
|
+
accountId: grant.accountId,
|
|
226
235
|
workspaceId,
|
|
227
236
|
subjectId: grant.subjectId,
|
|
228
237
|
payload,
|
|
@@ -308,21 +317,45 @@ export function registerApiIntegrationRoutes(
|
|
|
308
317
|
export async function resolveForRoute(input: {
|
|
309
318
|
deps: ApiRouteDeps;
|
|
310
319
|
transport: ReturnType<typeof createPinnedIntegrationTransport>;
|
|
320
|
+
accountId: string;
|
|
311
321
|
workspaceId: string;
|
|
312
322
|
subjectId: string;
|
|
313
323
|
payload: PreviewApiIntegrationRequest | InstallApiIntegrationRequest;
|
|
314
324
|
}): ReturnType<typeof resolveApiIntegrationPreview> {
|
|
325
|
+
const payload = structuredClone(input.payload);
|
|
326
|
+
const preparation = await withOrganizationIntegrationPolicyFence(
|
|
327
|
+
input.deps.db,
|
|
328
|
+
input,
|
|
329
|
+
async (tx, policy) => {
|
|
330
|
+
try {
|
|
331
|
+
return { source: integrationSourceForOrganizationPolicy(policy, payload.source) };
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (!(error instanceof OrganizationIntegrationDeniedError)) throw error;
|
|
334
|
+
const install = InstallApiIntegrationRequest.safeParse(payload);
|
|
335
|
+
if (install.success) {
|
|
336
|
+
const resolved = await storedReconciliationPreview(
|
|
337
|
+
{ ...input, deps: { ...input.deps, db: tx } },
|
|
338
|
+
install.data,
|
|
339
|
+
);
|
|
340
|
+
if (resolved) return { resolved };
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
if (preparation.resolved) return preparation.resolved;
|
|
347
|
+
const source = preparation.source!;
|
|
315
348
|
try {
|
|
316
|
-
const connection =
|
|
349
|
+
const connection = payload.connectionId
|
|
317
350
|
? await requireVisibleConnection(
|
|
318
351
|
input.deps,
|
|
319
352
|
input.workspaceId,
|
|
320
353
|
input.subjectId,
|
|
321
|
-
|
|
354
|
+
payload.connectionId,
|
|
322
355
|
)
|
|
323
356
|
: null;
|
|
324
357
|
return await resolveApiIntegrationPreview({
|
|
325
|
-
source
|
|
358
|
+
source,
|
|
326
359
|
connection: connectionDescriptor(connection),
|
|
327
360
|
transport: input.transport,
|
|
328
361
|
authority: {
|
|
@@ -353,6 +386,85 @@ export async function resolveForRoute(input: {
|
|
|
353
386
|
}
|
|
354
387
|
}
|
|
355
388
|
|
|
389
|
+
async function storedReconciliationPreview(
|
|
390
|
+
input: { deps: ApiRouteDeps; accountId: string; workspaceId: string; subjectId: string },
|
|
391
|
+
payload: InstallApiIntegrationRequest,
|
|
392
|
+
): Promise<ResolvedApiIntegrationPreview | null> {
|
|
393
|
+
const snapshot = await getApiIntegrationReconciliationSnapshot(input.deps.db, {
|
|
394
|
+
accountId: input.accountId,
|
|
395
|
+
workspaceId: input.workspaceId,
|
|
396
|
+
subjectId: input.subjectId,
|
|
397
|
+
source: payload.source,
|
|
398
|
+
expectedRevisionId: payload.expectedRevisionId,
|
|
399
|
+
expectedContentSha256: payload.expectedContentSha256,
|
|
400
|
+
...(payload.connectionId !== undefined ? { connectionId: payload.connectionId } : {}),
|
|
401
|
+
...(payload.instanceKey !== undefined ? { instanceKey: payload.instanceKey } : {}),
|
|
402
|
+
...(payload.allowedTools !== undefined ? { allowedTools: payload.allowedTools } : {}),
|
|
403
|
+
});
|
|
404
|
+
if (!snapshot) return null;
|
|
405
|
+
const { runtime, baseServerId, name, provider, requiredScopes } = snapshot;
|
|
406
|
+
const connection = payload.connectionId
|
|
407
|
+
? await requireVisibleConnection(
|
|
408
|
+
input.deps,
|
|
409
|
+
input.workspaceId,
|
|
410
|
+
input.subjectId,
|
|
411
|
+
payload.connectionId,
|
|
412
|
+
)
|
|
413
|
+
: null;
|
|
414
|
+
const scheme = runtime.authScheme;
|
|
415
|
+
const auth = !apiIntegrationRequiresConnection(scheme)
|
|
416
|
+
? { kind: "none" }
|
|
417
|
+
: scheme.kind === "oauth2"
|
|
418
|
+
? { kind: "oauth2", providerDomain: runtime.providerDomain, scopes: requiredScopes }
|
|
419
|
+
: scheme.kind === "api_key"
|
|
420
|
+
? {
|
|
421
|
+
kind: "api_key",
|
|
422
|
+
providerDomain: runtime.providerDomain,
|
|
423
|
+
carrier: scheme.carrier,
|
|
424
|
+
name: scheme.name,
|
|
425
|
+
}
|
|
426
|
+
: scheme.kind === "http"
|
|
427
|
+
? { kind: "http", providerDomain: runtime.providerDomain, scheme: scheme.scheme }
|
|
428
|
+
: null;
|
|
429
|
+
if (!auth) return null;
|
|
430
|
+
return {
|
|
431
|
+
preview: ApiIntegrationPreview.parse({
|
|
432
|
+
source: payload.source,
|
|
433
|
+
definitionId: runtime.definitionId,
|
|
434
|
+
definitionProvenance: runtime.definitionProvenance,
|
|
435
|
+
protocol: runtime.protocol,
|
|
436
|
+
capabilityId: runtime.capabilityId,
|
|
437
|
+
pluginKey: runtime.pluginKey,
|
|
438
|
+
serverId: baseServerId,
|
|
439
|
+
name,
|
|
440
|
+
description: runtime.description,
|
|
441
|
+
provider,
|
|
442
|
+
providerDomain: runtime.providerDomain,
|
|
443
|
+
baseUrl: runtime.baseUrl,
|
|
444
|
+
sourceUrl: runtime.sourceUrl,
|
|
445
|
+
revisionId: runtime.revision.id,
|
|
446
|
+
contentSha256: runtime.revision.contentSha256,
|
|
447
|
+
auth,
|
|
448
|
+
connectionId: connection?.id ?? null,
|
|
449
|
+
connectionOwnership: connection ? (connection.subjectId ? "personal" : "workspace") : null,
|
|
450
|
+
tools: runtime.revision.tools.map((tool) => ({
|
|
451
|
+
id: tool.id,
|
|
452
|
+
operationKey: tool.operationKey,
|
|
453
|
+
name: tool.name,
|
|
454
|
+
description: tool.description,
|
|
455
|
+
safety: tool.safety,
|
|
456
|
+
approvalMode: tool.approvalMode,
|
|
457
|
+
deprecated: tool.deprecated,
|
|
458
|
+
})),
|
|
459
|
+
warnings: [],
|
|
460
|
+
}),
|
|
461
|
+
revision: runtime.revision,
|
|
462
|
+
provider,
|
|
463
|
+
requiredScopes,
|
|
464
|
+
authScheme: runtime.authScheme,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
356
468
|
async function requireVisibleConnection(
|
|
357
469
|
deps: ApiRouteDeps,
|
|
358
470
|
workspaceId: string,
|