@opengeni/api-router 0.30.3 → 2.2.0-canary.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/api-websocket.d.ts +18 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +6 -0
- package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
- package/dist/chunk-3TP54PPX.js.map +1 -0
- package/dist/codemode.d.ts +8 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-authority-owner.d.ts +59 -0
- package/dist/connection-ownership.d.ts +47 -0
- package/dist/controller-data-plane.d.ts +16 -0
- package/dist/editable-artifact-websocket.d.ts +5 -15
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/http/api-error.d.ts +10 -0
- package/dist/http/cors.d.ts +5 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/sse.d.ts +2 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +326 -82
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +17 -0
- package/dist/integrations/fiken.d.ts +7 -2
- package/dist/integrations/google-drive.d.ts +18 -0
- package/dist/integrations/oauth-client.d.ts +22 -3
- package/dist/integrations/oauth-profiles.d.ts +195 -0
- package/dist/integrations/personal-github-repositories.d.ts +45 -0
- package/dist/integrations/personal-github.d.ts +40 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +8 -0
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +157 -9
- package/dist/integrations/slack-interactions.d.ts +39 -5
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/social-oauth.d.ts +5 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/documents.d.ts +1 -0
- package/dist/mcp/remember.d.ts +28 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +15 -2
- package/dist/mcp/session-view.d.ts +4 -0
- package/dist/mcp/session-wait.d.ts +137 -0
- package/dist/routes/automations.d.ts +4 -0
- package/dist/routes/billing.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +4 -1
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/connection-authorities.d.ts +3 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +19 -0
- package/dist/sandbox/channel-a.d.ts +11 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +2 -0
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +10 -20
- package/dist/sandbox/viewer.d.ts +24 -1
- package/dist/sandbox-file-artifacts.d.ts +11 -0
- package/dist/scheduled-task-deletion.d.ts +14 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/package.json +20 -18
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +161 -23
- package/src/auth/managed-auth.ts +25 -3
- package/src/codemode.ts +53 -23
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-authority-owner.ts +61 -0
- package/src/connection-ownership.ts +180 -0
- package/src/controller-data-plane.ts +47 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +28 -3
- package/src/editable-artifact-production.ts +13 -7
- package/src/editable-artifact-websocket.ts +11 -18
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/http/api-error.ts +28 -0
- package/src/http/auth.ts +4 -0
- package/src/http/cors.ts +35 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/sse.ts +112 -66
- package/src/index.ts +46 -6
- package/src/integrations/atlassian.ts +146 -35
- package/src/integrations/fiken.ts +102 -22
- package/src/integrations/google-drive.ts +301 -92
- package/src/integrations/oauth-client.ts +272 -143
- package/src/integrations/oauth-profiles.ts +477 -0
- package/src/integrations/personal-github-repositories.ts +445 -0
- package/src/integrations/personal-github.ts +705 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +121 -5
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +342 -57
- package/src/integrations/slack-interactions.ts +1898 -259
- package/src/integrations/slack-routing.ts +337 -0
- package/src/integrations/social-oauth.ts +25 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/mcp/company-brain-governed-writes.ts +262 -0
- package/src/mcp/company-profile-agent-admin.ts +205 -0
- package/src/mcp/documents.ts +37 -6
- package/src/mcp/files.ts +16 -1
- package/src/mcp/remember.ts +181 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-view.ts +1 -0
- package/src/mcp/server.ts +719 -369
- package/src/mcp/session-view.ts +54 -0
- package/src/mcp/session-wait.ts +554 -0
- package/src/model-catalog.ts +20 -12
- package/src/routes/api-integrations.ts +4 -0
- package/src/routes/api-keys.ts +1 -0
- package/src/routes/automations.ts +534 -0
- package/src/routes/billing.ts +57 -1
- package/src/routes/browser-sessions.ts +329 -79
- package/src/routes/channels.ts +17 -1
- package/src/routes/codex.ts +50 -0
- package/src/routes/company-brain.ts +367 -0
- package/src/routes/company-profile.ts +1 -1
- package/src/routes/computer-sessions.ts +320 -93
- package/src/routes/connection-authorities.ts +139 -0
- package/src/routes/connections.ts +144 -34
- package/src/routes/documents.ts +353 -4
- package/src/routes/editable-artifacts.ts +11 -3
- package/src/routes/enrollments.ts +116 -26
- package/src/routes/environments.ts +127 -55
- package/src/routes/files.ts +72 -11
- package/src/routes/install.ts +82 -28
- package/src/routes/integration-facets.ts +29 -0
- package/src/routes/interaction-resources.ts +20 -2
- package/src/routes/machines.ts +275 -13
- package/src/routes/organization-memberships.ts +717 -28
- package/src/routes/packs.ts +4 -3
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +319 -0
- package/src/routes/pr-review.ts +531 -0
- package/src/routes/rigs.ts +104 -39
- package/src/routes/scheduled-tasks.ts +19 -21
- package/src/routes/sessions.ts +775 -38
- package/src/routes/social.ts +14 -2
- package/src/routes/supergrok.ts +23 -4
- package/src/routes/transcription-recordings.ts +8 -2
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/workspace-artifacts.ts +4 -1
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-learning.ts +228 -0
- package/src/routes/workspaces.ts +59 -6
- package/src/sandbox/auth-callout.ts +38 -12
- package/src/sandbox/channel-a.ts +242 -19
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +15 -3
- package/src/sandbox/machines.ts +186 -64
- package/src/sandbox/metrics-ingestion.ts +220 -58
- package/src/sandbox/viewer.ts +243 -19
- package/src/sandbox-file-artifacts.ts +329 -0
- package/src/scheduled-task-deletion.ts +127 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/dist/chunk-WCXHI3FF.js.map +0 -1
|
@@ -3,7 +3,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
3
3
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
4
4
|
import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
|
|
5
5
|
import {
|
|
6
|
-
OPENGENI_PERSONAL_SLACK_MCP_URL,
|
|
7
6
|
OAuthStartResponse,
|
|
8
7
|
selectCanonicalPersonalSlackConnection,
|
|
9
8
|
type ConnectionOwnership,
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
decryptEnvironmentValue,
|
|
18
17
|
encryptEnvironmentValue,
|
|
19
18
|
getConnectionMetadata,
|
|
19
|
+
getGlobalCatalogOAuthProfile,
|
|
20
20
|
getWorkspaceGrant,
|
|
21
21
|
listConnectionsMetadata,
|
|
22
22
|
loadIntegrationOAuthClient,
|
|
@@ -40,21 +40,37 @@ import {
|
|
|
40
40
|
import { Buffer } from "node:buffer";
|
|
41
41
|
import { createHash, randomBytes } from "node:crypto";
|
|
42
42
|
import { HTTPException } from "hono/http-exception";
|
|
43
|
+
import {
|
|
44
|
+
assertConnectionOwnershipAllowedForPrincipal,
|
|
45
|
+
personalOwnerStateAccepted,
|
|
46
|
+
personalOwnerVerifiedInState,
|
|
47
|
+
PERSONAL_CONNECTION_PRINCIPAL_MESSAGE,
|
|
48
|
+
PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
|
|
49
|
+
} from "../connection-ownership";
|
|
43
50
|
import { ApiHttpError } from "../http/api-error";
|
|
51
|
+
import {
|
|
52
|
+
DEFAULT_OAUTH_PROFILE,
|
|
53
|
+
DEPLOYMENT_MANAGED_CLIENTS,
|
|
54
|
+
builtInOAuthProfileByKey,
|
|
55
|
+
assertAuthorizationServerNotReserved,
|
|
56
|
+
assertAuthorizationServerPins,
|
|
57
|
+
assertOwnershipAllowed,
|
|
58
|
+
builtInOAuthProfileFor,
|
|
59
|
+
catalogMcpUrlKey,
|
|
60
|
+
defaultOwnershipFor,
|
|
61
|
+
deploymentManagedClientFor,
|
|
62
|
+
oauthProfileFromCatalog,
|
|
63
|
+
type OAuthProviderProfile,
|
|
64
|
+
type PinnableAuthorizationServer,
|
|
65
|
+
} from "./oauth-profiles";
|
|
44
66
|
import { canonicalProviderDomain } from "./provider-domain";
|
|
45
67
|
|
|
46
68
|
export const oauthStateTtlMs = 10 * 60 * 1000;
|
|
47
|
-
export
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"https://www.googleapis.com/auth/gmail.modify",
|
|
53
|
-
] as const;
|
|
54
|
-
const SLACK_OAUTH_ORIGIN = "https://slack.com";
|
|
55
|
-
const SLACK_MCP_ORIGIN = "https://mcp.slack.com";
|
|
56
|
-
const GOOGLE_OAUTH_ISSUER_ORIGIN = "https://accounts.google.com";
|
|
57
|
-
const GOOGLE_TOKEN_ORIGIN = "https://oauth2.googleapis.com";
|
|
69
|
+
export {
|
|
70
|
+
OFFICIAL_GMAIL_MCP_SCOPES,
|
|
71
|
+
OFFICIAL_GMAIL_MCP_URL,
|
|
72
|
+
OFFICIAL_SLACK_MCP_URL,
|
|
73
|
+
} from "./oauth-profiles";
|
|
58
74
|
export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
|
|
59
75
|
|
|
60
76
|
type OAuthClientDeps = {
|
|
@@ -69,6 +85,12 @@ export type OAuthStartContext = {
|
|
|
69
85
|
accountId: string;
|
|
70
86
|
workspaceId: string;
|
|
71
87
|
subjectId: string;
|
|
88
|
+
/**
|
|
89
|
+
* False for every principal that cannot own a personal Connection (API keys,
|
|
90
|
+
* the configured key, services, agent attempts). Resolved by the route from
|
|
91
|
+
* the live authenticated principal, never inferred here.
|
|
92
|
+
*/
|
|
93
|
+
personalOwnershipAllowed: boolean;
|
|
72
94
|
requestUrl: string;
|
|
73
95
|
payload: OAuthStartRequest;
|
|
74
96
|
};
|
|
@@ -116,6 +138,8 @@ type OAuthStatePayload = {
|
|
|
116
138
|
workspaceId: string;
|
|
117
139
|
subjectId: string;
|
|
118
140
|
ownership: ConnectionOwnership;
|
|
141
|
+
/** Signed proof that a live managed human authorized personal ownership. */
|
|
142
|
+
personalOwnerVerified: boolean;
|
|
119
143
|
providerDomain: string;
|
|
120
144
|
mcpUrl: string;
|
|
121
145
|
resource: string;
|
|
@@ -305,15 +329,29 @@ async function startMcpOAuthWithinDeadline(
|
|
|
305
329
|
): Promise<OAuthStartResponse> {
|
|
306
330
|
const { db, settings } = deps;
|
|
307
331
|
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
332
|
+
const urlProfile = builtInOAuthProfileFor({ mcpUrl });
|
|
333
|
+
const providerDomain =
|
|
334
|
+
urlProfile?.canonicalProviderDomain ??
|
|
335
|
+
canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
|
|
336
|
+
const builtInProfile = urlProfile ?? builtInOAuthProfileFor({ mcpUrl, providerDomain });
|
|
337
|
+
const profile =
|
|
338
|
+
builtInProfile ??
|
|
339
|
+
(await deadline.run("connection_lookup", async () => {
|
|
340
|
+
const raw = await getGlobalCatalogOAuthProfile(
|
|
341
|
+
db,
|
|
342
|
+
context.workspaceId,
|
|
343
|
+
catalogMcpUrlKey(mcpUrl),
|
|
344
|
+
);
|
|
345
|
+
return raw === null ? null : oauthProfileFromCatalog(mcpUrl, raw);
|
|
346
|
+
})) ??
|
|
347
|
+
DEFAULT_OAUTH_PROFILE;
|
|
348
|
+
assertOAuthStartProfile(settings, context.payload, mcpUrl, profile);
|
|
349
|
+
// Personal-only resources default an omitted ownership to personal, matching
|
|
350
|
+
// the fence that existed before #1240; an explicit non-personal value is the
|
|
351
|
+
// only thing rejected. Everything else defaults to workspace as before.
|
|
352
|
+
const requestedOwnership: ConnectionOwnership =
|
|
353
|
+
context.payload.ownership ?? defaultOwnershipFor(profile);
|
|
354
|
+
assertOwnershipAllowed(profile, requestedOwnership);
|
|
317
355
|
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
318
356
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
319
357
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
@@ -324,8 +362,8 @@ async function startMcpOAuthWithinDeadline(
|
|
|
324
362
|
subjectId: context.subjectId,
|
|
325
363
|
providerDomain,
|
|
326
364
|
mcpUrl,
|
|
327
|
-
|
|
328
|
-
exactMcpBinding:
|
|
365
|
+
connectionSelection: profile.connectionSelection,
|
|
366
|
+
exactMcpBinding: profile.exactMcpBinding,
|
|
329
367
|
connectionId: context.payload.connectionId,
|
|
330
368
|
requestedOwnership: context.payload.ownership,
|
|
331
369
|
newConnectionOwnership: requestedOwnership,
|
|
@@ -337,27 +375,25 @@ async function startMcpOAuthWithinDeadline(
|
|
|
337
375
|
const ownership = existing
|
|
338
376
|
? ownershipForConnection(existing.subjectId, context.subjectId)
|
|
339
377
|
: requestedOwnership;
|
|
378
|
+
// A reconnect of a legacy row must not renew an ownership the profile no
|
|
379
|
+
// longer allows (e.g. shared authority over one human's hosted-Slack grant).
|
|
380
|
+
assertOwnershipAllowed(profile, ownership);
|
|
381
|
+
// Only a managed human can own a personal Connection: personal-authority
|
|
382
|
+
// execution resolves through a delegation snapshot frozen on a human's causal
|
|
383
|
+
// turn, and migration 0256 can mint the `user` authority scope only for a
|
|
384
|
+
// subject that holds an active organization membership. For a personal-only
|
|
385
|
+
// profile (Gmail, hosted Slack MCP) this refuses the whole flow rather than
|
|
386
|
+
// silently downgrading to the workspace ownership those profiles forbid.
|
|
387
|
+
assertConnectionOwnershipAllowedForPrincipal(ownership, context.personalOwnershipAllowed);
|
|
340
388
|
|
|
341
389
|
const discovery = await discoverMcpOAuth(mcpUrl, settings, deadline);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
const googleAuthorizationServer = hasGoogleAuthorizationServerIdentity(discovery.as);
|
|
346
|
-
if (officialGmailResource || googleAuthorizationServer) {
|
|
347
|
-
if (!officialGmailResource || providerDomain !== "gmailmcp.googleapis.com") {
|
|
348
|
-
throw new HTTPException(422, {
|
|
349
|
-
message: `Google OAuth is allowed only for ${OFFICIAL_GMAIL_MCP_URL}`,
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
assertGoogleAuthorizationServer(discovery.as);
|
|
353
|
-
}
|
|
354
|
-
const resourceParameterSupported = !officialGmailResource;
|
|
390
|
+
assertDiscoveredAuthorizationServer(settings, discovery.as, profile, providerDomain);
|
|
391
|
+
const resourceParameterSupported = profile.sendResourceParameter;
|
|
355
392
|
const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
|
|
356
393
|
const verifier = randomPkceVerifier();
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
const authorizeScopes =
|
|
360
|
-
mcpUrl,
|
|
394
|
+
// A profile's exact scope override wins outright: the reviewed connector
|
|
395
|
+
// never lets a caller widen the capability contract.
|
|
396
|
+
const authorizeScopes = chooseProfileAuthorizeScopes(profile, {
|
|
361
397
|
requested: context.payload.requestedScopes,
|
|
362
398
|
challenged: discovery.challenge.scope,
|
|
363
399
|
supported: discovery.prm.scopesSupported,
|
|
@@ -371,6 +407,7 @@ async function startMcpOAuthWithinDeadline(
|
|
|
371
407
|
redirectUri,
|
|
372
408
|
authorizeScopes,
|
|
373
409
|
context.payload.oauthClient,
|
|
410
|
+
profile,
|
|
374
411
|
signal,
|
|
375
412
|
),
|
|
376
413
|
);
|
|
@@ -380,11 +417,14 @@ async function startMcpOAuthWithinDeadline(
|
|
|
380
417
|
workspaceId: context.workspaceId,
|
|
381
418
|
subjectId: context.subjectId,
|
|
382
419
|
ownership,
|
|
420
|
+
// Signed record that a live principal was checked; the callback has no
|
|
421
|
+
// principal of its own and enforces exactly this decision.
|
|
422
|
+
[PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: context.personalOwnershipAllowed,
|
|
383
423
|
providerDomain,
|
|
384
424
|
mcpUrl,
|
|
385
425
|
resource,
|
|
386
|
-
requestedScopes:
|
|
387
|
-
? [...
|
|
426
|
+
requestedScopes: profile.requestedScopes
|
|
427
|
+
? [...profile.requestedScopes]
|
|
388
428
|
: uniqueStrings(context.payload.requestedScopes ?? []),
|
|
389
429
|
authorizeScopes,
|
|
390
430
|
encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
|
|
@@ -413,6 +453,7 @@ async function startMcpOAuthWithinDeadline(
|
|
|
413
453
|
verifier,
|
|
414
454
|
scopes: authorizeScopes,
|
|
415
455
|
resourceParameterSupported,
|
|
456
|
+
...(profile.extraAuthorizeParams ? { extraParams: profile.extraAuthorizeParams } : {}),
|
|
416
457
|
});
|
|
417
458
|
return OAuthStartResponse.parse({
|
|
418
459
|
state,
|
|
@@ -421,18 +462,94 @@ async function startMcpOAuthWithinDeadline(
|
|
|
421
462
|
});
|
|
422
463
|
}
|
|
423
464
|
|
|
465
|
+
/** Ownership fence for the official Gmail profile, keyed by exact MCP URL. */
|
|
424
466
|
export function assertOfficialGmailPersonalOwnership(
|
|
425
467
|
mcpUrl: string,
|
|
426
468
|
ownership: ConnectionOwnership,
|
|
427
469
|
): void {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
470
|
+
const profile = builtInOAuthProfileFor({ mcpUrl });
|
|
471
|
+
if (profile?.key === "official-gmail") {
|
|
472
|
+
assertOwnershipAllowed(profile, ownership);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Slack's hosted MCP issues user tokens only. Sharing one human's grant as
|
|
478
|
+
* workspace authority made every shared agent act as a named employee; the
|
|
479
|
+
* OpenGeni workspace bot owns shared Slack access instead (bot-token search
|
|
480
|
+
* covers public channels, files, and users).
|
|
481
|
+
*/
|
|
482
|
+
export function assertHostedSlackMcpPersonalOwnership(
|
|
483
|
+
hostedSlackMcp: boolean,
|
|
484
|
+
ownership: ConnectionOwnership,
|
|
485
|
+
): void {
|
|
486
|
+
if (hostedSlackMcp) {
|
|
487
|
+
assertOwnershipAllowed(builtInOAuthProfileByKey("hosted-slack-mcp"), ownership);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function isHostedSlackMcpTarget(providerDomain: string, mcpUrl: string): boolean {
|
|
492
|
+
return builtInOAuthProfileFor({ mcpUrl, providerDomain })?.key === "hosted-slack-mcp";
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Start-time payload fences declared by the resolved profile. */
|
|
496
|
+
function assertOAuthStartProfile(
|
|
497
|
+
settings: Settings,
|
|
498
|
+
payload: OAuthStartRequest,
|
|
499
|
+
mcpUrl: string,
|
|
500
|
+
profile: OAuthProviderProfile,
|
|
501
|
+
): void {
|
|
502
|
+
if (profile.rejectCallerOAuthClient && payload.oauthClient) {
|
|
503
|
+
throw new HTTPException(422, { message: profile.rejectCallerOAuthClient.message });
|
|
504
|
+
}
|
|
505
|
+
if (
|
|
506
|
+
profile.requireProviderDomain &&
|
|
507
|
+
payload.providerDomain &&
|
|
508
|
+
canonicalProviderDomain(payload.providerDomain) !== profile.requireProviderDomain.domain
|
|
509
|
+
) {
|
|
510
|
+
throw new HTTPException(422, { message: profile.requireProviderDomain.message });
|
|
511
|
+
}
|
|
512
|
+
if (
|
|
513
|
+
profile.requireExactMcpUrl &&
|
|
514
|
+
!isLocalTestEnvironment(settings.environment) &&
|
|
515
|
+
mcpUrl !== profile.requireExactMcpUrl.url
|
|
516
|
+
) {
|
|
517
|
+
throw new HTTPException(422, { message: profile.requireExactMcpUrl.message });
|
|
518
|
+
}
|
|
519
|
+
if (
|
|
520
|
+
profile.requireDeploymentClient &&
|
|
521
|
+
!deploymentManagedClientFor(settings, profile.requireDeploymentClient.key)
|
|
522
|
+
) {
|
|
523
|
+
throw new HTTPException(503, { message: profile.requireDeploymentClient.message });
|
|
433
524
|
}
|
|
434
525
|
}
|
|
435
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Post-discovery fences, in an order that preserves the historical error
|
|
529
|
+
* surface: the profile's provider identity, then its authorization-server
|
|
530
|
+
* origin pins, then the reserved-server guard for everything else.
|
|
531
|
+
*/
|
|
532
|
+
function assertDiscoveredAuthorizationServer(
|
|
533
|
+
settings: Settings,
|
|
534
|
+
as: PinnableAuthorizationServer,
|
|
535
|
+
profile: OAuthProviderProfile,
|
|
536
|
+
providerDomain: string,
|
|
537
|
+
): void {
|
|
538
|
+
if (
|
|
539
|
+
profile.postDiscoveryProviderDomain &&
|
|
540
|
+
providerDomain !== profile.postDiscoveryProviderDomain.domain
|
|
541
|
+
) {
|
|
542
|
+
throw new HTTPException(422, { message: profile.postDiscoveryProviderDomain.message });
|
|
543
|
+
}
|
|
544
|
+
if (
|
|
545
|
+
profile.authorizationServer &&
|
|
546
|
+
!(profile.authorizationServer.skipInLocalTest && isLocalTestEnvironment(settings.environment))
|
|
547
|
+
) {
|
|
548
|
+
assertAuthorizationServerPins(as, profile.authorizationServer);
|
|
549
|
+
}
|
|
550
|
+
assertAuthorizationServerNotReserved(as, profile);
|
|
551
|
+
}
|
|
552
|
+
|
|
436
553
|
export async function completeMcpOAuthCallback(
|
|
437
554
|
deps: OAuthClientDeps,
|
|
438
555
|
input: {
|
|
@@ -479,8 +596,27 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
479
596
|
try {
|
|
480
597
|
state = readOAuthState(input.state, settings);
|
|
481
598
|
// Fence state minted by an older deployment too: a rolling update must not
|
|
482
|
-
// let a still-valid
|
|
483
|
-
|
|
599
|
+
// let a still-valid callback persist an ownership the target's profile no
|
|
600
|
+
// longer allows (workspace-owned Gmail or hosted Slack, for example).
|
|
601
|
+
const builtInCallbackProfile = builtInOAuthProfileFor({
|
|
602
|
+
mcpUrl: state.mcpUrl,
|
|
603
|
+
providerDomain: state.providerDomain,
|
|
604
|
+
});
|
|
605
|
+
if (builtInCallbackProfile) {
|
|
606
|
+
assertOwnershipAllowed(builtInCallbackProfile, state.ownership);
|
|
607
|
+
}
|
|
608
|
+
// Only a managed human may own a personal Connection. This callback has no
|
|
609
|
+
// live principal, so it enforces the signed start-time decision; a state
|
|
610
|
+
// minted before that fence existed carries no claim and is refused, which
|
|
611
|
+
// closes the in-flight window across a rolling deploy. The legacy
|
|
612
|
+
// `?? "personal"` decode above therefore cannot land a machine-owned row.
|
|
613
|
+
if (!personalOwnerStateAccepted(state)) {
|
|
614
|
+
throw new OAuthCallbackStageError(
|
|
615
|
+
"state_verify",
|
|
616
|
+
"state_invalid",
|
|
617
|
+
new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE }),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
484
620
|
if (!input.code) {
|
|
485
621
|
return {
|
|
486
622
|
redirectTo: callbackReturnPath(state.returnPath, "error", {
|
|
@@ -495,6 +631,18 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
495
631
|
db,
|
|
496
632
|
async (scopedDb) => {
|
|
497
633
|
await requireOAuthCallbackGrant(scopedDb, state!);
|
|
634
|
+
if (!builtInCallbackProfile) {
|
|
635
|
+
// Catalog-declared ownership fences get the same rolling-update
|
|
636
|
+
// treatment as the built-in ones.
|
|
637
|
+
const raw = await getGlobalCatalogOAuthProfile(
|
|
638
|
+
scopedDb,
|
|
639
|
+
state!.workspaceId,
|
|
640
|
+
catalogMcpUrlKey(state!.mcpUrl),
|
|
641
|
+
);
|
|
642
|
+
if (raw !== null) {
|
|
643
|
+
assertOwnershipAllowed(oauthProfileFromCatalog(state!.mcpUrl, raw), state!.ownership);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
498
646
|
return await consumeIntegrationOAuthStateNonce(scopedDb, {
|
|
499
647
|
accountId: state!.accountId,
|
|
500
648
|
workspaceId: state!.workspaceId,
|
|
@@ -656,68 +804,19 @@ async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload)
|
|
|
656
804
|
}
|
|
657
805
|
}
|
|
658
806
|
|
|
659
|
-
|
|
660
|
-
settings: Settings,
|
|
661
|
-
payload: OAuthStartRequest,
|
|
662
|
-
mcpUrl: string,
|
|
663
|
-
hostedSlackMcp: boolean,
|
|
664
|
-
): void {
|
|
665
|
-
if (!hostedSlackMcp) return;
|
|
666
|
-
if (payload.oauthClient) {
|
|
667
|
-
throw new HTTPException(422, {
|
|
668
|
-
message: "Slack OAuth client credentials are deployment-managed",
|
|
669
|
-
});
|
|
670
|
-
}
|
|
671
|
-
if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
|
|
672
|
-
throw new HTTPException(422, {
|
|
673
|
-
message: "Slack provider identity does not match slack.com",
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
|
|
677
|
-
throw new HTTPException(422, {
|
|
678
|
-
message: `Slack MCP OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
|
|
682
|
-
throw new HTTPException(503, {
|
|
683
|
-
message: "Slack MCP OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
807
|
+
/** The hosted-Slack profile's origin pins, kept exported for its tests. */
|
|
688
808
|
export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
(
|
|
692
|
-
);
|
|
693
|
-
if (
|
|
694
|
-
issuerOrigins.some((origin) => origin !== SLACK_OAUTH_ORIGIN && origin !== SLACK_MCP_ORIGIN) ||
|
|
695
|
-
endpointOrigins.some((origin) => origin !== SLACK_OAUTH_ORIGIN)
|
|
696
|
-
) {
|
|
697
|
-
throw new HTTPException(422, {
|
|
698
|
-
message: "Slack MCP authorization metadata did not remain bound to slack.com",
|
|
699
|
-
});
|
|
809
|
+
const pins = builtInOAuthProfileByKey("hosted-slack-mcp").authorizationServer;
|
|
810
|
+
if (pins) {
|
|
811
|
+
assertAuthorizationServerPins(as, pins);
|
|
700
812
|
}
|
|
701
813
|
}
|
|
702
814
|
|
|
703
|
-
|
|
704
|
-
return [as.issuer, as.authorizationServer].some(
|
|
705
|
-
(value) => new URL(value).origin === GOOGLE_OAUTH_ISSUER_ORIGIN,
|
|
706
|
-
);
|
|
707
|
-
}
|
|
708
|
-
|
|
815
|
+
/** The official-Gmail profile's origin pins, kept exported for its tests. */
|
|
709
816
|
export function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata): void {
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (
|
|
714
|
-
issuerOrigins.some((origin) => origin !== GOOGLE_OAUTH_ISSUER_ORIGIN) ||
|
|
715
|
-
authorizationOrigin !== GOOGLE_OAUTH_ISSUER_ORIGIN ||
|
|
716
|
-
tokenOrigin !== GOOGLE_TOKEN_ORIGIN
|
|
717
|
-
) {
|
|
718
|
-
throw new HTTPException(422, {
|
|
719
|
-
message: "Gmail MCP authorization metadata did not remain bound to Google",
|
|
720
|
-
});
|
|
817
|
+
const pins = builtInOAuthProfileByKey("official-gmail").authorizationServer;
|
|
818
|
+
if (pins) {
|
|
819
|
+
assertAuthorizationServerPins(as, pins);
|
|
721
820
|
}
|
|
722
821
|
}
|
|
723
822
|
|
|
@@ -884,16 +983,15 @@ async function registerOAuthClient(
|
|
|
884
983
|
redirectUri: string,
|
|
885
984
|
scopes: string[],
|
|
886
985
|
manual: OAuthStartRequest["oauthClient"],
|
|
986
|
+
profile: OAuthProviderProfile,
|
|
887
987
|
signal: AbortSignal,
|
|
888
988
|
): Promise<OAuthClientRegistration> {
|
|
889
989
|
const operator = operatorClientForAs(settings, as);
|
|
890
990
|
if (operator) {
|
|
891
991
|
return operator;
|
|
892
992
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
// so prefer the simultaneously advertised registration endpoint.
|
|
896
|
-
if (prefersDynamicClientRegistration(as)) {
|
|
993
|
+
const selfRegistration = preferredOAuthSelfRegistration(as, profile.clientSource);
|
|
994
|
+
if (selfRegistration === "dcr") {
|
|
897
995
|
return await getOrCreateDynamicClientRegistration(
|
|
898
996
|
db,
|
|
899
997
|
settings,
|
|
@@ -903,7 +1001,7 @@ async function registerOAuthClient(
|
|
|
903
1001
|
signal,
|
|
904
1002
|
);
|
|
905
1003
|
}
|
|
906
|
-
if (
|
|
1004
|
+
if (selfRegistration === "cimd") {
|
|
907
1005
|
return {
|
|
908
1006
|
method: "cimd",
|
|
909
1007
|
issuer: as.issuer,
|
|
@@ -928,10 +1026,22 @@ async function registerOAuthClient(
|
|
|
928
1026
|
return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes, signal);
|
|
929
1027
|
}
|
|
930
1028
|
|
|
931
|
-
function
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1029
|
+
export function preferredOAuthSelfRegistration(
|
|
1030
|
+
as: Pick<
|
|
1031
|
+
AuthorizationServerMetadata,
|
|
1032
|
+
"clientIdMetadataDocumentSupported" | "registrationEndpoint"
|
|
1033
|
+
>,
|
|
1034
|
+
clientSource: OAuthProviderProfile["clientSource"],
|
|
1035
|
+
): "cimd" | "dcr" | null {
|
|
1036
|
+
if (clientSource === "dcr") return "dcr";
|
|
1037
|
+
if (clientSource === "cimd" && as.clientIdMetadataDocumentSupported) return "cimd";
|
|
1038
|
+
// DCR produces an authorization-server-issued client id and is therefore the
|
|
1039
|
+
// interoperable default whenever the server advertises both registration
|
|
1040
|
+
// mechanisms. A reviewed provider profile may explicitly force CIMD for a
|
|
1041
|
+
// server whose registration endpoint is unsuitable.
|
|
1042
|
+
if (clientSource !== "cimd" && as.registrationEndpoint) return "dcr";
|
|
1043
|
+
if (as.clientIdMetadataDocumentSupported) return "cimd";
|
|
1044
|
+
return null;
|
|
935
1045
|
}
|
|
936
1046
|
|
|
937
1047
|
async function getOrCreateDynamicClientRegistration(
|
|
@@ -1106,23 +1216,21 @@ function operatorClientEntryFor(
|
|
|
1106
1216
|
candidates: string[],
|
|
1107
1217
|
): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
|
|
1108
1218
|
const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
tokenEndpointAuthMethod: "client_secret_post",
|
|
1125
|
-
};
|
|
1219
|
+
const candidateOrigins = candidates.flatMap((candidate) => {
|
|
1220
|
+
try {
|
|
1221
|
+
return [new URL(candidate).origin];
|
|
1222
|
+
} catch {
|
|
1223
|
+
return [];
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
for (const entry of DEPLOYMENT_MANAGED_CLIENTS) {
|
|
1227
|
+
if (!candidateOrigins.some((origin) => entry.issuerOrigins.includes(origin))) {
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
const resolved = entry.resolve(settings);
|
|
1231
|
+
if (resolved) {
|
|
1232
|
+
return resolved;
|
|
1233
|
+
}
|
|
1126
1234
|
}
|
|
1127
1235
|
const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
|
|
1128
1236
|
const exactKeys = uniqueStrings(
|
|
@@ -1210,7 +1318,7 @@ async function existingOAuthConnectionForStart(
|
|
|
1210
1318
|
subjectId: string;
|
|
1211
1319
|
providerDomain: string;
|
|
1212
1320
|
mcpUrl: string;
|
|
1213
|
-
|
|
1321
|
+
connectionSelection: OAuthProviderProfile["connectionSelection"];
|
|
1214
1322
|
exactMcpBinding: boolean;
|
|
1215
1323
|
connectionId?: string | undefined;
|
|
1216
1324
|
requestedOwnership?: ConnectionOwnership | undefined;
|
|
@@ -1247,7 +1355,7 @@ async function existingOAuthConnectionForStart(
|
|
|
1247
1355
|
connection.providerDomain === input.providerDomain &&
|
|
1248
1356
|
(!input.exactMcpBinding || connection.metadata.mcpUrl === input.mcpUrl),
|
|
1249
1357
|
);
|
|
1250
|
-
if (input.
|
|
1358
|
+
if (input.connectionSelection === "canonical_personal") {
|
|
1251
1359
|
return selectCanonicalPersonalSlackConnection(matching);
|
|
1252
1360
|
}
|
|
1253
1361
|
return matching.find((connection) => connection.status === "active") ?? null;
|
|
@@ -1272,6 +1380,8 @@ export function buildAuthorizationUrl(input: {
|
|
|
1272
1380
|
verifier: string;
|
|
1273
1381
|
scopes: string[];
|
|
1274
1382
|
resourceParameterSupported: boolean;
|
|
1383
|
+
/** Profile-declared extra authorize parameters, applied last. */
|
|
1384
|
+
extraParams?: Readonly<Record<string, string>> | undefined;
|
|
1275
1385
|
}): string {
|
|
1276
1386
|
const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint");
|
|
1277
1387
|
const url = new URL(endpoint);
|
|
@@ -1280,20 +1390,25 @@ export function buildAuthorizationUrl(input: {
|
|
|
1280
1390
|
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
1281
1391
|
url.searchParams.set("state", input.state);
|
|
1282
1392
|
if (input.resourceParameterSupported) {
|
|
1393
|
+
// Suppressing RFC 8707 is a profile fact (Google's endpoints reject the
|
|
1394
|
+
// parameter); any provider-specific replacement parameters, such as
|
|
1395
|
+
// Google's offline-consent options, are that profile's
|
|
1396
|
+
// `extraAuthorizeParams` rather than behavior implied by the suppression.
|
|
1283
1397
|
url.searchParams.set("resource", input.resource);
|
|
1284
|
-
} else {
|
|
1285
|
-
// Google's OAuth endpoints do not implement RFC 8707's `resource`
|
|
1286
|
-
// parameter. Explicit offline consent is required to obtain the refresh
|
|
1287
|
-
// token used by the durable connection broker.
|
|
1288
|
-
url.searchParams.set("access_type", "offline");
|
|
1289
|
-
url.searchParams.set("include_granted_scopes", "true");
|
|
1290
|
-
url.searchParams.set("prompt", "consent");
|
|
1291
1398
|
}
|
|
1292
1399
|
url.searchParams.set("code_challenge_method", "S256");
|
|
1293
1400
|
url.searchParams.set("code_challenge", pkceChallenge(input.verifier));
|
|
1294
1401
|
if (input.scopes.length > 0) {
|
|
1295
1402
|
url.searchParams.set("scope", input.scopes.join(" "));
|
|
1296
1403
|
}
|
|
1404
|
+
for (const [name, value] of Object.entries(input.extraParams ?? {})) {
|
|
1405
|
+
// Defense in depth behind the schema/curation validation: an extra
|
|
1406
|
+
// parameter can never displace a security parameter the client already
|
|
1407
|
+
// set (state, PKCE, scope, resource, redirect_uri, ...).
|
|
1408
|
+
if (!url.searchParams.has(name)) {
|
|
1409
|
+
url.searchParams.set(name, value);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1297
1412
|
return url.toString();
|
|
1298
1413
|
}
|
|
1299
1414
|
|
|
@@ -1318,6 +1433,8 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
1318
1433
|
// OAuth states minted before ownership was explicit were always personal.
|
|
1319
1434
|
// Preserve that meaning for in-flight reconnects during a rolling deploy.
|
|
1320
1435
|
ownership: connectionOwnership(payload.ownership) ?? "personal",
|
|
1436
|
+
// Absent on a legacy state, which therefore cannot land a personal owner.
|
|
1437
|
+
personalOwnerVerified: personalOwnerVerifiedInState(payload),
|
|
1321
1438
|
providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
|
|
1322
1439
|
mcpUrl: stringValue(payload.mcpUrl) ?? resource,
|
|
1323
1440
|
resource,
|
|
@@ -2061,8 +2178,20 @@ export function chooseMcpAuthorizeScopes(input: {
|
|
|
2061
2178
|
challenged: string[] | undefined;
|
|
2062
2179
|
supported: string[];
|
|
2063
2180
|
}): string[] {
|
|
2064
|
-
|
|
2065
|
-
|
|
2181
|
+
const profile = builtInOAuthProfileFor({ mcpUrl: input.mcpUrl }) ?? DEFAULT_OAUTH_PROFILE;
|
|
2182
|
+
return chooseProfileAuthorizeScopes(profile, input);
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function chooseProfileAuthorizeScopes(
|
|
2186
|
+
profile: OAuthProviderProfile,
|
|
2187
|
+
input: {
|
|
2188
|
+
requested: string[] | undefined;
|
|
2189
|
+
challenged: string[] | undefined;
|
|
2190
|
+
supported: string[];
|
|
2191
|
+
},
|
|
2192
|
+
): string[] {
|
|
2193
|
+
return profile.requestedScopes
|
|
2194
|
+
? [...profile.requestedScopes]
|
|
2066
2195
|
: chooseAuthorizeScopes(input.requested, input.challenged, input.supported);
|
|
2067
2196
|
}
|
|
2068
2197
|
|