@voilajsx/appkit
Version:
Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development
500 lines • 22.2 kB
JavaScript
/**
* Core authentication class with role-level-permission system
* @module @voilajsx/appkit/auth
* @file src/auth/authentication.ts
*
* @llm-rule WHEN: Building apps that need JWT operations, password hashing, and role-based middleware
* @llm-rule AVOID: Using directly - always get instance via auth.get()
* @llm-rule NOTE: Use requireRole() for hierarchy-based access, requirePermission() for action-specific access
* @llm-rule NOTE: Uses role.level format (user.basic, admin.tenant) with automatic inheritance
*/
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { validateRounds, validateRoleLevel, validatePermission, } from './defaults.js';
/**
* Authentication class with JWT, password, and role-level-permission system
*/
export class AuthenticationClass {
config;
constructor(config) {
this.config = config;
}
/**
* Creates and signs a JWT token with role-level-permission structure
* @llm-rule WHEN: Creating tokens for authenticated users with role-based access
* @llm-rule AVOID: Missing userId, role, or level in payload - token will be invalid
* @llm-rule AVOID: Using {userId, roles: ['admin']} format - use {userId, role: 'admin', level: 'tenant'}
* @llm-rule NOTE: permissions array is optional - defaults are used from role.level config
* @llm-rule NOTE: CORRECT TOKEN STRUCTURE EXAMPLES:
* @llm-rule NOTE: Basic: {userId: 123, role: 'user', level: 'basic', permissions: ['manage:own']}
* @llm-rule NOTE: Admin: {userId: 456, role: 'admin', level: 'tenant', permissions: ['manage:tenant']}
* @llm-rule NOTE: WRONG: {userId: 123, roles: ['admin']} or {userId: 123, role: 'admin.tenant'}
*/
signToken(payload, expiresIn) {
if (!payload || typeof payload !== 'object') {
throw new Error('Payload must be an object');
}
if (!payload.userId) {
throw new Error('Payload must include userId');
}
if (!payload.role || !payload.level) {
throw new Error('Payload must include both role and level');
}
// Validate role.level exists
const roleLevel = `${payload.role}.${payload.level}`;
if (!validateRoleLevel(roleLevel, this.config.roles)) {
throw new Error(`Invalid role.level: "${roleLevel}"`);
}
const jwtSecret = this.config.jwt.secret;
if (!jwtSecret) {
throw new Error('JWT secret required. Set VOILA_AUTH_SECRET environment variable');
}
const tokenExpiration = expiresIn || this.config.jwt.expiresIn;
try {
return jwt.sign(payload, jwtSecret, {
expiresIn: tokenExpiration,
});
}
catch (error) {
throw new Error(`Failed to generate token: ${error.message}`);
}
}
/**
* Verifies and decodes a JWT token
* @llm-rule WHEN: Validating incoming tokens from requests
* @llm-rule AVOID: Using jwt.verify directly - this handles errors and validates structure
*/
verifyToken(token) {
if (!token || typeof token !== 'string') {
throw new Error('Token must be a string');
}
const jwtSecret = this.config.jwt.secret;
if (!jwtSecret) {
throw new Error('JWT secret required. Set VOILA_AUTH_SECRET environment variable');
}
try {
const decoded = jwt.verify(token, jwtSecret, {
algorithms: [this.config.jwt.algorithm],
});
// Validate decoded token has required structure
if (!decoded.role || !decoded.level) {
throw new Error('Token missing required role or level information');
}
return decoded;
}
catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token has expired');
}
if (error.name === 'JsonWebTokenError') {
throw new Error('Invalid token');
}
throw new Error(`Token verification failed: ${error.message}`);
}
}
/**
* Hashes a password using bcrypt
* @llm-rule WHEN: Storing user passwords - always hash before saving to database
* @llm-rule AVOID: Storing plain text passwords - major security vulnerability
* @llm-rule NOTE: Takes ~100ms with default 10 rounds - don't call in tight loops
*/
async hashPassword(password, rounds) {
if (!password || typeof password !== 'string') {
throw new Error('Password must be a non-empty string');
}
const saltRounds = rounds || this.config.password.saltRounds;
validateRounds(saltRounds);
try {
return await bcrypt.hash(password, saltRounds);
}
catch (error) {
throw new Error(`Password hashing failed: ${error.message}`);
}
}
/**
* Compares a password with its hash
* @llm-rule WHEN: Validating user login credentials
* @llm-rule AVOID: Manual string comparison - timing attacks possible
* @llm-rule NOTE: Always returns boolean, never throws on comparison failure
*/
async comparePassword(password, hash) {
if (!password || typeof password !== 'string') {
return false;
}
if (!hash || typeof hash !== 'string') {
return false;
}
try {
return await bcrypt.compare(password, hash);
}
catch (error) {
// bcrypt.compare can fail on malformed hashes
return false;
}
}
/**
* Safely extracts user from request - never crashes
* @llm-rule WHEN: Need to access user data from authenticated requests
* @llm-rule AVOID: Accessing req.user directly - may be undefined and cause crashes
* @llm-rule NOTE: Always returns null for unauthenticated requests - safe to use
* @llm-rule NOTE: Works with both user authentication (req.user) and API tokens (req.token)
*/
user(request) {
if (!request || typeof request !== 'object') {
return null;
}
// Check for user authentication first (login-based)
if (request.user && typeof request.user === 'object' && request.user.userId) {
return request.user;
}
// Check for token authentication (API-based)
if (request.token && typeof request.token === 'object' && request.token.userId) {
return request.token;
}
return null;
}
/**
* Checks if user has specified role with automatic inheritance
* @llm-rule WHEN: Checking if user can access role-protected resources
* @llm-rule AVOID: Manual role comparisons - this handles inheritance automatically
* @llm-rule NOTE: Higher levels inherit lower (admin.org has admin.tenant access)
* @llm-rule NOTE: INHERITANCE EXAMPLES:
* @llm-rule NOTE: auth.hasRole('admin.org', 'admin.tenant') → TRUE (org > tenant)
* @llm-rule NOTE: auth.hasRole('admin.system', 'user.basic') → TRUE (system > basic)
* @llm-rule NOTE: auth.hasRole('user.basic', 'admin.tenant') → FALSE (basic < tenant)
* @llm-rule NOTE: Role hierarchy: admin.system > admin.org > admin.tenant > user.max > user.pro > user.basic
*/
hasRole(userRoleLevel, requiredRoleLevel) {
// INHERITANCE RULE: Higher role levels automatically include lower levels
// Example: admin.org (level 6) includes admin.tenant (level 5) access
if (!userRoleLevel || !requiredRoleLevel) {
return false;
}
if (!validateRoleLevel(userRoleLevel, this.config.roles)) {
return false;
}
if (!validateRoleLevel(requiredRoleLevel, this.config.roles)) {
return false;
}
const userLevel = this.config.roles[userRoleLevel]?.level;
const requiredLevel = this.config.roles[requiredRoleLevel]?.level;
if (userLevel === undefined || requiredLevel === undefined) {
return false;
}
// Higher numeric levels include lower levels
return userLevel >= requiredLevel;
}
/**
* Checks if user has specific permission with automatic action inheritance
* @llm-rule WHEN: Checking fine-grained permissions for specific actions
* @llm-rule AVOID: Hardcoding permission checks - this handles action inheritance
* @llm-rule NOTE: 'manage:scope' includes ALL other actions for that scope
* @llm-rule NOTE: PERMISSION INHERITANCE EXAMPLES:
* @llm-rule NOTE: If user has 'manage:tenant' → can('edit:tenant') returns TRUE
* @llm-rule NOTE: If user has 'manage:tenant' → can('view:tenant') returns TRUE
* @llm-rule NOTE: If user has 'edit:tenant' → can('manage:tenant') returns FALSE
* @llm-rule NOTE: Actions hierarchy: manage > delete > edit > create > view
*/
can(user, permission) {
// PERMISSION INHERITANCE: 'manage:tenant' automatically includes:
// - view:tenant, create:tenant, edit:tenant, delete:tenant
// Example: if user has 'manage:tenant', they can do 'edit:tenant'
if (!user || !permission) {
return false;
}
if (!validatePermission(permission)) {
throw new Error(`Invalid permission format: "${permission}"`);
}
// Check if user has the specific permission
if (user.permissions && Array.isArray(user.permissions)) {
if (user.permissions.includes(permission)) {
return true;
}
// Check for manage permission (includes all other actions)
const [action, scope] = permission.split(':');
if (action !== 'manage') {
const managePermission = `manage:${scope}`;
if (user.permissions.includes(managePermission)) {
return true;
}
}
}
// Fallback: check default permissions for user's role.level
const userRoleLevel = `${user.role}.${user.level}`;
const defaultPermissions = this.config.permissions.defaults[userRoleLevel];
if (defaultPermissions && Array.isArray(defaultPermissions)) {
if (defaultPermissions.includes(permission)) {
return true;
}
// Check for manage permission in defaults
const [action, scope] = permission.split(':');
if (action !== 'manage') {
const managePermission = `manage:${scope}`;
if (defaultPermissions.includes(managePermission)) {
return true;
}
}
}
return false;
}
// ====================================================================
// FRAMEWORK-SPECIFIC MIDDLEWARE
// ====================================================================
// Use requireLogin() for Fastify (async/await pattern)
// Use requireLoginExpress() for Express (callback pattern)
// The framework is auto-detected by the response object type
// ====================================================================
/**
* Creates Fastify-native authentication middleware for login-based routes
* @llm-rule WHEN: Protecting routes that need authenticated users (Fastify framework)
* @llm-rule AVOID: Using without requireRole/requirePermission - this only validates token
* @llm-rule AVOID: Using with Express - use requireLoginExpress() for Express apps
* @llm-rule NOTE: FASTIFY PATTERN: async (request, reply) => Promise<void>
* @llm-rule NOTE: Auto-detects from async handler signature and reply.code() method
*/
requireLogin(options = {}) {
if (!this.config.jwt.secret) {
throw new Error('JWT secret required for authentication middleware');
}
const getToken = options.getToken || this.getDefaultTokenExtractor();
return async (request, reply) => {
try {
const token = getToken(request);
if (!token) {
throw {
statusCode: 401,
error: 'Authentication required',
message: this.config.middleware.errorMessages.noToken,
};
}
const payload = this.verifyToken(token);
request.user = payload;
}
catch (error) {
const isExpired = error.message === 'Token has expired';
const statusCode = error.statusCode || 401;
const message = isExpired
? this.config.middleware.errorMessages.expiredToken
: this.config.middleware.errorMessages.invalidToken;
reply.code(statusCode).send({
error: 'Unauthorized',
message,
});
}
};
}
/**
* Creates Express-native authentication middleware for login-based routes
* @llm-rule WHEN: Protecting routes that need authenticated users (Express framework)
* @llm-rule AVOID: Using without requireRole/requirePermission - this only validates token
* @llm-rule AVOID: Using with Fastify - use requireLogin() for Fastify apps
* @llm-rule NOTE: EXPRESS PATTERN: (req, res, next) => void
* @llm-rule NOTE: Auto-detects from callback signature and res.status() method
*/
requireLoginExpress(options = {}) {
if (!this.config.jwt.secret) {
throw new Error('JWT secret required for authentication middleware');
}
const getToken = options.getToken || this.getDefaultTokenExtractor();
return (req, res, next) => {
try {
const token = getToken(req);
if (!token) {
return res.status(401).json({
error: 'Authentication required',
message: this.config.middleware.errorMessages.noToken,
});
}
const payload = this.verifyToken(token);
req.user = payload;
next();
}
catch (error) {
const isExpired = error.message === 'Token has expired';
const statusCode = 401;
const message = isExpired
? this.config.middleware.errorMessages.expiredToken
: this.config.middleware.errorMessages.invalidToken;
return res.status(statusCode).json({
error: 'Unauthorized',
message,
});
}
};
}
/**
* Creates Fastify role-based authorization middleware
* @llm-rule WHEN: Protecting routes that require specific role.level (Fastify)
* @llm-rule AVOID: Using without requireLogin - this assumes user is already authenticated
* @llm-rule NOTE: Role inheritance applies - admin.org can access admin.tenant routes
*/
requireRole(requiredRoleLevel) {
if (!validateRoleLevel(requiredRoleLevel, this.config.roles)) {
throw new Error(`Invalid role.level for middleware: "${requiredRoleLevel}"`);
}
return async (request, reply) => {
const user = this.user(request);
if (!user) {
reply.code(401).send({
error: 'Authentication required',
message: this.config.middleware.errorMessages.noToken,
});
return;
}
const userRoleLevel = `${user.role}.${user.level}`;
if (!this.hasRole(userRoleLevel, requiredRoleLevel)) {
reply.code(403).send({
error: 'Access denied',
message: this.config.middleware.errorMessages.insufficientRole,
});
return;
}
};
}
/**
* Creates Express role-based authorization middleware
* @llm-rule WHEN: Protecting routes that require specific role.level (Express)
* @llm-rule AVOID: Using without requireLogin - this assumes user is already authenticated
* @llm-rule NOTE: Role inheritance applies - admin.org can access admin.tenant routes
*/
requireRoleExpress(requiredRoleLevel) {
if (!validateRoleLevel(requiredRoleLevel, this.config.roles)) {
throw new Error(`Invalid role.level for middleware: "${requiredRoleLevel}"`);
}
return (req, res, next) => {
const user = this.user(req);
if (!user) {
return res.status(401).json({
error: 'Authentication required',
message: this.config.middleware.errorMessages.noToken,
});
}
const userRoleLevel = `${user.role}.${user.level}`;
if (!this.hasRole(userRoleLevel, requiredRoleLevel)) {
return res.status(403).json({
error: 'Access denied',
message: this.config.middleware.errorMessages.insufficientRole,
});
}
next();
};
}
/**
* Creates Fastify permission-based authorization middleware
* @llm-rule WHEN: Protecting routes that require specific permissions (Fastify)
* @llm-rule AVOID: Using without requireLogin - this assumes user is already authenticated
* @llm-rule NOTE: Permission inheritance applies - manage:tenant can access edit:tenant routes
*/
requirePermission(permission) {
if (!validatePermission(permission)) {
throw new Error(`Invalid permission format for middleware: "${permission}"`);
}
return async (request, reply) => {
const user = this.user(request);
if (!user) {
reply.code(401).send({
error: 'Authentication required',
message: this.config.middleware.errorMessages.noToken,
});
return;
}
if (!this.can(user, permission)) {
reply.code(403).send({
error: 'Access denied',
message: this.config.middleware.errorMessages.insufficientPermissions,
});
return;
}
};
}
/**
* Creates Express permission-based authorization middleware
* @llm-rule WHEN: Protecting routes that require specific permissions (Express)
* @llm-rule AVOID: Using without requireLogin - this assumes user is already authenticated
* @llm-rule NOTE: Permission inheritance applies - manage:tenant can access edit:tenant routes
*/
requirePermissionExpress(permission) {
if (!validatePermission(permission)) {
throw new Error(`Invalid permission format for middleware: "${permission}"`);
}
return (req, res, next) => {
const user = this.user(req);
if (!user) {
return res.status(401).json({
error: 'Authentication required',
message: this.config.middleware.errorMessages.noToken,
});
}
if (!this.can(user, permission)) {
return res.status(403).json({
error: 'Access denied',
message: this.config.middleware.errorMessages.insufficientPermissions,
});
}
next();
};
}
/**
* Creates API token authentication middleware for service-to-service communication
* @llm-rule WHEN: Protecting API routes that need token-based authentication
* @llm-rule AVOID: Using for user-facing routes - use requireLogin instead
* @llm-rule NOTE: Sets req.token instead of req.user for API authentication
*/
requireToken(options = {}) {
if (!this.config.jwt.secret) {
throw new Error('JWT secret required for token authentication middleware');
}
const getToken = options.getToken || this.getDefaultTokenExtractor();
return async (request, reply) => {
try {
const token = getToken(request);
if (!token) {
throw {
statusCode: 401,
error: 'API token required',
message: 'API token required for this endpoint',
};
}
const payload = this.verifyToken(token);
request.token = payload;
}
catch (error) {
const statusCode = error.statusCode || 401;
const message = error.message === 'Token has expired'
? 'API token has expired'
: 'Invalid API token';
reply.code(statusCode).send({
error: 'Unauthorized',
message,
});
}
};
}
/**
* Gets default token extractor that checks headers, cookies, and query params
* @llm-rule WHEN: Need custom token extraction logic
* @llm-rule AVOID: Modifying directly - pass custom getToken to middleware options
*/
getDefaultTokenExtractor() {
return (request) => {
// Check Authorization header (Bearer token)
const authHeader = request.headers.authorization;
if (authHeader && typeof authHeader === 'string') {
const match = authHeader.match(/^Bearer\s+(.+)$/);
if (match) {
return match[1];
}
}
// Check cookies
if (request.cookies?.token) {
return request.cookies.token;
}
// Check query parameter
if (request.query?.token && typeof request.query.token === 'string') {
return request.query.token;
}
return null;
};
}
}
//# sourceMappingURL=auth.js.map