UNPKG

@graphql-mesh/cache-redis

Version:
211 lines (210 loc) • 10.1 kB
"use strict"; // inspired by https://github.com/redis/ioredis/issues/1738#issuecomment-1969925020 Object.defineProperty(exports, "__esModule", { value: true }); exports.generateIamToken = generateIamToken; exports.buildIamRedisOptions = buildIamRedisOptions; exports.setupIamAuthRefreshForStandalone = setupIamAuthRefreshForStandalone; exports.setupIamAuthForCluster = setupIamAuthForCluster; const tslib_1 = require("tslib"); const ioredis = tslib_1.__importStar(require("ioredis")); const AbstractConnectorClass = // @ts-expect-error ioredis is CJS-only and reassigns module.exports, which breaks Node.js static // named export analysis for ESM interop - AbstractConnector named import resolves to undefined // at runtime; access it via the default import (which is the CJS module.exports object) instead (ioredis.AbstractConnector ?? ioredis.default.AbstractConnector); // IamTokenConnector extends the public AbstractConnector and replicates StandaloneConnector's // TCP/TLS connection logic so we avoid importing the non-exported deep internal path // ioredis/built/connectors/StandaloneConnector which is not resolvable in strict ESM. class IamTokenConnector extends AbstractConnectorClass { // ioredis calls Connector as `new (options: unknown)` so the parameter must be unknown here constructor(options) { const opts = options; super(opts.disconnectTimeout ?? 2000); this.options = opts; this.redisRef = opts.tokenConnector.redisRef; this.getToken = opts.tokenConnector.getToken; } async connect(emitter) { const token = await this.getToken(); const condition = this.redisRef.current?.condition; if (!condition) throw new Error('expected redis.condition to be set at this point'); if (condition.auth === undefined || typeof condition.auth === 'string') { condition.auth = token; } else if (Array.isArray(condition.auth)) { condition.auth = [condition.auth[0], token]; } return this.createStream(emitter); } // replicates StandaloneConnector.connect() TCP/TLS stream creation createStream(_emitter) { const { options } = this; this.connecting = true; return new Promise((resolve, reject) => { process.nextTick(async () => { if (!this.connecting) { reject(new Error('Connection is closed.')); return; } try { if (options.tls) { // eslint-disable-next-line import/no-nodejs-modules const { connect } = await Promise.resolve().then(() => tslib_1.__importStar(require('node:tls'))); if (options.path) { this.stream = connect({ ...options.tls, path: options.path }); } else { this.stream = connect({ ...options.tls, port: options.port ?? 6380, host: options.host, }); } } else { // eslint-disable-next-line import/no-nodejs-modules const { createConnection } = await Promise.resolve().then(() => tslib_1.__importStar(require('node:net'))); if (options.path) { this.stream = createConnection({ path: options.path }); } else { this.stream = createConnection({ port: options.port ?? 6379, host: options.host, family: options.family, }); } } } catch (err) { reject(err); return; } this.stream.once('error', err => { this.firstError = err; }); resolve(this.stream); }); }); } } // generates a short-lived SigV4 presigned token for ElastiCache/MemoryDB IAM auth. // returns the signed URL without protocol prefix - that's the auth password. async function generateIamToken(cfg) { const { SignatureV4 } = await Promise.resolve().then(() => tslib_1.__importStar(require('@smithy/signature-v4'))).catch(() => { throw new Error('Missing dependency: install @smithy/signature-v4 to use Redis IAM authentication'); }); const { fromNodeProviderChain } = await Promise.resolve().then(() => tslib_1.__importStar(require('@aws-sdk/credential-providers'))).catch(() => { throw new Error('Missing dependency: install @aws-sdk/credential-providers to use Redis IAM authentication'); }); // @aws-crypto/sha256-js is required - the hand-rolled node:crypto impl produces // tokens that fail AUTH on subsequent connections (WRONGPASS) due to subtle // differences in how Smithy feeds HMAC keys as Uint8Array vs Buffer const { Sha256 } = await Promise.resolve().then(() => tslib_1.__importStar(require('@aws-crypto/sha256-js'))).catch(() => { throw new Error('Missing dependency: install @aws-crypto/sha256-js to use Redis IAM authentication'); }); const { formatUrl } = await Promise.resolve().then(() => tslib_1.__importStar(require('@aws-sdk/util-format-url'))).catch(() => { throw new Error('Missing dependency: install @aws-sdk/util-format-url to use Redis IAM authentication'); }); const { HttpRequest } = await Promise.resolve().then(() => tslib_1.__importStar(require('@smithy/protocol-http'))).catch(() => { throw new Error('Missing dependency: install @smithy/protocol-http to use Redis IAM authentication'); }); const service = cfg.serviceName ?? 'elasticache'; const expirySeconds = cfg.tokenExpirySeconds ?? 900; const signer = new SignatureV4({ credentials: fromNodeProviderChain(), region: cfg.region, service, sha256: Sha256, }); const request = new HttpRequest({ method: 'GET', protocol: 'http:', hostname: cfg.clusterName, path: '/', headers: { host: cfg.clusterName }, query: { Action: 'connect', User: cfg.userId, }, }); const signed = await signer.presign(request, { expiresIn: expirySeconds, signingDate: new Date(), }); // formatUrl encodes query values exactly once (important for X-Amz-Security-Token) return formatUrl(signed).replace(/^https?:\/\//, ''); } function buildIamRedisOptions(base, cfg, redisRef) { return { ...base, tokenConnector: { redisRef, getToken: () => generateIamToken(cfg), }, Connector: IamTokenConnector, }; } // standalone mode has no periodic refresh like the cluster path below, so condition.auth can // go stale (>tokenExpirySeconds old) across a long idle period and get replayed verbatim on the // next reconnect before IamTokenConnector.connect() regenerates it, causing a one-time WRONGPASS. // mirror the cluster strategy: refresh on an interval and push the new token onto the live // connection's condition.auth so a stale token is never sitting there waiting to be reused. function setupIamAuthRefreshForStandalone(redisRef, cfg, username, logger) { const expiryMs = (cfg.tokenExpirySeconds ?? 900) * 1000; const refreshIntervalMs = expiryMs * 0.8; return setInterval(() => { generateIamToken(cfg) .then(token => { const condition = redisRef.current?.condition; if (condition != null) { condition.auth = username ? [username, token] : token; } }) .catch(err => { logger?.error('Failed to refresh IAM token for Redis:', err); }); }, refreshIntervalMs); } // cluster mode: ioredis does not support per-node credential providers, so we use a different // strategy. we install a password getter on redisOptions so every new node connection reads // the latest token. we also refresh via setInterval at 80% of token expiry. // // lazyConnect on a Redis.Cluster only defers user commands - ioredis still performs slot // discovery (connects to startup nodes) immediately on construction. the token must therefore // be ready before cluster.connect() is called, which is why this function is async and the // caller must await it before issuing any commands. async function setupIamAuthForCluster(cluster, redisOptions, cfg, username, logger) { const expiryMs = (cfg.tokenExpirySeconds ?? 900) * 1000; const refreshIntervalMs = expiryMs * 0.8; let currentToken = ''; // password getter - called each time ioredis sets condition.auth on a new node connection Object.defineProperty(redisOptions, 'password', { get: () => currentToken, set: (v) => { currentToken = v; }, enumerable: true, configurable: true, }); const refreshToken = async () => { currentToken = await generateIamToken(cfg); // also update existing nodes because ioredis rebuilds condition.auth from options.password // before reconnecting for (const node of cluster.nodes()) { node.options.password = currentToken; if (node.condition != null) { node.condition.auth = username ? [username, currentToken] : currentToken; } } }; // await the initial token so it is set before cluster.connect() triggers slot discovery await refreshToken(); const timer = setInterval(() => { refreshToken().catch(err => { logger?.error('Failed to refresh IAM token for Redis cluster:', err); }); }, refreshIntervalMs); return timer; }