@theoptimalpartner/jwt-auth-validator
Version:
JWT token validation package with offline JWKS validation and Redis-based token revocation support
475 lines • 19.1 kB
JavaScript
import * as jose from 'jose';
import { JWKSService } from './jwks-service.js';
import { TokenBlacklistService } from './token-blacklist-service.js';
import { RedisService } from './redis-service.js';
import { ApiKeyValidator } from './api-key-validator.js';
import { UserDataService } from './user-data-service.js';
import { logError, getUserFriendlyErrorMessage, JWT_ERROR_MESSAGES } from './error-utils.js';
function createValidationResult(valid, decoded, error, apiKey) {
const result = { valid };
if (decoded !== undefined)
result.decoded = decoded;
if (error !== undefined)
result.error = error;
if (apiKey !== undefined)
result.apiKey = apiKey;
return result;
}
export class JWTValidator {
jwksService;
blacklistService;
apiKeyValidator;
userDataService;
redisService;
config;
initialized = false;
constructor(config) {
this.config = config;
this.jwksService = new JWKSService();
const needsRedis = config.enableRedisBlacklist || config.enableApiKeyValidation ||
config.enableUserDataRetrieval || config.userData?.enableUserDataRetrieval;
if (needsRedis && config.redis) {
this.redisService = new RedisService(config.redis);
if (config.enableRedisBlacklist) {
this.blacklistService = new TokenBlacklistService(this.redisService);
}
if (config.enableApiKeyValidation) {
this.apiKeyValidator = new ApiKeyValidator(this.redisService);
}
if (config.enableUserDataRetrieval || config.userData?.enableUserDataRetrieval) {
const userDataConfig = config.userData || {};
if (config.enableUserDataRetrieval) {
userDataConfig.enableUserDataRetrieval = true;
}
this.userDataService = new UserDataService(config.redis, userDataConfig);
}
}
}
async initialize() {
if (this.initialized)
return;
try {
this.jwksService.initialize(this.config.jwks);
if (this.redisService) {
await this.redisService.initialize();
if (this.blacklistService) {
this.blacklistService.initialize(this.redisService);
}
if (this.userDataService) {
await this.userDataService.initialize();
}
}
this.initialized = true;
}
catch (error) {
logError(error, 'JWT Validator initialization failed');
throw new Error(JWT_ERROR_MESSAGES.INITIALIZATION_FAILED);
}
}
async validate(token, options = {}) {
try {
if (!this.initialized) {
await this.initialize();
}
const { apiKey, forceSecure = false, enrichUserData = true, requireAppAccess = false } = options;
let apiKeyData;
if (apiKey && this.config.enableApiKeyValidation && this.apiKeyValidator) {
const apiKeyResult = await this.apiKeyValidator.validateApiKey(apiKey);
if (!apiKeyResult.valid) {
return {
valid: false,
error: apiKeyResult.error || 'API key validation failed',
};
}
apiKeyData = apiKeyResult.keyData;
}
const useSecureValidation = forceSecure || this.config.forceSecureValidation || process.env.NODE_ENV === 'production';
let tokenResult;
if (useSecureValidation) {
tokenResult = await this.validateTokenSecure(token);
}
else {
console.warn('Using basic token validation. This should not be used in production!');
tokenResult = await this.validateTokenBasic(token);
}
if (!tokenResult.valid || !tokenResult.decoded) {
return tokenResult;
}
if (apiKeyData) {
const appId = apiKeyData.appId || apiKeyData.metadata?.appId;
if (appId || requireAppAccess) {
if (!appId && requireAppAccess) {
return {
valid: false,
error: 'API key is not associated with an application',
};
}
if (appId) {
if (this.apiKeyValidator && !this.apiKeyValidator.canAccessApp(apiKeyData, appId)) {
return {
valid: false,
error: `API key does not have access to application ${appId}`,
};
}
if (apiKeyData.scope !== 'system') {
const userId = tokenResult.decoded.sub;
if (!userId) {
return {
valid: false,
error: 'Token missing user ID (sub claim)',
};
}
if (this.userDataService) {
try {
const userApplications = await this.userDataService.getUserApplications(userId);
const hasAccess = userApplications.some(app => app.appId === appId);
if (!hasAccess) {
return {
valid: false,
error: `User does not have access to application ${appId}`,
};
}
}
catch (userDataError) {
if (requireAppAccess) {
return {
valid: false,
error: 'Could not verify application access',
};
}
console.warn('Could not verify user application access, proceeding:', userDataError);
}
}
else if (requireAppAccess) {
return {
valid: false,
error: 'User data service not available for application access verification',
};
}
}
}
}
}
const enrichedResult = {
...tokenResult,
};
if (apiKeyData !== undefined) {
enrichedResult.apiKey = apiKeyData;
}
if (enrichUserData && this.config.userData?.enableUserDataRetrieval && this.userDataService) {
const userId = tokenResult.decoded.sub;
if (userId) {
try {
const userData = await this.userDataService.getComprehensiveUserData(userId);
enrichedResult.userPermissions = userData.permissions;
enrichedResult.userOrganizations = userData.organizations;
enrichedResult.applications = userData.applications;
}
catch (userDataError) {
console.warn('Failed to retrieve user data, returning basic validation result:', userDataError);
}
}
}
return enrichedResult;
}
catch (error) {
logError(error, 'Token validation');
return createValidationResult(false, undefined, getUserFriendlyErrorMessage(error));
}
}
async validateToken(token, forceSecure = false) {
const result = await this.validate(token, { forceSecure, enrichUserData: false });
return createValidationResult(result.valid, result.decoded, result.error);
}
async validateWithApiKey(token, apiKey, options = {}) {
const validateOptions = { apiKey, enrichUserData: options.enrichUserData ?? true };
if (options.forceSecure !== undefined) {
validateOptions.forceSecure = options.forceSecure;
}
return this.validate(token, validateOptions);
}
async validateWithAppAccess(token, apiKey, options = {}) {
const validateOptions = {
apiKey,
enrichUserData: options.enrichUserData ?? true,
requireAppAccess: true
};
if (options.forceSecure !== undefined) {
validateOptions.forceSecure = options.forceSecure;
}
return this.validate(token, validateOptions);
}
async validateEnriched(token, apiKey, options = {}) {
const validateOptions = { enrichUserData: true };
if (apiKey !== undefined) {
validateOptions.apiKey = apiKey;
}
if (options.forceSecure !== undefined) {
validateOptions.forceSecure = options.forceSecure;
}
return this.validate(token, validateOptions);
}
async validateTokenEnriched(token, apiKey, forceSecure = false) {
console.warn('validateTokenEnriched is deprecated. Use validateEnriched() instead.');
return this.validateEnriched(token, apiKey, { forceSecure });
}
async validateTokenWithApiKey(token, apiKey, forceSecure = false) {
console.warn('validateTokenWithApiKey is deprecated. Use validate() or validateWithApiKey() instead.');
const validateOptions = { enrichUserData: false, forceSecure };
if (apiKey !== undefined) {
validateOptions.apiKey = apiKey;
}
const result = await this.validate(token, validateOptions);
return createValidationResult(result.valid, result.decoded, result.error, result.apiKey);
}
async validateTokenWithAppId(token, apiKey, forceSecure = false) {
console.warn('validateTokenWithAppId is deprecated. Use validateWithAppAccess() instead.');
const result = await this.validateWithAppAccess(token, apiKey, { forceSecure, enrichUserData: false });
return createValidationResult(result.valid, result.decoded, result.error, result.apiKey);
}
async validateTokenEnhanced(token, apiKey, forceSecure = false) {
console.warn('validateTokenEnhanced is deprecated. Use validate() instead.');
const validateOptions = { enrichUserData: false, forceSecure };
if (apiKey !== undefined) {
validateOptions.apiKey = apiKey;
}
const result = await this.validate(token, validateOptions);
return createValidationResult(result.valid, result.decoded, result.error, result.apiKey);
}
async validateTokenSecure(token) {
try {
if (!this.initialized) {
await this.initialize();
}
if (this.blacklistService) {
const isBlacklisted = await this.blacklistService.isBlacklisted(token);
if (isBlacklisted) {
return {
valid: false,
error: 'Token has been revoked',
};
}
}
const decoded = await this.jwksService.validateCognitoToken(token);
return {
valid: true,
decoded: decoded,
};
}
catch (error) {
logError(error, 'Secure token validation');
return {
valid: false,
error: getUserFriendlyErrorMessage(error),
};
}
}
async validateTokenBasic(token) {
try {
if (this.blacklistService) {
const isBlacklisted = await this.blacklistService.isBlacklisted(token);
if (isBlacklisted) {
return {
valid: false,
error: 'Token has been revoked',
};
}
}
const decoded = jose.decodeJwt(token);
if (!decoded) {
return { valid: false, error: 'Invalid token format' };
}
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
return { valid: false, error: 'Token expired' };
}
if (!decoded.sub) {
return { valid: false, error: 'Token missing sub claim' };
}
return { valid: true, decoded };
}
catch {
return { valid: false, error: 'Token validation failed' };
}
}
async validateAccessToken(token) {
const result = await this.validateToken(token);
if (!result.valid || !result.decoded) {
return result;
}
if (result.decoded.token_use !== 'access') {
return {
valid: false,
error: `Invalid token type. Expected 'access', got '${result.decoded.token_use || 'unknown'}'`,
};
}
return result;
}
async validateIdToken(token) {
const result = await this.validateToken(token);
if (!result.valid || !result.decoded) {
return result;
}
if (result.decoded.token_use !== 'id') {
return {
valid: false,
error: `Invalid token type. Expected 'id', got '${result.decoded.token_use || 'unknown'}'`,
};
}
return result;
}
extractTokenFromHeader(authHeader) {
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
return authHeader.substring(7);
}
extractApiKeyFromHeader(apiKeyHeader) {
if (!apiKeyHeader) {
return null;
}
return apiKeyHeader.trim();
}
extractApiKeyFromHeaders(headers) {
const apiKeyHeaders = ['x-api-key', 'X-API-Key', 'X-API-KEY', 'X-Api-Key'];
for (const headerName of apiKeyHeaders) {
const value = headers[headerName];
if (value) {
return this.extractApiKeyFromHeader(value);
}
}
return null;
}
getTokenInfo(token) {
try {
return jose.decodeJwt(token);
}
catch {
return null;
}
}
isTokenExpired(token) {
try {
const decoded = jose.decodeJwt(token);
if (!decoded?.exp)
return true;
const currentTime = Math.floor(Date.now() / 1000);
return decoded.exp < currentTime;
}
catch {
return true;
}
}
getTimeToExpiry(token) {
try {
const decoded = jose.decodeJwt(token);
if (!decoded?.exp)
return 0;
const currentTime = Math.floor(Date.now() / 1000);
return Math.max(0, decoded.exp - currentTime);
}
catch {
return 0;
}
}
async validateMultipleTokens(tokens) {
const promises = tokens.map((token) => this.validateToken(token));
return await Promise.all(promises);
}
decodeToken(token) {
try {
return jose.decodeJwt(token);
}
catch {
return null;
}
}
async revokeToken(token) {
if (!this.blacklistService) {
throw new Error('Redis blacklist not enabled');
}
const decoded = this.decodeToken(token);
if (!decoded?.exp) {
throw new Error('Cannot revoke token without expiration');
}
await this.blacklistService.addToBlacklist(token, decoded.exp);
}
async revokeUserTokens(userId, tokens) {
if (!this.blacklistService) {
throw new Error('Redis blacklist not enabled');
}
await this.blacklistService.invalidateUserTokens(userId, tokens);
}
getCacheStats() {
return this.jwksService.getCacheStats();
}
async getBlacklistStats() {
if (!this.blacklistService) {
return null;
}
return await this.blacklistService.getStats();
}
getClientSecret() {
return this.config.jwks.clientSecret;
}
hasClientSecret() {
return Boolean(this.config.jwks.clientSecret && this.config.jwks.clientSecret.trim().length > 0);
}
calculateSecretHash(identifier) {
if (!this.config.jwks.clientSecret || !this.config.jwks.audience) {
return "";
}
const { safeCalculateSecretHash } = require('./cognito-utils.js');
return safeCalculateSecretHash(identifier, this.config.jwks.audience, this.config.jwks.clientSecret);
}
async getUserPermissions(userId) {
if (!this.userDataService) {
throw new Error('User data service not enabled. Enable userData.enableUserDataRetrieval in config');
}
return await this.userDataService.getUserPermissions(userId);
}
async getUserOrganizations(userId) {
if (!this.userDataService) {
throw new Error('User data service not enabled. Enable userData.enableUserDataRetrieval in config');
}
return await this.userDataService.getUserOrganizations(userId);
}
async getUserApplications(userId) {
if (!this.userDataService) {
throw new Error('User data service not enabled. Enable userData.enableUserDataRetrieval in config');
}
return await this.userDataService.getUserApplications(userId);
}
async getComprehensiveUserData(userId) {
if (!this.userDataService) {
throw new Error('User data service not enabled. Enable userData.enableUserDataRetrieval in config');
}
return await this.userDataService.getComprehensiveUserData(userId);
}
async getUserDataStats() {
if (!this.userDataService) {
return null;
}
return this.userDataService.getStats();
}
clearUserCache(userId) {
if (this.userDataService) {
this.userDataService.clearUserCache(userId);
}
}
isUserDataEnabled() {
return Boolean(this.userDataService && this.userDataService.isInitialized());
}
async disconnect() {
if (this.userDataService) {
await this.userDataService.shutdown();
}
if (this.redisService) {
await this.redisService.disconnect();
}
}
diagnoseToken(token) {
return this.jwksService.diagnoseTokenIssues(token);
}
}
//# sourceMappingURL=jwt-validator.js.map