auth/src/services/jwtKeys.js

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,
};