feat: delete users - not tested
This commit is contained in:
200
backend/src/core/auth/auth0-management.client.ts
Normal file
200
backend/src/core/auth/auth0-management.client.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface UserPreferences {
|
||||
export interface CreateUserPreferencesRequest {
|
||||
userId: string;
|
||||
unitSystem?: UnitSystem;
|
||||
currencyCode?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserPreferencesRequest {
|
||||
|
||||
Reference in New Issue
Block a user