feat(mongodb): support OIDC browser authentication

This commit is contained in:
zipg authored and GitHub committed 2026-08-20 09:27:04 +08:00
1 parent f36d854e68
commit 2c75a6535e
22 files changed
+1109 -24

No files matched your search

@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { ACCESS_AGENT_MIN_CONNECT_TIMEOUT_SECS, AGENT_DRIVER_MIN_CONNECT_TIMEOUT_SECS, connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs, CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS } from "@/lib/connection/connectionAttemptTimeout";
import {
ACCESS_AGENT_MIN_CONNECT_TIMEOUT_SECS,
AGENT_DRIVER_MIN_CONNECT_TIMEOUT_SECS,
connectionAttemptOriginalErrorMessage,
connectionAttemptTimeoutMessage,
connectionAttemptTimeoutMs,
CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS,
MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS,
MONGO_OIDC_BROWSER_AUTH_TIMEOUT_MS,
} from "@/lib/connection/connectionAttemptTimeout";
describe("connectionAttemptTimeout", () => {
it("uses a 10s default for regular database connections", () => {
@@ -42,6 +51,21 @@ describe("connectionAttemptTimeout", () => {
expect(connectionAttemptTimeoutMs({ db_type: "access", connect_timeout_secs: 45, transport_layers: [] })).toBe(47_000);
});
it("keeps the legacy fallback buffer for regular MongoDB connections", () => {
expect(connectionAttemptTimeoutMs({ db_type: "mongodb", connect_timeout_secs: 5, transport_layers: [] })).toBe(5_000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS + MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS);
});
it("allows the MongoDB OIDC browser flow to use its full five-minute budget", () => {
expect(
connectionAttemptTimeoutMs({
db_type: "mongodb",
connect_timeout_secs: 5,
transport_layers: [],
connection_string: "mongodb+srv://cluster.example.com/app?authMechanism=MONGODB-OIDC&authSource=%24external",
}),
).toBe(5_000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS + MONGO_OIDC_BROWSER_AUTH_TIMEOUT_MS);
});
it("adds HTTP tunnel setup and tunneled endpoint probe budgets", () => {
expect(
connectionAttemptTimeoutMs({
@@ -1,7 +1,9 @@
import type { ConnectionConfig, DatabaseType, TransportLayerConfig, TunnelProfile } from "@/types/database";
import { mongoConnectionUsesOidc } from "@/lib/mongo/mongoConnectionOptions";
export const CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS = 2_000;
export const MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS = 30_000;
export const MONGO_OIDC_BROWSER_AUTH_TIMEOUT_MS = 5 * 60_000;
export const AGENT_DRIVER_MIN_CONNECT_TIMEOUT_SECS = 30;
export const ACCESS_AGENT_MIN_CONNECT_TIMEOUT_SECS = 30;
const DEFAULT_CONNECT_TIMEOUT_SECS = 10;
@@ -66,7 +68,7 @@ function resolvedTimeoutLayer(layer: TransportLayerConfig, resolveTunnelProfile?
return { ...profile, id: layer.id, enabled: layer.enabled, profile_id: layer.profile_id } as TransportLayerConfig;
}
export function connectionAttemptTimeoutMs(config: Pick<ConnectionConfig, "connect_timeout_secs" | "transport_layers"> & Partial<Pick<ConnectionConfig, "db_type">>, resolveTunnelProfile?: TunnelProfileResolver): number {
export function connectionAttemptTimeoutMs(config: Pick<ConnectionConfig, "connect_timeout_secs" | "transport_layers"> & Partial<Pick<ConnectionConfig, "db_type" | "url_params" | "connection_string">>, resolveTunnelProfile?: TunnelProfileResolver): number {
const baseTimeoutSecs = positiveSeconds(config.connect_timeout_secs, DEFAULT_CONNECT_TIMEOUT_SECS);
const agentMinTimeoutSecs = config.db_type === "access" ? ACCESS_AGENT_MIN_CONNECT_TIMEOUT_SECS : AGENT_DRIVER_MIN_CONNECT_TIMEOUT_SECS;
let timeoutSecs = DRIVER_STARTUP_FLOOR_TYPES.has(config.db_type as DatabaseType) ? Math.max(baseTimeoutSecs, agentMinTimeoutSecs) : baseTimeoutSecs;
@@ -83,8 +85,10 @@ export function connectionAttemptTimeoutMs(config: Pick<ConnectionConfig, "conne
// happen sequentially in the backend. Keep the UI guard outside their total
// budget so it cannot cancel a connection attempt that is still progressing.
if (hasEnabledTransportLayer) timeoutSecs += baseTimeoutSecs;
const fallbackBuffer = config.db_type === "mongodb" ? MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS : 0;
return Math.ceil(timeoutSecs * 1000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS + fallbackBuffer);
const usesMongoOidc = config.db_type === "mongodb" && mongoConnectionUsesOidc(config.url_params, config.connection_string);
const fallbackBuffer = config.db_type === "mongodb" && !usesMongoOidc ? MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS : 0;
const browserAuthBuffer = usesMongoOidc ? MONGO_OIDC_BROWSER_AUTH_TIMEOUT_MS : 0;
return Math.ceil(timeoutSecs * 1000 + CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS + fallbackBuffer + browserAuthBuffer);
}
export function connectionAttemptTimeoutMessage(timeoutMs: number): string {
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { mongoConnectionUsesOidc } from "@/lib/mongo/mongoConnectionOptions";
describe("mongoConnectionUsesOidc", () => {
it("detects OIDC in structured URL params without case sensitivity", () => {
expect(mongoConnectionUsesOidc("authSource=%24external&AUTHMECHANISM=mongodb-oidc")).toBe(true);
});
it("detects OIDC in mongodb and mongodb+srv connection strings", () => {
expect(mongoConnectionUsesOidc(undefined, "mongodb://localhost/app?authMechanism=MONGODB-OIDC")).toBe(true);
expect(mongoConnectionUsesOidc(undefined, "mongodb+srv://cluster.example.com/app?authMechanism=MONGODB%2DOIDC#fragment")).toBe(true);
});
it("does not classify non-OIDC or non-MongoDB URLs as OIDC", () => {
expect(mongoConnectionUsesOidc("authMechanism=SCRAM-SHA-256")).toBe(false);
expect(mongoConnectionUsesOidc(undefined, "postgres://localhost/app?authMechanism=MONGODB-OIDC")).toBe(false);
});
it("treats a connection string as authoritative over stale form params", () => {
expect(mongoConnectionUsesOidc("authMechanism=MONGODB-OIDC", "mongodb://localhost/app?authMechanism=SCRAM-SHA-256")).toBe(false);
});
});
@@ -3,6 +3,20 @@ export function mongoUrlParam(urlParams: string | undefined, key: string): strin
return params.get(key) || "";
}
export function mongoConnectionUsesOidc(urlParams: string | undefined, connectionString?: string): boolean {
const input = connectionString?.trim() || "";
if (input) {
if (!/^mongodb(?:\+srv)?:\/\//i.test(input)) return false;
const queryStart = input.indexOf("?");
if (queryStart < 0) return false;
const fragmentStart = input.indexOf("#", queryStart + 1);
const query = input.slice(queryStart + 1, fragmentStart < 0 ? undefined : fragmentStart);
return mongoUrlParamCaseInsensitive(query, "authMechanism").toUpperCase() === "MONGODB-OIDC";
}
return mongoUrlParamCaseInsensitive(urlParams, "authMechanism").toUpperCase() === "MONGODB-OIDC";
}
export function setMongoUrlParam(urlParams: string | undefined, key: string, value: string): string {
const params = parseMongoUrlParams(urlParams);
const normalized = value.trim();
@@ -84,3 +98,11 @@ export function mongodbAuthFailureHint(message: string): string {
function parseMongoUrlParams(urlParams: string | undefined): URLSearchParams {
return new URLSearchParams((urlParams || "").trim().replace(/^\?/, ""));
}
function mongoUrlParamCaseInsensitive(urlParams: string | undefined, key: string): string {
const expectedKey = key.toLowerCase();
for (const [paramKey, value] of parseMongoUrlParams(urlParams)) {
if (paramKey.toLowerCase() === expectedKey) return value;
}
return "";
}