feat: delete users - not tested

This commit is contained in:
Eric Gullickson
2025-12-22 18:20:25 -06:00
parent 91b4534e76
commit 4897f0a52c
73 changed files with 4923 additions and 62 deletions

View File

@@ -0,0 +1,200 @@
/**
* Auth0 Management API Client
* Provides methods for user management operations via Auth0 Management API
*/
import { ManagementClient } from 'auth0';
import { appConfig } from '../config/config-loader';
import { logger } from '../logging/logger';
interface CreateUserParams {
email: string;
password: string;
}
interface UserDetails {
userId: string;
email: string;
emailVerified: boolean;
}
class Auth0ManagementClientSingleton {
private client: ManagementClient | null = null;
private readonly CONNECTION_NAME = 'Username-Password-Authentication';
/**
* Lazy initialization of ManagementClient to avoid startup issues
*/
private getClient(): ManagementClient {
if (!this.client) {
const config = appConfig.getAuth0ManagementConfig();
this.client = new ManagementClient({
domain: config.domain,
clientId: config.clientId,
clientSecret: config.clientSecret,
});
logger.info('Auth0 Management API client initialized');
}
return this.client;
}
/**
* Create a new user in Auth0
* @param email User's email address
* @param password User's password
* @returns Auth0 user ID
*/
async createUser({ email, password }: CreateUserParams): Promise<string> {
try {
const client = this.getClient();
const response = await client.users.create({
connection: this.CONNECTION_NAME,
email,
password,
email_verified: false,
});
const user = response.data;
if (!user.user_id) {
throw new Error('Auth0 did not return a user_id');
}
logger.info('User created in Auth0', { userId: user.user_id, email });
return user.user_id;
} catch (error) {
logger.error('Failed to create user in Auth0', { email, error });
throw new Error(`Auth0 user creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get user details from Auth0
* @param userId Auth0 user ID (format: auth0|xxx or google-oauth2|xxx)
* @returns User details including email and verification status
*/
async getUser(userId: string): Promise<UserDetails> {
try {
const client = this.getClient();
const response = await client.users.get({ id: userId });
const user = response.data;
if (!user.email) {
throw new Error('User email not found in Auth0 response');
}
logger.info('Retrieved user from Auth0', { userId, email: user.email });
return {
userId: user.user_id || userId,
email: user.email,
emailVerified: user.email_verified || false,
};
} catch (error) {
logger.error('Failed to get user from Auth0', { userId, error });
throw new Error(`Auth0 user retrieval failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Resend email verification to user
* @param userId Auth0 user ID
*/
async resendVerificationEmail(userId: string): Promise<void> {
try {
const client = this.getClient();
await client.jobs.verifyEmail({
user_id: userId,
});
logger.info('Verification email sent', { userId });
} catch (error) {
logger.error('Failed to send verification email', { userId, error });
throw new Error(`Failed to send verification email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Check if user's email is verified
* @param userId Auth0 user ID
* @returns true if email is verified, false otherwise
*/
async checkEmailVerified(userId: string): Promise<boolean> {
try {
const user = await this.getUser(userId);
return user.emailVerified;
} catch (error) {
logger.error('Failed to check email verification status', { userId, error });
throw new Error(`Failed to check email verification: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Delete a user from Auth0 (permanent deletion)
* @param userId Auth0 user ID (format: auth0|xxx or google-oauth2|xxx)
*/
async deleteUser(userId: string): Promise<void> {
try {
const client = this.getClient();
await client.users.delete({ id: userId });
logger.info('User deleted from Auth0', { userId });
} catch (error) {
logger.error('Failed to delete user from Auth0', { userId, error });
throw new Error(`Auth0 user deletion failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Verify user password using Resource Owner Password Grant
* @param email User's email address
* @param password User's password to verify
* @returns true if password is valid, false otherwise
*/
async verifyPassword(email: string, password: string): Promise<boolean> {
try {
const config = appConfig.getAuth0ManagementConfig();
const response = await fetch(`https://${config.domain}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'password',
username: email,
password,
client_id: config.clientId,
client_secret: config.clientSecret,
audience: `https://${config.domain}/api/v2/`,
scope: 'openid profile email',
}),
});
if (response.ok) {
logger.info('Password verification successful', { email });
return true;
}
if (response.status === 403 || response.status === 401) {
logger.info('Password verification failed - invalid credentials', { email });
return false;
}
const errorBody = await response.text();
logger.error('Password verification request failed', { email, status: response.status, error: errorBody });
throw new Error(`Password verification failed with status ${response.status}`);
} catch (error) {
logger.error('Failed to verify password', { email, error });
throw new Error(`Password verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
// Export singleton instance
export const auth0ManagementClient = new Auth0ManagementClientSingleton();

View File

@@ -122,6 +122,8 @@ const configSchema = z.object({
const secretsSchema = z.object({
postgres_password: z.string(),
auth0_client_secret: z.string(),
auth0_management_client_id: z.string(),
auth0_management_client_secret: z.string(),
google_maps_api_key: z.string(),
resend_api_key: z.string(),
});
@@ -137,6 +139,7 @@ export interface AppConfiguration {
getDatabaseUrl(): string;
getRedisUrl(): string;
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
}
class ConfigurationLoader {
@@ -171,6 +174,8 @@ class ConfigurationLoader {
const secretFiles = [
'postgres-password',
'auth0-client-secret',
'auth0-management-client-id',
'auth0-management-client-secret',
'google-maps-api-key',
'resend-api-key',
];
@@ -227,6 +232,14 @@ class ConfigurationLoader {
clientSecret: secrets.auth0_client_secret,
};
},
getAuth0ManagementConfig() {
return {
domain: config.auth0.domain,
clientId: secrets.auth0_management_client_id,
clientSecret: secrets.auth0_management_client_secret,
};
},
};
// Set RESEND_API_KEY in environment for EmailService

View File

@@ -1,6 +1,7 @@
/**
* @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';
@@ -10,6 +11,15 @@ 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';
// 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 {
@@ -42,6 +52,8 @@ declare module 'fastify' {
userId: string;
email?: string;
displayName?: string;
emailVerified: boolean;
onboardingCompleted: boolean;
isAdmin: boolean;
adminRecord?: any;
};
@@ -97,31 +109,91 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
// 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();
const userId = request.user?.sub;
if (!userId) {
throw new Error('Missing user ID in JWT');
}
// Get or create user profile from database
// This ensures we have reliable email/displayName for notifications
let email = request.user?.email;
let displayName: string | undefined;
let emailVerified = false;
let onboardingCompleted = false;
try {
// If JWT doesn't have email, fetch from Auth0 Management API
if (!email || email.includes('@unknown.local')) {
try {
const auth0User = await auth0ManagementClient.getUser(userId);
if (auth0User.email) {
email = auth0User.email;
emailVerified = auth0User.emailVerified;
logger.info('Fetched email from Auth0 Management API', {
userId: userId.substring(0, 8) + '...',
hasEmail: true,
});
}
} catch (auth0Error) {
logger.warn('Failed to fetch user from Auth0 Management API', {
userId: userId.substring(0, 8) + '...',
error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error',
});
}
}
// Get or create profile with correct email
const profile = await profileRepo.getOrCreate(userId, {
email: request.user?.email || `${userId}@unknown.local`,
email: email || `${userId}@unknown.local`,
displayName: request.user?.name || request.user?.nickname,
});
// 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;
// Sync email verification status from Auth0 if needed
if (!emailVerified) {
try {
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId);
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: userId?.substring(0, 8) + '...',
userId: userId.substring(0, 8) + '...',
error: profileError instanceof Error ? profileError.message : 'Unknown error',
});
// Fall back to JWT email if available
@@ -133,13 +205,29 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
userId,
email,
displayName,
emailVerified,
onboardingCompleted,
isAdmin: false, // Default to false; admin status checked by admin guard
};
// 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) + '...',
userId: userId.substring(0, 8) + '...',
hasEmail: !!email,
audience: auth0Config.audience
emailVerified,
audience: auth0Config.audience,
});
} catch (error) {
logger.warn('JWT authentication failed', {
@@ -148,9 +236,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
error: error instanceof Error ? error.message : 'Unknown error',
});
reply.code(401).send({
return reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or missing JWT token'
message: 'Invalid or missing JWT token',
});
}
});

View File

@@ -6,6 +6,7 @@
import cron from 'node-cron';
import { logger } from '../logging/logger';
import { processScheduledNotifications } from '../../features/notifications/jobs/notification-processor.job';
import { processAccountPurges } from '../../features/user-profile/jobs/account-purge.job';
let schedulerInitialized = false;
@@ -29,8 +30,25 @@ export function initializeScheduler(): void {
}
});
// Daily account purge job at 2 AM (GDPR compliance - 30-day grace period)
cron.schedule('0 2 * * *', async () => {
logger.info('Running account purge job');
try {
const result = await processAccountPurges();
logger.info('Account purge job completed successfully', {
processed: result.processed,
deleted: result.deleted,
errors: result.errors.length,
});
} catch (error) {
logger.error('Account purge job failed', {
error: error instanceof Error ? error.message : String(error)
});
}
});
schedulerInitialized = true;
logger.info('Cron scheduler initialized - notification job scheduled for 8 AM daily');
logger.info('Cron scheduler initialized - notification job (8 AM) and account purge job (2 AM) scheduled daily');
}
export function isSchedulerInitialized(): boolean {

View File

@@ -26,12 +26,12 @@ export class UserPreferencesRepository {
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const values = [
data.userId,
data.unitSystem || 'imperial',
(data as any).currencyCode || 'USD',
(data as any).timeZone || 'UTC'
data.currencyCode || 'USD',
data.timeZone || 'UTC'
];
const result = await this.db.query(query, values);
@@ -47,13 +47,13 @@ export class UserPreferencesRepository {
fields.push(`unit_system = $${paramCount++}`);
values.push(data.unitSystem);
}
if ((data as any).currencyCode !== undefined) {
if (data.currencyCode !== undefined) {
fields.push(`currency_code = $${paramCount++}`);
values.push((data as any).currencyCode);
values.push(data.currencyCode);
}
if ((data as any).timeZone !== undefined) {
if (data.timeZone !== undefined) {
fields.push(`time_zone = $${paramCount++}`);
values.push((data as any).timeZone);
values.push(data.timeZone);
}
if (fields.length === 0) {
@@ -61,12 +61,12 @@ export class UserPreferencesRepository {
}
const query = `
UPDATE user_preferences
UPDATE user_preferences
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $${paramCount}
RETURNING *
`;
values.push(userId);
const result = await this.db.query(query, values);
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;

View File

@@ -18,6 +18,8 @@ export interface UserPreferences {
export interface CreateUserPreferencesRequest {
userId: string;
unitSystem?: UnitSystem;
currencyCode?: string;
timeZone?: string;
}
export interface UpdateUserPreferencesRequest {