@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.
- package/dist/app.js +1 -1
- package/dist/{chunk-YY6OAEL6.js → chunk-DO2G3JSB.js} +5333 -2205
- package/dist/chunk-DO2G3JSB.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +297 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -21
- package/src/app.ts +415 -147
- 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 +27 -6
- package/src/index.ts +196 -74
- package/src/integrations/oauth-client.ts +592 -131
- package/src/integrations/provider-domain.ts +4 -1
- package/src/mcp/documents.ts +173 -94
- package/src/mcp/server.ts +1517 -692
- 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 +72 -34
- 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 +839 -329
- package/src/routes/social.ts +50 -38
- package/src/routes/workspace-capture.ts +238 -0
- package/src/routes/workspaces.ts +159 -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-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
|
|
116
|
-
const providerDomain = canonicalProviderDomain(
|
|
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 =
|
|
122
|
-
|
|
123
|
-
:
|
|
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(
|
|
129
|
-
const
|
|
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(
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
279
|
+
mcpToolsVerification: verification.metadata,
|
|
280
|
+
...(verification.tools ? { mcpTools: verification.tools } : {}),
|
|
216
281
|
};
|
|
217
282
|
const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
218
|
-
const connection =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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, {
|
|
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 {
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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, {
|
|
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(
|
|
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(
|
|
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, {
|
|
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, {
|
|
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(
|
|
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(
|
|
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)
|
|
461
|
+
...(stringValue(payload.registration_endpoint)
|
|
462
|
+
? { registrationEndpoint: stringValue(payload.registration_endpoint)! }
|
|
463
|
+
: {}),
|
|
365
464
|
};
|
|
366
465
|
}
|
|
367
|
-
throw new HTTPException(422, {
|
|
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(
|
|
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
|
|
537
|
+
const storeInput = {
|
|
409
538
|
issuer: as.issuer,
|
|
410
539
|
authorizationServer: as.authorizationServer,
|
|
411
540
|
clientId: dcr.clientId,
|
|
412
|
-
clientSecretEncrypted:
|
|
541
|
+
clientSecretEncrypted:
|
|
542
|
+
dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null,
|
|
413
543
|
tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
|
|
414
|
-
metadata:
|
|
415
|
-
|
|
416
|
-
|
|
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, {
|
|
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(
|
|
598
|
+
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
599
|
+
stored.tokenEndpointAuthMethod,
|
|
600
|
+
Boolean(stored.clientSecret),
|
|
601
|
+
),
|
|
443
602
|
};
|
|
444
603
|
}
|
|
445
604
|
|
|
446
|
-
function operatorClientForAs(
|
|
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(
|
|
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(
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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(
|
|
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<
|
|
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
|
-
|
|
769
|
+
mcpUrl: stringValue(payload.mcpUrl) ?? resource,
|
|
770
|
+
resource,
|
|
564
771
|
requestedScopes: stringArray(payload.requestedScopes),
|
|
565
772
|
authorizeScopes: stringArray(payload.authorizeScopes),
|
|
566
|
-
encryptedPkceVerifier: requiredString(
|
|
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(
|
|
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(
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
643
|
-
|
|
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 (
|
|
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, {
|
|
895
|
+
const response = await fetchOAuth(input.tokenEndpoint, settings, {
|
|
896
|
+
method: "POST",
|
|
897
|
+
headers,
|
|
898
|
+
body,
|
|
899
|
+
});
|
|
650
900
|
if (!response.ok) {
|
|
651
|
-
|
|
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)
|
|
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
|
|
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(
|
|
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: {
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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 (
|
|
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, {
|
|
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
|
|
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, {
|
|
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("
|
|
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(
|
|
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(
|
|
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 =
|
|
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)
|
|
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 {
|