Finaliza auth de rotas

This commit is contained in:
LeoMortari
2025-09-11 22:25:11 -03:00
parent 13b41d2f52
commit 7139633915
6 changed files with 187 additions and 18 deletions

View File

@@ -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",

View File

@@ -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<typeof axios>;
describe('AuthService', () => {
let service: AuthService;
@@ -10,9 +17,160 @@ describe('AuthService', () => {
}).compile();
service = module.get<AuthService>(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,
);
});
});
});

View File

@@ -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({

View File

@@ -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;

View File

@@ -4,7 +4,7 @@ import type { JwtPayload } from './keycloak.strategy';
@Injectable()
export class KeycloakAuthGuard extends AuthGuard('jwt') {
handleRequest(err: unknown, user: JwtPayload): JwtPayload {
handleRequest<TUser = JwtPayload>(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;
}
}

View File

@@ -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;
}
}