Harden protected route authentication

This commit is contained in:
diyaa
2026-06-14 09:43:41 +02:00
parent 8902efb92e
commit fa7727d572
32 changed files with 765 additions and 296 deletions
+2
View File
@@ -5,6 +5,7 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
const setTitle = jest.fn().mockReturnThis();
const setDescription = jest.fn().mockReturnThis();
const setVersion = jest.fn().mockReturnThis();
const addBearerAuth = jest.fn().mockReturnThis();
const build = jest.fn().mockReturnValue({});
const createDocument = jest.fn().mockReturnValue({});
const setup = jest.fn();
@@ -23,6 +24,7 @@ jest.mock('@nestjs/swagger', () => {
setTitle,
setDescription,
setVersion,
addBearerAuth,
build,
})),
SwaggerModule: {
+9
View File
@@ -37,6 +37,15 @@ export async function createApp(): Promise<NestExpressApplication> {
.setTitle('Velody API')
.setDescription('Velody Phase 1 foundation API')
.setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'Bearer',
description: 'Device access token',
},
'bearer',
)
.build(),
);
@@ -42,10 +42,8 @@ export class ArtworkController {
@Query() query: AssetDownloadQueryDto,
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
const download = await this.artworkService.getOwnedArtworkDownload(
artworkId,
query.deviceId,
);
void query;
const download = await this.artworkService.getOwnedArtworkDownload(artworkId);
response.setHeader('Content-Type', download.mimeType);
response.setHeader('Content-Length', String(download.contentLength));
@@ -42,7 +42,7 @@ describe('ArtworkService', () => {
let storageService: LocalFilesystemStorageService;
let ownerUserId: string;
let deviceAuthService: {
resolveCurrentDevice: jest.Mock;
getAuthenticatedDeviceOrThrow: jest.Mock;
};
beforeEach(async () => {
@@ -52,7 +52,7 @@ describe('ArtworkService', () => {
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
ownerUserId = randomUUID();
deviceAuthService = {
resolveCurrentDevice: jest.fn().mockResolvedValue({
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: randomUUID(),
userId: ownerUserId,
}),
@@ -87,7 +87,7 @@ describe('ArtworkService', () => {
await storageService.ensureParentDirectory(filePath);
await writeFile(filePath, bytes);
const download = await service.getOwnedArtworkDownload(artworkId, randomUUID());
const download = await service.getOwnedArtworkDownload(artworkId);
expect(download.filePath).toBe(filePath);
expect(download.contentLength).toBe(bytes.length);
@@ -105,17 +105,17 @@ describe('ArtworkService', () => {
});
await expect(
service.getOwnedArtworkDownload(artworkId, randomUUID()),
service.getOwnedArtworkDownload(artworkId),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects foreign-owner devices before reading artwork', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
);
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
throw new NotFoundException('Device not found');
});
await expect(
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
service.getOwnedArtworkDownload(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
@@ -130,13 +130,13 @@ describe('ArtworkService', () => {
});
await expect(
service.getOwnedArtworkDownload(artworkId, randomUUID()),
service.getOwnedArtworkDownload(artworkId),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the artwork asset does not exist', async () => {
await expect(
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
service.getOwnedArtworkDownload(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -24,10 +24,9 @@ export class ArtworkService {
async getOwnedArtworkDownload(
artworkId: string,
legacyDeviceId?: string,
): Promise<ArtworkDownload> {
const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const artwork = await this.prismaService.artworkAsset.findUnique({
where: { id: artworkId },
@@ -42,10 +42,8 @@ export class AssetsController {
@Query() query: AssetDownloadQueryDto,
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
const download = await this.assetsService.getOwnedAudioAssetDownload(
assetId,
query.deviceId,
);
void query;
const download = await this.assetsService.getOwnedAudioAssetDownload(assetId);
response.setHeader('Content-Type', 'audio/mpeg');
response.setHeader('Content-Length', String(download.contentLength));
+1 -1
View File
@@ -6,7 +6,7 @@ export class AssetDownloadQueryDto {
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
})
@IsOptional()
@IsUUID()
@@ -42,7 +42,7 @@ describe('AssetsService', () => {
let storageService: LocalFilesystemStorageService;
let ownerUserId: string;
let deviceAuthService: {
resolveCurrentDevice: jest.Mock;
getAuthenticatedDeviceOrThrow: jest.Mock;
};
beforeEach(async () => {
@@ -52,7 +52,7 @@ describe('AssetsService', () => {
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
ownerUserId = randomUUID();
deviceAuthService = {
resolveCurrentDevice: jest.fn().mockResolvedValue({
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: randomUUID(),
userId: ownerUserId,
}),
@@ -84,7 +84,7 @@ describe('AssetsService', () => {
await storageService.ensureParentDirectory(filePath);
await writeFile(filePath, assetBytes);
const download = await service.getOwnedAudioAssetDownload(assetId, randomUUID());
const download = await service.getOwnedAudioAssetDownload(assetId);
expect(download.filePath).toBe(filePath);
expect(download.contentLength).toBe(assetBytes.length);
@@ -101,17 +101,17 @@ describe('AssetsService', () => {
});
await expect(
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
service.getOwnedAudioAssetDownload(assetId),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects foreign-owner devices before reading audio assets', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
);
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
throw new NotFoundException('Device not found');
});
await expect(
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
service.getOwnedAudioAssetDownload(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
@@ -126,17 +126,17 @@ describe('AssetsService', () => {
});
await expect(
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
service.getOwnedAudioAssetDownload(assetId),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns not found when the device does not exist', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
);
deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
throw new NotFoundException('Device not found');
});
await expect(
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
service.getOwnedAudioAssetDownload(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
});
+1 -2
View File
@@ -23,10 +23,9 @@ export class AssetsService {
async getOwnedAudioAssetDownload(
assetId: string,
legacyDeviceId?: string,
): Promise<AudioAssetDownload> {
const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const asset = await this.prismaService.audioAsset.findUnique({
where: { id: assetId },
+17 -3
View File
@@ -4,14 +4,28 @@ import { RequestContextMiddleware } from '../../infrastructure/request-context/r
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
import { DeviceAuthGuard } from './device-auth.guard';
import { DeviceAuthService } from './device-auth.service';
import { OptionalDeviceAuthGuard } from './optional-device-auth.guard';
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
@Module({
imports: [PrismaModule, RequestContextModule],
providers: [DeviceAuthService, DeviceAuthGuard],
exports: [DeviceAuthService, DeviceAuthGuard],
providers: [
DeviceAuthService,
DeviceAuthGuard,
OptionalDeviceAuthGuard,
ProtectedDeviceAuthMiddleware,
],
exports: [
DeviceAuthService,
DeviceAuthGuard,
OptionalDeviceAuthGuard,
ProtectedDeviceAuthMiddleware,
],
})
export class AuthModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestContextMiddleware).forRoutes('*');
consumer
.apply(RequestContextMiddleware, ProtectedDeviceAuthMiddleware)
.forRoutes('*');
}
}
+12 -1
View File
@@ -2,6 +2,7 @@ import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { Request } from 'express';
import { DeviceAuthService } from './device-auth.service';
@@ -12,10 +13,20 @@ export class DeviceAuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const existingAuthenticatedDevice =
this.deviceAuthService.getAuthenticatedDevice();
if (existingAuthenticatedDevice) {
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
existingAuthenticatedDevice;
return true;
}
const authorization = request.headers.authorization;
if (!authorization) {
return true;
throw new UnauthorizedException('Authorization header is required');
}
const authenticatedDevice =
@@ -68,13 +68,29 @@ export class DeviceAuthService {
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> {
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
if (authenticatedDevice) {
return authenticatedDevice;
try {
return this.getAuthenticatedDeviceOrThrow();
} catch (error) {
if (!(error instanceof UnauthorizedException)) {
throw error;
}
}
const requestedDeviceId =
@@ -0,0 +1,41 @@
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import type { Request } from 'express';
import { DeviceAuthService } from './device-auth.service';
@Injectable()
export class OptionalDeviceAuthGuard implements CanActivate {
constructor(private readonly deviceAuthService: DeviceAuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const existingAuthenticatedDevice =
this.deviceAuthService.getAuthenticatedDevice();
if (existingAuthenticatedDevice) {
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
existingAuthenticatedDevice;
return true;
}
const authorization = request.headers.authorization;
if (!authorization) {
return true;
}
const authenticatedDevice =
await this.deviceAuthService.authenticateAuthorizationHeader(
authorization,
);
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
authenticatedDevice;
return true;
}
}
@@ -0,0 +1,165 @@
import { UnauthorizedException } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
function createResponseMock() {
const response = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
} as unknown as Response;
return response;
}
describe('ProtectedDeviceAuthMiddleware', () => {
it.each([
'/api/v1/library/tracks',
'/api/v1/sync/bootstrap',
'/api/v1/sync/changes',
'/api/v1/assets/asset-id/download',
'/api/v1/artwork/artwork-id/download',
'/api/v1/uploads/prepare',
'/api/v1/uploads/upload-id',
'/api/v1/uploads/upload-id/file',
'/api/v1/uploads/upload-id/finalize',
'/api/v1/devices/heartbeat',
])(
'returns 401 before validation-relevant request data can matter when Authorization is missing on %s',
async (path) => {
const authenticateAuthorizationHeader = jest.fn();
const middleware = new ProtectedDeviceAuthMiddleware({
authenticateAuthorizationHeader,
} as any);
const request = {
path,
originalUrl: `${path}?deviceId=not-a-uuid`,
headers: {},
body: {
deviceId: 'not-a-uuid',
appVersion: '',
sha256: 'invalid-sha',
},
query: {
deviceId: 'not-a-uuid',
after: 'not-a-cursor',
},
} as unknown as Request;
const response = createResponseMock();
const next = jest.fn() as NextFunction;
await middleware.use(request, response, next);
expect(authenticateAuthorizationHeader).not.toHaveBeenCalled();
expect(response.status).toHaveBeenCalledWith(401);
expect(response.json).toHaveBeenCalledWith({
statusCode: 401,
message: 'Authorization header is required',
error: 'Unauthorized',
});
expect(next).not.toHaveBeenCalled();
},
);
it.each([
'/api/v1/library/tracks',
'/api/v1/sync/changes',
'/api/v1/uploads/prepare',
'/api/v1/devices/heartbeat',
])(
'returns 401 before validation-relevant request data can matter when Authorization is invalid on %s',
async (path) => {
const authenticateAuthorizationHeader = jest
.fn()
.mockRejectedValue(
new UnauthorizedException('Invalid device access token'),
);
const middleware = new ProtectedDeviceAuthMiddleware({
authenticateAuthorizationHeader,
} as any);
const request = {
path,
originalUrl: `${path}?deviceId=not-a-uuid`,
headers: {
authorization: 'Bearer invalid-device-token',
},
body: {
deviceId: 'not-a-uuid',
appVersion: '',
sha256: 'invalid-sha',
},
query: {
deviceId: 'not-a-uuid',
after: 'not-a-cursor',
},
} as unknown as Request;
const response = createResponseMock();
const next = jest.fn() as NextFunction;
await middleware.use(request, response, next);
expect(authenticateAuthorizationHeader).toHaveBeenCalledWith(
'Bearer invalid-device-token',
);
expect(response.status).toHaveBeenCalledWith(401);
expect(response.json).toHaveBeenCalledWith({
statusCode: 401,
message: 'Invalid device access token',
error: 'Unauthorized',
});
expect(next).not.toHaveBeenCalled();
},
);
it('allows public routes through without requiring Authorization', async () => {
const authenticateAuthorizationHeader = jest.fn();
const middleware = new ProtectedDeviceAuthMiddleware({
authenticateAuthorizationHeader,
} as any);
const request = {
path: '/api/v1/devices/register',
originalUrl: '/api/v1/devices/register',
headers: {},
} as unknown as Request;
const response = createResponseMock();
const next = jest.fn() as NextFunction;
await middleware.use(request, response, next);
expect(authenticateAuthorizationHeader).not.toHaveBeenCalled();
expect(response.status).not.toHaveBeenCalled();
expect(response.json).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
it('stores the authenticated device and proceeds when Authorization is valid', async () => {
const authenticatedDevice = {
deviceId: 'device-id',
userId: 'user-id',
};
const authenticateAuthorizationHeader = jest
.fn()
.mockResolvedValue(authenticatedDevice);
const middleware = new ProtectedDeviceAuthMiddleware({
authenticateAuthorizationHeader,
} as any);
const request = {
path: '/api/v1/library/tracks',
originalUrl: '/api/v1/library/tracks?deviceId=not-a-uuid',
headers: {
authorization: 'Bearer valid-device-token',
},
} as Request & { authenticatedDevice?: unknown };
const response = createResponseMock();
const next = jest.fn() as NextFunction;
await middleware.use(request, response, next);
expect(authenticateAuthorizationHeader).toHaveBeenCalledWith(
'Bearer valid-device-token',
);
expect(request.authenticatedDevice).toEqual(authenticatedDevice);
expect(response.status).not.toHaveBeenCalled();
expect(response.json).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,82 @@
import {
HttpException,
Injectable,
NestMiddleware,
UnauthorizedException,
} from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { DeviceAuthService } from './device-auth.service';
const PROTECTED_ROUTE_PREFIXES = [
'/api/v1/artwork',
'/api/v1/assets',
'/api/v1/library',
'/api/v1/sync',
'/api/v1/uploads',
];
const PROTECTED_ROUTE_EXACT_PATHS = new Set(['/api/v1/devices/heartbeat']);
@Injectable()
export class ProtectedDeviceAuthMiddleware implements NestMiddleware {
constructor(private readonly deviceAuthService: DeviceAuthService) {}
async use(
request: Request,
response: Response,
next: NextFunction,
): Promise<void> {
if (!this.isProtectedRoute(request)) {
next();
return;
}
const authorization = request.headers.authorization;
if (!authorization) {
this.replyWithException(
response,
new UnauthorizedException('Authorization header is required'),
);
return;
}
try {
const authenticatedDevice =
await this.deviceAuthService.authenticateAuthorizationHeader(
authorization,
);
(request as Request & { authenticatedDevice?: unknown }).authenticatedDevice =
authenticatedDevice;
next();
} catch (error) {
if (!(error instanceof UnauthorizedException)) {
next(error as Error);
return;
}
this.replyWithException(response, error);
}
}
private isProtectedRoute(request: Request): boolean {
const requestPath = request.path ?? request.originalUrl.split('?')[0];
if (PROTECTED_ROUTE_EXACT_PATHS.has(requestPath)) {
return true;
}
return PROTECTED_ROUTE_PREFIXES.some(
(prefix) => requestPath === prefix || requestPath.startsWith(`${prefix}/`),
);
}
private replyWithException(
response: Response,
exception: HttpException,
): void {
response.status(exception.getStatus()).json(exception.getResponse());
}
}
@@ -6,6 +6,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import { OptionalDeviceAuthGuard } from '../auth/optional-device-auth.guard';
import {
DeviceHeartbeatRequestDto,
DeviceHeartbeatResponseDto,
@@ -23,8 +24,7 @@ export class DevicesController {
constructor(private readonly devicesService: DevicesService) {}
@Post('register')
@UseGuards(DeviceAuthGuard)
@ApiBearerAuth()
@UseGuards(OptionalDeviceAuthGuard)
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
async register(
@Body() body: RegisterDeviceRequestDto,
+1 -1
View File
@@ -46,7 +46,7 @@ export class DeviceHeartbeatRequestDto {
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
})
@IsOptional()
@IsUUID()
@@ -25,7 +25,7 @@ describe('DevicesService', () => {
const deviceAuthService = {
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
resolveCurrentDevice: jest.fn(),
getAuthenticatedDeviceOrThrow: jest.fn(),
} as any;
const service = new DevicesService(
prismaService,
@@ -65,9 +65,9 @@ describe('DevicesService', () => {
const deviceAuthService = {
generateDeviceAccessToken: jest.fn(),
hashDeviceAccessToken: jest.fn(),
resolveCurrentDevice: jest
.fn()
.mockRejectedValue(new NotFoundException('Device not found')),
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
throw new NotFoundException('Device not found');
}),
} as any;
const service = new DevicesService(
prismaService,
@@ -54,7 +54,7 @@ export class DevicesService {
async heartbeat(
body: DeviceHeartbeatRequestDto,
): Promise<DeviceHeartbeatResponseDto> {
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
await this.prismaService.device.update({
where: { id: device.deviceId },
@@ -69,9 +69,4 @@ export class DevicesService {
serverTime: new Date().toISOString(),
};
}
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
}
@@ -22,8 +22,9 @@ export class LibraryController {
async getTracks(
@Query() query: LibraryTracksQueryDto,
): Promise<LibraryTracksResponseDto> {
void query;
return {
tracks: await this.libraryService.getRemoteLibraryTracks(query.deviceId),
tracks: await this.libraryService.getRemoteLibraryTracks(),
};
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export class LibraryTracksQueryDto {
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
})
@IsOptional()
@IsUUID()
@@ -60,7 +60,7 @@ describe('LibraryService', () => {
resolve: jest.Mock;
};
let deviceAuthServiceMock: {
resolveCurrentDevice: jest.Mock;
getAuthenticatedDeviceOrThrow: jest.Mock;
};
beforeEach(async () => {
@@ -70,7 +70,7 @@ describe('LibraryService', () => {
resolve: jest.fn(),
};
deviceAuthServiceMock = {
resolveCurrentDevice: jest.fn(),
getAuthenticatedDeviceOrThrow: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
@@ -162,7 +162,7 @@ describe('LibraryService', () => {
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
deviceId: ownerDeviceId,
userId: ownerId,
});
@@ -225,7 +225,7 @@ describe('LibraryService', () => {
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
});
const tracks = await libraryService.getRemoteLibraryTracks(ownerDeviceId);
const tracks = await libraryService.getRemoteLibraryTracks();
expect(tracks).toEqual([
{
@@ -266,14 +266,12 @@ describe('LibraryService', () => {
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
deviceId: ownerDeviceId,
userId: ownerId,
});
await expect(
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
});
it('does not leak remote library tracks from other owners', async () => {
@@ -287,7 +285,7 @@ describe('LibraryService', () => {
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
deviceId: ownerDeviceId,
userId: ownerId,
});
@@ -308,19 +306,19 @@ describe('LibraryService', () => {
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
});
await expect(
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
});
it('throws for an unknown device', async () => {
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockImplementationOnce(
() => {
throw new NotFoundException('Device not found');
},
);
await expect(
libraryService.getRemoteLibraryTracks(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(libraryService.getRemoteLibraryTracks()).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('rejects cross-owner track access through a foreign-owner device', async () => {
@@ -333,13 +331,15 @@ describe('LibraryService', () => {
id: foreignDeviceId,
userId: otherUserId,
});
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
new NotFoundException('Device not found'),
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockImplementationOnce(
() => {
throw new NotFoundException('Device not found');
},
);
await expect(
libraryService.getRemoteLibraryTracks(foreignDeviceId),
).rejects.toBeInstanceOf(NotFoundException);
await expect(libraryService.getRemoteLibraryTracks()).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('skips tracks without a primary audio asset', async () => {
@@ -349,7 +349,7 @@ describe('LibraryService', () => {
userId: ownerId,
});
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
deviceId: ownerDeviceId,
userId: ownerId,
});
@@ -366,8 +366,6 @@ describe('LibraryService', () => {
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
});
await expect(
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
});
});
@@ -37,11 +37,9 @@ export class LibraryService {
}));
}
async getRemoteLibraryTracks(
legacyDeviceId?: string,
): Promise<RemoteLibraryTrackDto[]> {
async getRemoteLibraryTracks(): Promise<RemoteLibraryTrackDto[]> {
const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const tracks = await this.prismaService.track.findMany({
where: {
@@ -113,7 +111,10 @@ export class LibraryService {
}
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
const owner = await this.ownerContext.resolve({
allowLegacyDeviceFallback: false,
allowBootstrapFallback: false,
});
return owner.userId;
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export class SyncBootstrapQueryDto {
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
})
@IsOptional()
@IsUUID()
+4 -1
View File
@@ -39,7 +39,10 @@ export class SyncService {
}
private async getLatestCursor(): Promise<string> {
const owner = await this.ownerContext.resolve();
const owner = await this.ownerContext.resolve({
allowLegacyDeviceFallback: false,
allowBootstrapFallback: false,
});
const latest = await this.prismaService.libraryEvent.findFirst({
where: {
userId: owner.userId,
+1 -1
View File
@@ -18,7 +18,7 @@ export class UploadPrepareRequestDto {
format: 'uuid',
required: false,
description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
})
@IsOptional()
@IsUUID()
@@ -285,8 +285,9 @@ describe('UploadsService', () => {
let storageService: LocalFilesystemStorageService;
let service: UploadsService;
let ownerContext: OwnerContext;
let currentAuthenticatedDeviceId: string | null;
let deviceAuthService: {
resolveCurrentDevice: jest.Mock;
getAuthenticatedDeviceOrThrow: jest.Mock;
};
beforeEach(async () => {
@@ -295,18 +296,19 @@ describe('UploadsService', () => {
state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
currentAuthenticatedDeviceId = null;
ownerContext = {
resolve: jest.fn().mockResolvedValue({
userId: state.defaultUser.id,
}),
} as OwnerContext;
deviceAuthService = {
resolveCurrentDevice: jest.fn().mockImplementation(async (deviceId?: string) => {
if (!deviceId) {
getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
if (!currentAuthenticatedDeviceId) {
throw new NotFoundException('Device not found');
}
const device = state.devices.get(deviceId);
const device = state.devices.get(currentAuthenticatedDeviceId);
if (!device || device.userId !== state.defaultUser.id) {
throw new NotFoundException('Device not found');
@@ -345,6 +347,7 @@ describe('UploadsService', () => {
updatedAt: new Date(),
};
state.devices.set(deviceId, device);
currentAuthenticatedDeviceId = deviceId;
return device;
}
@@ -54,7 +54,7 @@ export class UploadsService {
this.assertFileSizeWithinLimit(body.sizeBytes);
this.assertMp3Filename(body.originalFilename);
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const ownerUserId = device.userId;
const existingAsset = await this.prismaService.audioAsset.findUnique({
@@ -477,7 +477,10 @@ export class UploadsService {
}
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
const owner = await this.ownerContext.resolve({
allowLegacyDeviceFallback: false,
allowBootstrapFallback: false,
});
return owner.userId;
}
@@ -2,6 +2,7 @@ import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
@@ -11,8 +12,15 @@ export interface ResolvedOwnerContext {
userId: string;
}
export interface OwnerContextResolveOptions {
allowLegacyDeviceFallback?: boolean;
allowBootstrapFallback?: boolean;
}
export abstract class OwnerContext {
abstract resolve(): Promise<ResolvedOwnerContext>;
abstract resolve(
options?: OwnerContextResolveOptions,
): Promise<ResolvedOwnerContext>;
}
const UUID_PATTERN =
@@ -28,7 +36,13 @@ export class BootstrapOwnerContextService extends OwnerContext {
super();
}
async resolve(): Promise<ResolvedOwnerContext> {
private async resolveWithOptions(
options: OwnerContextResolveOptions = {},
): Promise<ResolvedOwnerContext> {
const {
allowLegacyDeviceFallback = true,
allowBootstrapFallback = true,
} = options;
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
if (authenticatedDevice) {
@@ -39,7 +53,7 @@ export class BootstrapOwnerContextService extends OwnerContext {
const legacyDeviceId = this.requestContext.getLegacyDeviceId();
if (legacyDeviceId) {
if (allowLegacyDeviceFallback && legacyDeviceId) {
if (!UUID_PATTERN.test(legacyDeviceId)) {
throw new BadRequestException('deviceId must be a UUID');
}
@@ -62,10 +76,20 @@ export class BootstrapOwnerContextService extends OwnerContext {
};
}
if (!allowBootstrapFallback) {
throw new UnauthorizedException('Authorization header is required');
}
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
return {
userId: defaultUser.id,
};
}
async resolve(
options?: OwnerContextResolveOptions,
): Promise<ResolvedOwnerContext> {
return this.resolveWithOptions(options);
}
}