@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,7 +1,20 @@
|
|
|
1
1
|
import type { ConnectionOwnership } from "@opengeni/contracts";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
externalActorContinuationForAuthorization,
|
|
4
|
+
type AccessGrantAuthorization,
|
|
5
|
+
} from "@opengeni/core";
|
|
3
6
|
import { HTTPException } from "hono/http-exception";
|
|
4
7
|
|
|
8
|
+
/** A verified external actor must not enter a native callback that has no
|
|
9
|
+
* corresponding key/identity reauthorization proof. Remove at an entry point
|
|
10
|
+
* only when that provider's signed continuation and commit fence are wired. */
|
|
11
|
+
export function requireLegacyOAuthActor(access: AccessGrantAuthorization): void {
|
|
12
|
+
if (externalActorContinuationForAuthorization(access))
|
|
13
|
+
throw new HTTPException(422, {
|
|
14
|
+
message: "This provider does not yet support external-user OAuth continuation",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
19
|
* Who may own a *personal* Connection.
|
|
7
20
|
*
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type Observability,
|
|
28
28
|
} from "@opengeni/observability";
|
|
29
29
|
import { createObjectStorage } from "@opengeni/storage";
|
|
30
|
+
import { createRemoteMcpCredentialsPort } from "@opengeni/core/remote-mcp-credentials";
|
|
30
31
|
import { isArtifactRuntimeConfigured } from "@opengeni/artifact-tool/runtime/development";
|
|
31
32
|
import {
|
|
32
33
|
resolveCatalogSettings,
|
|
@@ -419,6 +420,7 @@ export async function startApi(
|
|
|
419
420
|
}
|
|
420
421
|
const { app, routeDeps } = createAppComposition({
|
|
421
422
|
settings,
|
|
423
|
+
connectionCredentials: createRemoteMcpCredentialsPort(settings),
|
|
422
424
|
db: dbClient.db,
|
|
423
425
|
bus,
|
|
424
426
|
workflowClient: workflowClient.client,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
4
|
+
import { requireConnectOwnerAuthority } from "./connect-authority";
|
|
2
5
|
import type { AccessGrant, ScheduledTask, ScheduledTaskScheduleSpec } from "@opengeni/contracts";
|
|
3
6
|
import {
|
|
4
7
|
ATLASSIAN_CREDENTIAL_LABEL,
|
|
@@ -24,7 +27,6 @@ import {
|
|
|
24
27
|
import {
|
|
25
28
|
captureScheduledTaskRestoreState,
|
|
26
29
|
createValidatedScheduledTask,
|
|
27
|
-
hasPermission,
|
|
28
30
|
manualScheduledTaskTriggerWorkflowId,
|
|
29
31
|
requireEnvironmentEncryption,
|
|
30
32
|
syncCreatedScheduledTask,
|
|
@@ -37,6 +39,11 @@ import {
|
|
|
37
39
|
ConnectionDisconnectGenerationError,
|
|
38
40
|
ConnectionDisconnectIdempotencyError,
|
|
39
41
|
consumeIntegrationOAuthStateNonce,
|
|
42
|
+
claimConnectOperation,
|
|
43
|
+
finishConnectOperation,
|
|
44
|
+
getConnectAttempt,
|
|
45
|
+
decryptEnvironmentValue,
|
|
46
|
+
type Database,
|
|
40
47
|
createConnection,
|
|
41
48
|
deauthorizeKnowledgeSourceRetrieval,
|
|
42
49
|
disconnectConnectionIdempotently,
|
|
@@ -44,7 +51,6 @@ import {
|
|
|
44
51
|
getConnectionMetadata,
|
|
45
52
|
getKnowledgeSourceByExternalIdentityForSyncAuthority,
|
|
46
53
|
getKnowledgeSourceForSyncAuthority,
|
|
47
|
-
getWorkspaceGrant,
|
|
48
54
|
listKnowledgeSourceSyncTasksForConnection,
|
|
49
55
|
loadConnectionCredentialForBroker,
|
|
50
56
|
recordKnowledgeLifecycleEvent,
|
|
@@ -87,6 +93,8 @@ const ATLASSIAN_REQUEST_TIMEOUT_MS = 15_000;
|
|
|
87
93
|
const ATLASSIAN_RETURN_PATH = (workspaceId: string) => `/workspaces/${workspaceId}/capabilities`;
|
|
88
94
|
|
|
89
95
|
type AtlassianOAuthState = {
|
|
96
|
+
connectAttemptId?: string;
|
|
97
|
+
externalContinuation?: ExternalActorContinuation;
|
|
90
98
|
accountId: string;
|
|
91
99
|
workspaceId: string;
|
|
92
100
|
subjectId: string;
|
|
@@ -115,6 +123,8 @@ export async function startAtlassianOAuth(
|
|
|
115
123
|
subjectId: string;
|
|
116
124
|
requestUrl: string;
|
|
117
125
|
payload: AtlassianOAuthStartRequest;
|
|
126
|
+
connectAttemptId?: string;
|
|
127
|
+
externalContinuation?: ExternalActorContinuation;
|
|
118
128
|
},
|
|
119
129
|
): Promise<AtlassianOAuthStartResponse> {
|
|
120
130
|
const oauth = requireAtlassianSettings(deps.settings);
|
|
@@ -138,6 +148,15 @@ export async function startAtlassianOAuth(
|
|
|
138
148
|
workspaceId: input.workspaceId,
|
|
139
149
|
subjectId: input.subjectId,
|
|
140
150
|
kind: ATLASSIAN_OAUTH_STATE_KIND,
|
|
151
|
+
...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
|
|
152
|
+
...(input.externalContinuation
|
|
153
|
+
? {
|
|
154
|
+
encryptedExternalContinuation: encryptEnvironmentValue(
|
|
155
|
+
requireEnvironmentEncryption(deps.settings),
|
|
156
|
+
JSON.stringify(ExternalActorContinuation.parse(input.externalContinuation)),
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
: {}),
|
|
141
160
|
// The route admits only a managed human, so reaching here is the proof; the
|
|
142
161
|
// callback has no live principal and enforces exactly this claim.
|
|
143
162
|
[PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: true,
|
|
@@ -166,12 +185,32 @@ export async function completeAtlassianOAuthCallback(
|
|
|
166
185
|
error?: string | undefined;
|
|
167
186
|
requestUrl: string;
|
|
168
187
|
},
|
|
169
|
-
): Promise<{ redirectTo: string }> {
|
|
188
|
+
): Promise<{ redirectTo: string; exactReturn?: boolean }> {
|
|
170
189
|
const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
171
190
|
const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/, "") ?? baseUrl;
|
|
172
191
|
let state: AtlassianOAuthState | null = null;
|
|
192
|
+
let exactReturnUrl: string | undefined;
|
|
193
|
+
let operation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
|
|
173
194
|
try {
|
|
174
195
|
state = readAtlassianOAuthState(input.state, deps.settings);
|
|
196
|
+
if (state.connectAttemptId) {
|
|
197
|
+
const stored = await getConnectAttempt(deps.db, state, state.connectAttemptId);
|
|
198
|
+
if (stored.attempt.providerId !== "atlassian" || stored.attempt.ownership !== "personal")
|
|
199
|
+
throw new AtlassianCallbackError("connection_conflict");
|
|
200
|
+
exactReturnUrl = stored.returnUrl;
|
|
201
|
+
operation = {
|
|
202
|
+
attemptId: state.connectAttemptId,
|
|
203
|
+
operationId: `oauth:${state.nonce}`,
|
|
204
|
+
inputDigest: createHash("sha256").update(input.state!).digest("hex"),
|
|
205
|
+
};
|
|
206
|
+
const claim = await claimConnectOperation(deps.db, state, {
|
|
207
|
+
...operation,
|
|
208
|
+
expectedRevision: stored.attempt.revision,
|
|
209
|
+
authorize: (tx, _attempt, origin) =>
|
|
210
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
211
|
+
});
|
|
212
|
+
if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
213
|
+
}
|
|
175
214
|
await requireCallbackGrant(deps, state);
|
|
176
215
|
const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
|
|
177
216
|
accountId: state.accountId,
|
|
@@ -182,6 +221,25 @@ export async function completeAtlassianOAuthCallback(
|
|
|
182
221
|
now: new Date(),
|
|
183
222
|
});
|
|
184
223
|
if (!consumed) throw new AtlassianCallbackError("state_reused");
|
|
224
|
+
if (operation && (input.error || !input.code)) {
|
|
225
|
+
await finishConnectOperation(deps.db, state, {
|
|
226
|
+
...operation,
|
|
227
|
+
authorize: (tx, _attempt, origin) =>
|
|
228
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
229
|
+
commit: async (_tx, current) => ({
|
|
230
|
+
...current,
|
|
231
|
+
revision: current.revision + 1,
|
|
232
|
+
state: input.error === "access_denied" ? "cancelled" : "failed",
|
|
233
|
+
nextAction: { type: "none" },
|
|
234
|
+
error: {
|
|
235
|
+
code: input.error ? "provider_denied" : "missing_code",
|
|
236
|
+
message: "Authorization was not completed. Start a new connection attempt.",
|
|
237
|
+
retryable: false,
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
return { redirectTo: exactReturnUrl!, exactReturn: true };
|
|
242
|
+
}
|
|
185
243
|
if (input.error) throw new AtlassianCallbackError("provider_denied");
|
|
186
244
|
if (!input.code) throw new AtlassianCallbackError("missing_code");
|
|
187
245
|
|
|
@@ -262,39 +320,77 @@ export async function completeAtlassianOAuthCallback(
|
|
|
262
320
|
: {}),
|
|
263
321
|
selectedSources: previousMetadata?.selectedSources ?? [],
|
|
264
322
|
});
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
323
|
+
const acceptedState = state;
|
|
324
|
+
const persist = (tx: Database) => {
|
|
325
|
+
// Capture the validated immutable callback state for delayed persistence.
|
|
326
|
+
// eslint-disable-next-line no-shadow
|
|
327
|
+
const state = acceptedState;
|
|
328
|
+
return existing
|
|
329
|
+
? updateConnection(tx, {
|
|
330
|
+
workspaceId: state.workspaceId,
|
|
331
|
+
connectionId: existing.id,
|
|
332
|
+
visibleToSubjectId: state.subjectId,
|
|
333
|
+
expectedVersion: existing.version,
|
|
334
|
+
subjectId: state.subjectId,
|
|
335
|
+
providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
|
|
336
|
+
kind: "oauth2",
|
|
337
|
+
status: "active",
|
|
338
|
+
credentialEncrypted,
|
|
339
|
+
grantedScopes: token.scopes,
|
|
340
|
+
expiresAt: token.expiresAt,
|
|
341
|
+
metadata,
|
|
342
|
+
updatedBySubjectId: state.subjectId,
|
|
343
|
+
})
|
|
344
|
+
: createConnection(tx, {
|
|
345
|
+
accountId: state.accountId,
|
|
346
|
+
workspaceId: state.workspaceId,
|
|
347
|
+
subjectId: state.subjectId,
|
|
348
|
+
providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
|
|
349
|
+
kind: "oauth2",
|
|
350
|
+
credentialEncrypted,
|
|
351
|
+
grantedScopes: token.scopes,
|
|
352
|
+
expiresAt: token.expiresAt,
|
|
353
|
+
metadata,
|
|
354
|
+
createdBySubjectId: state.subjectId,
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
if (operation) {
|
|
358
|
+
await finishConnectOperation(deps.db, acceptedState, {
|
|
359
|
+
...operation,
|
|
360
|
+
authorize: (tx, _attempt, origin) =>
|
|
361
|
+
requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
|
|
362
|
+
commit: async (tx, current) => {
|
|
363
|
+
const connection = await persist(tx);
|
|
364
|
+
if (!connection) throw new AtlassianCallbackError("connection_conflict");
|
|
365
|
+
return {
|
|
366
|
+
...current,
|
|
367
|
+
revision: current.revision + 1,
|
|
368
|
+
state: "complete",
|
|
369
|
+
credentialsCommitted: true,
|
|
370
|
+
nextAction: { type: "none" },
|
|
371
|
+
account: {
|
|
372
|
+
id: connection.id,
|
|
373
|
+
version: connection.version,
|
|
374
|
+
providerId: "atlassian",
|
|
375
|
+
label: profile.displayName ?? "Atlassian",
|
|
376
|
+
ownership: "personal",
|
|
377
|
+
status: "connected",
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
return { redirectTo: exactReturnUrl!, exactReturn: true };
|
|
383
|
+
}
|
|
384
|
+
const connection = await deps.db.transaction(async (tx) => {
|
|
385
|
+
await requireCallbackGrant({ ...deps, db: tx }, acceptedState);
|
|
386
|
+
return persist(tx);
|
|
387
|
+
});
|
|
293
388
|
if (!connection) throw new AtlassianCallbackError("connection_conflict");
|
|
294
389
|
return {
|
|
295
390
|
redirectTo: returnUrl(returnBaseUrl, state.returnPath, "connected", connection.id),
|
|
296
391
|
};
|
|
297
392
|
} catch (error) {
|
|
393
|
+
if (exactReturnUrl) return { redirectTo: exactReturnUrl, exactReturn: true };
|
|
298
394
|
return {
|
|
299
395
|
redirectTo: returnUrl(
|
|
300
396
|
returnBaseUrl,
|
|
@@ -1521,6 +1617,21 @@ function readAtlassianOAuthState(raw: string | undefined, settings: Settings): A
|
|
|
1521
1617
|
workspaceId,
|
|
1522
1618
|
subjectId,
|
|
1523
1619
|
personalOwnerVerified: personalOwnerVerifiedInState(payload),
|
|
1620
|
+
...(typeof payload.connectAttemptId === "string"
|
|
1621
|
+
? { connectAttemptId: payload.connectAttemptId }
|
|
1622
|
+
: {}),
|
|
1623
|
+
...(typeof payload.encryptedExternalContinuation === "string"
|
|
1624
|
+
? {
|
|
1625
|
+
externalContinuation: ExternalActorContinuation.parse(
|
|
1626
|
+
JSON.parse(
|
|
1627
|
+
decryptEnvironmentValue(
|
|
1628
|
+
requireEnvironmentEncryption(settings),
|
|
1629
|
+
payload.encryptedExternalContinuation,
|
|
1630
|
+
),
|
|
1631
|
+
),
|
|
1632
|
+
),
|
|
1633
|
+
}
|
|
1634
|
+
: {}),
|
|
1524
1635
|
returnPath,
|
|
1525
1636
|
...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
|
|
1526
1637
|
nonce: requiredString(payload.nonce, "state.nonce"),
|
|
@@ -1544,14 +1655,7 @@ async function requireCallbackGrant(deps: ApiRouteDeps, state: AtlassianOAuthSta
|
|
|
1544
1655
|
message: personalOnlyConnectionPrincipalMessage("Atlassian"),
|
|
1545
1656
|
});
|
|
1546
1657
|
}
|
|
1547
|
-
|
|
1548
|
-
if (
|
|
1549
|
-
!grant ||
|
|
1550
|
-
grant.accountId !== state.accountId ||
|
|
1551
|
-
!hasPermission(grant.permissions, "connections:write")
|
|
1552
|
-
) {
|
|
1553
|
-
throw new HTTPException(403, { message: "Atlassian OAuth subject no longer has access" });
|
|
1554
|
-
}
|
|
1658
|
+
await requireConnectOwnerAuthority(deps.db, state, "connections:write");
|
|
1555
1659
|
}
|
|
1556
1660
|
|
|
1557
1661
|
function requireAtlassianSettings(settings: Settings) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
3
|
+
import { getConnectAttempt } from "@opengeni/db";
|
|
4
|
+
import { readSignedState } from "@opengeni/github";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
const callbacks = new Set([
|
|
8
|
+
"/v1/integrations/slack/callback",
|
|
9
|
+
"/v1/integrations/fiken/callback",
|
|
10
|
+
"/v1/integrations/atlassian/callback",
|
|
11
|
+
"/v1/integrations/google-drive/callback",
|
|
12
|
+
"/v1/integrations/oauth/callback",
|
|
13
|
+
"/v1/integrations/provider-oauth/callback",
|
|
14
|
+
"/v1/integrations/github-personal/oauth/callback",
|
|
15
|
+
"/v1/social/oauth/callback",
|
|
16
|
+
"/v1/github/oauth/callback",
|
|
17
|
+
"/v1/github/setup",
|
|
18
|
+
"/v1/github/install/callback",
|
|
19
|
+
"/v1/pr-review/github/oauth/callback",
|
|
20
|
+
"/v1/pr-review/github/setup",
|
|
21
|
+
"/v1/pr-review/github/install/callback",
|
|
22
|
+
]);
|
|
23
|
+
const locator = z.object({
|
|
24
|
+
accountId: z.string().uuid(),
|
|
25
|
+
workspaceId: z.string().uuid(),
|
|
26
|
+
subjectId: z.string().min(1).max(1024),
|
|
27
|
+
connectAttemptId: z.string().uuid(),
|
|
28
|
+
iat: z.number().int().nonnegative(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Navigation-only recovery. Recently expired signed state can identify the host's saved
|
|
32
|
+
* destination, but cannot authorize exchange, persistence, or credential use.
|
|
33
|
+
* Normal callbacks independently enforce current time and live authority. */
|
|
34
|
+
export function registerConnectCallbackReturns(app: Hono, deps: ApiRouteDeps) {
|
|
35
|
+
app.use("/v1/*", async (c, next) => {
|
|
36
|
+
if (c.req.method !== "GET" || !callbacks.has(c.req.path)) return next();
|
|
37
|
+
const raw = c.req.query("state");
|
|
38
|
+
if (!raw || raw.length > 32_768) return next();
|
|
39
|
+
let candidate: z.infer<typeof locator>;
|
|
40
|
+
try {
|
|
41
|
+
candidate = locator.parse(
|
|
42
|
+
JSON.parse(Buffer.from(raw.split(".")[0]!, "base64url").toString("utf8")),
|
|
43
|
+
);
|
|
44
|
+
} catch {
|
|
45
|
+
return next();
|
|
46
|
+
}
|
|
47
|
+
const secret =
|
|
48
|
+
c.req.path.startsWith("/v1/github/") || c.req.path.startsWith("/v1/pr-review/github/")
|
|
49
|
+
? deps.githubStateSecret
|
|
50
|
+
: deps.settings.integrationsStateSecret?.trim();
|
|
51
|
+
// Verify at the signed timestamp ONLY for a read of the immutable return
|
|
52
|
+
// destination. Never pass this timestamp to the actual provider callback.
|
|
53
|
+
// Keep recovery useful after a normal provider timeout, without making a
|
|
54
|
+
// signed callback an indefinitely reusable redirect. This does not change
|
|
55
|
+
// the trusted backend's exact destination or provider exchange expiry.
|
|
56
|
+
const now = Math.floor(Date.now() / 1000);
|
|
57
|
+
const recoveryWindow = candidate.iat <= now + 60 && candidate.iat >= now - 24 * 60 * 60;
|
|
58
|
+
const verified =
|
|
59
|
+
secret && recoveryWindow
|
|
60
|
+
? locator.safeParse(readSignedState(raw, secret, candidate.iat))
|
|
61
|
+
: null;
|
|
62
|
+
let destination: string | undefined;
|
|
63
|
+
if (verified?.success) {
|
|
64
|
+
try {
|
|
65
|
+
destination = (
|
|
66
|
+
await getConnectAttempt(deps.db, verified.data, verified.data.connectAttemptId)
|
|
67
|
+
).returnUrl;
|
|
68
|
+
} catch {
|
|
69
|
+
/* Deleted attempts or unavailable storage cannot supply a return. */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await next();
|
|
73
|
+
c.res = destination
|
|
74
|
+
? new Response(null, {
|
|
75
|
+
status: 302,
|
|
76
|
+
headers: { location: destination, "cache-control": "no-store" },
|
|
77
|
+
})
|
|
78
|
+
: new Response(
|
|
79
|
+
"Connection setup could not be completed. Return to the application and start again.",
|
|
80
|
+
{
|
|
81
|
+
status: 400,
|
|
82
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
}
|