velody/backend/src/modules/auth/device-auth.service.ts
2026-06-09 12:05:15 +02:00

124 lines
3.1 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';
import { OwnerContext } from '../users/owner-context.service';
@Injectable()
export class DeviceAuthService {
constructor(
private readonly prismaService: PrismaService,
private readonly requestContext: RequestContextService,
private readonly ownerContext: OwnerContext,
) {}
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;
}
async resolveCurrentDevice(
legacyDeviceId?: string,
): Promise<AuthenticatedDeviceContextValue> {
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
if (authenticatedDevice) {
return authenticatedDevice;
}
if (!legacyDeviceId) {
throw new BadRequestException(
'deviceId is required when Authorization is missing.',
);
}
const owner = await this.ownerContext.resolve();
const device = await this.prismaService.device.findUnique({
where: {
id: legacyDeviceId,
},
select: {
id: true,
userId: true,
},
});
if (!device || device.userId !== owner.userId) {
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();
}
}