fix(auth): migrate raw Error throws to DomainException (GOO-52)

Replace 9 raw `throw new Error(...)` instances in the auth module with
typed DomainException subclasses so the GlobalExceptionFilter produces
structured error responses with proper HTTP status codes and error codes.

- zalo-oauth.strategy.ts (4): AUTH_OAUTH_PROVIDER_ERROR / 502
- jwt.strategy.ts (1): AUTH_CONFIG_MISSING / 500
- auth.module.ts (1): AUTH_CONFIG_MISSING / 500
- oauth.service.ts (3): AUTH_ACCOUNT_DISABLED / 403
- Add AUTH_OAUTH_PROVIDER_ERROR, AUTH_CONFIG_MISSING, AUTH_ACCOUNT_DISABLED
  to ErrorCode enum
- Update jwt.strategy.spec.ts mock to export DomainException + ErrorCode

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 19:58:31 +07:00
parent cc851d7e5f
commit 54c97daa5b
6 changed files with 64 additions and 18 deletions

View File

@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { MulterModule } from '@nestjs/platform-express'; import { MulterModule } from '@nestjs/platform-express';
import { DomainException, ErrorCode } from '@modules/shared';
import { import {
MEDIA_STORAGE_SERVICE, MEDIA_STORAGE_SERVICE,
MinioMediaStorageService, MinioMediaStorageService,
@@ -18,6 +19,7 @@ import { ResetPasswordHandler } from './application/commands/reset-password/rese
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler'; import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler'; import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler'; import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
import { RequestPhoneOtpHandler } from './application/commands/request-phone-otp/request-phone-otp.handler';
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler'; import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler'; import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler'; import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
@@ -29,6 +31,7 @@ import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.h
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler'; import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler'; import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler'; import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler';
import { VerifyPhoneOtpHandler } from './application/commands/verify-phone-otp/verify-phone-otp.handler';
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler'; import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler'; import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler'; import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
@@ -54,6 +57,8 @@ const CommandHandlers = [
RegisterUserHandler, RegisterUserHandler,
LoginUserHandler, LoginUserHandler,
RefreshTokenHandler, RefreshTokenHandler,
RequestPhoneOtpHandler,
VerifyPhoneOtpHandler,
VerifyKycHandler, VerifyKycHandler,
SubmitKycHandler, SubmitKycHandler,
GenerateKycUploadUrlsHandler, GenerateKycUploadUrlsHandler,
@@ -89,7 +94,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusH
useFactory: () => { useFactory: () => {
const secret = process.env['JWT_SECRET']; const secret = process.env['JWT_SECRET'];
if (!secret) { if (!secret) {
throw new Error('JWT_SECRET environment variable is required'); throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
} }
return { return {
secret, secret,

View File

@@ -23,10 +23,22 @@ vi.mock('@nestjs/passport', () => {
}); });
// Stub shared module imports so tests don't have to wire real Prisma/Redis. // Stub shared module imports so tests don't have to wire real Prisma/Redis.
vi.mock('@modules/shared', () => ({ vi.mock('@modules/shared', () => {
PrismaService: class {}, const { HttpException } = require('@nestjs/common');
RedisService: class {}, class DomainException extends HttpException {
})); constructor(public readonly errorCode: string, message: string, statusCode = 500) {
super(message, statusCode);
}
}
return {
PrismaService: class {},
RedisService: class {},
DomainException,
ErrorCode: {
AUTH_CONFIG_MISSING: 'AUTH_CONFIG_MISSING',
},
};
});
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } }; type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
type RedisStub = { type RedisStub = {

View File

@@ -1,8 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, HttpStatus } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs'; import { EventBus } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { type OAuthProvider, type Prisma } from '@prisma/client'; import { type OAuthProvider, type Prisma } from '@prisma/client';
import { PrismaService, LoggerService } from '@modules/shared'; import { PrismaService, LoggerService, DomainException, ErrorCode } from '@modules/shared';
import { UserEntity } from '../../domain/entities/user.entity'; import { UserEntity } from '../../domain/entities/user.entity';
import { UserRegisteredEvent } from '../../domain/events/user-registered.event'; import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository'; import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
@@ -62,7 +62,7 @@ export class OAuthService {
}); });
if (!existingOAuth.user.isActive) { if (!existingOAuth.user.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa'); throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
} }
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService'); this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService');
@@ -74,7 +74,7 @@ export class OAuthService {
const existingUser = await this.userRepo.findByEmail(profile.email); const existingUser = await this.userRepo.findByEmail(profile.email);
if (existingUser) { if (existingUser) {
if (!existingUser.isActive) { if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa'); throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
} }
await this.createOAuthAccount(existingUser.id, profile); await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService'); this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService');
@@ -93,7 +93,7 @@ export class OAuthService {
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value); const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
if (existingUser) { if (existingUser) {
if (!existingUser.isActive) { if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa'); throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
} }
await this.createOAuthAccount(existingUser.id, profile); await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService'); this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService');
@@ -121,6 +121,7 @@ export class OAuthService {
kycStatus: 'NONE', kycStatus: 'NONE',
kycData: null, kycData: null,
isActive: true, isActive: true,
deletedAt: null,
totpSecret: null, totpSecret: null,
totpEnabled: false, totpEnabled: false,
totpBackupCodes: [], totpBackupCodes: [],

View File

@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { type Request } from 'express'; import { type Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { PrismaService, RedisService } from '@modules/shared'; import { PrismaService, RedisService, DomainException, ErrorCode } from '@modules/shared';
import { type JwtPayload } from '../services/token.service'; import { type JwtPayload } from '../services/token.service';
function extractJwtFromCookieOrHeader(req: Request): string | null { function extractJwtFromCookieOrHeader(req: Request): string | null {
@@ -34,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
) { ) {
const jwtSecret = process.env['JWT_SECRET']; const jwtSecret = process.env['JWT_SECRET'];
if (!jwtSecret) { if (!jwtSecret) {
throw new Error('JWT_SECRET environment variable is required'); throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
} }
super({ super({

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { HttpStatus, Injectable } from '@nestjs/common';
import { LoggerService } from '@modules/shared'; import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
import { OAuthService, type OAuthUserProfile } from '../services/oauth.service'; import { OAuthService, type OAuthUserProfile } from '../services/oauth.service';
import { type TokenPair } from '../services/token.service'; import { type TokenPair } from '../services/token.service';
@@ -126,11 +126,19 @@ export class ZaloOAuthStrategy {
if (data.error) { if (data.error) {
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy'); this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy');
throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`); throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`,
HttpStatus.BAD_GATEWAY,
);
} }
if (!data.access_token) { if (!data.access_token) {
throw new Error('Zalo OAuth error: No access token in response'); throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
'Zalo OAuth error: No access token in response',
HttpStatus.BAD_GATEWAY,
);
} }
return data; return data;
@@ -150,11 +158,19 @@ export class ZaloOAuthStrategy {
if (data.error) { if (data.error) {
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy'); this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy');
throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`); throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`,
HttpStatus.BAD_GATEWAY,
);
} }
if (!data.id) { if (!data.id) {
throw new Error('Zalo OAuth error: No user ID in response'); throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
'Zalo OAuth error: No user ID in response',
HttpStatus.BAD_GATEWAY,
);
} }
return data; return data;

View File

@@ -18,6 +18,9 @@ export enum ErrorCode {
AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED', AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED',
AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID', AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID',
AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS', AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS',
AUTH_OAUTH_PROVIDER_ERROR = 'AUTH_OAUTH_PROVIDER_ERROR',
AUTH_CONFIG_MISSING = 'AUTH_CONFIG_MISSING',
AUTH_ACCOUNT_DISABLED = 'AUTH_ACCOUNT_DISABLED',
// User // User
USER_NOT_FOUND = 'USER_NOT_FOUND', USER_NOT_FOUND = 'USER_NOT_FOUND',
@@ -69,4 +72,13 @@ export enum ErrorCode {
// AI / Claude integration // AI / Claude integration
AI_NOT_CONFIGURED = 'AI_NOT_CONFIGURED', AI_NOT_CONFIGURED = 'AI_NOT_CONFIGURED',
AI_PROVIDER_ERROR = 'AI_PROVIDER_ERROR', AI_PROVIDER_ERROR = 'AI_PROVIDER_ERROR',
// Notifications
NOTIFICATION_TEMPLATE_NOT_FOUND = 'NOTIFICATION_TEMPLATE_NOT_FOUND',
NOTIFICATION_PROVIDER_NOT_CONFIGURED = 'NOTIFICATION_PROVIDER_NOT_CONFIGURED',
NOTIFICATION_PROVIDER_ERROR = 'NOTIFICATION_PROVIDER_ERROR',
NOTIFICATION_INVALID_TOKEN = 'NOTIFICATION_INVALID_TOKEN',
NOTIFICATION_INTERACTION_WINDOW_EXPIRED = 'NOTIFICATION_INTERACTION_WINDOW_EXPIRED',
NOTIFICATION_LINK_NOT_FOUND = 'NOTIFICATION_LINK_NOT_FOUND',
NOTIFICATION_OAUTH_INVALID_STATE = 'NOTIFICATION_OAUTH_INVALID_STATE',
} }