diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 15d8910..b34ebc3 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -7,10 +7,15 @@ import { ConfigService } from '@nestjs/config'; import { getRepositoryToken } from '@nestjs/typeorm'; import { RefreshToken } from './refresh-token.entity'; import { PasswordReset } from './password-reset.entity'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; -describe('AuthService - Password Reset', () => { +describe('AuthService', () => { let service: AuthService; const mockUser = { @@ -342,4 +347,111 @@ describe('AuthService - Password Reset', () => { expect(matches).toBe(true); }); }); + + describe('refresh token hashing', () => { + function sha256(raw: string): string { + return crypto.createHash('sha256').update(raw).digest('hex'); + } + + describe('generateRefreshToken', () => { + it('should return the raw token, not the hash', async () => { + mockRefreshTokenRepository.save.mockResolvedValue({}); + + const raw = await service.generateRefreshToken(mockUser.id); + + // 32 random bytes encoded as hex = 64 chars + expect(raw).toMatch(/^[0-9a-f]{64}$/); + }); + + it('should persist the SHA-256 hash, not the raw token', async () => { + let savedData: any; + mockRefreshTokenRepository.save.mockImplementation((data) => { + savedData = data; + return Promise.resolve(data); + }); + + const raw = await service.generateRefreshToken(mockUser.id); + + expect(savedData.token).toBe(sha256(raw)); + expect(savedData.token).not.toBe(raw); + }); + + it('should set expiry 7 calendar days from now', async () => { + const now = new Date(); + let savedData: any; + mockRefreshTokenRepository.save.mockImplementation((data) => { + savedData = data; + return Promise.resolve(data); + }); + + await service.generateRefreshToken(mockUser.id); + + // Mirror the implementation's setDate(+7) so the assertion is + // DST-safe (calendar days ≠ exactly 7 * 24h across DST boundaries). + const expected = new Date(now); + expected.setDate(expected.getDate() + 7); + expect( + Math.abs(savedData.expiresAt.getTime() - expected.getTime()), + ).toBeLessThan(1000); + }); + }); + + describe('refreshAccessToken', () => { + it('should query the repository using the SHA-256 hash of the presented token', async () => { + const rawToken = 'a'.repeat(64); + const storedToken = { + id: '550e8400-e29b-41d4-a716-446655440001', + token: sha256(rawToken), + revoked: false, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + user: mockUser, + userId: mockUser.id, + }; + + mockRefreshTokenRepository.findOne.mockResolvedValue(storedToken); + mockRefreshTokenRepository.update.mockResolvedValue({ affected: 1 }); + mockRefreshTokenRepository.save.mockResolvedValue({}); + mockJwtService.sign.mockReturnValue('new-access-token'); + + await service.refreshAccessToken(rawToken); + + expect(mockRefreshTokenRepository.findOne).toHaveBeenCalledWith( + expect.objectContaining({ where: { token: sha256(rawToken) } }), + ); + }); + + it('should reject a token that has no matching hash in the database', async () => { + mockRefreshTokenRepository.findOne.mockResolvedValue(null); + + await expect( + service.refreshAccessToken('plaintext-token'), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('revokeRefreshToken', () => { + it('should update using the SHA-256 hash of the presented token', async () => { + const rawToken = 'b'.repeat(64); + mockRefreshTokenRepository.update.mockResolvedValue({ affected: 1 }); + + await service.revokeRefreshToken(rawToken); + + expect(mockRefreshTokenRepository.update).toHaveBeenCalledWith( + { token: sha256(rawToken) }, + { revoked: true }, + ); + }); + + it('should not pass the raw token to the repository', async () => { + const rawToken = 'plaintext-token'; + mockRefreshTokenRepository.update.mockResolvedValue({ affected: 0 }); + + await service.revokeRefreshToken(rawToken); + + const callArg = mockRefreshTokenRepository.update.mock.calls[0][0]; + expect(callArg.token).toBe(sha256(rawToken)); + expect(callArg.token).not.toBe(rawToken); + }); + }); + }); }); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 2917db2..23f03ca 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -79,31 +79,31 @@ export class AuthService { return result; } + private hashToken(raw: string): string { + return crypto.createHash('sha256').update(raw).digest('hex'); + } + async generateRefreshToken(userId: number): Promise { - // Generate a random token - const token = crypto.randomBytes(32).toString('hex'); + const raw = crypto.randomBytes(32).toString('hex'); - // Calculate expiry (7 days from now) const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); - // Save to database await this.refreshTokenRepository.save({ - token, + token: this.hashToken(raw), userId, expiresAt, revoked: false, }); - return token; + return raw; } async refreshAccessToken( refreshToken: string, ): Promise<{ access_token: string; refresh_token: string }> { - // Find the refresh token const storedToken = await this.refreshTokenRepository.findOne({ - where: { token: refreshToken }, + where: { token: this.hashToken(refreshToken) }, relations: ['user'], }); @@ -111,7 +111,6 @@ export class AuthService { throw new UnauthorizedException('Invalid refresh token'); } - // Check if token is expired or revoked if (storedToken.revoked || new Date() > storedToken.expiresAt) { throw new UnauthorizedException('Refresh token expired or revoked'); } @@ -121,7 +120,6 @@ export class AuthService { revoked: true, }); - // Generate new tokens const payload = { username: storedToken.user.username, sub: storedToken.user.id, @@ -138,7 +136,10 @@ export class AuthService { } async revokeRefreshToken(token: string): Promise { - await this.refreshTokenRepository.update({ token }, { revoked: true }); + await this.refreshTokenRepository.update( + { token: this.hashToken(token) }, + { revoked: true }, + ); } async requestPasswordReset(email: string): Promise<{ message: string }> {