/** * @ai-summary Fastify JWT authentication plugin using Auth0 * @ai-context Validates JWT tokens against Auth0 JWKS endpoint, hydrates userContext with profile * @ai-context Includes email verification guard to block unverified users */ import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; import fp from 'fastify-plugin'; import buildGetJwks from 'get-jwks'; import fastifyJwt from '@fastify/jwt'; import { appConfig } from '../config/config-loader'; import { logger } from '../logging/logger'; import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository'; import { pool } from '../config/database'; import { auth0ManagementClient } from '../auth/auth0-management.client'; import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types'; // Routes that don't require email verification const VERIFICATION_EXEMPT_ROUTES = [ '/api/auth/', '/api/onboarding/', '/api/health', '/health', ]; // Define the Auth0 JWT payload type interface Auth0JwtPayload { sub: string; email?: string; name?: string; nickname?: string; 'https://motovaultpro.com/roles'?: string[]; iss?: string; aud?: string | string[]; iat?: number; exp?: number; } // Extend @fastify/jwt module with our payload type declare module '@fastify/jwt' { interface FastifyJWT { payload: Auth0JwtPayload; user: Auth0JwtPayload; } } declare module 'fastify' { interface FastifyInstance { authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; } interface FastifyRequest { jwtVerify(): Promise; userContext?: { userId: string; email?: string; displayName?: string; emailVerified: boolean; onboardingCompleted: boolean; isAdmin: boolean; adminRecord?: any; subscriptionTier: SubscriptionTier; }; } } const authPlugin: FastifyPluginAsync = async (fastify) => { const auth0Config = appConfig.getAuth0Config(); // Security validation: ensure AUTH0_DOMAIN is properly configured if (!auth0Config.domain || !auth0Config.domain.includes('.auth0.com')) { throw new Error('AUTH0_DOMAIN must be a valid Auth0 domain'); } // Initialize JWKS client for Auth0 public key retrieval const getJwks = buildGetJwks({ ttl: 60 * 60 * 1000, // 1 hour cache }); // Register @fastify/jwt with Auth0 JWKS validation await fastify.register(fastifyJwt, { decode: { complete: true }, secret: async (_request: FastifyRequest, token: any) => { try { const { header: { kid, alg }, payload: { iss } } = token; // Validate issuer matches Auth0 domain (security: prevent issuer spoofing) const expectedIssuer = `https://${auth0Config.domain}/`; if (iss !== expectedIssuer) { throw new Error(`Invalid issuer: ${iss}`); } // Get public key from Auth0 JWKS endpoint (security: uses full HTTPS URL) return getJwks.getPublicKey({ kid, domain: expectedIssuer, // Use validated issuer as domain alg }); } catch (error) { logger.error('JWKS key retrieval failed', { error: error instanceof Error ? error.message : 'Unknown error', domain: auth0Config.domain }); throw error; } }, verify: { allowedIss: `https://${auth0Config.domain}/`, allowedAud: auth0Config.audience, }, }); // Initialize profile repository for user profile hydration const profileRepo = new UserProfileRepository(pool); // Helper to check if route is exempt from verification const isVerificationExempt = (url: string): boolean => { return VERIFICATION_EXEMPT_ROUTES.some(route => url.startsWith(route)); }; // Decorate with authenticate function that validates JWT fastify.decorate('authenticate', async function(request: FastifyRequest, reply: FastifyReply) { try { await request.jwtVerify(); // Two identifiers: auth0Sub (external, for Auth0 API) and userId (internal UUID, for all DB operations) const auth0Sub = request.user?.sub; if (!auth0Sub) { throw new Error('Missing user ID in JWT'); } let userId: string = auth0Sub; // Default to auth0Sub; overwritten with UUID after profile load // Get or create user profile from database let email = request.user?.email; let displayName: string | undefined; let emailVerified = false; let onboardingCompleted = false; let subscriptionTier: SubscriptionTier = 'free'; try { // If JWT doesn't have email, fetch from Auth0 Management API if (!email || email.includes('@unknown.local')) { try { const auth0User = await auth0ManagementClient.getUser(auth0Sub); if (auth0User.email) { email = auth0User.email; emailVerified = auth0User.emailVerified; logger.info('Fetched email from Auth0 Management API', { userId: auth0Sub.substring(0, 8) + '...', hasEmail: true, }); } } catch (auth0Error) { logger.warn('Failed to fetch user from Auth0 Management API', { userId: auth0Sub.substring(0, 8) + '...', error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error', }); } } // Get or create profile with correct email const profile = await profileRepo.getOrCreate(auth0Sub, { email: email || `${auth0Sub}@unknown.local`, displayName: request.user?.name || request.user?.nickname, }); userId = profile.id; // If profile has placeholder email but we now have real email, update it if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) { await profileRepo.updateEmail(userId, email); logger.info('Updated profile with correct email from Auth0', { userId: userId.substring(0, 8) + '...', }); } // Use notificationEmail if set, otherwise fall back to profile email email = profile.notificationEmail || profile.email; displayName = profile.displayName || undefined; emailVerified = profile.emailVerified; onboardingCompleted = profile.onboardingCompletedAt !== null; subscriptionTier = profile.subscriptionTier || 'free'; // Sync email verification status from Auth0 if needed if (!emailVerified) { try { const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub); if (isVerifiedInAuth0 && !profile.emailVerified) { await profileRepo.updateEmailVerified(userId, true); emailVerified = true; logger.info('Synced email verification status from Auth0', { userId: userId.substring(0, 8) + '...', }); } } catch (syncError) { // Don't fail auth if sync fails, just log logger.warn('Failed to sync email verification status', { userId: userId.substring(0, 8) + '...', error: syncError instanceof Error ? syncError.message : 'Unknown error', }); } } } catch (profileError) { // Log but don't fail auth if profile fetch fails logger.warn('Failed to fetch user profile', { userId: auth0Sub.substring(0, 8) + '...', error: profileError instanceof Error ? profileError.message : 'Unknown error', }); // Fall back to JWT email if available email = request.user?.email; } // Hydrate userContext with profile data request.userContext = { userId, email, displayName, emailVerified, onboardingCompleted, isAdmin: false, // Default to false; admin status checked by admin guard subscriptionTier, }; // Email verification guard - block unverified users from non-exempt routes if (!emailVerified && !isVerificationExempt(request.url)) { logger.warn('Blocked unverified user from accessing protected route', { userId: userId.substring(0, 8) + '...', path: request.url, }); return reply.code(403).send({ error: 'Email not verified', message: 'Please verify your email address before accessing the application', code: 'EMAIL_NOT_VERIFIED', }); } logger.info('JWT authentication successful', { userId: userId.substring(0, 8) + '...', hasEmail: !!email, emailVerified, audience: auth0Config.audience, }); } catch (error) { logger.warn('JWT authentication failed', { path: request.url, method: request.method, error: error instanceof Error ? error.message : 'Unknown error', }); return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or missing JWT token', }); } }); }; export default fp(authPlugin, { name: 'auth-plugin' });