@opengeni/api-router 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +9 -1
- package/dist/app.js +7 -1
- package/dist/{chunk-3HIA43CC.js → chunk-HBEJMWD3.js} +5470 -2223
- package/dist/chunk-HBEJMWD3.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +279 -55
- package/dist/index.js.map +1 -1
- package/package.json +20 -20
- package/src/app.ts +583 -166
- package/src/auth/managed-auth.ts +32 -16
- package/src/http/auth.ts +8 -1
- package/src/http/common.ts +6 -2
- package/src/http/sse.ts +84 -8
- package/src/index.ts +178 -75
- package/src/integrations/oauth-client.ts +403 -120
- package/src/integrations/provider-domain.ts +4 -1
- package/src/mcp/documents.ts +173 -94
- package/src/mcp/server.ts +1600 -693
- package/src/mcp/session-view.ts +8 -2
- package/src/mcp/toolspace.ts +175 -84
- package/src/observability.ts +7 -1
- package/src/routes/api-keys.ts +39 -23
- package/src/routes/billing.ts +180 -65
- package/src/routes/capabilities.ts +17 -8
- package/src/routes/catalog-assets.ts +5 -2
- package/src/routes/codex.ts +244 -63
- package/src/routes/connections.ts +71 -33
- package/src/routes/documents.ts +242 -92
- package/src/routes/enrollments.ts +100 -70
- package/src/routes/environments.ts +205 -136
- package/src/routes/files.ts +164 -39
- package/src/routes/github.ts +123 -50
- package/src/routes/install.ts +9 -2
- package/src/routes/machines.ts +9 -8
- package/src/routes/packs.ts +141 -89
- package/src/routes/rigs.ts +189 -0
- package/src/routes/scheduled-tasks.ts +51 -9
- package/src/routes/sessions.ts +870 -328
- package/src/routes/social.ts +50 -38
- package/src/routes/workspace-capture.ts +238 -0
- package/src/routes/workspaces.ts +146 -13
- package/src/sandbox/access.ts +11 -3
- package/src/sandbox/auth-callout.ts +5 -1
- package/src/sandbox/channel-a.ts +104 -27
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +68 -59
- package/src/sandbox/metrics-ingestion.ts +238 -17
- package/src/sandbox/viewer.ts +172 -46
- package/dist/chunk-3HIA43CC.js.map +0 -1
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
encryptEnvironmentValue,
|
|
13
13
|
getConnectionMetadata,
|
|
14
14
|
isPrivateAddress,
|
|
15
|
+
listConnectionsMetadata,
|
|
15
16
|
loadIntegrationOAuthClient,
|
|
17
|
+
normalizeBearerScheme,
|
|
16
18
|
storeIntegrationOAuthClient,
|
|
17
19
|
updateConnection,
|
|
18
20
|
type Database,
|
|
@@ -65,12 +67,13 @@ type AuthorizationServerMetadata = {
|
|
|
65
67
|
tokenEndpoint: string;
|
|
66
68
|
registrationEndpoint?: string;
|
|
67
69
|
clientIdMetadataDocumentSupported: boolean;
|
|
70
|
+
tokenEndpointAuthMethodsSupported: string[];
|
|
68
71
|
codeChallengeMethodsSupported: string[];
|
|
69
72
|
raw: Record<string, unknown>;
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
type OAuthClientRegistration = {
|
|
73
|
-
method: "operator" | "cimd" | "dcr";
|
|
76
|
+
method: "operator" | "manual" | "cimd" | "dcr";
|
|
74
77
|
issuer: string;
|
|
75
78
|
authorizationServer: string;
|
|
76
79
|
clientId: string;
|
|
@@ -94,6 +97,7 @@ type OAuthStatePayload = {
|
|
|
94
97
|
issuer: string;
|
|
95
98
|
clientRegistrationMethod: OAuthClientRegistration["method"];
|
|
96
99
|
tokenEndpointAuthMethod: OAuthClientRegistration["tokenEndpointAuthMethod"];
|
|
100
|
+
encryptedClientSecret?: string;
|
|
97
101
|
returnPath: string;
|
|
98
102
|
connectionId?: string;
|
|
99
103
|
connectionVersion?: number;
|
|
@@ -129,23 +133,40 @@ export async function startMcpOAuth(
|
|
|
129
133
|
): Promise<OAuthStartResponse> {
|
|
130
134
|
const { db, settings } = deps;
|
|
131
135
|
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
132
|
-
const providerDomain = canonicalProviderDomain(
|
|
136
|
+
const providerDomain = canonicalProviderDomain(
|
|
137
|
+
context.payload.providerDomain ?? new URL(mcpUrl).hostname,
|
|
138
|
+
);
|
|
133
139
|
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
134
140
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
135
141
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
136
142
|
const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
|
|
137
|
-
const existing =
|
|
138
|
-
|
|
139
|
-
:
|
|
143
|
+
const existing = await existingOAuthConnectionForStart(db, {
|
|
144
|
+
workspaceId: context.workspaceId,
|
|
145
|
+
subjectId: context.subjectId,
|
|
146
|
+
providerDomain,
|
|
147
|
+
connectionId: context.payload.connectionId,
|
|
148
|
+
});
|
|
140
149
|
if (context.payload.connectionId && !existing) {
|
|
141
150
|
throw new HTTPException(404, { message: "connection not found" });
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
const discovery = await discoverMcpOAuth(mcpUrl, settings);
|
|
145
154
|
const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
|
|
146
|
-
const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
|
|
147
155
|
const verifier = randomPkceVerifier();
|
|
148
|
-
const authorizeScopes = chooseAuthorizeScopes(
|
|
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
|
+
);
|
|
149
170
|
const key = requireEnvironmentEncryption(settings);
|
|
150
171
|
const state = createSignedState(requireIntegrationsStateSecret(settings), {
|
|
151
172
|
accountId: context.accountId,
|
|
@@ -163,6 +184,9 @@ export async function startMcpOAuth(
|
|
|
163
184
|
issuer: client.issuer,
|
|
164
185
|
clientRegistrationMethod: client.method,
|
|
165
186
|
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
|
|
187
|
+
...(client.method === "manual" && client.clientSecret
|
|
188
|
+
? { encryptedClientSecret: encryptEnvironmentValue(key, client.clientSecret) }
|
|
189
|
+
: {}),
|
|
166
190
|
returnPath,
|
|
167
191
|
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
168
192
|
});
|
|
@@ -189,14 +213,20 @@ export async function completeMcpOAuthCallback(
|
|
|
189
213
|
const { db, settings, observability } = deps;
|
|
190
214
|
let state: OAuthStatePayload | null = null;
|
|
191
215
|
if (!input.state) {
|
|
192
|
-
const error = new OAuthCallbackStageError(
|
|
216
|
+
const error = new OAuthCallbackStageError(
|
|
217
|
+
"state_verify",
|
|
218
|
+
"state_invalid",
|
|
219
|
+
new Error("missing OAuth state"),
|
|
220
|
+
);
|
|
193
221
|
logOAuthCallbackFailure(observability, error, state);
|
|
194
222
|
return { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
|
|
195
223
|
}
|
|
196
224
|
try {
|
|
197
225
|
state = readOAuthState(input.state, settings);
|
|
198
226
|
if (!input.code) {
|
|
199
|
-
return {
|
|
227
|
+
return {
|
|
228
|
+
redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }),
|
|
229
|
+
};
|
|
200
230
|
}
|
|
201
231
|
const consumed = await consumeIntegrationOAuthStateNonce(db, {
|
|
202
232
|
accountId: state.accountId,
|
|
@@ -212,7 +242,11 @@ export async function completeMcpOAuthCallback(
|
|
|
212
242
|
} catch (error) {
|
|
213
243
|
const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
|
|
214
244
|
logOAuthCallbackFailure(observability, staged, state);
|
|
215
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", {
|
|
247
|
+
reason: staged.reason,
|
|
248
|
+
}),
|
|
249
|
+
};
|
|
216
250
|
}
|
|
217
251
|
|
|
218
252
|
try {
|
|
@@ -221,14 +255,16 @@ export async function completeMcpOAuthCallback(
|
|
|
221
255
|
const key = requireEnvironmentEncryption(settings);
|
|
222
256
|
const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
|
|
223
257
|
const client = await clientForState(db, settings, state);
|
|
224
|
-
const token = await
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
);
|
|
232
268
|
const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
|
|
233
269
|
const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
|
|
234
270
|
const credential = credentialBundle(token, state, client);
|
|
@@ -244,35 +280,39 @@ export async function completeMcpOAuthCallback(
|
|
|
244
280
|
...(verification.tools ? { mcpTools: verification.tools } : {}),
|
|
245
281
|
};
|
|
246
282
|
const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
247
|
-
const connection = await
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
);
|
|
274
312
|
if (!connection) {
|
|
275
|
-
throw new HTTPException(409, {
|
|
313
|
+
throw new HTTPException(409, {
|
|
314
|
+
message: "connection changed during OAuth reconnect; start again",
|
|
315
|
+
});
|
|
276
316
|
}
|
|
277
317
|
// Carry the canonical providerDomain (not just the id) so the SPA can build
|
|
278
318
|
// the enable connectionRef straight from the redirect, without a listConnections
|
|
@@ -286,9 +326,10 @@ export async function completeMcpOAuthCallback(
|
|
|
286
326
|
}),
|
|
287
327
|
};
|
|
288
328
|
} catch (error) {
|
|
289
|
-
const staged =
|
|
290
|
-
|
|
291
|
-
|
|
329
|
+
const staged =
|
|
330
|
+
error instanceof OAuthCallbackStageError
|
|
331
|
+
? error
|
|
332
|
+
: new OAuthCallbackStageError("persist", "persist_failed", error);
|
|
292
333
|
logOAuthCallbackFailure(observability, staged, state);
|
|
293
334
|
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
|
|
294
335
|
}
|
|
@@ -301,30 +342,46 @@ export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl
|
|
|
301
342
|
export function requireIntegrationsStateSecret(settings: Settings): string {
|
|
302
343
|
const secret = settings.integrationsStateSecret?.trim();
|
|
303
344
|
if (!secret) {
|
|
304
|
-
throw new HTTPException(503, {
|
|
345
|
+
throw new HTTPException(503, {
|
|
346
|
+
message: "integrations OAuth requires OPENGENI_INTEGRATIONS_STATE_SECRET",
|
|
347
|
+
});
|
|
305
348
|
}
|
|
306
349
|
return secret;
|
|
307
350
|
}
|
|
308
351
|
|
|
309
|
-
async function discoverMcpOAuth(
|
|
352
|
+
async function discoverMcpOAuth(
|
|
353
|
+
resource: string,
|
|
354
|
+
settings: Settings,
|
|
355
|
+
): Promise<{
|
|
310
356
|
challenge: WwwAuthenticateChallenge;
|
|
311
357
|
prm: ProtectedResourceMetadata;
|
|
312
358
|
as: AuthorizationServerMetadata;
|
|
313
359
|
}> {
|
|
314
360
|
const challenge = await probeMcpChallenge(resource, settings);
|
|
315
|
-
const prm = await discoverProtectedResourceMetadata(
|
|
361
|
+
const prm = await discoverProtectedResourceMetadata(
|
|
362
|
+
resource,
|
|
363
|
+
settings,
|
|
364
|
+
challenge.resourceMetadata,
|
|
365
|
+
);
|
|
316
366
|
const authorizationServer = prm.authorizationServers[0];
|
|
317
367
|
if (!authorizationServer) {
|
|
318
|
-
throw new HTTPException(422, {
|
|
368
|
+
throw new HTTPException(422, {
|
|
369
|
+
message: "MCP protected resource metadata did not advertise an authorization server",
|
|
370
|
+
});
|
|
319
371
|
}
|
|
320
372
|
const as = await discoverAuthorizationServerMetadata(authorizationServer, settings);
|
|
321
373
|
if (!as.codeChallengeMethodsSupported.includes("S256")) {
|
|
322
|
-
throw new HTTPException(422, {
|
|
374
|
+
throw new HTTPException(422, {
|
|
375
|
+
message: "authorization server does not support required PKCE S256",
|
|
376
|
+
});
|
|
323
377
|
}
|
|
324
378
|
return { challenge, prm, as };
|
|
325
379
|
}
|
|
326
380
|
|
|
327
|
-
async function probeMcpChallenge(
|
|
381
|
+
async function probeMcpChallenge(
|
|
382
|
+
resource: string,
|
|
383
|
+
settings: Settings,
|
|
384
|
+
): Promise<WwwAuthenticateChallenge> {
|
|
328
385
|
const response = await fetchOAuth(resource, settings, {
|
|
329
386
|
method: "GET",
|
|
330
387
|
headers: { accept: "application/json" },
|
|
@@ -368,7 +425,10 @@ async function discoverProtectedResourceMetadata(
|
|
|
368
425
|
throw new HTTPException(422, { message: "could not discover MCP protected resource metadata" });
|
|
369
426
|
}
|
|
370
427
|
|
|
371
|
-
async function discoverAuthorizationServerMetadata(
|
|
428
|
+
async function discoverAuthorizationServerMetadata(
|
|
429
|
+
authorizationServer: string,
|
|
430
|
+
settings: Settings,
|
|
431
|
+
): Promise<AuthorizationServerMetadata> {
|
|
372
432
|
const candidates = uniqueStrings([
|
|
373
433
|
authorizationServer,
|
|
374
434
|
...wellKnownCandidates(authorizationServer, "oauth-authorization-server"),
|
|
@@ -395,12 +455,17 @@ async function discoverAuthorizationServerMetadata(authorizationServer: string,
|
|
|
395
455
|
authorizationEndpoint,
|
|
396
456
|
tokenEndpoint,
|
|
397
457
|
clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
|
|
458
|
+
tokenEndpointAuthMethodsSupported: stringArray(payload.token_endpoint_auth_methods_supported),
|
|
398
459
|
codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
|
|
399
460
|
raw: payload,
|
|
400
|
-
...(stringValue(payload.registration_endpoint)
|
|
461
|
+
...(stringValue(payload.registration_endpoint)
|
|
462
|
+
? { registrationEndpoint: stringValue(payload.registration_endpoint)! }
|
|
463
|
+
: {}),
|
|
401
464
|
};
|
|
402
465
|
}
|
|
403
|
-
throw new HTTPException(422, {
|
|
466
|
+
throw new HTTPException(422, {
|
|
467
|
+
message: "could not discover OAuth authorization server metadata",
|
|
468
|
+
});
|
|
404
469
|
}
|
|
405
470
|
|
|
406
471
|
async function registerOAuthClient(
|
|
@@ -409,6 +474,8 @@ async function registerOAuthClient(
|
|
|
409
474
|
as: AuthorizationServerMetadata,
|
|
410
475
|
metadataUrl: string,
|
|
411
476
|
redirectUri: string,
|
|
477
|
+
scopes: string[],
|
|
478
|
+
manual: OAuthStartRequest["oauthClient"],
|
|
412
479
|
): Promise<OAuthClientRegistration> {
|
|
413
480
|
const operator = operatorClientForAs(settings, as);
|
|
414
481
|
if (operator) {
|
|
@@ -423,15 +490,41 @@ async function registerOAuthClient(
|
|
|
423
490
|
tokenEndpointAuthMethod: "none",
|
|
424
491
|
};
|
|
425
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> {
|
|
426
516
|
const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
427
|
-
if (storedClient) {
|
|
517
|
+
if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, scopes)) {
|
|
428
518
|
return {
|
|
429
519
|
method: "dcr",
|
|
430
520
|
issuer: storedClient.issuer,
|
|
431
521
|
authorizationServer: storedClient.authorizationServer,
|
|
432
522
|
clientId: storedClient.clientId,
|
|
433
523
|
...(storedClient.clientSecret ? { clientSecret: storedClient.clientSecret } : {}),
|
|
434
|
-
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
524
|
+
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
525
|
+
storedClient.tokenEndpointAuthMethod,
|
|
526
|
+
Boolean(storedClient.clientSecret),
|
|
527
|
+
),
|
|
435
528
|
};
|
|
436
529
|
}
|
|
437
530
|
if (!as.registrationEndpoint) {
|
|
@@ -439,29 +532,56 @@ async function registerOAuthClient(
|
|
|
439
532
|
message: "manual OAuth client credentials are required for this authorization server",
|
|
440
533
|
});
|
|
441
534
|
}
|
|
442
|
-
const dcr = await dynamicClientRegistration(settings, as, redirectUri);
|
|
535
|
+
const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes);
|
|
443
536
|
const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
|
|
444
|
-
const
|
|
537
|
+
const storeInput = {
|
|
445
538
|
issuer: as.issuer,
|
|
446
539
|
authorizationServer: as.authorizationServer,
|
|
447
540
|
clientId: dcr.clientId,
|
|
448
|
-
clientSecretEncrypted:
|
|
541
|
+
clientSecretEncrypted:
|
|
542
|
+
dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null,
|
|
449
543
|
tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
|
|
450
|
-
metadata:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
},
|
|
454
|
-
});
|
|
544
|
+
metadata: registrationMetadata(as, scopes),
|
|
545
|
+
};
|
|
546
|
+
const storedWinner = await storeIntegrationOAuthClient(db, storeInput);
|
|
455
547
|
if (storedWinner.clientId !== dcr.clientId) {
|
|
456
548
|
const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
457
549
|
if (!winner) {
|
|
458
|
-
throw new HTTPException(422, {
|
|
550
|
+
throw new HTTPException(422, {
|
|
551
|
+
message: "OAuth client registration could not be loaded after a registration race",
|
|
552
|
+
});
|
|
459
553
|
}
|
|
460
554
|
return dcrRegistrationFromStored(winner);
|
|
461
555
|
}
|
|
462
556
|
return dcr;
|
|
463
557
|
}
|
|
464
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
|
+
|
|
465
585
|
function dcrRegistrationFromStored(stored: {
|
|
466
586
|
issuer: string;
|
|
467
587
|
authorizationServer: string;
|
|
@@ -475,11 +595,17 @@ function dcrRegistrationFromStored(stored: {
|
|
|
475
595
|
authorizationServer: stored.authorizationServer,
|
|
476
596
|
clientId: stored.clientId,
|
|
477
597
|
...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
|
|
478
|
-
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
598
|
+
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
599
|
+
stored.tokenEndpointAuthMethod,
|
|
600
|
+
Boolean(stored.clientSecret),
|
|
601
|
+
),
|
|
479
602
|
};
|
|
480
603
|
}
|
|
481
604
|
|
|
482
|
-
function operatorClientForAs(
|
|
605
|
+
function operatorClientForAs(
|
|
606
|
+
settings: Settings,
|
|
607
|
+
as: AuthorizationServerMetadata,
|
|
608
|
+
): OAuthClientRegistration | null {
|
|
483
609
|
const entry = operatorClientEntryFor(settings, [as.issuer, as.authorizationServer]);
|
|
484
610
|
if (!entry) {
|
|
485
611
|
return null;
|
|
@@ -490,7 +616,10 @@ function operatorClientForAs(settings: Settings, as: AuthorizationServerMetadata
|
|
|
490
616
|
authorizationServer: as.authorizationServer,
|
|
491
617
|
clientId: entry.clientId,
|
|
492
618
|
...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
|
|
493
|
-
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
619
|
+
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
620
|
+
entry.tokenEndpointAuthMethod,
|
|
621
|
+
Boolean(entry.clientSecret),
|
|
622
|
+
),
|
|
494
623
|
};
|
|
495
624
|
}
|
|
496
625
|
|
|
@@ -499,7 +628,9 @@ function operatorClientEntryFor(
|
|
|
499
628
|
candidates: string[],
|
|
500
629
|
): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
|
|
501
630
|
const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
|
|
502
|
-
const exactKeys = uniqueStrings(
|
|
631
|
+
const exactKeys = uniqueStrings(
|
|
632
|
+
candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]),
|
|
633
|
+
);
|
|
503
634
|
for (const key of exactKeys) {
|
|
504
635
|
const entry = configured[key];
|
|
505
636
|
if (entry) {
|
|
@@ -523,9 +654,12 @@ async function dynamicClientRegistration(
|
|
|
523
654
|
settings: Settings,
|
|
524
655
|
as: AuthorizationServerMetadata,
|
|
525
656
|
redirectUri: string,
|
|
657
|
+
scopes: string[],
|
|
526
658
|
): Promise<OAuthClientRegistration> {
|
|
527
659
|
if (!as.registrationEndpoint) {
|
|
528
|
-
throw new HTTPException(422, {
|
|
660
|
+
throw new HTTPException(422, {
|
|
661
|
+
message: "authorization server does not support dynamic client registration",
|
|
662
|
+
});
|
|
529
663
|
}
|
|
530
664
|
await assertOAuthFetchAllowed(as.registrationEndpoint, settings);
|
|
531
665
|
const response = await fetchOAuth(as.registrationEndpoint, settings, {
|
|
@@ -537,15 +671,20 @@ async function dynamicClientRegistration(
|
|
|
537
671
|
token_endpoint_auth_method: "none",
|
|
538
672
|
grant_types: ["authorization_code", "refresh_token"],
|
|
539
673
|
response_types: ["code"],
|
|
674
|
+
...(scopes.length ? { scope: scopes.join(" ") } : {}),
|
|
540
675
|
}),
|
|
541
676
|
});
|
|
542
677
|
if (!response.ok) {
|
|
543
|
-
throw new HTTPException(422, {
|
|
678
|
+
throw new HTTPException(422, {
|
|
679
|
+
message: `dynamic client registration failed with HTTP ${response.status}`,
|
|
680
|
+
});
|
|
544
681
|
}
|
|
545
|
-
const payload = await response.json() as Record<string, unknown>;
|
|
682
|
+
const payload = (await response.json()) as Record<string, unknown>;
|
|
546
683
|
const clientId = stringValue(payload.client_id);
|
|
547
684
|
if (!clientId) {
|
|
548
|
-
throw new HTTPException(422, {
|
|
685
|
+
throw new HTTPException(422, {
|
|
686
|
+
message: "dynamic client registration response did not include client_id",
|
|
687
|
+
});
|
|
549
688
|
}
|
|
550
689
|
const clientSecret = stringValue(payload.client_secret);
|
|
551
690
|
return {
|
|
@@ -554,10 +693,37 @@ async function dynamicClientRegistration(
|
|
|
554
693
|
authorizationServer: as.authorizationServer,
|
|
555
694
|
clientId,
|
|
556
695
|
...(clientSecret ? { clientSecret } : {}),
|
|
557
|
-
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
696
|
+
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
697
|
+
stringValue(payload.token_endpoint_auth_method),
|
|
698
|
+
Boolean(clientSecret),
|
|
699
|
+
),
|
|
558
700
|
};
|
|
559
701
|
}
|
|
560
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
|
+
|
|
561
727
|
function buildAuthorizationUrl(input: {
|
|
562
728
|
endpoint: string;
|
|
563
729
|
clientId: string;
|
|
@@ -582,7 +748,10 @@ function buildAuthorizationUrl(input: {
|
|
|
582
748
|
}
|
|
583
749
|
|
|
584
750
|
function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
585
|
-
const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<
|
|
751
|
+
const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<
|
|
752
|
+
string,
|
|
753
|
+
unknown
|
|
754
|
+
> | null;
|
|
586
755
|
if (!payload) {
|
|
587
756
|
throw new HTTPException(400, { message: "invalid or expired OAuth state" });
|
|
588
757
|
}
|
|
@@ -601,13 +770,19 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
601
770
|
resource,
|
|
602
771
|
requestedScopes: stringArray(payload.requestedScopes),
|
|
603
772
|
authorizeScopes: stringArray(payload.authorizeScopes),
|
|
604
|
-
encryptedPkceVerifier: requiredString(
|
|
773
|
+
encryptedPkceVerifier: requiredString(
|
|
774
|
+
payload.encryptedPkceVerifier,
|
|
775
|
+
"state.encryptedPkceVerifier",
|
|
776
|
+
),
|
|
605
777
|
clientId: requiredString(payload.clientId, "state.clientId"),
|
|
606
778
|
tokenEndpoint: requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
|
|
607
779
|
authorizationServer: requiredString(payload.authorizationServer, "state.authorizationServer"),
|
|
608
780
|
issuer: requiredString(payload.issuer, "state.issuer"),
|
|
609
781
|
clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
|
|
610
782
|
tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
|
|
783
|
+
...(stringValue(payload.encryptedClientSecret)
|
|
784
|
+
? { encryptedClientSecret: stringValue(payload.encryptedClientSecret)! }
|
|
785
|
+
: {}),
|
|
611
786
|
returnPath: safeReturnPath(stringValue(payload.returnPath) ?? "/integrations"),
|
|
612
787
|
nonce: requiredString(payload.nonce, "state.nonce"),
|
|
613
788
|
iat,
|
|
@@ -621,7 +796,11 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
621
796
|
};
|
|
622
797
|
}
|
|
623
798
|
|
|
624
|
-
async function clientForState(
|
|
799
|
+
async function clientForState(
|
|
800
|
+
db: Database,
|
|
801
|
+
settings: Settings,
|
|
802
|
+
state: OAuthStatePayload,
|
|
803
|
+
): Promise<OAuthClientRegistration> {
|
|
625
804
|
if (state.clientRegistrationMethod === "cimd") {
|
|
626
805
|
return {
|
|
627
806
|
method: "cimd",
|
|
@@ -631,6 +810,19 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
|
|
|
631
810
|
tokenEndpointAuthMethod: "none",
|
|
632
811
|
};
|
|
633
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
|
+
}
|
|
634
826
|
if (state.clientRegistrationMethod === "dcr") {
|
|
635
827
|
const stored = await loadIntegrationOAuthClient(db, settings, state.issuer);
|
|
636
828
|
if (!stored || stored.clientId !== state.clientId) {
|
|
@@ -642,12 +834,17 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
|
|
|
642
834
|
authorizationServer: stored.authorizationServer,
|
|
643
835
|
clientId: stored.clientId,
|
|
644
836
|
...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
|
|
645
|
-
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
837
|
+
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
838
|
+
stored.tokenEndpointAuthMethod,
|
|
839
|
+
Boolean(stored.clientSecret),
|
|
840
|
+
),
|
|
646
841
|
};
|
|
647
842
|
}
|
|
648
843
|
const entry = operatorClientEntryFor(settings, [state.issuer, state.authorizationServer]);
|
|
649
844
|
if (!entry || entry.clientId !== state.clientId) {
|
|
650
|
-
throw new HTTPException(400, {
|
|
845
|
+
throw new HTTPException(400, {
|
|
846
|
+
message: "operator OAuth client credentials are no longer available",
|
|
847
|
+
});
|
|
651
848
|
}
|
|
652
849
|
return {
|
|
653
850
|
method: "operator",
|
|
@@ -655,7 +852,10 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
|
|
|
655
852
|
authorizationServer: state.authorizationServer,
|
|
656
853
|
clientId: entry.clientId,
|
|
657
854
|
...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
|
|
658
|
-
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
855
|
+
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
856
|
+
entry.tokenEndpointAuthMethod,
|
|
857
|
+
Boolean(entry.clientSecret),
|
|
858
|
+
),
|
|
659
859
|
};
|
|
660
860
|
}
|
|
661
861
|
|
|
@@ -677,19 +877,35 @@ async function exchangeAuthorizationCode(
|
|
|
677
877
|
body.set("redirect_uri", input.redirectUri);
|
|
678
878
|
body.set("code_verifier", input.verifier);
|
|
679
879
|
body.set("resource", input.resource);
|
|
680
|
-
|
|
681
|
-
|
|
880
|
+
const headers: Record<string, string> = {
|
|
881
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
882
|
+
accept: "application/json",
|
|
883
|
+
};
|
|
682
884
|
if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_post") {
|
|
885
|
+
body.set("client_id", input.client.clientId);
|
|
683
886
|
body.set("client_secret", input.client.clientSecret);
|
|
684
|
-
} else if (
|
|
887
|
+
} else if (
|
|
888
|
+
input.client.clientSecret &&
|
|
889
|
+
input.client.tokenEndpointAuthMethod === "client_secret_basic"
|
|
890
|
+
) {
|
|
685
891
|
headers.authorization = `Basic ${Buffer.from(`${input.client.clientId}:${input.client.clientSecret}`).toString("base64")}`;
|
|
892
|
+
} else {
|
|
893
|
+
body.set("client_id", input.client.clientId);
|
|
686
894
|
}
|
|
687
|
-
const response = await fetchOAuth(input.tokenEndpoint, settings, {
|
|
895
|
+
const response = await fetchOAuth(input.tokenEndpoint, settings, {
|
|
896
|
+
method: "POST",
|
|
897
|
+
headers,
|
|
898
|
+
body,
|
|
899
|
+
});
|
|
688
900
|
if (!response.ok) {
|
|
689
901
|
const oauthError = await oauthErrorFromResponse(response);
|
|
690
|
-
throw new OAuthCallbackStageError(
|
|
902
|
+
throw new OAuthCallbackStageError(
|
|
903
|
+
"token_exchange",
|
|
904
|
+
oauthError ?? "token_exchange_failed",
|
|
905
|
+
new Error(`OAuth token endpoint returned HTTP ${response.status}`),
|
|
906
|
+
);
|
|
691
907
|
}
|
|
692
|
-
const payload = await response.json() as Record<string, unknown>;
|
|
908
|
+
const payload = (await response.json()) as Record<string, unknown>;
|
|
693
909
|
const accessToken = stringValue(payload.access_token);
|
|
694
910
|
if (!accessToken) {
|
|
695
911
|
throw new Error("OAuth token response did not include access_token");
|
|
@@ -699,13 +915,15 @@ async function exchangeAuthorizationCode(
|
|
|
699
915
|
tokenType: stringValue(payload.token_type) ?? "Bearer",
|
|
700
916
|
expiresAt: expiresAtFromTokenResponse(payload),
|
|
701
917
|
raw: payload,
|
|
702
|
-
...(stringValue(payload.refresh_token)
|
|
918
|
+
...(stringValue(payload.refresh_token)
|
|
919
|
+
? { refreshToken: stringValue(payload.refresh_token)! }
|
|
920
|
+
: {}),
|
|
703
921
|
...(stringValue(payload.scope) ? { scopeText: stringValue(payload.scope)! } : {}),
|
|
704
922
|
};
|
|
705
923
|
}
|
|
706
924
|
|
|
707
|
-
async function
|
|
708
|
-
|
|
925
|
+
async function runCallbackStage<T>(
|
|
926
|
+
callbackStage: OAuthCallbackStage,
|
|
709
927
|
fallbackReason: string,
|
|
710
928
|
fn: () => Promise<T>,
|
|
711
929
|
): Promise<T> {
|
|
@@ -715,7 +933,7 @@ async function stage<T>(
|
|
|
715
933
|
if (error instanceof OAuthCallbackStageError) {
|
|
716
934
|
throw error;
|
|
717
935
|
}
|
|
718
|
-
throw new OAuthCallbackStageError(
|
|
936
|
+
throw new OAuthCallbackStageError(callbackStage, fallbackReason, error);
|
|
719
937
|
}
|
|
720
938
|
}
|
|
721
939
|
|
|
@@ -781,7 +999,10 @@ async function oauthErrorFromResponse(response: Response): Promise<string | null
|
|
|
781
999
|
if (!contentType.toLowerCase().includes("application/json")) {
|
|
782
1000
|
return null;
|
|
783
1001
|
}
|
|
784
|
-
const payload = await response
|
|
1002
|
+
const payload = (await response
|
|
1003
|
+
.clone()
|
|
1004
|
+
.json()
|
|
1005
|
+
.catch(() => null)) as Record<string, unknown> | null;
|
|
785
1006
|
const error = stringValue(payload?.error);
|
|
786
1007
|
if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
|
|
787
1008
|
return null;
|
|
@@ -789,17 +1010,29 @@ async function oauthErrorFromResponse(response: Response): Promise<string | null
|
|
|
789
1010
|
return error;
|
|
790
1011
|
}
|
|
791
1012
|
|
|
792
|
-
async function verifyMcpToolsList(
|
|
1013
|
+
async function verifyMcpToolsList(
|
|
1014
|
+
settings: Settings,
|
|
1015
|
+
resource: string,
|
|
1016
|
+
token: TokenResponse,
|
|
1017
|
+
): Promise<Array<{ name: string; description?: string }>> {
|
|
793
1018
|
await assertOAuthFetchAllowed(resource, settings);
|
|
794
|
-
const client = new Client(
|
|
1019
|
+
const client = new Client(
|
|
1020
|
+
{ name: "opengeni-integration-verify", version: "0.1.0" },
|
|
1021
|
+
{ capabilities: {} },
|
|
1022
|
+
);
|
|
795
1023
|
try {
|
|
796
1024
|
const transport = new StreamableHTTPClientTransport(new URL(resource), {
|
|
797
1025
|
requestInit: {
|
|
798
|
-
headers: {
|
|
1026
|
+
headers: {
|
|
1027
|
+
authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`,
|
|
1028
|
+
},
|
|
799
1029
|
},
|
|
800
1030
|
fetch: (url, init) => fetchOAuth(url.toString(), settings, init),
|
|
801
1031
|
});
|
|
802
|
-
await client.connect(transport as unknown as Transport, {
|
|
1032
|
+
await client.connect(transport as unknown as Transport, {
|
|
1033
|
+
timeout: 10_000,
|
|
1034
|
+
maxTotalTimeout: 10_000,
|
|
1035
|
+
});
|
|
803
1036
|
const listed = await client.listTools(undefined, { timeout: 10_000, maxTotalTimeout: 10_000 });
|
|
804
1037
|
return listed.tools.map((tool) => ({
|
|
805
1038
|
name: tool.name,
|
|
@@ -822,7 +1055,9 @@ async function verifyMcpToolsListNonFatal(
|
|
|
822
1055
|
tools?: Array<{ name: string; description?: string }>;
|
|
823
1056
|
}> {
|
|
824
1057
|
try {
|
|
825
|
-
const tools = await
|
|
1058
|
+
const tools = await runCallbackStage("tools_list", "tools_list_failed", () =>
|
|
1059
|
+
verifyMcpToolsList(settings, state.mcpUrl, token),
|
|
1060
|
+
);
|
|
826
1061
|
return {
|
|
827
1062
|
metadata: {
|
|
828
1063
|
status: "ok",
|
|
@@ -832,9 +1067,10 @@ async function verifyMcpToolsListNonFatal(
|
|
|
832
1067
|
tools,
|
|
833
1068
|
};
|
|
834
1069
|
} catch (error) {
|
|
835
|
-
const staged =
|
|
836
|
-
|
|
837
|
-
|
|
1070
|
+
const staged =
|
|
1071
|
+
error instanceof OAuthCallbackStageError
|
|
1072
|
+
? error
|
|
1073
|
+
: new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
|
|
838
1074
|
logOAuthVerificationWarning(observability, staged, state);
|
|
839
1075
|
return {
|
|
840
1076
|
metadata: {
|
|
@@ -846,7 +1082,11 @@ async function verifyMcpToolsListNonFatal(
|
|
|
846
1082
|
}
|
|
847
1083
|
}
|
|
848
1084
|
|
|
849
|
-
function credentialBundle(
|
|
1085
|
+
function credentialBundle(
|
|
1086
|
+
token: TokenResponse,
|
|
1087
|
+
state: OAuthStatePayload,
|
|
1088
|
+
client: OAuthClientRegistration,
|
|
1089
|
+
): Record<string, unknown> {
|
|
850
1090
|
return {
|
|
851
1091
|
access_token: token.accessToken,
|
|
852
1092
|
...(token.refreshToken ? { refresh_token: token.refreshToken } : {}),
|
|
@@ -854,14 +1094,27 @@ function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client
|
|
|
854
1094
|
...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
|
|
855
1095
|
resource: state.resource,
|
|
856
1096
|
mcp_url: state.mcpUrl,
|
|
857
|
-
...(token.scopeText
|
|
1097
|
+
...(token.scopeText
|
|
1098
|
+
? { scope: token.scopeText }
|
|
1099
|
+
: state.authorizeScopes.length
|
|
1100
|
+
? { scope: state.authorizeScopes.join(" ") }
|
|
1101
|
+
: {}),
|
|
858
1102
|
token_endpoint: state.tokenEndpoint,
|
|
859
1103
|
client_id: client.clientId,
|
|
860
|
-
...(client.clientSecret
|
|
1104
|
+
...(client.clientSecret
|
|
1105
|
+
? {
|
|
1106
|
+
client_secret: client.clientSecret,
|
|
1107
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
1108
|
+
}
|
|
1109
|
+
: {}),
|
|
861
1110
|
};
|
|
862
1111
|
}
|
|
863
1112
|
|
|
864
|
-
function callbackReturnPath(
|
|
1113
|
+
function callbackReturnPath(
|
|
1114
|
+
returnPath: string,
|
|
1115
|
+
status: "success" | "error",
|
|
1116
|
+
params: Record<string, string>,
|
|
1117
|
+
): string {
|
|
865
1118
|
const url = new URL(returnPath, "https://opengeni.local");
|
|
866
1119
|
url.searchParams.set("integration_oauth", status);
|
|
867
1120
|
for (const [key, value] of Object.entries(params)) {
|
|
@@ -887,7 +1140,9 @@ function canonicalMcpResource(value: string | undefined): string {
|
|
|
887
1140
|
function canonicalOAuthResource(value: string): string {
|
|
888
1141
|
const trimmed = value.trim();
|
|
889
1142
|
if (!trimmed) {
|
|
890
|
-
throw new HTTPException(422, {
|
|
1143
|
+
throw new HTTPException(422, {
|
|
1144
|
+
message: "MCP protected resource metadata advertised an invalid resource",
|
|
1145
|
+
});
|
|
891
1146
|
}
|
|
892
1147
|
try {
|
|
893
1148
|
const url = new URL(trimmed);
|
|
@@ -897,7 +1152,9 @@ function canonicalOAuthResource(value: string): string {
|
|
|
897
1152
|
}
|
|
898
1153
|
return trimmed;
|
|
899
1154
|
} catch {
|
|
900
|
-
throw new HTTPException(422, {
|
|
1155
|
+
throw new HTTPException(422, {
|
|
1156
|
+
message: "MCP protected resource metadata advertised an invalid resource",
|
|
1157
|
+
});
|
|
901
1158
|
}
|
|
902
1159
|
}
|
|
903
1160
|
|
|
@@ -924,7 +1181,12 @@ async function fetchJsonObject(url: string, settings: Settings): Promise<Record<
|
|
|
924
1181
|
return payload as Record<string, unknown>;
|
|
925
1182
|
}
|
|
926
1183
|
|
|
927
|
-
async function fetchOAuth(
|
|
1184
|
+
async function fetchOAuth(
|
|
1185
|
+
rawUrl: string,
|
|
1186
|
+
settings: Settings,
|
|
1187
|
+
init: RequestInit = {},
|
|
1188
|
+
hop = 0,
|
|
1189
|
+
): Promise<Response> {
|
|
928
1190
|
await assertOAuthFetchAllowed(rawUrl, settings);
|
|
929
1191
|
const response = await fetch(rawUrl, { ...init, redirect: "manual" });
|
|
930
1192
|
if (response.status < 300 || response.status >= 400) {
|
|
@@ -951,20 +1213,29 @@ async function assertOAuthFetchAllowed(rawUrl: string, settings: Settings): Prom
|
|
|
951
1213
|
if (!["https:", "http:"].includes(url.protocol)) {
|
|
952
1214
|
throw new HTTPException(422, { message: "OAuth discovery only supports http and https URLs" });
|
|
953
1215
|
}
|
|
954
|
-
if (
|
|
1216
|
+
if (
|
|
1217
|
+
settings.integrationsAllowPrivateNetworkTargets ||
|
|
1218
|
+
["local", "test"].includes(settings.environment)
|
|
1219
|
+
) {
|
|
955
1220
|
return;
|
|
956
1221
|
}
|
|
957
1222
|
if (url.protocol !== "https:") {
|
|
958
|
-
throw new HTTPException(422, {
|
|
1223
|
+
throw new HTTPException(422, {
|
|
1224
|
+
message: "OAuth discovery targets must use https outside local/test",
|
|
1225
|
+
});
|
|
959
1226
|
}
|
|
960
1227
|
const hostname = url.hostname.toLowerCase();
|
|
961
1228
|
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
962
1229
|
throw new HTTPException(422, { message: "OAuth discovery may not target localhost" });
|
|
963
1230
|
}
|
|
964
1231
|
const literal = isIP(hostname);
|
|
965
|
-
const addresses = literal
|
|
1232
|
+
const addresses = literal
|
|
1233
|
+
? [hostname]
|
|
1234
|
+
: (await lookup(hostname, { all: true })).map((entry) => entry.address);
|
|
966
1235
|
if (addresses.some(isPrivateAddress)) {
|
|
967
|
-
throw new HTTPException(422, {
|
|
1236
|
+
throw new HTTPException(422, {
|
|
1237
|
+
message: "OAuth discovery may not target private network addresses",
|
|
1238
|
+
});
|
|
968
1239
|
}
|
|
969
1240
|
}
|
|
970
1241
|
|
|
@@ -982,7 +1253,9 @@ function parseWwwAuthenticate(header: string | null): WwwAuthenticateChallenge {
|
|
|
982
1253
|
let match: RegExpExecArray | null;
|
|
983
1254
|
while ((match = re.exec(paramsText)) !== null) {
|
|
984
1255
|
const raw = match[2]!;
|
|
985
|
-
params[match[1]!.toLowerCase()] = raw.startsWith("
|
|
1256
|
+
params[match[1]!.toLowerCase()] = raw.startsWith('"')
|
|
1257
|
+
? raw.slice(1, -1).replace(/\\"/g, '"')
|
|
1258
|
+
: raw;
|
|
986
1259
|
}
|
|
987
1260
|
return {
|
|
988
1261
|
...(params.resource_metadata ? { resourceMetadata: params.resource_metadata } : {}),
|
|
@@ -1001,7 +1274,11 @@ function wellKnownCandidates(rawUrl: string, name: string): string[] {
|
|
|
1001
1274
|
]);
|
|
1002
1275
|
}
|
|
1003
1276
|
|
|
1004
|
-
function chooseAuthorizeScopes(
|
|
1277
|
+
function chooseAuthorizeScopes(
|
|
1278
|
+
requested: string[] | undefined,
|
|
1279
|
+
challenged: string[] | undefined,
|
|
1280
|
+
supported: string[],
|
|
1281
|
+
): string[] {
|
|
1005
1282
|
if (requested?.length) {
|
|
1006
1283
|
return uniqueStrings(requested);
|
|
1007
1284
|
}
|
|
@@ -1018,7 +1295,10 @@ function grantedScopes(scopeText: string | undefined, fallback: string[]): strin
|
|
|
1018
1295
|
return fallback;
|
|
1019
1296
|
}
|
|
1020
1297
|
|
|
1021
|
-
function tokenAuthMethod(
|
|
1298
|
+
function tokenAuthMethod(
|
|
1299
|
+
raw: string | undefined,
|
|
1300
|
+
hasSecret: boolean,
|
|
1301
|
+
): OAuthClientRegistration["tokenEndpointAuthMethod"] {
|
|
1022
1302
|
if (raw === "client_secret_post" || raw === "client_secret_basic") {
|
|
1023
1303
|
return raw;
|
|
1024
1304
|
}
|
|
@@ -1026,7 +1306,7 @@ function tokenAuthMethod(raw: string | undefined, hasSecret: boolean): OAuthClie
|
|
|
1026
1306
|
}
|
|
1027
1307
|
|
|
1028
1308
|
function registrationMethod(value: unknown): OAuthClientRegistration["method"] {
|
|
1029
|
-
if (value === "operator" || value === "cimd" || value === "dcr") {
|
|
1309
|
+
if (value === "operator" || value === "manual" || value === "cimd" || value === "dcr") {
|
|
1030
1310
|
return value;
|
|
1031
1311
|
}
|
|
1032
1312
|
throw new HTTPException(400, { message: "invalid OAuth state" });
|
|
@@ -1038,7 +1318,8 @@ function expiresAtFromTokenResponse(payload: Record<string, unknown>): Date | nu
|
|
|
1038
1318
|
const parsed = new Date(expiresAt);
|
|
1039
1319
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
1040
1320
|
}
|
|
1041
|
-
const expiresIn =
|
|
1321
|
+
const expiresIn =
|
|
1322
|
+
typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in);
|
|
1042
1323
|
if (Number.isFinite(expiresIn) && expiresIn > 0) {
|
|
1043
1324
|
return new Date(Date.now() + expiresIn * 1000);
|
|
1044
1325
|
}
|
|
@@ -1058,7 +1339,9 @@ function uniqueStrings(values: string[]): string[] {
|
|
|
1058
1339
|
}
|
|
1059
1340
|
|
|
1060
1341
|
function stringArray(value: unknown): string[] {
|
|
1061
|
-
return Array.isArray(value)
|
|
1342
|
+
return Array.isArray(value)
|
|
1343
|
+
? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string"))
|
|
1344
|
+
: [];
|
|
1062
1345
|
}
|
|
1063
1346
|
|
|
1064
1347
|
function stringValue(value: unknown): string | undefined {
|