import { BadRequestException, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { createHash, randomBytes } from 'node:crypto'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { AuthenticatedDeviceContextValue, RequestContextService, } from '../../infrastructure/request-context/request-context.service'; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @Injectable() export class DeviceAuthService { constructor( private readonly prismaService: PrismaService, private readonly requestContext: RequestContextService, ) {} generateDeviceAccessToken(): string { return randomBytes(32).toString('base64url'); } hashDeviceAccessToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } async authenticateAuthorizationHeader( authorizationHeader: string | string[], ): Promise { const token = this.extractBearerToken(authorizationHeader); const tokenHash = this.hashDeviceAccessToken(token); const device = await this.prismaService.device.findUnique({ where: { tokenHash, }, select: { id: true, userId: true, tokenRevokedAt: true, }, }); if (!device || device.tokenRevokedAt) { throw new UnauthorizedException('Invalid device access token'); } await this.prismaService.device.update({ where: { id: device.id, }, data: { tokenLastUsedAt: new Date(), }, }); const authenticatedDevice = { deviceId: device.id, userId: device.userId, }; this.requestContext.setAuthenticatedDevice(authenticatedDevice); return authenticatedDevice; } getAuthenticatedDeviceOrThrow(): AuthenticatedDeviceContextValue { const authenticatedDevice = this.getAuthenticatedDevice(); if (!authenticatedDevice) { throw new UnauthorizedException('Authorization header is required'); } return authenticatedDevice; } getAuthenticatedDevice(): AuthenticatedDeviceContextValue | null { return this.requestContext.getAuthenticatedDevice(); } async resolveCurrentDevice( legacyDeviceId?: string, ): Promise { try { return this.getAuthenticatedDeviceOrThrow(); } catch (error) { if (!(error instanceof UnauthorizedException)) { throw error; } } const requestedDeviceId = legacyDeviceId ?? this.requestContext.getLegacyDeviceId(); if (!requestedDeviceId) { throw new BadRequestException( 'deviceId is required when Authorization is missing.', ); } if (!UUID_PATTERN.test(requestedDeviceId)) { throw new BadRequestException('deviceId must be a UUID'); } const device = await this.prismaService.device.findUnique({ where: { id: requestedDeviceId, }, select: { id: true, userId: true, }, }); if (!device) { throw new NotFoundException('Device not found'); } return { deviceId: device.id, userId: device.userId, }; } private extractBearerToken(authorizationHeader: string | string[]): string { const normalizedHeader = Array.isArray(authorizationHeader) ? authorizationHeader[0] : authorizationHeader; if (!normalizedHeader) { throw new UnauthorizedException('Invalid device access token'); } const match = normalizedHeader.match(/^Bearer\s+(.+)$/i); if (!match || !match[1]?.trim()) { throw new UnauthorizedException('Invalid device access token'); } return match[1].trim(); } }