Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 114 additions & 2 deletions backend/src/modules/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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');
}

Comment thread
GitAddRemote marked this conversation as resolved.
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,
};
Comment thread
GitAddRemote marked this conversation as resolved.

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);
});
});
});
});
23 changes: 12 additions & 11 deletions backend/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,39 +79,38 @@ export class AuthService {
return result;
}

private hashToken(raw: string): string {
return crypto.createHash('sha256').update(raw).digest('hex');
}

async generateRefreshToken(userId: number): Promise<string> {
// 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,
Comment thread
GitAddRemote marked this conversation as resolved.
});

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'],
Comment thread
GitAddRemote marked this conversation as resolved.
});

if (!storedToken) {
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');
}
Expand All @@ -121,7 +120,6 @@ export class AuthService {
revoked: true,
});

// Generate new tokens
const payload = {
username: storedToken.user.username,
sub: storedToken.user.id,
Expand All @@ -138,7 +136,10 @@ export class AuthService {
}

async revokeRefreshToken(token: string): Promise<void> {
await this.refreshTokenRepository.update({ token }, { revoked: true });
await this.refreshTokenRepository.update(
{ token: this.hashToken(token) },
{ revoked: true },
);
}

async requestPasswordReset(email: string): Promise<{ message: string }> {
Expand Down
Loading