From df2d4f99e31ba9d374740d971dcd3df15570360f Mon Sep 17 00:00:00 2001 From: diyaa Date: Thu, 25 Jun 2026 05:48:15 +0200 Subject: [PATCH] Add device management foundation --- .../users/device-management.service.spec.ts | 308 ++++++++++++++++++ .../users/device-management.service.ts | 244 ++++++++++++++ backend/src/modules/users/users.module.ts | 3 + 3 files changed, 555 insertions(+) create mode 100644 backend/src/modules/users/device-management.service.spec.ts create mode 100644 backend/src/modules/users/device-management.service.ts diff --git a/backend/src/modules/users/device-management.service.spec.ts b/backend/src/modules/users/device-management.service.spec.ts new file mode 100644 index 0000000..409a8a0 --- /dev/null +++ b/backend/src/modules/users/device-management.service.spec.ts @@ -0,0 +1,308 @@ +import { randomUUID } from 'node:crypto'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { UserAccountStatus } from '@prisma/client'; +import { DeviceAuthService } from '../auth/device-auth.service'; +import { DeviceManagementService } from './device-management.service'; + +describe('DeviceManagementService', () => { + const activeUserId = randomUUID(); + const foreignUserId = randomUUID(); + const lockedUserId = randomUUID(); + const deletedUserId = randomUUID(); + + const buildService = () => { + const ownedDeviceId = randomUUID(); + const foreignDeviceId = randomUUID(); + const devices = new Map([ + [ + ownedDeviceId, + { + id: ownedDeviceId, + userId: activeUserId, + platform: 'IOS', + deviceName: 'Primary iPhone', + appVersion: '1.2.3', + lastSeenAt: new Date('2026-06-25T08:00:00.000Z'), + tokenHash: 'owned-token-hash', + tokenLastUsedAt: new Date('2026-06-25T08:30:00.000Z'), + tokenRevokedAt: null, + linkedAt: new Date('2026-06-20T08:00:00.000Z'), + relinkedAt: null, + createdAt: new Date('2026-06-20T08:00:00.000Z'), + }, + ], + [ + foreignDeviceId, + { + id: foreignDeviceId, + userId: foreignUserId, + platform: 'ANDROID', + deviceName: 'Foreign Pixel', + appVersion: '9.9.9', + lastSeenAt: new Date('2026-06-24T08:00:00.000Z'), + tokenHash: 'foreign-token-hash', + tokenLastUsedAt: new Date('2026-06-24T08:30:00.000Z'), + tokenRevokedAt: null, + linkedAt: new Date('2026-06-18T08:00:00.000Z'), + relinkedAt: new Date('2026-06-21T08:00:00.000Z'), + createdAt: new Date('2026-06-18T08:00:00.000Z'), + }, + ], + ]); + + const users = new Map([ + [ + activeUserId, + { + id: activeUserId, + accountStatus: UserAccountStatus.ACTIVE, + }, + ], + [ + foreignUserId, + { + id: foreignUserId, + accountStatus: UserAccountStatus.ACTIVE, + }, + ], + [ + lockedUserId, + { + id: lockedUserId, + accountStatus: UserAccountStatus.LOCKED, + }, + ], + [ + deletedUserId, + { + id: deletedUserId, + accountStatus: UserAccountStatus.DELETED, + }, + ], + ]); + + const deviceLinkHistoryCreates: any[] = []; + + const selectFields = (record: Record, select?: Record) => { + if (!select) { + return record; + } + + return Object.fromEntries( + Object.keys(select).map((key) => [key, record[key]]), + ); + }; + + const device = { + findMany: jest.fn().mockImplementation(async ({ where, select }) => + Array.from(devices.values()) + .filter((candidate) => candidate.userId === where.userId) + .sort( + (left, right) => + left.createdAt.getTime() - right.createdAt.getTime(), + ) + .map((candidate) => + selectFields(candidate as Record, select), + ), + ), + findUnique: jest.fn().mockImplementation(async ({ where, select }) => { + let record: any = null; + + if (where.id) { + record = devices.get(where.id) ?? null; + } else if (where.tokenHash) { + record = + Array.from(devices.values()).find( + (candidate) => candidate.tokenHash === where.tokenHash, + ) ?? null; + } + + if (!record) { + return null; + } + + return selectFields(record as Record, select); + }), + update: jest.fn().mockImplementation(async ({ where, data, select }) => { + const record = devices.get(where.id); + if (!record) { + return null; + } + + Object.assign(record, data); + + return selectFields(record as Record, select); + }), + }; + + const user = { + findUnique: jest.fn().mockImplementation(async ({ where, select }) => { + const record = users.get(where.id) ?? null; + if (!record) { + return null; + } + + return selectFields(record as Record, select); + }), + }; + + const tx = { + device, + deviceLinkHistory: { + create: jest.fn().mockImplementation(async ({ data }) => { + deviceLinkHistoryCreates.push(data); + return { + id: randomUUID(), + ...data, + }; + }), + }, + }; + + const prismaService = { + device, + user, + deviceLinkHistory: tx.deviceLinkHistory, + $transaction: jest.fn().mockImplementation(async (callback) => callback(tx)), + } as any; + + const deviceAuthService = { + generateDeviceAccessToken: jest.fn().mockReturnValue('rotated-device-token'), + hashDeviceAccessToken: jest.fn().mockReturnValue('rotated-device-token-hash'), + } as any; + + const service = new DeviceManagementService( + prismaService, + deviceAuthService as DeviceAuthService, + ); + + return { + service, + prismaService, + deviceAuthService, + devices, + deviceLinkHistoryCreates, + ownedDeviceId, + foreignDeviceId, + }; + }; + + it('lists only devices owned by the requested user', async () => { + const { service, ownedDeviceId, foreignDeviceId } = buildService(); + + const result = await service.listDevicesForUser(activeUserId); + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe(ownedDeviceId); + expect(result.map((device) => device.id)).not.toContain(foreignDeviceId); + }); + + it('never exposes token hashes when listing devices', async () => { + const { service } = buildService(); + + const result = await service.listDevicesForUser(activeUserId); + + expect(result[0]).toEqual({ + id: expect.any(String), + platform: 'IOS', + deviceName: 'Primary iPhone', + appVersion: '1.2.3', + lastSeenAt: new Date('2026-06-25T08:00:00.000Z'), + tokenLastUsedAt: new Date('2026-06-25T08:30:00.000Z'), + tokenRevokedAt: null, + linkedAt: new Date('2026-06-20T08:00:00.000Z'), + relinkedAt: null, + }); + expect(result[0]).not.toHaveProperty('tokenHash'); + }); + + it('revokes an owned device without deleting it', async () => { + const { service, devices, ownedDeviceId } = buildService(); + const revokedAt = new Date('2026-06-25T12:00:00.000Z'); + + const result = await service.revokeDevice(activeUserId, ownedDeviceId, revokedAt); + + expect(result.tokenRevokedAt).toEqual(revokedAt); + expect(devices.get(ownedDeviceId)?.tokenRevokedAt).toEqual(revokedAt); + expect(devices.get(ownedDeviceId)).toBeDefined(); + }); + + it('rejects revoking a foreign device', async () => { + const { service, foreignDeviceId } = buildService(); + + await expect( + service.revokeDevice(activeUserId, foreignDeviceId), + ).rejects.toThrow(new BadRequestException('Device does not belong to account')); + }); + + it('rotates the token for an owned device during recovery', async () => { + const { service, devices, deviceLinkHistoryCreates, deviceAuthService, ownedDeviceId } = + buildService(); + const now = new Date('2026-06-25T14:00:00.000Z'); + + const result = await service.rotateDeviceTokenForRecovery( + activeUserId, + ownedDeviceId, + now, + ); + + expect(result).toEqual({ + deviceId: ownedDeviceId, + deviceAccessToken: 'rotated-device-token', + tokenHash: 'rotated-device-token-hash', + tokenCreatedAt: now, + tokenRevokedAt: null, + tokenLastUsedAt: null, + }); + expect(deviceAuthService.generateDeviceAccessToken).toHaveBeenCalled(); + expect(deviceAuthService.hashDeviceAccessToken).toHaveBeenCalledWith( + 'rotated-device-token', + ); + expect(devices.get(ownedDeviceId)).toMatchObject({ + tokenHash: 'rotated-device-token-hash', + tokenCreatedAt: now, + tokenLastUsedAt: null, + tokenRevokedAt: null, + }); + expect(deviceLinkHistoryCreates).toContainEqual({ + userId: activeUserId, + deviceId: ownedDeviceId, + linkedByDeviceId: null, + linkMethod: DeviceManagementService.recoveryAuditMethod, + }); + }); + + it('rejects recovery rotation for locked users', async () => { + const { service, ownedDeviceId, devices } = buildService(); + devices.get(ownedDeviceId).userId = lockedUserId; + + await expect( + service.rotateDeviceTokenForRecovery(lockedUserId, ownedDeviceId), + ).rejects.toThrow(new BadRequestException('Account is locked')); + }); + + it('rejects recovery rotation for deleted users', async () => { + const { service, ownedDeviceId, devices } = buildService(); + devices.get(ownedDeviceId).userId = deletedUserId; + + await expect( + service.rotateDeviceTokenForRecovery(deletedUserId, ownedDeviceId), + ).rejects.toThrow(new BadRequestException('Account is deleted')); + }); + + it('rejects recovery rotation for foreign devices', async () => { + const { service, foreignDeviceId } = buildService(); + + await expect( + service.rotateDeviceTokenForRecovery(activeUserId, foreignDeviceId), + ).rejects.toThrow(new BadRequestException('Device does not belong to account')); + }); + + it('throws when validating an unknown device', async () => { + const { service } = buildService(); + + await expect( + service.validateDeviceBelongsToUser(activeUserId, randomUUID()), + ).rejects.toThrow(new NotFoundException('Device not found')); + }); +}); diff --git a/backend/src/modules/users/device-management.service.ts b/backend/src/modules/users/device-management.service.ts new file mode 100644 index 0000000..431cf03 --- /dev/null +++ b/backend/src/modules/users/device-management.service.ts @@ -0,0 +1,244 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, UserAccountStatus } from '@prisma/client'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { DeviceAuthService } from '../auth/device-auth.service'; + +const RECOVERY_AUDIT_METHOD = 'INTERNAL_DEVICE_RECOVERY'; + +type DeviceManagementTransactionClient = Pick< + Prisma.TransactionClient, + 'device' | 'deviceLinkHistory' +>; + +interface UserDeviceRecord { + id: string; + userId: string; + platform: string; + deviceName: string; + appVersion: string; + lastSeenAt: Date; + tokenLastUsedAt: Date | null; + tokenRevokedAt: Date | null; + linkedAt: Date | null; + relinkedAt: Date | null; +} + +interface DeviceOwnerRecord { + id: string; + accountStatus: UserAccountStatus; +} + +export interface ManagedDeviceSummary { + id: string; + platform: string; + deviceName: string; + appVersion: string; + lastSeenAt: Date; + tokenLastUsedAt: Date | null; + tokenRevokedAt: Date | null; + linkedAt: Date | null; + relinkedAt: Date | null; +} + +export interface RotatedRecoveryDeviceTokenResult { + deviceId: string; + deviceAccessToken: string; + tokenHash: string; + tokenCreatedAt: Date; + tokenRevokedAt: null; + tokenLastUsedAt: null; +} + +@Injectable() +export class DeviceManagementService { + static readonly recoveryAuditMethod = RECOVERY_AUDIT_METHOD; + + constructor( + private readonly prismaService: PrismaService, + private readonly deviceAuthService: DeviceAuthService, + ) {} + + async listDevicesForUser(userId: string): Promise { + return this.prismaService.device.findMany({ + where: { + userId, + }, + orderBy: { + createdAt: 'asc', + }, + select: { + id: true, + platform: true, + deviceName: true, + appVersion: true, + lastSeenAt: true, + tokenLastUsedAt: true, + tokenRevokedAt: true, + linkedAt: true, + relinkedAt: true, + }, + }); + } + + async validateDeviceBelongsToUser( + userId: string, + deviceId: string, + ): Promise { + const device = await this.prismaService.device.findUnique({ + where: { + id: deviceId, + }, + select: { + id: true, + userId: true, + platform: true, + deviceName: true, + appVersion: true, + lastSeenAt: true, + tokenLastUsedAt: true, + tokenRevokedAt: true, + linkedAt: true, + relinkedAt: true, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + if (device.userId !== userId) { + throw new BadRequestException('Device does not belong to account'); + } + + return device; + } + + async revokeDevice( + userId: string, + deviceId: string, + now = new Date(), + ): Promise { + await this.validateDeviceBelongsToUser(userId, deviceId); + + return this.prismaService.device.update({ + where: { + id: deviceId, + }, + data: { + tokenRevokedAt: now, + }, + select: { + id: true, + platform: true, + deviceName: true, + appVersion: true, + lastSeenAt: true, + tokenLastUsedAt: true, + tokenRevokedAt: true, + linkedAt: true, + relinkedAt: true, + }, + }); + } + + async rotateDeviceTokenForRecovery( + userId: string, + deviceId: string, + now = new Date(), + ): Promise { + const [device, user] = await Promise.all([ + this.validateDeviceBelongsToUser(userId, deviceId), + this.prismaService.user.findUnique({ + where: { + id: userId, + }, + select: { + id: true, + accountStatus: true, + }, + }), + ]); + + if (!user) { + throw new NotFoundException('Account not found'); + } + + this.assertUserCanRecoverDevice(user); + + return this.prismaService.$transaction(async (tx) => { + const rotatedToken = await this.rotateDeviceToken(tx, device, now); + + await tx.deviceLinkHistory.create({ + data: { + userId, + deviceId: device.id, + linkedByDeviceId: null, + linkMethod: RECOVERY_AUDIT_METHOD, + }, + }); + + return { + deviceId: device.id, + tokenRevokedAt: null, + tokenLastUsedAt: null, + ...rotatedToken, + }; + }); + } + + async rotateDeviceToken( + client: Pick, + device: Pick, + now = new Date(), + ): Promise<{ + deviceAccessToken: string; + tokenHash: string; + tokenCreatedAt: Date; + }> { + const deviceAccessToken = this.deviceAuthService.generateDeviceAccessToken(); + const tokenHash = + this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken); + + await client.device.update({ + where: { + id: device.id, + }, + data: { + tokenHash: null, + tokenRevokedAt: now, + }, + }); + + await client.device.update({ + where: { + id: device.id, + }, + data: { + tokenHash, + tokenCreatedAt: now, + tokenLastUsedAt: null, + tokenRevokedAt: null, + }, + }); + + return { + deviceAccessToken, + tokenHash, + tokenCreatedAt: now, + }; + } + + private assertUserCanRecoverDevice(user: DeviceOwnerRecord): void { + if (user.accountStatus === UserAccountStatus.LOCKED) { + throw new BadRequestException('Account is locked'); + } + + if (user.accountStatus === UserAccountStatus.DELETED) { + throw new BadRequestException('Account is deleted'); + } + } +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index 6ac7152..52d858f 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -5,6 +5,7 @@ import { AuthModule } from '../auth/auth.module'; import { AccountController } from './account.controller'; import { AccountService } from './account.service'; import { DefaultUserService } from './default-user.service'; +import { DeviceManagementService } from './device-management.service'; import { DeviceLinkingService } from './device-linking.service'; import { OAuthIdentityService } from './oauth-identity.service'; import { OwnershipTransferService } from './ownership-transfer.service'; @@ -20,6 +21,7 @@ import { StorageModule } from '../storage/storage.module'; providers: [ AccountService, DefaultUserService, + DeviceManagementService, DeviceLinkingService, OAuthIdentityService, OwnershipTransferService, @@ -33,6 +35,7 @@ import { StorageModule } from '../storage/storage.module'; DefaultUserService, OwnerContext, AccountService, + DeviceManagementService, DeviceLinkingService, OAuthIdentityService, OwnershipTransferService,