Add multi-device identity foundation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user