Extract shared `verifyWithRotation` helper and `makeSecretOrKeyProvider` into `jwt-rotation.ts` so both REST (passport-jwt strategy) and WebSocket (TokenService.verifyAccessToken) paths honour JWT_SECRET_PREVIOUS during secret rotation. Add env-validation for optional previous secrets and document the rotation policy for WebSocket sessions. Resolves GOO-237 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
193 lines
5.9 KiB
TypeScript
193 lines
5.9 KiB
TypeScript
/**
|
|
* Validates that critical environment variables are set at application startup.
|
|
*
|
|
* Security-sensitive vars (JWT secrets) are required in ALL environments and must
|
|
* not be placeholder/weak values. Infrastructure vars (DATABASE_URL, CORS, Redis)
|
|
* are required only in production.
|
|
*/
|
|
|
|
const ALWAYS_REQUIRED: readonly string[] = [
|
|
'JWT_SECRET',
|
|
'JWT_REFRESH_SECRET',
|
|
];
|
|
|
|
const REQUIRED_IN_PRODUCTION: readonly string[] = [
|
|
'DATABASE_URL',
|
|
'CORS_ORIGINS',
|
|
'REDIS_HOST',
|
|
'FIELD_ENCRYPTION_KEY',
|
|
];
|
|
|
|
const REQUIRED_WHEN_USED: ReadonlyMap<string, string> = new Map([
|
|
['VNPAY_TMN_CODE', 'VNPay payments'],
|
|
['VNPAY_HASH_SECRET', 'VNPay payments'],
|
|
['MOMO_PARTNER_CODE', 'MoMo payments'],
|
|
['MOMO_ACCESS_KEY', 'MoMo payments'],
|
|
['MOMO_SECRET_KEY', 'MoMo payments'],
|
|
['ZALOPAY_APP_ID', 'ZaloPay payments'],
|
|
['ZALOPAY_KEY1', 'ZaloPay payments'],
|
|
['ZALOPAY_KEY2', 'ZaloPay payments'],
|
|
['BANK_TRANSFER_ACCOUNT_NUMBER', 'Bank transfer payments'],
|
|
['BANK_TRANSFER_BANK_NAME', 'Bank transfer payments'],
|
|
['BANK_TRANSFER_ACCOUNT_HOLDER', 'Bank transfer payments'],
|
|
['BANK_TRANSFER_WEBHOOK_SECRET', 'Bank transfer payments'],
|
|
['MINIO_ACCESS_KEY', 'Media storage'],
|
|
['MINIO_SECRET_KEY', 'Media storage'],
|
|
['GOOGLE_CLIENT_ID', 'Google OAuth'],
|
|
['GOOGLE_CLIENT_SECRET', 'Google OAuth'],
|
|
['ZALO_APP_ID', 'Zalo OAuth'],
|
|
['ZALO_APP_SECRET', 'Zalo OAuth'],
|
|
['ZALO_OA_ID', 'Zalo OA notifications'],
|
|
['ZALO_OA_ACCESS_TOKEN', 'Zalo OA notifications'],
|
|
]);
|
|
|
|
/**
|
|
* Known placeholder values that must never be used as real secrets.
|
|
* Comparison is case-insensitive to catch common variants.
|
|
*/
|
|
/**
|
|
* Previous-version secrets used during key rotation. Validated if set but never
|
|
* required. Note: JWT_REFRESH_SECRET_PREVIOUS currently has no runtime consumer
|
|
* because refresh tokens are opaque random bytes, not JWTs — the variable is
|
|
* accepted here for forward-compatibility should the refresh mechanism change.
|
|
*/
|
|
const OPTIONAL_PREVIOUS_SECRETS: readonly string[] = [
|
|
'JWT_SECRET_PREVIOUS',
|
|
'JWT_REFRESH_SECRET_PREVIOUS',
|
|
];
|
|
|
|
const FORBIDDEN_SECRET_VALUES: readonly string[] = [
|
|
'change_me',
|
|
'changeme',
|
|
'your_jwt_secret',
|
|
'your_jwt_secret_change_me',
|
|
'your_jwt_refresh_secret',
|
|
'your_jwt_refresh_secret_change_me',
|
|
'secret',
|
|
'jwt_secret',
|
|
'jwt_refresh_secret',
|
|
'supersecret',
|
|
'super_secret',
|
|
'mysecret',
|
|
'my_secret',
|
|
'password',
|
|
'test',
|
|
'default',
|
|
'placeholder',
|
|
'replace_me',
|
|
'replaceme',
|
|
'fixme',
|
|
'todo',
|
|
'xxx',
|
|
];
|
|
|
|
/** Minimum length for JWT secrets — NIST SP 800-117 recommends ≥256 bits for HMAC keys. */
|
|
const MIN_SECRET_LENGTH = 32;
|
|
|
|
/**
|
|
* Validates that a JWT secret is not a placeholder value, is long enough, and
|
|
* does not appear to be a trivially weak key.
|
|
*
|
|
* @returns An error message string if the value is invalid, or `null` if OK.
|
|
*/
|
|
export function validateJwtSecret(key: string, value: string): string | null {
|
|
if (FORBIDDEN_SECRET_VALUES.includes(value.toLowerCase().trim())) {
|
|
return `${key} is set to a known placeholder value ("${value}"). Generate a real secret: \`openssl rand -base64 48\``;
|
|
}
|
|
|
|
if (value.length < MIN_SECRET_LENGTH) {
|
|
return `${key} is too short (${value.length} chars, minimum ${MIN_SECRET_LENGTH}). Generate a strong secret: \`openssl rand -base64 48\``;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function validateEnv(): void {
|
|
const isProduction = process.env['NODE_ENV'] === 'production';
|
|
const missing: string[] = [];
|
|
|
|
// JWT secrets are required in every environment — a missing secret is a
|
|
// security risk regardless of NODE_ENV.
|
|
for (const key of ALWAYS_REQUIRED) {
|
|
if (!process.env[key]) {
|
|
missing.push(key);
|
|
}
|
|
}
|
|
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`Missing required environment variables:\n ${missing.join('\n ')}\n` +
|
|
'JWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.',
|
|
);
|
|
}
|
|
|
|
// Reject placeholder / weak JWT secret values — these are dangerous even in
|
|
// development because tokens signed with predictable keys are trivially forgeable.
|
|
const secretErrors: string[] = [];
|
|
for (const key of ALWAYS_REQUIRED) {
|
|
const error = validateJwtSecret(key, process.env[key]!);
|
|
if (error) {
|
|
secretErrors.push(error);
|
|
}
|
|
}
|
|
|
|
if (secretErrors.length > 0) {
|
|
throw new Error(
|
|
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` +
|
|
'Generate secure secrets with: openssl rand -base64 48',
|
|
);
|
|
}
|
|
|
|
// Validate optional previous secrets if they are set (rotation window).
|
|
const prevSecretErrors: string[] = [];
|
|
for (const key of OPTIONAL_PREVIOUS_SECRETS) {
|
|
const value = process.env[key];
|
|
if (value) {
|
|
const error = validateJwtSecret(key, value);
|
|
if (error) {
|
|
prevSecretErrors.push(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (prevSecretErrors.length > 0) {
|
|
throw new Error(
|
|
`Insecure previous-secret configuration:\n ${prevSecretErrors.join('\n ')}\n` +
|
|
'Previous secrets must meet the same strength requirements as primary secrets.',
|
|
);
|
|
}
|
|
|
|
if (!isProduction) {
|
|
return;
|
|
}
|
|
|
|
// Infrastructure vars — required in production only.
|
|
const missingProd: string[] = [];
|
|
for (const key of REQUIRED_IN_PRODUCTION) {
|
|
if (!process.env[key]) {
|
|
missingProd.push(key);
|
|
}
|
|
}
|
|
|
|
if (missingProd.length > 0) {
|
|
throw new Error(
|
|
`Missing required environment variables in production:\n ${missingProd.join('\n ')}\n` +
|
|
'See .env.example for the full list of variables.',
|
|
);
|
|
}
|
|
|
|
// Warn about payment/storage vars that are empty — services will fail at
|
|
// runtime when invoked, but we surface the warning early.
|
|
const warnings: string[] = [];
|
|
for (const [key, feature] of REQUIRED_WHEN_USED) {
|
|
if (!process.env[key]) {
|
|
warnings.push(` ${key} (${feature})`);
|
|
}
|
|
}
|
|
if (warnings.length > 0) {
|
|
console.warn(
|
|
`[env-validation] The following optional vars are unset — related features will fail at runtime:\n${warnings.join('\n')}`,
|
|
);
|
|
}
|
|
}
|