Finaliza auth de rotas
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user