Files
goodgo-platform/apps/api/src/modules/shared/infrastructure/env-validation.ts
Ho Ngoc Hai 3705193f97 fix(auth): wire dual-key JWT verification into TokenService for WebSocket auth
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>
2026-04-24 14:44:23 +07:00

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')}`,
);
}
}