Updated user-profile.repository.ts to use UUID instead of auth0_sub: - Added getById(id) method for UUID-based lookups - Changed all methods (except getByAuth0Sub, getOrCreate) to accept userId (UUID) instead of auth0Sub - Updated SQL WHERE clauses from auth0_sub to id for UUID-based queries - Fixed cross-table joins in listAllUsers and getUserWithAdminStatus to use user_profile_id - Updated hardDeleteUser to use UUID for all DELETE statements - Updated auth.plugin.ts to call updateEmail and updateEmailVerified with userId (UUID) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
258 lines
9.0 KiB
TypeScript
258 lines
9.0 KiB
TypeScript
/**
|
|
* @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<void>;
|
|
}
|
|
interface FastifyRequest {
|
|
jwtVerify(): Promise<void>;
|
|
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'
|
|
}); |