@opengeni/api-router 0.5.2 → 0.5.3
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-3HIA43CC.js} +176 -37
- package/dist/chunk-3HIA43CC.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +4 -4
- package/src/integrations/oauth-client.ts +214 -36
- package/src/routes/connections.ts +3 -3
- package/dist/chunk-YY6OAEL6.js.map +0 -1
package/dist/app.js
CHANGED
|
@@ -2423,10 +2423,22 @@ function canonicalProviderDomain(value) {
|
|
|
2423
2423
|
|
|
2424
2424
|
// src/integrations/oauth-client.ts
|
|
2425
2425
|
var oauthStateTtlMs = 10 * 60 * 1e3;
|
|
2426
|
+
var OAuthCallbackStageError = class extends Error {
|
|
2427
|
+
constructor(stage2, reason, cause) {
|
|
2428
|
+
super(errorMessage(cause));
|
|
2429
|
+
this.stage = stage2;
|
|
2430
|
+
this.reason = reason;
|
|
2431
|
+
this.cause = cause;
|
|
2432
|
+
this.name = "OAuthCallbackStageError";
|
|
2433
|
+
}
|
|
2434
|
+
stage;
|
|
2435
|
+
reason;
|
|
2436
|
+
cause;
|
|
2437
|
+
};
|
|
2426
2438
|
async function startMcpOAuth(deps, context) {
|
|
2427
2439
|
const { db, settings } = deps;
|
|
2428
|
-
const
|
|
2429
|
-
const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(
|
|
2440
|
+
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
2441
|
+
const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
|
|
2430
2442
|
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
2431
2443
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
2432
2444
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
@@ -2435,7 +2447,8 @@ async function startMcpOAuth(deps, context) {
|
|
|
2435
2447
|
if (context.payload.connectionId && !existing) {
|
|
2436
2448
|
throw new HTTPException6(404, { message: "connection not found" });
|
|
2437
2449
|
}
|
|
2438
|
-
const discovery = await discoverMcpOAuth(
|
|
2450
|
+
const discovery = await discoverMcpOAuth(mcpUrl, settings);
|
|
2451
|
+
const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
|
|
2439
2452
|
const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
|
|
2440
2453
|
const verifier = randomPkceVerifier();
|
|
2441
2454
|
const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
|
|
@@ -2445,6 +2458,7 @@ async function startMcpOAuth(deps, context) {
|
|
|
2445
2458
|
workspaceId: context.workspaceId,
|
|
2446
2459
|
subjectId: context.subjectId,
|
|
2447
2460
|
providerDomain,
|
|
2461
|
+
mcpUrl,
|
|
2448
2462
|
resource,
|
|
2449
2463
|
requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
|
|
2450
2464
|
authorizeScopes,
|
|
@@ -2474,24 +2488,33 @@ async function startMcpOAuth(deps, context) {
|
|
|
2474
2488
|
});
|
|
2475
2489
|
}
|
|
2476
2490
|
async function completeMcpOAuthCallback(deps, input) {
|
|
2477
|
-
const { db, settings } = deps;
|
|
2491
|
+
const { db, settings, observability } = deps;
|
|
2492
|
+
let state = null;
|
|
2478
2493
|
if (!input.state) {
|
|
2479
|
-
|
|
2494
|
+
const error = new OAuthCallbackStageError("state_verify", "state_invalid", new Error("missing OAuth state"));
|
|
2495
|
+
logOAuthCallbackFailure(observability, error, state);
|
|
2496
|
+
return { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
|
|
2480
2497
|
}
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2498
|
+
try {
|
|
2499
|
+
state = readOAuthState(input.state, settings);
|
|
2500
|
+
if (!input.code) {
|
|
2501
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
|
|
2502
|
+
}
|
|
2503
|
+
const consumed = await consumeIntegrationOAuthStateNonce(db, {
|
|
2504
|
+
accountId: state.accountId,
|
|
2505
|
+
workspaceId: state.workspaceId,
|
|
2506
|
+
subjectId: state.subjectId,
|
|
2507
|
+
nonce: state.nonce,
|
|
2508
|
+
expiresAt: new Date(state.iat * 1e3 + oauthStateTtlMs),
|
|
2509
|
+
now: /* @__PURE__ */ new Date()
|
|
2510
|
+
});
|
|
2511
|
+
if (!consumed) {
|
|
2512
|
+
throw new HTTPException6(400, { message: "OAuth state has already been used" });
|
|
2513
|
+
}
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
|
|
2516
|
+
logOAuthCallbackFailure(observability, staged, state);
|
|
2517
|
+
return { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", { reason: staged.reason }) };
|
|
2495
2518
|
}
|
|
2496
2519
|
try {
|
|
2497
2520
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, input.requestUrl);
|
|
@@ -2499,28 +2522,30 @@ async function completeMcpOAuthCallback(deps, input) {
|
|
|
2499
2522
|
const key = requireEnvironmentEncryption2(settings);
|
|
2500
2523
|
const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
|
|
2501
2524
|
const client = await clientForState(db, settings, state);
|
|
2502
|
-
const token = await exchangeAuthorizationCode(settings, {
|
|
2525
|
+
const token = await stage("token_exchange", "token_exchange_failed", () => exchangeAuthorizationCode(settings, {
|
|
2503
2526
|
code: input.code,
|
|
2504
2527
|
verifier,
|
|
2505
2528
|
redirectUri,
|
|
2506
2529
|
resource: state.resource,
|
|
2507
2530
|
tokenEndpoint: state.tokenEndpoint,
|
|
2508
2531
|
client
|
|
2509
|
-
});
|
|
2510
|
-
const
|
|
2532
|
+
}));
|
|
2533
|
+
const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
|
|
2511
2534
|
const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
|
|
2512
2535
|
const credential = credentialBundle(token, state, client);
|
|
2513
2536
|
const metadata = {
|
|
2514
2537
|
resource: state.resource,
|
|
2538
|
+
mcpUrl: state.mcpUrl,
|
|
2515
2539
|
authorizationServer: state.authorizationServer,
|
|
2516
2540
|
authorizationServerIssuer: state.issuer,
|
|
2517
2541
|
tokenEndpoint: state.tokenEndpoint,
|
|
2518
2542
|
clientId: client.clientId,
|
|
2519
2543
|
clientRegistrationMethod: state.clientRegistrationMethod,
|
|
2520
|
-
|
|
2544
|
+
mcpToolsVerification: verification.metadata,
|
|
2545
|
+
...verification.tools ? { mcpTools: verification.tools } : {}
|
|
2521
2546
|
};
|
|
2522
2547
|
const credentialEncrypted = encryptEnvironmentValue3(key, JSON.stringify(credential));
|
|
2523
|
-
const connection = state.connectionId ?
|
|
2548
|
+
const connection = await stage("persist", "persist_failed", () => state.connectionId ? updateConnection(db, {
|
|
2524
2549
|
workspaceId: state.workspaceId,
|
|
2525
2550
|
connectionId: state.connectionId,
|
|
2526
2551
|
visibleToSubjectId: state.subjectId,
|
|
@@ -2533,7 +2558,7 @@ async function completeMcpOAuthCallback(deps, input) {
|
|
|
2533
2558
|
expiresAt: token.expiresAt,
|
|
2534
2559
|
metadata,
|
|
2535
2560
|
updatedBySubjectId: state.subjectId
|
|
2536
|
-
}) :
|
|
2561
|
+
}) : createConnection(db, {
|
|
2537
2562
|
accountId: state.accountId,
|
|
2538
2563
|
workspaceId: state.workspaceId,
|
|
2539
2564
|
subjectId: null,
|
|
@@ -2544,16 +2569,21 @@ async function completeMcpOAuthCallback(deps, input) {
|
|
|
2544
2569
|
expiresAt: token.expiresAt,
|
|
2545
2570
|
metadata,
|
|
2546
2571
|
createdBySubjectId: state.subjectId
|
|
2547
|
-
});
|
|
2572
|
+
}));
|
|
2548
2573
|
if (!connection) {
|
|
2549
2574
|
throw new HTTPException6(409, { message: "connection changed during OAuth reconnect; start again" });
|
|
2550
2575
|
}
|
|
2551
|
-
return {
|
|
2576
|
+
return {
|
|
2577
|
+
redirectTo: callbackReturnPath(state.returnPath, "success", {
|
|
2578
|
+
connectionId: connection.id,
|
|
2579
|
+
providerDomain: connection.providerDomain,
|
|
2580
|
+
...verification.metadata.status === "failed" ? { verification: "failed" } : {}
|
|
2581
|
+
})
|
|
2582
|
+
};
|
|
2552
2583
|
} catch (error) {
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
}
|
|
2556
|
-
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "oauth_callback_failed" }) };
|
|
2584
|
+
const staged = error instanceof OAuthCallbackStageError ? error : new OAuthCallbackStageError("persist", "persist_failed", error);
|
|
2585
|
+
logOAuthCallbackFailure(observability, staged, state);
|
|
2586
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
|
|
2557
2587
|
}
|
|
2558
2588
|
}
|
|
2559
2589
|
function integrationBaseUrl(publicBaseUrl, requestUrl) {
|
|
@@ -2805,12 +2835,14 @@ function readOAuthState(state, settings) {
|
|
|
2805
2835
|
if (iat === void 0 || nowSeconds - iat > oauthStateTtlMs / 1e3 || nowSeconds < iat) {
|
|
2806
2836
|
throw new HTTPException6(400, { message: "invalid or expired OAuth state" });
|
|
2807
2837
|
}
|
|
2838
|
+
const resource = requiredString(payload.resource, "state.resource");
|
|
2808
2839
|
const parsed = {
|
|
2809
2840
|
accountId: requiredString(payload.accountId, "state.accountId"),
|
|
2810
2841
|
workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
|
|
2811
2842
|
subjectId: requiredString(payload.subjectId, "state.subjectId"),
|
|
2812
2843
|
providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
|
|
2813
|
-
|
|
2844
|
+
mcpUrl: stringValue(payload.mcpUrl) ?? resource,
|
|
2845
|
+
resource,
|
|
2814
2846
|
requestedScopes: stringArray(payload.requestedScopes),
|
|
2815
2847
|
authorizeScopes: stringArray(payload.authorizeScopes),
|
|
2816
2848
|
encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
|
|
@@ -2886,7 +2918,8 @@ async function exchangeAuthorizationCode(settings, input) {
|
|
|
2886
2918
|
}
|
|
2887
2919
|
const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
|
|
2888
2920
|
if (!response.ok) {
|
|
2889
|
-
|
|
2921
|
+
const oauthError = await oauthErrorFromResponse(response);
|
|
2922
|
+
throw new OAuthCallbackStageError("token_exchange", oauthError ?? "token_exchange_failed", new Error(`OAuth token endpoint returned HTTP ${response.status}`));
|
|
2890
2923
|
}
|
|
2891
2924
|
const payload = await response.json();
|
|
2892
2925
|
const accessToken = stringValue(payload.access_token);
|
|
@@ -2902,6 +2935,72 @@ async function exchangeAuthorizationCode(settings, input) {
|
|
|
2902
2935
|
...stringValue(payload.scope) ? { scopeText: stringValue(payload.scope) } : {}
|
|
2903
2936
|
};
|
|
2904
2937
|
}
|
|
2938
|
+
async function stage(stage2, fallbackReason, fn) {
|
|
2939
|
+
try {
|
|
2940
|
+
return await fn();
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
if (error instanceof OAuthCallbackStageError) {
|
|
2943
|
+
throw error;
|
|
2944
|
+
}
|
|
2945
|
+
throw new OAuthCallbackStageError(stage2, fallbackReason, error);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
function logOAuthCallbackFailure(observability, error, state) {
|
|
2949
|
+
observability?.error("MCP OAuth callback failed", {
|
|
2950
|
+
"opengeni.oauth.stage": error.stage,
|
|
2951
|
+
"opengeni.oauth.reason": error.reason,
|
|
2952
|
+
"opengeni.oauth.provider_domain": state?.providerDomain,
|
|
2953
|
+
"opengeni.oauth.resource_host": state ? safeHost(state.resource) : void 0,
|
|
2954
|
+
"opengeni.oauth.authorization_server": state?.authorizationServer,
|
|
2955
|
+
"opengeni.oauth.issuer": state?.issuer,
|
|
2956
|
+
"opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
|
|
2957
|
+
error: sanitizedError(error.cause)
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
function logOAuthVerificationWarning(observability, error, state) {
|
|
2961
|
+
observability?.warn("MCP OAuth tools/list verification failed after token exchange", {
|
|
2962
|
+
"opengeni.oauth.stage": error.stage,
|
|
2963
|
+
"opengeni.oauth.reason": error.reason,
|
|
2964
|
+
"opengeni.oauth.provider_domain": state.providerDomain,
|
|
2965
|
+
"opengeni.oauth.resource_host": safeHost(state.resource),
|
|
2966
|
+
"opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
|
|
2967
|
+
"opengeni.oauth.authorization_server": state.authorizationServer,
|
|
2968
|
+
"opengeni.oauth.issuer": state.issuer,
|
|
2969
|
+
"opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
|
|
2970
|
+
error: sanitizedError(error.cause)
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
function sanitizedError(error) {
|
|
2974
|
+
if (error instanceof HTTPException6) {
|
|
2975
|
+
return `HTTPException ${error.status}: ${error.message}`;
|
|
2976
|
+
}
|
|
2977
|
+
if (error instanceof Error) {
|
|
2978
|
+
return `${error.name}: ${error.message}`;
|
|
2979
|
+
}
|
|
2980
|
+
return String(error);
|
|
2981
|
+
}
|
|
2982
|
+
function errorMessage(error) {
|
|
2983
|
+
return error instanceof Error ? error.message : String(error);
|
|
2984
|
+
}
|
|
2985
|
+
function safeHost(rawUrl) {
|
|
2986
|
+
try {
|
|
2987
|
+
return new URL(rawUrl).host;
|
|
2988
|
+
} catch {
|
|
2989
|
+
return void 0;
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
async function oauthErrorFromResponse(response) {
|
|
2993
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
2994
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
2995
|
+
return null;
|
|
2996
|
+
}
|
|
2997
|
+
const payload = await response.clone().json().catch(() => null);
|
|
2998
|
+
const error = stringValue(payload?.error);
|
|
2999
|
+
if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
|
|
3000
|
+
return null;
|
|
3001
|
+
}
|
|
3002
|
+
return error;
|
|
3003
|
+
}
|
|
2905
3004
|
async function verifyMcpToolsList(settings, resource, token) {
|
|
2906
3005
|
await assertOAuthFetchAllowed(resource, settings);
|
|
2907
3006
|
const client = new Client2({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
|
|
@@ -2922,6 +3021,29 @@ async function verifyMcpToolsList(settings, resource, token) {
|
|
|
2922
3021
|
await client.close().catch(() => void 0);
|
|
2923
3022
|
}
|
|
2924
3023
|
}
|
|
3024
|
+
async function verifyMcpToolsListNonFatal(observability, settings, state, token) {
|
|
3025
|
+
try {
|
|
3026
|
+
const tools = await stage("tools_list", "tools_list_failed", () => verifyMcpToolsList(settings, state.mcpUrl, token));
|
|
3027
|
+
return {
|
|
3028
|
+
metadata: {
|
|
3029
|
+
status: "ok",
|
|
3030
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3031
|
+
toolCount: tools.length
|
|
3032
|
+
},
|
|
3033
|
+
tools
|
|
3034
|
+
};
|
|
3035
|
+
} catch (error) {
|
|
3036
|
+
const staged = error instanceof OAuthCallbackStageError ? error : new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
|
|
3037
|
+
logOAuthVerificationWarning(observability, staged, state);
|
|
3038
|
+
return {
|
|
3039
|
+
metadata: {
|
|
3040
|
+
status: "failed",
|
|
3041
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3042
|
+
reason: staged.reason
|
|
3043
|
+
}
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
2925
3047
|
function credentialBundle(token, state, client) {
|
|
2926
3048
|
return {
|
|
2927
3049
|
access_token: token.accessToken,
|
|
@@ -2929,6 +3051,7 @@ function credentialBundle(token, state, client) {
|
|
|
2929
3051
|
token_type: token.tokenType,
|
|
2930
3052
|
...token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {},
|
|
2931
3053
|
resource: state.resource,
|
|
3054
|
+
mcp_url: state.mcpUrl,
|
|
2932
3055
|
...token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {},
|
|
2933
3056
|
token_endpoint: state.tokenEndpoint,
|
|
2934
3057
|
client_id: client.clientId,
|
|
@@ -2956,6 +3079,22 @@ function canonicalMcpResource(value) {
|
|
|
2956
3079
|
url.hash = "";
|
|
2957
3080
|
return url.toString();
|
|
2958
3081
|
}
|
|
3082
|
+
function canonicalOAuthResource(value) {
|
|
3083
|
+
const trimmed = value.trim();
|
|
3084
|
+
if (!trimmed) {
|
|
3085
|
+
throw new HTTPException6(422, { message: "MCP protected resource metadata advertised an invalid resource" });
|
|
3086
|
+
}
|
|
3087
|
+
try {
|
|
3088
|
+
const url = new URL(trimmed);
|
|
3089
|
+
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
3090
|
+
url.hash = "";
|
|
3091
|
+
return url.toString();
|
|
3092
|
+
}
|
|
3093
|
+
return trimmed;
|
|
3094
|
+
} catch {
|
|
3095
|
+
throw new HTTPException6(422, { message: "MCP protected resource metadata advertised an invalid resource" });
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
2959
3098
|
function safeReturnPath(value) {
|
|
2960
3099
|
if (!value.startsWith("/") || value.startsWith("//")) {
|
|
2961
3100
|
throw new HTTPException6(400, { message: "OAuth returnPath must be a relative path" });
|
|
@@ -3117,7 +3256,7 @@ function requiredString(value, field) {
|
|
|
3117
3256
|
|
|
3118
3257
|
// src/routes/connections.ts
|
|
3119
3258
|
function registerConnectionRoutes(app, deps) {
|
|
3120
|
-
const { db, settings } = deps;
|
|
3259
|
+
const { db, settings, observability } = deps;
|
|
3121
3260
|
function assertIntegrationsEnabled() {
|
|
3122
3261
|
if (!settings.integrationsEnabled) {
|
|
3123
3262
|
throw new HTTPException7(404, { message: "integrations are not enabled for this deployment" });
|
|
@@ -3210,7 +3349,7 @@ function registerConnectionRoutes(app, deps) {
|
|
|
3210
3349
|
throw new HTTPException7(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
|
|
3211
3350
|
}
|
|
3212
3351
|
const payload = parsed.data;
|
|
3213
|
-
const result = await startMcpOAuth({ db, settings }, {
|
|
3352
|
+
const result = await startMcpOAuth({ db, settings, observability }, {
|
|
3214
3353
|
accountId: grant.accountId,
|
|
3215
3354
|
workspaceId,
|
|
3216
3355
|
subjectId: grant.subjectId,
|
|
@@ -3221,7 +3360,7 @@ function registerConnectionRoutes(app, deps) {
|
|
|
3221
3360
|
});
|
|
3222
3361
|
app.get("/v1/integrations/oauth/callback", async (c) => {
|
|
3223
3362
|
assertIntegrationsEnabled();
|
|
3224
|
-
const result = await completeMcpOAuthCallback({ db, settings }, {
|
|
3363
|
+
const result = await completeMcpOAuthCallback({ db, settings, observability }, {
|
|
3225
3364
|
code: c.req.query("code"),
|
|
3226
3365
|
state: c.req.query("state"),
|
|
3227
3366
|
requestUrl: c.req.url
|
|
@@ -8096,4 +8235,4 @@ export {
|
|
|
8096
8235
|
withDefaultEnabledCapabilityMcpTools,
|
|
8097
8236
|
workflowIdForSession3 as workflowIdForSession
|
|
8098
8237
|
};
|
|
8099
|
-
//# sourceMappingURL=chunk-
|
|
8238
|
+
//# sourceMappingURL=chunk-3HIA43CC.js.map
|