@opengeni/db 0.4.0 → 0.6.0
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/LICENSE +190 -0
- package/dist/{chunk-T2U4H4Z2.js → chunk-ZIUCA2IO.js} +150 -5
- package/dist/chunk-ZIUCA2IO.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1161 -21
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.d.ts +286 -5
- package/dist/{schema-DuRsrmzD.d.ts → schema-Dsz6UHNv.d.ts} +2391 -854
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +11 -1
- package/drizzle/0038_enrollment_desktop_unavailable_reason.sql +18 -0
- package/drizzle/0038_observability_counts.sql +32 -0
- package/drizzle/0039_connections.sql +62 -0
- package/drizzle/0039_session_mcp_require_approval.sql +7 -0
- package/drizzle/0040_schema_agnostic_opengeni_app_grants.sql +36 -0
- package/drizzle/0041_knowledge_layer.sql +73 -0
- package/drizzle/0042_integration_oauth_state.sql +56 -0
- package/drizzle/0043_integrations_catalog_imports.sql +104 -0
- package/drizzle/0043_toolspace_call_budget.sql +14 -0
- package/package.json +5 -10
- package/src/connection-token-resolver.ts +481 -0
- package/src/event-payload-sanitizer.ts +23 -0
- package/src/index.ts +1086 -23
- package/src/schema.ts +150 -2
- package/dist/chunk-T2U4H4Z2.js.map +0 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { environmentsEncryptionKeyBytes, type McpServerConnectionRef, type Settings } from "@opengeni/config";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { lookup } from "node:dns/promises";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { encryptEnvironmentValue } from "./environment-crypto";
|
|
6
|
+
import {
|
|
7
|
+
loadConnectionCredentialForBroker,
|
|
8
|
+
recordConnectionTokenRefresh,
|
|
9
|
+
recordConnectionUsed,
|
|
10
|
+
setConnectionStatus,
|
|
11
|
+
type ConnectionCredentialForBroker,
|
|
12
|
+
type Database,
|
|
13
|
+
} from "./index";
|
|
14
|
+
|
|
15
|
+
export type ResolveConnectionCredentialResult =
|
|
16
|
+
| { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
|
|
17
|
+
| {
|
|
18
|
+
status: "auth_needed";
|
|
19
|
+
reason: "missing_connection" | "expired" | "insufficient_scope" | "refresh_failed";
|
|
20
|
+
providerDomain: string;
|
|
21
|
+
connectionId?: string;
|
|
22
|
+
scopes?: string[];
|
|
23
|
+
resource?: string;
|
|
24
|
+
authorizationUrl?: string;
|
|
25
|
+
};
|
|
26
|
+
type AuthNeededReason = Extract<ResolveConnectionCredentialResult, { status: "auth_needed" }>["reason"];
|
|
27
|
+
|
|
28
|
+
export type ResolveConnectionCredentialInput = {
|
|
29
|
+
workspaceId: string;
|
|
30
|
+
subjectId?: string;
|
|
31
|
+
serverId: string;
|
|
32
|
+
toolId?: string;
|
|
33
|
+
connectionRef: McpServerConnectionRef;
|
|
34
|
+
forceRefresh?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ConnectionBrokerDeps = {
|
|
38
|
+
loadCredential: typeof loadConnectionCredentialForBroker;
|
|
39
|
+
recordRefresh: typeof recordConnectionTokenRefresh;
|
|
40
|
+
setStatus: typeof setConnectionStatus;
|
|
41
|
+
recordUsed: typeof recordConnectionUsed;
|
|
42
|
+
refresh: typeof refreshOAuthConnectionCredential;
|
|
43
|
+
encrypt: typeof encryptEnvironmentValue;
|
|
44
|
+
keyBytes: typeof environmentsEncryptionKeyBytes;
|
|
45
|
+
now: () => Date;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const defaultDeps: ConnectionBrokerDeps = {
|
|
49
|
+
loadCredential: loadConnectionCredentialForBroker,
|
|
50
|
+
recordRefresh: recordConnectionTokenRefresh,
|
|
51
|
+
setStatus: setConnectionStatus,
|
|
52
|
+
recordUsed: recordConnectionUsed,
|
|
53
|
+
refresh: refreshOAuthConnectionCredential,
|
|
54
|
+
encrypt: encryptEnvironmentValue,
|
|
55
|
+
keyBytes: environmentsEncryptionKeyBytes,
|
|
56
|
+
now: () => new Date(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const inflight = new Map<string, Promise<ConnectionCredentialForBroker>>();
|
|
60
|
+
const REFRESH_WINDOW_MS = 60_000;
|
|
61
|
+
|
|
62
|
+
export function buildConnectionTokenResolver(
|
|
63
|
+
db: Database,
|
|
64
|
+
settings: Settings,
|
|
65
|
+
deps: ConnectionBrokerDeps = defaultDeps,
|
|
66
|
+
): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
|
|
67
|
+
const load = async (input: ResolveConnectionCredentialInput): Promise<ConnectionCredentialForBroker | null> => {
|
|
68
|
+
const request: Parameters<typeof loadConnectionCredentialForBroker>[2] = {
|
|
69
|
+
workspaceId: input.workspaceId,
|
|
70
|
+
providerDomain: input.connectionRef.providerDomain,
|
|
71
|
+
// I1 deliberately accepts workspace-shared connections only at runtime.
|
|
72
|
+
allowSubjectOwned: false,
|
|
73
|
+
};
|
|
74
|
+
if (input.connectionRef.connectionId !== undefined) {
|
|
75
|
+
request.connectionId = input.connectionRef.connectionId;
|
|
76
|
+
}
|
|
77
|
+
if (input.connectionRef.kind !== undefined) {
|
|
78
|
+
request.kind = input.connectionRef.kind;
|
|
79
|
+
}
|
|
80
|
+
if (input.subjectId !== undefined) {
|
|
81
|
+
request.subjectId = input.subjectId;
|
|
82
|
+
}
|
|
83
|
+
return deps.loadCredential(db, settings, request);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const snapshot = async (cred: ConnectionCredentialForBroker, ref: McpServerConnectionRef): Promise<ResolveConnectionCredentialResult> => {
|
|
87
|
+
if (cred.status !== "active") {
|
|
88
|
+
return authNeededForStatus(cred, ref);
|
|
89
|
+
}
|
|
90
|
+
const missingScopes = missingRequestedScopes(ref.scopes, cred.grantedScopes);
|
|
91
|
+
if (missingScopes.length > 0) {
|
|
92
|
+
return {
|
|
93
|
+
status: "auth_needed",
|
|
94
|
+
reason: "insufficient_scope",
|
|
95
|
+
providerDomain: ref.providerDomain,
|
|
96
|
+
connectionId: cred.id,
|
|
97
|
+
scopes: missingScopes,
|
|
98
|
+
...(ref.resource ? { resource: ref.resource } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const headers = headersForCredential(cred);
|
|
102
|
+
if (!headers) {
|
|
103
|
+
return {
|
|
104
|
+
status: "auth_needed",
|
|
105
|
+
reason: "refresh_failed",
|
|
106
|
+
providerDomain: ref.providerDomain,
|
|
107
|
+
connectionId: cred.id,
|
|
108
|
+
...(ref.scopes ? { scopes: ref.scopes } : {}),
|
|
109
|
+
...(ref.resource ? { resource: ref.resource } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
await deps.recordUsed(db, cred.workspaceId, cred.id);
|
|
113
|
+
return {
|
|
114
|
+
status: "ok",
|
|
115
|
+
headers,
|
|
116
|
+
connectionId: cred.id,
|
|
117
|
+
expiresAt: cred.expiresAt,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const performRefresh = async (cred: ConnectionCredentialForBroker, ref: McpServerConnectionRef): Promise<ConnectionCredentialForBroker> => {
|
|
122
|
+
const key = deps.keyBytes(settings);
|
|
123
|
+
if (!key) {
|
|
124
|
+
throw new Error("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured");
|
|
125
|
+
}
|
|
126
|
+
const refreshed = await deps.refresh(cred, ref, settings);
|
|
127
|
+
const refreshRecord: Parameters<typeof recordConnectionTokenRefresh>[1] = {
|
|
128
|
+
id: cred.id,
|
|
129
|
+
version: cred.version,
|
|
130
|
+
workspaceId: cred.workspaceId,
|
|
131
|
+
credentialEncrypted: deps.encrypt(key, JSON.stringify(refreshed.credential)),
|
|
132
|
+
expiresAt: refreshed.expiresAt,
|
|
133
|
+
lastRefreshAt: deps.now(),
|
|
134
|
+
};
|
|
135
|
+
if (refreshed.grantedScopes !== undefined) {
|
|
136
|
+
refreshRecord.grantedScopes = refreshed.grantedScopes;
|
|
137
|
+
}
|
|
138
|
+
const persisted = await deps.recordRefresh(db, refreshRecord);
|
|
139
|
+
if (persisted) {
|
|
140
|
+
const current = await load({
|
|
141
|
+
workspaceId: cred.workspaceId,
|
|
142
|
+
serverId: "",
|
|
143
|
+
connectionRef: { ...ref, connectionId: cred.id },
|
|
144
|
+
});
|
|
145
|
+
if (current) {
|
|
146
|
+
return current;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const winner = await load({
|
|
150
|
+
workspaceId: cred.workspaceId,
|
|
151
|
+
serverId: "",
|
|
152
|
+
connectionRef: { ...ref, connectionId: cred.id },
|
|
153
|
+
});
|
|
154
|
+
if (winner?.status === "active") {
|
|
155
|
+
return winner;
|
|
156
|
+
}
|
|
157
|
+
throw new Error("connection credential changed during token refresh");
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const refreshSingleFlight = (cred: ConnectionCredentialForBroker, ref: McpServerConnectionRef): Promise<ConnectionCredentialForBroker> => {
|
|
161
|
+
const key = `${cred.id}:${cred.version}`;
|
|
162
|
+
const existing = inflight.get(key);
|
|
163
|
+
if (existing) {
|
|
164
|
+
return existing;
|
|
165
|
+
}
|
|
166
|
+
const promise = performRefresh(cred, ref).finally(() => {
|
|
167
|
+
if (inflight.get(key) === promise) {
|
|
168
|
+
inflight.delete(key);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
inflight.set(key, promise);
|
|
172
|
+
return promise;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return async (input) => {
|
|
176
|
+
const ref = input.connectionRef;
|
|
177
|
+
let cred: ConnectionCredentialForBroker | null;
|
|
178
|
+
try {
|
|
179
|
+
cred = await load(input);
|
|
180
|
+
} catch {
|
|
181
|
+
return authNeeded(ref, "refresh_failed");
|
|
182
|
+
}
|
|
183
|
+
if (!cred) {
|
|
184
|
+
return authNeeded(ref, "missing_connection");
|
|
185
|
+
}
|
|
186
|
+
if (cred.status !== "active") {
|
|
187
|
+
return authNeededForStatus(cred, ref);
|
|
188
|
+
}
|
|
189
|
+
if (shouldRefresh(cred, input.forceRefresh === true, deps.now())) {
|
|
190
|
+
try {
|
|
191
|
+
cred = await refreshSingleFlight(cred, ref);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Only a rejected grant may poison the connection; transient failures
|
|
194
|
+
// (network errors, AS 5xx) leave it active so the next resolve retries.
|
|
195
|
+
if (isPermanentRefreshError(error)) {
|
|
196
|
+
await deps.setStatus(db, input.workspaceId, "needs_reauth", error instanceof Error ? error.message : String(error), {
|
|
197
|
+
id: cred.id,
|
|
198
|
+
version: cred.version,
|
|
199
|
+
}).catch(() => undefined);
|
|
200
|
+
}
|
|
201
|
+
return authNeeded(ref, "refresh_failed", cred.id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return await snapshot(cred, ref);
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export class ConnectionRefreshHttpError extends Error {
|
|
209
|
+
readonly httpStatus: number;
|
|
210
|
+
|
|
211
|
+
constructor(httpStatus: number) {
|
|
212
|
+
super(`connection refresh failed with HTTP ${httpStatus}`);
|
|
213
|
+
this.name = "ConnectionRefreshHttpError";
|
|
214
|
+
this.httpStatus = httpStatus;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The token endpoint rejecting the grant itself means re-auth is the only way
|
|
219
|
+
// forward. 429 (throttling) and 408 are transient despite being 4xx; network
|
|
220
|
+
// failures and AS 5xx are likewise retryable.
|
|
221
|
+
function isPermanentRefreshError(error: unknown): boolean {
|
|
222
|
+
return error instanceof ConnectionRefreshHttpError
|
|
223
|
+
&& error.httpStatus >= 400 && error.httpStatus < 500
|
|
224
|
+
&& error.httpStatus !== 408 && error.httpStatus !== 429;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function shouldRefresh(cred: ConnectionCredentialForBroker, force: boolean, now: Date): boolean {
|
|
228
|
+
if (cred.kind !== "oauth2") {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
if (force) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
if (!cred.expiresAt) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
return cred.expiresAt.getTime() <= now.getTime() + REFRESH_WINDOW_MS;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function authNeeded(ref: McpServerConnectionRef, reason: AuthNeededReason, connectionId?: string): ResolveConnectionCredentialResult {
|
|
241
|
+
return {
|
|
242
|
+
status: "auth_needed",
|
|
243
|
+
reason,
|
|
244
|
+
providerDomain: ref.providerDomain,
|
|
245
|
+
...(connectionId ? { connectionId } : {}),
|
|
246
|
+
...(ref.scopes ? { scopes: ref.scopes } : {}),
|
|
247
|
+
...(ref.resource ? { resource: ref.resource } : {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function authNeededForStatus(cred: ConnectionCredentialForBroker, ref: McpServerConnectionRef): ResolveConnectionCredentialResult {
|
|
252
|
+
if (cred.status === "revoked") {
|
|
253
|
+
return authNeeded(ref, "missing_connection", cred.id);
|
|
254
|
+
}
|
|
255
|
+
return authNeeded(ref, cred.expiresAt && cred.expiresAt.getTime() <= Date.now() ? "expired" : "refresh_failed", cred.id);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function missingRequestedScopes(requested: string[] | undefined, granted: string[]): string[] {
|
|
259
|
+
if (!requested?.length) {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
const grantedSet = new Set(granted);
|
|
263
|
+
return requested.filter((scope) => !grantedSet.has(scope));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function headersForCredential(cred: ConnectionCredentialForBroker): Record<string, string> | null {
|
|
267
|
+
if (cred.kind === "api_key") {
|
|
268
|
+
return stringRecord((cred.credential as { headers?: unknown }).headers);
|
|
269
|
+
}
|
|
270
|
+
if (cred.kind === "oauth2") {
|
|
271
|
+
const accessToken = stringValue((cred.credential as { access_token?: unknown }).access_token);
|
|
272
|
+
if (!accessToken) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const tokenType = stringValue((cred.credential as { token_type?: unknown }).token_type) || "Bearer";
|
|
276
|
+
return { authorization: `${tokenType} ${accessToken}` };
|
|
277
|
+
}
|
|
278
|
+
return stringRecord((cred.credential as { headers?: unknown }).headers);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function refreshOAuthConnectionCredential(
|
|
282
|
+
cred: ConnectionCredentialForBroker,
|
|
283
|
+
ref: McpServerConnectionRef,
|
|
284
|
+
settings?: Settings,
|
|
285
|
+
): Promise<{ credential: Record<string, unknown>; expiresAt: Date | null; grantedScopes?: string[] }> {
|
|
286
|
+
if (cred.kind !== "oauth2") {
|
|
287
|
+
return { credential: cred.credential, expiresAt: cred.expiresAt, grantedScopes: cred.grantedScopes };
|
|
288
|
+
}
|
|
289
|
+
const refreshToken = stringValue((cred.credential as { refresh_token?: unknown }).refresh_token);
|
|
290
|
+
const tokenEndpoint =
|
|
291
|
+
stringValue((cred.credential as { token_endpoint?: unknown }).token_endpoint)
|
|
292
|
+
?? stringValue((cred.metadata as { tokenEndpoint?: unknown }).tokenEndpoint)
|
|
293
|
+
?? stringValue((cred.metadata as { token_endpoint?: unknown }).token_endpoint);
|
|
294
|
+
if (!refreshToken || !tokenEndpoint) {
|
|
295
|
+
throw new Error("connection has no refresh token endpoint");
|
|
296
|
+
}
|
|
297
|
+
if (settings) {
|
|
298
|
+
await assertOAuthEndpointAllowed(tokenEndpoint, settings);
|
|
299
|
+
}
|
|
300
|
+
const body = new URLSearchParams();
|
|
301
|
+
body.set("grant_type", "refresh_token");
|
|
302
|
+
body.set("refresh_token", refreshToken);
|
|
303
|
+
// Public clients (token_endpoint_auth_method "none") must identify themselves
|
|
304
|
+
// in the token request body (RFC 6749 §3.2.1); grant flows persist the
|
|
305
|
+
// client_id they authorized with into the bundle/metadata.
|
|
306
|
+
const clientId =
|
|
307
|
+
stringValue((cred.credential as { client_id?: unknown }).client_id)
|
|
308
|
+
?? stringValue((cred.metadata as { clientId?: unknown }).clientId)
|
|
309
|
+
?? stringValue((cred.metadata as { client_id?: unknown }).client_id);
|
|
310
|
+
const clientSecret = stringValue((cred.credential as { client_secret?: unknown }).client_secret);
|
|
311
|
+
const authMethod = stringValue((cred.credential as { token_endpoint_auth_method?: unknown }).token_endpoint_auth_method) ?? "none";
|
|
312
|
+
if (clientId) {
|
|
313
|
+
body.set("client_id", clientId);
|
|
314
|
+
}
|
|
315
|
+
const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded" };
|
|
316
|
+
if (clientSecret && authMethod === "client_secret_post") {
|
|
317
|
+
body.set("client_secret", clientSecret);
|
|
318
|
+
} else if (clientId && clientSecret && authMethod === "client_secret_basic") {
|
|
319
|
+
headers.authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
|
|
320
|
+
}
|
|
321
|
+
const resource = ref.resource ?? stringValue((cred.credential as { resource?: unknown }).resource);
|
|
322
|
+
if (resource) {
|
|
323
|
+
body.set("resource", resource);
|
|
324
|
+
}
|
|
325
|
+
if (ref.scopes?.length) {
|
|
326
|
+
body.set("scope", ref.scopes.join(" "));
|
|
327
|
+
}
|
|
328
|
+
const response = await fetch(tokenEndpoint, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers,
|
|
331
|
+
body,
|
|
332
|
+
redirect: "manual",
|
|
333
|
+
});
|
|
334
|
+
if (response.status >= 300 && response.status < 400) {
|
|
335
|
+
throw new ConnectionRefreshHttpError(response.status);
|
|
336
|
+
}
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
throw new ConnectionRefreshHttpError(response.status);
|
|
339
|
+
}
|
|
340
|
+
const payload = await response.json() as Record<string, unknown>;
|
|
341
|
+
const accessToken = stringValue(payload.access_token);
|
|
342
|
+
if (!accessToken) {
|
|
343
|
+
throw new Error("connection refresh response did not include access_token");
|
|
344
|
+
}
|
|
345
|
+
const expiresAt = expiresAtFromTokenResponse(payload, cred.expiresAt);
|
|
346
|
+
const scopeText = stringValue(payload.scope);
|
|
347
|
+
const nextCredential = {
|
|
348
|
+
...cred.credential,
|
|
349
|
+
access_token: accessToken,
|
|
350
|
+
refresh_token: stringValue(payload.refresh_token) ?? refreshToken,
|
|
351
|
+
token_type: stringValue(payload.token_type) ?? stringValue((cred.credential as { token_type?: unknown }).token_type) ?? "Bearer",
|
|
352
|
+
...(expiresAt ? { expires_at: expiresAt.toISOString() } : {}),
|
|
353
|
+
...(resource ? { resource } : {}),
|
|
354
|
+
...(scopeText ? { scope: scopeText } : {}),
|
|
355
|
+
...(clientSecret ? { client_secret: clientSecret, token_endpoint_auth_method: authMethod } : {}),
|
|
356
|
+
};
|
|
357
|
+
return {
|
|
358
|
+
credential: nextCredential,
|
|
359
|
+
expiresAt,
|
|
360
|
+
...(scopeText ? { grantedScopes: scopeText.split(/\s+/).filter(Boolean) } : {}),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function expiresAtFromTokenResponse(payload: Record<string, unknown>, fallback: Date | null): Date | null {
|
|
365
|
+
const expiresAt = stringValue(payload.expires_at);
|
|
366
|
+
if (expiresAt) {
|
|
367
|
+
const parsed = new Date(expiresAt);
|
|
368
|
+
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
|
369
|
+
}
|
|
370
|
+
const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : undefined;
|
|
371
|
+
if (expiresIn && Number.isFinite(expiresIn) && expiresIn > 0) {
|
|
372
|
+
return new Date(Date.now() + expiresIn * 1000);
|
|
373
|
+
}
|
|
374
|
+
return fallback;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function stringRecord(value: unknown): Record<string, string> | null {
|
|
378
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
const out: Record<string, string> = {};
|
|
382
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
383
|
+
if (typeof raw !== "string") {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
out[key] = raw;
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function stringValue(value: unknown): string | undefined {
|
|
392
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function assertOAuthEndpointAllowed(rawUrl: string, settings: Settings): Promise<void> {
|
|
396
|
+
if (settings.integrationsAllowPrivateNetworkTargets || ["local", "test"].includes(settings.environment)) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const url = new URL(rawUrl);
|
|
400
|
+
if (url.protocol !== "https:") {
|
|
401
|
+
throw new Error("OAuth token endpoint must use https outside local/test");
|
|
402
|
+
}
|
|
403
|
+
const hostname = url.hostname.toLowerCase();
|
|
404
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
405
|
+
throw new Error("OAuth token endpoint may not target localhost");
|
|
406
|
+
}
|
|
407
|
+
const literal = isIP(hostname);
|
|
408
|
+
const addresses = literal
|
|
409
|
+
? [hostname]
|
|
410
|
+
: (await lookup(hostname, { all: true })).map((entry) => entry.address);
|
|
411
|
+
if (addresses.some(isPrivateAddress)) {
|
|
412
|
+
throw new Error("OAuth token endpoint may not target a private network address");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function isPrivateAddress(address: string): boolean {
|
|
417
|
+
const normalized = normalizeAddress(address);
|
|
418
|
+
const mapped = ipv4FromMappedIpv6(normalized);
|
|
419
|
+
if (mapped) {
|
|
420
|
+
return isPrivateIpv4Address(mapped);
|
|
421
|
+
}
|
|
422
|
+
if (normalized.includes(":")) {
|
|
423
|
+
if (isIP(normalized) !== 6) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
return normalized === "::1"
|
|
427
|
+
|| normalized === "::"
|
|
428
|
+
|| normalized.startsWith("fc")
|
|
429
|
+
|| normalized.startsWith("fd")
|
|
430
|
+
|| normalized.startsWith("fe8")
|
|
431
|
+
|| normalized.startsWith("fe9")
|
|
432
|
+
|| normalized.startsWith("fea")
|
|
433
|
+
|| normalized.startsWith("feb");
|
|
434
|
+
}
|
|
435
|
+
return isPrivateIpv4Address(normalized);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function normalizeAddress(address: string): string {
|
|
439
|
+
const trimmed = address.trim().toLowerCase();
|
|
440
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
441
|
+
return trimmed.slice(1, -1);
|
|
442
|
+
}
|
|
443
|
+
return trimmed;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function ipv4FromMappedIpv6(address: string): string | null {
|
|
447
|
+
if (!address.startsWith("::ffff:")) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
const embedded = address.slice("::ffff:".length);
|
|
451
|
+
if (embedded.includes(".")) {
|
|
452
|
+
return embedded;
|
|
453
|
+
}
|
|
454
|
+
const parts = embedded.split(":");
|
|
455
|
+
if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
const high = Number.parseInt(parts[0]!, 16);
|
|
459
|
+
const low = Number.parseInt(parts[1]!, 16);
|
|
460
|
+
if (!Number.isInteger(high) || !Number.isInteger(low) || high < 0 || high > 0xffff || low < 0 || low > 0xffff) {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isPrivateIpv4Address(address: string): boolean {
|
|
467
|
+
if (isIP(address) !== 4) {
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
const parts = address.split(".").map((part) => Number(part));
|
|
471
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
const [a, b] = parts as [number, number, number, number];
|
|
475
|
+
return a === 0
|
|
476
|
+
|| a === 10
|
|
477
|
+
|| a === 127
|
|
478
|
+
|| (a === 169 && b === 254)
|
|
479
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
480
|
+
|| (a === 192 && b === 168);
|
|
481
|
+
}
|
|
@@ -17,6 +17,22 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const REPLACEMENT = "�";
|
|
20
|
+
const REDACTED = "[redacted]";
|
|
21
|
+
const SENSITIVE_FIELD_NAMES = new Set([
|
|
22
|
+
"authorization",
|
|
23
|
+
"headers",
|
|
24
|
+
"accesstoken",
|
|
25
|
+
"refreshtoken",
|
|
26
|
+
"idtoken",
|
|
27
|
+
"token",
|
|
28
|
+
"apikey",
|
|
29
|
+
"secret",
|
|
30
|
+
"clientsecret",
|
|
31
|
+
"credential",
|
|
32
|
+
"credentialencrypted",
|
|
33
|
+
"encryptedpkceverifier",
|
|
34
|
+
"codeverifier",
|
|
35
|
+
]);
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
38
|
* Strip NUL and repair invalid/lone UTF-16 surrogates in a single string.
|
|
@@ -95,6 +111,9 @@ function sanitizeSensitiveEventField(key: string, value: unknown): unknown {
|
|
|
95
111
|
if (key === "mcpCredentialUpdates") {
|
|
96
112
|
return sanitizeMcpCredentialUpdateList(value);
|
|
97
113
|
}
|
|
114
|
+
if (SENSITIVE_FIELD_NAMES.has(normalizeFieldName(key))) {
|
|
115
|
+
return REDACTED;
|
|
116
|
+
}
|
|
98
117
|
return sanitizeEventPayload(value);
|
|
99
118
|
}
|
|
100
119
|
|
|
@@ -144,3 +163,7 @@ function safeHeaderNames(value: unknown): string[] | null {
|
|
|
144
163
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
145
164
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
146
165
|
}
|
|
166
|
+
|
|
167
|
+
function normalizeFieldName(key: string): string {
|
|
168
|
+
return key.toLowerCase().replace(/[-_]/g, "");
|
|
169
|
+
}
|