From 67a378edebfe3710612c39e92400d207ab1cb938 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Fri, 29 May 2026 12:44:54 +0100 Subject: [PATCH] feat(auth): improve token refresh response dto documentation Add token_type field to TokenResponseDto and the refresh() service method. The OAuth 2.0 convention requires token_type: 'Bearer' in token responses so that consumers know how to send the token in the Authorization header. Previously this field was absent from both the service response and the DTO, leaving API consumers to infer the token type from convention alone. Changes: - TokenResponseDto: add token_type field with ApiProperty decorator documenting its value and usage - auth.service.ts refresh(): return token_type: 'Bearer' alongside access_token; also add explicit User | null type annotation to fix a pre-existing build error in the resetPassword method - auth.controller.ts: add missing @ApiResponse({ status: 400 }) decorator to the refresh endpoint; Prettier formatting applied - auth.service.spec.ts: assert token_type === 'Bearer' in the refresh success test; fix resetPassword tests to use find mock instead of the incorrect findOne mock Fixes #317 Fixes #614 --- .../backend/src/auth/auth.controller.ts | 48 ++++++++++++------- .../backend/src/auth/auth.service.spec.ts | 12 +++-- .../backend/src/auth/auth.service.ts | 4 +- .../backend/src/auth/dto/auth-response.dto.ts | 9 +++- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/harvest-finance/backend/src/auth/auth.controller.ts b/harvest-finance/backend/src/auth/auth.controller.ts index 43ed8544a..d815b309e 100644 --- a/harvest-finance/backend/src/auth/auth.controller.ts +++ b/harvest-finance/backend/src/auth/auth.controller.ts @@ -22,7 +22,11 @@ import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; -import { AuthResponseDto, LogoutResponseDto, TokenResponseDto } from './dto/auth-response.dto'; +import { + AuthResponseDto, + LogoutResponseDto, + TokenResponseDto, +} from './dto/auth-response.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { RateLimit } from '../common/decorators/rate-limit.decorator'; import { RateLimitGuard } from '../common/guards/rate-limit.guard'; @@ -107,6 +111,10 @@ export class AuthController { status: 401, description: 'Invalid or expired refresh token', }) + @ApiResponse({ + status: 400, + description: 'Validation error — refresh_token field is missing or malformed', + }) async refresh( @Body() refreshTokenDto: RefreshTokenDto, ): Promise { @@ -126,9 +134,7 @@ export class AuthController { description: 'Logged out successfully', type: LogoutResponseDto, }) - async logout( - @Req() req: Request, - ): Promise { + async logout(@Req() req: Request): Promise { const token = (req as any).headers.authorization?.replace('Bearer ', ''); return this.authService.logout(token); } @@ -142,7 +148,11 @@ export class AuthController { */ @Post('forgot-password') @UseGuards(RateLimitGuard) - @RateLimit({ limit: 5, ttl: 3600, message: 'Too many password reset requests. Please try again in 1 hour.' }) + @RateLimit({ + limit: 5, + ttl: 3600, + message: 'Too many password reset requests. Please try again in 1 hour.', + }) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Request password reset' }) @ApiBody({ type: ForgotPasswordDto }) @@ -164,7 +174,11 @@ export class AuthController { */ @Post('reset-password') @UseGuards(RateLimitGuard) - @RateLimit({ limit: 5, ttl: 3600, message: 'Too many password reset attempts. Please try again in 1 hour.' }) + @RateLimit({ + limit: 5, + ttl: 3600, + message: 'Too many password reset attempts. Please try again in 1 hour.', + }) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Reset password with token' }) @ApiBody({ type: ResetPasswordDto }) @@ -236,16 +250,16 @@ export class AuthController { const user = await this.stellarStrategy.validate(verifyDto.transaction); const tokens = await this.authService['generateTokens'](user); -return { - access_token: tokens.accessToken, - refresh_token: tokens.refreshToken, - user: { - id: user.id, - stellar_address: user.stellarAddress ?? '', - role: user.role, - full_name: - [user.firstName, user.lastName].filter(Boolean).join(' ') || '', - }, - }; + return { + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + user: { + id: user.id, + stellar_address: user.stellarAddress ?? '', + role: user.role, + full_name: + [user.firstName, user.lastName].filter(Boolean).join(' ') || '', + }, + }; } } diff --git a/harvest-finance/backend/src/auth/auth.service.spec.ts b/harvest-finance/backend/src/auth/auth.service.spec.ts index 54a4e11ff..0674d8aa1 100644 --- a/harvest-finance/backend/src/auth/auth.service.spec.ts +++ b/harvest-finance/backend/src/auth/auth.service.spec.ts @@ -43,6 +43,7 @@ describe('AuthService', () => { beforeEach(async () => { mockUserRepository = { findOne: jest.fn(), + find: jest.fn(), create: jest.fn(), save: jest.fn(), update: jest.fn(), @@ -222,6 +223,7 @@ describe('AuthService', () => { expect(result).toHaveProperty('access_token'); expect(result.access_token).toBe('new_access_token'); + expect(result).toHaveProperty('token_type', 'Bearer'); }); }); @@ -269,8 +271,8 @@ describe('AuthService', () => { new_password: 'NewSecurePass123!', }; - it('should throw BadRequestException if token is invalid', async () => { - mockUserRepository.findOne.mockResolvedValue(null); + it('should throw BadRequestException if no active reset tokens exist', async () => { + mockUserRepository.find.mockResolvedValue([]); await expect(service.resetPassword(resetPasswordDto)).rejects.toThrow( BadRequestException, @@ -278,14 +280,14 @@ describe('AuthService', () => { }); it('should successfully reset password', async () => { - const hashedToken = await bcrypt.hash('valid_token', 10); const userWithToken = { ...mockUser, - resetPasswordToken: hashedToken, + resetPasswordToken: 'hashed_valid_token', resetPasswordExpires: new Date(Date.now() + 3600000), }; - mockUserRepository.findOne.mockResolvedValue(userWithToken); + mockUserRepository.find.mockResolvedValue([userWithToken]); (bcrypt.compare as jest.Mock).mockResolvedValue(true); + (bcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password'); mockUserRepository.update.mockResolvedValue({ affected: 1 }); const result = await service.resetPassword(resetPasswordDto); diff --git a/harvest-finance/backend/src/auth/auth.service.ts b/harvest-finance/backend/src/auth/auth.service.ts index 5d7a90c7e..be4c245e8 100644 --- a/harvest-finance/backend/src/auth/auth.service.ts +++ b/harvest-finance/backend/src/auth/auth.service.ts @@ -205,7 +205,7 @@ export class AuthService { }, ); - return { access_token: accessToken }; + return { access_token: accessToken, token_type: 'Bearer' }; } catch (error) { throw new UnauthorizedException('Invalid or expired refresh token'); } @@ -299,7 +299,7 @@ export class AuthService { select: ['id', 'password', 'resetPasswordToken', 'resetPasswordExpires'], }); - let user = null; + let user: User | null = null; for (const u of activeUsers) { if ( u.resetPasswordToken && diff --git a/harvest-finance/backend/src/auth/dto/auth-response.dto.ts b/harvest-finance/backend/src/auth/dto/auth-response.dto.ts index d8e7eec01..65bfe9add 100644 --- a/harvest-finance/backend/src/auth/dto/auth-response.dto.ts +++ b/harvest-finance/backend/src/auth/dto/auth-response.dto.ts @@ -76,9 +76,16 @@ export class TokenResponseDto { /** Freshly issued short-lived JWT access token. */ @ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - description: 'Access token (JWT)', + description: 'JWT access token to be sent as a Bearer token in the Authorization header', }) access_token: string; + + /** OAuth 2.0 token type. Always "Bearer" for this API. */ + @ApiProperty({ + example: 'Bearer', + description: 'Token type — always "Bearer". Prefix the access_token with this value in the Authorization header.', + }) + token_type: string; } /** Response shape returned after a successful logout. */