@opengeni/api-router 0.5.2 → 0.5.4

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.
Files changed (48) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-YY6OAEL6.js → chunk-DO2G3JSB.js} +5333 -2205
  3. package/dist/chunk-DO2G3JSB.js.map +1 -0
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.js +297 -54
  6. package/dist/index.js.map +1 -1
  7. package/package.json +21 -21
  8. package/src/app.ts +415 -147
  9. package/src/auth/managed-auth.ts +32 -16
  10. package/src/http/auth.ts +8 -1
  11. package/src/http/common.ts +6 -2
  12. package/src/http/sse.ts +27 -6
  13. package/src/index.ts +196 -74
  14. package/src/integrations/oauth-client.ts +592 -131
  15. package/src/integrations/provider-domain.ts +4 -1
  16. package/src/mcp/documents.ts +173 -94
  17. package/src/mcp/server.ts +1517 -692
  18. package/src/mcp/session-view.ts +8 -2
  19. package/src/mcp/toolspace.ts +175 -84
  20. package/src/observability.ts +7 -1
  21. package/src/routes/api-keys.ts +39 -23
  22. package/src/routes/billing.ts +180 -65
  23. package/src/routes/capabilities.ts +17 -8
  24. package/src/routes/catalog-assets.ts +5 -2
  25. package/src/routes/codex.ts +244 -63
  26. package/src/routes/connections.ts +72 -34
  27. package/src/routes/documents.ts +242 -92
  28. package/src/routes/enrollments.ts +100 -70
  29. package/src/routes/environments.ts +205 -136
  30. package/src/routes/files.ts +164 -39
  31. package/src/routes/github.ts +123 -50
  32. package/src/routes/install.ts +9 -2
  33. package/src/routes/machines.ts +9 -8
  34. package/src/routes/packs.ts +141 -89
  35. package/src/routes/rigs.ts +189 -0
  36. package/src/routes/scheduled-tasks.ts +51 -9
  37. package/src/routes/sessions.ts +839 -329
  38. package/src/routes/social.ts +50 -38
  39. package/src/routes/workspace-capture.ts +238 -0
  40. package/src/routes/workspaces.ts +159 -13
  41. package/src/sandbox/access.ts +11 -3
  42. package/src/sandbox/auth-callout.ts +5 -1
  43. package/src/sandbox/channel-a.ts +104 -27
  44. package/src/sandbox/enrollment.ts +13 -3
  45. package/src/sandbox/machines.ts +68 -59
  46. package/src/sandbox/metrics-ingestion.ts +238 -17
  47. package/src/sandbox/viewer.ts +172 -46
  48. package/dist/chunk-YY6OAEL6.js.map +0 -1
@@ -4,6 +4,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
4
  import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
5
5
  import { OAuthStartResponse, type OAuthStartRequest } from "@opengeni/contracts";
6
6
  import { requireEnvironmentEncryption } from "@opengeni/core";
