193 lines
4.6 KiB
JavaScript
193 lines
4.6 KiB
JavaScript
// src/services/jwtKeys.js
|
|
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
|
// JWT key management with support for key rotation and multiple signing keys
|
|
|
|
/**
|
|
* JWT Key Management
|
|
*
|
|
* Supports:
|
|
* - Multiple signing keys with key IDs (kid)
|
|
* - Active key for signing new tokens
|
|
* - Multiple verification keys for token rotation
|
|
* - Key rotation without breaking existing tokens
|
|
*
|
|
* Configuration:
|
|
* - JWT_ACTIVE_KEY_ID: The key ID to use for signing new tokens (default: '1')
|
|
* - JWT_KEYS_JSON: JSON object mapping key IDs to secrets
|
|
* Example: {"1": "secret1", "2": "secret2"}
|
|
* - JWT_ISSUER: Issuer claim (iss) for tokens (default: 'farm-auth-service')
|
|
* - JWT_AUDIENCE: Audience claim (aud) for tokens (default: 'mobile-app')
|
|
*
|
|
* TODO: In production, load keys from a secrets manager (AWS Secrets Manager,
|
|
* HashiCorp Vault, etc.) instead of environment variables
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
|
|
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
|
// Load keys from environment
|
|
const ACTIVE_KEY_ID = process.env.JWT_ACTIVE_KEY_ID || '1';
|
|
const JWT_ISSUER = process.env.JWT_ISSUER || 'farm-auth-service';
|
|
const JWT_AUDIENCE = process.env.JWT_AUDIENCE || 'mobile-app';
|
|
|
|
// Parse keys from environment
|
|
// Format: JWT_KEYS_JSON='{"1":"secret1","2":"secret2"}'
|
|
// OR use legacy single keys: JWT_ACCESS_SECRET, JWT_REFRESH_SECRET
|
|
let keys = {};
|
|
|
|
function loadKeys() {
|
|
// Try new format first (JWT_KEYS_JSON)
|
|
if (process.env.JWT_KEYS_JSON) {
|
|
try {
|
|
keys = JSON.parse(process.env.JWT_KEYS_JSON);
|
|
} catch (err) {
|
|
console.error('Failed to parse JWT_KEYS_JSON:', err);
|
|
keys = {};
|
|
}
|
|
}
|
|
|
|
// Fallback to legacy format for backward compatibility
|
|
if (Object.keys(keys).length === 0) {
|
|
if (process.env.JWT_ACCESS_SECRET) {
|
|
keys['1'] = process.env.JWT_ACCESS_SECRET;
|
|
}
|
|
if (process.env.JWT_REFRESH_SECRET && !keys['1']) {
|
|
keys['1'] = process.env.JWT_REFRESH_SECRET;
|
|
}
|
|
}
|
|
|
|
// Ensure we have at least one key
|
|
if (Object.keys(keys).length === 0) {
|
|
throw new Error('No JWT keys configured. Set JWT_KEYS_JSON or JWT_ACCESS_SECRET');
|
|
}
|
|
|
|
// Ensure active key exists
|
|
if (!keys[ACTIVE_KEY_ID]) {
|
|
console.warn(`Active key ID ${ACTIVE_KEY_ID} not found, using first available key`);
|
|
const firstKeyId = Object.keys(keys)[0];
|
|
return { keys, activeKeyId: firstKeyId };
|
|
}
|
|
|
|
return { keys, activeKeyId: ACTIVE_KEY_ID };
|
|
}
|
|
|
|
const { keys: loadedKeys, activeKeyId } = loadKeys();
|
|
|
|
/**
|
|
* Get the secret for a specific key ID
|
|
*/
|
|
function getKeySecret(keyId) {
|
|
return loadedKeys[keyId] || null;
|
|
}
|
|
|
|
/**
|
|
* Get the active key ID and secret for signing new tokens
|
|
*/
|
|
function getActiveKey() {
|
|
return {
|
|
keyId: activeKeyId,
|
|
secret: loadedKeys[activeKeyId],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all keys (for verification during rotation)
|
|
*/
|
|
function getAllKeys() {
|
|
return loadedKeys;
|
|
}
|
|
|
|
/**
|
|
* Get issuer claim
|
|
*/
|
|
function getIssuer() {
|
|
return JWT_ISSUER;
|
|
}
|
|
|
|
/**
|
|
* Get audience claim
|
|
*/
|
|
function getAudience() {
|
|
return JWT_AUDIENCE;
|
|
}
|
|
|
|
/**
|
|
* Validate JWT claims (iss, aud, exp, iat, nbf)
|
|
*/
|
|
function validateTokenClaims(payload, options = {}) {
|
|
const {
|
|
allowExpired = false,
|
|
clockSkewSeconds = 60, // Allow 60 seconds clock skew
|
|
} = options;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const skew = clockSkewSeconds;
|
|
|
|
// Validate issuer
|
|
if (payload.iss && payload.iss !== JWT_ISSUER) {
|
|
return { valid: false, reason: 'invalid_issuer' };
|
|
}
|
|
|
|
// Validate audience
|
|
if (payload.aud && payload.aud !== JWT_AUDIENCE) {
|
|
return { valid: false, reason: 'invalid_audience' };
|
|
}
|
|
|
|
// Validate expiration
|
|
if (payload.exp) {
|
|
if (!allowExpired && payload.exp < now - skew) {
|
|
return { valid: false, reason: 'expired' };
|
|
}
|
|
}
|
|
|
|
// Validate issued at (should not be in the future beyond clock skew)
|
|
if (payload.iat) {
|
|
if (payload.iat > now + skew) {
|
|
return { valid: false, reason: 'issued_in_future' };
|
|
}
|
|
}
|
|
|
|
// Validate not before (if present)
|
|
if (payload.nbf) {
|
|
if (payload.nbf > now + skew) {
|
|
return { valid: false, reason: 'not_yet_valid' };
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* TODO: Load keys from secrets manager
|
|
* Example implementation for AWS Secrets Manager:
|
|
*
|
|
* async function loadKeysFromSecretsManager() {
|
|
* const AWS = require('aws-sdk');
|
|
* const secretsManager = new AWS.SecretsManager();
|
|
*
|
|
* const secret = await secretsManager.getSecretValue({
|
|
* SecretId: process.env.JWT_SECRETS_ARN
|
|
* }).promise();
|
|
*
|
|
* return JSON.parse(secret.SecretString);
|
|
* }
|
|
*/
|
|
|
|
module.exports = {
|
|
getKeySecret,
|
|
getActiveKey,
|
|
getAllKeys,
|
|
getIssuer,
|
|
getAudience,
|
|
validateTokenClaims,
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|