Add multi-device identity foundation

This commit is contained in:
diyaa
2026-06-10 10:50:46 +02:00
parent 45c270c187
commit 8902efb92e
12 changed files with 689 additions and 49 deletions
@@ -6,7 +6,44 @@ import { RequestContextService } from './request-context.service';
export class RequestContextMiddleware implements NestMiddleware {
constructor(private readonly requestContext: RequestContextService) {}
use(_request: Request, _response: Response, next: NextFunction): void {
this.requestContext.run(() => next());
use(request: Request, _response: Response, next: NextFunction): void {
this.requestContext.run(() => {
const legacyDeviceId = this.extractLegacyDeviceId(request);
if (legacyDeviceId) {
this.requestContext.setLegacyDeviceId(legacyDeviceId);
}
next();
});
}
private extractLegacyDeviceId(request: Request): string | null {
return (
this.readDeviceIdCandidate(request.body) ??
this.readDeviceIdCandidate(request.query)
);
}
private readDeviceIdCandidate(source: unknown): string | null {
if (!source || typeof source !== 'object') {
return null;
}
const rawDeviceId = (source as { deviceId?: unknown }).deviceId;
if (typeof rawDeviceId === 'string' && rawDeviceId.trim()) {
return rawDeviceId.trim();
}
if (!Array.isArray(rawDeviceId)) {
return null;
}
const firstString = rawDeviceId.find(
(value): value is string => typeof value === 'string' && value.trim().length > 0,
);
return firstString?.trim() ?? null;
}
}
@@ -8,6 +8,7 @@ export interface AuthenticatedDeviceContextValue {
interface RequestContextState {
authenticatedDevice: AuthenticatedDeviceContextValue | null;
legacyDeviceId: string | null;
}
@Injectable()
@@ -18,6 +19,7 @@ export class RequestContextService {
return this.storage.run(
{
authenticatedDevice: null,
legacyDeviceId: null,
},
callback,
);
@@ -36,4 +38,18 @@ export class RequestContextService {
store.authenticatedDevice = device;
}
getLegacyDeviceId(): string | null {
return this.storage.getStore()?.legacyDeviceId ?? null;
}
setLegacyDeviceId(deviceId: string): void {
const store = this.storage.getStore();
if (!store) {
return;
}
store.legacyDeviceId = deviceId;
}
}
+1 -2
View File
@@ -2,12 +2,11 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
import { UsersModule } from '../users/users.module';
import { DeviceAuthGuard } from './device-auth.guard';
import { DeviceAuthService } from './device-auth.service';
@Module({
imports: [PrismaModule, RequestContextModule, UsersModule],
imports: [PrismaModule, RequestContextModule],
providers: [DeviceAuthService, DeviceAuthGuard],
exports: [DeviceAuthService, DeviceAuthGuard],
})
@@ -5,7 +5,6 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
import { OwnerContext } from '../users/owner-context.service';
import { DeviceAuthService } from './device-auth.service';
describe('DeviceAuthService', () => {
@@ -13,9 +12,6 @@ describe('DeviceAuthService', () => {
const userId = randomUUID();
const deviceId = randomUUID();
const requestContext = new RequestContextService();
const ownerContext = {
resolve: jest.fn().mockResolvedValue({ userId }),
} as OwnerContext;
const service = new DeviceAuthService(
{
device: {
@@ -28,7 +24,6 @@ describe('DeviceAuthService', () => {
},
} as any,
requestContext,
ownerContext,
);
const token = service.generateDeviceAccessToken();
@@ -63,9 +58,6 @@ describe('DeviceAuthService', () => {
},
} as any,
requestContext,
{
resolve: jest.fn(),
} as any,
);
await expect(
@@ -83,11 +75,8 @@ describe('DeviceAuthService', () => {
it('falls back to legacy device ids only when no authenticated device is present', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const otherDeviceId = randomUUID();
const missingDeviceId = randomUUID();
const requestContext = new RequestContextService();
const ownerContext = {
resolve: jest.fn().mockResolvedValue({ userId }),
} as any;
const service = new DeviceAuthService(
{
device: {
@@ -99,27 +88,19 @@ describe('DeviceAuthService', () => {
};
}
if (where.id === otherDeviceId) {
return {
id: otherDeviceId,
userId: randomUUID(),
};
}
return null;
}),
update: jest.fn(),
},
} as any,
requestContext,
ownerContext as OwnerContext,
);
await expect(
requestContext.run(() => service.resolveCurrentDevice()),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
requestContext.run(() => service.resolveCurrentDevice(otherDeviceId)),
requestContext.run(() => service.resolveCurrentDevice(missingDeviceId)),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
requestContext.run(() => service.resolveCurrentDevice(deviceId)),
@@ -128,4 +109,32 @@ describe('DeviceAuthService', () => {
userId,
});
});
it('uses the request-context legacy device id when the caller does not pass one explicitly', async () => {
const userId = randomUUID();
const deviceId = randomUUID();
const requestContext = new RequestContextService();
const service = new DeviceAuthService(
{
device: {
findUnique: jest.fn().mockResolvedValue({
id: deviceId,
userId,
}),
update: jest.fn(),
},
} as any,
requestContext,
);
await expect(
requestContext.run(async () => {
requestContext.setLegacyDeviceId(deviceId);
return service.resolveCurrentDevice();
}),
).resolves.toEqual({
deviceId,
userId,
});
});
});
@@ -10,14 +10,15 @@ import {
AuthenticatedDeviceContextValue,
RequestContextService,
} from '../../infrastructure/request-context/request-context.service';
import { OwnerContext } from '../users/owner-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,
private readonly ownerContext: OwnerContext,
) {}
generateDeviceAccessToken(): string {
@@ -76,16 +77,22 @@ export class DeviceAuthService {
return authenticatedDevice;
}
if (!legacyDeviceId) {
const requestedDeviceId =
legacyDeviceId ?? this.requestContext.getLegacyDeviceId();
if (!requestedDeviceId) {
throw new BadRequestException(
'deviceId is required when Authorization is missing.',
);
}
const owner = await this.ownerContext.resolve();
if (!UUID_PATTERN.test(requestedDeviceId)) {
throw new BadRequestException('deviceId must be a UUID');
}
const device = await this.prismaService.device.findUnique({
where: {
id: legacyDeviceId,
id: requestedDeviceId,
},
select: {
id: true,
@@ -93,7 +100,7 @@ export class DeviceAuthService {
},
});
if (!device || device.userId !== owner.userId) {
if (!device) {
throw new NotFoundException('Device not found');
}
@@ -23,6 +23,8 @@ export class DevicesController {
constructor(private readonly devicesService: DevicesService) {}
@Post('register')
@UseGuards(DeviceAuthGuard)
@ApiBearerAuth()
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
async register(
@Body() body: RegisterDeviceRequestDto,
+4 -1
View File
@@ -2,6 +2,7 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import {
SyncBootstrapQueryDto,
SyncBootstrapResponseDto,
SyncChangesQueryDto,
SyncChangesResponseDto,
@@ -20,7 +21,9 @@ export class SyncController {
@Get('bootstrap')
@ApiOkResponse({ type: SyncBootstrapResponseDto })
async bootstrap(): Promise<SyncBootstrapResponseDto> {
async bootstrap(
@Query() _query?: SyncBootstrapQueryDto,
): Promise<SyncBootstrapResponseDto> {
return this.syncService.bootstrap();
}
+14 -2
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, Matches } from 'class-validator';
import { IsOptional, IsString, IsUUID, Matches } from 'class-validator';
export class LibraryTrackDto {
@ApiProperty({ format: 'uuid', required: false })
@@ -43,7 +43,19 @@ export class SyncBootstrapResponseDto {
serverTime!: string;
}
export class SyncChangesQueryDto {
export class SyncBootstrapQueryDto {
@ApiProperty({
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
})
@IsOptional()
@IsUUID()
deviceId?: string;
}
export class SyncChangesQueryDto extends SyncBootstrapQueryDto {
@ApiProperty({ required: false, example: '0' })
@IsOptional()
@IsString()
@@ -16,6 +16,11 @@ describe('BootstrapOwnerContextService', () => {
const service = new BootstrapOwnerContextService(
defaultUserService,
new RequestContextService(),
{
device: {
findUnique: jest.fn(),
},
} as any,
);
await expect(service.resolve()).resolves.toEqual({
@@ -29,13 +34,23 @@ describe('BootstrapOwnerContextService', () => {
const defaultUserService = {
getOrCreateDefaultUser: jest.fn(),
} as any;
const legacyOwnerId = randomUUID();
const prismaService = {
device: {
findUnique: jest.fn().mockResolvedValue({
userId: legacyOwnerId,
}),
},
} as any;
const service = new BootstrapOwnerContextService(
defaultUserService,
requestContext,
prismaService,
);
const authenticatedOwnerId = randomUUID();
await requestContext.run(async () => {
requestContext.setLegacyDeviceId(randomUUID());
requestContext.setAuthenticatedDevice({
deviceId: randomUUID(),
userId: authenticatedOwnerId,
@@ -47,5 +62,45 @@ describe('BootstrapOwnerContextService', () => {
});
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
expect(prismaService.device.findUnique).not.toHaveBeenCalled();
});
it('falls back to the legacy device owner before the bootstrap default owner', async () => {
const requestContext = new RequestContextService();
const defaultUserService = {
getOrCreateDefaultUser: jest.fn(),
} as any;
const legacyDeviceId = randomUUID();
const legacyOwnerId = randomUUID();
const prismaService = {
device: {
findUnique: jest.fn().mockResolvedValue({
userId: legacyOwnerId,
}),
},
} as any;
const service = new BootstrapOwnerContextService(
defaultUserService,
requestContext,
prismaService,
);
await requestContext.run(async () => {
requestContext.setLegacyDeviceId(legacyDeviceId);
await expect(service.resolve()).resolves.toEqual({
userId: legacyOwnerId,
});
});
expect(prismaService.device.findUnique).toHaveBeenCalledWith({
where: {
id: legacyDeviceId,
},
select: {
userId: true,
},
});
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,9 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
import { DefaultUserService } from './default-user.service';
@@ -10,11 +15,15 @@ export abstract class OwnerContext {
abstract resolve(): Promise<ResolvedOwnerContext>;
}
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 BootstrapOwnerContextService extends OwnerContext {
constructor(
private readonly defaultUserService: DefaultUserService,
private readonly requestContext: RequestContextService,
private readonly prismaService: PrismaService,
) {
super();
}
@@ -28,6 +37,31 @@ export class BootstrapOwnerContextService extends OwnerContext {
};
}
const legacyDeviceId = this.requestContext.getLegacyDeviceId();
if (legacyDeviceId) {
if (!UUID_PATTERN.test(legacyDeviceId)) {
throw new BadRequestException('deviceId must be a UUID');
}
const legacyDevice = await this.prismaService.device.findUnique({
where: {
id: legacyDeviceId,
},
select: {
userId: true,
},
});
if (!legacyDevice) {
throw new NotFoundException('Device not found');
}
return {
userId: legacyDevice.userId,
};
}
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
return {