From 2a4659d4594d1d498b16c2e93d5d9df0bdc0e2c4 Mon Sep 17 00:00:00 2001 From: Demian Date: Sat, 11 Apr 2026 01:12:40 -0400 Subject: [PATCH 1/5] fix: hash refresh tokens at rest in the database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store SHA-256(token) in the database instead of the raw token value. The raw token is returned to the caller only; the hash is used for all DB lookups and updates. Existing plaintext tokens become invalid on deploy — users must re-login (acceptable per spec). - Add private hashToken() helper using crypto.sha256 - generateRefreshToken: saves hash, returns raw value - refreshAccessToken: hashes incoming token before lookup - revokeRefreshToken: hashes incoming token before update Closes #96 --- backend/src/modules/auth/auth.service.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 }> { From 958b0f948e4efc2654c513f2974e980880da4fba Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 10:02:55 -0400 Subject: [PATCH 2/5] test: add unit tests for refresh token SHA-256 hashing behavior Covers three contracts: - generateRefreshToken() returns the raw token and persists only its hash - refreshAccessToken() queries by hash, rejects tokens with no hash match - revokeRefreshToken() passes the hash (not the raw token) to the update call --- backend/src/modules/auth/auth.service.spec.ts | 111 +++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 15d8910..c8d5ef9 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -7,8 +7,13 @@ 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', () => { let service: AuthService; @@ -342,4 +347,108 @@ 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 days from now', async () => { + const now = Date.now(); + let savedData: any; + mockRefreshTokenRepository.save.mockImplementation((data) => { + savedData = data; + return Promise.resolve(data); + }); + + await service.generateRefreshToken(mockUser.id); + + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + expect( + Math.abs(savedData.expiresAt.getTime() - (now + sevenDaysMs)), + ).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: 1, + 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); + }); + }); + }); }); From 1c0c93b85bd6771d54ea816a3f18e9c16e963261 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 10:09:21 -0400 Subject: [PATCH 3/5] fix: make refresh token expiry assertion DST-safe setDate(+7) adds calendar days, not a fixed millisecond count, so comparing against 7 * 24h would be flaky across DST transitions. Mirror the implementation by computing expected via setDate(+7). --- backend/src/modules/auth/auth.service.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index c8d5ef9..9046a59 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -376,8 +376,8 @@ describe('AuthService - Password Reset', () => { expect(savedData.token).not.toBe(raw); }); - it('should set expiry 7 days from now', async () => { - const now = Date.now(); + it('should set expiry 7 calendar days from now', async () => { + const now = new Date(); let savedData: any; mockRefreshTokenRepository.save.mockImplementation((data) => { savedData = data; @@ -386,9 +386,12 @@ describe('AuthService - Password Reset', () => { await service.generateRefreshToken(mockUser.id); - const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + // 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() - (now + sevenDaysMs)), + Math.abs(savedData.expiresAt.getTime() - expected.getTime()), ).toBeLessThan(1000); }); }); From 758272bc4f40a0b8a4da216271410234440b8806 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 10:17:23 -0400 Subject: [PATCH 4/5] test: rename top-level describe to AuthService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite now covers password reset, change password, and refresh token hashing — scoping it to "Password Reset" was misleading. --- backend/src/modules/auth/auth.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 9046a59..6c86367 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -15,7 +15,7 @@ import { import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; -describe('AuthService - Password Reset', () => { +describe('AuthService', () => { let service: AuthService; const mockUser = { From 01547fd22092f2337295db7535aad56fb262a5ac Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 10:24:25 -0400 Subject: [PATCH 5/5] test: use UUID string for RefreshToken id fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RefreshToken.id is @PrimaryGeneratedColumn('uuid') — a string, not a number. Using id: 1 in the mock fixture misrepresents the entity shape and could mask bugs in code paths that call update(storedToken.id, ...). --- backend/src/modules/auth/auth.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 6c86367..b34ebc3 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -400,7 +400,7 @@ describe('AuthService', () => { it('should query the repository using the SHA-256 hash of the presented token', async () => { const rawToken = 'a'.repeat(64); const storedToken = { - id: 1, + id: '550e8400-e29b-41d4-a716-446655440001', token: sha256(rawToken), revoked: false, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),