diff --git a/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx b/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx index 77516533..ffbd6372 100644 --- a/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx +++ b/apps/frontend/src/components/cluster-topology/cluster-node-row.tsx @@ -90,6 +90,7 @@ export function ClusterNodeRow({ port: port.toString(), tls: primaryConfig.tls, verifyTlsCertificate: primaryConfig.verifyTlsCertificate, + caCertPath: primaryConfig.caCertPath, endpointType: "node", db: clusterDb, } @@ -109,6 +110,16 @@ export function ClusterNodeRow({ awsReplicationGroupId: primaryConfig.awsReplicationGroupId, }, })) + } else if (primaryConfig.authType === "gcp-iam") { + // GCP IAM: tokens are minted from ambient credentials, no password needed + dispatch(connectPending({ + connectionId, + connectionDetails: { + ...baseDetails, + username: primaryConfig.username ?? "", + authType: "gcp-iam", + }, + })) } else if (R.isNotNil(encryptedPassword)) { // Password already encrypted from existing cluster connection — do NOT re-encrypt dispatch(connectPending({ diff --git a/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx b/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx index 5c0cbf30..8cdf68d5 100644 --- a/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx +++ b/apps/frontend/src/components/connection/ClusterConnectionGroup.tsx @@ -101,7 +101,7 @@ export const ClusterConnectionGroup = ({ clusterId, connections, highlight = "", const handleConnectLatest = () => { if (!lastOpenedNode) return const { password, authType } = lastOpenedNode.connection.connectionDetails - if (authType !== "iam" && R.isNil(password) && onPasswordRequired) { + if (authType !== "iam" && authType !== "gcp-iam" && R.isNil(password) && onPasswordRequired) { onPasswordRequired(lastOpenedNode.connectionId) return } diff --git a/apps/frontend/src/components/connection/ConnectionEntry.tsx b/apps/frontend/src/components/connection/ConnectionEntry.tsx index d1737fd8..cf38d8c8 100644 --- a/apps/frontend/src/components/connection/ConnectionEntry.tsx +++ b/apps/frontend/src/components/connection/ConnectionEntry.tsx @@ -43,7 +43,7 @@ export const ConnectionEntry = ({ const handleDisconnect = () => dispatch(closeConnection({ connectionId })) const handleConnect = () => { const { password, authType } = connection.connectionDetails - if (authType !== "iam" && R.isNil(password) && onPasswordRequired) { + if (authType !== "iam" && authType !== "gcp-iam" && R.isNil(password) && onPasswordRequired) { onPasswordRequired(connectionId) return } diff --git a/apps/frontend/src/components/ui/connection-modal.tsx b/apps/frontend/src/components/ui/connection-modal.tsx index 574c7904..1046a7e8 100644 --- a/apps/frontend/src/components/ui/connection-modal.tsx +++ b/apps/frontend/src/components/ui/connection-modal.tsx @@ -168,7 +168,7 @@ export function ConnectionModal({ - onConnectionDetailsChange({ ...connectionDetails, authType: value as "password" | "iam" }) + onConnectionDetailsChange({ ...connectionDetails, authType: value as "password" | "iam" | "gcp-iam" }) } value={connectionDetails.authType ?? "password"} > @@ -180,6 +180,10 @@ export function ConnectionModal({ +
+ + +
@@ -222,6 +226,12 @@ export function ConnectionModal({ /> + ) : connectionDetails.authType === "gcp-iam" ? ( +

+ Uses Application Default Credentials to mint and rotate a short-lived access + token. Authenticates as the default user — no username or + password required. +

) : (
diff --git a/apps/frontend/src/state/epics/valkeyEpics.ts b/apps/frontend/src/state/epics/valkeyEpics.ts index 427f111e..9257978c 100644 --- a/apps/frontend/src/state/epics/valkeyEpics.ts +++ b/apps/frontend/src/state/epics/valkeyEpics.ts @@ -57,8 +57,8 @@ export const connectionEpic = (store: Store) => select(connectPending), filter(() => !selectIsAtConnectionLimit(store.getState())), mergeMap(async (action) => { - const { password } = action.payload.connectionDetails - if (R.isNil(password) || action.payload.connectionDetails.authType === "iam") return action + const { password, authType } = action.payload.connectionDetails + if (R.isNil(password) || authType === "iam" || authType === "gcp-iam") return action // Password is dispatched as plaintext if secureStorage is unavailable const decryptedPassword = password.length > 0 && secureStorage.isAvailable() ? await secureStorage.decrypt(password) : password @@ -283,7 +283,8 @@ export const autoReconnectEpic = (store: Store) => .filter(([, connection]) => connection.status === DISCONNECTED) .filter(([, connection]) => R.isNotNil(connection.connectionDetails.password) || - connection.connectionDetails.authType === "iam") + connection.connectionDetails.authType === "iam" || + connection.connectionDetails.authType === "gcp-iam") if (disconnectedConnections.length > 0) { console.log(`Auto-reconnecting ${disconnectedConnections.length} connection(s)`) @@ -316,7 +317,7 @@ export const autoResumeEpic = (store: Store) => .filter(([, connection]) => isAutoResumeEligible(connection)) .forEach(([connectionId, connection]) => { const { password, authType } = connection.connectionDetails - if (authType === "iam" || (R.isNotNil(password) && R.isEmpty(password))) { + if (authType === "iam" || authType === "gcp-iam" || (R.isNotNil(password) && R.isEmpty(password))) { if (connection.status !== DISCONNECTED) { store.dispatch(connectPending({ connectionId, diff --git a/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts b/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts index 15ca494b..2e042818 100644 --- a/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts +++ b/apps/frontend/src/state/valkey-features/cluster/clusterSlice.ts @@ -16,7 +16,7 @@ export interface PrimaryNode { //TODO: Add handling and UI for uploading cert caCertPath?: string replicas: ReplicaNode[]; - authType?: "password" | "iam"; + authType?: "password" | "iam" | "gcp-iam"; awsRegion?: string; awsReplicationGroupId?: string; } diff --git a/apps/frontend/src/state/valkey-features/connection/connectionSlice.ts b/apps/frontend/src/state/valkey-features/connection/connectionSlice.ts index 9db74e15..8e23d7bd 100644 --- a/apps/frontend/src/state/valkey-features/connection/connectionSlice.ts +++ b/apps/frontend/src/state/valkey-features/connection/connectionSlice.ts @@ -38,7 +38,7 @@ export interface ConnectionDetails { // JSON module availability check jsonModuleAvailable?: boolean; endpointType: EndpointType - authType?: "password" | "iam" + authType?: "password" | "iam" | "gcp-iam" awsRegion?: string awsReplicationGroupId?: string /** @@ -88,7 +88,7 @@ export const isAutoResumeEligible = (connection: ConnectionState): boolean => { if (userDisconnected) return false const { password, authType } = connectionDetails - if (authType === "iam" || (R.isNotNil(password) && R.isEmpty(password))) + if (authType === "iam" || authType === "gcp-iam" || (R.isNotNil(password) && R.isEmpty(password))) return status !== DISCONNECTED return R.isNil(password) } diff --git a/apps/metrics/src/effects/monitor-stream.js b/apps/metrics/src/effects/monitor-stream.js index 73f366f0..8cc1f931 100644 --- a/apps/metrics/src/effects/monitor-stream.js +++ b/apps/metrics/src/effects/monitor-stream.js @@ -1,25 +1,43 @@ import { Subject, timer, race, firstValueFrom, defer, of } from "rxjs" import { exhaustMap, catchError, map } from "rxjs" import Valkey from "iovalkey" +import { readFileSync } from "node:fs" +import { mintGcpAccessToken } from "valkey-common" import { ElastiCacheIAMProvider } from "../utils/elasticache-iam-provider.js" function getConnectionOptions() { const host = process.env.VALKEY_HOST const port = Number(process.env.VALKEY_PORT) - const username = process.env.VALKEY_USERNAME + // GCP IAM authenticates as the fixed "default" user; any other username is rejected. + const username = process.env.VALKEY_AUTH_TYPE === "gcp-iam" + ? "default" + : process.env.VALKEY_USERNAME const verifyTlsCertificate = process.env.VALKEY_VERIFY_CERT let tls = undefined if (process.env.VALKEY_TLS === "true") { - tls = verifyTlsCertificate === "false" ? { rejectUnauthorized: false } : {} + if (verifyTlsCertificate === "false") { + tls = { rejectUnauthorized: false } + } else if (process.env.VALKEY_CA_CERT_PATH) { + tls = { ca: readFileSync(process.env.VALKEY_CA_CERT_PATH) } + } else { + tls = {} + } } return { host, port, username, tls } } async function getPassword() { const username = process.env.VALKEY_USERNAME - return process.env.VALKEY_AUTH_TYPE === "iam" - ? await new ElastiCacheIAMProvider(username, process.env.VALKEY_REPLICATION_GROUP_ID, process.env.VALKEY_AWS_REGION).getCredentials() - : process.env.VALKEY_PASSWORD + if (process.env.VALKEY_AUTH_TYPE === "iam") { + return await new ElastiCacheIAMProvider(username, process.env.VALKEY_REPLICATION_GROUP_ID, process.env.VALKEY_AWS_REGION).getCredentials() + } + if (process.env.VALKEY_AUTH_TYPE === "gcp-iam") { + return await mintGcpAccessToken( + process.env.VALKEY_TLS === "true", + process.env.VALKEY_VERIFY_CERT !== "false", + ) + } + return process.env.VALKEY_PASSWORD } /** diff --git a/apps/metrics/src/index.js b/apps/metrics/src/index.js index b7182339..2ba02aac 100644 --- a/apps/metrics/src/index.js +++ b/apps/metrics/src/index.js @@ -1,6 +1,7 @@ import fs from "node:fs" import express from "express" import { ORCHESTRATOR_AUTH_KEY_ENV, buildUrl } from "valkey-common" +import { registerGcpTokenRefresh, unregisterGcpTokenRefresh } from "valkey-common" import { getConfig } from "./config.js" import * as Streamer from "./effects/ndjson-streamer.js" import { setupCollectors, stopCollectors } from "./init-collectors.js" @@ -33,6 +34,17 @@ async function main() { const client = await createValkeyClient(cfg) const ownNodeId = sanitizeUrl(`${process.env.VALKEY_HOST}-${process.env.VALKEY_PORT}`) + // GCP OAuth2 tokens expire ~1h; rotate the connection password before then so + // reconnects keep authenticating. AWS IAM refreshes natively inside Glide. + if (process.env.VALKEY_AUTH_TYPE === "gcp-iam") { + registerGcpTokenRefresh( + client, + "metrics", + process.env.VALKEY_TLS === "true", + process.env.VALKEY_VERIFY_CERT !== "false", + ) + } + await setupNdjsonCleaner(cfg) await setupCollectors(client, cfg) @@ -247,6 +259,7 @@ async function main() { try { await stopNdjsonCleaner() await stopCollectors() + unregisterGcpTokenRefresh(client) if (client) { client.close() } diff --git a/apps/metrics/src/valkey-client.js b/apps/metrics/src/valkey-client.js index 5c76dff0..2a7a8697 100644 --- a/apps/metrics/src/valkey-client.js +++ b/apps/metrics/src/valkey-client.js @@ -1,5 +1,6 @@ import { GlideClient, GlideClusterClient, ServiceType, NodeDiscoveryMode } from "@valkey/valkey-glide" -import { APP_VERSION ,deploymentSuffix } from "valkey-common" +import { readFileSync } from "node:fs" +import { APP_VERSION ,deploymentSuffix, mintGcpAccessToken } from "valkey-common" const clientInfoTag = `valkey-admin-metrics-${deploymentSuffix()}:${APP_VERSION}` @@ -23,6 +24,8 @@ export const createValkeyClient = async (cfg = {}) => { port: Number(process.env.VALKEY_PORT), }, ] + const useTLS = process.env.VALKEY_TLS === "true" + const verifyTlsCertificate = process.env.VALKEY_VERIFY_CERT !== "false" const credentials = process.env.VALKEY_AUTH_TYPE === "iam" ? { @@ -33,23 +36,36 @@ export const createValkeyClient = async (cfg = {}) => { region: process.env.VALKEY_AWS_REGION, }, } - : process.env.VALKEY_PASSWORD ? { - username: process.env.VALKEY_USERNAME, - password: process.env.VALKEY_PASSWORD, - } : undefined + : process.env.VALKEY_AUTH_TYPE === "gcp-iam" + ? { + // "default" is the only supported username for GCP IAM authentication + // https://docs.cloud.google.com/memorystore/docs/valkey/manage-iam-auth#error-messages + // mintGcpAccessToken rejects non-TLS / unverified transports for this bearer token. + username: "default", + password: await mintGcpAccessToken(useTLS, verifyTlsCertificate), + } + : process.env.VALKEY_PASSWORD ? { + username: process.env.VALKEY_USERNAME, + password: process.env.VALKEY_PASSWORD, + } : undefined - const useTLS = process.env.VALKEY_TLS === "true" + // Glide's TLS runs in its Rust core, so a custom CA must be passed explicitly + // via `rootCertificates` (Node's trust store / NODE_EXTRA_CA_CERTS do not apply). + const caCertPath = process.env.VALKEY_CA_CERT_PATH + const tlsAdvancedConfiguration = !useTLS + ? undefined + : process.env.VALKEY_VERIFY_CERT === "false" + ? { insecure: true } + : caCertPath + ? { rootCertificates: readFileSync(caCertPath) } + : undefined const sharedOptions = { addresses, credentials, useTLS, clientInfoTag, advancedConfiguration: { - ...(useTLS && process.env.VALKEY_VERIFY_CERT === "false" && { - tlsAdvancedConfiguration: { - insecure: true, - }, - }), + ...(tlsAdvancedConfiguration && { tlsAdvancedConfiguration }), connectionTimeout: 30000, }, requestTimeout: 5000, diff --git a/apps/server/package.json b/apps/server/package.json index 44471942..4e5c380b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "@valkey/valkey-glide": "^2.5.2", "express": "^4.21.2", "express-rate-limit": "^8.3.1", + "google-auth-library": "^11.0.2", "helmet": "^8.2.0", "p-limit": "^6.1.0", "ramda": "^0.31.3", diff --git a/apps/server/src/__tests__/connection.test.ts b/apps/server/src/__tests__/connection.test.ts index eeaa3020..110215a6 100644 --- a/apps/server/src/__tests__/connection.test.ts +++ b/apps/server/src/__tests__/connection.test.ts @@ -1,8 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, mock, beforeEach, afterEach } from "node:test" import assert from "node:assert" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" import { GlideClient, GlideClusterClient, InfoOptions } from "@valkey/valkey-glide" import { buildConnectionId, KEY_EVICTION_POLICY, VALKEY, toNodeId } from "valkey-common" +import { GoogleAuth } from "google-auth-library" import { _resetConnectInFlight, _resetInFlightClusterClients, @@ -347,6 +351,111 @@ describe("connectToValkey", () => { }) }) + it("should mint an access token as the password when authType is gcp-iam", async () => { + const standalone = buildStandaloneMock({ clusterEnabled: "1" }) + const cluster = buildClusterMock() + mock.method(GoogleAuth.prototype, "getAccessToken", async () => "fake-gcp-token") + + const gcpPayload = { + ...DEFAULT_PAYLOAD, + connectionDetails: { + ...DEFAULT_PAYLOAD.connectionDetails, + authType: "gcp-iam" as const, + username: "ignored@project.iam.gserviceaccount.com", + tls: true, + verifyTlsCertificate: true, + password: undefined, + }, + } + + await withMockedClients(standalone, cluster, async () => { + await connectToValkey(ctx(), mockWs, gcpPayload) + + const calls = (GlideClusterClient.createClient as any).mock.calls + assert.ok(calls.length > 0) + const config = calls[0].arguments[0] + assert.strictEqual(config.credentials.password, "fake-gcp-token") + assert.strictEqual(config.credentials.username, "default") + assert.strictEqual(config.credentials.iamConfig, undefined) + }) + }) + + it("rejects gcp-iam without TLS instead of sending the token in cleartext", async () => { + const standalone = buildStandaloneMock({ clusterEnabled: "1" }) + const cluster = buildClusterMock() + mock.method(GoogleAuth.prototype, "getAccessToken", async () => "fake-gcp-token") + + const insecurePayload = { + ...DEFAULT_PAYLOAD, + connectionDetails: { + ...DEFAULT_PAYLOAD.connectionDetails, + authType: "gcp-iam" as const, + tls: false, + password: undefined, + }, + } + + await withMockedClients(standalone, cluster, async () => { + const result = await connectToValkey(ctx(), mockWs, insecurePayload) + assert.strictEqual(result, undefined) + assert.strictEqual((GlideClusterClient.createClient as any).mock.calls.length, 0) + assert.strictEqual((GlideClient.createClient as any).mock.calls.length, 0) + }) + }) + + it("rejects gcp-iam when certificate verification is disabled", async () => { + const standalone = buildStandaloneMock({ clusterEnabled: "1" }) + const cluster = buildClusterMock() + mock.method(GoogleAuth.prototype, "getAccessToken", async () => "fake-gcp-token") + + const insecurePayload = { + ...DEFAULT_PAYLOAD, + connectionDetails: { + ...DEFAULT_PAYLOAD.connectionDetails, + authType: "gcp-iam" as const, + tls: true, + verifyTlsCertificate: false, + password: undefined, + }, + } + + await withMockedClients(standalone, cluster, async () => { + const result = await connectToValkey(ctx(), mockWs, insecurePayload) + assert.strictEqual(result, undefined) + assert.strictEqual((GlideClusterClient.createClient as any).mock.calls.length, 0) + assert.strictEqual((GlideClient.createClient as any).mock.calls.length, 0) + }) + }) + + it("passes the connection's caCertPath to Glide as rootCertificates", async () => { + const standalone = buildStandaloneMock({ clusterEnabled: "1" }) + const cluster = buildClusterMock() + const caPath = path.join(os.tmpdir(), `valkey-ca-${process.pid}-${Date.now()}.pem`) + fs.writeFileSync(caPath, "-----BEGIN CERTIFICATE-----\ntestca\n-----END CERTIFICATE-----\n") + + const tlsPayload = { + ...DEFAULT_PAYLOAD, + connectionDetails: { + ...DEFAULT_PAYLOAD.connectionDetails, + tls: true, + verifyTlsCertificate: true, + caCertPath: caPath, + }, + } + + try { + await withMockedClients(standalone, cluster, async () => { + await connectToValkey(ctx(), mockWs, tlsPayload) + const config = (GlideClusterClient.createClient as any).mock.calls[0].arguments[0] + const ca = config.advancedConfiguration.tlsAdvancedConfiguration.rootCertificates + assert.ok(Buffer.isBuffer(ca)) + assert.match(ca.toString(), /BEGIN CERTIFICATE/) + }) + } finally { + fs.rmSync(caPath, { force: true }) + } + }) + it("should handle connection errors", async () => { const error = new Error("Connection failed") const originalCreateClient = GlideClient.createClient diff --git a/apps/server/src/actions/connection.ts b/apps/server/src/actions/connection.ts index 25076102..1eb4739e 100644 --- a/apps/server/src/actions/connection.ts +++ b/apps/server/src/actions/connection.ts @@ -17,7 +17,7 @@ export interface ConnectionDetails { //TODO: Add handling and UI for uploading cert caCertPath?: string; endpointType: EndpointType; - authType?: "password" | "iam"; + authType?: "password" | "iam" | "gcp-iam"; awsRegion?: string; awsReplicationGroupId?: string; /** diff --git a/apps/server/src/connection.ts b/apps/server/src/connection.ts index 191853e9..68cb3fcf 100644 --- a/apps/server/src/connection.ts +++ b/apps/server/src/connection.ts @@ -3,6 +3,7 @@ import * as R from "ramda" import WebSocket from "ws" import { VALKEY } from "valkey-common" import { buildConnectionId, isValidDatabaseIndex, sanitizeUrl, toNodeId, buildUrl } from "valkey-common" +import { mintGcpAccessToken, registerGcpTokenRefresh, unregisterGcpTokenRefresh } from "valkey-common" import { KeyEvictionPolicy } from "common/dist" import { getExistingClusterClient, @@ -172,7 +173,7 @@ async function connectToValkeyLocked( const { host, port, username, password, tls: useTLS, - verifyTlsCertificate, authType, awsRegion, awsReplicationGroupId, + verifyTlsCertificate, caCertPath, authType, awsRegion, awsReplicationGroupId, } = payload.connectionDetails const db = payload.connectionDetails.db @@ -184,21 +185,23 @@ async function connectToValkeyLocked( port: Number(port), }, ] - const credentials: ServerCredentials | undefined = - authType === "iam" - ? { - username: username!, - iamConfig: { - clusterName: awsReplicationGroupId!, - service: ServiceType.Elasticache, - region: awsRegion!, - }, - } - : password ? { username, password } : undefined - let standaloneClient: GlideClient | undefined try { + const credentials: ServerCredentials | undefined = + authType === "iam" + ? { + username: username!, + iamConfig: { + clusterName: awsReplicationGroupId!, + service: ServiceType.Elasticache, + region: awsRegion!, + }, + } + : authType === "gcp-iam" + ? { username: "default", password: await mintGcpAccessToken(useTLS, verifyTlsCertificate) } + : password ? { username, password } : undefined + if (!isValidDatabaseIndex(db)) { throw new ConnectionRejectedError( "Invalid Database_Index: must be a non-negative integer", @@ -257,6 +260,7 @@ async function connectToValkeyLocked( credentials, useTLS, verifyTlsCertificate, + caCertPath, }) // Open the registration gate: the metrics process spawned below will POST @@ -331,6 +335,7 @@ async function connectToValkeyLocked( credentials, useTLS, verifyTlsCertificate, + caCertPath, databaseId: clusterDatabaseId, }) inFlightClusterClients.set(clusterId, ownInflight) @@ -367,6 +372,7 @@ async function connectToValkeyLocked( } shouldCloseClusterClientOnError = false + if (authType === "gcp-iam") registerGcpTokenRefresh(clusterClient, `cluster ${clusterId}`, useTLS, verifyTlsCertificate) return clusterClient } finally { if (ownInflight && inFlightClusterClients.get(clusterId) === ownInflight) { @@ -409,6 +415,7 @@ async function connectToValkeyLocked( credentials, useTLS, verifyTlsCertificate, + caCertPath, databaseId: db, }) clients.set(connectionId, { client: standaloneClient }) @@ -431,6 +438,7 @@ async function connectToValkeyLocked( }, }) + if (authType === "gcp-iam") registerGcpTokenRefresh(standaloneClient, connectionId, useTLS, verifyTlsCertificate) return standaloneClient } catch (err) { @@ -480,29 +488,32 @@ export async function discoverTopology( const { discoveryId, connectionDetails } = payload const { host, port, username, password, tls: useTLS, - verifyTlsCertificate, authType, awsRegion, awsReplicationGroupId, + verifyTlsCertificate, caCertPath, authType, awsRegion, awsReplicationGroupId, } = connectionDetails const addresses = [{ host, port: Number(port) }] - const credentials: ServerCredentials | undefined = - authType === "iam" - ? { - username: username!, - iamConfig: { - clusterName: awsReplicationGroupId!, - service: ServiceType.Elasticache, - region: awsRegion!, - }, - } - : password ? { username, password } : undefined let client: GlideClient | undefined try { + const credentials: ServerCredentials | undefined = + authType === "iam" + ? { + username: username!, + iamConfig: { + clusterName: awsReplicationGroupId!, + service: ServiceType.Elasticache, + region: awsRegion!, + }, + } + : authType === "gcp-iam" + ? { username: "default", password: await mintGcpAccessToken(useTLS, verifyTlsCertificate) } + : password ? { username, password } : undefined + // Cluster discovery is read-only `CLUSTER SLOTS` against the seed node; // database selection is irrelevant for cluster commands. Skip // `databaseId` entirely so Glide does not issue `SELECT` (cluster nodes // reject `SELECT` even for db 0). - client = await createStandaloneValkeyClient({ addresses, credentials, useTLS, verifyTlsCertificate }) + client = await createStandaloneValkeyClient({ addresses, credentials, useTLS, verifyTlsCertificate, caCertPath }) const { discoveredClusterNodes } = await discoverCluster(client, { connectionDetails }) if (Object.keys(discoveredClusterNodes).length < 1) { throw new Error("Unable to discover cluster") @@ -542,6 +553,9 @@ function updateClusterNodesClient( .filter(([, entry]) => entry.client === existingClusterConnection.client) .map(([id]) => id) + // Stop the GCP IAM token-refresh timer before closing so the stale client is + // released immediately rather than lingering until the next refresh interval. + unregisterGcpTokenRefresh(existingClusterConnection.client) try { existingClusterConnection.client.close() } catch (error) { console.error(`Error closing stale client for ${existingClusterConnection.clusterId}:`, error) } @@ -618,8 +632,12 @@ export async function discoverCluster( awsRegion: payload.connectionDetails.awsRegion, awsReplicationGroupId: payload.connectionDetails.awsReplicationGroupId, }), + ...(payload.connectionDetails.authType === "gcp-iam" && { + authType: "gcp-iam" as const, + }), tls: payload.connectionDetails.tls, verifyTlsCertificate: payload.connectionDetails.verifyTlsCertificate, + caCertPath: payload.connectionDetails.caCertPath, replicas: [], } } @@ -643,6 +661,7 @@ export async function discoverCluster( username?: string, tls: boolean, verifyTlsCertificate: boolean, + caCertPath?: string, replicas: { id: string; host: string; port: number }[]; }>) @@ -755,6 +774,7 @@ export function teardownConnection( clients.delete(connectionId) if (connection && ![...clients.values()].some((c) => c.client === connection.client)) { + unregisterGcpTokenRefresh(connection.client) try { connection.client.close() } catch (error) { diff --git a/apps/server/src/metrics-orchestrator.ts b/apps/server/src/metrics-orchestrator.ts index 4d88a414..f43ae54c 100644 --- a/apps/server/src/metrics-orchestrator.ts +++ b/apps/server/src/metrics-orchestrator.ts @@ -28,7 +28,9 @@ import { resolveOrchestratorAuthWindowMs, sanitizeUrl, toNodeId, - verifyOrchestratorAuthCredential + verifyOrchestratorAuthCredential, + mintGcpAccessToken, + registerGcpTokenRefresh } from "valkey-common" import { discoverCluster, belongsToCluster } from "./connection" import { ConnectionDetails } from "./actions/connection" @@ -50,8 +52,9 @@ type NodeInfo = { password?: string; tls: boolean; verifyTlsCertificate: boolean; + caCertPath?: string; replicas?: { id: string; host: string; port: number }[]; - authType?: "password" | "iam"; + authType?: "password" | "iam" | "gcp-iam"; awsRegion?: string; awsReplicationGroupId?: string; } @@ -109,8 +112,13 @@ export const initialConnectionDetails: ConnectionDetails = { // Default certificate verification ON. Only an explicit VALKEY_VERIFY_CERT=false disables it, // so an unset variable never silently downgrades a TLS connection to insecure (see #445). verifyTlsCertificate: process.env.VALKEY_VERIFY_CERT !== "false", + caCertPath: process.env.VALKEY_CA_CERT_PATH, endpointType, - authType: process.env.VALKEY_AUTH_TYPE === "iam" ? "iam" : "password", + authType: process.env.VALKEY_AUTH_TYPE === "iam" + ? "iam" + : process.env.VALKEY_AUTH_TYPE === "gcp-iam" + ? "gcp-iam" + : "password", awsRegion: process.env.VALKEY_AWS_REGION, awsReplicationGroupId: process.env.VALKEY_REPLICATION_GROUP_ID, db: Number(process.env.VALKEY_DB ?? 0), @@ -337,12 +345,23 @@ let initialClient: GlideClient | null = null export async function getInitialClient() { if (!initialClient) { initialClient = await createClient(initialConnectionDetails) + if (initialConnectionDetails.authType === "gcp-iam") { + registerGcpTokenRefresh( + initialClient, + "orchestrator", + initialConnectionDetails.tls, + initialConnectionDetails.verifyTlsCertificate, + ) + } } return initialClient } async function createClient(connectionDetails: ConnectionDetails) { - const { host, port, username, password, tls, verifyTlsCertificate, authType, awsRegion, awsReplicationGroupId, db } = connectionDetails + const { + host, port, username, password, tls, verifyTlsCertificate, caCertPath, + authType, awsRegion, awsReplicationGroupId, db, + } = connectionDetails const addresses = [{ host, port: Number(port) }] const credentials = authType === "iam" @@ -354,9 +373,11 @@ async function createClient(connectionDetails: ConnectionDetails) { region: awsRegion!, }, } - : password ? { username, password } : undefined + : authType === "gcp-iam" + ? { username: "default", password: await mintGcpAccessToken(tls, verifyTlsCertificate) } + : password ? { username, password } : undefined - return await createOrchestratorValkeyClient({ addresses, credentials, useTLS: tls, verifyTlsCertificate, databaseId: db }) + return await createOrchestratorValkeyClient({ addresses, credentials, useTLS: tls, verifyTlsCertificate, caCertPath, databaseId: db }) } async function getClusterTopology(client: GlideClusterClient | GlideClient | null, node: ConnectionDetails) { @@ -486,6 +507,7 @@ export async function startMetricsServer(nodeToStart: NodeInfo, nodeId: string) VALKEY_PASSWORD: nodeToStart.password, VALKEY_TLS: String(nodeToStart.tls), VALKEY_VERIFY_CERT: String(nodeToStart.verifyTlsCertificate), + ...(nodeToStart.caCertPath && { VALKEY_CA_CERT_PATH: nodeToStart.caCertPath }), VALKEY_AUTH_TYPE: nodeToStart.authType ?? "password", VALKEY_AWS_REGION: nodeToStart.awsRegion, VALKEY_REPLICATION_GROUP_ID: nodeToStart.awsReplicationGroupId, diff --git a/apps/server/src/valkey-client.ts b/apps/server/src/valkey-client.ts index cb7d8bf0..2fa693d1 100644 --- a/apps/server/src/valkey-client.ts +++ b/apps/server/src/valkey-client.ts @@ -1,4 +1,5 @@ import { GlideClient, GlideClusterClient, NodeDiscoveryMode, type ServerCredentials } from "@valkey/valkey-glide" +import { readFileSync, statSync } from "node:fs" import { APP_VERSION, deploymentSuffix } from "valkey-common" type Address = { @@ -11,16 +12,36 @@ type ClientOptions = { credentials?: ServerCredentials useTLS: boolean verifyTlsCertificate: boolean + caCertPath?: string databaseId?: number } const clientInfoTag = `valkey-admin-${deploymentSuffix()}:${APP_VERSION}` +// A connection's `caCertPath` can originate from a client-supplied connection +// request, so guard the synchronous read: reject non-regular files (FIFOs or +// devices that would block the event loop) and oversized files before reading. +const MAX_CA_CERT_BYTES = 1024 * 1024 + +const readCaCertificate = (caCertPath: string): Buffer => { + const stats = statSync(caCertPath) + if (!stats.isFile()) { + throw new Error(`CA certificate path is not a regular file: ${caCertPath}`) + } + if (stats.size > MAX_CA_CERT_BYTES) { + throw new Error( + `CA certificate file exceeds ${MAX_CA_CERT_BYTES} bytes (${stats.size}): ${caCertPath}`, + ) + } + return readFileSync(caCertPath) +} + const buildSharedOptions = ({ addresses, credentials, useTLS, verifyTlsCertificate, + caCertPath, databaseId, }: ClientOptions) => { // Surface any insecure TLS connection: disabling certificate validation exposes the @@ -33,6 +54,17 @@ const buildSharedOptions = ({ ) } + // Glide's TLS runs in its Rust core, so a private CA (the connection's + // `caCertPath`) must be passed via `rootCertificates`; Node's trust store + // and NODE_EXTRA_CA_CERTS do not apply. + const tlsAdvancedConfiguration = !useTLS + ? undefined + : verifyTlsCertificate === false + ? { insecure: true } + : caCertPath + ? { rootCertificates: readCaCertificate(caCertPath) } + : undefined + return { addresses, credentials, @@ -45,11 +77,7 @@ const buildSharedOptions = ({ // mandatory for cluster. ...(typeof databaseId === "number" && databaseId > 0 && { databaseId }), advancedConfiguration: { - ...(useTLS && verifyTlsCertificate === false && { - tlsAdvancedConfiguration: { - insecure: true, - }, - }), + ...(tlsAdvancedConfiguration && { tlsAdvancedConfiguration }), connectionTimeout: 30000, }, requestTimeout: 5000, diff --git a/common/package.json b/common/package.json index b21aa61d..6e5eba6d 100644 --- a/common/package.json +++ b/common/package.json @@ -16,6 +16,9 @@ "build": "node scripts/sync-version.mjs && tsup src/index.ts --format esm --dts --out-dir dist --clean", "test": "npx tsx --test src/__tests__/*.test.ts" }, + "dependencies": { + "google-auth-library": "^11.0.2" + }, "devDependencies": { "@types/node": "^24.2.1", "tsup": "^8.5.1", diff --git a/common/src/gcp-iam-provider.ts b/common/src/gcp-iam-provider.ts new file mode 100644 index 00000000..a9f638ab --- /dev/null +++ b/common/src/gcp-iam-provider.ts @@ -0,0 +1,27 @@ +import { GoogleAuth } from "google-auth-library" + +// GCP Memorystore for Valkey IAM auth uses a short-lived OAuth2 access token as the AUTH password. +// We mint the token from Application Default Credentials (Workload Identity in GKE, +// the metadata server on Google Compute Engine, or GOOGLE_APPLICATION_CREDENTIALS locally). +const auth = new GoogleAuth({ + scopes: "https://www.googleapis.com/auth/cloud-platform", +}) + +// The token is an OAuth2 bearer credential; refuse to mint it for a transport +// that could leak it (plaintext, or TLS without certificate verification). +export async function mintGcpAccessToken(useTLS: boolean, verifyTlsCertificate: boolean): Promise { + if (!useTLS) { + throw new Error("GCP IAM authentication requires TLS. Set VALKEY_TLS=true.") + } + if (verifyTlsCertificate === false) { + throw new Error( + "GCP IAM authentication requires TLS certificate verification. " + + "Do not disable VALKEY_VERIFY_CERT; provide the server CA via VALKEY_CA_CERT_PATH instead.", + ) + } + const token = await auth.getAccessToken() + if (!token) { + throw new Error("Unable to mint a GCP access token from Application Default Credentials") + } + return token +} diff --git a/common/src/iam-token-refresh.ts b/common/src/iam-token-refresh.ts new file mode 100644 index 00000000..5181ad99 --- /dev/null +++ b/common/src/iam-token-refresh.ts @@ -0,0 +1,85 @@ +import { retryDelay } from "./constants" +import { mintGcpAccessToken } from "./gcp-iam-provider" + +// Structural type so this module does not depend on @valkey/valkey-glide. +// Glide's GlideClient / GlideClusterClient satisfy it (their +// updateConnectionPassword returns a GlideString, which we ignore). +export interface RefreshableClient { + updateConnectionPassword(password: string, immediateAuth: boolean): Promise +} + +// GCP OAuth2 access tokens live ~1 hour. Glide keeps existing connections +// authenticated after expiry, but new/reconnecting connections need a fresh +// token, so we re-mint and push it to every node connection well before expiry. +const REFRESH_INTERVAL_MS = 45 * 60 * 1000 + +interface RefreshState { + label: string + useTLS: boolean + verifyTlsCertificate: boolean + timer?: NodeJS.Timeout // the next scheduled refresh (regular cadence or backoff retry) + failures: number // 0 while healthy; drives exponential backoff while failing +} + +const refreshStates = new Map() + +// Glide throws ClosingError once the client is closed; its `name` getter returns +// the constructor name, so we detect it without importing @valkey/valkey-glide. +function isClosingError(error: unknown): boolean { + return error instanceof Error && error.name === "ClosingError" +} + +function scheduleNext(client: RefreshableClient, delayMs: number): void { + const state = refreshStates.get(client) + if (!state) return + if (state.timer) clearTimeout(state.timer) + const timer = setTimeout(() => { + void refresh(client) + }, delayMs) + // Do not keep the process alive solely for the refresh timer. + timer.unref?.() + state.timer = timer +} + +async function refresh(client: RefreshableClient): Promise { + const state = refreshStates.get(client) + if (!state) return + try { + const token = await mintGcpAccessToken(state.useTLS, state.verifyTlsCertificate) + await client.updateConnectionPassword(token, true) + state.failures = 0 + scheduleNext(client, REFRESH_INTERVAL_MS) + } catch (error) { + if (isClosingError(error)) { + unregisterGcpTokenRefresh(client) + return + } + state.failures += 1 + console.error(`Error refreshing GCP IAM token for ${state.label}:`, error) + // Back off (fibonacci) but never wait longer than the regular interval, so a + // fresh token still lands before the ~1h token expires. A later success + // resets `failures` and restores the regular cadence. + scheduleNext(client, Math.min(retryDelay(state.failures), REFRESH_INTERVAL_MS)) + } +} + +// Rotate the connection password for a gcp-iam client on a timer. +// Keyed by the client instance so shared cluster clients are only scheduled once; the timer +// self-clears once the client is closed (updateConnectionPassword throws ClosingError), +// so callers do not have to unregister at every close site. +export function registerGcpTokenRefresh( + client: RefreshableClient, + label: string, + useTLS: boolean, + verifyTlsCertificate: boolean, +): void { + if (refreshStates.has(client)) return + refreshStates.set(client, { label, useTLS, verifyTlsCertificate, failures: 0 }) + scheduleNext(client, REFRESH_INTERVAL_MS) +} + +export function unregisterGcpTokenRefresh(client: RefreshableClient): void { + const state = refreshStates.get(client) + if (state?.timer) clearTimeout(state.timer) + refreshStates.delete(client) +} diff --git a/common/src/index.ts b/common/src/index.ts index 35888da3..3da0ec0d 100644 --- a/common/src/index.ts +++ b/common/src/index.ts @@ -7,6 +7,8 @@ export * from "./orchestrator-auth" export * from "./constants" export * from "./dashboard-metrics" export * from "./format-metric-value" +export * from "./gcp-iam-provider" +export * from "./iam-token-refresh" export * from "./json-utils" export * from "./key-tree-builder" export * from "./key-validators" diff --git a/docker/description.md b/docker/description.md index 6d80f234..2b167f3f 100644 --- a/docker/description.md +++ b/docker/description.md @@ -67,6 +67,25 @@ $ docker run -d --name valkey-admin -p 8080:8080 \ valkey/valkey-admin ``` +### GCP Memorystore for Valkey with IAM authentication + +Runs with Application Default Credentials (Workload Identity in GKE, the metadata +server on GCE, or a mounted `GOOGLE_APPLICATION_CREDENTIALS` key file locally). +No password is supplied — the token is minted and rotated automatically. TLS with +certificate verification is required for `gcp-iam`, so provide the instance's +server CA via `VALKEY_CA_CERT_PATH`. + +```console +$ docker run -d --name valkey-admin -p 8080:8080 \ + -e VALKEY_HOST=your-instance-endpoint \ + -e VALKEY_PORT=6379 \ + -e VALKEY_AUTH_TYPE=gcp-iam \ + -e VALKEY_TLS=true \ + -e VALKEY_CA_CERT_PATH=/etc/valkey/tls/valkey-ca.pem \ + -v /path/to/valkey-ca.pem:/etc/valkey/tls/valkey-ca.pem:ro \ + valkey/valkey-admin +``` + ### Environment variables Here are all the relevant environment variables for configuration @@ -80,7 +99,9 @@ Here are all the relevant environment variables for configuration | `VALKEY_USERNAME` | Valkey username | — | | `VALKEY_PASSWORD` | Valkey password | — | | `VALKEY_TLS` | Enable TLS | `false` | -| `VALKEY_AUTH_TYPE` | Authentication type (`password`, `iam`) | `password` | +| `VALKEY_VERIFY_CERT` | Verify the server's TLS certificate; set `false` to disable (not allowed for `gcp-iam`) | `true` | +| `VALKEY_CA_CERT_PATH` | Path to a PEM CA cert used to verify the server's TLS certificate | — | +| `VALKEY_AUTH_TYPE` | Authentication type (`password`, `iam`, `gcp-iam`) | `password` | | `HOT_KEYS_COUNT` | Maximum hot keys returned per query | `50` | | `COMMAND_LOGS_COUNT` | Maximum command log entries returned per query | `100` | | `KEY_VALUE_SIZE_LIMIT_BYTES` | Max value size (bytes) shown in the Key Browser before a size warning | `2048` | diff --git a/docs-site/src/content/docs/configuration/metrics.md b/docs-site/src/content/docs/configuration/metrics.md index 7579c524..4b7292cf 100644 --- a/docs-site/src/content/docs/configuration/metrics.md +++ b/docs-site/src/content/docs/configuration/metrics.md @@ -99,11 +99,16 @@ Enable TLS. Compared as the literal string `"true"`. Verify the TLS server certificate. When TLS is enabled and this is `"false"`, certificate verification is skipped — useful for development against self-signed certs, but not for production. +### `VALKEY_CA_CERT_PATH` + +Filesystem path to a PEM CA certificate used to verify the Valkey server's TLS certificate — for both the Glide client and the iovalkey MONITOR stream. Only consulted when `VALKEY_TLS=true` and verification is enabled. Glide performs TLS in its Rust core, so a private CA must be supplied this way rather than via Node's trust store or `NODE_EXTRA_CA_CERTS`. + ### `VALKEY_AUTH_TYPE` Selects the credentials provider. - **`"iam"`** — use AWS ElastiCache IAM authentication via `ElastiCacheIAMProvider`. Requires `VALKEY_USERNAME`, `VALKEY_AWS_REGION`, and `VALKEY_REPLICATION_GROUP_ID`. +- **`"gcp-iam"`** — use GCP Memorystore for Valkey IAM authentication. Mints a short-lived OAuth2 access token from Application Default Credentials and rotates it before expiry. Authenticates as the `default` user — the only username Memorystore supports — so `VALKEY_USERNAME` is ignored. Requires TLS with certificate verification: `VALKEY_TLS=true` and `VALKEY_VERIFY_CERT` must not be `"false"` (the general verification opt-out does not apply to `gcp-iam`). Startup fails otherwise, since the IAM token is a bearer credential that must not travel over an unverified channel. - **anything else** — password authentication using `VALKEY_USERNAME` / `VALKEY_PASSWORD`. ### `VALKEY_AWS_REGION` diff --git a/docs-site/src/content/docs/configuration/server.md b/docs-site/src/content/docs/configuration/server.md index b03aa830..fc49ae76 100644 --- a/docs-site/src/content/docs/configuration/server.md +++ b/docs-site/src/content/docs/configuration/server.md @@ -174,9 +174,13 @@ Enable TLS for the Valkey connection. Compared as the literal string `"true"`. ### `VALKEY_VERIFY_CERT` -Verify the TLS server certificate. Compared as the literal string `"true"`. Leave this off only when you are knowingly talking to a node with a self-signed cert. +Verify the TLS server certificate. Verification is **on** unless this is set to the literal string `"false"`. Disable it only when you knowingly talk to a node with a self-signed cert and cannot supply its CA via `VALKEY_CA_CERT_PATH`. Verification cannot be disabled for `gcp-iam` — the IAM token is a bearer credential and requires a verified TLS channel. -- **Default:** `false` +- **Default:** `true` + +### `VALKEY_CA_CERT_PATH` + +Filesystem path to a PEM CA certificate used to verify the Valkey server's TLS certificate. Only consulted when `VALKEY_TLS=true` and verification is enabled (`VALKEY_VERIFY_CERT` is not `false`). Glide performs TLS in its Rust core, so a private CA must be supplied this way — Node's trust store and `NODE_EXTRA_CA_CERTS` do not apply. Typical use: mount your server CA (for example a Memorystore for Valkey CA) as a secret and point this at the mounted file. ### `VALKEY_ENDPOINT_TYPE` @@ -190,6 +194,7 @@ Tells the orchestrator how to interpret `VALKEY_HOST` / `VALKEY_PORT` when disco Selects the credentials provider for the initial connection. - **`"iam"`** — use AWS ElastiCache IAM authentication. Requires `VALKEY_USERNAME`, `VALKEY_AWS_REGION`, and `VALKEY_REPLICATION_GROUP_ID`. +- **`"gcp-iam"`** — use GCP Memorystore for Valkey IAM authentication. Mints a short-lived OAuth2 access token from Application Default Credentials (Workload Identity in GKE, the metadata server on GCE, or `GOOGLE_APPLICATION_CREDENTIALS` locally) and rotates it before expiry. Authenticates as the `default` user — the only username Memorystore supports — so `VALKEY_USERNAME` is ignored. - **anything else** — fall back to password authentication using `VALKEY_USERNAME` / `VALKEY_PASSWORD`. - **Default:** `"password"` diff --git a/docs-site/src/content/docs/features/connections.md b/docs-site/src/content/docs/features/connections.md index 35215262..29648385 100644 --- a/docs-site/src/content/docs/features/connections.md +++ b/docs-site/src/content/docs/features/connections.md @@ -53,7 +53,7 @@ All Valkey versions support numbered databases in standalone mode, up to the ser ## Authentication -Valkey Admin supports two authentication methods: +Valkey Admin supports three authentication methods: ### Password Authentication @@ -68,6 +68,10 @@ For ElastiCache clusters with IAM authentication enabled, provide: Valkey Admin generates short-lived IAM auth tokens automatically. +### GCP IAM Authentication + +For Memorystore for Valkey instances with IAM authentication enabled, select **GCP IAM**. No username or password is required — Valkey Admin mints a short-lived OAuth2 access token from the ambient Application Default Credentials (Workload Identity in GKE, the metadata server on GCE, or `GOOGLE_APPLICATION_CREDENTIALS` locally) and rotates it before expiry, including across every cluster node. Connections authenticate as the `default` user, which is the only username Memorystore for Valkey supports. TLS **with certificate verification** is required (the token is a bearer credential); supply the instance's server CA via `VALKEY_CA_CERT_PATH`. + ## TLS Enable TLS for encrypted connections. Optionally verify the server certificate — disable verification only for self-signed certificates in development environments. diff --git a/docs-site/src/content/docs/reference/limitations.md b/docs-site/src/content/docs/reference/limitations.md index 5498754a..0015255b 100644 --- a/docs-site/src/content/docs/reference/limitations.md +++ b/docs-site/src/content/docs/reference/limitations.md @@ -14,7 +14,7 @@ For runtime issues and fixes, see the [Troubleshooting guide](/reference/trouble ## TLS -- **mTLS is not currently supported.** Standard TLS with password authentication or AWS ElastiCache IAM authentication is available. +- **mTLS is not currently supported.** Standard TLS with password authentication, AWS ElastiCache IAM authentication, or GCP Memorystore for Valkey IAM authentication is available. ## Managed services diff --git a/package-lock.json b/package-lock.json index 28c6abbc..93dc3b67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,6 +104,7 @@ "@valkey/valkey-glide": "^2.5.2", "express": "^4.21.2", "express-rate-limit": "^8.3.1", + "google-auth-library": "^11.0.2", "helmet": "^8.2.0", "p-limit": "^6.1.0", "ramda": "^0.31.3", @@ -124,6 +125,9 @@ "common": { "name": "valkey-common", "version": "1.0.0", + "dependencies": { + "google-auth-library": "^11.0.2" + }, "devDependencies": { "@types/node": "^24.2.1", "tsup": "^8.5.1", @@ -5194,9 +5198,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5210,9 +5211,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5226,9 +5224,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5242,9 +5237,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5518,7 +5510,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -6090,7 +6081,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -6120,6 +6110,14 @@ "node": ">=6.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", @@ -6269,6 +6267,11 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7025,6 +7028,14 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -7553,6 +7564,14 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8671,6 +8690,11 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -8776,6 +8800,28 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -8997,6 +9043,17 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -9095,6 +9152,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.0.3.tgz", + "integrity": "sha512-2YYnIlHaKBGT2IPg3G2M57hia9Galz15zsEOvw9T3oRf0lSn6KN6VcHQLqby7x8ksYKnjXvp3rp2KJyLCN6zfQ==", + "dependencies": { + "gaxios": "^7.1.3", + "google-logging-utils": "^2.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -9305,6 +9388,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz", + "integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "^9.0.0", + "google-logging-utils": "^2.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-logging-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.0.1.tgz", + "integrity": "sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A==", + "engines": { + "node": ">=22" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9554,7 +9661,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -10421,6 +10527,14 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -10489,6 +10603,25 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11280,6 +11413,25 @@ "node": ">=10" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -11299,6 +11451,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", @@ -15508,6 +15677,14 @@ "node": ">=18" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",