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