import { Inject, Injectable } from '@nestjs/common' import { createHmac, randomUUID } from 'crypto' import { PASSPORT_OPTIONS } from './constants' import { PassportOptions, TokenRolePayload } from './interfaces' import { base64UrlDecode, base64UrlEncode, constantTimeEqual } from './utils' const TOKEN_TYPES = { AUTH: 'PassportToken/v1', TEMP_2FA: 'PassportToken/v1/2fa' } as const type TokenType = (typeof TOKEN_TYPES)[keyof typeof TOKEN_TYPES] @Injectable() export class PassportService { private readonly SECRET_KEY: string private static readonly INTERNAL_SEPARATOR = '|' public constructor( @Inject(PASSPORT_OPTIONS) private readonly options: PassportOptions ) { this.SECRET_KEY = options.secretKey } public generate( userId: string, role: TokenRolePayload, ttl: number, tokenType: TokenType = TOKEN_TYPES.AUTH ) { const issuedAt = this.now() const expiresAt = issuedAt + ttl const jti = randomUUID() const userPart = base64UrlEncode(userId) const rolePart = base64UrlEncode(JSON.stringify(role)) const iatPart = base64UrlEncode(String(issuedAt)) const expPart = base64UrlEncode(String(expiresAt)) const jtiPart = base64UrlEncode(jti) const serialized = this.serialize( tokenType, userPart, rolePart, iatPart, expPart, jtiPart ) const mac = this.computeHmac(this.SECRET_KEY, serialized) return `${userPart}.${rolePart}.${iatPart}.${expPart}.${jtiPart}.${mac}` } public verify(token: string, expectedType: TokenType = TOKEN_TYPES.AUTH) { try { const parts = token.split('.') if (parts.length !== 6) return { valid: false, reason: 'Не верный формат токена.' } const [userPart, rolePart, iatPart, expPart, jtiPart, mac] = parts const serialized = this.serialize( expectedType, userPart, rolePart, iatPart, expPart, jtiPart ) const expectedMac = this.computeHmac(this.SECRET_KEY, serialized) if (!constantTimeEqual(expectedMac, mac)) return { valid: false, reason: 'Невалидная подпись токена или неверный формат.' } const expNumber = Number(base64UrlDecode(expPart)) if (!Number.isFinite(expNumber)) return { valid: false, reason: 'Ошибка формата даты.' } if (this.now() > expNumber) return { valid: false, reason: 'Срок действия токена истёк.' } const decodedRole = JSON.parse( base64UrlDecode(rolePart) ) as TokenRolePayload return { valid: true, userId: base64UrlDecode(userPart), role: decodedRole, jti: base64UrlDecode(jtiPart) } } catch (error) { return { valid: false, reason: 'Поврежденный токен.' } } } private now() { return Math.floor(Date.now() / 1000) } private serialize( domain: string, user: string, role: string, iat: string, exp: string, jti: string ) { return [domain, user, role, iat, exp, jti].join( PassportService.INTERNAL_SEPARATOR ) } private computeHmac(secretKey: string, data: string) { return createHmac('sha256', secretKey).update(data).digest('hex') } }