Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
197 lines
6.4 KiB
TypeScript
197 lines
6.4 KiB
TypeScript
// src/middleware/tsoaAuthentication.ts
|
|
/**
|
|
* @file tsoa Authentication Middleware
|
|
*
|
|
* Provides JWT authentication for tsoa-generated routes.
|
|
* This middleware bridges tsoa's @Security decorators with the existing
|
|
* Passport JWT strategy used throughout the application.
|
|
*
|
|
* tsoa calls this function when a controller method is decorated with:
|
|
* - @Security('bearerAuth') - requires JWT authentication
|
|
*
|
|
* The security names must match those defined in tsoa.json:
|
|
* - bearerAuth: type "http", scheme "bearer", bearerFormat "JWT"
|
|
*
|
|
* @see tsoa.json for security definitions
|
|
* @see src/config/passport.ts for JWT strategy implementation
|
|
*/
|
|
|
|
import { Request } from 'express';
|
|
import * as jwt from 'jsonwebtoken';
|
|
import * as db from '../services/db/index.db';
|
|
import { logger } from '../services/logger.server';
|
|
import type { UserProfile } from '../types';
|
|
|
|
/**
|
|
* Custom error class for authentication failures.
|
|
* tsoa catches errors thrown from expressAuthentication and converts them
|
|
* to appropriate HTTP responses.
|
|
*/
|
|
class AuthenticationError extends Error {
|
|
public status: number;
|
|
|
|
constructor(message: string, status: number = 401) {
|
|
super(message);
|
|
this.name = 'AuthenticationError';
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* JWT payload structure matching the tokens issued by the application.
|
|
* This interface defines what we expect to find in a decoded JWT.
|
|
*/
|
|
interface JwtPayload {
|
|
user_id: string;
|
|
email?: string;
|
|
role?: string;
|
|
iat?: number;
|
|
exp?: number;
|
|
}
|
|
|
|
/**
|
|
* tsoa Authentication Handler.
|
|
*
|
|
* This function is called by tsoa-generated routes when a controller method
|
|
* is decorated with @Security('bearerAuth'). It validates the JWT token
|
|
* and retrieves the user profile from the database.
|
|
*
|
|
* @param request - Express request object
|
|
* @param securityName - Name of the security scheme (must match tsoa.json)
|
|
* @param scopes - Optional array of required scopes (not currently used)
|
|
* @returns Promise resolving to the authenticated UserProfile
|
|
* @throws AuthenticationError when authentication fails
|
|
*
|
|
* @example
|
|
* // In a tsoa controller:
|
|
* @Security('bearerAuth')
|
|
* @Get('profile')
|
|
* public async getProfile(@Request() req: Express.Request): Promise<UserProfile> {
|
|
* // req.user is populated by expressAuthentication
|
|
* return req.user as UserProfile;
|
|
* }
|
|
*/
|
|
export async function expressAuthentication(
|
|
request: Request,
|
|
securityName: string,
|
|
_scopes?: string[],
|
|
): Promise<UserProfile> {
|
|
const requestLog = request.log || logger;
|
|
|
|
// Validate security scheme name
|
|
if (securityName !== 'bearerAuth') {
|
|
requestLog.error({ securityName }, '[tsoa Auth] Unknown security scheme requested');
|
|
throw new AuthenticationError(`Unknown security scheme: ${securityName}`);
|
|
}
|
|
|
|
// Extract JWT token from Authorization header
|
|
const authHeader = request.headers.authorization;
|
|
if (!authHeader) {
|
|
requestLog.debug('[tsoa Auth] No Authorization header present');
|
|
throw new AuthenticationError('No authorization header provided');
|
|
}
|
|
|
|
// Validate Bearer token format
|
|
const parts = authHeader.split(' ');
|
|
if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
|
|
requestLog.debug(
|
|
{ authHeader: authHeader.substring(0, 20) + '...' },
|
|
'[tsoa Auth] Invalid Authorization header format',
|
|
);
|
|
throw new AuthenticationError('Invalid authorization header format. Expected: Bearer <token>');
|
|
}
|
|
|
|
const token = parts[1];
|
|
|
|
// Validate JWT_SECRET is configured
|
|
const jwtSecret = process.env.JWT_SECRET;
|
|
if (!jwtSecret) {
|
|
requestLog.error('[tsoa Auth] JWT_SECRET is not configured');
|
|
throw new AuthenticationError('Server configuration error', 500);
|
|
}
|
|
|
|
try {
|
|
// Verify and decode the JWT
|
|
const decoded = jwt.verify(token, jwtSecret) as JwtPayload;
|
|
|
|
requestLog.debug({ user_id: decoded.user_id }, '[tsoa Auth] JWT token verified successfully');
|
|
|
|
// Validate required claims
|
|
if (!decoded.user_id) {
|
|
requestLog.warn('[tsoa Auth] JWT payload missing user_id claim');
|
|
throw new AuthenticationError('Invalid token: missing user_id');
|
|
}
|
|
|
|
// Fetch user profile from database to ensure user is still valid
|
|
const userProfile = await db.userRepo.findUserProfileById(decoded.user_id, requestLog);
|
|
|
|
if (!userProfile) {
|
|
requestLog.warn({ user_id: decoded.user_id }, '[tsoa Auth] User not found in database');
|
|
throw new AuthenticationError('User not found');
|
|
}
|
|
|
|
requestLog.debug(
|
|
{ user_id: decoded.user_id, role: userProfile.role },
|
|
'[tsoa Auth] User authenticated successfully',
|
|
);
|
|
|
|
// Attach user to request for use in controller methods
|
|
// Note: tsoa also passes the resolved value to the controller
|
|
request.user = userProfile;
|
|
|
|
return userProfile;
|
|
} catch (error) {
|
|
// Handle specific JWT errors
|
|
if (error instanceof jwt.TokenExpiredError) {
|
|
requestLog.debug('[tsoa Auth] JWT token expired');
|
|
throw new AuthenticationError('Token expired');
|
|
}
|
|
|
|
if (error instanceof jwt.JsonWebTokenError) {
|
|
requestLog.debug({ error: error.message }, '[tsoa Auth] JWT verification failed');
|
|
throw new AuthenticationError('Invalid token');
|
|
}
|
|
|
|
// Re-throw AuthenticationError as-is
|
|
if (error instanceof AuthenticationError) {
|
|
throw error;
|
|
}
|
|
|
|
// Log and wrap unexpected errors
|
|
requestLog.error({ error }, '[tsoa Auth] Unexpected error during authentication');
|
|
throw new AuthenticationError('Authentication failed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if a user object is a valid UserProfile.
|
|
* Useful for runtime validation of req.user in controllers.
|
|
*
|
|
* @param user - Object to validate
|
|
* @returns True if the object is a valid UserProfile
|
|
*/
|
|
export function isUserProfile(user: unknown): user is UserProfile {
|
|
return (
|
|
typeof user === 'object' &&
|
|
user !== null &&
|
|
'role' in user &&
|
|
'user' in user &&
|
|
typeof (user as { user: unknown }).user === 'object' &&
|
|
(user as { user: unknown }).user !== null &&
|
|
'user_id' in ((user as { user: unknown }).user as object)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates that the authenticated user has admin role.
|
|
* For use in controller methods that require admin access.
|
|
*
|
|
* @param user - UserProfile from authentication
|
|
* @throws AuthenticationError with 403 status if user is not an admin
|
|
*/
|
|
export function requireAdminRole(user: UserProfile): void {
|
|
if (user.role !== 'admin') {
|
|
throw new AuthenticationError('Forbidden: Administrator access required', 403);
|
|
}
|
|
}
|