@graphql-mesh/cache-redis
Version:
211 lines (210 loc) • 10.1 kB
JavaScript
;
// 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;
}