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

View File

@ -17,7 +17,7 @@
"name": "deviceId", "name": "deviceId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.", "description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -63,7 +63,7 @@
"name": "deviceId", "name": "deviceId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.", "description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -140,11 +140,6 @@
} }
} }
}, },
"security": [
{
"bearer": []
}
],
"tags": [ "tags": [
"devices" "devices"
] ]
@ -368,7 +363,7 @@
"name": "deviceId", "name": "deviceId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.", "description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -405,7 +400,7 @@
"name": "deviceId", "name": "deviceId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.", "description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -442,7 +437,7 @@
"name": "deviceId", "name": "deviceId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.", "description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -490,6 +485,14 @@
"tags": [], "tags": [],
"servers": [], "servers": [],
"components": { "components": {
"securitySchemes": {
"bearer": {
"scheme": "bearer",
"bearerFormat": "Bearer",
"type": "http",
"description": "Device access token"
}
},
"schemas": { "schemas": {
"HealthDependencyDto": { "HealthDependencyDto": {
"type": "object", "type": "object",
@ -608,7 +611,7 @@
"deviceId": { "deviceId": {
"type": "string", "type": "string",
"format": "uuid", "format": "uuid",
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided." "description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access."
}, },
"appVersion": { "appVersion": {
"type": "string", "type": "string",
@ -642,7 +645,7 @@
"deviceId": { "deviceId": {
"type": "string", "type": "string",
"format": "uuid", "format": "uuid",
"description": "Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided." "description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access."
}, },
"sha256": { "sha256": {
"type": "string", "type": "string",

View File

@ -21,6 +21,15 @@ async function generate(): Promise<void> {
.setTitle('Velody API') .setTitle('Velody API')
.setDescription('Velody Phase 1 foundation API') .setDescription('Velody Phase 1 foundation API')
.setVersion('1.0.0') .setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'Bearer',
description: 'Device access token',
},
'bearer',
)
.build(), .build(),
); );

View File

@ -5,6 +5,7 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
const setTitle = jest.fn().mockReturnThis(); const setTitle = jest.fn().mockReturnThis();
const setDescription = jest.fn().mockReturnThis(); const setDescription = jest.fn().mockReturnThis();
const setVersion = jest.fn().mockReturnThis(); const setVersion = jest.fn().mockReturnThis();
const addBearerAuth = jest.fn().mockReturnThis();
const build = jest.fn().mockReturnValue({}); const build = jest.fn().mockReturnValue({});
const createDocument = jest.fn().mockReturnValue({}); const createDocument = jest.fn().mockReturnValue({});
const setup = jest.fn(); const setup = jest.fn();
@ -23,6 +24,7 @@ jest.mock('@nestjs/swagger', () => {
setTitle, setTitle,
setDescription, setDescription,
setVersion, setVersion,
addBearerAuth,
build, build,
})), })),
SwaggerModule: { SwaggerModule: {

View File

@ -37,6 +37,15 @@ export async function createApp(): Promise<NestExpressApplication> {
.setTitle('Velody API') .setTitle('Velody API')
.setDescription('Velody Phase 1 foundation API') .setDescription('Velody Phase 1 foundation API')
.setVersion('1.0.0') .setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'Bearer',
description: 'Device access token',
},
'bearer',
)
.build(), .build(),
); );

View File

@ -42,10 +42,8 @@ export class ArtworkController {
@Query() query: AssetDownloadQueryDto, @Query() query: AssetDownloadQueryDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const download = await this.artworkService.getOwnedArtworkDownload( void query;
artworkId, const download = await this.artworkService.getOwnedArtworkDownload(artworkId);
query.deviceId,
);
response.setHeader('Content-Type', download.mimeType); response.setHeader('Content-Type', download.mimeType);
response.setHeader('Content-Length', String(download.contentLength)); response.setHeader('Content-Length', String(download.contentLength));

View File

@ -42,7 +42,7 @@ describe('ArtworkService', () => {
let storageService: LocalFilesystemStorageService; let storageService: LocalFilesystemStorageService;
let ownerUserId: string; let ownerUserId: string;
let deviceAuthService: { let deviceAuthService: {
resolveCurrentDevice: jest.Mock; getAuthenticatedDeviceOrThrow: jest.Mock;
}; };
beforeEach(async () => { beforeEach(async () => {
@ -52,7 +52,7 @@ describe('ArtworkService', () => {
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
ownerUserId = randomUUID(); ownerUserId = randomUUID();
deviceAuthService = { deviceAuthService = {
resolveCurrentDevice: jest.fn().mockResolvedValue({ getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: randomUUID(), deviceId: randomUUID(),
userId: ownerUserId, userId: ownerUserId,
}), }),
@ -87,7 +87,7 @@ describe('ArtworkService', () => {
await storageService.ensureParentDirectory(filePath); await storageService.ensureParentDirectory(filePath);
await writeFile(filePath, bytes); await writeFile(filePath, bytes);
const download = await service.getOwnedArtworkDownload(artworkId, randomUUID()); const download = await service.getOwnedArtworkDownload(artworkId);
expect(download.filePath).toBe(filePath); expect(download.filePath).toBe(filePath);
expect(download.contentLength).toBe(bytes.length); expect(download.contentLength).toBe(bytes.length);
@ -105,17 +105,17 @@ describe('ArtworkService', () => {
}); });
await expect( await expect(
service.getOwnedArtworkDownload(artworkId, randomUUID()), service.getOwnedArtworkDownload(artworkId),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('rejects foreign-owner devices before reading artwork', async () => { it('rejects foreign-owner devices before reading artwork', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce( deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
new NotFoundException('Device not found'), throw new NotFoundException('Device not found');
); });
await expect( await expect(
service.getOwnedArtworkDownload(randomUUID(), randomUUID()), service.getOwnedArtworkDownload(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
@ -130,13 +130,13 @@ describe('ArtworkService', () => {
}); });
await expect( await expect(
service.getOwnedArtworkDownload(artworkId, randomUUID()), service.getOwnedArtworkDownload(artworkId),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
it('returns not found when the artwork asset does not exist', async () => { it('returns not found when the artwork asset does not exist', async () => {
await expect( await expect(
service.getOwnedArtworkDownload(randomUUID(), randomUUID()), service.getOwnedArtworkDownload(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
}); });

View File

@ -24,10 +24,9 @@ export class ArtworkService {
async getOwnedArtworkDownload( async getOwnedArtworkDownload(
artworkId: string, artworkId: string,
legacyDeviceId?: string,
): Promise<ArtworkDownload> { ): Promise<ArtworkDownload> {
const { userId: ownerUserId } = const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId); this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const artwork = await this.prismaService.artworkAsset.findUnique({ const artwork = await this.prismaService.artworkAsset.findUnique({
where: { id: artworkId }, where: { id: artworkId },

View File

@ -42,10 +42,8 @@ export class AssetsController {
@Query() query: AssetDownloadQueryDto, @Query() query: AssetDownloadQueryDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
const download = await this.assetsService.getOwnedAudioAssetDownload( void query;
assetId, const download = await this.assetsService.getOwnedAudioAssetDownload(assetId);
query.deviceId,
);
response.setHeader('Content-Type', 'audio/mpeg'); response.setHeader('Content-Type', 'audio/mpeg');
response.setHeader('Content-Length', String(download.contentLength)); response.setHeader('Content-Length', String(download.contentLength));

View File

@ -6,7 +6,7 @@ export class AssetDownloadQueryDto {
format: 'uuid', format: 'uuid',
required: false, required: false,
description: description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.', 'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
}) })
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()

View File

@ -42,7 +42,7 @@ describe('AssetsService', () => {
let storageService: LocalFilesystemStorageService; let storageService: LocalFilesystemStorageService;
let ownerUserId: string; let ownerUserId: string;
let deviceAuthService: { let deviceAuthService: {
resolveCurrentDevice: jest.Mock; getAuthenticatedDeviceOrThrow: jest.Mock;
}; };
beforeEach(async () => { beforeEach(async () => {
@ -52,7 +52,7 @@ describe('AssetsService', () => {
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
ownerUserId = randomUUID(); ownerUserId = randomUUID();
deviceAuthService = { deviceAuthService = {
resolveCurrentDevice: jest.fn().mockResolvedValue({ getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: randomUUID(), deviceId: randomUUID(),
userId: ownerUserId, userId: ownerUserId,
}), }),
@ -84,7 +84,7 @@ describe('AssetsService', () => {
await storageService.ensureParentDirectory(filePath); await storageService.ensureParentDirectory(filePath);
await writeFile(filePath, assetBytes); await writeFile(filePath, assetBytes);
const download = await service.getOwnedAudioAssetDownload(assetId, randomUUID()); const download = await service.getOwnedAudioAssetDownload(assetId);
expect(download.filePath).toBe(filePath); expect(download.filePath).toBe(filePath);
expect(download.contentLength).toBe(assetBytes.length); expect(download.contentLength).toBe(assetBytes.length);
@ -101,17 +101,17 @@ describe('AssetsService', () => {
}); });
await expect( await expect(
service.getOwnedAudioAssetDownload(assetId, randomUUID()), service.getOwnedAudioAssetDownload(assetId),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
it('rejects foreign-owner devices before reading audio assets', async () => { it('rejects foreign-owner devices before reading audio assets', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce( deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
new NotFoundException('Device not found'), throw new NotFoundException('Device not found');
); });
await expect( await expect(
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()), service.getOwnedAudioAssetDownload(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
@ -126,17 +126,17 @@ describe('AssetsService', () => {
}); });
await expect( await expect(
service.getOwnedAudioAssetDownload(assetId, randomUUID()), service.getOwnedAudioAssetDownload(assetId),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
it('returns not found when the device does not exist', async () => { it('returns not found when the device does not exist', async () => {
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce( deviceAuthService.getAuthenticatedDeviceOrThrow.mockImplementationOnce(() => {
new NotFoundException('Device not found'), throw new NotFoundException('Device not found');
); });
await expect( await expect(
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()), service.getOwnedAudioAssetDownload(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
}); });

View File

@ -23,10 +23,9 @@ export class AssetsService {
async getOwnedAudioAssetDownload( async getOwnedAudioAssetDownload(
assetId: string, assetId: string,
legacyDeviceId?: string,
): Promise<AudioAssetDownload> { ): Promise<AudioAssetDownload> {
const { userId: ownerUserId } = const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId); this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const asset = await this.prismaService.audioAsset.findUnique({ const asset = await this.prismaService.audioAsset.findUnique({
where: { id: assetId }, where: { id: assetId },

View File

@ -4,14 +4,28 @@ import { RequestContextMiddleware } from '../../infrastructure/request-context/r
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module'; import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
import { DeviceAuthGuard } from './device-auth.guard'; import { DeviceAuthGuard } from './device-auth.guard';
import { DeviceAuthService } from './device-auth.service'; import { DeviceAuthService } from './device-auth.service';
import { OptionalDeviceAuthGuard } from './optional-device-auth.guard';
import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware';
@Module({ @Module({
imports: [PrismaModule, RequestContextModule], imports: [PrismaModule, RequestContextModule],
providers: [DeviceAuthService, DeviceAuthGuard], providers: [
exports: [DeviceAuthService, DeviceAuthGuard], DeviceAuthService,
DeviceAuthGuard,
OptionalDeviceAuthGuard,
ProtectedDeviceAuthMiddleware,
],
exports: [
DeviceAuthService,
DeviceAuthGuard,
OptionalDeviceAuthGuard,
ProtectedDeviceAuthMiddleware,
],
}) })
export class AuthModule implements NestModule { export class AuthModule implements NestModule {
configure(consumer: MiddlewareConsumer): void { configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestContextMiddleware).forRoutes('*'); consumer
.apply(RequestContextMiddleware, ProtectedDeviceAuthMiddleware)
.forRoutes('*');
} }
} }

View File

@ -2,6 +2,7 @@ import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
Injectable, Injectable,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import { DeviceAuthService } from './device-auth.service'; import { DeviceAuthService } from './device-auth.service';
@ -12,10 +13,20 @@ export class DeviceAuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>(); 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; const authorization = request.headers.authorization;
if (!authorization) { if (!authorization) {
return true; throw new UnauthorizedException('Authorization header is required');
} }
const authenticatedDevice = const authenticatedDevice =

View File

@ -68,13 +68,29 @@ export class DeviceAuthService {
return authenticatedDevice; 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( async resolveCurrentDevice(
legacyDeviceId?: string, legacyDeviceId?: string,
): Promise<AuthenticatedDeviceContextValue> { ): Promise<AuthenticatedDeviceContextValue> {
const authenticatedDevice = this.requestContext.getAuthenticatedDevice(); try {
return this.getAuthenticatedDeviceOrThrow();
if (authenticatedDevice) { } catch (error) {
return authenticatedDevice; if (!(error instanceof UnauthorizedException)) {
throw error;
}
} }
const requestedDeviceId = const requestedDeviceId =

View File

@ -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;
}
}

View File

@ -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);
});
});

View File

@ -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());
}
}

View File

@ -6,6 +6,7 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard'; import { DeviceAuthGuard } from '../auth/device-auth.guard';
import { OptionalDeviceAuthGuard } from '../auth/optional-device-auth.guard';
import { import {
DeviceHeartbeatRequestDto, DeviceHeartbeatRequestDto,
DeviceHeartbeatResponseDto, DeviceHeartbeatResponseDto,
@ -23,8 +24,7 @@ export class DevicesController {
constructor(private readonly devicesService: DevicesService) {} constructor(private readonly devicesService: DevicesService) {}
@Post('register') @Post('register')
@UseGuards(DeviceAuthGuard) @UseGuards(OptionalDeviceAuthGuard)
@ApiBearerAuth()
@ApiCreatedResponse({ type: RegisterDeviceResponseDto }) @ApiCreatedResponse({ type: RegisterDeviceResponseDto })
async register( async register(
@Body() body: RegisterDeviceRequestDto, @Body() body: RegisterDeviceRequestDto,

View File

@ -46,7 +46,7 @@ export class DeviceHeartbeatRequestDto {
format: 'uuid', format: 'uuid',
required: false, required: false,
description: description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.', 'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
}) })
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()

View File

@ -25,7 +25,7 @@ describe('DevicesService', () => {
const deviceAuthService = { const deviceAuthService = {
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'), generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'), hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
resolveCurrentDevice: jest.fn(), getAuthenticatedDeviceOrThrow: jest.fn(),
} as any; } as any;
const service = new DevicesService( const service = new DevicesService(
prismaService, prismaService,
@ -65,9 +65,9 @@ describe('DevicesService', () => {
const deviceAuthService = { const deviceAuthService = {
generateDeviceAccessToken: jest.fn(), generateDeviceAccessToken: jest.fn(),
hashDeviceAccessToken: jest.fn(), hashDeviceAccessToken: jest.fn(),
resolveCurrentDevice: jest getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
.fn() throw new NotFoundException('Device not found');
.mockRejectedValue(new NotFoundException('Device not found')), }),
} as any; } as any;
const service = new DevicesService( const service = new DevicesService(
prismaService, prismaService,

View File

@ -54,7 +54,7 @@ export class DevicesService {
async heartbeat( async heartbeat(
body: DeviceHeartbeatRequestDto, body: DeviceHeartbeatRequestDto,
): Promise<DeviceHeartbeatResponseDto> { ): Promise<DeviceHeartbeatResponseDto> {
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId); const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
await this.prismaService.device.update({ await this.prismaService.device.update({
where: { id: device.deviceId }, where: { id: device.deviceId },
@ -69,9 +69,4 @@ export class DevicesService {
serverTime: new Date().toISOString(), serverTime: new Date().toISOString(),
}; };
} }
private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve();
return owner.userId;
}
} }

View File

@ -22,8 +22,9 @@ export class LibraryController {
async getTracks( async getTracks(
@Query() query: LibraryTracksQueryDto, @Query() query: LibraryTracksQueryDto,
): Promise<LibraryTracksResponseDto> { ): Promise<LibraryTracksResponseDto> {
void query;
return { return {
tracks: await this.libraryService.getRemoteLibraryTracks(query.deviceId), tracks: await this.libraryService.getRemoteLibraryTracks(),
}; };
} }
} }

View File

@ -6,7 +6,7 @@ export class LibraryTracksQueryDto {
format: 'uuid', format: 'uuid',
required: false, required: false,
description: description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.', 'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
}) })
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()

View File

@ -60,7 +60,7 @@ describe('LibraryService', () => {
resolve: jest.Mock; resolve: jest.Mock;
}; };
let deviceAuthServiceMock: { let deviceAuthServiceMock: {
resolveCurrentDevice: jest.Mock; getAuthenticatedDeviceOrThrow: jest.Mock;
}; };
beforeEach(async () => { beforeEach(async () => {
@ -70,7 +70,7 @@ describe('LibraryService', () => {
resolve: jest.fn(), resolve: jest.fn(),
}; };
deviceAuthServiceMock = { deviceAuthServiceMock = {
resolveCurrentDevice: jest.fn(), getAuthenticatedDeviceOrThrow: jest.fn(),
}; };
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
@ -162,7 +162,7 @@ describe('LibraryService', () => {
userId: ownerId, userId: ownerId,
}); });
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({ deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
deviceId: ownerDeviceId, deviceId: ownerDeviceId,
userId: ownerId, userId: ownerId,
}); });
@ -225,7 +225,7 @@ describe('LibraryService', () => {
updatedAt: new Date('2026-05-29T08:02:00.000Z'), updatedAt: new Date('2026-05-29T08:02:00.000Z'),
}); });
const tracks = await libraryService.getRemoteLibraryTracks(ownerDeviceId); const tracks = await libraryService.getRemoteLibraryTracks();
expect(tracks).toEqual([ expect(tracks).toEqual([
{ {
@ -266,14 +266,12 @@ describe('LibraryService', () => {
userId: ownerId, userId: ownerId,
}); });
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({ deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
deviceId: ownerDeviceId, deviceId: ownerDeviceId,
userId: ownerId, userId: ownerId,
}); });
await expect( await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
}); });
it('does not leak remote library tracks from other owners', async () => { it('does not leak remote library tracks from other owners', async () => {
@ -287,7 +285,7 @@ describe('LibraryService', () => {
userId: ownerId, userId: ownerId,
}); });
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({ deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
deviceId: ownerDeviceId, deviceId: ownerDeviceId,
userId: ownerId, userId: ownerId,
}); });
@ -308,19 +306,19 @@ describe('LibraryService', () => {
updatedAt: new Date('2026-05-29T08:01:00.000Z'), updatedAt: new Date('2026-05-29T08:01:00.000Z'),
}); });
await expect( await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
}); });
it('throws for an unknown device', async () => { it('throws for an unknown device', async () => {
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce( deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockImplementationOnce(
new NotFoundException('Device not found'), () => {
throw new NotFoundException('Device not found');
},
); );
await expect( await expect(libraryService.getRemoteLibraryTracks()).rejects.toBeInstanceOf(
libraryService.getRemoteLibraryTracks(randomUUID()), NotFoundException,
).rejects.toBeInstanceOf(NotFoundException); );
}); });
it('rejects cross-owner track access through a foreign-owner device', async () => { it('rejects cross-owner track access through a foreign-owner device', async () => {
@ -333,13 +331,15 @@ describe('LibraryService', () => {
id: foreignDeviceId, id: foreignDeviceId,
userId: otherUserId, userId: otherUserId,
}); });
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce( deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockImplementationOnce(
new NotFoundException('Device not found'), () => {
throw new NotFoundException('Device not found');
},
); );
await expect( await expect(libraryService.getRemoteLibraryTracks()).rejects.toBeInstanceOf(
libraryService.getRemoteLibraryTracks(foreignDeviceId), NotFoundException,
).rejects.toBeInstanceOf(NotFoundException); );
}); });
it('skips tracks without a primary audio asset', async () => { it('skips tracks without a primary audio asset', async () => {
@ -349,7 +349,7 @@ describe('LibraryService', () => {
userId: ownerId, userId: ownerId,
}); });
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({ deviceAuthServiceMock.getAuthenticatedDeviceOrThrow.mockReturnValue({
deviceId: ownerDeviceId, deviceId: ownerDeviceId,
userId: ownerId, userId: ownerId,
}); });
@ -366,8 +366,6 @@ describe('LibraryService', () => {
updatedAt: new Date('2026-05-29T08:01:00.000Z'), updatedAt: new Date('2026-05-29T08:01:00.000Z'),
}); });
await expect( await expect(libraryService.getRemoteLibraryTracks()).resolves.toEqual([]);
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
}); });
}); });

View File

@ -37,11 +37,9 @@ export class LibraryService {
})); }));
} }
async getRemoteLibraryTracks( async getRemoteLibraryTracks(): Promise<RemoteLibraryTrackDto[]> {
legacyDeviceId?: string,
): Promise<RemoteLibraryTrackDto[]> {
const { userId: ownerUserId } = const { userId: ownerUserId } =
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId); this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const tracks = await this.prismaService.track.findMany({ const tracks = await this.prismaService.track.findMany({
where: { where: {
@ -113,7 +111,10 @@ export class LibraryService {
} }
private async resolveCurrentOwnerUserId(): Promise<string> { private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve(); const owner = await this.ownerContext.resolve({
allowLegacyDeviceFallback: false,
allowBootstrapFallback: false,
});
return owner.userId; return owner.userId;
} }
} }

View File

@ -48,7 +48,7 @@ export class SyncBootstrapQueryDto {
format: 'uuid', format: 'uuid',
required: false, required: false,
description: description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.', 'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
}) })
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()

View File

@ -39,7 +39,10 @@ export class SyncService {
} }
private async getLatestCursor(): Promise<string> { 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({ const latest = await this.prismaService.libraryEvent.findFirst({
where: { where: {
userId: owner.userId, userId: owner.userId,

View File

@ -18,7 +18,7 @@ export class UploadPrepareRequestDto {
format: 'uuid', format: 'uuid',
required: false, required: false,
description: description:
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.', 'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
}) })
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()

View File

@ -285,8 +285,9 @@ describe('UploadsService', () => {
let storageService: LocalFilesystemStorageService; let storageService: LocalFilesystemStorageService;
let service: UploadsService; let service: UploadsService;
let ownerContext: OwnerContext; let ownerContext: OwnerContext;
let currentAuthenticatedDeviceId: string | null;
let deviceAuthService: { let deviceAuthService: {
resolveCurrentDevice: jest.Mock; getAuthenticatedDeviceOrThrow: jest.Mock;
}; };
beforeEach(async () => { beforeEach(async () => {
@ -295,18 +296,19 @@ describe('UploadsService', () => {
state = mock.state; state = mock.state;
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-')); storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
currentAuthenticatedDeviceId = null;
ownerContext = { ownerContext = {
resolve: jest.fn().mockResolvedValue({ resolve: jest.fn().mockResolvedValue({
userId: state.defaultUser.id, userId: state.defaultUser.id,
}), }),
} as OwnerContext; } as OwnerContext;
deviceAuthService = { deviceAuthService = {
resolveCurrentDevice: jest.fn().mockImplementation(async (deviceId?: string) => { getAuthenticatedDeviceOrThrow: jest.fn().mockImplementation(() => {
if (!deviceId) { if (!currentAuthenticatedDeviceId) {
throw new NotFoundException('Device not found'); 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) { if (!device || device.userId !== state.defaultUser.id) {
throw new NotFoundException('Device not found'); throw new NotFoundException('Device not found');
@ -345,6 +347,7 @@ describe('UploadsService', () => {
updatedAt: new Date(), updatedAt: new Date(),
}; };
state.devices.set(deviceId, device); state.devices.set(deviceId, device);
currentAuthenticatedDeviceId = deviceId;
return device; return device;
} }

View File

@ -54,7 +54,7 @@ export class UploadsService {
this.assertFileSizeWithinLimit(body.sizeBytes); this.assertFileSizeWithinLimit(body.sizeBytes);
this.assertMp3Filename(body.originalFilename); this.assertMp3Filename(body.originalFilename);
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId); const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const ownerUserId = device.userId; const ownerUserId = device.userId;
const existingAsset = await this.prismaService.audioAsset.findUnique({ const existingAsset = await this.prismaService.audioAsset.findUnique({
@ -477,7 +477,10 @@ export class UploadsService {
} }
private async resolveCurrentOwnerUserId(): Promise<string> { private async resolveCurrentOwnerUserId(): Promise<string> {
const owner = await this.ownerContext.resolve(); const owner = await this.ownerContext.resolve({
allowLegacyDeviceFallback: false,
allowBootstrapFallback: false,
});
return owner.userId; return owner.userId;
} }

View File

@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
Injectable, Injectable,
NotFoundException, NotFoundException,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { RequestContextService } from '../../infrastructure/request-context/request-context.service'; import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
@ -11,8 +12,15 @@ export interface ResolvedOwnerContext {
userId: string; userId: string;
} }
export interface OwnerContextResolveOptions {
allowLegacyDeviceFallback?: boolean;
allowBootstrapFallback?: boolean;
}
export abstract class OwnerContext { export abstract class OwnerContext {
abstract resolve(): Promise<ResolvedOwnerContext>; abstract resolve(
options?: OwnerContextResolveOptions,
): Promise<ResolvedOwnerContext>;
} }
const UUID_PATTERN = const UUID_PATTERN =
@ -28,7 +36,13 @@ export class BootstrapOwnerContextService extends OwnerContext {
super(); super();
} }
async resolve(): Promise<ResolvedOwnerContext> { private async resolveWithOptions(
options: OwnerContextResolveOptions = {},
): Promise<ResolvedOwnerContext> {
const {
allowLegacyDeviceFallback = true,
allowBootstrapFallback = true,
} = options;
const authenticatedDevice = this.requestContext.getAuthenticatedDevice(); const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
if (authenticatedDevice) { if (authenticatedDevice) {
@ -39,7 +53,7 @@ export class BootstrapOwnerContextService extends OwnerContext {
const legacyDeviceId = this.requestContext.getLegacyDeviceId(); const legacyDeviceId = this.requestContext.getLegacyDeviceId();
if (legacyDeviceId) { if (allowLegacyDeviceFallback && legacyDeviceId) {
if (!UUID_PATTERN.test(legacyDeviceId)) { if (!UUID_PATTERN.test(legacyDeviceId)) {
throw new BadRequestException('deviceId must be a UUID'); 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(); const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
return { return {
userId: defaultUser.id, userId: defaultUser.id,
}; };
} }
async resolve(
options?: OwnerContextResolveOptions,
): Promise<ResolvedOwnerContext> {
return this.resolveWithOptions(options);
}
} }

View File

@ -520,12 +520,19 @@ describe('Velody API wiring (e2e)', () => {
expect.any(Date), expect.any(Date),
); );
const heartbeatResponse = await devicesController.heartbeat({ const heartbeatResponse = await runAsDevice(
deviceId: registerResponse.deviceId, registerResponse.deviceAccessToken,
appVersion: '0.1.1', () =>
}); devicesController.heartbeat({
deviceId: registerResponse.deviceId,
appVersion: '0.1.1',
}),
);
expect(heartbeatResponse.ok).toBe(true); 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 () => { 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); ).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(); const foreignDeviceId = randomUUID();
prismaState.devices.set(foreignDeviceId, { prismaState.devices.set(foreignDeviceId, {
id: foreignDeviceId, id: foreignDeviceId,
@ -603,18 +672,32 @@ describe('Velody API wiring (e2e)', () => {
updatedAt: new Date(), updatedAt: new Date(),
}); });
const response = await devicesController.heartbeat({ const response = await runAsDevice(ownerDevice.deviceAccessToken, () =>
deviceId: foreignDeviceId, devicesController.heartbeat({
appVersion: '0.1.1', deviceId: foreignDeviceId,
}); appVersion: '0.1.1',
}),
);
expect(response.ok).toBe(true); 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 () => { it('returns sync bootstrap and changes payloads', async () => {
const bootstrapResponse = await syncController.bootstrap(); const device = await devicesController.register({
const changesResponse = await syncController.changes({ after: '0' }); 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(bootstrapResponse.tracks).toEqual([]);
expect(changesResponse.events).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 () => { 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 foreignUserId = randomUUID();
const foreignTrackId = randomUUID(); const foreignTrackId = randomUUID();
@ -654,8 +742,12 @@ describe('Velody API wiring (e2e)', () => {
createdAt: new Date('2026-05-29T08:02:00.000Z'), createdAt: new Date('2026-05-29T08:02:00.000Z'),
}); });
const bootstrapResponse = await syncController.bootstrap(); const bootstrapResponse = await runAsDevice(device.deviceAccessToken, () =>
const changesResponse = await syncController.changes({ after: '0' }); syncController.bootstrap(),
);
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.changes({ after: '0' }),
);
expect(bootstrapResponse.tracks).toEqual([]); expect(bootstrapResponse.tracks).toEqual([]);
expect(changesResponse.events).toEqual([]); expect(changesResponse.events).toEqual([]);
@ -704,10 +796,12 @@ describe('Velody API wiring (e2e)', () => {
}, },
} as any; } as any;
const streamable = await assetsController.download( const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
assetId, assetsController.download(
{ deviceId: registerResponse.deviceId }, assetId,
responseMock, { deviceId: registerResponse.deviceId },
responseMock,
),
); );
const downloadedBytes = await streamToBuffer(streamable.getStream()); const downloadedBytes = await streamToBuffer(streamable.getStream());
@ -741,10 +835,12 @@ describe('Velody API wiring (e2e)', () => {
}); });
await expect( await expect(
assetsController.download( runAsDevice(registerResponse.deviceAccessToken, () =>
assetId, assetsController.download(
{ deviceId: registerResponse.deviceId }, assetId,
{ setHeader() {} } as any, { deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
), ),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
@ -778,10 +874,12 @@ describe('Velody API wiring (e2e)', () => {
}); });
await expect( await expect(
assetsController.download( runAsDevice(registerResponse.deviceAccessToken, () =>
assetId, assetsController.download(
{ deviceId: registerResponse.deviceId }, assetId,
{ setHeader() {} } as any, { deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
), ),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
@ -848,10 +946,12 @@ describe('Velody API wiring (e2e)', () => {
}, },
} as any; } as any;
const streamable = await artworkController.download( const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
artworkId, artworkController.download(
{ deviceId: registerResponse.deviceId }, artworkId,
responseMock, { deviceId: registerResponse.deviceId },
responseMock,
),
); );
const downloadedBytes = await streamToBuffer(streamable.getStream()); const downloadedBytes = await streamToBuffer(streamable.getStream());
@ -882,10 +982,12 @@ describe('Velody API wiring (e2e)', () => {
}); });
await expect( await expect(
artworkController.download( runAsDevice(registerResponse.deviceAccessToken, () =>
artworkId, artworkController.download(
{ deviceId: registerResponse.deviceId }, artworkId,
{ setHeader() {} } as any, { deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
), ),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
}); });
@ -917,10 +1019,12 @@ describe('Velody API wiring (e2e)', () => {
}); });
await expect( await expect(
artworkController.download( runAsDevice(registerResponse.deviceAccessToken, () =>
artworkId, artworkController.download(
{ deviceId: registerResponse.deviceId }, artworkId,
{ setHeader() {} } as any, { deviceId: registerResponse.deviceId },
{ setHeader() {} } as any,
),
), ),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
@ -1089,9 +1193,11 @@ describe('Velody API wiring (e2e)', () => {
updatedAt: new Date('2026-05-29T08:02:30.000Z'), updatedAt: new Date('2026-05-29T08:02:30.000Z'),
}); });
const response = await libraryController.getTracks({ const response = await runAsDevice(primaryDevice.deviceAccessToken, () =>
deviceId: primaryDevice.deviceId, libraryController.getTracks({
}); deviceId: primaryDevice.deviceId,
}),
);
expect(response).toEqual({ expect(response).toEqual({
tracks: [ tracks: [
@ -1340,59 +1446,18 @@ describe('Velody API wiring (e2e)', () => {
expect(linkedLibrary.tracks).toEqual(primaryLibrary.tracks); 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({ const ownerDevice = await devicesController.register({
platform: 'IPHONE', platform: 'IPHONE',
deviceName: 'Legacy iPhone', deviceName: 'Legacy iPhone',
appVersion: '0.1.0', appVersion: '0.1.0',
}); });
const trackId = randomUUID();
const assetId = randomUUID();
prismaState.audioAssets.set(assetId, { await expect(
id: assetId, libraryController.getTracks({
userId: prismaState.defaultUser.id, deviceId: ownerDevice.deviceId,
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,
}), }),
]); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => { 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 bytes = sampleMp3Bytes('e2e-upload');
const sha256 = sha256Hex(bytes); const sha256 = sha256Hex(bytes);
const prepareResponse = await uploadsController.prepare({ const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
deviceId: registerResponse.deviceId, uploadsController.prepare({
sha256, deviceId: registerResponse.deviceId,
originalFilename: 'e2e-upload.mp3', sha256,
sizeBytes: bytes.length, originalFilename: 'e2e-upload.mp3',
}); sizeBytes: bytes.length,
}),
);
expect(prepareResponse.status).toBe('upload_required'); expect(prepareResponse.status).toBe('upload_required');
const uploadResponse = await uploadsService.uploadFile( const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
prepareResponse.uploadId!, uploadsService.uploadFile(
createUploadRequest(bytes), prepareResponse.uploadId!,
createUploadRequest(bytes),
),
); );
expect(uploadResponse.status).toBe('COMPLETED'); expect(uploadResponse.status).toBe('COMPLETED');
const finalizeResponse = await uploadsController.finalize( const finalizeResponse = await runAsDevice(
prepareResponse.uploadId!, registerResponse.deviceAccessToken,
{ () =>
title: 'Uploaded Track', uploadsController.finalize(prepareResponse.uploadId!, {
artist: 'Velody', title: 'Uploaded Track',
album: 'Milestone 6', artist: 'Velody',
durationMs: 222000, album: 'Milestone 6',
}, durationMs: 222000,
}),
); );
expect(finalizeResponse.trackId).toBeDefined(); expect(finalizeResponse.trackId).toBeDefined();
@ -1629,12 +1699,16 @@ describe('Velody API wiring (e2e)', () => {
); );
expect(storedBytes.equals(bytes)).toBe(true); expect(storedBytes.equals(bytes)).toBe(true);
const duplicatePrepare = await uploadsController.prepare({ const duplicatePrepare = await runAsDevice(
deviceId: registerResponse.deviceId, registerResponse.deviceAccessToken,
sha256, () =>
originalFilename: 'e2e-upload.mp3', uploadsController.prepare({
sizeBytes: bytes.length, deviceId: registerResponse.deviceId,
}); sha256,
originalFilename: 'e2e-upload.mp3',
sizeBytes: bytes.length,
}),
);
expect(duplicatePrepare.status).toBe('exists'); expect(duplicatePrepare.status).toBe('exists');
expect(duplicatePrepare.uploadId).toBeDefined(); expect(duplicatePrepare.uploadId).toBeDefined();
@ -1656,45 +1730,52 @@ describe('Velody API wiring (e2e)', () => {
const sha256 = sha256Hex(bytes); const sha256 = sha256Hex(bytes);
const artworkSha256 = sha256Hex(artworkBytes); const artworkSha256 = sha256Hex(artworkBytes);
const prepareResponse = await uploadsController.prepare({ const prepareResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
deviceId: registerResponse.deviceId, uploadsController.prepare({
sha256, deviceId: registerResponse.deviceId,
originalFilename: 'e2e-upload-artwork.mp3', sha256,
sizeBytes: bytes.length, originalFilename: 'e2e-upload-artwork.mp3',
}); sizeBytes: bytes.length,
}),
);
expect(prepareResponse.status).toBe('upload_required'); expect(prepareResponse.status).toBe('upload_required');
const uploadResponse = await uploadsService.uploadFile( const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
prepareResponse.uploadId!, uploadsService.uploadFile(
createUploadRequest(bytes), prepareResponse.uploadId!,
createUploadRequest(bytes),
),
); );
expect(uploadResponse.status).toBe('COMPLETED'); expect(uploadResponse.status).toBe('COMPLETED');
const finalizeResponse = await uploadsController.finalize( const finalizeResponse = await runAsDevice(
prepareResponse.uploadId!, registerResponse.deviceAccessToken,
{ () =>
title: 'Uploaded Artwork Track', uploadsController.finalize(prepareResponse.uploadId!, {
artist: 'Velody', title: 'Uploaded Artwork Track',
album: 'Milestone 8.1', artist: 'Velody',
durationMs: 222000, album: 'Milestone 8.1',
artwork: { durationMs: 222000,
dataBase64: artworkBytes.toString('base64'), artwork: {
sha256: artworkSha256, dataBase64: artworkBytes.toString('base64'),
mimeType: 'image/png', sha256: artworkSha256,
width: 1, mimeType: 'image/png',
height: 1, width: 1,
}, height: 1,
}, },
}),
); );
expect(finalizeResponse.trackId).toBeDefined(); expect(finalizeResponse.trackId).toBeDefined();
expect(prismaState.artworkAssets.size).toBe(1); expect(prismaState.artworkAssets.size).toBe(1);
const remoteLibrary = await libraryController.getTracks({ const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () =>
deviceId: registerResponse.deviceId, libraryController.getTracks({
}); deviceId: registerResponse.deviceId,
}),
);
expect(remoteLibrary.tracks).toEqual([ expect(remoteLibrary.tracks).toEqual([
expect.objectContaining({ expect.objectContaining({
trackId: finalizeResponse.trackId, trackId: finalizeResponse.trackId,
@ -1727,10 +1808,12 @@ describe('Velody API wiring (e2e)', () => {
}, },
} as any; } as any;
const streamable = await artworkController.download( const streamable = await runAsDevice(registerResponse.deviceAccessToken, () =>
artworkAsset.id, artworkController.download(
{ deviceId: registerResponse.deviceId }, artworkAsset.id,
responseMock, { deviceId: registerResponse.deviceId },
responseMock,
),
); );
const downloadedArtworkBytes = await streamToBuffer(streamable.getStream()); const downloadedArtworkBytes = await streamToBuffer(streamable.getStream());
@ -1752,67 +1835,79 @@ describe('Velody API wiring (e2e)', () => {
const sha256 = sha256Hex(bytes); const sha256 = sha256Hex(bytes);
const artworkSha256 = sha256Hex(artworkBytes); const artworkSha256 = sha256Hex(artworkBytes);
const firstPrepare = await uploadsController.prepare({ const firstPrepare = await runAsDevice(registerResponse.deviceAccessToken, () =>
deviceId: registerResponse.deviceId, uploadsController.prepare({
sha256, deviceId: registerResponse.deviceId,
originalFilename: 'e2e-deduped-artwork.mp3', sha256,
sizeBytes: bytes.length, originalFilename: 'e2e-deduped-artwork.mp3',
}); sizeBytes: bytes.length,
}),
);
expect(firstPrepare.status).toBe('upload_required'); expect(firstPrepare.status).toBe('upload_required');
const uploadResponse = await uploadsService.uploadFile( const uploadResponse = await runAsDevice(registerResponse.deviceAccessToken, () =>
firstPrepare.uploadId!, uploadsService.uploadFile(
createUploadRequest(bytes), firstPrepare.uploadId!,
createUploadRequest(bytes),
),
); );
expect(uploadResponse.status).toBe('COMPLETED'); expect(uploadResponse.status).toBe('COMPLETED');
const firstFinalize = await uploadsController.finalize( const firstFinalize = await runAsDevice(
firstPrepare.uploadId!, registerResponse.deviceAccessToken,
{ () =>
title: 'Deduped Artwork Track', uploadsController.finalize(firstPrepare.uploadId!, {
artist: 'Velody', title: 'Deduped Artwork Track',
album: 'Milestone 8.1', artist: 'Velody',
durationMs: 222000, album: 'Milestone 8.1',
}, durationMs: 222000,
}),
); );
const secondPrepare = await uploadsController.prepare({ const secondPrepare = await runAsDevice(
deviceId: registerResponse.deviceId, registerResponse.deviceAccessToken,
sha256, () =>
originalFilename: 'e2e-deduped-artwork.mp3', uploadsController.prepare({
sizeBytes: bytes.length, deviceId: registerResponse.deviceId,
}); sha256,
originalFilename: 'e2e-deduped-artwork.mp3',
sizeBytes: bytes.length,
}),
);
expect(secondPrepare.status).toBe('exists'); expect(secondPrepare.status).toBe('exists');
expect(secondPrepare.uploadId).toBeDefined(); expect(secondPrepare.uploadId).toBeDefined();
expect(prismaState.audioAssets.size).toBe(1); expect(prismaState.audioAssets.size).toBe(1);
const secondFinalize = await uploadsController.finalize( const secondFinalize = await runAsDevice(
secondPrepare.uploadId!, registerResponse.deviceAccessToken,
{ () =>
title: 'Deduped Artwork Track', uploadsController.finalize(secondPrepare.uploadId!, {
artist: 'Velody', title: 'Deduped Artwork Track',
album: 'Milestone 8.1', artist: 'Velody',
durationMs: 222000, album: 'Milestone 8.1',
artwork: { durationMs: 222000,
dataBase64: artworkBytes.toString('base64'), artwork: {
sha256: artworkSha256, dataBase64: artworkBytes.toString('base64'),
mimeType: 'image/png', sha256: artworkSha256,
width: 1, mimeType: 'image/png',
height: 1, width: 1,
}, height: 1,
}, },
}),
); );
expect(secondFinalize.trackId).toBe(firstFinalize.trackId); expect(secondFinalize.trackId).toBe(firstFinalize.trackId);
expect(secondFinalize.assetId).toBe(firstFinalize.assetId); expect(secondFinalize.assetId).toBe(firstFinalize.assetId);
expect(prismaState.artworkAssets.size).toBe(1); expect(prismaState.artworkAssets.size).toBe(1);
const remoteLibrary = await libraryController.getTracks({ const remoteLibrary = await runAsDevice(registerResponse.deviceAccessToken, () =>
deviceId: registerResponse.deviceId, libraryController.getTracks({
}); deviceId: registerResponse.deviceId,
}),
);
expect(remoteLibrary.tracks).toEqual([ expect(remoteLibrary.tracks).toEqual([
expect.objectContaining({ expect.objectContaining({
trackId: firstFinalize.trackId, trackId: firstFinalize.trackId,