+ 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",