Add device management foundation

This commit is contained in:
diyaa 2026-06-25 05:48:15 +02:00
parent 8107458ce3
commit df2d4f99e3
3 changed files with 555 additions and 0 deletions

View File

@ -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<string, any>([
[
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<string, any>([
[
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<string, unknown>, select?: Record<string, boolean>) => {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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'));
});
});

View File

@ -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<ManagedDeviceSummary[]> {
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<UserDeviceRecord> {
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<ManagedDeviceSummary> {
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<RotatedRecoveryDeviceTokenResult> {
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<Prisma.TransactionClient, 'device'>,
device: Pick<UserDeviceRecord, 'id'>,
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');
}
}
}

View File

@ -5,6 +5,7 @@ import { AuthModule } from '../auth/auth.module';
import { AccountController } from './account.controller'; import { AccountController } from './account.controller';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { DefaultUserService } from './default-user.service'; import { DefaultUserService } from './default-user.service';
import { DeviceManagementService } from './device-management.service';
import { DeviceLinkingService } from './device-linking.service'; import { DeviceLinkingService } from './device-linking.service';
import { OAuthIdentityService } from './oauth-identity.service'; import { OAuthIdentityService } from './oauth-identity.service';
import { OwnershipTransferService } from './ownership-transfer.service'; import { OwnershipTransferService } from './ownership-transfer.service';
@ -20,6 +21,7 @@ import { StorageModule } from '../storage/storage.module';
providers: [ providers: [
AccountService, AccountService,
DefaultUserService, DefaultUserService,
DeviceManagementService,
DeviceLinkingService, DeviceLinkingService,
OAuthIdentityService, OAuthIdentityService,
OwnershipTransferService, OwnershipTransferService,
@ -33,6 +35,7 @@ import { StorageModule } from '../storage/storage.module';
DefaultUserService, DefaultUserService,
OwnerContext, OwnerContext,
AccountService, AccountService,
DeviceManagementService,
DeviceLinkingService, DeviceLinkingService,
OAuthIdentityService, OAuthIdentityService,
OwnershipTransferService, OwnershipTransferService,