@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
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { safeReturnPath } from "./oauth-return-path";
|
|
2
|
+
import { assertOrganizationIntegrationAllowed, stableJson } from "@opengeni/contracts";
|
|
3
|
+
import {
|
|
4
|
+
withOrganizationIntegrationAcquisition,
|
|
5
|
+
withOrganizationIntegrationPolicyFence,
|
|
6
|
+
} from "@opengeni/db/organization-integration-policy";
|
|
2
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
8
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
9
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
@@ -9,7 +14,7 @@ import {
|
|
|
9
14
|
type ConnectionOwnership,
|
|
10
15
|
type OAuthStartRequest,
|
|
11
16
|
} from "@opengeni/contracts";
|
|
12
|
-
import { requireEnvironmentEncryption } from "@opengeni/core";
|
|
17
|
+
import { requireEnvironmentEncryption, integrationKeyForConnectProvider } from "@opengeni/core";
|
|
13
18
|
import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
14
19
|
import type { Observability } from "@opengeni/observability";
|
|
15
20
|
import {
|
|
@@ -63,11 +68,11 @@ import { ApiHttpError } from "../http/api-error";
|
|
|
63
68
|
import { requireConnectOwnerAuthority } from "./connect-authority";
|
|
64
69
|
import {
|
|
65
70
|
DEFAULT_OAUTH_PROFILE,
|
|
71
|
+
OFFICIAL_GMAIL_MCP_URL,
|
|
66
72
|
DEPLOYMENT_MANAGED_CLIENTS,
|
|
67
73
|
builtInOAuthProfileByKey,
|
|
68
74
|
assertAuthorizationServerNotReserved,
|
|
69
75
|
assertAuthorizationServerPins,
|
|
70
|
-
assertOwnershipAllowed,
|
|
71
76
|
builtInOAuthProfileFor,
|
|
72
77
|
catalogMcpUrlKey,
|
|
73
78
|
defaultOwnershipFor,
|
|
@@ -95,6 +100,8 @@ type OAuthClientDeps = {
|
|
|
95
100
|
};
|
|
96
101
|
|
|
97
102
|
export type OAuthStartContext = {
|
|
103
|
+
/** Server-selected dedicated adapter identity; never copied from request metadata. */
|
|
104
|
+
integrationKey?: "gmail" | "slack-personal";
|
|
98
105
|
connectAttemptId?: string;
|
|
99
106
|
externalContinuation?: ExternalActorContinuation;
|
|
100
107
|
accountId: string;
|
|
@@ -129,6 +136,7 @@ type OAuthClientRegistration = {
|
|
|
129
136
|
};
|
|
130
137
|
|
|
131
138
|
type OAuthStatePayload = {
|
|
139
|
+
integrationKey?: "gmail" | "slack-personal";
|
|
132
140
|
connectAttemptId?: string;
|
|
133
141
|
externalContinuation?: ExternalActorContinuation;
|
|
134
142
|
returnUrl?: string;
|
|
@@ -312,10 +320,90 @@ class OAuthCallbackDeadline {
|
|
|
312
320
|
}
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
/** Policy is the outermost lock. Receipt replay still runs normal authorization,
|
|
324
|
+
* but the DB calls authorizeAcquisition only for uncommitted progress. Provider
|
|
325
|
+
* requests must happen after this transaction, never inside either wrapper. */
|
|
326
|
+
export async function claimOAuthAcquisition(
|
|
327
|
+
db: Database,
|
|
328
|
+
scope: Parameters<typeof claimConnectOperation>[1],
|
|
329
|
+
input: Parameters<typeof claimConnectOperation>[2],
|
|
330
|
+
integrationKey: string | null,
|
|
331
|
+
acquiring = true,
|
|
332
|
+
) {
|
|
333
|
+
if (!acquiring) return claimConnectOperation(db, scope, input);
|
|
334
|
+
return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
|
|
335
|
+
claimConnectOperation(tx, scope, {
|
|
336
|
+
...input,
|
|
337
|
+
authorizeAcquisition: async (locked, attempt) => {
|
|
338
|
+
assertOrganizationIntegrationAllowed(policy, integrationKey);
|
|
339
|
+
await input.authorizeAcquisition?.(locked, attempt);
|
|
340
|
+
},
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
type IntegrationSourceSelection = Record<string, unknown> & {
|
|
346
|
+
id: string;
|
|
347
|
+
syncEnabled: boolean;
|
|
348
|
+
readPolicy: "allow" | "ask" | "block";
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** Compare server-bound selections, not provider classification. Removing a
|
|
352
|
+
* source, disabling sync, or narrowing its read permission acquires no authority.
|
|
353
|
+
* Generation/timestamp bookkeeping does not turn an unchanged save into setup. */
|
|
354
|
+
export function integrationSourceSelectionRequiresAcquisition(
|
|
355
|
+
previous: readonly IntegrationSourceSelection[],
|
|
356
|
+
requested: readonly IntegrationSourceSelection[],
|
|
357
|
+
): boolean {
|
|
358
|
+
const rank = { block: 0, ask: 1, allow: 2 };
|
|
359
|
+
const config = (source: IntegrationSourceSelection) => {
|
|
360
|
+
const {
|
|
361
|
+
selectedAt: _selectedAt,
|
|
362
|
+
configGeneration: _generation,
|
|
363
|
+
syncEnabled: _enabled,
|
|
364
|
+
readPolicy: _readPolicy,
|
|
365
|
+
...binding
|
|
366
|
+
} = source;
|
|
367
|
+
return stableJson(binding);
|
|
368
|
+
};
|
|
369
|
+
return requested.some((source) => {
|
|
370
|
+
const existing = previous.find((candidate) => candidate.id === source.id);
|
|
371
|
+
return (
|
|
372
|
+
!existing ||
|
|
373
|
+
(!existing.syncEnabled && source.syncEnabled) ||
|
|
374
|
+
rank[source.readPolicy] > rank[existing.readPolicy] ||
|
|
375
|
+
config(existing) !== config(source)
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function finishOAuthAcquisition(
|
|
381
|
+
db: Database,
|
|
382
|
+
scope: Parameters<typeof finishConnectOperation>[1],
|
|
383
|
+
input: Parameters<typeof finishConnectOperation>[2],
|
|
384
|
+
integrationKey: string | null,
|
|
385
|
+
) {
|
|
386
|
+
return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
|
|
387
|
+
finishConnectOperation(tx, scope, {
|
|
388
|
+
...input,
|
|
389
|
+
authorizeAcquisition: async (locked, attempt) => {
|
|
390
|
+
assertOrganizationIntegrationAllowed(policy, integrationKey);
|
|
391
|
+
await input.authorizeAcquisition?.(locked, attempt);
|
|
392
|
+
},
|
|
393
|
+
}),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
315
397
|
export async function startMcpOAuth(
|
|
316
398
|
deps: OAuthClientDeps,
|
|
317
399
|
context: OAuthStartContext,
|
|
318
400
|
): Promise<OAuthStartResponse> {
|
|
401
|
+
await withOrganizationIntegrationAcquisition(
|
|
402
|
+
deps.db,
|
|
403
|
+
context,
|
|
404
|
+
[context.integrationKey ?? "custom:mcp"],
|
|
405
|
+
async () => {},
|
|
406
|
+
);
|
|
319
407
|
const deadline = new OAuthStartDeadline(deps.oauthStartDeadlineMs ?? OAUTH_START_DEADLINE_MS);
|
|
320
408
|
try {
|
|
321
409
|
return await startMcpOAuthWithinDeadline(deps, context, deadline);
|
|
@@ -375,12 +463,9 @@ async function startMcpOAuthWithinDeadline(
|
|
|
375
463
|
})) ??
|
|
376
464
|
DEFAULT_OAUTH_PROFILE;
|
|
377
465
|
assertOAuthStartProfile(settings, context.payload, mcpUrl, profile);
|
|
378
|
-
//
|
|
379
|
-
// the fence that existed before #1240; an explicit non-personal value is the
|
|
380
|
-
// only thing rejected. Everything else defaults to workspace as before.
|
|
466
|
+
// Catalog defaults are setup preferences; an explicit ownership always wins.
|
|
381
467
|
const requestedOwnership: ConnectionOwnership =
|
|
382
468
|
context.payload.ownership ?? defaultOwnershipFor(profile);
|
|
383
|
-
assertOwnershipAllowed(profile, requestedOwnership);
|
|
384
469
|
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
385
470
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
386
471
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
@@ -404,15 +489,11 @@ async function startMcpOAuthWithinDeadline(
|
|
|
404
489
|
const ownership = existing
|
|
405
490
|
? ownershipForConnection(existing.subjectId, context.subjectId)
|
|
406
491
|
: requestedOwnership;
|
|
407
|
-
// A reconnect of a legacy row must not renew an ownership the profile no
|
|
408
|
-
// longer allows (e.g. shared authority over one human's hosted-Slack grant).
|
|
409
|
-
assertOwnershipAllowed(profile, ownership);
|
|
410
492
|
// Only a managed human can own a personal Connection: personal-authority
|
|
411
493
|
// execution resolves through a delegation snapshot frozen on a human's causal
|
|
412
494
|
// turn, and migration 0256 can mint the `user` authority scope only for a
|
|
413
|
-
// subject that holds an active organization membership.
|
|
414
|
-
//
|
|
415
|
-
// silently downgrading to the workspace ownership those profiles forbid.
|
|
495
|
+
// subject that holds an active organization membership. An explicit personal
|
|
496
|
+
// choice is never silently downgraded to workspace ownership.
|
|
416
497
|
assertConnectionOwnershipAllowedForPrincipal(ownership, context.personalOwnershipAllowed);
|
|
417
498
|
|
|
418
499
|
const discovery = await discoverMcpOAuth(mcpUrl, settings, deadline);
|
|
@@ -439,10 +520,12 @@ async function startMcpOAuthWithinDeadline(
|
|
|
439
520
|
context.payload.oauthClient,
|
|
440
521
|
profile,
|
|
441
522
|
signal,
|
|
523
|
+
context,
|
|
442
524
|
),
|
|
443
525
|
);
|
|
444
526
|
const key = requireEnvironmentEncryption(settings);
|
|
445
527
|
const state = createSignedState(requireIntegrationsStateSecret(settings), {
|
|
528
|
+
...(context.integrationKey ? { integrationKey: context.integrationKey } : {}),
|
|
446
529
|
...(context.connectAttemptId ? { connectAttemptId: context.connectAttemptId } : {}),
|
|
447
530
|
accountId: context.accountId,
|
|
448
531
|
workspaceId: context.workspaceId,
|
|
@@ -510,32 +593,6 @@ async function startMcpOAuthWithinDeadline(
|
|
|
510
593
|
});
|
|
511
594
|
}
|
|
512
595
|
|
|
513
|
-
/** Ownership fence for the official Gmail profile, keyed by exact MCP URL. */
|
|
514
|
-
export function assertOfficialGmailPersonalOwnership(
|
|
515
|
-
mcpUrl: string,
|
|
516
|
-
ownership: ConnectionOwnership,
|
|
517
|
-
): void {
|
|
518
|
-
const profile = builtInOAuthProfileFor({ mcpUrl });
|
|
519
|
-
if (profile?.key === "official-gmail") {
|
|
520
|
-
assertOwnershipAllowed(profile, ownership);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Slack's hosted MCP issues user tokens only. Sharing one human's grant as
|
|
526
|
-
* workspace authority made every shared agent act as a named employee; the
|
|
527
|
-
* OpenGeni workspace bot owns shared Slack access instead (bot-token search
|
|
528
|
-
* covers public channels, files, and users).
|
|
529
|
-
*/
|
|
530
|
-
export function assertHostedSlackMcpPersonalOwnership(
|
|
531
|
-
hostedSlackMcp: boolean,
|
|
532
|
-
ownership: ConnectionOwnership,
|
|
533
|
-
): void {
|
|
534
|
-
if (hostedSlackMcp) {
|
|
535
|
-
assertOwnershipAllowed(builtInOAuthProfileByKey("hosted-slack-mcp"), ownership);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
596
|
export function isHostedSlackMcpTarget(providerDomain: string, mcpUrl: string): boolean {
|
|
540
597
|
return builtInOAuthProfileFor({ mcpUrl, providerDomain })?.key === "hosted-slack-mcp";
|
|
541
598
|
}
|
|
@@ -627,6 +684,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
627
684
|
): Promise<OAuthCallbackResult> {
|
|
628
685
|
const { db, settings, observability } = deps;
|
|
629
686
|
let state: OAuthStatePayload | null = null;
|
|
687
|
+
let integrationKey: string | null = "custom:mcp";
|
|
630
688
|
let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
|
|
631
689
|
if (!input.state) {
|
|
632
690
|
const error = new OAuthCallbackStageError(
|
|
@@ -644,10 +702,21 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
644
702
|
}
|
|
645
703
|
try {
|
|
646
704
|
state = readOAuthState(input.state, settings);
|
|
705
|
+
integrationKey = state.integrationKey ?? "custom:mcp";
|
|
647
706
|
if (state.connectAttemptId) {
|
|
648
707
|
const stored = await getConnectAttempt(db, state, state.connectAttemptId);
|
|
708
|
+
// Native Gmail setup uses its own provider id, but shares this callback.
|
|
709
|
+
// Bind that id to the exact reviewed Gmail destination before
|
|
710
|
+
// claiming an operation (including receipt replay), not to arbitrary MCP.
|
|
711
|
+
const gmailAttemptMatches =
|
|
712
|
+
stored.attempt.providerId === "gmail" &&
|
|
713
|
+
state.mcpUrl === OFFICIAL_GMAIL_MCP_URL &&
|
|
714
|
+
state.providerDomain === "gmailmcp.googleapis.com" &&
|
|
715
|
+
builtInOAuthProfileFor(state)?.key === "official-gmail" &&
|
|
716
|
+
personalOwnerStateAccepted(state);
|
|
649
717
|
if (
|
|
650
|
-
!["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId)
|
|
718
|
+
(!["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) &&
|
|
719
|
+
!gmailAttemptMatches) ||
|
|
651
720
|
stored.attempt.ownership !== state.ownership ||
|
|
652
721
|
stored.returnUrl !== state.returnUrl
|
|
653
722
|
)
|
|
@@ -657,29 +726,23 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
657
726
|
operationId: `oauth:${state.nonce}`,
|
|
658
727
|
inputDigest: createHash("sha256").update(input.state).digest("hex"),
|
|
659
728
|
};
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
729
|
+
integrationKey = integrationKeyForConnectProvider(stored.attempt.providerId);
|
|
730
|
+
const claim = await claimOAuthAcquisition(
|
|
731
|
+
db,
|
|
732
|
+
state,
|
|
733
|
+
{
|
|
734
|
+
...connectOperation,
|
|
735
|
+
expectedRevision: stored.attempt.revision,
|
|
736
|
+
authorize: (tx, _attempt, origin) =>
|
|
737
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
738
|
+
},
|
|
739
|
+
integrationKey,
|
|
740
|
+
Boolean(input.code),
|
|
741
|
+
);
|
|
666
742
|
if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true };
|
|
667
743
|
}
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
// longer allows (workspace-owned Gmail or hosted Slack, for example).
|
|
671
|
-
const builtInCallbackProfile = builtInOAuthProfileFor({
|
|
672
|
-
mcpUrl: state.mcpUrl,
|
|
673
|
-
providerDomain: state.providerDomain,
|
|
674
|
-
});
|
|
675
|
-
if (builtInCallbackProfile) {
|
|
676
|
-
assertOwnershipAllowed(builtInCallbackProfile, state.ownership);
|
|
677
|
-
}
|
|
678
|
-
// Only a managed human may own a personal Connection. This callback has no
|
|
679
|
-
// live principal, so it enforces the signed start-time decision; a state
|
|
680
|
-
// minted before that fence existed carries no claim and is refused, which
|
|
681
|
-
// closes the in-flight window across a rolling deploy. The legacy
|
|
682
|
-
// `?? "personal"` decode above therefore cannot land a machine-owned row.
|
|
744
|
+
// The callback enforces the signed start-time principal decision.
|
|
745
|
+
// Workspace ownership requires no personal-owner claim.
|
|
683
746
|
if (!personalOwnerStateAccepted(state)) {
|
|
684
747
|
throw new OAuthCallbackStageError(
|
|
685
748
|
"state_verify",
|
|
@@ -711,24 +774,13 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
711
774
|
reason: "missing_code",
|
|
712
775
|
});
|
|
713
776
|
}
|
|
777
|
+
await withOrganizationIntegrationAcquisition(db, state, [integrationKey], async () => {});
|
|
714
778
|
const consumed = await runCallbackDatabaseStage(
|
|
715
779
|
deadline,
|
|
716
780
|
"state_verify",
|
|
717
781
|
db,
|
|
718
782
|
async (scopedDb) => {
|
|
719
783
|
await requireOAuthCallbackGrant(scopedDb, state!);
|
|
720
|
-
if (!builtInCallbackProfile) {
|
|
721
|
-
// Catalog-declared ownership fences get the same rolling-update
|
|
722
|
-
// treatment as the built-in ones.
|
|
723
|
-
const raw = await getGlobalCatalogOAuthProfile(
|
|
724
|
-
scopedDb,
|
|
725
|
-
state!.workspaceId,
|
|
726
|
-
catalogMcpUrlKey(state!.mcpUrl),
|
|
727
|
-
);
|
|
728
|
-
if (raw !== null) {
|
|
729
|
-
assertOwnershipAllowed(oauthProfileFromCatalog(state!.mcpUrl, raw), state!.ownership);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
784
|
return await consumeIntegrationOAuthStateNonce(scopedDb, {
|
|
733
785
|
accountId: state!.accountId,
|
|
734
786
|
workspaceId: state!.workspaceId,
|
|
@@ -843,38 +895,45 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
843
895
|
};
|
|
844
896
|
if (connectOperation) {
|
|
845
897
|
await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
898
|
+
finishOAuthAcquisition(
|
|
899
|
+
scopedDb,
|
|
900
|
+
state!,
|
|
901
|
+
{
|
|
902
|
+
...connectOperation!,
|
|
903
|
+
authorize: (tx, _attempt, origin) =>
|
|
904
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
905
|
+
commit: async (tx, current) => {
|
|
906
|
+
const connection = await persist(tx);
|
|
907
|
+
if (!connection)
|
|
908
|
+
throw new HTTPException(409, {
|
|
909
|
+
message: "connection changed during OAuth reconnect",
|
|
910
|
+
});
|
|
911
|
+
return {
|
|
912
|
+
...current,
|
|
913
|
+
revision: current.revision + 1,
|
|
914
|
+
state: "complete",
|
|
915
|
+
completionRequirement: "connection",
|
|
916
|
+
credentialsCommitted: true,
|
|
917
|
+
nextAction: { type: "none" },
|
|
918
|
+
account: {
|
|
919
|
+
id: connection.id,
|
|
920
|
+
version: connection.version,
|
|
921
|
+
providerId: current.providerId,
|
|
922
|
+
label: state!.providerDomain,
|
|
923
|
+
ownership: current.ownership,
|
|
924
|
+
status: "connected",
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
},
|
|
872
928
|
},
|
|
873
|
-
|
|
929
|
+
integrationKey,
|
|
930
|
+
),
|
|
874
931
|
);
|
|
875
932
|
return { redirectTo: state.returnUrl!, exactReturn: true };
|
|
876
933
|
}
|
|
877
|
-
const connection = await runCallbackDatabaseStage(deadline, "persist", db,
|
|
934
|
+
const connection = await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
|
|
935
|
+
withOrganizationIntegrationAcquisition(scopedDb, state!, [integrationKey], persist),
|
|
936
|
+
);
|
|
878
937
|
if (!connection) {
|
|
879
938
|
throw new HTTPException(409, {
|
|
880
939
|
message: "connection changed during OAuth reconnect; start again",
|
|
@@ -1206,6 +1265,7 @@ async function registerOAuthClient(
|
|
|
1206
1265
|
manual: OAuthStartRequest["oauthClient"],
|
|
1207
1266
|
profile: OAuthProviderProfile,
|
|
1208
1267
|
signal: AbortSignal,
|
|
1268
|
+
acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
|
|
1209
1269
|
): Promise<OAuthClientRegistration> {
|
|
1210
1270
|
const operator = operatorClientForAs(settings, as);
|
|
1211
1271
|
if (operator) {
|
|
@@ -1220,6 +1280,7 @@ async function registerOAuthClient(
|
|
|
1220
1280
|
redirectUri,
|
|
1221
1281
|
scopes,
|
|
1222
1282
|
signal,
|
|
1283
|
+
acquisition,
|
|
1223
1284
|
);
|
|
1224
1285
|
}
|
|
1225
1286
|
if (selfRegistration === "cimd") {
|
|
@@ -1244,7 +1305,15 @@ async function registerOAuthClient(
|
|
|
1244
1305
|
),
|
|
1245
1306
|
};
|
|
1246
1307
|
}
|
|
1247
|
-
return await getOrCreateDynamicClientRegistration(
|
|
1308
|
+
return await getOrCreateDynamicClientRegistration(
|
|
1309
|
+
db,
|
|
1310
|
+
settings,
|
|
1311
|
+
as,
|
|
1312
|
+
redirectUri,
|
|
1313
|
+
scopes,
|
|
1314
|
+
signal,
|
|
1315
|
+
acquisition,
|
|
1316
|
+
);
|
|
1248
1317
|
}
|
|
1249
1318
|
|
|
1250
1319
|
export function preferredOAuthSelfRegistration(
|
|
@@ -1272,6 +1341,7 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
1272
1341
|
redirectUri: string,
|
|
1273
1342
|
scopes: string[],
|
|
1274
1343
|
signal: AbortSignal,
|
|
1344
|
+
acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
|
|
1275
1345
|
): Promise<OAuthClientRegistration> {
|
|
1276
1346
|
const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
1277
1347
|
if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, as, redirectUri, scopes)) {
|
|
@@ -1292,6 +1362,12 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
1292
1362
|
message: "manual OAuth client credentials are required for this authorization server",
|
|
1293
1363
|
});
|
|
1294
1364
|
}
|
|
1365
|
+
await withOrganizationIntegrationAcquisition(
|
|
1366
|
+
db,
|
|
1367
|
+
acquisition,
|
|
1368
|
+
[acquisition.integrationKey ?? "custom:mcp"],
|
|
1369
|
+
async () => {},
|
|
1370
|
+
);
|
|
1295
1371
|
const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes, signal);
|
|
1296
1372
|
const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
|
|
1297
1373
|
const storeInput = {
|
|
@@ -1303,34 +1379,43 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
1303
1379
|
tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
|
|
1304
1380
|
metadata: registrationMetadata(as, redirectUri, scopes),
|
|
1305
1381
|
};
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1382
|
+
// Registration has already happened outside this transaction. Fence only the
|
|
1383
|
+
// durable client credential; a restriction racing DCR must not persist it.
|
|
1384
|
+
return withOrganizationIntegrationAcquisition(
|
|
1385
|
+
db,
|
|
1386
|
+
acquisition,
|
|
1387
|
+
[acquisition.integrationKey ?? "custom:mcp"],
|
|
1388
|
+
async (tx) => {
|
|
1389
|
+
if (storedClient) {
|
|
1390
|
+
const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
|
|
1391
|
+
...storeInput,
|
|
1392
|
+
expectedClientId: storedClient.clientId,
|
|
1393
|
+
});
|
|
1394
|
+
if (replaced?.clientId === dcr.clientId) {
|
|
1395
|
+
return dcr;
|
|
1396
|
+
}
|
|
1397
|
+
return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
|
|
1398
|
+
}
|
|
1399
|
+
const storedWinner = await storeIntegrationOAuthClient(tx, storeInput);
|
|
1400
|
+
if (storedWinner.clientId === dcr.clientId) {
|
|
1401
|
+
return dcr;
|
|
1402
|
+
}
|
|
1403
|
+
const winner = await loadIntegrationOAuthClient(tx, settings, as.issuer);
|
|
1404
|
+
if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
|
|
1405
|
+
return dcrRegistrationFromStored(winner);
|
|
1406
|
+
}
|
|
1407
|
+
if (winner) {
|
|
1408
|
+
const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
|
|
1409
|
+
...storeInput,
|
|
1410
|
+
expectedClientId: winner.clientId,
|
|
1411
|
+
});
|
|
1412
|
+
if (replaced?.clientId === dcr.clientId) {
|
|
1413
|
+
return dcr;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
|
|
1417
|
+
},
|
|
1418
|
+
);
|
|
1334
1419
|
}
|
|
1335
1420
|
|
|
1336
1421
|
function storedDcrClientSatisfiesPolicy(
|
|
@@ -1641,6 +1726,13 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
1641
1726
|
if (!payload) {
|
|
1642
1727
|
throw new HTTPException(400, { message: "invalid or expired OAuth state" });
|
|
1643
1728
|
}
|
|
1729
|
+
if (
|
|
1730
|
+
payload.integrationKey !== undefined &&
|
|
1731
|
+
payload.integrationKey !== "gmail" &&
|
|
1732
|
+
payload.integrationKey !== "slack-personal"
|
|
1733
|
+
) {
|
|
1734
|
+
throw new HTTPException(400, { message: "invalid OAuth integration identity" });
|
|
1735
|
+
}
|
|
1644
1736
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1645
1737
|
const iat = numberValue(payload.iat);
|
|
1646
1738
|
if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
|
|
@@ -1670,6 +1762,12 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
1670
1762
|
if (discoveryMode === "legacy_2025_03_26_metadata" && resourceParameterSupported) {
|
|
1671
1763
|
throw new HTTPException(400, { message: "invalid legacy OAuth resource parameter state" });
|
|
1672
1764
|
}
|
|
1765
|
+
const ownership = connectionOwnership(payload.ownership);
|
|
1766
|
+
if (!ownership) {
|
|
1767
|
+
throw new HTTPException(400, {
|
|
1768
|
+
message: "missing or invalid OAuth ownership; restart connection setup",
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1673
1771
|
const parsed = {
|
|
1674
1772
|
accountId: requiredString(payload.accountId, "state.accountId"),
|
|
1675
1773
|
workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
|
|
@@ -1692,11 +1790,12 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
1692
1790
|
...(stringValue(payload.connectAttemptId)
|
|
1693
1791
|
? { connectAttemptId: requiredString(payload.connectAttemptId, "state.connectAttemptId") }
|
|
1694
1792
|
: {}),
|
|
1695
|
-
|
|
1696
|
-
// Preserve that meaning for in-flight reconnects during a rolling deploy.
|
|
1697
|
-
ownership: connectionOwnership(payload.ownership) ?? "personal",
|
|
1793
|
+
ownership,
|
|
1698
1794
|
// Absent on a legacy state, which therefore cannot land a personal owner.
|
|
1699
1795
|
personalOwnerVerified: personalOwnerVerifiedInState(payload),
|
|
1796
|
+
...(payload.integrationKey
|
|
1797
|
+
? { integrationKey: payload.integrationKey as "gmail" | "slack-personal" }
|
|
1798
|
+
: {}),
|
|
1700
1799
|
providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
|
|
1701
1800
|
mcpUrl: stringValue(payload.mcpUrl) ?? resource,
|
|
1702
1801
|
resource,
|
|
@@ -16,10 +16,9 @@ import { canonicalProviderDomain } from "./provider-domain";
|
|
|
16
16
|
*
|
|
17
17
|
* Profiles come from two layers:
|
|
18
18
|
*
|
|
19
|
-
* 1. Built-in profiles
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the fences never depend on catalog import state.
|
|
19
|
+
* 1. Built-in profiles pin known authorization servers and protocol quirks.
|
|
20
|
+
* Ownership defaults guide setup; users may choose personal or workspace.
|
|
21
|
+
* Authorization-server pins never depend on catalog import state.
|
|
23
22
|
* 2. A validated `oauthProfile` object on a global catalog row (curated
|
|
24
23
|
* overlay -> importer -> `capability_catalog_items.metadata`). A catalog
|
|
25
24
|
* profile applies only when no built-in matches, and it can only narrow the
|
|
@@ -81,10 +80,8 @@ export type OAuthProviderProfile = {
|
|
|
81
80
|
requireExactMcpUrl?: { url: string; message: string };
|
|
82
81
|
/** Deployment-managed client credentials must be configured (503 otherwise). */
|
|
83
82
|
requireDeploymentClient?: { key: DeploymentManagedClientKey; message: string };
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
/** Exact 422 message when an explicit disallowed ownership is requested. */
|
|
87
|
-
ownershipMessage?: string;
|
|
83
|
+
/** Suggested ownership; an explicit user choice always wins. */
|
|
84
|
+
defaultOwnership?: ConnectionOwnership;
|
|
88
85
|
/** Bind reconnect/dedupe to the exact mcpUrl, not just the provider domain. */
|
|
89
86
|
exactMcpBinding: boolean;
|
|
90
87
|
/** How an existing connection is chosen for reconnect coalescing. */
|
|
@@ -113,7 +110,6 @@ export type OAuthProviderProfile = {
|
|
|
113
110
|
export const DEFAULT_OAUTH_PROFILE: OAuthProviderProfile = {
|
|
114
111
|
key: "default",
|
|
115
112
|
match: {},
|
|
116
|
-
allowedOwnership: ["workspace", "personal"],
|
|
117
113
|
exactMcpBinding: false,
|
|
118
114
|
connectionSelection: "first_active",
|
|
119
115
|
sendResourceParameter: true,
|
|
@@ -138,9 +134,6 @@ const HOSTED_SLACK_PROFILE: OAuthProviderProfile = {
|
|
|
138
134
|
key: "slack",
|
|
139
135
|
message: "Slack MCP OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
|
|
140
136
|
},
|
|
141
|
-
allowedOwnership: ["personal"],
|
|
142
|
-
ownershipMessage:
|
|
143
|
-
"Slack's hosted MCP connection is personal only; install the OpenGeni Slack bot for workspace access",
|
|
144
137
|
exactMcpBinding: true,
|
|
145
138
|
connectionSelection: "canonical_personal",
|
|
146
139
|
authorizationServer: {
|
|
@@ -156,9 +149,7 @@ const HOSTED_SLACK_PROFILE: OAuthProviderProfile = {
|
|
|
156
149
|
const OFFICIAL_GMAIL_PROFILE: OAuthProviderProfile = {
|
|
157
150
|
key: "official-gmail",
|
|
158
151
|
match: { mcpUrls: [OFFICIAL_GMAIL_MCP_URL] },
|
|
159
|
-
|
|
160
|
-
ownershipMessage:
|
|
161
|
-
"Gmail connections are personal only; each workspace member must connect their own mailbox",
|
|
152
|
+
defaultOwnership: "personal",
|
|
162
153
|
exactMcpBinding: true,
|
|
163
154
|
connectionSelection: "first_active",
|
|
164
155
|
authorizationServer: {
|
|
@@ -285,24 +276,7 @@ export function builtInOAuthProfileFor(input: {
|
|
|
285
276
|
|
|
286
277
|
/** Ownership an omitted request defaults to under a profile. */
|
|
287
278
|
export function defaultOwnershipFor(profile: OAuthProviderProfile): ConnectionOwnership {
|
|
288
|
-
return profile.
|
|
289
|
-
? "personal"
|
|
290
|
-
: "workspace";
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Rejects an ownership outside the profile's allowed set with its exact message. */
|
|
294
|
-
export function assertOwnershipAllowed(
|
|
295
|
-
profile: OAuthProviderProfile,
|
|
296
|
-
ownership: ConnectionOwnership,
|
|
297
|
-
): void {
|
|
298
|
-
if (profile.allowedOwnership.includes(ownership)) {
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
throw new HTTPException(422, {
|
|
302
|
-
message:
|
|
303
|
-
profile.ownershipMessage ??
|
|
304
|
-
`this integration allows only ${profile.allowedOwnership.join(" or ")} connections`,
|
|
305
|
-
});
|
|
279
|
+
return profile.defaultOwnership ?? "workspace";
|
|
306
280
|
}
|
|
307
281
|
|
|
308
282
|
/** Enforces a profile's authorization-server origin pins with its exact message. */
|
|
@@ -375,10 +349,7 @@ export const catalogOAuthProfileSchema = z
|
|
|
375
349
|
pinnedIssuerOrigins: z.array(z.string().url()).min(1).optional(),
|
|
376
350
|
pinnedEndpointOrigins: z.array(z.string().url()).min(1).optional(),
|
|
377
351
|
sendResourceParameter: z.boolean().optional(),
|
|
378
|
-
|
|
379
|
-
.array(z.enum(["personal", "workspace"]))
|
|
380
|
-
.min(1)
|
|
381
|
-
.optional(),
|
|
352
|
+
defaultOwnership: z.enum(["personal", "workspace"]).optional(),
|
|
382
353
|
requestedScopes: z.array(z.string().min(1)).min(1).optional(),
|
|
383
354
|
extraAuthorizeParams: z
|
|
384
355
|
.record(z.string(), z.string())
|
|
@@ -425,7 +396,7 @@ export function catalogMcpUrlKey(value: string): string {
|
|
|
425
396
|
*
|
|
426
397
|
* A present-but-invalid profile fails closed with a 422: the row's operator
|
|
427
398
|
* declared constraints, and silently degrading to the default profile would
|
|
428
|
-
* drop an
|
|
399
|
+
* drop an origin pin on a JSON typo.
|
|
429
400
|
*/
|
|
430
401
|
export function oauthProfileFromCatalog(mcpUrl: string, raw: unknown): OAuthProviderProfile {
|
|
431
402
|
const parsed = catalogOAuthProfileSchema.safeParse(raw);
|
|
@@ -451,13 +422,7 @@ export function oauthProfileFromCatalog(mcpUrl: string, raw: unknown): OAuthProv
|
|
|
451
422
|
return {
|
|
452
423
|
key: `catalog:${mcpUrl}`,
|
|
453
424
|
match: { mcpUrls: [mcpUrl] },
|
|
454
|
-
|
|
455
|
-
...(data.allowedOwnership && !data.allowedOwnership.includes("workspace")
|
|
456
|
-
? {
|
|
457
|
-
ownershipMessage:
|
|
458
|
-
"this integration allows only personal connections; each workspace member must connect their own account",
|
|
459
|
-
}
|
|
460
|
-
: {}),
|
|
425
|
+
...(data.defaultOwnership ? { defaultOwnership: data.defaultOwnership } : {}),
|
|
461
426
|
exactMcpBinding: Boolean(data.exactMcpUrl),
|
|
462
427
|
...(data.exactMcpUrl
|
|
463
428
|
? {
|