Add device access token authentication

This commit is contained in:
diyaa
2026-06-09 12:05:15 +02:00
parent 2945257ea7
commit 45c270c187
49 changed files with 1345 additions and 233 deletions
+342 -7
View File
@@ -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',