From 71396339151c64e3ccb30a0fdb5388cbf89c540a Mon Sep 17 00:00:00 2001 From: LeoMortari Date: Thu, 11 Sep 2025 22:25:11 -0300 Subject: [PATCH] Finaliza auth de rotas --- package.json | 7 +- src/auth/auth.service.spec.ts | 158 ++++++++++++++++++++++++++++++++ src/auth/auth.service.ts | 9 +- src/auth/dto/login.dto.ts | 9 +- src/auth/keycloak-auth.guard.ts | 4 +- src/auth/keycloak.strategy.ts | 18 +++- 6 files changed, 187 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 875ab73..e5cf9e0 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "start:dev": "NODE_ENV=development nest start --watch", + "start:debug": "NODE_ENV=development nest start --debug --watch", + "start:prod": "NODE_ENV=production node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", @@ -47,6 +47,7 @@ "@types/express": "5.0.0", "@types/jest": "30.0.0", "@types/node": "22.10.7", + "@types/passport-jwt": "4.0.1", "@types/supertest": "6.0.2", "eslint": "9.18.0", "eslint-config-prettier": "10.0.1", diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 800ab66..dbf0870 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,5 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { UnauthorizedException } from '@nestjs/common'; +import axios, { AxiosInstance } from 'axios'; import { AuthService } from './auth.service'; +import { LoginDto } from './dto/login.dto'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; describe('AuthService', () => { let service: AuthService; @@ -10,9 +17,160 @@ describe('AuthService', () => { }).compile(); service = module.get(AuthService); + jest.clearAllMocks(); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getUrl', () => { + it('should return development URL when NODE_ENV is development', () => { + process.env.NODE_ENV = 'development'; + expect(service['getUrl']()).toBe('https://auth.clipperia.com.br'); + }); + + it('should return production URL when NODE_ENV is not development', () => { + process.env.NODE_ENV = 'production'; + expect(service['getUrl']()).toBe('http://keycloak:8080'); + }); + }); + + describe('login', () => { + const loginDto: LoginDto = { + username: 'testuser', + password: 'testpass', + }; + + const mockTokenResponse = { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 300, + refresh_expires_in: 1800, + }; + + it('should login successfully', async () => { + const mockPost = jest + .fn() + .mockResolvedValueOnce({ data: mockTokenResponse }); + mockedAxios.create.mockReturnValueOnce({ + post: mockPost, + } as unknown as AxiosInstance); + + const result = await service.login(loginDto); + + expect(mockPost).toHaveBeenCalledWith( + '/token', + expect.any(URLSearchParams), + ); + expect(result).toEqual(mockTokenResponse); + }); + + it('should throw UnauthorizedException when login fails', async () => { + const mockError = { + isAxiosError: true, + response: { + status: 401, + data: { error: 'invalid_grant' }, + }, + }; + + const mockPost = jest.fn().mockRejectedValueOnce(mockError); + + mockedAxios.create.mockReturnValueOnce({ + post: mockPost, + } as unknown as AxiosInstance); + + await expect(service.login(loginDto)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('logout', () => { + const refreshToken = 'mock-refresh-token'; + + it('should logout successfully', async () => { + const mockPost = jest.fn().mockResolvedValueOnce({}); + + mockedAxios.create.mockReturnValueOnce({ + post: mockPost, + } as unknown as AxiosInstance); + + await service.logout(refreshToken); + + expect(mockPost).toHaveBeenCalledWith( + '/logout', + expect.any(URLSearchParams), + ); + }); + + it('should throw UnauthorizedException when logout fails', async () => { + const mockError = { + isAxiosError: true, + response: { + status: 400, + data: { error: 'invalid_token' }, + }, + }; + + const mockPost = jest.fn().mockRejectedValueOnce(mockError); + + mockedAxios.create.mockReturnValueOnce({ + post: mockPost, + } as unknown as AxiosInstance); + + await expect(service.logout(refreshToken)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('refreshToken', () => { + const refreshToken = 'mock-refresh-token'; + const mockTokenResponse = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 300, + refresh_expires_in: 1800, + }; + + it('should refresh token successfully', async () => { + const mockPost = jest + .fn() + .mockResolvedValueOnce({ data: mockTokenResponse }); + + mockedAxios.create.mockReturnValueOnce({ + post: mockPost, + } as unknown as AxiosInstance); + + const result = await service.refreshToken(refreshToken); + + expect(mockPost).toHaveBeenCalledWith( + '/token', + expect.any(URLSearchParams), + ); + expect(result).toEqual(mockTokenResponse); + }); + + it('should throw UnauthorizedException when refresh token is invalid', async () => { + const mockError = { + isAxiosError: true, + response: { + status: 400, + data: { error: 'invalid_grant' }, + }, + }; + + const mockPost = jest.fn().mockRejectedValueOnce(mockError); + + mockedAxios.create.mockReturnValueOnce({ + post: mockPost, + } as unknown as AxiosInstance); + + await expect(service.refreshToken(refreshToken)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ea1f26e..1570441 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -9,7 +9,8 @@ import LoginResponseDto from './dto/loginResponse.dto'; @Injectable() export class AuthService { private readonly keycloakUrl = - 'https://auth.clipperia.com.br/realms/clipperia/protocol/openid-connect'; + this.getUrl() + '/realms/clipperia/protocol/openid-connect'; + private readonly clientId = 'account'; private readonly keycloakApi = axios.create({ @@ -19,6 +20,12 @@ export class AuthService { }, }); + getUrl() { + return process.env.NODE_ENV === 'development' + ? 'https://auth.clipperia.com.br' + : 'http://keycloak:8080'; + } + async login(loginDto: LoginDto) { try { const params = new URLSearchParams({ diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index b30792a..fc0cac7 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,15 +1,10 @@ -import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class LoginDto { @IsString() - @IsNotEmpty() + @IsOptional() username: string; - @IsString() - @IsEmail() - @IsNotEmpty() - email: string; - @IsString() @IsNotEmpty() password: string; diff --git a/src/auth/keycloak-auth.guard.ts b/src/auth/keycloak-auth.guard.ts index c256b68..4820ac9 100644 --- a/src/auth/keycloak-auth.guard.ts +++ b/src/auth/keycloak-auth.guard.ts @@ -4,7 +4,7 @@ import type { JwtPayload } from './keycloak.strategy'; @Injectable() export class KeycloakAuthGuard extends AuthGuard('jwt') { - handleRequest(err: unknown, user: JwtPayload): JwtPayload { + handleRequest(err: any, user: JwtPayload): TUser { if (err || !user) { if (err instanceof UnauthorizedException) { throw err; @@ -13,6 +13,6 @@ export class KeycloakAuthGuard extends AuthGuard('jwt') { throw new UnauthorizedException('Usuário não autenticado'); } - return user; + return user as unknown as TUser; } } diff --git a/src/auth/keycloak.strategy.ts b/src/auth/keycloak.strategy.ts index d32b9ec..b5edbe1 100644 --- a/src/auth/keycloak.strategy.ts +++ b/src/auth/keycloak.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import * as jwksRsa from 'jwks-rsa'; @@ -32,23 +32,31 @@ export interface JwtPayload { @Injectable() export class KeycloakJwtStrategy extends PassportStrategy(Strategy, 'jwt') { constructor() { + const baseUrl = + process.env.NODE_ENV === 'production' + ? 'http://keycloak:8080' + : 'https://auth.clipperia.com.br'; + super({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKeyProvider: jwksRsa.passportJwtSecret({ cache: true, rateLimit: true, jwksRequestsPerMinute: 5, - jwksUri: - 'https://auth.clipperia.com.br/realms/clipperia/protocol/openid-connect/certs', + jwksUri: `${baseUrl}/realms/clipperia/protocol/openid-connect/certs`, }), algorithms: ['RS256'], - audience: 'account', - issuer: 'https://auth.clipperia.com.br/realms/clipperia', + issuer: `${baseUrl}/realms/clipperia`, ignoreExpiration: false, }); } validate(payload: JwtPayload): JwtPayload { + if (payload.exp < Date.now() / 1000) { + throw new UnauthorizedException('Token expirado'); + } + return payload; } }