diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 7e76af3..381e13b 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -16,6 +16,7 @@ import { Auth0Strategy } from './strategies/auth0.strategy'; import { RolesGuard } from './guards/roles.guard'; import { AuthFacadeService } from './services/auth-facade.service'; import { AuthOAuthService } from './services/auth-oauth.service'; +import { Auth0ManagementService } from './services/auth0-management.service'; import { UserAuthRepository } from './repositories/user-auth.repository'; import { AuthRepository } from './repositories/auth.repository'; import { TokenRedisRepository } from './repositories/token-redis.repository'; @@ -44,7 +45,18 @@ import { TokenRedisRepository } from './repositories/token-redis.repository'; RolesGuard, AuthFacadeService, AuthOAuthService, + Auth0ManagementService, + ], + exports: [ + AuthCredentialsService, + PasswordService, + TokenService, + JwtModule, + PassportModule, + RolesGuard, + TokenRedisRepository, + AuthRepository, + Auth0ManagementService, ], - exports: [AuthCredentialsService, PasswordService, TokenService, JwtModule, PassportModule, RolesGuard], }) export class AuthModule {} diff --git a/src/modules/auth/repositories/auth.repository.ts b/src/modules/auth/repositories/auth.repository.ts index a3e3c6f..812532f 100644 --- a/src/modules/auth/repositories/auth.repository.ts +++ b/src/modules/auth/repositories/auth.repository.ts @@ -14,4 +14,8 @@ export class AuthRepository extends Repository { relations: ['user'], }); } + + async findByUserId(userId: string): Promise { + return this.findOne({ where: { userId } }); + } } diff --git a/src/modules/auth/services/auth0-management.service.ts b/src/modules/auth/services/auth0-management.service.ts new file mode 100644 index 0000000..dbe3a4b --- /dev/null +++ b/src/modules/auth/services/auth0-management.service.ts @@ -0,0 +1,111 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppLogger } from '@/common/logger/app-logger'; + +interface Auth0ManagementToken { + access_token: string; + expires_at: number; +} + +@Injectable() +export class Auth0ManagementService { + private readonly domain: string; + private readonly clientId: string; + private readonly clientSecret: string; + private readonly audience: string; + + private cachedToken: Auth0ManagementToken | null = null; + + constructor( + private readonly configService: ConfigService, + private readonly logger: AppLogger, + ) { + this.logger.setLoggerContext(Auth0ManagementService.name); + this.domain = this.configService.getOrThrow('AUTH0_DOMAIN'); + this.clientId = this.configService.getOrThrow('AUTH0_CLIENT_ID'); + this.clientSecret = this.configService.getOrThrow('AUTH0_CLIENT_SECRET'); + this.audience = `https://${this.domain}/api/v2/`; + } + + async deleteUser(auth0UserId: string): Promise { + const token = await this.getManagementToken(); + + const url = `https://${this.domain}/api/v2/users/${encodeURIComponent(auth0UserId)}`; + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok && response.status !== 204) { + const body = await response.text(); + this.logger.error({ auth0UserId, status: response.status, body }, 'Failed to delete user from Auth0'); + throw new InternalServerErrorException('Failed to delete user from Auth0'); + } + + this.logger.log({ auth0UserId }, 'User deleted from Auth0 successfully'); + } + + async updateUser( + auth0UserId: string, + data: { given_name?: string; family_name?: string; name?: string; picture?: string }, + ): Promise { + const token = await this.getManagementToken(); + + const url = `https://${this.domain}/api/v2/users/${encodeURIComponent(auth0UserId)}`; + + const response = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const body = await response.text(); + this.logger.error({ auth0UserId, status: response.status, body }, 'Failed to update user in Auth0'); + throw new InternalServerErrorException('Failed to update user in Auth0'); + } + + this.logger.log({ auth0UserId, data }, 'User updated in Auth0 successfully'); + } + + private async getManagementToken(): Promise { + const now = Date.now(); + + if (this.cachedToken && now < this.cachedToken.expires_at) { + return this.cachedToken.access_token; + } + + const response = await fetch(`https://${this.domain}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: this.clientId, + client_secret: this.clientSecret, + audience: this.audience, + }), + }); + + if (!response.ok) { + const body = await response.text(); + this.logger.error({ status: response.status, body }, 'Failed to obtain Auth0 Management token'); + throw new InternalServerErrorException('Failed to obtain Auth0 Management API token'); + } + + const data = (await response.json()) as { access_token: string; expires_in: number }; + + this.cachedToken = { + access_token: data.access_token, + expires_at: now + (data.expires_in - 60) * 1000, + }; + + this.logger.log('Auth0 Management token obtained and cached'); + return this.cachedToken.access_token; + } +} diff --git a/src/modules/users/__tests__/users.service.spec.ts b/src/modules/users/__tests__/users.service.spec.ts index b9e1d74..2464657 100644 --- a/src/modules/users/__tests__/users.service.spec.ts +++ b/src/modules/users/__tests__/users.service.spec.ts @@ -7,8 +7,12 @@ import { AuthCredentialsService } from '@/modules/auth/services/auth-credentials import { AppLogger } from '@/common/logger/app-logger'; import { S3Service } from '@/modules/s3/s3.service'; import { User } from '../entities/user.entity'; +import { Auth } from '@/modules/auth/entities/auth.entity'; import { UserRole } from '@/common/enums/user-role.enum'; import { UpdateUserDto } from '../dto/req/update-user.req.dto'; +import { TokenRedisRepository } from '@/modules/auth/repositories/token-redis.repository'; +import { AuthRepository } from '@/modules/auth/repositories/auth.repository'; +import { Auth0ManagementService } from '@/modules/auth/services/auth0-management.service'; // === Factory functions === @@ -19,18 +23,41 @@ const makeUser = (overrides: Partial = {}): User => firstName: 'Ivan', lastName: 'Test', role: UserRole.USER, + avatarUrl: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), ...overrides, }) as User; +// Auth record for a regular email/password user (no googleId) +const makeLocalAuth = (overrides: Partial = {}): Auth => + ({ + id: 'auth-uuid-1', + userId: 'user-uuid-1', + passwordHash: 'hashed-password', + googleId: null, + resetPasswordTokenHash: null, + resetPasswordExpiresAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + ...overrides, + }) as Auth; + +// Auth record for an Auth0 OAuth user (has googleId, no passwordHash) +const makeOAuthAuth = (overrides: Partial = {}): Auth => + makeLocalAuth({ passwordHash: null, googleId: 'google-oauth2|123456', ...overrides }); + // === Tests === describe('UsersService', () => { let service: UsersService; let userRepository: jest.Mocked; let authCredentialsService: jest.Mocked; + let s3Service: jest.Mocked; let dataSource: { transaction: jest.Mock }; + let tokenRedisRepository: jest.Mocked; + let authRepository: jest.Mocked; + let auth0ManagementService: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -42,6 +69,7 @@ describe('UsersService', () => { findByIdOrFail: jest.fn(), findPaginated: jest.fn(), remove: jest.fn(), + save: jest.fn(), }, }, { @@ -51,19 +79,16 @@ describe('UsersService', () => { setPasswordTx: jest.fn(), }, }, - // Typed as plain object to avoid DataSource transaction overload conflict { provide: DataSource, - useValue: { - transaction: jest.fn(), - }, + useValue: { transaction: jest.fn() }, }, { provide: AppLogger, useValue: { setLoggerContext: jest.fn(), log: jest.fn(), - logError: jest.fn(), + error: jest.fn(), }, }, { @@ -73,32 +98,48 @@ describe('UsersService', () => { deleteFile: jest.fn(), }, }, + { + provide: TokenRedisRepository, + useValue: { revokeAllUserTokens: jest.fn() }, + }, + { + provide: AuthRepository, + useValue: { findByUserId: jest.fn() }, + }, + { + provide: Auth0ManagementService, + useValue: { + deleteUser: jest.fn(), + updateUser: jest.fn(), + }, + }, ], }).compile(); service = module.get(UsersService); userRepository = module.get(UserRepository); authCredentialsService = module.get(AuthCredentialsService); + s3Service = module.get(S3Service); dataSource = module.get<{ transaction: jest.Mock }>(DataSource); + tokenRedisRepository = module.get(TokenRedisRepository); + authRepository = module.get(AuthRepository); + auth0ManagementService = module.get(Auth0ManagementService); }); // === findOne === describe('findOne', () => { - it('should return user by id; throw NotFoundException if not found', async () => { + it('should return user by id', async () => { const user = makeUser(); - - // === Success case === userRepository.findByIdOrFail.mockResolvedValueOnce(user); const result = await service.findOne(user.id); expect(result.id).toBe(user.id); expect(result.email).toBe(user.email); + }); - // === User not found === - // findByIdOrFail internally calls GuardAssertions.exists which throws NotFoundException - + it('should throw NotFoundException if user not found', async () => { userRepository.findByIdOrFail.mockRejectedValueOnce(new NotFoundException('User with ID not found')); await expect(service.findOne('nonexistent-id')).rejects.toThrow(NotFoundException); @@ -108,36 +149,64 @@ describe('UsersService', () => { // === update === describe('update', () => { - it('should update firstName/lastName; call setPasswordTx if password provided', async () => { - const user = makeUser(); - - // === Update name fields === - const nameDto: UpdateUserDto = { firstName: 'UpdatedName', lastName: 'UpdatedLastName' }; - - // Transaction mock: invoke callback with fake EntityManager + const mockTransaction = (updatedUser: User): void => { dataSource.transaction.mockImplementationOnce(async (cb: (manager: EntityManager) => Promise) => { - const updatedUser = { ...user, ...nameDto }; const fakeManager = { - findOneOrFail: jest.fn().mockResolvedValue(user), + findOneOrFail: jest.fn().mockResolvedValue(updatedUser), getRepository: jest.fn().mockReturnValue({ save: jest.fn().mockResolvedValue(updatedUser), }), } as unknown as EntityManager; return cb(fakeManager); }); + }; - const nameResult = await service.update(user.id, nameDto); + it('should update firstName/lastName for a regular user (no Auth0 sync)', async () => { + const user = makeUser(); + const dto: UpdateUserDto = { firstName: 'Updated', lastName: 'Name' }; + mockTransaction({ ...user, ...dto }); + + // Regular user — no googleId + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); - expect(nameResult.firstName).toBe('UpdatedName'); - expect(nameResult.lastName).toBe('UpdatedLastName'); - // Password was not provided — setPasswordTx must not be called + const result = await service.update(user.id, dto); + + expect(result.firstName).toBe('Updated'); + expect(result.lastName).toBe('Name'); expect(authCredentialsService.setPasswordTx).not.toHaveBeenCalled(); + // No Auth0 sync for local user + expect(auth0ManagementService.updateUser).not.toHaveBeenCalled(); + }); - // === Update with password === - const passwordDto: UpdateUserDto = { password: 'NewStrongPass123!' }; + it('should sync name to Auth0 when updating an OAuth user', async () => { + const user = makeUser(); + const dto: UpdateUserDto = { firstName: 'UpdatedName', lastName: 'UpdatedLastName' }; + const updatedUser = { ...user, ...dto }; + mockTransaction(updatedUser); + + // Auth0 user — has googleId + authRepository.findByUserId.mockResolvedValueOnce(makeOAuthAuth()); + // Called inside syncNameToAuth0IfOAuthUser to read current names + userRepository.findByIdOrFail.mockResolvedValueOnce(updatedUser); + + const result = await service.update(user.id, dto); + + expect(result.firstName).toBe('UpdatedName'); + expect(auth0ManagementService.updateUser).toHaveBeenCalledWith( + 'google-oauth2|123456', + expect.objectContaining({ + given_name: 'UpdatedName', + family_name: 'UpdatedLastName', + }), + ); + }); + + it('should call setPasswordTx when password is provided', async () => { + const user = makeUser(); + const dto: UpdateUserDto = { password: 'NewStrongPass123!' }; + const userWithAuth = { ...user, auth: { passwordHash: 'oldhash' } }; dataSource.transaction.mockImplementationOnce(async (cb: (manager: EntityManager) => Promise) => { - const userWithAuth = { ...user, auth: { passwordHash: 'oldhash' } }; const fakeManager = { findOneOrFail: jest.fn().mockResolvedValue(userWithAuth), getRepository: jest.fn().mockReturnValue({ @@ -147,28 +216,111 @@ describe('UsersService', () => { return cb(fakeManager); }); - authCredentialsService.setPasswordTx.mockResolvedValue({ id: 'auth-uuid-1' } as never); + authCredentialsService.setPasswordTx.mockResolvedValue(makeLocalAuth()); - await service.update(user.id, passwordDto); + await service.update(user.id, dto); - // Password was provided — setPasswordTx must be called once expect(authCredentialsService.setPasswordTx).toHaveBeenCalledTimes(1); + expect(authRepository.findByUserId).not.toHaveBeenCalled(); + }); + }); + + // === updateAvatar === + + describe('updateAvatar', () => { + const fakeFile = { originalname: 'avatar.jpg', buffer: Buffer.from('') } as Express.Multer.File; + const newAvatarUrl = 'https://s3.example.com/users/avatar.jpg'; + + it('should upload new avatar, delete old one, and return updated user', async () => { + const user = makeUser({ avatarUrl: 'https://s3.example.com/users/old.jpg' }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + s3Service.uploadFile.mockResolvedValueOnce(newAvatarUrl); + userRepository.save.mockResolvedValueOnce({ ...user, avatarUrl: newAvatarUrl }); + + // Local user — no Auth0 sync + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); + + const result = await service.updateAvatar(user.id, fakeFile); + + expect(s3Service.uploadFile).toHaveBeenCalledWith(fakeFile, 'users'); + expect(s3Service.deleteFile).toHaveBeenCalledWith('https://s3.example.com/users/old.jpg'); + expect(result.avatarUrl).toBe(newAvatarUrl); + expect(auth0ManagementService.updateUser).not.toHaveBeenCalled(); + }); + + it('should sync avatar (picture) to Auth0 for an OAuth user', async () => { + const user = makeUser({ avatarUrl: null }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + s3Service.uploadFile.mockResolvedValueOnce(newAvatarUrl); + userRepository.save.mockResolvedValueOnce({ ...user, avatarUrl: newAvatarUrl }); + + // OAuth user — has googleId + authRepository.findByUserId.mockResolvedValueOnce(makeOAuthAuth()); + + const result = await service.updateAvatar(user.id, fakeFile); + + expect(result.avatarUrl).toBe(newAvatarUrl); + expect(auth0ManagementService.updateUser).toHaveBeenCalledWith( + 'google-oauth2|123456', + expect.objectContaining({ picture: newAvatarUrl }), + ); + }); + + it('should not delete old avatar from S3 if user had no avatar', async () => { + const user = makeUser({ avatarUrl: null }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + s3Service.uploadFile.mockResolvedValueOnce(newAvatarUrl); + userRepository.save.mockResolvedValueOnce({ ...user, avatarUrl: newAvatarUrl }); + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); + + await service.updateAvatar(user.id, fakeFile); + + // No old avatar — deleteFile must not be called + expect(s3Service.deleteFile).not.toHaveBeenCalled(); }); }); // === remove === describe('remove', () => { - it('should call userRepository.remove with the found user', async () => { - const user = makeUser(); + it('should revoke tokens, remove user and avatar from S3, skip Auth0 for regular user', async () => { + const user = makeUser({ avatarUrl: 'https://s3.example.com/users/avatar.jpg' }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + userRepository.remove.mockResolvedValueOnce(user); + // Regular user — no googleId + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); - userRepository.findByIdOrFail.mockResolvedValue(user); - userRepository.remove.mockResolvedValue(user); + await service.remove(user.id); + + expect(tokenRedisRepository.revokeAllUserTokens).toHaveBeenCalledWith(user.id); + expect(s3Service.deleteFile).toHaveBeenCalledWith(user.avatarUrl); + expect(auth0ManagementService.deleteUser).not.toHaveBeenCalled(); + expect(userRepository.remove).toHaveBeenCalledWith(user); + }); + + it('should delete user from Auth0 when removing an OAuth user', async () => { + const user = makeUser(); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + userRepository.remove.mockResolvedValueOnce(user); + // OAuth user — has googleId + authRepository.findByUserId.mockResolvedValueOnce(makeOAuthAuth()); await service.remove(user.id); - // Ensure the correct user entity is passed to remove + expect(tokenRedisRepository.revokeAllUserTokens).toHaveBeenCalledWith(user.id); + expect(auth0ManagementService.deleteUser).toHaveBeenCalledWith('google-oauth2|123456'); expect(userRepository.remove).toHaveBeenCalledWith(user); }); + + it('should not delete avatar from S3 if user had no avatar', async () => { + const user = makeUser({ avatarUrl: null }); + userRepository.findByIdOrFail.mockResolvedValueOnce(user); + userRepository.remove.mockResolvedValueOnce(user); + authRepository.findByUserId.mockResolvedValueOnce(makeLocalAuth()); + + await service.remove(user.id); + + expect(s3Service.deleteFile).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 25f06ba..170834d 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -11,6 +11,9 @@ import { PaginatedResultResponseDto } from '@/common/dto/res/paginated-result.dt import { UserRepository } from './repositories/user.repository'; import { GuardAssertions } from '@/common/helpers/assert.helper'; import { S3Service } from '@/modules/s3/s3.service'; +import { TokenRedisRepository } from '@/modules/auth/repositories/token-redis.repository'; +import { AuthRepository } from '@/modules/auth/repositories/auth.repository'; +import { Auth0ManagementService } from '@/modules/auth/services/auth0-management.service'; @Injectable() export class UsersService { @@ -20,6 +23,9 @@ export class UsersService { private readonly authCredentialsService: AuthCredentialsService, private readonly logger: AppLogger, private readonly s3Service: S3Service, + private readonly tokenRedisRepository: TokenRedisRepository, + private readonly authRepository: AuthRepository, + private readonly auth0ManagementService: Auth0ManagementService, ) { this.logger.setLoggerContext(UsersService.name); } @@ -53,7 +59,7 @@ export class UsersService { } async update(id: string, dto: UpdateUserDto): Promise { - return this.dataSource.transaction(async (manager) => { + const updated = await this.dataSource.transaction(async (manager) => { const relations = dto.password ? ['auth'] : []; const user = await manager.findOneOrFail(User, { where: { id }, relations }); GuardAssertions.exists(user, `User with ID ${id} not found`); @@ -70,10 +76,14 @@ export class UsersService { await this.authCredentialsService.setPasswordTx(manager, user, dto.password); } - const updated = await manager.getRepository(User).save(user); - this.logger.log({ userId: updated.id }, 'User updated successfully'); - return updated; + const result = await manager.getRepository(User).save(user); + this.logger.log({ userId: result.id }, 'User updated successfully'); + return result; }); + + await this.syncNameToAuth0IfOAuthUser(id, dto); + + return updated; } async updateAvatar(userId: string, file: Express.Multer.File): Promise { @@ -89,12 +99,22 @@ export class UsersService { await this.s3Service.deleteFile(oldUrl); } + await this.syncAvatarToAuth0IfOAuthUser(userId, updated.avatarUrl); + return updated; } async remove(id: string): Promise { const user = await this.userRepository.findByIdOrFail(id); + await this.tokenRedisRepository.revokeAllUserTokens(id); + this.logger.log({ userId: id }, 'All user tokens revoked from Redis'); + + const auth = await this.authRepository.findByUserId(id); + if (auth?.googleId) { + await this.auth0ManagementService.deleteUser(auth.googleId); + } + if (user.avatarUrl) { await this.s3Service.deleteFile(user.avatarUrl); } @@ -102,4 +122,55 @@ export class UsersService { await this.userRepository.remove(user); this.logger.log({ userId: id, email: user.email }, 'User removed successfully'); } + + private async syncNameToAuth0IfOAuthUser( + userId: string, + dto: Pick, + ): Promise { + const hasNameChange = dto.firstName !== undefined || dto.lastName !== undefined; + if (!hasNameChange) { + return; + } + + const auth = await this.authRepository.findByUserId(userId); + if (!auth?.googleId) { + return; + } + + try { + const user = await this.userRepository.findByIdOrFail(userId); + await this.auth0ManagementService.updateUser(auth.googleId, { + given_name: user.firstName, + family_name: user.lastName, + name: `${user.firstName} ${user.lastName}`, + }); + } catch (err) { + this.logger.error( + { userId, error: (err as Error).message }, + 'Failed to sync name update to Auth0 (non-critical)', + ); + } + } + + private async syncAvatarToAuth0IfOAuthUser(userId: string, avatarUrl: string | null): Promise { + if (!avatarUrl) { + return; + } + + const auth = await this.authRepository.findByUserId(userId); + if (!auth?.googleId) { + return; + } + + try { + await this.auth0ManagementService.updateUser(auth.googleId, { + picture: avatarUrl, + }); + } catch (err) { + this.logger.error( + { userId, error: (err as Error).message }, + 'Failed to sync avatar update to Auth0 (non-critical)', + ); + } + } }