@opengeni/api-router 0.4.1 → 0.5.1

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.
@@ -0,0 +1,899 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
+ import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
5
+ import { OAuthStartResponse, type OAuthStartRequest } from "@opengeni/contracts";
6
+ import { requireEnvironmentEncryption } from "@opengeni/core";
7
+ import {
8
+ consumeIntegrationOAuthStateNonce,
9
+ createConnection,
10
+ decryptEnvironmentValue,
11
+ encryptEnvironmentValue,
12
+ getConnectionMetadata,
13
+ isPrivateAddress,
14
+ loadIntegrationOAuthClient,
15
+ storeIntegrationOAuthClient,
16
+ updateConnection,
17
+ type Database,
18
+ } from "@opengeni/db";
19
+ import { createSignedState, readSignedState } from "@opengeni/github";
20
+ import { Buffer } from "node:buffer";
21
+ import { createHash, randomBytes } from "node:crypto";
22
+ import { lookup } from "node:dns/promises";
23
+ import { isIP } from "node:net";
24
+ import { HTTPException } from "hono/http-exception";
25
+
26
+ export const oauthStateTtlMs = 10 * 60 * 1000;
27
+
28
+ type OAuthClientDeps = {
29
+ db: Database;
30
+ settings: Settings;
31
+ };
32
+
33
+ export type OAuthStartContext = {
34
+ accountId: string;
35
+ workspaceId: string;
36
+ subjectId: string;
37
+ requestUrl: string;
38
+ payload: OAuthStartRequest;
39
+ };
40
+
41
+ export type OAuthCallbackResult = {
42
+ redirectTo: string;
43
+ };
44
+
45
+ type WwwAuthenticateChallenge = {
46
+ resourceMetadata?: string;
47
+ scope?: string[];
48
+ error?: string;
49
+ };
50
+
51
+ type ProtectedResourceMetadata = {
52
+ resource?: string;
53
+ authorizationServers: string[];
54
+ scopesSupported: string[];
55
+ raw: Record<string, unknown>;
56
+ };
57
+
58
+ type AuthorizationServerMetadata = {
59
+ issuer: string;
60
+ authorizationServer: string;
61
+ authorizationEndpoint: string;
62
+ tokenEndpoint: string;
63
+ registrationEndpoint?: string;
64
+ clientIdMetadataDocumentSupported: boolean;
65
+ codeChallengeMethodsSupported: string[];
66
+ raw: Record<string, unknown>;
67
+ };
68
+
69
+ type OAuthClientRegistration = {
70
+ method: "operator" | "cimd" | "dcr";
71
+ issuer: string;
72
+ authorizationServer: string;
73
+ clientId: string;
74
+ clientSecret?: string;
75
+ tokenEndpointAuthMethod: "none" | "client_secret_post" | "client_secret_basic";
76
+ };
77
+
78
+ type OAuthStatePayload = {
79
+ accountId: string;
80
+ workspaceId: string;
81
+ subjectId: string;
82
+ providerDomain: string;
83
+ resource: string;
84
+ requestedScopes: string[];
85
+ authorizeScopes: string[];
86
+ encryptedPkceVerifier: string;
87
+ clientId: string;
88
+ tokenEndpoint: string;
89
+ authorizationServer: string;
90
+ issuer: string;
91
+ clientRegistrationMethod: OAuthClientRegistration["method"];
92
+ tokenEndpointAuthMethod: OAuthClientRegistration["tokenEndpointAuthMethod"];
93
+ returnPath: string;
94
+ connectionId?: string;
95
+ connectionVersion?: number;
96
+ nonce: string;
97
+ iat: number;
98
+ };
99
+
100
+ type TokenResponse = {
101
+ accessToken: string;
102
+ refreshToken?: string;
103
+ tokenType: string;
104
+ expiresAt: Date | null;
105
+ scopeText?: string;
106
+ raw: Record<string, unknown>;
107
+ };
108
+
109
+ export async function startMcpOAuth(
110
+ deps: OAuthClientDeps,
111
+ context: OAuthStartContext,
112
+ ): Promise<OAuthStartResponse> {
113
+ const { db, settings } = deps;
114
+ const resource = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
115
+ const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(resource).hostname);
116
+ const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
117
+ const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
118
+ const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
119
+ const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
120
+ const existing = context.payload.connectionId
121
+ ? await getConnectionMetadata(db, context.workspaceId, context.payload.connectionId, context.subjectId)
122
+ : null;
123
+ if (context.payload.connectionId && !existing) {
124
+ throw new HTTPException(404, { message: "connection not found" });
125
+ }
126
+
127
+ const discovery = await discoverMcpOAuth(resource, settings);
128
+ const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
129
+ const verifier = randomPkceVerifier();
130
+ const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
131
+ const key = requireEnvironmentEncryption(settings);
132
+ const state = createSignedState(requireIntegrationsStateSecret(settings), {
133
+ accountId: context.accountId,
134
+ workspaceId: context.workspaceId,
135
+ subjectId: context.subjectId,
136
+ providerDomain,
137
+ resource,
138
+ requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
139
+ authorizeScopes,
140
+ encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
141
+ clientId: client.clientId,
142
+ tokenEndpoint: discovery.as.tokenEndpoint,
143
+ authorizationServer: client.authorizationServer,
144
+ issuer: client.issuer,
145
+ clientRegistrationMethod: client.method,
146
+ tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
147
+ returnPath,
148
+ ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
149
+ });
150
+ const authorizationUrl = buildAuthorizationUrl({
151
+ endpoint: discovery.as.authorizationEndpoint,
152
+ clientId: client.clientId,
153
+ redirectUri,
154
+ state,
155
+ resource,
156
+ verifier,
157
+ scopes: authorizeScopes,
158
+ });
159
+ return OAuthStartResponse.parse({
160
+ state,
161
+ authorizationUrl,
162
+ expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
163
+ });
164
+ }
165
+
166
+ export async function completeMcpOAuthCallback(
167
+ deps: OAuthClientDeps,
168
+ input: { code?: string | undefined; state?: string | undefined; requestUrl: string },
169
+ ): Promise<OAuthCallbackResult> {
170
+ const { db, settings } = deps;
171
+ if (!input.state) {
172
+ throw new HTTPException(400, { message: "missing OAuth state" });
173
+ }
174
+ const state = readOAuthState(input.state, settings);
175
+ if (!input.code) {
176
+ return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
177
+ }
178
+ const consumed = await consumeIntegrationOAuthStateNonce(db, {
179
+ accountId: state.accountId,
180
+ workspaceId: state.workspaceId,
181
+ subjectId: state.subjectId,
182
+ nonce: state.nonce,
183
+ expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
184
+ now: new Date(),
185
+ });
186
+ if (!consumed) {
187
+ throw new HTTPException(400, { message: "OAuth state has already been used" });
188
+ }
189
+
190
+ try {
191
+ const baseUrl = integrationBaseUrl(settings.publicBaseUrl, input.requestUrl);
192
+ const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
193
+ const key = requireEnvironmentEncryption(settings);
194
+ const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
195
+ const client = await clientForState(db, settings, state);
196
+ const token = await exchangeAuthorizationCode(settings, {
197
+ code: input.code,
198
+ verifier,
199
+ redirectUri,
200
+ resource: state.resource,
201
+ tokenEndpoint: state.tokenEndpoint,
202
+ client,
203
+ });
204
+ const tools = await verifyMcpToolsList(settings, state.resource, token);
205
+ const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
206
+ const credential = credentialBundle(token, state, client);
207
+ const metadata = {
208
+ resource: state.resource,
209
+ authorizationServer: state.authorizationServer,
210
+ authorizationServerIssuer: state.issuer,
211
+ tokenEndpoint: state.tokenEndpoint,
212
+ clientId: client.clientId,
213
+ clientRegistrationMethod: state.clientRegistrationMethod,
214
+ mcpTools: tools,
215
+ };
216
+ const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
217
+ const connection = state.connectionId
218
+ ? await updateConnection(db, {
219
+ workspaceId: state.workspaceId,
220
+ connectionId: state.connectionId,
221
+ visibleToSubjectId: state.subjectId,
222
+ expectedVersion: state.connectionVersion,
223
+ providerDomain: state.providerDomain,
224
+ kind: "oauth2",
225
+ status: "active",
226
+ credentialEncrypted,
227
+ grantedScopes: scopes,
228
+ expiresAt: token.expiresAt,
229
+ metadata,
230
+ updatedBySubjectId: state.subjectId,
231
+ })
232
+ : await createConnection(db, {
233
+ accountId: state.accountId,
234
+ workspaceId: state.workspaceId,
235
+ subjectId: null,
236
+ providerDomain: state.providerDomain,
237
+ kind: "oauth2",
238
+ credentialEncrypted,
239
+ grantedScopes: scopes,
240
+ expiresAt: token.expiresAt,
241
+ metadata,
242
+ createdBySubjectId: state.subjectId,
243
+ });
244
+ if (!connection) {
245
+ throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again" });
246
+ }
247
+ return { redirectTo: callbackReturnPath(state.returnPath, "success", { connectionId: connection.id }) };
248
+ } catch (error) {
249
+ if (error instanceof HTTPException && error.status >= 400 && error.status < 500) {
250
+ throw error;
251
+ }
252
+ return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "oauth_callback_failed" }) };
253
+ }
254
+ }
255
+
256
+ export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl: string): string {
257
+ return (publicBaseUrl ?? new URL(requestUrl).origin).replace(/\/+$/, "");
258
+ }
259
+
260
+ export function requireIntegrationsStateSecret(settings: Settings): string {
261
+ const secret = settings.integrationsStateSecret?.trim();
262
+ if (!secret) {
263
+ throw new HTTPException(503, { message: "integrations OAuth requires OPENGENI_INTEGRATIONS_STATE_SECRET" });
264
+ }
265
+ return secret;
266
+ }
267
+
268
+ async function discoverMcpOAuth(resource: string, settings: Settings): Promise<{
269
+ challenge: WwwAuthenticateChallenge;
270
+ prm: ProtectedResourceMetadata;
271
+ as: AuthorizationServerMetadata;
272
+ }> {
273
+ const challenge = await probeMcpChallenge(resource, settings);
274
+ const prm = await discoverProtectedResourceMetadata(resource, settings, challenge.resourceMetadata);
275
+ const authorizationServer = prm.authorizationServers[0];
276
+ if (!authorizationServer) {
277
+ throw new HTTPException(422, { message: "MCP protected resource metadata did not advertise an authorization server" });
278
+ }
279
+ const as = await discoverAuthorizationServerMetadata(authorizationServer, settings);
280
+ if (!as.codeChallengeMethodsSupported.includes("S256")) {
281
+ throw new HTTPException(422, { message: "authorization server does not support required PKCE S256" });
282
+ }
283
+ return { challenge, prm, as };
284
+ }
285
+
286
+ async function probeMcpChallenge(resource: string, settings: Settings): Promise<WwwAuthenticateChallenge> {
287
+ const response = await fetchOAuth(resource, settings, {
288
+ method: "GET",
289
+ headers: { accept: "application/json" },
290
+ });
291
+ if (response.status !== 401) {
292
+ return {};
293
+ }
294
+ return parseWwwAuthenticate(response.headers.get("www-authenticate"));
295
+ }
296
+
297
+ async function discoverProtectedResourceMetadata(
298
+ resource: string,
299
+ settings: Settings,
300
+ advertisedUrl?: string,
301
+ ): Promise<ProtectedResourceMetadata> {
302
+ const candidates = uniqueStrings([
303
+ ...(advertisedUrl ? [advertisedUrl] : []),
304
+ ...wellKnownCandidates(resource, "oauth-protected-resource"),
305
+ ]);
306
+ for (const candidate of candidates) {
307
+ const payload = await fetchJsonObject(candidate, settings).catch((error) => {
308
+ if (error instanceof HTTPException) {
309
+ throw error;
310
+ }
311
+ return null;
312
+ });
313
+ if (!payload) {
314
+ continue;
315
+ }
316
+ const authorizationServers = stringArray(payload.authorization_servers);
317
+ if (authorizationServers.length === 0) {
318
+ continue;
319
+ }
320
+ return {
321
+ authorizationServers,
322
+ scopesSupported: stringArray(payload.scopes_supported),
323
+ raw: payload,
324
+ ...(stringValue(payload.resource) ? { resource: stringValue(payload.resource)! } : {}),
325
+ };
326
+ }
327
+ throw new HTTPException(422, { message: "could not discover MCP protected resource metadata" });
328
+ }
329
+
330
+ async function discoverAuthorizationServerMetadata(authorizationServer: string, settings: Settings): Promise<AuthorizationServerMetadata> {
331
+ const candidates = uniqueStrings([
332
+ authorizationServer,
333
+ ...wellKnownCandidates(authorizationServer, "oauth-authorization-server"),
334
+ ...wellKnownCandidates(authorizationServer, "openid-configuration"),
335
+ ]);
336
+ for (const candidate of candidates) {
337
+ const payload = await fetchJsonObject(candidate, settings).catch((error) => {
338
+ if (error instanceof HTTPException) {
339
+ throw error;
340
+ }
341
+ return null;
342
+ });
343
+ if (!payload) {
344
+ continue;
345
+ }
346
+ const authorizationEndpoint = stringValue(payload.authorization_endpoint);
347
+ const tokenEndpoint = stringValue(payload.token_endpoint);
348
+ if (!authorizationEndpoint || !tokenEndpoint) {
349
+ continue;
350
+ }
351
+ return {
352
+ issuer: stringValue(payload.issuer) ?? authorizationServer.replace(/\/+$/, ""),
353
+ authorizationServer: authorizationServer.replace(/\/+$/, ""),
354
+ authorizationEndpoint,
355
+ tokenEndpoint,
356
+ clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
357
+ codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
358
+ raw: payload,
359
+ ...(stringValue(payload.registration_endpoint) ? { registrationEndpoint: stringValue(payload.registration_endpoint)! } : {}),
360
+ };
361
+ }
362
+ throw new HTTPException(422, { message: "could not discover OAuth authorization server metadata" });
363
+ }
364
+
365
+ async function registerOAuthClient(
366
+ db: Database,
367
+ settings: Settings,
368
+ as: AuthorizationServerMetadata,
369
+ metadataUrl: string,
370
+ redirectUri: string,
371
+ ): Promise<OAuthClientRegistration> {
372
+ const operator = operatorClientForAs(settings, as);
373
+ if (operator) {
374
+ return operator;
375
+ }
376
+ if (as.clientIdMetadataDocumentSupported) {
377
+ return {
378
+ method: "cimd",
379
+ issuer: as.issuer,
380
+ authorizationServer: as.authorizationServer,
381
+ clientId: metadataUrl,
382
+ tokenEndpointAuthMethod: "none",
383
+ };
384
+ }
385
+ const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
386
+ if (storedClient) {
387
+ return {
388
+ method: "dcr",
389
+ issuer: storedClient.issuer,
390
+ authorizationServer: storedClient.authorizationServer,
391
+ clientId: storedClient.clientId,
392
+ ...(storedClient.clientSecret ? { clientSecret: storedClient.clientSecret } : {}),
393
+ tokenEndpointAuthMethod: tokenAuthMethod(storedClient.tokenEndpointAuthMethod, Boolean(storedClient.clientSecret)),
394
+ };
395
+ }
396
+ if (!as.registrationEndpoint) {
397
+ throw new HTTPException(422, {
398
+ message: "manual OAuth client credentials are required for this authorization server",
399
+ });
400
+ }
401
+ const dcr = await dynamicClientRegistration(settings, as, redirectUri);
402
+ const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
403
+ const storedWinner = await storeIntegrationOAuthClient(db, {
404
+ issuer: as.issuer,
405
+ authorizationServer: as.authorizationServer,
406
+ clientId: dcr.clientId,
407
+ clientSecretEncrypted: dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null,
408
+ tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
409
+ metadata: {
410
+ registrationEndpoint: as.registrationEndpoint,
411
+ registeredAt: new Date().toISOString(),
412
+ },
413
+ });
414
+ if (storedWinner.clientId !== dcr.clientId) {
415
+ const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
416
+ if (!winner) {
417
+ throw new HTTPException(422, { message: "OAuth client registration could not be loaded after a registration race" });
418
+ }
419
+ return dcrRegistrationFromStored(winner);
420
+ }
421
+ return dcr;
422
+ }
423
+
424
+ function dcrRegistrationFromStored(stored: {
425
+ issuer: string;
426
+ authorizationServer: string;
427
+ clientId: string;
428
+ clientSecret: string | null;
429
+ tokenEndpointAuthMethod: string;
430
+ }): OAuthClientRegistration {
431
+ return {
432
+ method: "dcr",
433
+ issuer: stored.issuer,
434
+ authorizationServer: stored.authorizationServer,
435
+ clientId: stored.clientId,
436
+ ...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
437
+ tokenEndpointAuthMethod: tokenAuthMethod(stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret)),
438
+ };
439
+ }
440
+
441
+ function operatorClientForAs(settings: Settings, as: AuthorizationServerMetadata): OAuthClientRegistration | null {
442
+ const entry = operatorClientEntryFor(settings, [as.issuer, as.authorizationServer]);
443
+ if (!entry) {
444
+ return null;
445
+ }
446
+ return {
447
+ method: "operator",
448
+ issuer: as.issuer,
449
+ authorizationServer: as.authorizationServer,
450
+ clientId: entry.clientId,
451
+ ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
452
+ tokenEndpointAuthMethod: tokenAuthMethod(entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret)),
453
+ };
454
+ }
455
+
456
+ function operatorClientEntryFor(
457
+ settings: Settings,
458
+ candidates: string[],
459
+ ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
460
+ const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
461
+ const exactKeys = uniqueStrings(candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]));
462
+ for (const key of exactKeys) {
463
+ const entry = configured[key];
464
+ if (entry) {
465
+ return entry;
466
+ }
467
+ }
468
+ const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
469
+ for (const [key, entry] of Object.entries(configured)) {
470
+ if (normalizedCandidates.has(normalizedIssuerKey(key))) {
471
+ return entry;
472
+ }
473
+ }
474
+ return null;
475
+ }
476
+
477
+ function normalizedIssuerKey(value: string): string {
478
+ return value.replace(/\/+$/, "");
479
+ }
480
+
481
+ async function dynamicClientRegistration(
482
+ settings: Settings,
483
+ as: AuthorizationServerMetadata,
484
+ redirectUri: string,
485
+ ): Promise<OAuthClientRegistration> {
486
+ if (!as.registrationEndpoint) {
487
+ throw new HTTPException(422, { message: "authorization server does not support dynamic client registration" });
488
+ }
489
+ await assertOAuthFetchAllowed(as.registrationEndpoint, settings);
490
+ const response = await fetchOAuth(as.registrationEndpoint, settings, {
491
+ method: "POST",
492
+ headers: { "content-type": "application/json", accept: "application/json" },
493
+ body: JSON.stringify({
494
+ client_name: "OpenGeni",
495
+ redirect_uris: [redirectUri],
496
+ token_endpoint_auth_method: "none",
497
+ grant_types: ["authorization_code", "refresh_token"],
498
+ response_types: ["code"],
499
+ }),
500
+ });
501
+ if (!response.ok) {
502
+ throw new HTTPException(422, { message: `dynamic client registration failed with HTTP ${response.status}` });
503
+ }
504
+ const payload = await response.json() as Record<string, unknown>;
505
+ const clientId = stringValue(payload.client_id);
506
+ if (!clientId) {
507
+ throw new HTTPException(422, { message: "dynamic client registration response did not include client_id" });
508
+ }
509
+ const clientSecret = stringValue(payload.client_secret);
510
+ return {
511
+ method: "dcr",
512
+ issuer: as.issuer,
513
+ authorizationServer: as.authorizationServer,
514
+ clientId,
515
+ ...(clientSecret ? { clientSecret } : {}),
516
+ tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.token_endpoint_auth_method), Boolean(clientSecret)),
517
+ };
518
+ }
519
+
520
+ function buildAuthorizationUrl(input: {
521
+ endpoint: string;
522
+ clientId: string;
523
+ redirectUri: string;
524
+ state: string;
525
+ resource: string;
526
+ verifier: string;
527
+ scopes: string[];
528
+ }): string {
529
+ const url = new URL(input.endpoint);
530
+ url.searchParams.set("response_type", "code");
531
+ url.searchParams.set("client_id", input.clientId);
532
+ url.searchParams.set("redirect_uri", input.redirectUri);
533
+ url.searchParams.set("state", input.state);
534
+ url.searchParams.set("resource", input.resource);
535
+ url.searchParams.set("code_challenge_method", "S256");
536
+ url.searchParams.set("code_challenge", pkceChallenge(input.verifier));
537
+ if (input.scopes.length > 0) {
538
+ url.searchParams.set("scope", input.scopes.join(" "));
539
+ }
540
+ return url.toString();
541
+ }
542
+
543
+ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
544
+ const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<string, unknown> | null;
545
+ if (!payload) {
546
+ throw new HTTPException(400, { message: "invalid or expired OAuth state" });
547
+ }
548
+ const nowSeconds = Math.floor(Date.now() / 1000);
549
+ const iat = numberValue(payload.iat);
550
+ if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
551
+ throw new HTTPException(400, { message: "invalid or expired OAuth state" });
552
+ }
553
+ const parsed = {
554
+ accountId: requiredString(payload.accountId, "state.accountId"),
555
+ workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
556
+ subjectId: requiredString(payload.subjectId, "state.subjectId"),
557
+ providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
558
+ resource: requiredString(payload.resource, "state.resource"),
559
+ requestedScopes: stringArray(payload.requestedScopes),
560
+ authorizeScopes: stringArray(payload.authorizeScopes),
561
+ encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
562
+ clientId: requiredString(payload.clientId, "state.clientId"),
563
+ tokenEndpoint: requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
564
+ authorizationServer: requiredString(payload.authorizationServer, "state.authorizationServer"),
565
+ issuer: requiredString(payload.issuer, "state.issuer"),
566
+ clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
567
+ tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
568
+ returnPath: safeReturnPath(stringValue(payload.returnPath) ?? "/integrations"),
569
+ nonce: requiredString(payload.nonce, "state.nonce"),
570
+ iat,
571
+ };
572
+ const connectionId = stringValue(payload.connectionId);
573
+ const connectionVersion = numberValue(payload.connectionVersion);
574
+ return {
575
+ ...parsed,
576
+ ...(connectionId ? { connectionId } : {}),
577
+ ...(connectionVersion !== undefined ? { connectionVersion } : {}),
578
+ };
579
+ }
580
+
581
+ async function clientForState(db: Database, settings: Settings, state: OAuthStatePayload): Promise<OAuthClientRegistration> {
582
+ if (state.clientRegistrationMethod === "cimd") {
583
+ return {
584
+ method: "cimd",
585
+ issuer: state.issuer,
586
+ authorizationServer: state.authorizationServer,
587
+ clientId: state.clientId,
588
+ tokenEndpointAuthMethod: "none",
589
+ };
590
+ }
591
+ if (state.clientRegistrationMethod === "dcr") {
592
+ const stored = await loadIntegrationOAuthClient(db, settings, state.issuer);
593
+ if (!stored || stored.clientId !== state.clientId) {
594
+ throw new HTTPException(400, { message: "OAuth client registration is no longer available" });
595
+ }
596
+ return {
597
+ method: "dcr",
598
+ issuer: stored.issuer,
599
+ authorizationServer: stored.authorizationServer,
600
+ clientId: stored.clientId,
601
+ ...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
602
+ tokenEndpointAuthMethod: tokenAuthMethod(stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret)),
603
+ };
604
+ }
605
+ const entry = operatorClientEntryFor(settings, [state.issuer, state.authorizationServer]);
606
+ if (!entry || entry.clientId !== state.clientId) {
607
+ throw new HTTPException(400, { message: "operator OAuth client credentials are no longer available" });
608
+ }
609
+ return {
610
+ method: "operator",
611
+ issuer: state.issuer,
612
+ authorizationServer: state.authorizationServer,
613
+ clientId: entry.clientId,
614
+ ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
615
+ tokenEndpointAuthMethod: tokenAuthMethod(entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret)),
616
+ };
617
+ }
618
+
619
+ async function exchangeAuthorizationCode(
620
+ settings: Settings,
621
+ input: {
622
+ code: string;
623
+ verifier: string;
624
+ redirectUri: string;
625
+ resource: string;
626
+ tokenEndpoint: string;
627
+ client: OAuthClientRegistration;
628
+ },
629
+ ): Promise<TokenResponse> {
630
+ await assertOAuthFetchAllowed(input.tokenEndpoint, settings);
631
+ const body = new URLSearchParams();
632
+ body.set("grant_type", "authorization_code");
633
+ body.set("code", input.code);
634
+ body.set("redirect_uri", input.redirectUri);
635
+ body.set("code_verifier", input.verifier);
636
+ body.set("resource", input.resource);
637
+ body.set("client_id", input.client.clientId);
638
+ const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
639
+ if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_post") {
640
+ body.set("client_secret", input.client.clientSecret);
641
+ } else if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_basic") {
642
+ headers.authorization = `Basic ${Buffer.from(`${input.client.clientId}:${input.client.clientSecret}`).toString("base64")}`;
643
+ }
644
+ const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
645
+ if (!response.ok) {
646
+ throw new Error(`OAuth token endpoint returned HTTP ${response.status}`);
647
+ }
648
+ const payload = await response.json() as Record<string, unknown>;
649
+ const accessToken = stringValue(payload.access_token);
650
+ if (!accessToken) {
651
+ throw new Error("OAuth token response did not include access_token");
652
+ }
653
+ return {
654
+ accessToken,
655
+ tokenType: stringValue(payload.token_type) ?? "Bearer",
656
+ expiresAt: expiresAtFromTokenResponse(payload),
657
+ raw: payload,
658
+ ...(stringValue(payload.refresh_token) ? { refreshToken: stringValue(payload.refresh_token)! } : {}),
659
+ ...(stringValue(payload.scope) ? { scopeText: stringValue(payload.scope)! } : {}),
660
+ };
661
+ }
662
+
663
+ async function verifyMcpToolsList(settings: Settings, resource: string, token: TokenResponse): Promise<Array<{ name: string; description?: string }>> {
664
+ await assertOAuthFetchAllowed(resource, settings);
665
+ const client = new Client({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
666
+ try {
667
+ const transport = new StreamableHTTPClientTransport(new URL(resource), {
668
+ requestInit: {
669
+ headers: { authorization: `${token.tokenType} ${token.accessToken}` },
670
+ },
671
+ fetch: (url, init) => fetchOAuth(url.toString(), settings, init),
672
+ });
673
+ await client.connect(transport as unknown as Transport, { timeout: 10_000, maxTotalTimeout: 10_000 });
674
+ const listed = await client.listTools(undefined, { timeout: 10_000, maxTotalTimeout: 10_000 });
675
+ return listed.tools.map((tool) => ({
676
+ name: tool.name,
677
+ ...(tool.description ? { description: tool.description } : {}),
678
+ }));
679
+ } finally {
680
+ await client.close().catch(() => undefined);
681
+ }
682
+ }
683
+
684
+ function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client: OAuthClientRegistration): Record<string, unknown> {
685
+ return {
686
+ access_token: token.accessToken,
687
+ ...(token.refreshToken ? { refresh_token: token.refreshToken } : {}),
688
+ token_type: token.tokenType,
689
+ ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
690
+ resource: state.resource,
691
+ ...(token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {}),
692
+ token_endpoint: state.tokenEndpoint,
693
+ client_id: client.clientId,
694
+ ...(client.clientSecret ? { client_secret: client.clientSecret, token_endpoint_auth_method: client.tokenEndpointAuthMethod } : {}),
695
+ };
696
+ }
697
+
698
+ function callbackReturnPath(returnPath: string, status: "success" | "error", params: Record<string, string>): string {
699
+ const url = new URL(returnPath, "https://opengeni.local");
700
+ url.searchParams.set("integration_oauth", status);
701
+ for (const [key, value] of Object.entries(params)) {
702
+ url.searchParams.set(key, value);
703
+ }
704
+ return `${url.pathname}${url.search}${url.hash}`;
705
+ }
706
+
707
+ function canonicalMcpResource(value: string | undefined): string {
708
+ if (!value) {
709
+ throw new HTTPException(400, { message: "mcpUrl is required" });
710
+ }
711
+ let url: URL;
712
+ try {
713
+ url = new URL(value);
714
+ } catch {
715
+ throw new HTTPException(422, { message: "MCP resource URL is invalid" });
716
+ }
717
+ url.hash = "";
718
+ return url.toString();
719
+ }
720
+
721
+ function canonicalProviderDomain(value: string): string {
722
+ return value.trim().toLowerCase().replace(/^www\./, "");
723
+ }
724
+
725
+ function safeReturnPath(value: string): string {
726
+ if (!value.startsWith("/") || value.startsWith("//")) {
727
+ throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
728
+ }
729
+ const parsed = new URL(value, "https://opengeni.local");
730
+ if (parsed.origin !== "https://opengeni.local") {
731
+ throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
732
+ }
733
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
734
+ }
735
+
736
+ async function fetchJsonObject(url: string, settings: Settings): Promise<Record<string, unknown>> {
737
+ const response = await fetchOAuth(url, settings, { headers: { accept: "application/json" } });
738
+ if (!response.ok) {
739
+ throw new Error(`HTTP ${response.status}`);
740
+ }
741
+ const payload = await response.json();
742
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
743
+ throw new Error("metadata response was not a JSON object");
744
+ }
745
+ return payload as Record<string, unknown>;
746
+ }
747
+
748
+ async function fetchOAuth(rawUrl: string, settings: Settings, init: RequestInit = {}, hop = 0): Promise<Response> {
749
+ await assertOAuthFetchAllowed(rawUrl, settings);
750
+ const response = await fetch(rawUrl, { ...init, redirect: "manual" });
751
+ if (response.status < 300 || response.status >= 400) {
752
+ return response;
753
+ }
754
+ if (hop >= 3) {
755
+ throw new HTTPException(422, { message: "OAuth fetch exceeded maximum redirect hops" });
756
+ }
757
+ const location = response.headers.get("location");
758
+ if (!location) {
759
+ throw new HTTPException(422, { message: "OAuth fetch redirect was missing Location" });
760
+ }
761
+ let nextUrl: string;
762
+ try {
763
+ nextUrl = new URL(location, rawUrl).toString();
764
+ } catch {
765
+ throw new HTTPException(422, { message: "OAuth fetch redirect Location was invalid" });
766
+ }
767
+ return await fetchOAuth(nextUrl, settings, init, hop + 1);
768
+ }
769
+
770
+ async function assertOAuthFetchAllowed(rawUrl: string, settings: Settings): Promise<void> {
771
+ const url = new URL(rawUrl);
772
+ if (!["https:", "http:"].includes(url.protocol)) {
773
+ throw new HTTPException(422, { message: "OAuth discovery only supports http and https URLs" });
774
+ }
775
+ if (settings.integrationsAllowPrivateNetworkTargets || ["local", "test"].includes(settings.environment)) {
776
+ return;
777
+ }
778
+ if (url.protocol !== "https:") {
779
+ throw new HTTPException(422, { message: "OAuth discovery targets must use https outside local/test" });
780
+ }
781
+ const hostname = url.hostname.toLowerCase();
782
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
783
+ throw new HTTPException(422, { message: "OAuth discovery may not target localhost" });
784
+ }
785
+ const literal = isIP(hostname);
786
+ const addresses = literal ? [hostname] : (await lookup(hostname, { all: true })).map((entry) => entry.address);
787
+ if (addresses.some(isPrivateAddress)) {
788
+ throw new HTTPException(422, { message: "OAuth discovery may not target private network addresses" });
789
+ }
790
+ }
791
+
792
+ function parseWwwAuthenticate(header: string | null): WwwAuthenticateChallenge {
793
+ if (!header) {
794
+ return {};
795
+ }
796
+ const bearerIndex = header.toLowerCase().indexOf("bearer");
797
+ if (bearerIndex < 0) {
798
+ return {};
799
+ }
800
+ const paramsText = header.slice(bearerIndex + "bearer".length);
801
+ const params: Record<string, string> = {};
802
+ const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
803
+ let match: RegExpExecArray | null;
804
+ while ((match = re.exec(paramsText)) !== null) {
805
+ const raw = match[2]!;
806
+ params[match[1]!.toLowerCase()] = raw.startsWith("\"") ? raw.slice(1, -1).replace(/\\"/g, "\"") : raw;
807
+ }
808
+ return {
809
+ ...(params.resource_metadata ? { resourceMetadata: params.resource_metadata } : {}),
810
+ ...(params.scope ? { scope: params.scope.split(/\s+/).filter(Boolean) } : {}),
811
+ ...(params.error ? { error: params.error } : {}),
812
+ };
813
+ }
814
+
815
+ function wellKnownCandidates(rawUrl: string, name: string): string[] {
816
+ const url = new URL(rawUrl);
817
+ const path = url.pathname.replace(/^\/+|\/+$/g, "");
818
+ return uniqueStrings([
819
+ `${url.origin}/.well-known/${name}${path ? `/${path}` : ""}`,
820
+ `${url.origin}${path ? `/${path}` : ""}/.well-known/${name}`,
821
+ `${url.origin}/.well-known/${name}`,
822
+ ]);
823
+ }
824
+
825
+ function chooseAuthorizeScopes(requested: string[] | undefined, challenged: string[] | undefined, supported: string[]): string[] {
826
+ if (requested?.length) {
827
+ return uniqueStrings(requested);
828
+ }
829
+ if (challenged?.length) {
830
+ return uniqueStrings(challenged);
831
+ }
832
+ return uniqueStrings(supported);
833
+ }
834
+
835
+ function grantedScopes(scopeText: string | undefined, fallback: string[]): string[] {
836
+ if (scopeText) {
837
+ return uniqueStrings(scopeText.split(/\s+/).filter(Boolean));
838
+ }
839
+ return fallback;
840
+ }
841
+
842
+ function tokenAuthMethod(raw: string | undefined, hasSecret: boolean): OAuthClientRegistration["tokenEndpointAuthMethod"] {
843
+ if (raw === "client_secret_post" || raw === "client_secret_basic") {
844
+ return raw;
845
+ }
846
+ return hasSecret ? "client_secret_post" : "none";
847
+ }
848
+
849
+ function registrationMethod(value: unknown): OAuthClientRegistration["method"] {
850
+ if (value === "operator" || value === "cimd" || value === "dcr") {
851
+ return value;
852
+ }
853
+ throw new HTTPException(400, { message: "invalid OAuth state" });
854
+ }
855
+
856
+ function expiresAtFromTokenResponse(payload: Record<string, unknown>): Date | null {
857
+ const expiresAt = stringValue(payload.expires_at);
858
+ if (expiresAt) {
859
+ const parsed = new Date(expiresAt);
860
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
861
+ }
862
+ const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in);
863
+ if (Number.isFinite(expiresIn) && expiresIn > 0) {
864
+ return new Date(Date.now() + expiresIn * 1000);
865
+ }
866
+ return null;
867
+ }
868
+
869
+ function pkceChallenge(verifier: string): string {
870
+ return createHash("sha256").update(verifier).digest("base64url");
871
+ }
872
+
873
+ function randomPkceVerifier(): string {
874
+ return randomBytes(32).toString("base64url");
875
+ }
876
+
877
+ function uniqueStrings(values: string[]): string[] {
878
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
879
+ }
880
+
881
+ function stringArray(value: unknown): string[] {
882
+ return Array.isArray(value) ? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string")) : [];
883
+ }
884
+
885
+ function stringValue(value: unknown): string | undefined {
886
+ return typeof value === "string" && value.length > 0 ? value : undefined;
887
+ }
888
+
889
+ function numberValue(value: unknown): number | undefined {
890
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
891
+ }
892
+
893
+ function requiredString(value: unknown, field: string): string {
894
+ const result = stringValue(value);
895
+ if (!result) {
896
+ throw new HTTPException(400, { message: `invalid OAuth state: missing ${field}` });
897
+ }
898
+ return result;
899
+ }