@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.
@@ -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
+ }