@opengeni/api-router 2.11.3 → 2.12.3-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/app.js +1 -1
- package/dist/{chunk-5X3XM6CE.js → chunk-4AQMKVQM.js} +17727 -14427
- package/dist/chunk-4AQMKVQM.js.map +1 -0
- package/dist/connection-ownership.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +4 -0
- package/dist/integrations/connect-authority.d.ts +1 -0
- package/dist/integrations/connect-callback-return.d.ts +6 -0
- package/dist/integrations/fiken.d.ts +7 -0
- package/dist/integrations/github-app-connect.d.ts +47 -0
- package/dist/integrations/github-installation-proof.d.ts +3 -0
- package/dist/integrations/github-lens-connect.d.ts +153 -0
- package/dist/integrations/google-drive.d.ts +4 -0
- package/dist/integrations/oauth-client.d.ts +4 -0
- package/dist/integrations/oauth-return-path.d.ts +1 -0
- package/dist/integrations/personal-github.d.ts +2 -0
- package/dist/integrations/provider-oauth.d.ts +11 -1
- package/dist/integrations/slack-install.d.ts +20 -0
- package/dist/integrations/social-oauth.d.ts +2 -0
- package/dist/mcp/scheduled-task-view.d.ts +3 -3
- package/dist/routes/api-integrations.d.ts +14 -0
- package/dist/routes/connect.d.ts +5 -0
- package/dist/routes/external-identity-links.d.ts +6 -0
- package/dist/routes/host-mcp-bindings.d.ts +5 -0
- package/dist/sandbox/rematerialize.d.ts +1 -1
- package/dist/workspace-tool-gateway.d.ts +1 -0
- package/package.json +19 -19
- package/src/app.ts +22 -2
- package/src/connection-ownership.ts +14 -1
- package/src/index.ts +2 -0
- package/src/integrations/atlassian.ts +143 -39
- package/src/integrations/connect-authority.ts +2 -0
- package/src/integrations/connect-callback-return.ts +86 -0
- package/src/integrations/fiken.ts +222 -40
- package/src/integrations/github-app-connect.ts +389 -0
- package/src/integrations/github-installation-proof.ts +60 -0
- package/src/integrations/github-lens-connect.ts +97 -0
- package/src/integrations/google-drive.ts +152 -47
- package/src/integrations/oauth-client.ts +180 -84
- package/src/integrations/oauth-return-path.ts +18 -0
- package/src/integrations/personal-github.ts +146 -52
- package/src/integrations/provider-oauth.ts +132 -21
- package/src/integrations/slack-install.ts +83 -0
- package/src/integrations/social-oauth.ts +142 -1
- package/src/mcp/documents.ts +1 -1
- package/src/mcp/server.ts +64 -17
- package/src/routes/api-integrations.ts +74 -64
- package/src/routes/connect.ts +1869 -0
- package/src/routes/connections.ts +263 -221
- package/src/routes/documents.ts +2 -2
- package/src/routes/external-identity-links.ts +200 -0
- package/src/routes/github.ts +39 -64
- package/src/routes/host-mcp-bindings.ts +122 -0
- package/src/routes/organization-memberships.ts +23 -0
- package/src/routes/organization-sessions.ts +2 -2
- package/src/routes/personal-github.ts +8 -1
- package/src/routes/pr-review-github.ts +30 -3
- package/src/routes/scheduled-tasks.ts +29 -4
- package/src/routes/sessions.ts +61 -32
- package/src/routes/social.ts +5 -1
- package/src/routes/supergrok.ts +90 -3
- package/src/routes/workspaces.ts +16 -0
- package/src/sandbox/rematerialize.ts +33 -1
- package/src/workspace-tool-gateway.ts +136 -24
- package/dist/chunk-5X3XM6CE.js.map +0 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
3
|
+
import { requireConnectOwnerAuthority } from "./connect-authority";
|
|
2
4
|
import { personalGitHubOAuthCallbackUrl, type Settings } from "@opengeni/config";
|
|
3
5
|
import {
|
|
4
6
|
PERSONAL_GITHUB_AUTHORIZATION_URL,
|
|
@@ -14,22 +16,24 @@ import {
|
|
|
14
16
|
isPersonalGitHubConnection,
|
|
15
17
|
} from "@opengeni/contracts/personal-github";
|
|
16
18
|
import {
|
|
17
|
-
|
|
19
|
+
externalActorContinuationForAuthorization,
|
|
18
20
|
requireEnvironmentEncryption,
|
|
19
21
|
type AccessGrantAuthorization,
|
|
20
22
|
type ApiRouteDeps,
|
|
21
23
|
} from "@opengeni/core";
|
|
22
24
|
import {
|
|
23
25
|
consumeIntegrationOAuthStateNonce,
|
|
26
|
+
claimConnectOperation,
|
|
27
|
+
finishConnectOperation,
|
|
28
|
+
getConnectAttempt,
|
|
29
|
+
type Database,
|
|
24
30
|
ConnectionDisconnectGenerationError,
|
|
25
31
|
ConnectionDisconnectIdempotencyError,
|
|
26
32
|
decryptEnvironmentValue,
|
|
27
33
|
disconnectConnectionIdempotently,
|
|
28
34
|
encryptEnvironmentValue,
|
|
29
35
|
getConnectionMetadata,
|
|
30
|
-
getWorkspaceGrant,
|
|
31
36
|
listConnectionsMetadata,
|
|
32
|
-
namedSubjectHasLiveWorkspaceAuthority,
|
|
33
37
|
persistProviderOAuthConnection,
|
|
34
38
|
} from "@opengeni/db";
|
|
35
39
|
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
@@ -53,6 +57,8 @@ const GITHUB_REQUEST_TIMEOUT_MS = 10_000;
|
|
|
53
57
|
const GITHUB_API_VERSION = "2022-11-28";
|
|
54
58
|
|
|
55
59
|
type PersonalGitHubOAuthState = {
|
|
60
|
+
connectAttemptId?: string;
|
|
61
|
+
externalContinuation?: ExternalActorContinuation;
|
|
56
62
|
accountId: string;
|
|
57
63
|
workspaceId: string;
|
|
58
64
|
subjectId: string;
|
|
@@ -205,6 +211,7 @@ export async function startPersonalGitHubOAuth(
|
|
|
205
211
|
workspaceId: string;
|
|
206
212
|
connectionId?: string;
|
|
207
213
|
returnPath?: string;
|
|
214
|
+
connectAttemptId?: string;
|
|
208
215
|
},
|
|
209
216
|
) {
|
|
210
217
|
const oauth = requirePersonalGitHubOAuthSettings(deps.settings);
|
|
@@ -232,6 +239,15 @@ export async function startPersonalGitHubOAuth(
|
|
|
232
239
|
workspaceId: input.workspaceId,
|
|
233
240
|
subjectId: grant.subjectId,
|
|
234
241
|
kind: PERSONAL_GITHUB_OAUTH_STATE_KIND,
|
|
242
|
+
...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
|
|
243
|
+
...(externalActorContinuationForAuthorization(input.access)
|
|
244
|
+
? {
|
|
245
|
+
encryptedExternalContinuation: encryptEnvironmentValue(
|
|
246
|
+
key,
|
|
247
|
+
JSON.stringify(externalActorContinuationForAuthorization(input.access)),
|
|
248
|
+
),
|
|
249
|
+
}
|
|
250
|
+
: {}),
|
|
235
251
|
[PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: true,
|
|
236
252
|
canonicalManagedHumanSession: input.access.canonicalManagedHumanSession,
|
|
237
253
|
returnPath,
|
|
@@ -264,12 +280,35 @@ export async function completePersonalGitHubOAuthCallback(
|
|
|
264
280
|
error?: string;
|
|
265
281
|
requestUrl: string;
|
|
266
282
|
},
|
|
267
|
-
): Promise<{ redirectTo: string }> {
|
|
283
|
+
): Promise<{ redirectTo: string; exactReturn?: boolean }> {
|
|
268
284
|
const apiBaseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
269
285
|
const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/u, "") ?? apiBaseUrl;
|
|
270
286
|
let state: PersonalGitHubOAuthState | null = null;
|
|
287
|
+
let exactReturnUrl: string | undefined;
|
|
288
|
+
let operation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
|
|
271
289
|
try {
|
|
272
290
|
state = readPersonalGitHubOAuthState(input.state, deps.settings);
|
|
291
|
+
if (state.connectAttemptId) {
|
|
292
|
+
const stored = await getConnectAttempt(deps.db, state, state.connectAttemptId);
|
|
293
|
+
if (
|
|
294
|
+
stored.attempt.providerId !== "github-personal" ||
|
|
295
|
+
stored.attempt.ownership !== "personal"
|
|
296
|
+
)
|
|
297
|
+
throw new PersonalGitHubCallbackError("invalid_state");
|
|
298
|
+
exactReturnUrl = stored.returnUrl;
|
|
299
|
+
operation = {
|
|
300
|
+
attemptId: state.connectAttemptId,
|
|
301
|
+
operationId: `oauth:${state.nonce}`,
|
|
302
|
+
inputDigest: createHash("sha256").update(input.state!).digest("hex"),
|
|
303
|
+
};
|
|
304
|
+
const claim = await claimConnectOperation(deps.db, state, {
|
|
305
|
+
...operation,
|
|
306
|
+
expectedRevision: stored.attempt.revision,
|
|
307
|
+
authorize: (tx, _attempt, origin) =>
|
|
308
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
309
|
+
});
|
|
310
|
+
if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
311
|
+
}
|
|
273
312
|
await requirePersonalGitHubCallbackGrant(deps, state);
|
|
274
313
|
const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
|
|
275
314
|
accountId: state.accountId,
|
|
@@ -280,6 +319,25 @@ export async function completePersonalGitHubOAuthCallback(
|
|
|
280
319
|
now: new Date(),
|
|
281
320
|
});
|
|
282
321
|
if (!consumed) throw new PersonalGitHubCallbackError("state_replayed");
|
|
322
|
+
if (operation && (input.error || !input.code)) {
|
|
323
|
+
await finishConnectOperation(deps.db, state, {
|
|
324
|
+
...operation,
|
|
325
|
+
authorize: (tx, _attempt, origin) =>
|
|
326
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
327
|
+
commit: async (_tx, current) => ({
|
|
328
|
+
...current,
|
|
329
|
+
revision: current.revision + 1,
|
|
330
|
+
state: input.error === "access_denied" ? "cancelled" : "failed",
|
|
331
|
+
nextAction: { type: "none" },
|
|
332
|
+
error: {
|
|
333
|
+
code: input.error ? "provider_denied" : "missing_code",
|
|
334
|
+
message: "Authorization was not completed. Start a new connection attempt.",
|
|
335
|
+
retryable: false,
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
return { redirectTo: exactReturnUrl!, exactReturn: true };
|
|
340
|
+
}
|
|
283
341
|
if (input.error) throw new PersonalGitHubCallbackError("provider_denied");
|
|
284
342
|
if (!input.code) throw new PersonalGitHubCallbackError("missing_code");
|
|
285
343
|
|
|
@@ -376,35 +434,73 @@ export async function completePersonalGitHubOAuthCallback(
|
|
|
376
434
|
scope_parameter_supported: false,
|
|
377
435
|
}),
|
|
378
436
|
);
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
437
|
+
const acceptedState = state;
|
|
438
|
+
const persist = (tx: Database) => {
|
|
439
|
+
// Capture the validated immutable callback state for delayed persistence.
|
|
440
|
+
// eslint-disable-next-line no-shadow
|
|
441
|
+
const state = acceptedState;
|
|
442
|
+
return persistProviderOAuthConnection(tx, {
|
|
443
|
+
accountId: state.accountId,
|
|
444
|
+
workspaceId: state.workspaceId,
|
|
445
|
+
subjectId: state.subjectId,
|
|
446
|
+
visibleToSubjectId: state.subjectId,
|
|
447
|
+
providerDomain: PERSONAL_GITHUB_PROVIDER_DOMAIN,
|
|
448
|
+
kind: "oauth2",
|
|
449
|
+
status: "active",
|
|
450
|
+
credentialEncrypted,
|
|
451
|
+
grantedScopes: token.scopes,
|
|
452
|
+
expiresAt: token.expiresAt,
|
|
453
|
+
metadata,
|
|
454
|
+
createdBySubjectId: state.subjectId,
|
|
455
|
+
updatedBySubjectId: state.subjectId,
|
|
456
|
+
credentialRole: PERSONAL_GITHUB_CREDENTIAL_ROLE,
|
|
457
|
+
providerFamily: PERSONAL_GITHUB_PROVIDER_FAMILY,
|
|
458
|
+
providerPrincipalId: identity.id,
|
|
459
|
+
requireLiveUserAuthority: true,
|
|
460
|
+
requiredLiveUserPermission: "connections:write",
|
|
461
|
+
// The external lane has passed its own owning-user proof; it does not
|
|
462
|
+
// impersonate a native login. Both use the same personal workspace anchor.
|
|
463
|
+
allowCanonicalPersonalWorkspaceOwner:
|
|
464
|
+
state.canonicalManagedHumanSession || Boolean(state.externalContinuation),
|
|
465
|
+
authorize: (locked) => requireConnectOwnerAuthority(locked, state, "connections:write"),
|
|
466
|
+
exclusiveProviderPrincipalPerOwner: true,
|
|
467
|
+
preserveExistingMetadataKeys: ["credentialBindingId", "connectedAt"],
|
|
468
|
+
...(state.connectionId
|
|
469
|
+
? {
|
|
470
|
+
requestedConnectionId: state.connectionId,
|
|
471
|
+
requestedConnectionVersion: state.connectionVersion,
|
|
472
|
+
}
|
|
473
|
+
: {}),
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
if (operation) {
|
|
477
|
+
await finishConnectOperation(deps.db, acceptedState, {
|
|
478
|
+
...operation,
|
|
479
|
+
authorize: (tx, _attempt, origin) =>
|
|
480
|
+
requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
|
|
481
|
+
commit: async (tx, current) => {
|
|
482
|
+
const connection = await persist(tx);
|
|
483
|
+
if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
|
|
484
|
+
return {
|
|
485
|
+
...current,
|
|
486
|
+
revision: current.revision + 1,
|
|
487
|
+
state: "complete",
|
|
488
|
+
credentialsCommitted: true,
|
|
489
|
+
nextAction: { type: "none" },
|
|
490
|
+
account: {
|
|
491
|
+
id: connection.id,
|
|
492
|
+
version: connection.version,
|
|
493
|
+
providerId: "github-personal",
|
|
494
|
+
label: identity.login,
|
|
495
|
+
ownership: "personal",
|
|
496
|
+
status: "connected",
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
return { redirectTo: exactReturnUrl!, exactReturn: true };
|
|
502
|
+
}
|
|
503
|
+
const connection = await persist(deps.db);
|
|
408
504
|
if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
|
|
409
505
|
return {
|
|
410
506
|
redirectTo: personalGitHubCallbackReturnUrl(returnBaseUrl, state.returnPath, "success", {
|
|
@@ -412,6 +508,7 @@ export async function completePersonalGitHubOAuthCallback(
|
|
|
412
508
|
}),
|
|
413
509
|
};
|
|
414
510
|
} catch (error) {
|
|
511
|
+
if (exactReturnUrl) return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
415
512
|
return {
|
|
416
513
|
redirectTo: personalGitHubCallbackReturnUrl(
|
|
417
514
|
returnBaseUrl,
|
|
@@ -468,6 +565,21 @@ function readPersonalGitHubOAuthState(
|
|
|
468
565
|
subjectId: requiredString(payload.subjectId),
|
|
469
566
|
personalOwnerVerified: personalOwnerVerifiedInState(payload),
|
|
470
567
|
canonicalManagedHumanSession: payload.canonicalManagedHumanSession === true,
|
|
568
|
+
...(typeof payload.connectAttemptId === "string"
|
|
569
|
+
? { connectAttemptId: payload.connectAttemptId }
|
|
570
|
+
: {}),
|
|
571
|
+
...(typeof payload.encryptedExternalContinuation === "string"
|
|
572
|
+
? {
|
|
573
|
+
externalContinuation: ExternalActorContinuation.parse(
|
|
574
|
+
JSON.parse(
|
|
575
|
+
decryptEnvironmentValue(
|
|
576
|
+
requireEnvironmentEncryption(settings),
|
|
577
|
+
payload.encryptedExternalContinuation,
|
|
578
|
+
),
|
|
579
|
+
),
|
|
580
|
+
),
|
|
581
|
+
}
|
|
582
|
+
: {}),
|
|
471
583
|
returnPath: personalGitHubReturnPath(workspaceId, requiredString(payload.returnPath)),
|
|
472
584
|
encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier),
|
|
473
585
|
oauthEnvironment: requiredString(payload.oauthEnvironment),
|
|
@@ -491,25 +603,7 @@ async function requirePersonalGitHubCallbackGrant(
|
|
|
491
603
|
) {
|
|
492
604
|
throw new PersonalGitHubCallbackError("not_authorized");
|
|
493
605
|
}
|
|
494
|
-
|
|
495
|
-
if (
|
|
496
|
-
grant &&
|
|
497
|
-
grant.accountId === state.accountId &&
|
|
498
|
-
hasPermission(grant.permissions, "connections:write")
|
|
499
|
-
) {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
if (
|
|
503
|
-
state.canonicalManagedHumanSession &&
|
|
504
|
-
(await namedSubjectHasLiveWorkspaceAuthority(deps.db, {
|
|
505
|
-
accountId: state.accountId,
|
|
506
|
-
workspaceId: state.workspaceId,
|
|
507
|
-
subjectId: state.subjectId,
|
|
508
|
-
}))
|
|
509
|
-
) {
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
throw new PersonalGitHubCallbackError("not_authorized");
|
|
606
|
+
await requireConnectOwnerAuthority(deps.db, state, "connections:write");
|
|
513
607
|
}
|
|
514
608
|
|
|
515
609
|
async function exchangePersonalGitHubAuthorizationCode(
|
|
@@ -18,16 +18,18 @@ import {
|
|
|
18
18
|
type ApiIntegrationOAuthStartRequest,
|
|
19
19
|
type ConnectionOwnership,
|
|
20
20
|
} from "@opengeni/contracts";
|
|
21
|
-
import {
|
|
21
|
+
import { requireEnvironmentEncryption, type ApiRouteDeps } from "@opengeni/core";
|
|
22
|
+
import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
22
23
|
import {
|
|
23
24
|
consumeIntegrationOAuthStateNonce,
|
|
24
25
|
decryptEnvironmentValue,
|
|
25
26
|
encryptEnvironmentValue,
|
|
26
27
|
getConnectionMetadata,
|
|
27
|
-
getWorkspaceGrant,
|
|
28
28
|
loadConnectionCredentialForBroker,
|
|
29
29
|
persistProviderOAuthConnection,
|
|
30
|
-
|
|
30
|
+
getConnectAttempt,
|
|
31
|
+
claimConnectOperation,
|
|
32
|
+
finishConnectOperation,
|
|
31
33
|
} from "@opengeni/db";
|
|
32
34
|
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
33
35
|
import {
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
type FetchLike,
|
|
38
40
|
} from "@opengeni/network";
|
|
39
41
|
import { HTTPException } from "hono/http-exception";
|
|
42
|
+
import { requireConnectOwnerAuthority } from "./connect-authority";
|
|
40
43
|
|
|
41
44
|
import {
|
|
42
45
|
assertConnectionOwnershipAllowedForPrincipal,
|
|
@@ -69,6 +72,8 @@ type ProviderOAuthClient = {
|
|
|
69
72
|
};
|
|
70
73
|
|
|
71
74
|
type ProviderOAuthState = {
|
|
75
|
+
connectAttemptId?: string;
|
|
76
|
+
externalContinuation?: ExternalActorContinuation;
|
|
72
77
|
accountId: string;
|
|
73
78
|
workspaceId: string;
|
|
74
79
|
subjectId: string;
|
|
@@ -137,6 +142,8 @@ export async function startApiIntegrationProviderOAuth(
|
|
|
137
142
|
* route from the live authenticated principal, never inferred here.
|
|
138
143
|
*/
|
|
139
144
|
personalOwnershipAllowed: boolean;
|
|
145
|
+
externalContinuation?: ExternalActorContinuation;
|
|
146
|
+
connectAttemptId?: string;
|
|
140
147
|
requestUrl: string;
|
|
141
148
|
payload: ApiIntegrationOAuthStartRequest;
|
|
142
149
|
},
|
|
@@ -230,6 +237,15 @@ export async function startApiIntegrationProviderOAuth(
|
|
|
230
237
|
clientId: client.clientId,
|
|
231
238
|
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
|
|
232
239
|
returnPath,
|
|
240
|
+
...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
|
|
241
|
+
...(input.externalContinuation
|
|
242
|
+
? {
|
|
243
|
+
encryptedExternalContinuation: encryptEnvironmentValue(
|
|
244
|
+
key,
|
|
245
|
+
JSON.stringify(ExternalActorContinuation.parse(input.externalContinuation)),
|
|
246
|
+
),
|
|
247
|
+
}
|
|
248
|
+
: {}),
|
|
233
249
|
...(existing
|
|
234
250
|
? {
|
|
235
251
|
connectionId: existing.id,
|
|
@@ -270,12 +286,35 @@ export async function completeApiIntegrationProviderOAuth(
|
|
|
270
286
|
error?: string | undefined;
|
|
271
287
|
requestUrl: string;
|
|
272
288
|
},
|
|
273
|
-
): Promise<{ redirectTo: string }> {
|
|
289
|
+
): Promise<{ redirectTo: string; exactReturn?: boolean }> {
|
|
274
290
|
const apiBaseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
275
291
|
const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/, "") ?? apiBaseUrl;
|
|
276
292
|
let state: ProviderOAuthState | null = null;
|
|
293
|
+
let exactReturnUrl: string | undefined;
|
|
294
|
+
let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
|
|
277
295
|
try {
|
|
278
296
|
state = readProviderOAuthState(input.state, deps.settings);
|
|
297
|
+
if (state.connectAttemptId) {
|
|
298
|
+
const stored = await getConnectAttempt(deps.db, state, state.connectAttemptId);
|
|
299
|
+
if (
|
|
300
|
+
stored.attempt.providerId !== state.definitionId ||
|
|
301
|
+
stored.attempt.ownership !== state.ownership
|
|
302
|
+
)
|
|
303
|
+
throw new ProviderOAuthCallbackError("state_invalid");
|
|
304
|
+
exactReturnUrl = stored.returnUrl;
|
|
305
|
+
connectOperation = {
|
|
306
|
+
attemptId: state.connectAttemptId,
|
|
307
|
+
operationId: `oauth:${state.nonce}`,
|
|
308
|
+
inputDigest: createHash("sha256").update(input.state!).digest("hex"),
|
|
309
|
+
};
|
|
310
|
+
const claim = await claimConnectOperation(deps.db, state, {
|
|
311
|
+
...connectOperation,
|
|
312
|
+
expectedRevision: stored.attempt.revision,
|
|
313
|
+
authorize: (tx, _attempt, origin) =>
|
|
314
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
315
|
+
});
|
|
316
|
+
if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
317
|
+
}
|
|
279
318
|
requireProviderOAuthOwner(state);
|
|
280
319
|
await requireProviderOAuthGrant(deps, state);
|
|
281
320
|
const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
|
|
@@ -287,6 +326,25 @@ export async function completeApiIntegrationProviderOAuth(
|
|
|
287
326
|
now: new Date(),
|
|
288
327
|
});
|
|
289
328
|
if (!consumed) throw new ProviderOAuthCallbackError("state_replayed");
|
|
329
|
+
if (connectOperation && (input.error || !input.code)) {
|
|
330
|
+
await finishConnectOperation(deps.db, state, {
|
|
331
|
+
...connectOperation,
|
|
332
|
+
authorize: (tx, _attempt, origin) =>
|
|
333
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
334
|
+
commit: async (_tx, current) => ({
|
|
335
|
+
...current,
|
|
336
|
+
revision: current.revision + 1,
|
|
337
|
+
state: input.error === "access_denied" ? "cancelled" : "failed",
|
|
338
|
+
nextAction: { type: "none" },
|
|
339
|
+
error: {
|
|
340
|
+
code: input.error ? "provider_denied" : "missing_code",
|
|
341
|
+
message: "Authorization was not completed. Start a new connection attempt.",
|
|
342
|
+
retryable: false,
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
return { redirectTo: exactReturnUrl!, exactReturn: true };
|
|
347
|
+
}
|
|
290
348
|
if (input.error) throw new ProviderOAuthCallbackError("provider_denied");
|
|
291
349
|
if (!input.code) throw new ProviderOAuthCallbackError("missing_code");
|
|
292
350
|
|
|
@@ -389,7 +447,8 @@ export async function completeApiIntegrationProviderOAuth(
|
|
|
389
447
|
]),
|
|
390
448
|
verifiedAt: new Date().toISOString(),
|
|
391
449
|
});
|
|
392
|
-
const
|
|
450
|
+
const persistenceInput: import("@opengeni/db").PersistProviderOAuthConnectionInput = {
|
|
451
|
+
authorize: (tx: import("@opengeni/db").Database) => requireConnectOwnerAuthority(tx, state!),
|
|
393
452
|
accountId: state.accountId,
|
|
394
453
|
workspaceId: state.workspaceId,
|
|
395
454
|
subjectId: ownerSubjectId,
|
|
@@ -412,7 +471,36 @@ export async function completeApiIntegrationProviderOAuth(
|
|
|
412
471
|
requestedConnectionVersion: state.connectionVersion,
|
|
413
472
|
}
|
|
414
473
|
: {}),
|
|
415
|
-
}
|
|
474
|
+
};
|
|
475
|
+
const persist = (database: import("@opengeni/db").Database) =>
|
|
476
|
+
persistProviderOAuthConnection(database, persistenceInput);
|
|
477
|
+
if (connectOperation) {
|
|
478
|
+
await finishConnectOperation(deps.db, state, {
|
|
479
|
+
...connectOperation,
|
|
480
|
+
authorize: (tx, _attempt, origin) =>
|
|
481
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
482
|
+
commit: async (tx, current) => {
|
|
483
|
+
const connection = await persist(tx);
|
|
484
|
+
if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
|
|
485
|
+
return {
|
|
486
|
+
...current,
|
|
487
|
+
revision: current.revision + 1,
|
|
488
|
+
state: "connected_but_incomplete",
|
|
489
|
+
credentialsCommitted: true,
|
|
490
|
+
nextAction: { type: "none" },
|
|
491
|
+
account: {
|
|
492
|
+
id: connection.id,
|
|
493
|
+
providerId: current.providerId,
|
|
494
|
+
label: identity.displayName ?? identity.email ?? identity.principalId,
|
|
495
|
+
ownership: current.ownership,
|
|
496
|
+
status: "connected",
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
return { redirectTo: exactReturnUrl!, exactReturn: true };
|
|
502
|
+
}
|
|
503
|
+
const connection = await persist(deps.db);
|
|
416
504
|
if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
|
|
417
505
|
return {
|
|
418
506
|
redirectTo: providerOAuthReturnUrl(returnBaseUrl, state.returnPath, "success", {
|
|
@@ -423,6 +511,7 @@ export async function completeApiIntegrationProviderOAuth(
|
|
|
423
511
|
}),
|
|
424
512
|
};
|
|
425
513
|
} catch (error) {
|
|
514
|
+
if (exactReturnUrl !== undefined) return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
426
515
|
return {
|
|
427
516
|
redirectTo: providerOAuthReturnUrl(
|
|
428
517
|
returnBaseUrl,
|
|
@@ -465,6 +554,26 @@ function requiredDefinition(id: string): IntegrationDefinition {
|
|
|
465
554
|
return definition;
|
|
466
555
|
}
|
|
467
556
|
|
|
557
|
+
/** Configuration readiness only: consent and installation are still separate. */
|
|
558
|
+
export function curatedOAuthReadiness(
|
|
559
|
+
settings: Settings,
|
|
560
|
+
definition: IntegrationDefinition,
|
|
561
|
+
): { configured: boolean; ownership: ConnectionOwnership[] } {
|
|
562
|
+
const ownership = [
|
|
563
|
+
...providerOAuthProfile(definition, integrationDefinitionProviderDomain(definition))
|
|
564
|
+
.allowedOwnership,
|
|
565
|
+
];
|
|
566
|
+
try {
|
|
567
|
+
if (!settings.integrationsEnabled) return { configured: false, ownership };
|
|
568
|
+
requireEnvironmentEncryption(settings);
|
|
569
|
+
requireIntegrationsStateSecret(settings);
|
|
570
|
+
providerClientForDefinition(settings, definition);
|
|
571
|
+
return { configured: true, ownership };
|
|
572
|
+
} catch {
|
|
573
|
+
return { configured: false, ownership };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
468
577
|
function providerClientForDefinition(
|
|
469
578
|
settings: Settings,
|
|
470
579
|
definition: IntegrationDefinition,
|
|
@@ -594,6 +703,21 @@ function readProviderOAuthState(raw: string | undefined, settings: Settings): Pr
|
|
|
594
703
|
}
|
|
595
704
|
return {
|
|
596
705
|
accountId: requiredStateString(payload.accountId),
|
|
706
|
+
...(optionalString(payload.connectAttemptId)
|
|
707
|
+
? { connectAttemptId: optionalString(payload.connectAttemptId)! }
|
|
708
|
+
: {}),
|
|
709
|
+
...(typeof payload.encryptedExternalContinuation === "string"
|
|
710
|
+
? {
|
|
711
|
+
externalContinuation: ExternalActorContinuation.parse(
|
|
712
|
+
JSON.parse(
|
|
713
|
+
decryptEnvironmentValue(
|
|
714
|
+
requireEnvironmentEncryption(settings),
|
|
715
|
+
payload.encryptedExternalContinuation,
|
|
716
|
+
),
|
|
717
|
+
),
|
|
718
|
+
),
|
|
719
|
+
}
|
|
720
|
+
: {}),
|
|
597
721
|
workspaceId: requiredStateString(payload.workspaceId),
|
|
598
722
|
subjectId: requiredStateString(payload.subjectId),
|
|
599
723
|
ownership,
|
|
@@ -742,23 +866,10 @@ async function requireProviderOAuthGrant(
|
|
|
742
866
|
deps: ApiRouteDeps,
|
|
743
867
|
state: Pick<
|
|
744
868
|
ProviderOAuthState,
|
|
745
|
-
"accountId" | "workspaceId" | "subjectId" | "personalOwnerVerified"
|
|
869
|
+
"accountId" | "workspaceId" | "subjectId" | "personalOwnerVerified" | "externalContinuation"
|
|
746
870
|
>,
|
|
747
871
|
): Promise<void> {
|
|
748
|
-
|
|
749
|
-
const grant =
|
|
750
|
-
membershipGrant?.accountId === state.accountId
|
|
751
|
-
? membershipGrant
|
|
752
|
-
: state.personalOwnerVerified
|
|
753
|
-
? await resolveNamedManagedPersonalWorkspaceGrant(deps.db, state)
|
|
754
|
-
: null;
|
|
755
|
-
if (
|
|
756
|
-
!grant ||
|
|
757
|
-
grant.accountId !== state.accountId ||
|
|
758
|
-
!hasPermission(grant.permissions, "connections:write")
|
|
759
|
-
) {
|
|
760
|
-
throw new ProviderOAuthCallbackError("connection_conflict");
|
|
761
|
-
}
|
|
872
|
+
await deps.db.transaction((tx) => requireConnectOwnerAuthority(tx, state));
|
|
762
873
|
}
|
|
763
874
|
|
|
764
875
|
/**
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OpenGeniSlackBotInstallStart,
|
|
3
|
+
OPENGENI_SLACK_BOT_REQUESTED_SCOPES,
|
|
4
|
+
} from "@opengeni/contracts";
|
|
5
|
+
import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
6
|
+
import {
|
|
7
|
+
isOpenGeniSlackBotConnection,
|
|
8
|
+
requireEnvironmentEncryption,
|
|
9
|
+
type ApiRouteDeps,
|
|
10
|
+
} from "@opengeni/core";
|
|
11
|
+
import { encryptEnvironmentValue, getConnectionMetadata } from "@opengeni/db";
|
|
12
|
+
import { createSignedState } from "@opengeni/github";
|
|
13
|
+
import { HTTPException } from "hono/http-exception";
|
|
14
|
+
import {
|
|
15
|
+
integrationBaseUrl,
|
|
16
|
+
oauthStateTtlMs,
|
|
17
|
+
requireIntegrationsStateSecret,
|
|
18
|
+
} from "./oauth-client";
|
|
19
|
+
|
|
20
|
+
export function requireOpenGeniSlackOAuthSettings(settings: ApiRouteDeps["settings"]) {
|
|
21
|
+
const clientId = settings.slackClientId?.trim();
|
|
22
|
+
const clientSecret = settings.slackClientSecret?.trim();
|
|
23
|
+
if (!clientId || !clientSecret || !settings.slackSigningSecret?.trim())
|
|
24
|
+
throw new HTTPException(503, {
|
|
25
|
+
message:
|
|
26
|
+
"OpenGeni Slack bot installation requires OPENGENI_SLACK_CLIENT_ID, OPENGENI_SLACK_CLIENT_SECRET, and OPENGENI_SLACK_SIGNING_SECRET",
|
|
27
|
+
});
|
|
28
|
+
return { clientId, clientSecret };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Only authenticated route admission may invoke this workspace-bot starter.
|
|
32
|
+
* Hosted personal Slack MCP deliberately uses its own OAuth profile. */
|
|
33
|
+
export async function startSlackBotInstall(
|
|
34
|
+
deps: ApiRouteDeps,
|
|
35
|
+
input: {
|
|
36
|
+
accountId: string;
|
|
37
|
+
workspaceId: string;
|
|
38
|
+
subjectId: string;
|
|
39
|
+
requestUrl: string;
|
|
40
|
+
connectionId?: string;
|
|
41
|
+
connectAttemptId?: string;
|
|
42
|
+
externalContinuation?: ExternalActorContinuation;
|
|
43
|
+
},
|
|
44
|
+
) {
|
|
45
|
+
const slack = requireOpenGeniSlackOAuthSettings(deps.settings);
|
|
46
|
+
const key = requireEnvironmentEncryption(deps.settings);
|
|
47
|
+
const existing = input.connectionId
|
|
48
|
+
? await getConnectionMetadata(deps.db, input.workspaceId, input.connectionId, input.subjectId)
|
|
49
|
+
: null;
|
|
50
|
+
if (input.connectionId && !existing)
|
|
51
|
+
throw new HTTPException(404, { message: "connection not found" });
|
|
52
|
+
if (existing && !isOpenGeniSlackBotConnection(existing))
|
|
53
|
+
throw new HTTPException(422, {
|
|
54
|
+
message: "connectionId is not an OpenGeni Slack bot connection",
|
|
55
|
+
});
|
|
56
|
+
const redirectUri = `${integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl)}/v1/integrations/slack/callback`;
|
|
57
|
+
const state = createSignedState(requireIntegrationsStateSecret(deps.settings), {
|
|
58
|
+
kind: "slack_bot_install",
|
|
59
|
+
accountId: input.accountId,
|
|
60
|
+
workspaceId: input.workspaceId,
|
|
61
|
+
subjectId: input.subjectId,
|
|
62
|
+
returnPath: `/workspaces/${input.workspaceId}/capabilities`,
|
|
63
|
+
...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
|
|
64
|
+
...(input.externalContinuation
|
|
65
|
+
? {
|
|
66
|
+
encryptedExternalContinuation: encryptEnvironmentValue(
|
|
67
|
+
key,
|
|
68
|
+
JSON.stringify(ExternalActorContinuation.parse(input.externalContinuation)),
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
: {}),
|
|
72
|
+
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
73
|
+
});
|
|
74
|
+
const url = new URL("https://slack.com/oauth/v2/authorize");
|
|
75
|
+
url.searchParams.set("client_id", slack.clientId);
|
|
76
|
+
url.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUESTED_SCOPES.join(","));
|
|
77
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
78
|
+
url.searchParams.set("state", state);
|
|
79
|
+
return OpenGeniSlackBotInstallStart.parse({
|
|
80
|
+
authorizationUrl: url.toString(),
|
|
81
|
+
expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
|
|
82
|
+
});
|
|
83
|
+
}
|