UNPKG

@theoptimalpartner/jwt-auth-validator

Version:

JWT token validation package with offline JWKS validation and Redis-based token revocation support

475 lines 19.1 kB
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