@opengeni/api-router 0.21.3 → 0.21.8
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-AMMQI2D3.js → chunk-BESOWOUQ.js} +1172 -752
- package/dist/chunk-BESOWOUQ.js.map +1 -0
- package/dist/http/api-error.d.ts +16 -0
- package/dist/index.js +1 -1
- package/dist/integrations/oauth-client.d.ts +5 -0
- package/dist/workspace-state-export.d.ts +4 -0
- package/dist/workspace-state-projection.d.ts +5 -0
- package/package.json +12 -12
- package/src/app.ts +15 -4
- package/src/http/api-error.ts +25 -0
- package/src/integrations/oauth-client.ts +539 -98
- package/src/integrations/social-oauth.ts +7 -2
- package/src/model-catalog.ts +1 -0
- package/src/routes/connections.ts +2 -2
- package/src/routes/workspace-state.ts +101 -76
- package/src/workspace-state-export.ts +49 -0
- package/src/workspace-state-projection.ts +23 -0
- package/dist/chunk-AMMQI2D3.js.map +0 -1
|
@@ -21,14 +21,17 @@ import {
|
|
|
21
21
|
listConnectionsMetadata,
|
|
22
22
|
loadIntegrationOAuthClient,
|
|
23
23
|
normalizeBearerScheme,
|
|
24
|
+
replaceIntegrationOAuthClientIfCurrent,
|
|
24
25
|
storeIntegrationOAuthClient,
|
|
25
26
|
updateConnection,
|
|
27
|
+
withDatabaseStatementTimeout,
|
|
26
28
|
type Database,
|
|
27
29
|
} from "@opengeni/db";
|
|
28
30
|
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
29
31
|
import {
|
|
30
32
|
DestinationPolicyError,
|
|
31
33
|
OAUTH_MAX_RESPONSE_BYTES,
|
|
34
|
+
RequestDeadlineError,
|
|
32
35
|
isLocalTestEnvironment,
|
|
33
36
|
pinnedFetch,
|
|
34
37
|
readResponseJsonBounded,
|
|
@@ -37,6 +40,7 @@ import {
|
|
|
37
40
|
import { Buffer } from "node:buffer";
|
|
38
41
|
import { createHash, randomBytes } from "node:crypto";
|
|
39
42
|
import { HTTPException } from "hono/http-exception";
|
|
43
|
+
import { ApiHttpError } from "../http/api-error";
|
|
40
44
|
import { canonicalProviderDomain } from "./provider-domain";
|
|
41
45
|
|
|
42
46
|
export const oauthStateTtlMs = 10 * 60 * 1000;
|
|
@@ -49,6 +53,8 @@ type OAuthClientDeps = {
|
|
|
49
53
|
db: Database;
|
|
50
54
|
settings: Settings;
|
|
51
55
|
observability?: Observability | undefined;
|
|
56
|
+
oauthStartDeadlineMs?: number | undefined;
|
|
57
|
+
oauthCallbackDeadlineMs?: number | undefined;
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
export type OAuthStartContext = {
|
|
@@ -131,7 +137,74 @@ type TokenResponse = {
|
|
|
131
137
|
raw: Record<string, unknown>;
|
|
132
138
|
};
|
|
133
139
|
|
|
134
|
-
type OAuthCallbackStage =
|
|
140
|
+
type OAuthCallbackStage =
|
|
141
|
+
| "state_verify"
|
|
142
|
+
| "client_lookup"
|
|
143
|
+
| "token_exchange"
|
|
144
|
+
| "tools_list"
|
|
145
|
+
| "persist";
|
|
146
|
+
|
|
147
|
+
export const OAUTH_START_DEADLINE_MS = 15_000;
|
|
148
|
+
export const OAUTH_CALLBACK_DEADLINE_MS = 30_000;
|
|
149
|
+
const OAUTH_CALLBACK_DB_STATEMENT_TIMEOUT_MS = 5_000;
|
|
150
|
+
|
|
151
|
+
export type OAuthStartStage =
|
|
152
|
+
| "connection_lookup"
|
|
153
|
+
| "mcp_challenge"
|
|
154
|
+
| "protected_resource_metadata"
|
|
155
|
+
| "authorization_server_metadata"
|
|
156
|
+
| "client_registration";
|
|
157
|
+
|
|
158
|
+
class OAuthStartStageError extends Error {
|
|
159
|
+
constructor(
|
|
160
|
+
readonly stage: OAuthStartStage,
|
|
161
|
+
readonly reason: string,
|
|
162
|
+
readonly cause: unknown,
|
|
163
|
+
) {
|
|
164
|
+
super(errorMessage(cause));
|
|
165
|
+
this.name = "OAuthStartStageError";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
class OAuthStartDeadline {
|
|
170
|
+
readonly signal: AbortSignal;
|
|
171
|
+
private readonly controller = new AbortController();
|
|
172
|
+
private readonly timer: ReturnType<typeof setTimeout>;
|
|
173
|
+
|
|
174
|
+
constructor(timeoutMs: number) {
|
|
175
|
+
this.signal = this.controller.signal;
|
|
176
|
+
this.timer = setTimeout(() => this.controller.abort(), timeoutMs);
|
|
177
|
+
this.timer.unref?.();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async run<T>(stage: OAuthStartStage, operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
181
|
+
if (this.signal.aborted) {
|
|
182
|
+
throw new OAuthStartStageError(stage, "timeout", new RequestDeadlineError(stage));
|
|
183
|
+
}
|
|
184
|
+
let removeAbortListener = () => {};
|
|
185
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
186
|
+
const onAbort = () =>
|
|
187
|
+
reject(new OAuthStartStageError(stage, "timeout", new RequestDeadlineError(stage)));
|
|
188
|
+
this.signal.addEventListener("abort", onAbort, { once: true });
|
|
189
|
+
removeAbortListener = () => this.signal.removeEventListener("abort", onAbort);
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
return await Promise.race([operation(this.signal), aborted]);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error instanceof OAuthStartStageError) throw error;
|
|
195
|
+
if (this.signal.aborted || error instanceof RequestDeadlineError) {
|
|
196
|
+
throw new OAuthStartStageError(stage, "timeout", error);
|
|
197
|
+
}
|
|
198
|
+
throw new OAuthStartStageError(stage, oauthStartFailureReason(error), error);
|
|
199
|
+
} finally {
|
|
200
|
+
removeAbortListener();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
dispose(): void {
|
|
205
|
+
clearTimeout(this.timer);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
135
208
|
|
|
136
209
|
class OAuthCallbackStageError extends Error {
|
|
137
210
|
constructor(
|
|
@@ -144,9 +217,83 @@ class OAuthCallbackStageError extends Error {
|
|
|
144
217
|
}
|
|
145
218
|
}
|
|
146
219
|
|
|
220
|
+
class OAuthCallbackDeadline {
|
|
221
|
+
readonly signal: AbortSignal;
|
|
222
|
+
private readonly controller = new AbortController();
|
|
223
|
+
private readonly timer: ReturnType<typeof setTimeout>;
|
|
224
|
+
private readonly expiresAt: number;
|
|
225
|
+
|
|
226
|
+
constructor(timeoutMs: number) {
|
|
227
|
+
this.signal = this.controller.signal;
|
|
228
|
+
this.expiresAt = Date.now() + timeoutMs;
|
|
229
|
+
this.timer = setTimeout(() => this.controller.abort(), timeoutMs);
|
|
230
|
+
this.timer.unref?.();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
remainingMs(): number {
|
|
234
|
+
return Math.max(1, this.expiresAt - Date.now());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async run<T>(
|
|
238
|
+
stage: OAuthCallbackStage,
|
|
239
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
240
|
+
): Promise<T> {
|
|
241
|
+
if (this.signal.aborted) {
|
|
242
|
+
throw new OAuthCallbackStageError(stage, "timeout", new RequestDeadlineError(stage));
|
|
243
|
+
}
|
|
244
|
+
let removeAbortListener = () => {};
|
|
245
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
246
|
+
const onAbort = () =>
|
|
247
|
+
reject(new OAuthCallbackStageError(stage, "timeout", new RequestDeadlineError(stage)));
|
|
248
|
+
this.signal.addEventListener("abort", onAbort, { once: true });
|
|
249
|
+
removeAbortListener = () => this.signal.removeEventListener("abort", onAbort);
|
|
250
|
+
});
|
|
251
|
+
try {
|
|
252
|
+
return await Promise.race([operation(this.signal), aborted]);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error instanceof OAuthCallbackStageError) throw error;
|
|
255
|
+
if (
|
|
256
|
+
this.signal.aborted ||
|
|
257
|
+
error instanceof RequestDeadlineError ||
|
|
258
|
+
isDatabaseStatementTimeout(error)
|
|
259
|
+
) {
|
|
260
|
+
throw new OAuthCallbackStageError(stage, "timeout", error);
|
|
261
|
+
}
|
|
262
|
+
throw new OAuthCallbackStageError(stage, oauthCallbackFailureReason(stage, error), error);
|
|
263
|
+
} finally {
|
|
264
|
+
removeAbortListener();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
dispose(): void {
|
|
269
|
+
clearTimeout(this.timer);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
147
273
|
export async function startMcpOAuth(
|
|
148
274
|
deps: OAuthClientDeps,
|
|
149
275
|
context: OAuthStartContext,
|
|
276
|
+
): Promise<OAuthStartResponse> {
|
|
277
|
+
const deadline = new OAuthStartDeadline(deps.oauthStartDeadlineMs ?? OAUTH_START_DEADLINE_MS);
|
|
278
|
+
try {
|
|
279
|
+
return await startMcpOAuthWithinDeadline(deps, context, deadline);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
const staged =
|
|
282
|
+
error instanceof OAuthStartStageError
|
|
283
|
+
? error
|
|
284
|
+
: new OAuthStartStageError("connection_lookup", oauthStartFailureReason(error), error);
|
|
285
|
+
const providerDomain = safeRequestedProviderDomain(context.payload);
|
|
286
|
+
logOAuthStartFailure(deps.observability, staged, providerDomain);
|
|
287
|
+
throw oauthStartApiError(staged);
|
|
288
|
+
} finally {
|
|
289
|
+
deadline.dispose();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function startMcpOAuthWithinDeadline(
|
|
294
|
+
deps: OAuthClientDeps,
|
|
295
|
+
context: OAuthStartContext,
|
|
296
|
+
deadline: OAuthStartDeadline,
|
|
150
297
|
): Promise<OAuthStartResponse> {
|
|
151
298
|
const { db, settings } = deps;
|
|
152
299
|
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
@@ -169,16 +316,18 @@ export async function startMcpOAuth(
|
|
|
169
316
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
170
317
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
171
318
|
const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
|
|
172
|
-
const existing = await
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
319
|
+
const existing = await deadline.run("connection_lookup", async () =>
|
|
320
|
+
existingOAuthConnectionForStart(db, {
|
|
321
|
+
workspaceId: context.workspaceId,
|
|
322
|
+
subjectId: context.subjectId,
|
|
323
|
+
providerDomain,
|
|
324
|
+
mcpUrl,
|
|
325
|
+
personalSlack,
|
|
326
|
+
connectionId: context.payload.connectionId,
|
|
327
|
+
requestedOwnership: context.payload.ownership,
|
|
328
|
+
newConnectionOwnership: requestedOwnership,
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
182
331
|
if (context.payload.connectionId && !existing) {
|
|
183
332
|
throw new HTTPException(404, { message: "connection not found" });
|
|
184
333
|
}
|
|
@@ -186,7 +335,7 @@ export async function startMcpOAuth(
|
|
|
186
335
|
? ownershipForConnection(existing.subjectId, context.subjectId)
|
|
187
336
|
: requestedOwnership;
|
|
188
337
|
|
|
189
|
-
const discovery = await discoverMcpOAuth(mcpUrl, settings);
|
|
338
|
+
const discovery = await discoverMcpOAuth(mcpUrl, settings, deadline);
|
|
190
339
|
if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
|
|
191
340
|
assertSlackAuthorizationServer(discovery.as);
|
|
192
341
|
}
|
|
@@ -197,14 +346,17 @@ export async function startMcpOAuth(
|
|
|
197
346
|
discovery.challenge.scope,
|
|
198
347
|
discovery.prm.scopesSupported,
|
|
199
348
|
);
|
|
200
|
-
const client = await
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
349
|
+
const client = await deadline.run("client_registration", (signal) =>
|
|
350
|
+
registerOAuthClient(
|
|
351
|
+
db,
|
|
352
|
+
settings,
|
|
353
|
+
discovery.as,
|
|
354
|
+
metadataUrl,
|
|
355
|
+
redirectUri,
|
|
356
|
+
authorizeScopes,
|
|
357
|
+
context.payload.oauthClient,
|
|
358
|
+
signal,
|
|
359
|
+
),
|
|
208
360
|
);
|
|
209
361
|
const key = requireEnvironmentEncryption(settings);
|
|
210
362
|
const state = createSignedState(requireIntegrationsStateSecret(settings), {
|
|
@@ -225,7 +377,9 @@ export async function startMcpOAuth(
|
|
|
225
377
|
clientRegistrationMethod: client.method,
|
|
226
378
|
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
|
|
227
379
|
...(client.method === "manual" && client.clientSecret
|
|
228
|
-
? {
|
|
380
|
+
? {
|
|
381
|
+
encryptedClientSecret: encryptEnvironmentValue(key, client.clientSecret),
|
|
382
|
+
}
|
|
229
383
|
: {}),
|
|
230
384
|
returnPath,
|
|
231
385
|
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
@@ -249,7 +403,30 @@ export async function startMcpOAuth(
|
|
|
249
403
|
|
|
250
404
|
export async function completeMcpOAuthCallback(
|
|
251
405
|
deps: OAuthClientDeps,
|
|
252
|
-
input: {
|
|
406
|
+
input: {
|
|
407
|
+
code?: string | undefined;
|
|
408
|
+
state?: string | undefined;
|
|
409
|
+
requestUrl: string;
|
|
410
|
+
},
|
|
411
|
+
): Promise<OAuthCallbackResult> {
|
|
412
|
+
const deadline = new OAuthCallbackDeadline(
|
|
413
|
+
deps.oauthCallbackDeadlineMs ?? OAUTH_CALLBACK_DEADLINE_MS,
|
|
414
|
+
);
|
|
415
|
+
try {
|
|
416
|
+
return await completeMcpOAuthCallbackWithinDeadline(deps, input, deadline);
|
|
417
|
+
} finally {
|
|
418
|
+
deadline.dispose();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function completeMcpOAuthCallbackWithinDeadline(
|
|
423
|
+
deps: OAuthClientDeps,
|
|
424
|
+
input: {
|
|
425
|
+
code?: string | undefined;
|
|
426
|
+
state?: string | undefined;
|
|
427
|
+
requestUrl: string;
|
|
428
|
+
},
|
|
429
|
+
deadline: OAuthCallbackDeadline,
|
|
253
430
|
): Promise<OAuthCallbackResult> {
|
|
254
431
|
const { db, settings, observability } = deps;
|
|
255
432
|
let state: OAuthStatePayload | null = null;
|
|
@@ -260,32 +437,53 @@ export async function completeMcpOAuthCallback(
|
|
|
260
437
|
new Error("missing OAuth state"),
|
|
261
438
|
);
|
|
262
439
|
logOAuthCallbackFailure(observability, error, state);
|
|
263
|
-
return {
|
|
440
|
+
return {
|
|
441
|
+
redirectTo: callbackReturnPath("/integrations", "error", {
|
|
442
|
+
stage: error.stage,
|
|
443
|
+
reason: error.reason,
|
|
444
|
+
}),
|
|
445
|
+
};
|
|
264
446
|
}
|
|
265
447
|
try {
|
|
266
448
|
state = readOAuthState(input.state, settings);
|
|
267
|
-
await requireOAuthCallbackGrant(db, state);
|
|
268
449
|
if (!input.code) {
|
|
269
450
|
return {
|
|
270
|
-
redirectTo: callbackReturnPath(state.returnPath, "error", {
|
|
451
|
+
redirectTo: callbackReturnPath(state.returnPath, "error", {
|
|
452
|
+
stage: "state_verify",
|
|
453
|
+
reason: "missing_code",
|
|
454
|
+
}),
|
|
271
455
|
};
|
|
272
456
|
}
|
|
273
|
-
const consumed = await
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
457
|
+
const consumed = await runCallbackDatabaseStage(
|
|
458
|
+
deadline,
|
|
459
|
+
"state_verify",
|
|
460
|
+
db,
|
|
461
|
+
async (scopedDb) => {
|
|
462
|
+
await requireOAuthCallbackGrant(scopedDb, state!);
|
|
463
|
+
return await consumeIntegrationOAuthStateNonce(scopedDb, {
|
|
464
|
+
accountId: state!.accountId,
|
|
465
|
+
workspaceId: state!.workspaceId,
|
|
466
|
+
subjectId: state!.subjectId,
|
|
467
|
+
nonce: state!.nonce,
|
|
468
|
+
expiresAt: new Date(state!.iat * 1000 + oauthStateTtlMs),
|
|
469
|
+
now: new Date(),
|
|
470
|
+
});
|
|
471
|
+
},
|
|
472
|
+
);
|
|
281
473
|
if (!consumed) {
|
|
282
|
-
throw new HTTPException(400, {
|
|
474
|
+
throw new HTTPException(400, {
|
|
475
|
+
message: "OAuth state has already been used",
|
|
476
|
+
});
|
|
283
477
|
}
|
|
284
478
|
} catch (error) {
|
|
285
|
-
const staged =
|
|
479
|
+
const staged =
|
|
480
|
+
error instanceof OAuthCallbackStageError
|
|
481
|
+
? error
|
|
482
|
+
: new OAuthCallbackStageError("state_verify", "state_invalid", error);
|
|
286
483
|
logOAuthCallbackFailure(observability, staged, state);
|
|
287
484
|
return {
|
|
288
485
|
redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", {
|
|
486
|
+
stage: staged.stage,
|
|
289
487
|
reason: staged.reason,
|
|
290
488
|
}),
|
|
291
489
|
};
|
|
@@ -297,8 +495,10 @@ export async function completeMcpOAuthCallback(
|
|
|
297
495
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
298
496
|
const key = requireEnvironmentEncryption(settings);
|
|
299
497
|
const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
|
|
300
|
-
const client = await
|
|
301
|
-
|
|
498
|
+
const client = await runCallbackDatabaseStage(deadline, "client_lookup", db, (scopedDb) =>
|
|
499
|
+
clientForState(scopedDb, settings, state),
|
|
500
|
+
);
|
|
501
|
+
const token = await deadline.run("token_exchange", (signal) =>
|
|
302
502
|
exchangeAuthorizationCode(settings, {
|
|
303
503
|
code: input.code!,
|
|
304
504
|
verifier,
|
|
@@ -306,9 +506,16 @@ export async function completeMcpOAuthCallback(
|
|
|
306
506
|
resource: state.resource,
|
|
307
507
|
tokenEndpoint: state.tokenEndpoint,
|
|
308
508
|
client,
|
|
509
|
+
signal,
|
|
309
510
|
}),
|
|
310
511
|
);
|
|
311
|
-
const verification = await verifyMcpToolsListNonFatal(
|
|
512
|
+
const verification = await verifyMcpToolsListNonFatal(
|
|
513
|
+
observability,
|
|
514
|
+
settings,
|
|
515
|
+
state,
|
|
516
|
+
token,
|
|
517
|
+
deadline,
|
|
518
|
+
);
|
|
312
519
|
const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
|
|
313
520
|
const credential = credentialBundle(token, state, client);
|
|
314
521
|
const metadata = {
|
|
@@ -323,10 +530,10 @@ export async function completeMcpOAuthCallback(
|
|
|
323
530
|
...(verification.tools ? { mcpTools: verification.tools } : {}),
|
|
324
531
|
};
|
|
325
532
|
const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
326
|
-
await
|
|
327
|
-
|
|
328
|
-
state
|
|
329
|
-
? updateConnection(
|
|
533
|
+
const connection = await runCallbackDatabaseStage(deadline, "persist", db, async (scopedDb) => {
|
|
534
|
+
await requireOAuthCallbackGrant(scopedDb, state!);
|
|
535
|
+
return state!.connectionId
|
|
536
|
+
? await updateConnection(scopedDb, {
|
|
330
537
|
workspaceId: state.workspaceId,
|
|
331
538
|
connectionId: state.connectionId,
|
|
332
539
|
visibleToSubjectId: state.subjectId,
|
|
@@ -341,7 +548,7 @@ export async function completeMcpOAuthCallback(
|
|
|
341
548
|
metadata,
|
|
342
549
|
updatedBySubjectId: state.subjectId,
|
|
343
550
|
})
|
|
344
|
-
: createConnection(
|
|
551
|
+
: await createConnection(scopedDb, {
|
|
345
552
|
accountId: state.accountId,
|
|
346
553
|
workspaceId: state.workspaceId,
|
|
347
554
|
subjectId: ownerSubjectId,
|
|
@@ -352,8 +559,8 @@ export async function completeMcpOAuthCallback(
|
|
|
352
559
|
expiresAt: token.expiresAt,
|
|
353
560
|
metadata,
|
|
354
561
|
createdBySubjectId: state.subjectId,
|
|
355
|
-
})
|
|
356
|
-
);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
357
564
|
if (!connection) {
|
|
358
565
|
throw new HTTPException(409, {
|
|
359
566
|
message: "connection changed during OAuth reconnect; start again",
|
|
@@ -377,7 +584,12 @@ export async function completeMcpOAuthCallback(
|
|
|
377
584
|
? error
|
|
378
585
|
: new OAuthCallbackStageError("persist", "persist_failed", error);
|
|
379
586
|
logOAuthCallbackFailure(observability, staged, state);
|
|
380
|
-
return {
|
|
587
|
+
return {
|
|
588
|
+
redirectTo: callbackReturnPath(state.returnPath, "error", {
|
|
589
|
+
stage: staged.stage,
|
|
590
|
+
reason: staged.reason,
|
|
591
|
+
}),
|
|
592
|
+
};
|
|
381
593
|
}
|
|
382
594
|
}
|
|
383
595
|
|
|
@@ -421,7 +633,9 @@ function assertPersonalSlackOAuthStart(
|
|
|
421
633
|
});
|
|
422
634
|
}
|
|
423
635
|
if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
|
|
424
|
-
throw new HTTPException(422, {
|
|
636
|
+
throw new HTTPException(422, {
|
|
637
|
+
message: "Slack provider identity does not match slack.com",
|
|
638
|
+
});
|
|
425
639
|
}
|
|
426
640
|
if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
|
|
427
641
|
throw new HTTPException(422, {
|
|
@@ -454,16 +668,17 @@ export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata):
|
|
|
454
668
|
async function discoverMcpOAuth(
|
|
455
669
|
resource: string,
|
|
456
670
|
settings: Settings,
|
|
671
|
+
deadline: OAuthStartDeadline,
|
|
457
672
|
): Promise<{
|
|
458
673
|
challenge: WwwAuthenticateChallenge;
|
|
459
674
|
prm: ProtectedResourceMetadata;
|
|
460
675
|
as: AuthorizationServerMetadata;
|
|
461
676
|
}> {
|
|
462
|
-
const challenge = await
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
challenge.resourceMetadata,
|
|
677
|
+
const challenge = await deadline.run("mcp_challenge", (signal) =>
|
|
678
|
+
probeMcpChallenge(resource, settings, signal),
|
|
679
|
+
);
|
|
680
|
+
const prm = await deadline.run("protected_resource_metadata", (signal) =>
|
|
681
|
+
discoverProtectedResourceMetadata(resource, settings, challenge.resourceMetadata, signal),
|
|
467
682
|
);
|
|
468
683
|
const authorizationServer = prm.authorizationServers[0];
|
|
469
684
|
if (!authorizationServer) {
|
|
@@ -471,7 +686,9 @@ async function discoverMcpOAuth(
|
|
|
471
686
|
message: "MCP protected resource metadata did not advertise an authorization server",
|
|
472
687
|
});
|
|
473
688
|
}
|
|
474
|
-
const as = await
|
|
689
|
+
const as = await deadline.run("authorization_server_metadata", (signal) =>
|
|
690
|
+
discoverAuthorizationServerMetadata(authorizationServer, settings, signal),
|
|
691
|
+
);
|
|
475
692
|
if (!as.codeChallengeMethodsSupported.includes("S256")) {
|
|
476
693
|
throw new HTTPException(422, {
|
|
477
694
|
message: "authorization server does not support required PKCE S256",
|
|
@@ -483,10 +700,12 @@ async function discoverMcpOAuth(
|
|
|
483
700
|
async function probeMcpChallenge(
|
|
484
701
|
resource: string,
|
|
485
702
|
settings: Settings,
|
|
703
|
+
signal: AbortSignal,
|
|
486
704
|
): Promise<WwwAuthenticateChallenge> {
|
|
487
705
|
const response = await fetchOAuth(resource, settings, {
|
|
488
706
|
method: "GET",
|
|
489
707
|
headers: { accept: "application/json" },
|
|
708
|
+
signal,
|
|
490
709
|
});
|
|
491
710
|
try {
|
|
492
711
|
if (response.status !== 401) {
|
|
@@ -502,13 +721,14 @@ async function discoverProtectedResourceMetadata(
|
|
|
502
721
|
resource: string,
|
|
503
722
|
settings: Settings,
|
|
504
723
|
advertisedUrl?: string,
|
|
724
|
+
signal?: AbortSignal,
|
|
505
725
|
): Promise<ProtectedResourceMetadata> {
|
|
506
726
|
const candidates = uniqueStrings([
|
|
507
727
|
...(advertisedUrl ? [advertisedUrl] : []),
|
|
508
728
|
...wellKnownCandidates(resource, "oauth-protected-resource"),
|
|
509
729
|
]);
|
|
510
730
|
for (const candidate of candidates) {
|
|
511
|
-
const payload = await fetchJsonObject(candidate, settings).catch((error) => {
|
|
731
|
+
const payload = await fetchJsonObject(candidate, settings, signal).catch((error) => {
|
|
512
732
|
if (error instanceof HTTPException) {
|
|
513
733
|
throw error;
|
|
514
734
|
}
|
|
@@ -528,12 +748,15 @@ async function discoverProtectedResourceMetadata(
|
|
|
528
748
|
...(stringValue(payload.resource) ? { resource: stringValue(payload.resource)! } : {}),
|
|
529
749
|
};
|
|
530
750
|
}
|
|
531
|
-
throw new HTTPException(422, {
|
|
751
|
+
throw new HTTPException(422, {
|
|
752
|
+
message: "could not discover MCP protected resource metadata",
|
|
753
|
+
});
|
|
532
754
|
}
|
|
533
755
|
|
|
534
756
|
async function discoverAuthorizationServerMetadata(
|
|
535
757
|
authorizationServer: string,
|
|
536
758
|
settings: Settings,
|
|
759
|
+
signal: AbortSignal,
|
|
537
760
|
): Promise<AuthorizationServerMetadata> {
|
|
538
761
|
const safeAuthorizationServer = oauthEndpointUrl(
|
|
539
762
|
authorizationServer,
|
|
@@ -551,7 +774,7 @@ async function discoverAuthorizationServerMetadata(
|
|
|
551
774
|
safeAuthorizationServer,
|
|
552
775
|
]);
|
|
553
776
|
for (const candidate of candidates) {
|
|
554
|
-
const payload = await fetchJsonObject(candidate, settings).catch((error) => {
|
|
777
|
+
const payload = await fetchJsonObject(candidate, settings, signal).catch((error) => {
|
|
555
778
|
if (error instanceof HTTPException) {
|
|
556
779
|
throw error;
|
|
557
780
|
}
|
|
@@ -605,6 +828,7 @@ async function registerOAuthClient(
|
|
|
605
828
|
redirectUri: string,
|
|
606
829
|
scopes: string[],
|
|
607
830
|
manual: OAuthStartRequest["oauthClient"],
|
|
831
|
+
signal: AbortSignal,
|
|
608
832
|
): Promise<OAuthClientRegistration> {
|
|
609
833
|
const operator = operatorClientForAs(settings, as);
|
|
610
834
|
if (operator) {
|
|
@@ -614,7 +838,14 @@ async function registerOAuthClient(
|
|
|
614
838
|
// the authorization endpoint. Its documented interactive setup uses DCR,
|
|
615
839
|
// so prefer the simultaneously advertised registration endpoint.
|
|
616
840
|
if (prefersDynamicClientRegistration(as)) {
|
|
617
|
-
return await getOrCreateDynamicClientRegistration(
|
|
841
|
+
return await getOrCreateDynamicClientRegistration(
|
|
842
|
+
db,
|
|
843
|
+
settings,
|
|
844
|
+
as,
|
|
845
|
+
redirectUri,
|
|
846
|
+
scopes,
|
|
847
|
+
signal,
|
|
848
|
+
);
|
|
618
849
|
}
|
|
619
850
|
if (as.clientIdMetadataDocumentSupported) {
|
|
620
851
|
return {
|
|
@@ -638,7 +869,7 @@ async function registerOAuthClient(
|
|
|
638
869
|
),
|
|
639
870
|
};
|
|
640
871
|
}
|
|
641
|
-
return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes);
|
|
872
|
+
return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes, signal);
|
|
642
873
|
}
|
|
643
874
|
|
|
644
875
|
function prefersDynamicClientRegistration(as: AuthorizationServerMetadata): boolean {
|
|
@@ -653,9 +884,10 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
653
884
|
as: AuthorizationServerMetadata,
|
|
654
885
|
redirectUri: string,
|
|
655
886
|
scopes: string[],
|
|
887
|
+
signal: AbortSignal,
|
|
656
888
|
): Promise<OAuthClientRegistration> {
|
|
657
889
|
const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
658
|
-
if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, scopes)) {
|
|
890
|
+
if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, as, redirectUri, scopes)) {
|
|
659
891
|
return {
|
|
660
892
|
method: "dcr",
|
|
661
893
|
issuer: storedClient.issuer,
|
|
@@ -673,7 +905,7 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
673
905
|
message: "manual OAuth client credentials are required for this authorization server",
|
|
674
906
|
});
|
|
675
907
|
}
|
|
676
|
-
const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes);
|
|
908
|
+
const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes, signal);
|
|
677
909
|
const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
|
|
678
910
|
const storeInput = {
|
|
679
911
|
issuer: as.issuer,
|
|
@@ -682,26 +914,55 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
682
914
|
clientSecretEncrypted:
|
|
683
915
|
dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null,
|
|
684
916
|
tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
|
|
685
|
-
metadata: registrationMetadata(as, scopes),
|
|
917
|
+
metadata: registrationMetadata(as, redirectUri, scopes),
|
|
686
918
|
};
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
919
|
+
if (storedClient) {
|
|
920
|
+
const replaced = await replaceIntegrationOAuthClientIfCurrent(db, {
|
|
921
|
+
...storeInput,
|
|
922
|
+
expectedClientId: storedClient.clientId,
|
|
923
|
+
});
|
|
924
|
+
if (replaced?.clientId === dcr.clientId) {
|
|
925
|
+
return dcr;
|
|
694
926
|
}
|
|
927
|
+
return await loadCompatibleDcrWinner(db, settings, as, redirectUri, scopes);
|
|
928
|
+
}
|
|
929
|
+
const storedWinner = await storeIntegrationOAuthClient(db, storeInput);
|
|
930
|
+
if (storedWinner.clientId === dcr.clientId) {
|
|
931
|
+
return dcr;
|
|
932
|
+
}
|
|
933
|
+
const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
934
|
+
if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
|
|
695
935
|
return dcrRegistrationFromStored(winner);
|
|
696
936
|
}
|
|
697
|
-
|
|
937
|
+
if (winner) {
|
|
938
|
+
const replaced = await replaceIntegrationOAuthClientIfCurrent(db, {
|
|
939
|
+
...storeInput,
|
|
940
|
+
expectedClientId: winner.clientId,
|
|
941
|
+
});
|
|
942
|
+
if (replaced?.clientId === dcr.clientId) {
|
|
943
|
+
return dcr;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return await loadCompatibleDcrWinner(db, settings, as, redirectUri, scopes);
|
|
698
947
|
}
|
|
699
948
|
|
|
700
949
|
function storedDcrClientSatisfiesPolicy(
|
|
701
|
-
stored: {
|
|
950
|
+
stored: {
|
|
951
|
+
authorizationServer: string;
|
|
952
|
+
metadata: Record<string, unknown>;
|
|
953
|
+
},
|
|
954
|
+
as: AuthorizationServerMetadata,
|
|
955
|
+
redirectUri: string,
|
|
702
956
|
scopes: string[],
|
|
703
957
|
): boolean {
|
|
704
|
-
return
|
|
958
|
+
return (
|
|
959
|
+
stored.authorizationServer === as.authorizationServer &&
|
|
960
|
+
stringValue(stored.metadata.registrationEndpoint) === as.registrationEndpoint &&
|
|
961
|
+
stringValue(stored.metadata.authorizationEndpoint) === as.authorizationEndpoint &&
|
|
962
|
+
stringValue(stored.metadata.tokenEndpoint) === as.tokenEndpoint &&
|
|
963
|
+
stringValue(stored.metadata.redirectUri) === redirectUri &&
|
|
964
|
+
registeredScopesMatch(stored.metadata, scopes)
|
|
965
|
+
);
|
|
705
966
|
}
|
|
706
967
|
|
|
707
968
|
function registeredScopesMatch(metadata: Record<string, unknown>, scopes: string[]): boolean {
|
|
@@ -714,15 +975,35 @@ function stableScopeKey(scopes: string[]): string {
|
|
|
714
975
|
|
|
715
976
|
function registrationMetadata(
|
|
716
977
|
as: AuthorizationServerMetadata,
|
|
978
|
+
redirectUri: string,
|
|
717
979
|
scopes: string[],
|
|
718
980
|
): Record<string, unknown> {
|
|
719
981
|
return {
|
|
720
982
|
registrationEndpoint: as.registrationEndpoint,
|
|
983
|
+
authorizationEndpoint: as.authorizationEndpoint,
|
|
984
|
+
tokenEndpoint: as.tokenEndpoint,
|
|
985
|
+
redirectUri,
|
|
721
986
|
registeredAt: new Date().toISOString(),
|
|
722
987
|
registeredScopes: uniqueStrings(scopes),
|
|
723
988
|
};
|
|
724
989
|
}
|
|
725
990
|
|
|
991
|
+
async function loadCompatibleDcrWinner(
|
|
992
|
+
db: Database,
|
|
993
|
+
settings: Settings,
|
|
994
|
+
as: AuthorizationServerMetadata,
|
|
995
|
+
redirectUri: string,
|
|
996
|
+
scopes: string[],
|
|
997
|
+
): Promise<OAuthClientRegistration> {
|
|
998
|
+
const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
999
|
+
if (!winner || !storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
|
|
1000
|
+
throw new HTTPException(409, {
|
|
1001
|
+
message: "OAuth client registration changed concurrently; start again",
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
return dcrRegistrationFromStored(winner);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
726
1007
|
function dcrRegistrationFromStored(stored: {
|
|
727
1008
|
issuer: string;
|
|
728
1009
|
authorizationServer: string;
|
|
@@ -814,6 +1095,7 @@ async function dynamicClientRegistration(
|
|
|
814
1095
|
as: AuthorizationServerMetadata,
|
|
815
1096
|
redirectUri: string,
|
|
816
1097
|
scopes: string[],
|
|
1098
|
+
signal: AbortSignal,
|
|
817
1099
|
): Promise<OAuthClientRegistration> {
|
|
818
1100
|
if (!as.registrationEndpoint) {
|
|
819
1101
|
throw new HTTPException(422, {
|
|
@@ -831,6 +1113,7 @@ async function dynamicClientRegistration(
|
|
|
831
1113
|
response_types: ["code"],
|
|
832
1114
|
...(scopes.length ? { scope: scopes.join(" ") } : {}),
|
|
833
1115
|
}),
|
|
1116
|
+
signal,
|
|
834
1117
|
});
|
|
835
1118
|
if (!response.ok) {
|
|
836
1119
|
await cancelResponseBody(response);
|
|
@@ -842,6 +1125,7 @@ async function dynamicClientRegistration(
|
|
|
842
1125
|
response,
|
|
843
1126
|
OAUTH_MAX_RESPONSE_BYTES,
|
|
844
1127
|
"OAuth dynamic registration response",
|
|
1128
|
+
{ signal },
|
|
845
1129
|
);
|
|
846
1130
|
const clientId = stringValue(payload.client_id);
|
|
847
1131
|
if (!clientId) {
|
|
@@ -1039,7 +1323,9 @@ async function clientForState(
|
|
|
1039
1323
|
authorizationServer: state.authorizationServer,
|
|
1040
1324
|
clientId: state.clientId,
|
|
1041
1325
|
...(state.encryptedClientSecret
|
|
1042
|
-
? {
|
|
1326
|
+
? {
|
|
1327
|
+
clientSecret: decryptEnvironmentValue(key, state.encryptedClientSecret),
|
|
1328
|
+
}
|
|
1043
1329
|
: {}),
|
|
1044
1330
|
tokenEndpointAuthMethod: state.tokenEndpointAuthMethod,
|
|
1045
1331
|
};
|
|
@@ -1052,7 +1338,9 @@ async function clientForState(
|
|
|
1052
1338
|
stored.issuer !== state.issuer ||
|
|
1053
1339
|
stored.authorizationServer !== state.authorizationServer
|
|
1054
1340
|
) {
|
|
1055
|
-
throw new HTTPException(400, {
|
|
1341
|
+
throw new HTTPException(400, {
|
|
1342
|
+
message: "OAuth client registration is no longer available",
|
|
1343
|
+
});
|
|
1056
1344
|
}
|
|
1057
1345
|
return {
|
|
1058
1346
|
method: "dcr",
|
|
@@ -1094,6 +1382,7 @@ async function exchangeAuthorizationCode(
|
|
|
1094
1382
|
resource: string;
|
|
1095
1383
|
tokenEndpoint: string;
|
|
1096
1384
|
client: OAuthClientRegistration;
|
|
1385
|
+
signal: AbortSignal;
|
|
1097
1386
|
},
|
|
1098
1387
|
): Promise<TokenResponse> {
|
|
1099
1388
|
const body = new URLSearchParams();
|
|
@@ -1121,9 +1410,10 @@ async function exchangeAuthorizationCode(
|
|
|
1121
1410
|
method: "POST",
|
|
1122
1411
|
headers,
|
|
1123
1412
|
body,
|
|
1413
|
+
signal: input.signal,
|
|
1124
1414
|
});
|
|
1125
1415
|
if (!response.ok) {
|
|
1126
|
-
const oauthError = await oauthErrorFromResponse(response);
|
|
1416
|
+
const oauthError = await oauthErrorFromResponse(response, input.signal);
|
|
1127
1417
|
throw new OAuthCallbackStageError(
|
|
1128
1418
|
"token_exchange",
|
|
1129
1419
|
oauthError ?? "token_exchange_failed",
|
|
@@ -1134,6 +1424,7 @@ async function exchangeAuthorizationCode(
|
|
|
1134
1424
|
response,
|
|
1135
1425
|
OAUTH_MAX_RESPONSE_BYTES,
|
|
1136
1426
|
"OAuth token response",
|
|
1427
|
+
{ signal: input.signal },
|
|
1137
1428
|
);
|
|
1138
1429
|
const accessToken = stringValue(payload.access_token);
|
|
1139
1430
|
if (!accessToken) {
|
|
@@ -1151,18 +1442,32 @@ async function exchangeAuthorizationCode(
|
|
|
1151
1442
|
};
|
|
1152
1443
|
}
|
|
1153
1444
|
|
|
1154
|
-
async function
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1445
|
+
async function runCallbackDatabaseStage<T>(
|
|
1446
|
+
deadline: OAuthCallbackDeadline,
|
|
1447
|
+
stage: "state_verify" | "client_lookup" | "persist",
|
|
1448
|
+
db: Database,
|
|
1449
|
+
fn: (db: Database) => Promise<T>,
|
|
1158
1450
|
): Promise<T> {
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1451
|
+
return await deadline.run(stage, async (signal) => {
|
|
1452
|
+
const statementTimeoutMs = Math.min(
|
|
1453
|
+
OAUTH_CALLBACK_DB_STATEMENT_TIMEOUT_MS,
|
|
1454
|
+
deadline.remainingMs(),
|
|
1455
|
+
);
|
|
1456
|
+
return await withDatabaseStatementTimeout(db, statementTimeoutMs, async (scopedDb) => {
|
|
1457
|
+
throwIfCallbackAborted(signal, stage);
|
|
1458
|
+
const result = await fn(scopedDb);
|
|
1459
|
+
// If the application deadline won the race while Postgres was finishing,
|
|
1460
|
+
// throw inside this outer transaction so the write is rolled back rather
|
|
1461
|
+
// than committing after the browser has received a timeout redirect.
|
|
1462
|
+
throwIfCallbackAborted(signal, stage);
|
|
1463
|
+
return result;
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function throwIfCallbackAborted(signal: AbortSignal, stage: OAuthCallbackStage): void {
|
|
1469
|
+
if (signal.aborted) {
|
|
1470
|
+
throw new RequestDeadlineError(stage);
|
|
1166
1471
|
}
|
|
1167
1472
|
}
|
|
1168
1473
|
|
|
@@ -1183,6 +1488,96 @@ function logOAuthCallbackFailure(
|
|
|
1183
1488
|
});
|
|
1184
1489
|
}
|
|
1185
1490
|
|
|
1491
|
+
function logOAuthStartFailure(
|
|
1492
|
+
observability: Observability | undefined,
|
|
1493
|
+
error: OAuthStartStageError,
|
|
1494
|
+
providerDomain: string | undefined,
|
|
1495
|
+
): void {
|
|
1496
|
+
observability?.warn("MCP OAuth setup failed", {
|
|
1497
|
+
"opengeni.oauth.stage": error.stage,
|
|
1498
|
+
"opengeni.oauth.reason": error.reason,
|
|
1499
|
+
"opengeni.oauth.provider_domain": providerDomain,
|
|
1500
|
+
error: sanitizedError(error.cause),
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function oauthStartFailureReason(error: unknown): string {
|
|
1505
|
+
if (error instanceof RequestDeadlineError) return "timeout";
|
|
1506
|
+
if (error instanceof DestinationPolicyError) return error.reason;
|
|
1507
|
+
if (error instanceof HTTPException) return `http_${error.status}`;
|
|
1508
|
+
if (error instanceof SyntaxError) return "invalid_response";
|
|
1509
|
+
return "request_failed";
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function oauthCallbackFailureReason(stage: OAuthCallbackStage, error: unknown): string {
|
|
1513
|
+
if (error instanceof RequestDeadlineError || isDatabaseStatementTimeout(error)) return "timeout";
|
|
1514
|
+
switch (stage) {
|
|
1515
|
+
case "state_verify":
|
|
1516
|
+
return "state_invalid";
|
|
1517
|
+
case "client_lookup":
|
|
1518
|
+
return "client_lookup_failed";
|
|
1519
|
+
case "token_exchange":
|
|
1520
|
+
return "token_exchange_failed";
|
|
1521
|
+
case "tools_list":
|
|
1522
|
+
return "tools_list_failed";
|
|
1523
|
+
case "persist":
|
|
1524
|
+
return "persist_failed";
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function isDatabaseStatementTimeout(error: unknown): boolean {
|
|
1529
|
+
let current = error;
|
|
1530
|
+
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
|
|
1531
|
+
const candidate = current as { code?: unknown; message?: unknown; cause?: unknown };
|
|
1532
|
+
if (
|
|
1533
|
+
candidate.code === "57014" ||
|
|
1534
|
+
(typeof candidate.message === "string" &&
|
|
1535
|
+
candidate.message.toLowerCase().includes("statement timeout"))
|
|
1536
|
+
) {
|
|
1537
|
+
return true;
|
|
1538
|
+
}
|
|
1539
|
+
current = candidate.cause;
|
|
1540
|
+
}
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function oauthStartApiError(error: OAuthStartStageError): ApiHttpError {
|
|
1545
|
+
const timeout = error.reason === "timeout";
|
|
1546
|
+
const status = timeout ? 408 : error.cause instanceof HTTPException ? error.cause.status : 422;
|
|
1547
|
+
return new ApiHttpError(status, {
|
|
1548
|
+
code: timeout || status >= 500 ? "upstream_unavailable" : "validation_failed",
|
|
1549
|
+
retryable: timeout || status === 429 || status >= 500,
|
|
1550
|
+
message: timeout
|
|
1551
|
+
? oauthStartTimeoutMessage(error.stage)
|
|
1552
|
+
: error.cause instanceof HTTPException
|
|
1553
|
+
? error.cause.message
|
|
1554
|
+
: `Connection setup failed during ${oauthStartStageLabel(error.stage)}.`,
|
|
1555
|
+
details: {
|
|
1556
|
+
oauthStage: error.stage,
|
|
1557
|
+
oauthReason: error.reason,
|
|
1558
|
+
},
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function oauthStartTimeoutMessage(stage: OAuthStartStage): string {
|
|
1563
|
+
return `Connection setup timed out during ${oauthStartStageLabel(stage)}. Try again.`;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function oauthStartStageLabel(stage: OAuthStartStage): string {
|
|
1567
|
+
switch (stage) {
|
|
1568
|
+
case "connection_lookup":
|
|
1569
|
+
return "connection lookup";
|
|
1570
|
+
case "mcp_challenge":
|
|
1571
|
+
return "MCP authorization discovery";
|
|
1572
|
+
case "protected_resource_metadata":
|
|
1573
|
+
return "protected-resource discovery";
|
|
1574
|
+
case "authorization_server_metadata":
|
|
1575
|
+
return "authorization-server discovery";
|
|
1576
|
+
case "client_registration":
|
|
1577
|
+
return "OAuth client registration";
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1186
1581
|
function logOAuthVerificationWarning(
|
|
1187
1582
|
observability: Observability | undefined,
|
|
1188
1583
|
error: OAuthCallbackStageError,
|
|
@@ -1223,7 +1618,20 @@ function safeHost(rawUrl: string): string | undefined {
|
|
|
1223
1618
|
}
|
|
1224
1619
|
}
|
|
1225
1620
|
|
|
1226
|
-
|
|
1621
|
+
function safeRequestedProviderDomain(payload: OAuthStartRequest): string | undefined {
|
|
1622
|
+
try {
|
|
1623
|
+
const resource = payload.mcpUrl ?? payload.resource;
|
|
1624
|
+
if (!resource) return undefined;
|
|
1625
|
+
return canonicalProviderDomain(payload.providerDomain ?? new URL(resource).hostname);
|
|
1626
|
+
} catch {
|
|
1627
|
+
return undefined;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function oauthErrorFromResponse(
|
|
1632
|
+
response: Response,
|
|
1633
|
+
signal?: AbortSignal,
|
|
1634
|
+
): Promise<string | null> {
|
|
1227
1635
|
const contentType = response.headers.get("content-type") ?? "";
|
|
1228
1636
|
if (!contentType.toLowerCase().includes("application/json")) {
|
|
1229
1637
|
await cancelResponseBody(response);
|
|
@@ -1236,6 +1644,7 @@ async function oauthErrorFromResponse(response: Response): Promise<string | null
|
|
|
1236
1644
|
response,
|
|
1237
1645
|
OAUTH_MAX_RESPONSE_BYTES,
|
|
1238
1646
|
"OAuth token error response",
|
|
1647
|
+
{ ...(signal ? { signal } : {}) },
|
|
1239
1648
|
).catch(() => null);
|
|
1240
1649
|
const error = stringValue(payload?.error);
|
|
1241
1650
|
if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
|
|
@@ -1248,6 +1657,7 @@ async function verifyMcpToolsList(
|
|
|
1248
1657
|
settings: Settings,
|
|
1249
1658
|
resource: string,
|
|
1250
1659
|
token: TokenResponse,
|
|
1660
|
+
signal: AbortSignal,
|
|
1251
1661
|
): Promise<Array<{ name: string; description?: string }>> {
|
|
1252
1662
|
const client = new Client(
|
|
1253
1663
|
{ name: "opengeni-integration-verify", version: "0.1.0" },
|
|
@@ -1260,13 +1670,20 @@ async function verifyMcpToolsList(
|
|
|
1260
1670
|
authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`,
|
|
1261
1671
|
},
|
|
1262
1672
|
},
|
|
1263
|
-
fetch: (url, init) =>
|
|
1673
|
+
fetch: (url, init) =>
|
|
1674
|
+
fetchOAuth(url.toString(), settings, {
|
|
1675
|
+
...init,
|
|
1676
|
+
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
|
1677
|
+
}),
|
|
1264
1678
|
});
|
|
1265
1679
|
await client.connect(transport as unknown as Transport, {
|
|
1266
1680
|
timeout: 10_000,
|
|
1267
1681
|
maxTotalTimeout: 10_000,
|
|
1268
1682
|
});
|
|
1269
|
-
const listed = await client.listTools(undefined, {
|
|
1683
|
+
const listed = await client.listTools(undefined, {
|
|
1684
|
+
timeout: 10_000,
|
|
1685
|
+
maxTotalTimeout: 10_000,
|
|
1686
|
+
});
|
|
1270
1687
|
return listed.tools.map((tool) => ({
|
|
1271
1688
|
name: tool.name,
|
|
1272
1689
|
...(tool.description ? { description: tool.description } : {}),
|
|
@@ -1281,6 +1698,7 @@ async function verifyMcpToolsListNonFatal(
|
|
|
1281
1698
|
settings: Settings,
|
|
1282
1699
|
state: OAuthStatePayload,
|
|
1283
1700
|
token: TokenResponse,
|
|
1701
|
+
deadline: OAuthCallbackDeadline,
|
|
1284
1702
|
): Promise<{
|
|
1285
1703
|
metadata:
|
|
1286
1704
|
| { status: "ok"; checkedAt: string; toolCount: number }
|
|
@@ -1288,8 +1706,8 @@ async function verifyMcpToolsListNonFatal(
|
|
|
1288
1706
|
tools?: Array<{ name: string; description?: string }>;
|
|
1289
1707
|
}> {
|
|
1290
1708
|
try {
|
|
1291
|
-
const tools = await
|
|
1292
|
-
verifyMcpToolsList(settings, state.mcpUrl, token),
|
|
1709
|
+
const tools = await deadline.run("tools_list", (signal) =>
|
|
1710
|
+
verifyMcpToolsList(settings, state.mcpUrl, token, signal),
|
|
1293
1711
|
);
|
|
1294
1712
|
return {
|
|
1295
1713
|
metadata: {
|
|
@@ -1304,6 +1722,9 @@ async function verifyMcpToolsListNonFatal(
|
|
|
1304
1722
|
error instanceof OAuthCallbackStageError
|
|
1305
1723
|
? error
|
|
1306
1724
|
: new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
|
|
1725
|
+
if (staged.reason === "timeout" && deadline.signal.aborted) {
|
|
1726
|
+
throw staged;
|
|
1727
|
+
}
|
|
1307
1728
|
logOAuthVerificationWarning(observability, staged, state);
|
|
1308
1729
|
return {
|
|
1309
1730
|
metadata: {
|
|
@@ -1414,19 +1835,30 @@ function oauthEndpointUrl(rawUrl: string, settings: Settings, label: string): st
|
|
|
1414
1835
|
|
|
1415
1836
|
function safeReturnPath(value: string): string {
|
|
1416
1837
|
if (!value.startsWith("/") || value.startsWith("//")) {
|
|
1417
|
-
throw new HTTPException(400, {
|
|
1838
|
+
throw new HTTPException(400, {
|
|
1839
|
+
message: "OAuth returnPath must be a relative path",
|
|
1840
|
+
});
|
|
1418
1841
|
}
|
|
1419
1842
|
const parsed = new URL(value, "https://opengeni.local");
|
|
1420
1843
|
// `..` segments can normalize back into a `//host` prefix, which browsers
|
|
1421
1844
|
// resolve as a protocol-relative absolute URL. Reject the NORMALIZED path.
|
|
1422
1845
|
if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
|
|
1423
|
-
throw new HTTPException(400, {
|
|
1846
|
+
throw new HTTPException(400, {
|
|
1847
|
+
message: "OAuth returnPath must be a relative path",
|
|
1848
|
+
});
|
|
1424
1849
|
}
|
|
1425
1850
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
1426
1851
|
}
|
|
1427
1852
|
|
|
1428
|
-
async function fetchJsonObject(
|
|
1429
|
-
|
|
1853
|
+
async function fetchJsonObject(
|
|
1854
|
+
url: string,
|
|
1855
|
+
settings: Settings,
|
|
1856
|
+
signal?: AbortSignal,
|
|
1857
|
+
): Promise<Record<string, unknown>> {
|
|
1858
|
+
const response = await fetchOAuth(url, settings, {
|
|
1859
|
+
headers: { accept: "application/json" },
|
|
1860
|
+
...(signal ? { signal } : {}),
|
|
1861
|
+
});
|
|
1430
1862
|
if (!response.ok) {
|
|
1431
1863
|
await cancelResponseBody(response);
|
|
1432
1864
|
throw new Error(`HTTP ${response.status}`);
|
|
@@ -1435,6 +1867,7 @@ async function fetchJsonObject(url: string, settings: Settings): Promise<Record<
|
|
|
1435
1867
|
response,
|
|
1436
1868
|
OAUTH_MAX_RESPONSE_BYTES,
|
|
1437
1869
|
"OAuth metadata response",
|
|
1870
|
+
{ ...(signal ? { signal } : {}) },
|
|
1438
1871
|
);
|
|
1439
1872
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1440
1873
|
throw new Error("metadata response was not a JSON object");
|
|
@@ -1477,19 +1910,25 @@ async function fetchOAuth(
|
|
|
1477
1910
|
}
|
|
1478
1911
|
if (hop >= 3) {
|
|
1479
1912
|
await cancelResponseBody(response);
|
|
1480
|
-
throw new HTTPException(422, {
|
|
1913
|
+
throw new HTTPException(422, {
|
|
1914
|
+
message: "OAuth fetch exceeded maximum redirect hops",
|
|
1915
|
+
});
|
|
1481
1916
|
}
|
|
1482
1917
|
const location = response.headers.get("location");
|
|
1483
1918
|
if (!location) {
|
|
1484
1919
|
await cancelResponseBody(response);
|
|
1485
|
-
throw new HTTPException(422, {
|
|
1920
|
+
throw new HTTPException(422, {
|
|
1921
|
+
message: "OAuth fetch redirect was missing Location",
|
|
1922
|
+
});
|
|
1486
1923
|
}
|
|
1487
1924
|
let nextUrl: string;
|
|
1488
1925
|
try {
|
|
1489
1926
|
nextUrl = new URL(location, rawUrl).toString();
|
|
1490
1927
|
} catch {
|
|
1491
1928
|
await cancelResponseBody(response);
|
|
1492
|
-
throw new HTTPException(422, {
|
|
1929
|
+
throw new HTTPException(422, {
|
|
1930
|
+
message: "OAuth fetch redirect Location was invalid",
|
|
1931
|
+
});
|
|
1493
1932
|
}
|
|
1494
1933
|
await cancelResponseBody(response);
|
|
1495
1934
|
return await fetchOAuth(nextUrl, settings, init, hop + 1);
|
|
@@ -1624,7 +2063,9 @@ function numberValue(value: unknown): number | undefined {
|
|
|
1624
2063
|
function requiredString(value: unknown, field: string): string {
|
|
1625
2064
|
const result = stringValue(value);
|
|
1626
2065
|
if (!result) {
|
|
1627
|
-
throw new HTTPException(400, {
|
|
2066
|
+
throw new HTTPException(400, {
|
|
2067
|
+
message: `invalid OAuth state: missing ${field}`,
|
|
2068
|
+
});
|
|
1628
2069
|
}
|
|
1629
2070
|
return result;
|
|
1630
2071
|
}
|