All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m38s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 28s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
- Add terms_agreements table for legal audit trail - Create terms-agreement feature capsule with repository - Modify signup to create terms agreement atomically - Add checkbox with PDF link to SignupForm - Capture IP, User-Agent, terms version, content hash - Update CLAUDE.md documentation index 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
/**
|
|
* @ai-summary Auth service business logic
|
|
* @ai-context Coordinates between Auth0 Management API and local user profile database
|
|
*/
|
|
|
|
import { auth0ManagementClient } from '../../../core/auth/auth0-management.client';
|
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
|
import { TermsAgreementRepository } from '../../terms-agreement/data/terms-agreement.repository';
|
|
import { pool } from '../../../core/config/database';
|
|
import { logger } from '../../../core/logging/logger';
|
|
import {
|
|
SignupRequest,
|
|
SignupResponse,
|
|
VerifyStatusResponse,
|
|
ResendVerificationResponse,
|
|
SecurityStatusResponse,
|
|
PasswordResetResponse,
|
|
TermsData,
|
|
} from './auth.types';
|
|
|
|
export class AuthService {
|
|
constructor(
|
|
private userProfileRepository: UserProfileRepository,
|
|
private termsAgreementRepository: TermsAgreementRepository
|
|
) {}
|
|
|
|
/**
|
|
* Create a new user account
|
|
* 1. Create user in Auth0 (which automatically sends verification email)
|
|
* 2. Create local user profile and terms agreement atomically
|
|
*/
|
|
async signup(request: SignupRequest, termsData: TermsData): Promise<SignupResponse> {
|
|
const { email, password } = request;
|
|
|
|
try {
|
|
// Create user in Auth0 Management API
|
|
// Auth0 automatically sends verification email on user creation
|
|
const auth0UserId = await auth0ManagementClient.createUser({
|
|
email,
|
|
password,
|
|
});
|
|
|
|
logger.info('Auth0 user created', { auth0UserId, email });
|
|
|
|
// Create local user profile and terms agreement in a transaction
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Create user profile
|
|
const profileQuery = `
|
|
INSERT INTO user_profiles (auth0_sub, email, subscription_tier)
|
|
VALUES ($1, $2, 'free')
|
|
RETURNING id
|
|
`;
|
|
const profileResult = await client.query(profileQuery, [auth0UserId, email]);
|
|
const profileId = profileResult.rows[0].id;
|
|
|
|
logger.info('User profile created', { userId: profileId, email });
|
|
|
|
// Create terms agreement
|
|
await this.termsAgreementRepository.create({
|
|
userId: auth0UserId,
|
|
ipAddress: termsData.ipAddress,
|
|
userAgent: termsData.userAgent,
|
|
termsVersion: termsData.termsVersion,
|
|
termsUrl: termsData.termsUrl,
|
|
termsContentHash: termsData.termsContentHash,
|
|
}, client);
|
|
|
|
logger.info('Terms agreement created', { userId: auth0UserId });
|
|
|
|
await client.query('COMMIT');
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Transaction failed, rolling back', { error, email });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
|
|
return {
|
|
userId: auth0UserId,
|
|
email,
|
|
message: 'Account created successfully. Please check your email to verify your account.',
|
|
};
|
|
} catch (error) {
|
|
logger.error('Signup failed', { email, error });
|
|
|
|
// Check for duplicate email error from Auth0
|
|
if (error instanceof Error && error.message.includes('already exists')) {
|
|
throw new Error('Email already exists');
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check email verification status
|
|
* Queries Auth0 for current verification status and updates local database if changed
|
|
*/
|
|
async getVerifyStatus(auth0Sub: string): Promise<VerifyStatusResponse> {
|
|
try {
|
|
// Get user details from Auth0
|
|
const userDetails = await auth0ManagementClient.getUser(auth0Sub);
|
|
|
|
logger.info('Retrieved user verification status from Auth0', {
|
|
auth0Sub,
|
|
emailVerified: userDetails.emailVerified,
|
|
});
|
|
|
|
// Update local database if verification status changed
|
|
const localProfile = await this.userProfileRepository.getByAuth0Sub(auth0Sub);
|
|
|
|
if (localProfile && localProfile.emailVerified !== userDetails.emailVerified) {
|
|
await this.userProfileRepository.updateEmailVerified(
|
|
auth0Sub,
|
|
userDetails.emailVerified
|
|
);
|
|
logger.info('Local email verification status updated', {
|
|
auth0Sub,
|
|
emailVerified: userDetails.emailVerified,
|
|
});
|
|
}
|
|
|
|
return {
|
|
emailVerified: userDetails.emailVerified,
|
|
email: userDetails.email,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to get verification status', { auth0Sub, error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resend verification email
|
|
* Calls Auth0 Management API to trigger verification email
|
|
*/
|
|
async resendVerification(auth0Sub: string): Promise<ResendVerificationResponse> {
|
|
try {
|
|
// Check if already verified
|
|
const verified = await auth0ManagementClient.checkEmailVerified(auth0Sub);
|
|
|
|
if (verified) {
|
|
logger.info('Email already verified, skipping resend', { auth0Sub });
|
|
return {
|
|
message: 'Email is already verified',
|
|
};
|
|
}
|
|
|
|
// Request Auth0 to resend verification email
|
|
await auth0ManagementClient.resendVerificationEmail(auth0Sub);
|
|
|
|
logger.info('Verification email resent', { auth0Sub });
|
|
|
|
return {
|
|
message: 'Verification email sent. Please check your inbox.',
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to resend verification email', { auth0Sub, error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resend verification email by email address (public endpoint)
|
|
* Looks up user by email and sends verification if not verified
|
|
*/
|
|
async resendVerificationByEmail(email: string): Promise<ResendVerificationResponse> {
|
|
try {
|
|
// Look up user by email in our database
|
|
const userProfile = await this.userProfileRepository.getByEmail(email);
|
|
|
|
if (!userProfile) {
|
|
// Don't reveal if email exists - return success message regardless
|
|
logger.info('Resend verification requested for unknown email', {
|
|
email: email.substring(0, 3) + '***',
|
|
});
|
|
return {
|
|
message: 'If an account exists with this email, a verification link will be sent.',
|
|
};
|
|
}
|
|
|
|
// Check if already verified via Auth0
|
|
const verified = await auth0ManagementClient.checkEmailVerified(userProfile.auth0Sub);
|
|
|
|
if (verified) {
|
|
logger.info('Email already verified, skipping resend', { email: email.substring(0, 3) + '***' });
|
|
return {
|
|
message: 'If an account exists with this email, a verification link will be sent.',
|
|
};
|
|
}
|
|
|
|
// Request Auth0 to resend verification email
|
|
await auth0ManagementClient.resendVerificationEmail(userProfile.auth0Sub);
|
|
|
|
logger.info('Verification email resent via public endpoint', {
|
|
email: email.substring(0, 3) + '***',
|
|
});
|
|
|
|
return {
|
|
message: 'If an account exists with this email, a verification link will be sent.',
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to resend verification email by email', {
|
|
email: email.substring(0, 3) + '***',
|
|
error,
|
|
});
|
|
// Don't reveal error details - return generic success for security
|
|
return {
|
|
message: 'If an account exists with this email, a verification link will be sent.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user status for routing decisions
|
|
* Returns email verification and onboarding completion status
|
|
*/
|
|
async getUserStatus(auth0Sub: string): Promise<{
|
|
emailVerified: boolean;
|
|
onboardingCompleted: boolean;
|
|
email: string;
|
|
}> {
|
|
try {
|
|
// Get user details from Auth0
|
|
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
|
|
|
|
// Get local profile for onboarding status
|
|
const localProfile = await this.userProfileRepository.getByAuth0Sub(auth0Sub);
|
|
|
|
// Sync email verification status if needed
|
|
if (localProfile && localProfile.emailVerified !== auth0User.emailVerified) {
|
|
await this.userProfileRepository.updateEmailVerified(auth0Sub, auth0User.emailVerified);
|
|
}
|
|
|
|
return {
|
|
emailVerified: auth0User.emailVerified,
|
|
onboardingCompleted: localProfile?.onboardingCompletedAt !== null,
|
|
email: auth0User.email,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to get user status', { auth0Sub, error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get security status for the user
|
|
* Returns email verification status and passkey info
|
|
*/
|
|
async getSecurityStatus(auth0Sub: string): Promise<SecurityStatusResponse> {
|
|
try {
|
|
// Get user details from Auth0
|
|
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
|
|
|
|
logger.info('Retrieved security status', {
|
|
auth0Sub: auth0Sub.substring(0, 8) + '...',
|
|
emailVerified: auth0User.emailVerified,
|
|
});
|
|
|
|
return {
|
|
emailVerified: auth0User.emailVerified,
|
|
email: auth0User.email,
|
|
// Passkeys are enabled at the Auth0 connection level, not per-user
|
|
// This is informational - actual passkey enrollment happens in Auth0 Universal Login
|
|
passkeysEnabled: true,
|
|
// Auth0 doesn't expose password last changed date via Management API
|
|
// Would require Auth0 Logs API or user_metadata to track this
|
|
passwordLastChanged: null,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to get security status', { auth0Sub, error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request password reset email
|
|
* Triggers Auth0 to send password reset email to user
|
|
*/
|
|
async requestPasswordReset(auth0Sub: string): Promise<PasswordResetResponse> {
|
|
try {
|
|
// Get user email from Auth0
|
|
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
|
|
|
|
// Send password reset email via Auth0
|
|
await auth0ManagementClient.sendPasswordResetEmail(auth0User.email);
|
|
|
|
logger.info('Password reset email requested', {
|
|
auth0Sub: auth0Sub.substring(0, 8) + '...',
|
|
email: auth0User.email.substring(0, 3) + '***',
|
|
});
|
|
|
|
return {
|
|
message: 'Password reset email sent. Please check your inbox.',
|
|
success: true,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to request password reset', { auth0Sub, error });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|