7
+ import type { Observability } from "@opengeni/observability";
7
8
  import {
8
9
  consumeIntegrationOAuthStateNonce,
9
10
  createConnection,
@@ -11,7 +12,9 @@ import {
11
12
  encryptEnvironmentValue,
12
13
  getConnectionMetadata,
13
14
  isPrivateAddress,
15
+ listConnectionsMetadata,
14
16
  loadIntegrationOAuthClient,
17
+ normalizeBearerScheme,
15
18
  storeIntegrationOAuthClient,
16
19
  updateConnection,
17
20
  type Database,
@@ -29,6 +32,7 @@ export const oauthStateTtlMs = 10 * 60 * 1000;
29
32
  type OAuthClientDeps = {
30
33
  db: Database;
31
34
  settings: Settings;
35
+ observability?: Observability | undefined;
32
36
  };
33
37
 
34
38
  export type OAuthStartContext = {
@@ -63,12 +67,13 @@ type AuthorizationServerMetadata = {
63
67
  tokenEndpoint: string;
64
68
  registrationEndpoint?: string;
65
69
  clientIdMetadataDocumentSupported: boolean;
70
+ tokenEndpointAuthMethodsSupported: string[];
66
71
  codeChallengeMethodsSupported: string[];
67
72
  raw: Record<string, unknown>;
68
73
  };
69
74
 
70
75
  type OAuthClientRegistration = {
71
- method: "operator" | "cimd" | "dcr";
76
+ method: "operator" | "manual" | "cimd" | "dcr";
72
77
  issuer: string;
73
78
  authorizationServer: string;
74
79
  clientId: string;
@@ -81,6 +86,7 @@ type OAuthStatePayload = {
81
86
  workspaceId: string;
82
87
  subjectId: string;
83
88
  providerDomain: string;
89
+ mcpUrl: string;
84
90
  resource: string;
85
91
  requestedScopes: string[];
86
92
  authorizeScopes: string[];
@@ -91,6 +97,7 @@ type OAuthStatePayload = {
91
97
  issuer: string;
92
98
  clientRegistrationMethod: OAuthClientRegistration["method"];
93
99
  tokenEndpointAuthMethod: OAuthClientRegistration["tokenEndpointAuthMethod"];
100
+ encryptedClientSecret?: string;
94
101
  returnPath: string;
95
102
  connectionId?: string;
96
103
  connectionVersion?: number;
@@ -107,34 +114,66 @@ type TokenResponse = {
107
114
  raw: Record<string, unknown>;
108
115
  };
109
116
 
117
+ type OAuthCallbackStage = "state_verify" | "token_exchange" | "tools_list" | "persist";
118
+
119
+ class OAuthCallbackStageError extends Error {
120
+ constructor(
121
+ readonly stage: OAuthCallbackStage,
122
+ readonly reason: string,
123
+ readonly cause: unknown,
124
+ ) {
125
+ super(errorMessage(cause));
126
+ this.name = "OAuthCallbackStageError";
127
+ }
128
+ }
129
+
110
130
  export async function startMcpOAuth(
111
131
  deps: OAuthClientDeps,
112
132
  context: OAuthStartContext,
113
133
  ): Promise<OAuthStartResponse> {
114
134
  const { db, settings } = deps;
115
- const resource = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
116
- const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(resource).hostname);
135
+ const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
136
+ const providerDomain = canonicalProviderDomain(
137
+ context.payload.providerDomain ?? new URL(mcpUrl).hostname,
138
+ );
117
139
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
118
140
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
119
141
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
120
142
  const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
121
- const existing = context.payload.connectionId
122
- ? await getConnectionMetadata(db, context.workspaceId, context.payload.connectionId, context.subjectId)
123
- : null;
143
+ const existing = await existingOAuthConnectionForStart(db, {
144
+ workspaceId: context.workspaceId,
145
+ subjectId: context.subjectId,
146
+ providerDomain,
147
+ connectionId: context.payload.connectionId,
148
+ });
124
149
  if (context.payload.connectionId && !existing) {
125
150
  throw new HTTPException(404, { message: "connection not found" });
126
151
  }
127
152
 
128
- const discovery = await discoverMcpOAuth(resource, settings);
129
- const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
153
+ const discovery = await discoverMcpOAuth(mcpUrl, settings);
154
+ const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
130
155
  const verifier = randomPkceVerifier();
131
- const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
156
+ const authorizeScopes = chooseAuthorizeScopes(
157
+ context.payload.requestedScopes,
158
+ discovery.challenge.scope,
159
+ discovery.prm.scopesSupported,
160
+ );
161
+ const client = await registerOAuthClient(
162
+ db,
163
+ settings,
164
+ discovery.as,
165
+ metadataUrl,
166
+ redirectUri,
167
+ authorizeScopes,
168
+ context.payload.oauthClient,
169
+ );
132
170
  const key = requireEnvironmentEncryption(settings);
133
171
  const state = createSignedState(requireIntegrationsStateSecret(settings), {
134
172
  accountId: context.accountId,
135
173
  workspaceId: context.workspaceId,
136
174
  subjectId: context.subjectId,
137
175
  providerDomain,
176
+ mcpUrl,
138
177
  resource,
139
178
  requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
140
179
  authorizeScopes,
@@ -145,6 +184,9 @@ export async function startMcpOAuth(
145
184
  issuer: client.issuer,
146
185
  clientRegistrationMethod: client.method,
147
186
  tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
187
+ ...(client.method === "manual" && client.clientSecret
188
+ ? { encryptedClientSecret: encryptEnvironmentValue(key, client.clientSecret) }
189
+ : {}),
148
190
  returnPath,
149
191
  ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
150
192
  });
@@ -168,24 +210,43 @@ export async function completeMcpOAuthCallback(
168
210
  deps: OAuthClientDeps,
169
211
  input: { code?: string | undefined; state?: string | undefined; requestUrl: string },
170
212
  ): Promise<OAuthCallbackResult> {
171
- const { db, settings } = deps;
213
+ const { db, settings, observability } = deps;
214
+ let state: OAuthStatePayload | null = null;
172
215
  if (!input.state) {
173
- throw new HTTPException(400, { message: "missing OAuth state" });
174
- }
175
- const state = readOAuthState(input.state, settings);
176
- if (!input.code) {
177
- return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
178
- }
179
- const consumed = await consumeIntegrationOAuthStateNonce(db, {
180
- accountId: state.accountId,
181
- workspaceId: state.workspaceId,
182
- subjectId: state.subjectId,
183
- nonce: state.nonce,
184
- expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
185
- now: new Date(),
186
- });
187
- if (!consumed) {
188
- throw new HTTPException(400, { message: "OAuth state has already been used" });
216
+ const error = new OAuthCallbackStageError(
217
+ "state_verify",
218
+ "state_invalid",
219
+ new Error("missing OAuth state"),
220
+ );
221
+ logOAuthCallbackFailure(observability, error, state);
222
+ return { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
223
+ }
224
+ try {
225
+ state = readOAuthState(input.state, settings);
226
+ if (!input.code) {
227
+ return {
228
+ redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }),
229
+ };
230
+ }
231
+ const consumed = await consumeIntegrationOAuthStateNonce(db, {
232
+ accountId: state.accountId,
233
+ workspaceId: state.workspaceId,
234
+ subjectId: state.subjectId,
235
+ nonce: state.nonce,
236
+ expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
237
+ now: new Date(),
238
+ });
239
+ if (!consumed) {
240
+ throw new HTTPException(400, { message: "OAuth state has already been used" });
241
+ }
242
+ } catch (error) {
243
+ const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
244
+ logOAuthCallbackFailure(observability, staged, state);
245
+ return {
246
+ redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", {
247
+ reason: staged.reason,
248
+ }),
249
+ };
189
250
  }
190
251
 
191
252
  try {
@@ -194,67 +255,83 @@ export async function completeMcpOAuthCallback(
194
255
  const key = requireEnvironmentEncryption(settings);
195
256
  const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
196
257
  const client = await clientForState(db, settings, state);
197
- const token = await exchangeAuthorizationCode(settings, {
198
- code: input.code,
199
- verifier,
200
- redirectUri,
201
- resource: state.resource,
202
- tokenEndpoint: state.tokenEndpoint,
203
- client,
204
- });
205
- const tools = await verifyMcpToolsList(settings, state.resource, token);
258
+ const token = await runCallbackStage("token_exchange", "token_exchange_failed", () =>
259
+ exchangeAuthorizationCode(settings, {
260
+ code: input.code!,
261
+ verifier,
262
+ redirectUri,
263
+ resource: state.resource,
264
+ tokenEndpoint: state.tokenEndpoint,
265
+ client,
266
+ }),
267
+ );
268
+ const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
206
269
  const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
207
270
  const credential = credentialBundle(token, state, client);
208
271
  const metadata = {
209
272
  resource: state.resource,
273
+ mcpUrl: state.mcpUrl,
210
274
  authorizationServer: state.authorizationServer,
211
275
  authorizationServerIssuer: state.issuer,
212
276
  tokenEndpoint: state.tokenEndpoint,
213
277
  clientId: client.clientId,
214
278
  clientRegistrationMethod: state.clientRegistrationMethod,
215
- mcpTools: tools,
279
+ mcpToolsVerification: verification.metadata,
280
+ ...(verification.tools ? { mcpTools: verification.tools } : {}),
216
281
  };
217
282
  const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
218
- const connection = state.connectionId
219
- ? await updateConnection(db, {
220
- workspaceId: state.workspaceId,
221
- connectionId: state.connectionId,
222
- visibleToSubjectId: state.subjectId,
223
- expectedVersion: state.connectionVersion,
224
- providerDomain: state.providerDomain,
225
- kind: "oauth2",
226
- status: "active",
227
- credentialEncrypted,
228
- grantedScopes: scopes,
229
- expiresAt: token.expiresAt,
230
- metadata,
231
- updatedBySubjectId: state.subjectId,
232
- })
233
- : await createConnection(db, {
234
- accountId: state.accountId,
235
- workspaceId: state.workspaceId,
236
- subjectId: null,
237
- providerDomain: state.providerDomain,
238
- kind: "oauth2",
239
- credentialEncrypted,
240
- grantedScopes: scopes,
241
- expiresAt: token.expiresAt,
242
- metadata,
243
- createdBySubjectId: state.subjectId,
244
- });
283
+ const connection = await runCallbackStage("persist", "persist_failed", () =>
284
+ state.connectionId
285
+ ? updateConnection(db, {
286
+ workspaceId: state.workspaceId,
287
+ connectionId: state.connectionId,
288
+ visibleToSubjectId: state.subjectId,
289
+ expectedVersion: state.connectionVersion,
290
+ providerDomain: state.providerDomain,
291
+ kind: "oauth2",
292
+ status: "active",
293
+ credentialEncrypted,
294
+ grantedScopes: scopes,
295
+ expiresAt: token.expiresAt,
296
+ metadata,
297
+ updatedBySubjectId: state.subjectId,
298
+ })
299
+ : createConnection(db, {
300
+ accountId: state.accountId,
301
+ workspaceId: state.workspaceId,
302
+ subjectId: null,
303
+ providerDomain: state.providerDomain,
304
+ kind: "oauth2",
305
+ credentialEncrypted,
306
+ grantedScopes: scopes,
307
+ expiresAt: token.expiresAt,
308
+ metadata,
309
+ createdBySubjectId: state.subjectId,
310
+ }),
311
+ );
245
312
  if (!connection) {
246
- throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again" });
313
+ throw new HTTPException(409, {
314
+ message: "connection changed during OAuth reconnect; start again",
315
+ });
247
316
  }
248
317
  // Carry the canonical providerDomain (not just the id) so the SPA can build
249
318
  // the enable connectionRef straight from the redirect, without a listConnections
250
319
  // round-trip that could fail (transient, or a grant lacking connections:read)
251
320
  // and leave the connection created but the capability un-enabled.
252
- return { redirectTo: callbackReturnPath(state.returnPath, "success", { connectionId: connection.id, providerDomain: connection.providerDomain }) };
321
+ return {
322
+ redirectTo: callbackReturnPath(state.returnPath, "success", {
323
+ connectionId: connection.id,
324
+ providerDomain: connection.providerDomain,
325
+ ...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
326
+ }),
327
+ };
253
328
  } catch (error) {
254
- if (error instanceof HTTPException && error.status >= 400 && error.status < 500) {
255
- throw error;
256
- }
257
- return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "oauth_callback_failed" }) };
329
+ const staged =
330
+ error instanceof OAuthCallbackStageError
331
+ ? error
332
+ : new OAuthCallbackStageError("persist", "persist_failed", error);
333
+ logOAuthCallbackFailure(observability, staged, state);
334
+ return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
258
335
  }
259
336
  }
260
337
 
@@ -265,30 +342,46 @@ export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl
265
342
  export function requireIntegrationsStateSecret(settings: Settings): string {
266
343
  const secret = settings.integrationsStateSecret?.trim();
267
344
  if (!secret) {
268
- throw new HTTPException(503, { message: "integrations OAuth requires OPENGENI_INTEGRATIONS_STATE_SECRET" });
345
+ throw new HTTPException(503, {
346
+ message: "integrations OAuth requires OPENGENI_INTEGRATIONS_STATE_SECRET",
347
+ });
269
348
  }
270
349
  return secret;
271
350
  }
272
351
 
273
- async function discoverMcpOAuth(resource: string, settings: Settings): Promise<{
352
+ async function discoverMcpOAuth(
353
+ resource: string,
354
+ settings: Settings,
355
+ ): Promise<{
274
356
  challenge: WwwAuthenticateChallenge;
275
357
  prm: ProtectedResourceMetadata;
276
358
  as: AuthorizationServerMetadata;
277
359
  }> {
278
360
  const challenge = await probeMcpChallenge(resource, settings);
279
- const prm = await discoverProtectedResourceMetadata(resource, settings, challenge.resourceMetadata);
361
+ const prm = await discoverProtectedResourceMetadata(
362
+ resource,
363
+ settings,
364
+ challenge.resourceMetadata,
365
+ );
280
366
  const authorizationServer = prm.authorizationServers[0];
281
367
  if (!authorizationServer) {
282
- throw new HTTPException(422, { message: "MCP protected resource metadata did not advertise an authorization server" });
368
+ throw new HTTPException(422, {
369
+ message: "MCP protected resource metadata did not advertise an authorization server",
370
+ });
283
371
  }
284
372
  const as = await discoverAuthorizationServerMetadata(authorizationServer, settings);
285
373
  if (!as.codeChallengeMethodsSupported.includes("S256")) {
286
- throw new HTTPException(422, { message: "authorization server does not support required PKCE S256" });
374
+ throw new HTTPException(422, {
375
+ message: "authorization server does not support required PKCE S256",
376
+ });
287
377
  }
288
378
  return { challenge, prm, as };
289
379
  }
290
380
 
291
- async function probeMcpChallenge(resource: string, settings: Settings): Promise<WwwAuthenticateChallenge> {
381
+ async function probeMcpChallenge(
382
+ resource: string,
383
+ settings: Settings,
384
+ ): Promise<WwwAuthenticateChallenge> {
292
385
  const response = await fetchOAuth(resource, settings, {
293
386
  method: "GET",
294
387
  headers: { accept: "application/json" },
@@ -332,7 +425,10 @@ async function discoverProtectedResourceMetadata(
332
425
  throw new HTTPException(422, { message: "could not discover MCP protected resource metadata" });
333
426
  }
334
427
 
335
- async function discoverAuthorizationServerMetadata(authorizationServer: string, settings: Settings): Promise<AuthorizationServerMetadata> {
428
+ async function discoverAuthorizationServerMetadata(
429
+ authorizationServer: string,
430
+ settings: Settings,
431
+ ): Promise<AuthorizationServerMetadata> {
336
432
  const candidates = uniqueStrings([
337
433
  authorizationServer,
338
434
  ...wellKnownCandidates(authorizationServer, "oauth-authorization-server"),
@@ -359,12 +455,17 @@ async function discoverAuthorizationServerMetadata(authorizationServer: string,
359
455
  authorizationEndpoint,
360
456
  tokenEndpoint,
361
457
  clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
458
+ tokenEndpointAuthMethodsSupported: stringArray(payload.token_endpoint_auth_methods_supported),
362
459
  codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
363
460
  raw: payload,
364
- ...(stringValue(payload.registration_endpoint) ? { registrationEndpoint: stringValue(payload.registration_endpoint)! } : {}),
461
+ ...(stringValue(payload.registration_endpoint)
462
+ ? { registrationEndpoint: stringValue(payload.registration_endpoint)! }
463
+ : {}),
365
464
  };
366
465
  }
367
- throw new HTTPException(422, { message: "could not discover OAuth authorization server metadata" });
466
+ throw new HTTPException(422, {
467
+ message: "could not discover OAuth authorization server metadata",
468
+ });
368
469
  }
369
470
 
370
471
  async function registerOAuthClient(
@@ -373,6 +474,8 @@ async function registerOAuthClient(
373
474
  as: AuthorizationServerMetadata,
374
475
  metadataUrl: string,
375
476
  redirectUri: string,
477
+ scopes: string[],
478
+ manual: OAuthStartRequest["oauthClient"],
376
479
  ): Promise<OAuthClientRegistration> {
377
480
  const operator = operatorClientForAs(settings, as);
378
481
  if (operator) {
@@ -387,15 +490,41 @@ async function registerOAuthClient(
387
490
  tokenEndpointAuthMethod: "none",
388
491
  };
389
492
  }
493
+ if (manual) {
494
+ return {
495
+ method: "manual",
496
+ issuer: as.issuer,
497
+ authorizationServer: as.authorizationServer,
498
+ clientId: manual.clientId,
499
+ ...(manual.clientSecret ? { clientSecret: manual.clientSecret } : {}),
500
+ tokenEndpointAuthMethod: tokenAuthMethod(
501
+ manual.tokenEndpointAuthMethod,
502
+ Boolean(manual.clientSecret),
503
+ ),
504
+ };
505
+ }
506
+ return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes);
507
+ }
508
+
509
+ async function getOrCreateDynamicClientRegistration(
510
+ db: Database,
511
+ settings: Settings,
512
+ as: AuthorizationServerMetadata,
513
+ redirectUri: string,
514
+ scopes: string[],
515
+ ): Promise<OAuthClientRegistration> {
390
516
  const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
391
- if (storedClient) {
517
+ if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, scopes)) {
392
518
  return {
393
519
  method: "dcr",
394
520
  issuer: storedClient.issuer,
395
521
  authorizationServer: storedClient.authorizationServer,
396
522
  clientId: storedClient.clientId,
397
523
  ...(storedClient.clientSecret ? { clientSecret: storedClient.clientSecret } : {}),
398
- tokenEndpointAuthMethod: tokenAuthMethod(storedClient.tokenEndpointAuthMethod, Boolean(storedClient.clientSecret)),
524
+ tokenEndpointAuthMethod: tokenAuthMethod(
525
+ storedClient.tokenEndpointAuthMethod,
526
+ Boolean(storedClient.clientSecret),
527
+ ),
399
528
  };
400
529
  }
401
530
  if (!as.registrationEndpoint) {
@@ -403,29 +532,56 @@ async function registerOAuthClient(
403
532
  message: "manual OAuth client credentials are required for this authorization server",
404
533
  });
405
534
  }
406
- const dcr = await dynamicClientRegistration(settings, as, redirectUri);
535
+ const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes);
407
536
  const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
408
- const storedWinner = await storeIntegrationOAuthClient(db, {
537
+ const storeInput = {
409
538
  issuer: as.issuer,
410
539
  authorizationServer: as.authorizationServer,
411
540
  clientId: dcr.clientId,
412
- clientSecretEncrypted: dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null,
541
+ clientSecretEncrypted:
542
+ dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null,
413
543
  tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
414
- metadata: {
415
- registrationEndpoint: as.registrationEndpoint,
416
- registeredAt: new Date().toISOString(),
417
- },
418
- });
544
+ metadata: registrationMetadata(as, scopes),
545
+ };
546
+ const storedWinner = await storeIntegrationOAuthClient(db, storeInput);
419
547
  if (storedWinner.clientId !== dcr.clientId) {
420
548
  const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
421
549
  if (!winner) {
422
- throw new HTTPException(422, { message: "OAuth client registration could not be loaded after a registration race" });
550
+ throw new HTTPException(422, {
551
+ message: "OAuth client registration could not be loaded after a registration race",
552
+ });
423
553
  }
424
554
  return dcrRegistrationFromStored(winner);
425
555
  }
426
556
  return dcr;
427
557
  }
428
558
 
559
+ function storedDcrClientSatisfiesPolicy(
560
+ stored: { metadata: Record<string, unknown> },
561
+ scopes: string[],
562
+ ): boolean {
563
+ return registeredScopesMatch(stored.metadata, scopes);
564
+ }
565
+
566
+ function registeredScopesMatch(metadata: Record<string, unknown>, scopes: string[]): boolean {
567
+ return stableScopeKey(stringArray(metadata.registeredScopes)) === stableScopeKey(scopes);
568
+ }
569
+
570
+ function stableScopeKey(scopes: string[]): string {
571
+ return uniqueStrings(scopes).sort().join(" ");
572
+ }
573
+
574
+ function registrationMetadata(
575
+ as: AuthorizationServerMetadata,
576
+ scopes: string[],
577
+ ): Record<string, unknown> {
578
+ return {
579
+ registrationEndpoint: as.registrationEndpoint,
580
+ registeredAt: new Date().toISOString(),
581
+ registeredScopes: uniqueStrings(scopes),
582
+ };
583
+ }
584
+
429
585
  function dcrRegistrationFromStored(stored: {
430
586
  issuer: string;
431
587
  authorizationServer: string;
@@ -439,11 +595,17 @@ function dcrRegistrationFromStored(stored: {
439
595
  authorizationServer: stored.authorizationServer,
440
596
  clientId: stored.clientId,
441
597
  ...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
442
- tokenEndpointAuthMethod: tokenAuthMethod(stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret)),
598
+ tokenEndpointAuthMethod: tokenAuthMethod(
599
+ stored.tokenEndpointAuthMethod,
600
+ Boolean(stored.clientSecret),
601
+ ),
443
602
  };
444
603
  }
445
604
 
446
- function operatorClientForAs(settings: Settings, as: AuthorizationServerMetadata): OAuthClientRegistration | null {
605
+ function operatorClientForAs(
606
+ settings: Settings,
607
+ as: AuthorizationServerMetadata,
608
+ ): OAuthClientRegistration | null {
447
609
  const entry = operatorClientEntryFor(settings, [as.issuer, as.authorizationServer]);
448
610
  if (!entry) {
449
611
  return null;
@@ -454,7 +616,10 @@ function operatorClientForAs(settings: Settings, as: AuthorizationServerMetadata
454
616
  authorizationServer: as.authorizationServer,
455
617
  clientId: entry.clientId,
456
618
  ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
457
- tokenEndpointAuthMethod: tokenAuthMethod(entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret)),
619
+ tokenEndpointAuthMethod: tokenAuthMethod(
620
+ entry.tokenEndpointAuthMethod,
621
+ Boolean(entry.clientSecret),
622
+ ),
458
623
  };
459
624
  }
460
625
 
@@ -463,7 +628,9 @@ function operatorClientEntryFor(
463
628
  candidates: string[],
464
629
  ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
465
630
  const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
466
- const exactKeys = uniqueStrings(candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]));
631
+ const exactKeys = uniqueStrings(
632
+ candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]),
633
+ );
467
634
  for (const key of exactKeys) {
468
635
  const entry = configured[key];
469
636
  if (entry) {
@@ -487,9 +654,12 @@ async function dynamicClientRegistration(
487
654
  settings: Settings,
488
655
  as: AuthorizationServerMetadata,
489
656
  redirectUri: string,
657
+ scopes: string[],
490
658
  ): Promise<OAuthClientRegistration> {
491
659
  if (!as.registrationEndpoint) {
492
- throw new HTTPException(422, { message: "authorization server does not support dynamic client registration" });
660
+ throw new HTTPException(422, {
661
+ message: "authorization server does not support dynamic client registration",
662
+ });
493
663
  }
494
664
  await assertOAuthFetchAllowed(as.registrationEndpoint, settings);
495
665
  const response = await fetchOAuth(as.registrationEndpoint, settings, {
@@ -501,15 +671,20 @@ async function dynamicClientRegistration(
501
671
  token_endpoint_auth_method: "none",
502
672
  grant_types: ["authorization_code", "refresh_token"],
503
673
  response_types: ["code"],
674
+ ...(scopes.length ? { scope: scopes.join(" ") } : {}),
504
675
  }),
505
676
  });
506
677
  if (!response.ok) {
507
- throw new HTTPException(422, { message: `dynamic client registration failed with HTTP ${response.status}` });
678
+ throw new HTTPException(422, {
679
+ message: `dynamic client registration failed with HTTP ${response.status}`,
680
+ });
508
681
  }
509
- const payload = await response.json() as Record<string, unknown>;
682
+ const payload = (await response.json()) as Record<string, unknown>;
510
683
  const clientId = stringValue(payload.client_id);
511
684
  if (!clientId) {
512
- throw new HTTPException(422, { message: "dynamic client registration response did not include client_id" });
685
+ throw new HTTPException(422, {
686
+ message: "dynamic client registration response did not include client_id",
687
+ });
513
688
  }
514
689
  const clientSecret = stringValue(payload.client_secret);
515
690
  return {
@@ -518,10 +693,37 @@ async function dynamicClientRegistration(
518
693
  authorizationServer: as.authorizationServer,
519
694
  clientId,
520
695
  ...(clientSecret ? { clientSecret } : {}),
521
- tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.token_endpoint_auth_method), Boolean(clientSecret)),
696
+ tokenEndpointAuthMethod: tokenAuthMethod(
697
+ stringValue(payload.token_endpoint_auth_method),
698
+ Boolean(clientSecret),
699
+ ),
522
700
  };
523
701
  }
524
702
 
703
+ async function existingOAuthConnectionForStart(
704
+ db: Database,
705
+ input: {
706
+ workspaceId: string;
707
+ subjectId: string;
708
+ providerDomain: string;
709
+ connectionId?: string | undefined;
710
+ },
711
+ ) {
712
+ if (input.connectionId) {
713
+ return await getConnectionMetadata(db, input.workspaceId, input.connectionId, input.subjectId);
714
+ }
715
+ const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
716
+ return (
717
+ visible.find(
718
+ (connection) =>
719
+ connection.subjectId === null &&
720
+ connection.kind === "oauth2" &&
721
+ connection.status === "active" &&
722
+ connection.providerDomain === input.providerDomain,
723
+ ) ?? null
724
+ );
725
+ }
726
+
525
727
  function buildAuthorizationUrl(input: {
526
728
  endpoint: string;
527
729
  clientId: string;
@@ -546,7 +748,10 @@ function buildAuthorizationUrl(input: {
546
748
  }
547
749
 
548
750
  function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
549
- const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<string, unknown> | null;
751
+ const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<
752
+ string,
753
+ unknown
754
+ > | null;
550
755
  if (!payload) {
551
756
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
552
757
  }
@@ -555,21 +760,29 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
555
760
  if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
556
761
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
557
762
  }
763
+ const resource = requiredString(payload.resource, "state.resource");
558
764
  const parsed = {
559
765
  accountId: requiredString(payload.accountId, "state.accountId"),
560
766
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
561
767
  subjectId: requiredString(payload.subjectId, "state.subjectId"),
562
768
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
563
- resource: requiredString(payload.resource, "state.resource"),
769
+ mcpUrl: stringValue(payload.mcpUrl) ?? resource,
770
+ resource,
564
771
  requestedScopes: stringArray(payload.requestedScopes),
565
772
  authorizeScopes: stringArray(payload.authorizeScopes),
566
- encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
773
+ encryptedPkceVerifier: requiredString(
774
+ payload.encryptedPkceVerifier,
775
+ "state.encryptedPkceVerifier",
776
+ ),
567
777
  clientId: requiredString(payload.clientId, "state.clientId"),
568
778
  tokenEndpoint: requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
569
779
  authorizationServer: requiredString(payload.authorizationServer, "state.authorizationServer"),
570
780
  issuer: requiredString(payload.issuer, "state.issuer"),
571
781
  clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
572
782
  tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
783
+ ...(stringValue(payload.encryptedClientSecret)
784
+ ? { encryptedClientSecret: stringValue(payload.encryptedClientSecret)! }
785
+ : {}),
573
786
  returnPath: safeReturnPath(stringValue(payload.returnPath) ?? "/integrations"),
574
787
  nonce: requiredString(payload.nonce, "state.nonce"),
575
788
  iat,
@@ -583,7 +796,11 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
583
796
  };
584
797
  }
585
798
 
586
- async function clientForState(db: Database, settings: Settings, state: OAuthStatePayload): Promise<OAuthClientRegistration> {
799
+ async function clientForState(
800
+ db: Database,
801
+ settings: Settings,
802
+ state: OAuthStatePayload,
803
+ ): Promise<OAuthClientRegistration> {
587
804
  if (state.clientRegistrationMethod === "cimd") {
588
805
  return {
589
806
  method: "cimd",
@@ -593,6 +810,19 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
593
810
  tokenEndpointAuthMethod: "none",
594
811
  };
595
812
  }
813
+ if (state.clientRegistrationMethod === "manual") {
814
+ const key = requireEnvironmentEncryption(settings);
815
+ return {
816
+ method: "manual",
817
+ issuer: state.issuer,
818
+ authorizationServer: state.authorizationServer,
819
+ clientId: state.clientId,
820
+ ...(state.encryptedClientSecret
821
+ ? { clientSecret: decryptEnvironmentValue(key, state.encryptedClientSecret) }
822
+ : {}),
823
+ tokenEndpointAuthMethod: state.tokenEndpointAuthMethod,
824
+ };
825
+ }
596
826
  if (state.clientRegistrationMethod === "dcr") {
597
827
  const stored = await loadIntegrationOAuthClient(db, settings, state.issuer);
598
828
  if (!stored || stored.clientId !== state.clientId) {
@@ -604,12 +834,17 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
604
834
  authorizationServer: stored.authorizationServer,
605
835
  clientId: stored.clientId,
606
836
  ...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
607
- tokenEndpointAuthMethod: tokenAuthMethod(stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret)),
837
+ tokenEndpointAuthMethod: tokenAuthMethod(
838
+ stored.tokenEndpointAuthMethod,
839
+ Boolean(stored.clientSecret),
840
+ ),
608
841
  };
609
842
  }
610
843
  const entry = operatorClientEntryFor(settings, [state.issuer, state.authorizationServer]);
611
844
  if (!entry || entry.clientId !== state.clientId) {
612
- throw new HTTPException(400, { message: "operator OAuth client credentials are no longer available" });
845
+ throw new HTTPException(400, {
846
+ message: "operator OAuth client credentials are no longer available",
847
+ });
613
848
  }
614
849
  return {
615
850
  method: "operator",
@@ -617,7 +852,10 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
617
852
  authorizationServer: state.authorizationServer,
618
853
  clientId: entry.clientId,
619
854
  ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
620
- tokenEndpointAuthMethod: tokenAuthMethod(entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret)),
855
+ tokenEndpointAuthMethod: tokenAuthMethod(
856
+ entry.tokenEndpointAuthMethod,
857
+ Boolean(entry.clientSecret),
858
+ ),
621
859
  };
622
860
  }
623
861
 
@@ -639,18 +877,35 @@ async function exchangeAuthorizationCode(
639
877
  body.set("redirect_uri", input.redirectUri);
640
878
  body.set("code_verifier", input.verifier);
641
879
  body.set("resource", input.resource);
642
- body.set("client_id", input.client.clientId);
643
- const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
880
+ const headers: Record<string, string> = {
881
+ "content-type": "application/x-www-form-urlencoded",
882
+ accept: "application/json",
883
+ };
644
884
  if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_post") {
885
+ body.set("client_id", input.client.clientId);
645
886
  body.set("client_secret", input.client.clientSecret);
646
- } else if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_basic") {
887
+ } else if (
888
+ input.client.clientSecret &&
889
+ input.client.tokenEndpointAuthMethod === "client_secret_basic"
890
+ ) {
647
891
  headers.authorization = `Basic ${Buffer.from(`${input.client.clientId}:${input.client.clientSecret}`).toString("base64")}`;
892
+ } else {
893
+ body.set("client_id", input.client.clientId);
648
894
  }
649
- const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
895
+ const response = await fetchOAuth(input.tokenEndpoint, settings, {
896
+ method: "POST",
897
+ headers,
898
+ body,
899
+ });
650
900
  if (!response.ok) {
651
- throw new Error(`OAuth token endpoint returned HTTP ${response.status}`);
901
+ const oauthError = await oauthErrorFromResponse(response);
902
+ throw new OAuthCallbackStageError(
903
+ "token_exchange",
904
+ oauthError ?? "token_exchange_failed",
905
+ new Error(`OAuth token endpoint returned HTTP ${response.status}`),
906
+ );
652
907
  }
653
- const payload = await response.json() as Record<string, unknown>;
908
+ const payload = (await response.json()) as Record<string, unknown>;
654
909
  const accessToken = stringValue(payload.access_token);
655
910
  if (!accessToken) {
656
911
  throw new Error("OAuth token response did not include access_token");
@@ -660,22 +915,124 @@ async function exchangeAuthorizationCode(
660
915
  tokenType: stringValue(payload.token_type) ?? "Bearer",
661
916
  expiresAt: expiresAtFromTokenResponse(payload),
662
917
  raw: payload,
663
- ...(stringValue(payload.refresh_token) ? { refreshToken: stringValue(payload.refresh_token)! } : {}),
918
+ ...(stringValue(payload.refresh_token)
919
+ ? { refreshToken: stringValue(payload.refresh_token)! }
920
+ : {}),
664
921
  ...(stringValue(payload.scope) ? { scopeText: stringValue(payload.scope)! } : {}),
665
922
  };
666
923
  }
667
924
 
668
- async function verifyMcpToolsList(settings: Settings, resource: string, token: TokenResponse): Promise<Array<{ name: string; description?: string }>> {
925
+ async function runCallbackStage<T>(
926
+ callbackStage: OAuthCallbackStage,
927
+ fallbackReason: string,
928
+ fn: () => Promise<T>,
929
+ ): Promise<T> {
930
+ try {
931
+ return await fn();
932
+ } catch (error) {
933
+ if (error instanceof OAuthCallbackStageError) {
934
+ throw error;
935
+ }
936
+ throw new OAuthCallbackStageError(callbackStage, fallbackReason, error);
937
+ }
938
+ }
939
+
940
+ function logOAuthCallbackFailure(
941
+ observability: Observability | undefined,
942
+ error: OAuthCallbackStageError,
943
+ state: OAuthStatePayload | null,
944
+ ): void {
945
+ observability?.error("MCP OAuth callback failed", {
946
+ "opengeni.oauth.stage": error.stage,
947
+ "opengeni.oauth.reason": error.reason,
948
+ "opengeni.oauth.provider_domain": state?.providerDomain,
949
+ "opengeni.oauth.resource_host": state ? safeHost(state.resource) : undefined,
950
+ "opengeni.oauth.authorization_server": state?.authorizationServer,
951
+ "opengeni.oauth.issuer": state?.issuer,
952
+ "opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
953
+ error: sanitizedError(error.cause),
954
+ });
955
+ }
956
+
957
+ function logOAuthVerificationWarning(
958
+ observability: Observability | undefined,
959
+ error: OAuthCallbackStageError,
960
+ state: OAuthStatePayload,
961
+ ): void {
962
+ observability?.warn("MCP OAuth tools/list verification failed after token exchange", {
963
+ "opengeni.oauth.stage": error.stage,
964
+ "opengeni.oauth.reason": error.reason,
965
+ "opengeni.oauth.provider_domain": state.providerDomain,
966
+ "opengeni.oauth.resource_host": safeHost(state.resource),
967
+ "opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
968
+ "opengeni.oauth.authorization_server": state.authorizationServer,
969
+ "opengeni.oauth.issuer": state.issuer,
970
+ "opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
971
+ error: sanitizedError(error.cause),
972
+ });
973
+ }
974
+
975
+ function sanitizedError(error: unknown): string {
976
+ if (error instanceof HTTPException) {
977
+ return `HTTPException ${error.status}: ${error.message}`;
978
+ }
979
+ if (error instanceof Error) {
980
+ return `${error.name}: ${error.message}`;
981
+ }
982
+ return String(error);
983
+ }
984
+
985
+ function errorMessage(error: unknown): string {
986
+ return error instanceof Error ? error.message : String(error);
987
+ }
988
+
989
+ function safeHost(rawUrl: string): string | undefined {
990
+ try {
991
+ return new URL(rawUrl).host;
992
+ } catch {
993
+ return undefined;
994
+ }
995
+ }
996
+
997
+ async function oauthErrorFromResponse(response: Response): Promise<string | null> {
998
+ const contentType = response.headers.get("content-type") ?? "";
999
+ if (!contentType.toLowerCase().includes("application/json")) {
1000
+ return null;
1001
+ }
1002
+ const payload = (await response
1003
+ .clone()
1004
+ .json()
1005
+ .catch(() => null)) as Record<string, unknown> | null;
1006
+ const error = stringValue(payload?.error);
1007
+ if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
1008
+ return null;
1009
+ }
1010
+ return error;
1011
+ }
1012
+
1013
+ async function verifyMcpToolsList(
1014
+ settings: Settings,
1015
+ resource: string,
1016
+ token: TokenResponse,
1017
+ ): Promise<Array<{ name: string; description?: string }>> {
669
1018
  await assertOAuthFetchAllowed(resource, settings);
670
- const client = new Client({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
1019
+ const client = new Client(
1020
+ { name: "opengeni-integration-verify", version: "0.1.0" },
1021
+ { capabilities: {} },
1022
+ );
671
1023
  try {
672
1024
  const transport = new StreamableHTTPClientTransport(new URL(resource), {
673
1025
  requestInit: {
674
- headers: { authorization: `${token.tokenType} ${token.accessToken}` },
1026
+ headers: {
1027
+ authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`,
1028
+ },
675
1029
  },
676
1030
  fetch: (url, init) => fetchOAuth(url.toString(), settings, init),
677
1031
  });
678
- await client.connect(transport as unknown as Transport, { timeout: 10_000, maxTotalTimeout: 10_000 });
1032
+ await client.connect(transport as unknown as Transport, {
1033
+ timeout: 10_000,
1034
+ maxTotalTimeout: 10_000,
1035
+ });
679
1036
  const listed = await client.listTools(undefined, { timeout: 10_000, maxTotalTimeout: 10_000 });
680
1037
  return listed.tools.map((tool) => ({
681
1038
  name: tool.name,
@@ -686,21 +1043,78 @@ async function verifyMcpToolsList(settings: Settings, resource: string, token: T
686
1043
  }
687
1044
  }
688
1045
 
689
- function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client: OAuthClientRegistration): Record<string, unknown> {
1046
+ async function verifyMcpToolsListNonFatal(
1047
+ observability: Observability | undefined,
1048
+ settings: Settings,
1049
+ state: OAuthStatePayload,
1050
+ token: TokenResponse,
1051
+ ): Promise<{
1052
+ metadata:
1053
+ | { status: "ok"; checkedAt: string; toolCount: number }
1054
+ | { status: "failed"; checkedAt: string; reason: string };
1055
+ tools?: Array<{ name: string; description?: string }>;
1056
+ }> {
1057
+ try {
1058
+ const tools = await runCallbackStage("tools_list", "tools_list_failed", () =>
1059
+ verifyMcpToolsList(settings, state.mcpUrl, token),
1060
+ );
1061
+ return {
1062
+ metadata: {
1063
+ status: "ok",
1064
+ checkedAt: new Date().toISOString(),
1065
+ toolCount: tools.length,
1066
+ },
1067
+ tools,
1068
+ };
1069
+ } catch (error) {
1070
+ const staged =
1071
+ error instanceof OAuthCallbackStageError
1072
+ ? error
1073
+ : new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
1074
+ logOAuthVerificationWarning(observability, staged, state);
1075
+ return {
1076
+ metadata: {
1077
+ status: "failed",
1078
+ checkedAt: new Date().toISOString(),
1079
+ reason: staged.reason,
1080
+ },
1081
+ };
1082
+ }
1083
+ }
1084
+
1085
+ function credentialBundle(
1086
+ token: TokenResponse,
1087
+ state: OAuthStatePayload,
1088
+ client: OAuthClientRegistration,
1089
+ ): Record<string, unknown> {
690
1090
  return {
691
1091
  access_token: token.accessToken,
692
1092
  ...(token.refreshToken ? { refresh_token: token.refreshToken } : {}),
693
1093
  token_type: token.tokenType,
694
1094
  ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
695
1095
  resource: state.resource,
696
- ...(token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {}),
1096
+ mcp_url: state.mcpUrl,
1097
+ ...(token.scopeText
1098
+ ? { scope: token.scopeText }
1099
+ : state.authorizeScopes.length
1100
+ ? { scope: state.authorizeScopes.join(" ") }
1101
+ : {}),
697
1102
  token_endpoint: state.tokenEndpoint,
698
1103
  client_id: client.clientId,
699
- ...(client.clientSecret ? { client_secret: client.clientSecret, token_endpoint_auth_method: client.tokenEndpointAuthMethod } : {}),
1104
+ ...(client.clientSecret
1105
+ ? {
1106
+ client_secret: client.clientSecret,
1107
+ token_endpoint_auth_method: client.tokenEndpointAuthMethod,
1108
+ }
1109
+ : {}),
700
1110
  };
701
1111
  }
702
1112
 
703
- function callbackReturnPath(returnPath: string, status: "success" | "error", params: Record<string, string>): string {
1113
+ function callbackReturnPath(
1114
+ returnPath: string,
1115
+ status: "success" | "error",
1116
+ params: Record<string, string>,
1117
+ ): string {
704
1118
  const url = new URL(returnPath, "https://opengeni.local");
705
1119
  url.searchParams.set("integration_oauth", status);
706
1120
  for (const [key, value] of Object.entries(params)) {
@@ -723,6 +1137,27 @@ function canonicalMcpResource(value: string | undefined): string {
723
1137
  return url.toString();
724
1138
  }
725
1139
 
1140
+ function canonicalOAuthResource(value: string): string {
1141
+ const trimmed = value.trim();
1142
+ if (!trimmed) {
1143
+ throw new HTTPException(422, {
1144
+ message: "MCP protected resource metadata advertised an invalid resource",
1145
+ });
1146
+ }
1147
+ try {
1148
+ const url = new URL(trimmed);
1149
+ if (url.protocol === "http:" || url.protocol === "https:") {
1150
+ url.hash = "";
1151
+ return url.toString();
1152
+ }
1153
+ return trimmed;
1154
+ } catch {
1155
+ throw new HTTPException(422, {
1156
+ message: "MCP protected resource metadata advertised an invalid resource",
1157
+ });
1158
+ }
1159
+ }
1160
+
726
1161
  function safeReturnPath(value: string): string {
727
1162
  if (!value.startsWith("/") || value.startsWith("//")) {
728
1163
  throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
@@ -746,7 +1181,12 @@ async function fetchJsonObject(url: string, settings: Settings): Promise<Record<
746
1181
  return payload as Record<string, unknown>;
747
1182
  }
748
1183
 
749
- async function fetchOAuth(rawUrl: string, settings: Settings, init: RequestInit = {}, hop = 0): Promise<Response> {
1184
+ async function fetchOAuth(
1185
+ rawUrl: string,
1186
+ settings: Settings,
1187
+ init: RequestInit = {},
1188
+ hop = 0,
1189
+ ): Promise<Response> {
750
1190
  await assertOAuthFetchAllowed(rawUrl, settings);
751
1191
  const response = await fetch(rawUrl, { ...init, redirect: "manual" });
752
1192
  if (response.status < 300 || response.status >= 400) {
@@ -773,20 +1213,29 @@ async function assertOAuthFetchAllowed(rawUrl: string, settings: Settings): Prom
773
1213
  if (!["https:", "http:"].includes(url.protocol)) {
774
1214
  throw new HTTPException(422, { message: "OAuth discovery only supports http and https URLs" });
775
1215
  }
776
- if (settings.integrationsAllowPrivateNetworkTargets || ["local", "test"].includes(settings.environment)) {
1216
+ if (
1217
+ settings.integrationsAllowPrivateNetworkTargets ||
1218
+ ["local", "test"].includes(settings.environment)
1219
+ ) {
777
1220
  return;
778
1221
  }
779
1222
  if (url.protocol !== "https:") {
780
- throw new HTTPException(422, { message: "OAuth discovery targets must use https outside local/test" });
1223
+ throw new HTTPException(422, {
1224
+ message: "OAuth discovery targets must use https outside local/test",
1225
+ });
781
1226
  }
782
1227
  const hostname = url.hostname.toLowerCase();
783
1228
  if (hostname === "localhost" || hostname.endsWith(".localhost")) {
784
1229
  throw new HTTPException(422, { message: "OAuth discovery may not target localhost" });
785
1230
  }
786
1231
  const literal = isIP(hostname);
787
- const addresses = literal ? [hostname] : (await lookup(hostname, { all: true })).map((entry) => entry.address);
1232
+ const addresses = literal
1233
+ ? [hostname]
1234
+ : (await lookup(hostname, { all: true })).map((entry) => entry.address);
788
1235
  if (addresses.some(isPrivateAddress)) {
789
- throw new HTTPException(422, { message: "OAuth discovery may not target private network addresses" });
1236
+ throw new HTTPException(422, {
1237
+ message: "OAuth discovery may not target private network addresses",
1238
+ });
790
1239
  }
791
1240
  }
792
1241
 
@@ -804,7 +1253,9 @@ function parseWwwAuthenticate(header: string | null): WwwAuthenticateChallenge {
804
1253
  let match: RegExpExecArray | null;
805
1254
  while ((match = re.exec(paramsText)) !== null) {
806
1255
  const raw = match[2]!;
807
- params[match[1]!.toLowerCase()] = raw.startsWith("\"") ? raw.slice(1, -1).replace(/\\"/g, "\"") : raw;
1256
+ params[match[1]!.toLowerCase()] = raw.startsWith('"')
1257
+ ? raw.slice(1, -1).replace(/\\"/g, '"')
1258
+ : raw;
808
1259
  }
809
1260
  return {
810
1261
  ...(params.resource_metadata ? { resourceMetadata: params.resource_metadata } : {}),
@@ -823,7 +1274,11 @@ function wellKnownCandidates(rawUrl: string, name: string): string[] {
823
1274
  ]);
824
1275
  }
825
1276
 
826
- function chooseAuthorizeScopes(requested: string[] | undefined, challenged: string[] | undefined, supported: string[]): string[] {
1277
+ function chooseAuthorizeScopes(
1278
+ requested: string[] | undefined,
1279
+ challenged: string[] | undefined,
1280
+ supported: string[],
1281
+ ): string[] {
827
1282
  if (requested?.length) {
828
1283
  return uniqueStrings(requested);
829
1284
  }
@@ -840,7 +1295,10 @@ function grantedScopes(scopeText: string | undefined, fallback: string[]): strin
840
1295
  return fallback;
841
1296
  }
842
1297
 
843
- function tokenAuthMethod(raw: string | undefined, hasSecret: boolean): OAuthClientRegistration["tokenEndpointAuthMethod"] {
1298
+ function tokenAuthMethod(
1299
+ raw: string | undefined,
1300
+ hasSecret: boolean,
1301
+ ): OAuthClientRegistration["tokenEndpointAuthMethod"] {
844
1302
  if (raw === "client_secret_post" || raw === "client_secret_basic") {
845
1303
  return raw;
846
1304
  }
@@ -848,7 +1306,7 @@ function tokenAuthMethod(raw: string | undefined, hasSecret: boolean): OAuthClie
848
1306
  }
849
1307
 
850
1308
  function registrationMethod(value: unknown): OAuthClientRegistration["method"] {
851
- if (value === "operator" || value === "cimd" || value === "dcr") {
1309
+ if (value === "operator" || value === "manual" || value === "cimd" || value === "dcr") {
852
1310
  return value;
853
1311
  }
854
1312
  throw new HTTPException(400, { message: "invalid OAuth state" });
@@ -860,7 +1318,8 @@ function expiresAtFromTokenResponse(payload: Record<string, unknown>): Date | nu
860
1318
  const parsed = new Date(expiresAt);
861
1319
  return Number.isNaN(parsed.getTime()) ? null : parsed;
862
1320
  }
863
- const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in);
1321
+ const expiresIn =
1322
+ typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in);
864
1323
  if (Number.isFinite(expiresIn) && expiresIn > 0) {
865
1324
  return new Date(Date.now() + expiresIn * 1000);
866
1325
  }
@@ -880,7 +1339,9 @@ function uniqueStrings(values: string[]): string[] {
880
1339
  }
881
1340
 
882
1341
  function stringArray(value: unknown): string[] {
883
- return Array.isArray(value) ? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string")) : [];
1342
+ return Array.isArray(value)
1343
+ ? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string"))
1344
+ : [];
884
1345
  }
885
1346
 
886
1347
  function stringValue(value: unknown): string | undefined {