From fa7727d572ae39d84791d3e85c5ae69b4b9c5794 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 14 Jun 2026 09:43:41 +0200 Subject: [PATCH] Harden protected route authentication --- backend/openapi/velody.openapi.json | 27 +- backend/scripts/generate-openapi.ts | 9 + backend/src/app.factory.spec.ts | 2 + backend/src/app.factory.ts | 9 + .../src/modules/artwork/artwork.controller.ts | 6 +- .../modules/artwork/artwork.service.spec.ts | 20 +- .../src/modules/artwork/artwork.service.ts | 3 +- .../src/modules/assets/assets.controller.ts | 6 +- backend/src/modules/assets/assets.dto.ts | 2 +- .../src/modules/assets/assets.service.spec.ts | 26 +- backend/src/modules/assets/assets.service.ts | 3 +- backend/src/modules/auth/auth.module.ts | 20 +- backend/src/modules/auth/device-auth.guard.ts | 13 +- .../src/modules/auth/device-auth.service.ts | 24 +- .../auth/optional-device-auth.guard.ts | 41 ++ .../protected-device-auth.middleware.spec.ts | 165 +++++++ .../auth/protected-device-auth.middleware.ts | 82 ++++ .../src/modules/devices/devices.controller.ts | 4 +- backend/src/modules/devices/devices.dto.ts | 2 +- .../modules/devices/devices.service.spec.ts | 8 +- .../src/modules/devices/devices.service.ts | 7 +- .../src/modules/library/library.controller.ts | 3 +- backend/src/modules/library/library.dto.ts | 2 +- .../modules/library/library.service.spec.ts | 50 +- .../src/modules/library/library.service.ts | 11 +- backend/src/modules/sync/sync.dto.ts | 2 +- backend/src/modules/sync/sync.service.ts | 5 +- backend/src/modules/uploads/uploads.dto.ts | 2 +- .../modules/uploads/uploads.service.spec.ts | 11 +- .../src/modules/uploads/uploads.service.ts | 7 +- .../modules/users/owner-context.service.ts | 30 +- backend/test/e2e/app.e2e-spec.ts | 459 +++++++++++------- 32 files changed, 765 insertions(+), 296 deletions(-) create mode 100644 backend/src/modules/auth/optional-device-auth.guard.ts create mode 100644 backend/src/modules/auth/protected-device-auth.middleware.spec.ts create mode 100644 backend/src/modules/auth/protected-device-auth.middleware.ts diff --git a/backend/openapi/velody.openapi.json b/backend/openapi/velody.openapi.json index 6c624d8..33e7694 100644 --- a/backend/openapi/velody.openapi.json +++ b/backend/openapi/velody.openapi.json @@ -17,7 +17,7 @@ "name": "deviceId", "required": false, "in": "query", - "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", + "description": "Optional client metadata. Authorization: Bearer is required and determines access.", "schema": { "format": "uuid", "type": "string" @@ -63,7 +63,7 @@ "name": "deviceId", "required": false, "in": "query", - "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", + "description": "Optional client metadata. Authorization: Bearer is required and determines access.", "schema": { "format": "uuid", "type": "string" @@ -140,11 +140,6 @@ } } }, - "security": [ - { - "bearer": [] - } - ], "tags": [ "devices" ] @@ -368,7 +363,7 @@ "name": "deviceId", "required": false, "in": "query", - "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", + "description": "Optional client metadata. Authorization: Bearer is required and determines access.", "schema": { "format": "uuid", "type": "string" @@ -405,7 +400,7 @@ "name": "deviceId", "required": false, "in": "query", - "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", + "description": "Optional client metadata. Authorization: Bearer is required and determines access.", "schema": { "format": "uuid", "type": "string" @@ -442,7 +437,7 @@ "name": "deviceId", "required": false, "in": "query", - "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided.", + "description": "Optional client metadata. Authorization: Bearer is required and determines access.", "schema": { "format": "uuid", "type": "string" @@ -490,6 +485,14 @@ "tags": [], "servers": [], "components": { + "securitySchemes": { + "bearer": { + "scheme": "bearer", + "bearerFormat": "Bearer", + "type": "http", + "description": "Device access token" + } + }, "schemas": { "HealthDependencyDto": { "type": "object", @@ -608,7 +611,7 @@ "deviceId": { "type": "string", "format": "uuid", - "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided." + "description": "Optional client metadata. Authorization: Bearer is required and determines access." }, "appVersion": { "type": "string", @@ -642,7 +645,7 @@ "deviceId": { "type": "string", "format": "uuid", - "description": "Legacy migration fallback. Omit when Authorization: Bearer is provided." + "description": "Optional client metadata. Authorization: Bearer is required and determines access." }, "sha256": { "type": "string", diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index 51f4001..add9966 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -21,6 +21,15 @@ async function generate(): Promise { .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(), ); diff --git a/backend/src/app.factory.spec.ts b/backend/src/app.factory.spec.ts index 9c63dbd..c397b9e 100644 --- a/backend/src/app.factory.spec.ts +++ b/backend/src/app.factory.spec.ts @@ -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: { diff --git a/backend/src/app.factory.ts b/backend/src/app.factory.ts index 4ddeed5..88e149c 100644 --- a/backend/src/app.factory.ts +++ b/backend/src/app.factory.ts @@ -37,6 +37,15 @@ export async function createApp(): Promise { .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(), ); diff --git a/backend/src/modules/artwork/artwork.controller.ts b/backend/src/modules/artwork/artwork.controller.ts index ad8207d..e1fa782 100644 --- a/backend/src/modules/artwork/artwork.controller.ts +++ b/backend/src/modules/artwork/artwork.controller.ts @@ -42,10 +42,8 @@ export class ArtworkController { @Query() query: AssetDownloadQueryDto, @Res({ passthrough: true }) response: Response, ): Promise { - 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)); diff --git a/backend/src/modules/artwork/artwork.service.spec.ts b/backend/src/modules/artwork/artwork.service.spec.ts index 5c146cb..a5723c3 100644 --- a/backend/src/modules/artwork/artwork.service.spec.ts +++ b/backend/src/modules/artwork/artwork.service.spec.ts @@ -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); }); }); diff --git a/backend/src/modules/artwork/artwork.service.ts b/backend/src/modules/artwork/artwork.service.ts index a5d13b2..e4aea70 100644 --- a/backend/src/modules/artwork/artwork.service.ts +++ b/backend/src/modules/artwork/artwork.service.ts @@ -24,10 +24,9 @@ export class ArtworkService { async getOwnedArtworkDownload( artworkId: string, - legacyDeviceId?: string, ): Promise { const { userId: ownerUserId } = - await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId); + this.deviceAuthService.getAuthenticatedDeviceOrThrow(); const artwork = await this.prismaService.artworkAsset.findUnique({ where: { id: artworkId }, diff --git a/backend/src/modules/assets/assets.controller.ts b/backend/src/modules/assets/assets.controller.ts index 30e7c9c..2d575e7 100644 --- a/backend/src/modules/assets/assets.controller.ts +++ b/backend/src/modules/assets/assets.controller.ts @@ -42,10 +42,8 @@ export class AssetsController { @Query() query: AssetDownloadQueryDto, @Res({ passthrough: true }) response: Response, ): Promise { - 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)); diff --git a/backend/src/modules/assets/assets.dto.ts b/backend/src/modules/assets/assets.dto.ts index 3b2f27a..1e95e93 100644 --- a/backend/src/modules/assets/assets.dto.ts +++ b/backend/src/modules/assets/assets.dto.ts @@ -6,7 +6,7 @@ export class AssetDownloadQueryDto { format: 'uuid', required: false, description: - 'Legacy migration fallback. Omit when Authorization: Bearer is provided.', + 'Optional client metadata. Authorization: Bearer is required and determines access.', }) @IsOptional() @IsUUID() diff --git a/backend/src/modules/assets/assets.service.spec.ts b/backend/src/modules/assets/assets.service.spec.ts index 89b7064..2139b4c 100644 --- a/backend/src/modules/assets/assets.service.spec.ts +++ b/backend/src/modules/assets/assets.service.spec.ts @@ -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); }); }); diff --git a/backend/src/modules/assets/assets.service.ts b/backend/src/modules/assets/assets.service.ts index c2b12e7..25502a1 100644 --- a/backend/src/modules/assets/assets.service.ts +++ b/backend/src/modules/assets/assets.service.ts @@ -23,10 +23,9 @@ export class AssetsService { async getOwnedAudioAssetDownload( assetId: string, - legacyDeviceId?: string, ): Promise { const { userId: ownerUserId } = - await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId); + this.deviceAuthService.getAuthenticatedDeviceOrThrow(); const asset = await this.prismaService.audioAsset.findUnique({ where: { id: assetId }, diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 3f43057..3e49c2b 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -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('*'); } } diff --git a/backend/src/modules/auth/device-auth.guard.ts b/backend/src/modules/auth/device-auth.guard.ts index 328b01c..2075d06 100644 --- a/backend/src/modules/auth/device-auth.guard.ts +++ b/backend/src/modules/auth/device-auth.guard.ts @@ -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 { const request = context.switchToHttp().getRequest(); + 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 = diff --git a/backend/src/modules/auth/device-auth.service.ts b/backend/src/modules/auth/device-auth.service.ts index ae2e439..d33ce77 100644 --- a/backend/src/modules/auth/device-auth.service.ts +++ b/backend/src/modules/auth/device-auth.service.ts @@ -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 { - const authenticatedDevice = this.requestContext.getAuthenticatedDevice(); - - if (authenticatedDevice) { - return authenticatedDevice; + try { + return this.getAuthenticatedDeviceOrThrow(); + } catch (error) { + if (!(error instanceof UnauthorizedException)) { + throw error; + } } const requestedDeviceId = diff --git a/backend/src/modules/auth/optional-device-auth.guard.ts b/backend/src/modules/auth/optional-device-auth.guard.ts new file mode 100644 index 0000000..a345624 --- /dev/null +++ b/backend/src/modules/auth/optional-device-auth.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/backend/src/modules/auth/protected-device-auth.middleware.spec.ts b/backend/src/modules/auth/protected-device-auth.middleware.spec.ts new file mode 100644 index 0000000..b1ad21a --- /dev/null +++ b/backend/src/modules/auth/protected-device-auth.middleware.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/auth/protected-device-auth.middleware.ts b/backend/src/modules/auth/protected-device-auth.middleware.ts new file mode 100644 index 0000000..e270bcd --- /dev/null +++ b/backend/src/modules/auth/protected-device-auth.middleware.ts @@ -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 { + 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()); + } +} diff --git a/backend/src/modules/devices/devices.controller.ts b/backend/src/modules/devices/devices.controller.ts index 42ccdd0..537cb91 100644 --- a/backend/src/modules/devices/devices.controller.ts +++ b/backend/src/modules/devices/devices.controller.ts @@ -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, diff --git a/backend/src/modules/devices/devices.dto.ts b/backend/src/modules/devices/devices.dto.ts index b2d412a..f581f3b 100644 --- a/backend/src/modules/devices/devices.dto.ts +++ b/backend/src/modules/devices/devices.dto.ts @@ -46,7 +46,7 @@ export class DeviceHeartbeatRequestDto { format: 'uuid', required: false, description: - 'Legacy migration fallback. Omit when Authorization: Bearer is provided.', + 'Optional client metadata. Authorization: Bearer is required and determines access.', }) @IsOptional() @IsUUID() diff --git a/backend/src/modules/devices/devices.service.spec.ts b/backend/src/modules/devices/devices.service.spec.ts index 522f128..9425bb4 100644 --- a/backend/src/modules/devices/devices.service.spec.ts +++ b/backend/src/modules/devices/devices.service.spec.ts @@ -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, diff --git a/backend/src/modules/devices/devices.service.ts b/backend/src/modules/devices/devices.service.ts index 2ff9eff..45352f9 100644 --- a/backend/src/modules/devices/devices.service.ts +++ b/backend/src/modules/devices/devices.service.ts @@ -54,7 +54,7 @@ export class DevicesService { async heartbeat( body: DeviceHeartbeatRequestDto, ): Promise { - 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 { - const owner = await this.ownerContext.resolve(); - return owner.userId; - } } diff --git a/backend/src/modules/library/library.controller.ts b/backend/src/modules/library/library.controller.ts index 1a503a0..252cb5d 100644 --- a/backend/src/modules/library/library.controller.ts +++ b/backend/src/modules/library/library.controller.ts @@ -22,8 +22,9 @@ export class LibraryController { async getTracks( @Query() query: LibraryTracksQueryDto, ): Promise { + void query; return { - tracks: await this.libraryService.getRemoteLibraryTracks(query.deviceId), + tracks: await this.libraryService.getRemoteLibraryTracks(), }; } } diff --git a/backend/src/modules/library/library.dto.ts b/backend/src/modules/library/library.dto.ts index a6f599b..b7bd162 100644 --- a/backend/src/modules/library/library.dto.ts +++ b/backend/src/modules/library/library.dto.ts @@ -6,7 +6,7 @@ export class LibraryTracksQueryDto { format: 'uuid', required: false, description: - 'Legacy migration fallback. Omit when Authorization: Bearer is provided.', + 'Optional client metadata. Authorization: Bearer is required and determines access.', }) @IsOptional() @IsUUID() diff --git a/backend/src/modules/library/library.service.spec.ts b/backend/src/modules/library/library.service.spec.ts index 4f86cc9..7a2d447 100644 --- a/backend/src/modules/library/library.service.spec.ts +++ b/backend/src/modules/library/library.service.spec.ts @@ -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([]); }); }); diff --git a/backend/src/modules/library/library.service.ts b/backend/src/modules/library/library.service.ts index 4798a46..296ad8b 100644 --- a/backend/src/modules/library/library.service.ts +++ b/backend/src/modules/library/library.service.ts @@ -37,11 +37,9 @@ export class LibraryService { })); } - async getRemoteLibraryTracks( - legacyDeviceId?: string, - ): Promise { + async getRemoteLibraryTracks(): Promise { 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 { - const owner = await this.ownerContext.resolve(); + const owner = await this.ownerContext.resolve({ + allowLegacyDeviceFallback: false, + allowBootstrapFallback: false, + }); return owner.userId; } } diff --git a/backend/src/modules/sync/sync.dto.ts b/backend/src/modules/sync/sync.dto.ts index b23ba13..0595f2c 100644 --- a/backend/src/modules/sync/sync.dto.ts +++ b/backend/src/modules/sync/sync.dto.ts @@ -48,7 +48,7 @@ export class SyncBootstrapQueryDto { format: 'uuid', required: false, description: - 'Legacy migration fallback. Omit when Authorization: Bearer is provided.', + 'Optional client metadata. Authorization: Bearer is required and determines access.', }) @IsOptional() @IsUUID() diff --git a/backend/src/modules/sync/sync.service.ts b/backend/src/modules/sync/sync.service.ts index 1951620..90cfc95 100644 --- a/backend/src/modules/sync/sync.service.ts +++ b/backend/src/modules/sync/sync.service.ts @@ -39,7 +39,10 @@ export class SyncService { } private async getLatestCursor(): Promise { - 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, diff --git a/backend/src/modules/uploads/uploads.dto.ts b/backend/src/modules/uploads/uploads.dto.ts index 2c89644..bf38c42 100644 --- a/backend/src/modules/uploads/uploads.dto.ts +++ b/backend/src/modules/uploads/uploads.dto.ts @@ -18,7 +18,7 @@ export class UploadPrepareRequestDto { format: 'uuid', required: false, description: - 'Legacy migration fallback. Omit when Authorization: Bearer is provided.', + 'Optional client metadata. Authorization: Bearer is required and determines access.', }) @IsOptional() @IsUUID() diff --git a/backend/src/modules/uploads/uploads.service.spec.ts b/backend/src/modules/uploads/uploads.service.spec.ts index 3eb3e7a..41ad000 100644 --- a/backend/src/modules/uploads/uploads.service.spec.ts +++ b/backend/src/modules/uploads/uploads.service.spec.ts @@ -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; } diff --git a/backend/src/modules/uploads/uploads.service.ts b/backend/src/modules/uploads/uploads.service.ts index 82d15ca..35960dd 100644 --- a/backend/src/modules/uploads/uploads.service.ts +++ b/backend/src/modules/uploads/uploads.service.ts @@ -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 { - const owner = await this.ownerContext.resolve(); + const owner = await this.ownerContext.resolve({ + allowLegacyDeviceFallback: false, + allowBootstrapFallback: false, + }); return owner.userId; } diff --git a/backend/src/modules/users/owner-context.service.ts b/backend/src/modules/users/owner-context.service.ts index ce8cfdd..f3208c1 100644 --- a/backend/src/modules/users/owner-context.service.ts +++ b/backend/src/modules/users/owner-context.service.ts @@ -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; + abstract resolve( + options?: OwnerContextResolveOptions, + ): Promise; } const UUID_PATTERN = @@ -28,7 +36,13 @@ export class BootstrapOwnerContextService extends OwnerContext { super(); } - async resolve(): Promise { + private async resolveWithOptions( + options: OwnerContextResolveOptions = {}, + ): Promise { + 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 { + return this.resolveWithOptions(options); + } } diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index 455c65e..e458d3b 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -520,12 +520,19 @@ describe('Velody API wiring (e2e)', () => { expect.any(Date), ); - const heartbeatResponse = await devicesController.heartbeat({ - deviceId: registerResponse.deviceId, - appVersion: '0.1.1', - }); + const heartbeatResponse = await runAsDevice( + registerResponse.deviceAccessToken, + () => + devicesController.heartbeat({ + deviceId: registerResponse.deviceId, + appVersion: '0.1.1', + }), + ); expect(heartbeatResponse.ok).toBe(true); + expect(prismaState.devices.get(registerResponse.deviceId)?.appVersion).toBe( + '0.1.1', + ); }); it('registers a linked device under the authenticated device owner when Authorization is present', async () => { @@ -589,7 +596,69 @@ describe('Velody API wiring (e2e)', () => { ).rejects.toBeInstanceOf(UnauthorizedException); }); - it('accepts heartbeat updates for a legacy device id outside the bootstrap owner', async () => { + it('returns 401 when heartbeat is requested without Authorization', async () => { + await expect( + devicesController.heartbeat({ + deviceId: randomUUID(), + appVersion: '0.1.1', + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns 401 when sync bootstrap is requested without Authorization', async () => { + await expect(syncController.bootstrap()).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('returns 401 when upload prepare is requested without Authorization', async () => { + const bytes = sampleMp3Bytes('missing-upload-auth'); + await expect( + uploadsController.prepare({ + deviceId: randomUUID(), + sha256: sha256Hex(bytes), + originalFilename: 'missing-upload-auth.mp3', + sizeBytes: bytes.length, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns 401 when asset download is requested without Authorization', async () => { + await expect( + assetsController.download( + randomUUID(), + { deviceId: randomUUID() }, + { setHeader() {} } as any, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns 401 when artwork download is requested without Authorization', async () => { + await expect( + artworkController.download( + randomUUID(), + { deviceId: randomUUID() }, + { setHeader() {} } as any, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('returns 401 when a protected route receives an invalid Authorization header', async () => { + await requestContextService.run(async () => { + await expect( + deviceAuthService.authenticateAuthorizationHeader( + 'Bearer invalid-device-token', + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); + + it('uses the authenticated device for heartbeat even when the body deviceId is spoofed', async () => { + const ownerDevice = await devicesController.register({ + platform: 'MACOS', + deviceName: 'Owner Mac', + appVersion: '0.1.0', + }); const foreignDeviceId = randomUUID(); prismaState.devices.set(foreignDeviceId, { id: foreignDeviceId, @@ -603,18 +672,32 @@ describe('Velody API wiring (e2e)', () => { updatedAt: new Date(), }); - const response = await devicesController.heartbeat({ - deviceId: foreignDeviceId, - appVersion: '0.1.1', - }); + const response = await runAsDevice(ownerDevice.deviceAccessToken, () => + devicesController.heartbeat({ + deviceId: foreignDeviceId, + appVersion: '0.1.1', + }), + ); expect(response.ok).toBe(true); - expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.1'); + expect(prismaState.devices.get(ownerDevice.deviceId)?.appVersion).toBe( + '0.1.1', + ); + expect(prismaState.devices.get(foreignDeviceId)?.appVersion).toBe('0.1.0'); }); it('returns sync bootstrap and changes payloads', async () => { - const bootstrapResponse = await syncController.bootstrap(); - const changesResponse = await syncController.changes({ after: '0' }); + const device = await devicesController.register({ + platform: 'IPHONE', + deviceName: 'Sync iPhone', + appVersion: '0.1.0', + }); + const bootstrapResponse = await runAsDevice(device.deviceAccessToken, () => + syncController.bootstrap(), + ); + const changesResponse = await runAsDevice(device.deviceAccessToken, () => + syncController.changes({ after: '0' }), + ); expect(bootstrapResponse.tracks).toEqual([]); expect(changesResponse.events).toEqual([]); @@ -622,6 +705,11 @@ describe('Velody API wiring (e2e)', () => { }); it('sync bootstrap and changes do not expose foreign-owner data', async () => { + const device = await devicesController.register({ + platform: 'IPHONE', + deviceName: 'Scoped Sync iPhone', + appVersion: '0.1.0', + }); const foreignUserId = randomUUID(); const foreignTrackId = randomUUID(); @@ -654,8 +742,12 @@ describe('Velody API wiring (e2e)', () => { createdAt: new Date('2026-05-29T08:02:00.000Z'), }); - const bootstrapResponse = await syncController.bootstrap(); - const changesResponse = await syncController.changes({ after: '0' }); + const bootstrapResponse = await runAsDevice(device.deviceAccessToken, () => + syncController.bootstrap(), + ); + const changesResponse = await runAsDevice(device.deviceAccessToken, () => + syncController.changes({ after: '0' }), + ); expect(bootstrapResponse.tracks).toEqual([]); expect(changesResponse.events).toEqual([]); @@ -704,10 +796,12 @@ describe('Velody API wiring (e2e)', () => { }, } as any; - const streamable = await assetsController.download( - assetId, - { deviceId: registerResponse.deviceId }, - responseMock, + const streamable = await runAsDevice(registerResponse.deviceAccessToken, () => + assetsController.download( + assetId, + { deviceId: registerResponse.deviceId }, + responseMock, + ), ); const downloadedBytes = await streamToBuffer(streamable.getStream()); @@ -741,10 +835,12 @@ describe('Velody API wiring (e2e)', () => { }); await expect( - assetsController.download( - assetId, - { deviceId: registerResponse.deviceId }, - { setHeader() {} } as any, + runAsDevice(registerResponse.deviceAccessToken, () => + assetsController.download( + assetId, + { deviceId: registerResponse.deviceId }, + { setHeader() {} } as any, + ), ), ).rejects.toBeInstanceOf(ForbiddenException); }); @@ -778,10 +874,12 @@ describe('Velody API wiring (e2e)', () => { }); await expect( - assetsController.download( - assetId, - { deviceId: registerResponse.deviceId }, - { setHeader() {} } as any, + runAsDevice(registerResponse.deviceAccessToken, () => + assetsController.download( + assetId, + { deviceId: registerResponse.deviceId }, + { setHeader() {} } as any, + ), ), ).rejects.toBeInstanceOf(NotFoundException); }); @@ -848,10 +946,12 @@ describe('Velody API wiring (e2e)', () => { }, } as any; - const streamable = await artworkController.download( - artworkId, - { deviceId: registerResponse.deviceId }, - responseMock, + const streamable = await runAsDevice(registerResponse.deviceAccessToken, () => + artworkController.download( + artworkId, + { deviceId: registerResponse.deviceId }, + responseMock, + ), ); const downloadedBytes = await streamToBuffer(streamable.getStream()); @@ -882,10 +982,12 @@ describe('Velody API wiring (e2e)', () => { }); await expect( - artworkController.download( - artworkId, - { deviceId: registerResponse.deviceId }, - { setHeader() {} } as any, + runAsDevice(registerResponse.deviceAccessToken, () => + artworkController.download( + artworkId, + { deviceId: registerResponse.deviceId }, + { setHeader() {} } as any, + ), ), ).rejects.toBeInstanceOf(ForbiddenException); }); @@ -917,10 +1019,12 @@ describe('Velody API wiring (e2e)', () => { }); await expect( - artworkController.download( - artworkId, - { deviceId: registerResponse.deviceId }, - { setHeader() {} } as any, + runAsDevice(registerResponse.deviceAccessToken, () => + artworkController.download( + artworkId, + { deviceId: registerResponse.deviceId }, + { setHeader() {} } as any, + ), ), ).rejects.toBeInstanceOf(NotFoundException); }); @@ -1089,9 +1193,11 @@ describe('Velody API wiring (e2e)', () => { updatedAt: new Date('2026-05-29T08:02:30.000Z'), }); - const response = await libraryController.getTracks({ - deviceId: primaryDevice.deviceId, - }); + const response = await runAsDevice(primaryDevice.deviceAccessToken, () => + libraryController.getTracks({ + deviceId: primaryDevice.deviceId, + }), + ); expect(response).toEqual({ tracks: [ @@ -1340,59 +1446,18 @@ describe('Velody API wiring (e2e)', () => { expect(linkedLibrary.tracks).toEqual(primaryLibrary.tracks); }); - it('keeps the legacy library deviceId path working when Authorization is missing', async () => { + it('returns 401 when library is requested without Authorization even if deviceId is supplied', async () => { const ownerDevice = await devicesController.register({ platform: 'IPHONE', deviceName: 'Legacy iPhone', appVersion: '0.1.0', }); - const trackId = randomUUID(); - const assetId = randomUUID(); - prismaState.audioAssets.set(assetId, { - id: assetId, - userId: prismaState.defaultUser.id, - trackId, - sha256: 'legacy-library-sha', - storageKey: 'users/default/audio/legacy-library-sha.mp3', - originalFilename: 'legacy-library.mp3', - mimeType: 'audio/mpeg', - fileExtension: 'mp3', - fileSizeBytes: BigInt(42), - durationMs: 210000, - sourceDeviceId: ownerDevice.deviceId, - createdAt: new Date('2026-05-29T08:00:00.000Z'), - }); - prismaState.tracks.set(trackId, { - id: trackId, - userId: prismaState.defaultUser.id, - primaryAudioAssetId: assetId, - artworkAssetId: null, - title: 'Legacy Library Track', - artist: 'Velody', - album: null, - albumArtist: null, - genre: null, - discNumber: null, - trackNumber: null, - year: null, - durationMs: 210000, - status: 'ACTIVE', - deletedAt: null, - createdAt: new Date('2026-05-29T08:00:00.000Z'), - updatedAt: new Date('2026-05-29T08:02:00.000Z'), - }); - - const response = await libraryController.getTracks({ - deviceId: ownerDevice.deviceId, - }); - - expect(response.tracks).toEqual([ - expect.objectContaining({ - trackId, - assetId, + await expect( + libraryController.getTracks({ + deviceId: ownerDevice.deviceId, }), - ]); + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => { @@ -1595,30 +1660,35 @@ describe('Velody API wiring (e2e)', () => { const bytes = sampleMp3Bytes('e2e-upload'); const sha256 = sha256Hex(bytes); - const prepareResponse = await uploadsController.prepare({ - deviceId: registerResponse.deviceId, - sha256, - originalFilename: 'e2e-upload.mp3', - sizeBytes: bytes.length, - }); + const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () => + uploadsController.prepare({ + deviceId: registerResponse.deviceId, + sha256, + originalFilename: 'e2e-upload.mp3', + sizeBytes: bytes.length, + }), + ); expect(prepareResponse.status).toBe('upload_required'); - const uploadResponse = await uploadsService.uploadFile( - prepareResponse.uploadId!, - createUploadRequest(bytes), + const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () => + uploadsService.uploadFile( + prepareResponse.uploadId!, + createUploadRequest(bytes), + ), ); expect(uploadResponse.status).toBe('COMPLETED'); - const finalizeResponse = await uploadsController.finalize( - prepareResponse.uploadId!, - { - title: 'Uploaded Track', - artist: 'Velody', - album: 'Milestone 6', - durationMs: 222000, - }, + const finalizeResponse = await runAsDevice( + registerResponse.deviceAccessToken, + () => + uploadsController.finalize(prepareResponse.uploadId!, { + title: 'Uploaded Track', + artist: 'Velody', + album: 'Milestone 6', + durationMs: 222000, + }), ); expect(finalizeResponse.trackId).toBeDefined(); @@ -1629,12 +1699,16 @@ describe('Velody API wiring (e2e)', () => { ); expect(storedBytes.equals(bytes)).toBe(true); - const duplicatePrepare = await uploadsController.prepare({ - deviceId: registerResponse.deviceId, - sha256, - originalFilename: 'e2e-upload.mp3', - sizeBytes: bytes.length, - }); + const duplicatePrepare = await runAsDevice( + registerResponse.deviceAccessToken, + () => + uploadsController.prepare({ + deviceId: registerResponse.deviceId, + sha256, + originalFilename: 'e2e-upload.mp3', + sizeBytes: bytes.length, + }), + ); expect(duplicatePrepare.status).toBe('exists'); expect(duplicatePrepare.uploadId).toBeDefined(); @@ -1656,45 +1730,52 @@ describe('Velody API wiring (e2e)', () => { const sha256 = sha256Hex(bytes); const artworkSha256 = sha256Hex(artworkBytes); - const prepareResponse = await uploadsController.prepare({ - deviceId: registerResponse.deviceId, - sha256, - originalFilename: 'e2e-upload-artwork.mp3', - sizeBytes: bytes.length, - }); + const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () => + uploadsController.prepare({ + deviceId: registerResponse.deviceId, + sha256, + originalFilename: 'e2e-upload-artwork.mp3', + sizeBytes: bytes.length, + }), + ); expect(prepareResponse.status).toBe('upload_required'); - const uploadResponse = await uploadsService.uploadFile( - prepareResponse.uploadId!, - createUploadRequest(bytes), + const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () => + uploadsService.uploadFile( + prepareResponse.uploadId!, + createUploadRequest(bytes), + ), ); expect(uploadResponse.status).toBe('COMPLETED'); - const finalizeResponse = await uploadsController.finalize( - prepareResponse.uploadId!, - { - title: 'Uploaded Artwork Track', - artist: 'Velody', - album: 'Milestone 8.1', - durationMs: 222000, - artwork: { - dataBase64: artworkBytes.toString('base64'), - sha256: artworkSha256, - mimeType: 'image/png', - width: 1, - height: 1, - }, - }, + const finalizeResponse = await runAsDevice( + registerResponse.deviceAccessToken, + () => + uploadsController.finalize(prepareResponse.uploadId!, { + title: 'Uploaded Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 222000, + artwork: { + dataBase64: artworkBytes.toString('base64'), + sha256: artworkSha256, + mimeType: 'image/png', + width: 1, + height: 1, + }, + }), ); expect(finalizeResponse.trackId).toBeDefined(); expect(prismaState.artworkAssets.size).toBe(1); - const remoteLibrary = await libraryController.getTracks({ - deviceId: registerResponse.deviceId, - }); + const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () => + libraryController.getTracks({ + deviceId: registerResponse.deviceId, + }), + ); expect(remoteLibrary.tracks).toEqual([ expect.objectContaining({ trackId: finalizeResponse.trackId, @@ -1727,10 +1808,12 @@ describe('Velody API wiring (e2e)', () => { }, } as any; - const streamable = await artworkController.download( - artworkAsset.id, - { deviceId: registerResponse.deviceId }, - responseMock, + const streamable = await runAsDevice(registerResponse.deviceAccessToken, () => + artworkController.download( + artworkAsset.id, + { deviceId: registerResponse.deviceId }, + responseMock, + ), ); const downloadedArtworkBytes = await streamToBuffer(streamable.getStream()); @@ -1752,67 +1835,79 @@ describe('Velody API wiring (e2e)', () => { const sha256 = sha256Hex(bytes); const artworkSha256 = sha256Hex(artworkBytes); - const firstPrepare = await uploadsController.prepare({ - deviceId: registerResponse.deviceId, - sha256, - originalFilename: 'e2e-deduped-artwork.mp3', - sizeBytes: bytes.length, - }); + const firstPrepare = await runAsDevice(registerResponse.deviceAccessToken, () => + uploadsController.prepare({ + deviceId: registerResponse.deviceId, + sha256, + originalFilename: 'e2e-deduped-artwork.mp3', + sizeBytes: bytes.length, + }), + ); expect(firstPrepare.status).toBe('upload_required'); - const uploadResponse = await uploadsService.uploadFile( - firstPrepare.uploadId!, - createUploadRequest(bytes), + const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () => + uploadsService.uploadFile( + firstPrepare.uploadId!, + createUploadRequest(bytes), + ), ); expect(uploadResponse.status).toBe('COMPLETED'); - const firstFinalize = await uploadsController.finalize( - firstPrepare.uploadId!, - { - title: 'Deduped Artwork Track', - artist: 'Velody', - album: 'Milestone 8.1', - durationMs: 222000, - }, + const firstFinalize = await runAsDevice( + registerResponse.deviceAccessToken, + () => + uploadsController.finalize(firstPrepare.uploadId!, { + title: 'Deduped Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 222000, + }), ); - const secondPrepare = await uploadsController.prepare({ - deviceId: registerResponse.deviceId, - sha256, - originalFilename: 'e2e-deduped-artwork.mp3', - sizeBytes: bytes.length, - }); + const secondPrepare = await runAsDevice( + registerResponse.deviceAccessToken, + () => + uploadsController.prepare({ + deviceId: registerResponse.deviceId, + sha256, + originalFilename: 'e2e-deduped-artwork.mp3', + sizeBytes: bytes.length, + }), + ); expect(secondPrepare.status).toBe('exists'); expect(secondPrepare.uploadId).toBeDefined(); expect(prismaState.audioAssets.size).toBe(1); - const secondFinalize = await uploadsController.finalize( - secondPrepare.uploadId!, - { - title: 'Deduped Artwork Track', - artist: 'Velody', - album: 'Milestone 8.1', - durationMs: 222000, - artwork: { - dataBase64: artworkBytes.toString('base64'), - sha256: artworkSha256, - mimeType: 'image/png', - width: 1, - height: 1, - }, - }, + const secondFinalize = await runAsDevice( + registerResponse.deviceAccessToken, + () => + uploadsController.finalize(secondPrepare.uploadId!, { + title: 'Deduped Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 222000, + artwork: { + dataBase64: artworkBytes.toString('base64'), + sha256: artworkSha256, + mimeType: 'image/png', + width: 1, + height: 1, + }, + }), ); expect(secondFinalize.trackId).toBe(firstFinalize.trackId); expect(secondFinalize.assetId).toBe(firstFinalize.assetId); expect(prismaState.artworkAssets.size).toBe(1); - const remoteLibrary = await libraryController.getTracks({ - deviceId: registerResponse.deviceId, - }); + const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () => + libraryController.getTracks({ + deviceId: registerResponse.deviceId, + }), + ); expect(remoteLibrary.tracks).toEqual([ expect.objectContaining({ trackId: firstFinalize.trackId,