@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/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-3HIA43CC.js";
|
|
4
4
|
|
|
5
5
|
// src/index.ts
|
|
6
6
|
import { dbSearchPath, getSettings, resolveNatsCalloutConfig, resolveNatsControlPlaneAuth, retryStartupDependency, startupRetryOptions } from "@opengeni/config";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/api-router",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -49,13 +49,13 @@
|
|
|
49
49
|
"@opengeni/codex": "^0.2.1",
|
|
50
50
|
"@opengeni/config": "^0.4.0",
|
|
51
51
|
"@opengeni/contracts": "^0.9.0",
|
|
52
|
-
"@opengeni/core": "^0.4.
|
|
52
|
+
"@opengeni/core": "^0.4.6",
|
|
53
53
|
"@opengeni/db": "^0.6.1",
|
|
54
54
|
"@opengeni/documents": "^0.2.8",
|
|
55
55
|
"@opengeni/events": "^0.2.8",
|
|
56
56
|
"@opengeni/github": "^0.2.8",
|
|
57
|
-
"@opengeni/observability": "^0.
|
|
58
|
-
"@opengeni/runtime": "^0.6.
|
|
57
|
+
"@opengeni/observability": "^0.3.0",
|
|
58
|
+
"@opengeni/runtime": "^0.6.1",
|
|
59
59
|
"@opengeni/storage": "^0.2.8",
|
|
60
60
|
"@temporalio/client": "^1.17.0",
|
|
61
61
|
"better-auth": "^1.6.14",
|
|
@@ -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,
|
|
@@ -29,6 +30,7 @@ export const oauthStateTtlMs = 10 * 60 * 1000;
|
|
|
29
30
|
type OAuthClientDeps = {
|
|
30
31
|
db: Database;
|
|
31
32
|
settings: Settings;
|
|
33
|
+
observability?: Observability | undefined;
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
export type OAuthStartContext = {
|
|
@@ -81,6 +83,7 @@ type OAuthStatePayload = {
|
|
|
81
83
|
workspaceId: string;
|
|
82
84
|
subjectId: string;
|
|
83
85
|
providerDomain: string;
|
|
86
|
+
mcpUrl: string;
|
|
84
87
|
resource: string;
|
|
85
88
|
requestedScopes: string[];
|
|
86
89
|
authorizeScopes: string[];
|
|
@@ -107,13 +110,26 @@ type TokenResponse = {
|
|
|
107
110
|
raw: Record<string, unknown>;
|
|
108
111
|
};
|
|
109
112
|
|
|
113
|
+
type OAuthCallbackStage = "state_verify" | "token_exchange" | "tools_list" | "persist";
|
|
114
|
+
|
|
115
|
+
class OAuthCallbackStageError extends Error {
|
|
116
|
+
constructor(
|
|
117
|
+
readonly stage: OAuthCallbackStage,
|
|
118
|
+
readonly reason: string,
|
|
119
|
+
readonly cause: unknown,
|
|
120
|
+
) {
|
|
121
|
+
super(errorMessage(cause));
|
|
122
|
+
this.name = "OAuthCallbackStageError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
110
126
|
export async function startMcpOAuth(
|
|
111
127
|
deps: OAuthClientDeps,
|
|
112
128
|
context: OAuthStartContext,
|
|
113
129
|
): Promise<OAuthStartResponse> {
|
|
114
130
|
const { db, settings } = deps;
|
|
115
|
-
const
|
|
116
|
-
const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(
|
|
131
|
+
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
132
|
+
const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
|
|
117
133
|
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
118
134
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
119
135
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
@@ -125,7 +141,8 @@ export async function startMcpOAuth(
|
|
|
125
141
|
throw new HTTPException(404, { message: "connection not found" });
|
|
126
142
|
}
|
|
127
143
|
|
|
128
|
-
const discovery = await discoverMcpOAuth(
|
|
144
|
+
const discovery = await discoverMcpOAuth(mcpUrl, settings);
|
|
145
|
+
const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
|
|
129
146
|
const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
|
|
130
147
|
const verifier = randomPkceVerifier();
|
|
131
148
|
const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
|
|
@@ -135,6 +152,7 @@ export async function startMcpOAuth(
|
|
|
135
152
|
workspaceId: context.workspaceId,
|
|
136
153
|
subjectId: context.subjectId,
|
|
137
154
|
providerDomain,
|
|
155
|
+
mcpUrl,
|
|
138
156
|
resource,
|
|
139
157
|
requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
|
|
140
158
|
authorizeScopes,
|
|
@@ -168,24 +186,33 @@ export async function completeMcpOAuthCallback(
|
|
|
168
186
|
deps: OAuthClientDeps,
|
|
169
187
|
input: { code?: string | undefined; state?: string | undefined; requestUrl: string },
|
|
170
188
|
): Promise<OAuthCallbackResult> {
|
|
171
|
-
const { db, settings } = deps;
|
|
189
|
+
const { db, settings, observability } = deps;
|
|
190
|
+
let state: OAuthStatePayload | null = null;
|
|
172
191
|
if (!input.state) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
192
|
+
const error = new OAuthCallbackStageError("state_verify", "state_invalid", new Error("missing OAuth state"));
|
|
193
|
+
logOAuthCallbackFailure(observability, error, state);
|
|
194
|
+
return { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
state = readOAuthState(input.state, settings);
|
|
198
|
+
if (!input.code) {
|
|
199
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
|
|
200
|
+
}
|
|
201
|
+
const consumed = await consumeIntegrationOAuthStateNonce(db, {
|
|
202
|
+
accountId: state.accountId,
|
|
203
|
+
workspaceId: state.workspaceId,
|
|
204
|
+
subjectId: state.subjectId,
|
|
205
|
+
nonce: state.nonce,
|
|
206
|
+
expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
|
|
207
|
+
now: new Date(),
|
|
208
|
+
});
|
|
209
|
+
if (!consumed) {
|
|
210
|
+
throw new HTTPException(400, { message: "OAuth state has already been used" });
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
|
|
214
|
+
logOAuthCallbackFailure(observability, staged, state);
|
|
215
|
+
return { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", { reason: staged.reason }) };
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
try {
|
|
@@ -194,29 +221,31 @@ export async function completeMcpOAuthCallback(
|
|
|
194
221
|
const key = requireEnvironmentEncryption(settings);
|
|
195
222
|
const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
|
|
196
223
|
const client = await clientForState(db, settings, state);
|
|
197
|
-
const token = await exchangeAuthorizationCode(settings, {
|
|
198
|
-
code: input.code
|
|
224
|
+
const token = await stage("token_exchange", "token_exchange_failed", () => exchangeAuthorizationCode(settings, {
|
|
225
|
+
code: input.code!,
|
|
199
226
|
verifier,
|
|
200
227
|
redirectUri,
|
|
201
228
|
resource: state.resource,
|
|
202
229
|
tokenEndpoint: state.tokenEndpoint,
|
|
203
230
|
client,
|
|
204
|
-
});
|
|
205
|
-
const
|
|
231
|
+
}));
|
|
232
|
+
const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
|
|
206
233
|
const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
|
|
207
234
|
const credential = credentialBundle(token, state, client);
|
|
208
235
|
const metadata = {
|
|
209
236
|
resource: state.resource,
|
|
237
|
+
mcpUrl: state.mcpUrl,
|
|
210
238
|
authorizationServer: state.authorizationServer,
|
|
211
239
|
authorizationServerIssuer: state.issuer,
|
|
212
240
|
tokenEndpoint: state.tokenEndpoint,
|
|
213
241
|
clientId: client.clientId,
|
|
214
242
|
clientRegistrationMethod: state.clientRegistrationMethod,
|
|
215
|
-
|
|
243
|
+
mcpToolsVerification: verification.metadata,
|
|
244
|
+
...(verification.tools ? { mcpTools: verification.tools } : {}),
|
|
216
245
|
};
|
|
217
246
|
const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
218
|
-
const connection = state.connectionId
|
|
219
|
-
?
|
|
247
|
+
const connection = await stage("persist", "persist_failed", () => state.connectionId
|
|
248
|
+
? updateConnection(db, {
|
|
220
249
|
workspaceId: state.workspaceId,
|
|
221
250
|
connectionId: state.connectionId,
|
|
222
251
|
visibleToSubjectId: state.subjectId,
|
|
@@ -230,7 +259,7 @@ export async function completeMcpOAuthCallback(
|
|
|
230
259
|
metadata,
|
|
231
260
|
updatedBySubjectId: state.subjectId,
|
|
232
261
|
})
|
|
233
|
-
:
|
|
262
|
+
: createConnection(db, {
|
|
234
263
|
accountId: state.accountId,
|
|
235
264
|
workspaceId: state.workspaceId,
|
|
236
265
|
subjectId: null,
|
|
@@ -241,7 +270,7 @@ export async function completeMcpOAuthCallback(
|
|
|
241
270
|
expiresAt: token.expiresAt,
|
|
242
271
|
metadata,
|
|
243
272
|
createdBySubjectId: state.subjectId,
|
|
244
|
-
});
|
|
273
|
+
}));
|
|
245
274
|
if (!connection) {
|
|
246
275
|
throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again" });
|
|
247
276
|
}
|
|
@@ -249,12 +278,19 @@ export async function completeMcpOAuthCallback(
|
|
|
249
278
|
// the enable connectionRef straight from the redirect, without a listConnections
|
|
250
279
|
// round-trip that could fail (transient, or a grant lacking connections:read)
|
|
251
280
|
// and leave the connection created but the capability un-enabled.
|
|
252
|
-
return {
|
|
281
|
+
return {
|
|
282
|
+
redirectTo: callbackReturnPath(state.returnPath, "success", {
|
|
283
|
+
connectionId: connection.id,
|
|
284
|
+
providerDomain: connection.providerDomain,
|
|
285
|
+
...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
|
|
286
|
+
}),
|
|
287
|
+
};
|
|
253
288
|
} catch (error) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
289
|
+
const staged = error instanceof OAuthCallbackStageError
|
|
290
|
+
? error
|
|
291
|
+
: new OAuthCallbackStageError("persist", "persist_failed", error);
|
|
292
|
+
logOAuthCallbackFailure(observability, staged, state);
|
|
293
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
|
|
258
294
|
}
|
|
259
295
|
}
|
|
260
296
|
|
|
@@ -555,12 +591,14 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
555
591
|
if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
|
|
556
592
|
throw new HTTPException(400, { message: "invalid or expired OAuth state" });
|
|
557
593
|
}
|
|
594
|
+
const resource = requiredString(payload.resource, "state.resource");
|
|
558
595
|
const parsed = {
|
|
559
596
|
accountId: requiredString(payload.accountId, "state.accountId"),
|
|
560
597
|
workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
|
|
561
598
|
subjectId: requiredString(payload.subjectId, "state.subjectId"),
|
|
562
599
|
providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
|
|
563
|
-
|
|
600
|
+
mcpUrl: stringValue(payload.mcpUrl) ?? resource,
|
|
601
|
+
resource,
|
|
564
602
|
requestedScopes: stringArray(payload.requestedScopes),
|
|
565
603
|
authorizeScopes: stringArray(payload.authorizeScopes),
|
|
566
604
|
encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
|
|
@@ -648,7 +686,8 @@ async function exchangeAuthorizationCode(
|
|
|
648
686
|
}
|
|
649
687
|
const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
|
|
650
688
|
if (!response.ok) {
|
|
651
|
-
|
|
689
|
+
const oauthError = await oauthErrorFromResponse(response);
|
|
690
|
+
throw new OAuthCallbackStageError("token_exchange", oauthError ?? "token_exchange_failed", new Error(`OAuth token endpoint returned HTTP ${response.status}`));
|
|
652
691
|
}
|
|
653
692
|
const payload = await response.json() as Record<string, unknown>;
|
|
654
693
|
const accessToken = stringValue(payload.access_token);
|
|
@@ -665,6 +704,91 @@ async function exchangeAuthorizationCode(
|
|
|
665
704
|
};
|
|
666
705
|
}
|
|
667
706
|
|
|
707
|
+
async function stage<T>(
|
|
708
|
+
stage: OAuthCallbackStage,
|
|
709
|
+
fallbackReason: string,
|
|
710
|
+
fn: () => Promise<T>,
|
|
711
|
+
): Promise<T> {
|
|
712
|
+
try {
|
|
713
|
+
return await fn();
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (error instanceof OAuthCallbackStageError) {
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
throw new OAuthCallbackStageError(stage, fallbackReason, error);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function logOAuthCallbackFailure(
|
|
723
|
+
observability: Observability | undefined,
|
|
724
|
+
error: OAuthCallbackStageError,
|
|
725
|
+
state: OAuthStatePayload | null,
|
|
726
|
+
): void {
|
|
727
|
+
observability?.error("MCP OAuth callback failed", {
|
|
728
|
+
"opengeni.oauth.stage": error.stage,
|
|
729
|
+
"opengeni.oauth.reason": error.reason,
|
|
730
|
+
"opengeni.oauth.provider_domain": state?.providerDomain,
|
|
731
|
+
"opengeni.oauth.resource_host": state ? safeHost(state.resource) : undefined,
|
|
732
|
+
"opengeni.oauth.authorization_server": state?.authorizationServer,
|
|
733
|
+
"opengeni.oauth.issuer": state?.issuer,
|
|
734
|
+
"opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
|
|
735
|
+
error: sanitizedError(error.cause),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function logOAuthVerificationWarning(
|
|
740
|
+
observability: Observability | undefined,
|
|
741
|
+
error: OAuthCallbackStageError,
|
|
742
|
+
state: OAuthStatePayload,
|
|
743
|
+
): void {
|
|
744
|
+
observability?.warn("MCP OAuth tools/list verification failed after token exchange", {
|
|
745
|
+
"opengeni.oauth.stage": error.stage,
|
|
746
|
+
"opengeni.oauth.reason": error.reason,
|
|
747
|
+
"opengeni.oauth.provider_domain": state.providerDomain,
|
|
748
|
+
"opengeni.oauth.resource_host": safeHost(state.resource),
|
|
749
|
+
"opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
|
|
750
|
+
"opengeni.oauth.authorization_server": state.authorizationServer,
|
|
751
|
+
"opengeni.oauth.issuer": state.issuer,
|
|
752
|
+
"opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
|
|
753
|
+
error: sanitizedError(error.cause),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function sanitizedError(error: unknown): string {
|
|
758
|
+
if (error instanceof HTTPException) {
|
|
759
|
+
return `HTTPException ${error.status}: ${error.message}`;
|
|
760
|
+
}
|
|
761
|
+
if (error instanceof Error) {
|
|
762
|
+
return `${error.name}: ${error.message}`;
|
|
763
|
+
}
|
|
764
|
+
return String(error);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function errorMessage(error: unknown): string {
|
|
768
|
+
return error instanceof Error ? error.message : String(error);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function safeHost(rawUrl: string): string | undefined {
|
|
772
|
+
try {
|
|
773
|
+
return new URL(rawUrl).host;
|
|
774
|
+
} catch {
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function oauthErrorFromResponse(response: Response): Promise<string | null> {
|
|
780
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
781
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const payload = await response.clone().json().catch(() => null) as Record<string, unknown> | null;
|
|
785
|
+
const error = stringValue(payload?.error);
|
|
786
|
+
if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
return error;
|
|
790
|
+
}
|
|
791
|
+
|
|
668
792
|
async function verifyMcpToolsList(settings: Settings, resource: string, token: TokenResponse): Promise<Array<{ name: string; description?: string }>> {
|
|
669
793
|
await assertOAuthFetchAllowed(resource, settings);
|
|
670
794
|
const client = new Client({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
|
|
@@ -686,6 +810,42 @@ async function verifyMcpToolsList(settings: Settings, resource: string, token: T
|
|
|
686
810
|
}
|
|
687
811
|
}
|
|
688
812
|
|
|
813
|
+
async function verifyMcpToolsListNonFatal(
|
|
814
|
+
observability: Observability | undefined,
|
|
815
|
+
settings: Settings,
|
|
816
|
+
state: OAuthStatePayload,
|
|
817
|
+
token: TokenResponse,
|
|
818
|
+
): Promise<{
|
|
819
|
+
metadata:
|
|
820
|
+
| { status: "ok"; checkedAt: string; toolCount: number }
|
|
821
|
+
| { status: "failed"; checkedAt: string; reason: string };
|
|
822
|
+
tools?: Array<{ name: string; description?: string }>;
|
|
823
|
+
}> {
|
|
824
|
+
try {
|
|
825
|
+
const tools = await stage("tools_list", "tools_list_failed", () => verifyMcpToolsList(settings, state.mcpUrl, token));
|
|
826
|
+
return {
|
|
827
|
+
metadata: {
|
|
828
|
+
status: "ok",
|
|
829
|
+
checkedAt: new Date().toISOString(),
|
|
830
|
+
toolCount: tools.length,
|
|
831
|
+
},
|
|
832
|
+
tools,
|
|
833
|
+
};
|
|
834
|
+
} catch (error) {
|
|
835
|
+
const staged = error instanceof OAuthCallbackStageError
|
|
836
|
+
? error
|
|
837
|
+
: new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
|
|
838
|
+
logOAuthVerificationWarning(observability, staged, state);
|
|
839
|
+
return {
|
|
840
|
+
metadata: {
|
|
841
|
+
status: "failed",
|
|
842
|
+
checkedAt: new Date().toISOString(),
|
|
843
|
+
reason: staged.reason,
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
689
849
|
function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client: OAuthClientRegistration): Record<string, unknown> {
|
|
690
850
|
return {
|
|
691
851
|
access_token: token.accessToken,
|
|
@@ -693,6 +853,7 @@ function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client
|
|
|
693
853
|
token_type: token.tokenType,
|
|
694
854
|
...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
|
|
695
855
|
resource: state.resource,
|
|
856
|
+
mcp_url: state.mcpUrl,
|
|
696
857
|
...(token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {}),
|
|
697
858
|
token_endpoint: state.tokenEndpoint,
|
|
698
859
|
client_id: client.clientId,
|
|
@@ -723,6 +884,23 @@ function canonicalMcpResource(value: string | undefined): string {
|
|
|
723
884
|
return url.toString();
|
|
724
885
|
}
|
|
725
886
|
|
|
887
|
+
function canonicalOAuthResource(value: string): string {
|
|
888
|
+
const trimmed = value.trim();
|
|
889
|
+
if (!trimmed) {
|
|
890
|
+
throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource" });
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
const url = new URL(trimmed);
|
|
894
|
+
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
895
|
+
url.hash = "";
|
|
896
|
+
return url.toString();
|
|
897
|
+
}
|
|
898
|
+
return trimmed;
|
|
899
|
+
} catch {
|
|
900
|
+
throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource" });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
726
904
|
function safeReturnPath(value: string): string {
|
|
727
905
|
if (!value.startsWith("/") || value.startsWith("//")) {
|
|
728
906
|
throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { canonicalProviderDomain } from "../integrations/provider-domain";
|
|
28
28
|
|
|
29
29
|
export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
30
|
-
const { db, settings } = deps;
|
|
30
|
+
const { db, settings, observability } = deps;
|
|
31
31
|
|
|
32
32
|
function assertIntegrationsEnabled(): void {
|
|
33
33
|
if (!settings.integrationsEnabled) {
|
|
@@ -131,7 +131,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
131
131
|
throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
|
|
132
132
|
}
|
|
133
133
|
const payload = parsed.data;
|
|
134
|
-
const result = await startMcpOAuth({ db, settings }, {
|
|
134
|
+
const result = await startMcpOAuth({ db, settings, observability }, {
|
|
135
135
|
accountId: grant.accountId,
|
|
136
136
|
workspaceId,
|
|
137
137
|
subjectId: grant.subjectId,
|
|
@@ -143,7 +143,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
143
143
|
|
|
144
144
|
app.get("/v1/integrations/oauth/callback", async (c) => {
|
|
145
145
|
assertIntegrationsEnabled();
|
|
146
|
-
const result = await completeMcpOAuthCallback({ db, settings }, {
|
|
146
|
+
const result = await completeMcpOAuthCallback({ db, settings, observability }, {
|
|
147
147
|
code: c.req.query("code"),
|
|
148
148
|
state: c.req.query("state"),
|
|
149
149
|
requestUrl: c.req.url,
|