velody/backend/src/modules/auth/device-auth.service.ts
2026-06-14 09:43:41 +02:00

147 lines
3.7 KiB
TypeScript

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<AuthenticatedDeviceContextValue> {
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<AuthenticatedDeviceContextValue> {
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();
}
}