Add device access token authentication
This commit is contained in:
@@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
|
||||
import {
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
@@ -13,8 +14,10 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { RequestContextService } from '../../src/infrastructure/request-context/request-context.service';
|
||||
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
||||
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
||||
import { DeviceAuthService } from '../../src/modules/auth/device-auth.service';
|
||||
import { ArtworkController } from '../../src/modules/artwork/artwork.controller';
|
||||
import { AppConfigService } from '../../src/modules/config/config.service';
|
||||
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
||||
@@ -38,6 +41,25 @@ function sha256Hex(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function applySelect<T extends Record<string, any>>(
|
||||
record: T | null,
|
||||
select?: Record<string, boolean>,
|
||||
) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!select) {
|
||||
return record;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(select)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => [key, record[key]]),
|
||||
);
|
||||
}
|
||||
|
||||
function createUploadRequest(data: Buffer): any {
|
||||
const request = Readable.from([data]) as any;
|
||||
request.headers = {
|
||||
@@ -96,17 +118,21 @@ function createPrismaMock() {
|
||||
devices.set(record.id, record);
|
||||
return record;
|
||||
}),
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
const device = devices.get(where.id) ?? null;
|
||||
if (!device) {
|
||||
return null;
|
||||
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
|
||||
if (where.id) {
|
||||
return applySelect(devices.get(where.id) ?? null, select);
|
||||
}
|
||||
|
||||
if (where.id && typeof where.id === 'string') {
|
||||
return device;
|
||||
if (where.tokenHash) {
|
||||
const matchingDevice =
|
||||
[...devices.values()].find(
|
||||
(device) => device.tokenHash === where.tokenHash,
|
||||
) ?? null;
|
||||
|
||||
return applySelect(matchingDevice, select);
|
||||
}
|
||||
|
||||
return device;
|
||||
return null;
|
||||
}),
|
||||
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||
const current = devices.get(where.id);
|
||||
@@ -322,6 +348,8 @@ describe('Velody API wiring (e2e)', () => {
|
||||
let syncController: SyncController;
|
||||
let uploadsController: UploadsController;
|
||||
let uploadsService: UploadsService;
|
||||
let requestContextService: RequestContextService;
|
||||
let deviceAuthService: DeviceAuthService;
|
||||
let prismaState: ReturnType<typeof createPrismaMock>['state'];
|
||||
let storageRoot: string;
|
||||
|
||||
@@ -370,6 +398,8 @@ describe('Velody API wiring (e2e)', () => {
|
||||
syncController = moduleRef.get(SyncController);
|
||||
uploadsController = moduleRef.get(UploadsController);
|
||||
uploadsService = moduleRef.get(UploadsService);
|
||||
requestContextService = moduleRef.get(RequestContextService);
|
||||
deviceAuthService = moduleRef.get(DeviceAuthService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -413,10 +443,17 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
|
||||
expect(registerResponse.deviceId).toBeDefined();
|
||||
expect(registerResponse.deviceAccessToken).toBeDefined();
|
||||
expect(registerResponse.bootstrapToken).toBeDefined();
|
||||
expect(prismaState.devices.get(registerResponse.deviceId)?.userId).toBe(
|
||||
prismaState.defaultUser.id,
|
||||
);
|
||||
expect(prismaState.devices.get(registerResponse.deviceId)?.tokenHash).toBe(
|
||||
sha256Hex(Buffer.from(registerResponse.deviceAccessToken, 'utf8')),
|
||||
);
|
||||
expect(prismaState.devices.get(registerResponse.deviceId)?.tokenCreatedAt).toEqual(
|
||||
expect.any(Date),
|
||||
);
|
||||
|
||||
const heartbeatResponse = await devicesController.heartbeat({
|
||||
deviceId: registerResponse.deviceId,
|
||||
@@ -986,6 +1023,304 @@ describe('Velody API wiring (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('authenticates remote library requests with a valid device token and ignores spoofed device ids', async () => {
|
||||
const ownerDevice = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Auth iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const foreignUserId = randomUUID();
|
||||
const foreignDeviceId = randomUUID();
|
||||
const ownerTrackId = randomUUID();
|
||||
const ownerAssetId = randomUUID();
|
||||
const foreignTrackId = randomUUID();
|
||||
const foreignAssetId = randomUUID();
|
||||
|
||||
prismaState.devices.set(foreignDeviceId, {
|
||||
id: foreignDeviceId,
|
||||
userId: foreignUserId,
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Spoofed Foreign iPhone',
|
||||
appVersion: '0.1.0',
|
||||
installTokenHash: 'spoofed-device-hash',
|
||||
tokenHash: sha256Hex(Buffer.from('foreign-device-token', 'utf8')),
|
||||
tokenCreatedAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
prismaState.audioAssets.set(ownerAssetId, {
|
||||
id: ownerAssetId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
trackId: ownerTrackId,
|
||||
sha256: 'owner-library-sha',
|
||||
storageKey: 'users/default/audio/owner-library-sha.mp3',
|
||||
originalFilename: 'owner-library.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
fileExtension: 'mp3',
|
||||
fileSizeBytes: BigInt(42),
|
||||
durationMs: 183000,
|
||||
sourceDeviceId: ownerDevice.deviceId,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
});
|
||||
prismaState.audioAssets.set(foreignAssetId, {
|
||||
id: foreignAssetId,
|
||||
userId: foreignUserId,
|
||||
trackId: foreignTrackId,
|
||||
sha256: 'foreign-library-sha',
|
||||
storageKey: 'users/foreign/audio/foreign-library-sha.mp3',
|
||||
originalFilename: 'foreign-library.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
fileExtension: 'mp3',
|
||||
fileSizeBytes: BigInt(42),
|
||||
durationMs: 204000,
|
||||
sourceDeviceId: foreignDeviceId,
|
||||
createdAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||
});
|
||||
prismaState.tracks.set(ownerTrackId, {
|
||||
id: ownerTrackId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
primaryAudioAssetId: ownerAssetId,
|
||||
artworkAssetId: null,
|
||||
title: 'Authenticated Owner Track',
|
||||
artist: 'Velody',
|
||||
album: null,
|
||||
albumArtist: null,
|
||||
genre: null,
|
||||
discNumber: null,
|
||||
trackNumber: null,
|
||||
year: null,
|
||||
durationMs: 183000,
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||
});
|
||||
prismaState.tracks.set(foreignTrackId, {
|
||||
id: foreignTrackId,
|
||||
userId: foreignUserId,
|
||||
primaryAudioAssetId: foreignAssetId,
|
||||
artworkAssetId: null,
|
||||
title: 'Foreign Track',
|
||||
artist: 'Elsewhere',
|
||||
album: null,
|
||||
albumArtist: null,
|
||||
genre: null,
|
||||
discNumber: null,
|
||||
trackNumber: null,
|
||||
year: null,
|
||||
durationMs: 204000,
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
createdAt: new Date('2026-05-29T08:03:00.000Z'),
|
||||
updatedAt: new Date('2026-05-29T08:04:00.000Z'),
|
||||
});
|
||||
|
||||
await requestContextService.run(async () => {
|
||||
await deviceAuthService.authenticateAuthorizationHeader(
|
||||
`Bearer ${ownerDevice.deviceAccessToken}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
libraryController.getTracks({
|
||||
deviceId: foreignDeviceId,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
tracks: [
|
||||
expect.objectContaining({
|
||||
trackId: ownerTrackId,
|
||||
title: 'Authenticated Owner Track',
|
||||
assetId: ownerAssetId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the legacy library deviceId path working when Authorization is missing', async () => {
|
||||
const ownerDevice = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Legacy iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const trackId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
|
||||
prismaState.audioAssets.set(assetId, {
|
||||
id: assetId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
trackId,
|
||||
sha256: 'legacy-library-sha',
|
||||
storageKey: 'users/default/audio/legacy-library-sha.mp3',
|
||||
originalFilename: 'legacy-library.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
fileExtension: 'mp3',
|
||||
fileSizeBytes: BigInt(42),
|
||||
durationMs: 210000,
|
||||
sourceDeviceId: ownerDevice.deviceId,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
});
|
||||
prismaState.tracks.set(trackId, {
|
||||
id: trackId,
|
||||
userId: prismaState.defaultUser.id,
|
||||
primaryAudioAssetId: assetId,
|
||||
artworkAssetId: null,
|
||||
title: 'Legacy Library Track',
|
||||
artist: 'Velody',
|
||||
album: null,
|
||||
albumArtist: null,
|
||||
genre: null,
|
||||
discNumber: null,
|
||||
trackNumber: null,
|
||||
year: null,
|
||||
durationMs: 210000,
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||
});
|
||||
|
||||
const response = await libraryController.getTracks({
|
||||
deviceId: ownerDevice.deviceId,
|
||||
});
|
||||
|
||||
expect(response.tracks).toEqual([
|
||||
expect.objectContaining({
|
||||
trackId,
|
||||
assetId,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
|
||||
const ownerDevice = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Rejected iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
|
||||
await requestContextService.run(async () => {
|
||||
await expect(
|
||||
deviceAuthService.authenticateAuthorizationHeader(
|
||||
'Bearer invalid-device-token',
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
prismaState.devices.set(ownerDevice.deviceId, {
|
||||
...prismaState.devices.get(ownerDevice.deviceId),
|
||||
tokenRevokedAt: new Date('2026-06-09T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
await requestContextService.run(async () => {
|
||||
await expect(
|
||||
deviceAuthService.authenticateAuthorizationHeader(
|
||||
`Bearer ${ownerDevice.deviceAccessToken}`,
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects cross-owner asset and artwork downloads for authenticated tokens', async () => {
|
||||
const ownerDevice = await devicesController.register({
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Owner iPhone',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
const foreignUserId = randomUUID();
|
||||
const foreignDeviceId = randomUUID();
|
||||
const foreignAssetId = randomUUID();
|
||||
const foreignArtworkId = randomUUID();
|
||||
|
||||
prismaState.devices.set(foreignDeviceId, {
|
||||
id: foreignDeviceId,
|
||||
userId: foreignUserId,
|
||||
platform: 'IPHONE',
|
||||
deviceName: 'Foreign iPhone',
|
||||
appVersion: '0.1.0',
|
||||
installTokenHash: 'foreign-install-hash',
|
||||
tokenHash: sha256Hex(Buffer.from('foreign-access-token', 'utf8')),
|
||||
tokenCreatedAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
prismaState.audioAssets.set(foreignAssetId, {
|
||||
id: foreignAssetId,
|
||||
userId: foreignUserId,
|
||||
trackId: randomUUID(),
|
||||
sha256: 'foreign-asset-sha',
|
||||
storageKey: 'users/foreign/audio/foreign-asset-sha.mp3',
|
||||
originalFilename: 'foreign-asset.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
fileExtension: 'mp3',
|
||||
fileSizeBytes: BigInt(64),
|
||||
durationMs: 190000,
|
||||
sourceDeviceId: foreignDeviceId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
prismaState.artworkAssets.set(foreignArtworkId, {
|
||||
id: foreignArtworkId,
|
||||
userId: foreignUserId,
|
||||
sha256: 'foreign-artwork-sha',
|
||||
storageKey: 'users/foreign/artwork/foreign-artwork-sha.png',
|
||||
mimeType: 'image/png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fileSizeBytes: BigInt(64),
|
||||
createdAt: new Date(),
|
||||
tracks: [],
|
||||
});
|
||||
|
||||
await requestContextService.run(async () => {
|
||||
await deviceAuthService.authenticateAuthorizationHeader(
|
||||
`Bearer ${ownerDevice.deviceAccessToken}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
assetsController.download(
|
||||
foreignAssetId,
|
||||
{},
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
artworkController.download(
|
||||
foreignArtworkId,
|
||||
{},
|
||||
{ setHeader() {} } as any,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates tokenLastUsedAt during authenticated heartbeat', async () => {
|
||||
const ownerDevice = await devicesController.register({
|
||||
platform: 'MACOS',
|
||||
deviceName: 'Heartbeat Mac',
|
||||
appVersion: '0.1.0',
|
||||
});
|
||||
|
||||
expect(prismaState.devices.get(ownerDevice.deviceId)?.tokenLastUsedAt).toBeUndefined();
|
||||
|
||||
await requestContextService.run(async () => {
|
||||
await deviceAuthService.authenticateAuthorizationHeader(
|
||||
`Bearer ${ownerDevice.deviceAccessToken}`,
|
||||
);
|
||||
|
||||
const response = await devicesController.heartbeat({
|
||||
appVersion: '0.1.1',
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
});
|
||||
|
||||
expect(prismaState.devices.get(ownerDevice.deviceId)?.tokenLastUsedAt).toEqual(
|
||||
expect.any(Date),
|
||||
);
|
||||
});
|
||||
|
||||
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
|
||||
const registerResponse = await devicesController.register({
|
||||
platform: 'MACOS',
|
||||
|
||||
Reference in New Issue
Block a user