@middy/secrets-manager
Version:
Secrets Manager middleware for the middy framework
229 lines (202 loc) • 7.43 kB
JavaScript
// Copyright 2017 - 2026 will Farrell, Luciano Mammino, and Middy contributors.
// SPDX-License-Identifier: MIT
import {
DescribeSecretCommand,
GetSecretValueCommand,
SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";
import {
assignSetToContext,
buildSetToContextSpec,
canPrefetch,
catchInvalidSignatureException,
clearCache,
createClient,
createPrefetchClient,
getCache,
jsonSafeParse,
modifyCache,
processCache,
validateOptions,
} from "@middy/util";
const name = "secrets-manager";
const pkg = `@middy/${name}`;
const defaults = {
AwsClient: SecretsManagerClient,
awsClientOptions: {},
awsClientAssumeRole: undefined,
awsClientCapture: undefined,
fetchData: {},
fetchRotationDate: false, // true: apply to all or {key: true} for individual
disablePrefetch: false,
cacheKey: pkg,
cacheKeyExpiry: {},
cacheExpiry: -1, // with fetchRotationDate: -1 expires at NextRotationDate; >=0 adds to the last change date, capped at NextRotationDate
setToContext: false,
};
const optionSchema = {
type: "object",
properties: {
AwsClient: { instanceof: "Function" },
awsClientOptions: { type: "object" },
awsClientAssumeRole: { type: "string" },
awsClientCapture: { instanceof: "Function" },
fetchData: {
type: "object",
additionalProperties: { type: "string" },
},
fetchRotationDate: {
oneOf: [
{ type: "boolean" },
{ type: "object", additionalProperties: { type: "boolean" } },
],
},
disablePrefetch: { type: "boolean" },
cacheKey: { type: "string" },
cacheKeyExpiry: {
type: "object",
additionalProperties: { type: "number", minimum: -1 },
},
cacheExpiry: { type: "number", minimum: -1 },
setToContext: { type: "boolean" },
},
additionalProperties: false,
};
export const secretsManagerValidateOptions = (options) =>
validateOptions(pkg, optionSchema, options);
const secretsManagerMiddleware = (opts = {}) => {
const options = {
...defaults,
...opts,
cacheKeyExpiry: { ...defaults.cacheKeyExpiry, ...opts.cacheKeyExpiry },
};
const fetchDataKeys = Object.keys(options.fetchData);
const contextSpec = buildSetToContextSpec(options);
// AWS SDK v3 unmarshals DescribeSecret timestamps (NextRotationDate,
// LastRotationDate, LastChangedDate) to Date objects. `Number(date)` yields
// epoch milliseconds directly, so no per-second-to-ms conversion is needed.
const toMs = (date) => (date ? Number(date) : 0);
// processCache resolves a per-key expiry override by `options.cacheKey`
// (not by the internal fetch key), so the rotation expiry must be written
// under `options.cacheKey` BEFORE the cache entry is stored. We pick the
// soonest expiry across all fetched secrets so the shared cache entry is
// refreshed as soon as any secret is due to rotate.
const fetchRotationDates = async () => {
const pending = [];
for (const internalKey of fetchDataKeys) {
const fetchRotation =
options.fetchRotationDate === true ||
options.fetchRotationDate?.[internalKey];
if (!fetchRotation) continue;
const command = new DescribeSecretCommand({
SecretId: options.fetchData[internalKey],
});
pending.push(
client
.send(command)
.catch((e) => catchInvalidSignatureException(e, client, command)),
);
}
// Stryker disable next-line ConditionalExpression: equivalent - when pending is empty the remaining loop is a no-op and expiry stays undefined, so skipping the early return produces the identical result
if (!pending.length) return;
let expiry;
for (const resp of await Promise.all(pending)) {
let keyExpiry;
if (options.cacheExpiry < 0) {
if (resp.NextRotationDate) {
keyExpiry = toMs(resp.NextRotationDate);
}
} else {
const lastChanged =
Math.max(toMs(resp.LastRotationDate), toMs(resp.LastChangedDate)) +
options.cacheExpiry;
keyExpiry = resp.NextRotationDate
? Math.min(lastChanged, toMs(resp.NextRotationDate))
: lastChanged;
}
if (keyExpiry !== undefined) {
expiry = expiry === undefined ? keyExpiry : Math.min(expiry, keyExpiry);
}
}
// Stryker disable next-line ConditionalExpression: equivalent - reaching here with expiry===undefined writes `undefined`, which is read back via `cacheKeyExpiry?.[cacheKey] ?? cacheExpiry`, identical to not writing at all
if (expiry !== undefined) {
options.cacheKeyExpiry[options.cacheKey] = expiry;
}
};
const fetchRequest = (request, cachedValues = {}) => {
const values = {};
for (const internalKey of fetchDataKeys) {
if (cachedValues[internalKey]) continue;
const fetchSecret = () => {
const command = new GetSecretValueCommand({
SecretId: options.fetchData[internalKey],
});
return client
.send(command)
.catch((e) => catchInvalidSignatureException(e, client, command))
.then((resp) => jsonSafeParse(resp.SecretString));
};
values[internalKey] = fetchSecret().catch((e) => {
const value = getCache(options.cacheKey).value ?? {};
value[internalKey] = undefined;
modifyCache(options.cacheKey, value);
throw e;
});
}
return values;
};
// Equivalent mutant: forcing rotationEnabled true still no-ops because
// cacheUnexpired() gates refreshRotationExpiry and fetchRotationDates does
// nothing without rotation keys; clearCache only ever touches an
// already-expired entry that processCache would refetch regardless.
// Stryker disable next-line ConditionalExpression
const rotationEnabled =
// Stryker disable next-line ConditionalExpression
options.fetchRotationDate === true ||
fetchDataKeys.some((key) => options.fetchRotationDate?.[key]);
// True when the stored cache entry is still within its expiry window, so the
// rotation DescribeSecret call can be skipped on a cache hit.
const cacheUnexpired = () => {
const cached = getCache(options.cacheKey);
return !!cached.expiry && cached.expiry > Date.now();
};
// Refresh the rotation-derived expiry before processCache so it governs the
// cache entry (processCache reads the override by `options.cacheKey`). The
// stale entry is evicted first so processCache stores a fresh value under the
// new expiry rather than reusing the old value masked by a future override.
const refreshRotationExpiry = async () => {
if (!rotationEnabled || cacheUnexpired()) return;
clearCache([options.cacheKey]);
await fetchRotationDates();
};
let client;
let clientInit;
if (canPrefetch(options)) {
client = createPrefetchClient(options);
fetchRotationDates()
.then(() => processCache(options, fetchRequest))
.catch(() => {});
}
const secretsManagerMiddlewareBefore = async (request) => {
if (!client) {
clientInit ??= createClient(options, request);
client = await clientInit;
}
await refreshRotationExpiry();
const { value } = processCache(options, fetchRequest, request);
Object.assign(request.internal, value);
if (contextSpec) {
const pending = assignSetToContext(contextSpec, value, request);
// Stryker disable next-line ConditionalExpression: equivalent - pending is either a Promise (awaited under both) or undefined, and `await undefined` resolves immediately with no observable effect
if (pending) await pending;
}
};
return {
before: secretsManagerMiddlewareBefore,
};
};
export default secretsManagerMiddleware;
// used for TS type inference (see index.d.ts)
export function secretsManagerParam(name) {
return name;
}