@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.
@@ -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 = "state_verify" | "token_exchange" | "tools_list" | "persist";
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 existingOAuthConnectionForStart(db, {
173
- workspaceId: context.workspaceId,
174
- subjectId: context.subjectId,
175
- providerDomain,
176
- mcpUrl,
177
- personalSlack,
178
- connectionId: context.payload.connectionId,
179
- requestedOwnership: context.payload.ownership,
180
- newConnectionOwnership: requestedOwnership,
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 registerOAuthClient(
201
- db,
202
- settings,
203
- discovery.as,
204
- metadataUrl,
205
- redirectUri,
206
- authorizeScopes,
207
- context.payload.oauthClient,
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
- ? { encryptedClientSecret: encryptEnvironmentValue(key, client.clientSecret) }
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: { code?: string | undefined; state?: string | undefined; requestUrl: string },
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 { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
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", { reason: "missing_code" }),
451
+ redirectTo: callbackReturnPath(state.returnPath, "error", {
452
+ stage: "state_verify",
453
+ reason: "missing_code",
454
+ }),
271
455
  };
272
456
  }
273
- const consumed = await consumeIntegrationOAuthStateNonce(db, {
274
- accountId: state.accountId,
275
- workspaceId: state.workspaceId,
276
- subjectId: state.subjectId,
277
- nonce: state.nonce,
278
- expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
279
- now: new Date(),
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, { message: "OAuth state has already been used" });
474
+ throw new HTTPException(400, {
475
+ message: "OAuth state has already been used",
476
+ });
283
477
  }
284
478
  } catch (error) {
285
- const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
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 clientForState(db, settings, state);
301
- const token = await runCallbackStage("token_exchange", "token_exchange_failed", () =>
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(observability, settings, state, token);
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 requireOAuthCallbackGrant(db, state);
327
- const connection = await runCallbackStage("persist", "persist_failed", () =>
328
- state.connectionId
329
- ? updateConnection(db, {
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(db, {
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 { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
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, { message: "Slack provider identity does not match slack.com" });
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 probeMcpChallenge(resource, settings);
463
- const prm = await discoverProtectedResourceMetadata(
464
- resource,
465
- settings,
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 discoverAuthorizationServerMetadata(authorizationServer, settings);
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, { message: "could not discover MCP protected resource metadata" });
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(db, settings, as, redirectUri, scopes);
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
- const storedWinner = await storeIntegrationOAuthClient(db, storeInput);
688
- if (storedWinner.clientId !== dcr.clientId) {
689
- const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
690
- if (!winner) {
691
- throw new HTTPException(422, {
692
- message: "OAuth client registration could not be loaded after a registration race",
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
- return dcr;
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: { metadata: Record<string, unknown> },
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 registeredScopesMatch(stored.metadata, scopes);
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
- ? { clientSecret: decryptEnvironmentValue(key, state.encryptedClientSecret) }
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, { message: "OAuth client registration is no longer available" });
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 runCallbackStage<T>(
1155
- callbackStage: OAuthCallbackStage,
1156
- fallbackReason: string,
1157
- fn: () => Promise<T>,
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
- try {
1160
- return await fn();
1161
- } catch (error) {
1162
- if (error instanceof OAuthCallbackStageError) {
1163
- throw error;
1164
- }
1165
- throw new OAuthCallbackStageError(callbackStage, fallbackReason, error);
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
- async function oauthErrorFromResponse(response: Response): Promise<string | null> {
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) => fetchOAuth(url.toString(), settings, 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, { timeout: 10_000, maxTotalTimeout: 10_000 });
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 runCallbackStage("tools_list", "tools_list_failed", () =>
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, { message: "OAuth returnPath must be a relative path" });
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, { message: "OAuth returnPath must be a relative path" });
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(url: string, settings: Settings): Promise<Record<string, unknown>> {
1429
- const response = await fetchOAuth(url, settings, { headers: { accept: "application/json" } });
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, { message: "OAuth fetch exceeded maximum redirect hops" });
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, { message: "OAuth fetch redirect was missing Location" });
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, { message: "OAuth fetch redirect Location was invalid" });
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, { message: `invalid OAuth state: missing ${field}` });
2066
+ throw new HTTPException(400, {
2067
+ message: `invalid OAuth state: missing ${field}`,
2068
+ });
1628
2069
  }
1629
2070
  return result;
1630
2071
  }