147 lines
3.7 KiB
TypeScript
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();
|
|
}
|
|
}
|