Files
dmenu-api/src/modules/auth/services/tokens.service.ts
T
2025-12-06 22:51:34 +03:30

108 lines
4.0 KiB
TypeScript
Executable File

import { createHash, randomBytes } from 'node:crypto';
import { EntityManager } from '@mikro-orm/postgresql';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import dayjs from 'dayjs';
import { AuthMessage } from '../../../common/enums/message.enum';
import { RefreshToken, RefreshTokenType } from '../entities/refresh-token.entity';
import { IAdminTokenPayload, ITokenPayload } from '../interfaces/IToken-payload';
@Injectable()
export class TokensService {
private readonly logger = new Logger(TokensService.name);
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly em: EntityManager,
) {}
async generateAccessAndRefreshToken(ownerId: string, restaurantId: string, isAdmin: boolean) {
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
? { adminId: ownerId, restId: restaurantId }
: { userId: ownerId, restId: restaurantId };
const accessToken = await this.generateAccessToken(payload, accessExpire);
const refreshToken = this.generateRefreshToken();
const type = isAdmin ? RefreshTokenType.ADMIN : RefreshTokenType.USER;
await this.storeRefreshToken(ownerId, restaurantId, refreshToken, type);
return {
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
refreshToken: { token: refreshToken, expire: dayjs().add(refreshExpire, 'day').valueOf() },
};
}
private generateAccessToken(payload: ITokenPayload | IAdminTokenPayload, expiresIn: number) {
// Ensure expiresIn is passed as a string with time unit for reliability
// JWT library accepts: number (seconds) or string with unit (e.g., "3600s", "1h")
// Using string format is more explicit and prevents unit confusion
return this.jwtService.signAsync(payload, { expiresIn: `${expiresIn}s` });
}
async storeRefreshToken(ownerId: string, restId: string, refreshToken: string, type: RefreshTokenType) {
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
const expiresAt = dayjs().add(refreshExpire, 'day').toDate();
const hashedToken = this.hashToken(refreshToken);
const token = this.em.create(RefreshToken, {
hashedToken,
ownerId,
restId,
type,
expiresAt,
});
return this.em.persistAndFlush(token);
}
async refreshToken(oldRefreshToken: string) {
const hashedToken = this.hashToken(oldRefreshToken);
const token = await this.em.findOne(RefreshToken, { hashedToken });
if (!token) throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
if (dayjs(token.expiresAt).isBefore(dayjs())) {
this.em.remove(token);
await this.em.flush();
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
}
// Store token data before removal
const ownerId = token.ownerId;
const restId = token.restId;
// Remove the old token
this.em.remove(token);
const isAdmin = token.type === RefreshTokenType.ADMIN;
try {
// Generate new tokens
const tokens = await this.generateAccessAndRefreshToken(ownerId, restId, isAdmin);
// Commit the transaction
await this.em.flush();
return tokens;
} catch (error) {
// If token generation fails, the old token is already removed
// This is acceptable as the token was already used
this.logger.error('Failed to generate new tokens after refresh', error);
throw new UnauthorizedException('Failed to refresh token');
}
}
private generateRefreshToken() {
return randomBytes(32).toString('hex');
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}