Add device access token authentication
This commit is contained in:
parent
2945257ea7
commit
45c270c187
@ -1,11 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@ -27,8 +26,7 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
@ -51,8 +49,6 @@
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@ -74,8 +70,6 @@
|
||||
ReferencedContainer = "container:Velody.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
@ -93,8 +87,6 @@
|
||||
ReferencedContainer = "container:Velody.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
|
||||
@ -345,12 +345,7 @@ final class MacLibraryViewModel {
|
||||
appVersion: environment.appVersion
|
||||
)
|
||||
let response = try await makeAPIClient(for: environment).registerDevice(payload)
|
||||
|
||||
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
|
||||
try await keychainService.save(response.bootstrapToken, forKey: Self.bootstrapTokenKey)
|
||||
|
||||
registeredDeviceId = response.deviceId
|
||||
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
|
||||
try await persistRegisteredDevice(response)
|
||||
} catch {
|
||||
deviceRegistrationStatus = "Registration failed: \(error.localizedDescription)"
|
||||
}
|
||||
@ -365,7 +360,7 @@ final class MacLibraryViewModel {
|
||||
|
||||
do {
|
||||
let environment = try currentEnvironment()
|
||||
let deviceId = try await currentDeviceId()
|
||||
let deviceId = try await currentOrRegisterDeviceId(for: environment)
|
||||
let response = try await makeAPIClient(for: environment).sendHeartbeat(
|
||||
DeviceHeartbeatPayload(
|
||||
deviceId: deviceId,
|
||||
@ -421,7 +416,7 @@ final class MacLibraryViewModel {
|
||||
|
||||
do {
|
||||
let environment = try currentEnvironment()
|
||||
let deviceId = try await currentDeviceId()
|
||||
let deviceId = try await currentOrRegisterDeviceId(for: environment)
|
||||
let fileURL = URL(fileURLWithPath: initialTrack.localFilePath)
|
||||
|
||||
try await withStoredFolderAccess {
|
||||
@ -589,13 +584,18 @@ final class MacLibraryViewModel {
|
||||
private func restoreDeviceIdentity() async {
|
||||
do {
|
||||
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
|
||||
let deviceAccessToken = try await keychainService.loadValue(
|
||||
forKey: Self.deviceAccessTokenKey
|
||||
)
|
||||
let bootstrapToken = try await keychainService.loadValue(forKey: Self.bootstrapTokenKey)
|
||||
|
||||
registeredDeviceId = deviceId
|
||||
|
||||
if let deviceId {
|
||||
if let bootstrapToken, !bootstrapToken.isEmpty {
|
||||
if let deviceAccessToken, !deviceAccessToken.isEmpty {
|
||||
deviceRegistrationStatus = "Registered locally."
|
||||
} else if let bootstrapToken, !bootstrapToken.isEmpty {
|
||||
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Re-register to get a device access token."
|
||||
} else {
|
||||
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Bootstrap token is missing."
|
||||
}
|
||||
@ -621,23 +621,62 @@ final class MacLibraryViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
private func currentDeviceId() async throws -> String {
|
||||
private func currentOrRegisterDeviceId(
|
||||
for environment: ServerEnvironment
|
||||
) async throws -> String {
|
||||
if let registeredDeviceId, !registeredDeviceId.isEmpty {
|
||||
if let savedDeviceAccessToken = try await keychainService.loadValue(
|
||||
forKey: Self.deviceAccessTokenKey
|
||||
), !savedDeviceAccessToken.isEmpty {
|
||||
return registeredDeviceId
|
||||
}
|
||||
}
|
||||
|
||||
if let savedDeviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey),
|
||||
let savedDeviceAccessToken = try await keychainService.loadValue(
|
||||
forKey: Self.deviceAccessTokenKey
|
||||
),
|
||||
!savedDeviceAccessToken.isEmpty,
|
||||
!savedDeviceId.isEmpty
|
||||
{
|
||||
registeredDeviceId = savedDeviceId
|
||||
return savedDeviceId
|
||||
}
|
||||
|
||||
throw BackendConnectionError.missingDeviceIdentity
|
||||
let payload = DeviceRegistrationPayload(
|
||||
platform: .macos,
|
||||
deviceName: Self.currentDeviceName,
|
||||
appVersion: environment.appVersion
|
||||
)
|
||||
let response = try await makeAPIClient(for: environment).registerDevice(payload)
|
||||
try await persistRegisteredDevice(response)
|
||||
return response.deviceId
|
||||
}
|
||||
|
||||
private func makeAPIClient(for environment: ServerEnvironment) -> URLSessionVelodyAPIClient {
|
||||
URLSessionVelodyAPIClient(environment: environment)
|
||||
URLSessionVelodyAPIClient(
|
||||
environment: environment,
|
||||
deviceAccessTokenProvider: { [self] in
|
||||
try await self.keychainService.loadValue(forKey: Self.deviceAccessTokenKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func persistRegisteredDevice(
|
||||
_ response: DeviceRegistrationResponse
|
||||
) async throws {
|
||||
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
|
||||
try await keychainService.save(
|
||||
response.deviceAccessToken,
|
||||
forKey: Self.deviceAccessTokenKey
|
||||
)
|
||||
try await keychainService.save(
|
||||
response.bootstrapToken,
|
||||
forKey: Self.bootstrapTokenKey
|
||||
)
|
||||
|
||||
registeredDeviceId = response.deviceId
|
||||
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
|
||||
}
|
||||
|
||||
private func currentTrack(for trackID: String) -> LibraryTrack? {
|
||||
@ -847,6 +886,7 @@ final class MacLibraryViewModel {
|
||||
|
||||
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
|
||||
private static let deviceIdKey = "velody.device-id"
|
||||
private static let deviceAccessTokenKey = "velody.device-access-token"
|
||||
private static let bootstrapTokenKey = "velody.bootstrap-token"
|
||||
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
||||
|
||||
|
||||
@ -469,7 +469,12 @@ final class iPhoneLibraryViewModel {
|
||||
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||
appVersion: "0.1.0"
|
||||
)
|
||||
let apiClient = URLSessionVelodyAPIClient(environment: environment)
|
||||
let apiClient = URLSessionVelodyAPIClient(
|
||||
environment: environment,
|
||||
deviceAccessTokenProvider: {
|
||||
try await keychainService.loadValue(forKey: Self.deviceAccessTokenKey)
|
||||
}
|
||||
)
|
||||
let store = Self.makeRemoteLibraryStore()
|
||||
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
||||
let audioFileStore = Self.makeOfflineAudioFileStore()
|
||||
@ -657,7 +662,10 @@ final class iPhoneLibraryViewModel {
|
||||
private func currentOrRegisterDeviceID() async throws -> String {
|
||||
if let existingDeviceID = try await keychainService.loadValue(
|
||||
forKey: Self.deviceIDKey
|
||||
), !existingDeviceID.isEmpty {
|
||||
), !existingDeviceID.isEmpty,
|
||||
let existingDeviceAccessToken = try await keychainService.loadValue(
|
||||
forKey: Self.deviceAccessTokenKey
|
||||
), !existingDeviceAccessToken.isEmpty {
|
||||
return existingDeviceID
|
||||
}
|
||||
|
||||
@ -670,6 +678,10 @@ final class iPhoneLibraryViewModel {
|
||||
)
|
||||
|
||||
try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey)
|
||||
try await keychainService.save(
|
||||
response.deviceAccessToken,
|
||||
forKey: Self.deviceAccessTokenKey
|
||||
)
|
||||
try await keychainService.save(
|
||||
response.bootstrapToken,
|
||||
forKey: Self.bootstrapTokenKey
|
||||
@ -914,6 +926,7 @@ final class iPhoneLibraryViewModel {
|
||||
#endif
|
||||
|
||||
private static let deviceIDKey = "velody.iphone.device-id"
|
||||
private static let deviceAccessTokenKey = "velody.iphone.device-access-token"
|
||||
private static let bootstrapTokenKey = "velody.iphone.bootstrap-token"
|
||||
private static let remoteLibraryEmptyMessage = LibrarySectionMessage(
|
||||
title: "No music synced yet",
|
||||
|
||||
@ -454,6 +454,71 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
|
||||
func testRefreshSyncStoresDeviceAccessTokenWhenRegistrationRuns() async throws {
|
||||
let keychain = MemoryKeychainService()
|
||||
let counter = RegisterCallCounter()
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [
|
||||
makeRemoteTrack(
|
||||
trackId: "remote-auth-store",
|
||||
assetId: "asset-auth-store",
|
||||
title: "Store Token"
|
||||
),
|
||||
],
|
||||
apiClient: TestRegisterAPIClient(counter: counter),
|
||||
keychainService: keychain
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
await viewModel.refreshSync()
|
||||
|
||||
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
|
||||
let storedDeviceAccessToken = try await keychain.loadValue(
|
||||
forKey: "velody.iphone.device-access-token"
|
||||
)
|
||||
|
||||
XCTAssertEqual(await counter.count, 1)
|
||||
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
|
||||
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
||||
}
|
||||
|
||||
func testExistingDeviceWithoutAccessTokenReregistersCleanly() async throws {
|
||||
let keychain = MemoryKeychainService()
|
||||
let legacyDeviceID = "legacy-device-id"
|
||||
try await keychain.save(legacyDeviceID, forKey: "velody.iphone.device-id")
|
||||
try await keychain.save(
|
||||
"legacy-bootstrap-token",
|
||||
forKey: "velody.iphone.bootstrap-token"
|
||||
)
|
||||
let counter = RegisterCallCounter()
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [
|
||||
makeRemoteTrack(
|
||||
trackId: "remote-auth-reregister",
|
||||
assetId: "asset-auth-reregister",
|
||||
title: "Re-register Token"
|
||||
),
|
||||
],
|
||||
apiClient: TestRegisterAPIClient(counter: counter),
|
||||
keychainService: keychain
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
await viewModel.refreshSync()
|
||||
|
||||
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
|
||||
let storedDeviceAccessToken = try await keychain.loadValue(
|
||||
forKey: "velody.iphone.device-access-token"
|
||||
)
|
||||
|
||||
XCTAssertEqual(await counter.count, 1)
|
||||
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
|
||||
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private actor RegisterCallCounter {
|
||||
private(set) var count = 0
|
||||
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
ALTER TABLE "devices"
|
||||
ADD COLUMN "token_hash" TEXT,
|
||||
ADD COLUMN "token_created_at" TIMESTAMP(3),
|
||||
ADD COLUMN "token_last_used_at" TIMESTAMP(3),
|
||||
ADD COLUMN "token_revoked_at" TIMESTAMP(3);
|
||||
|
||||
CREATE UNIQUE INDEX "devices_token_hash_key" ON "devices"("token_hash");
|
||||
@ -31,6 +31,10 @@ model Device {
|
||||
deviceName String @map("device_name")
|
||||
appVersion String @map("app_version")
|
||||
installTokenHash String @map("install_token_hash")
|
||||
tokenHash String? @unique @map("token_hash")
|
||||
tokenCreatedAt DateTime? @map("token_created_at")
|
||||
tokenLastUsedAt DateTime? @map("token_last_used_at")
|
||||
tokenRevokedAt DateTime? @map("token_revoked_at")
|
||||
lastSeenAt DateTime @map("last_seen_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetsModule } from './modules/assets/assets.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { ArtworkModule } from './modules/artwork/artwork.module';
|
||||
import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DevicesModule } from './modules/devices/devices.module';
|
||||
@ -12,6 +13,7 @@ import { UsersModule } from './modules/users/users.module';
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
AssetsModule,
|
||||
ArtworkModule,
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { RequestContextService } from './request-context.service';
|
||||
|
||||
@Injectable()
|
||||
export class RequestContextMiddleware implements NestMiddleware {
|
||||
constructor(private readonly requestContext: RequestContextService) {}
|
||||
|
||||
use(_request: Request, _response: Response, next: NextFunction): void {
|
||||
this.requestContext.run(() => next());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RequestContextMiddleware } from './request-context.middleware';
|
||||
import { RequestContextService } from './request-context.service';
|
||||
|
||||
@Module({
|
||||
providers: [RequestContextService, RequestContextMiddleware],
|
||||
exports: [RequestContextService, RequestContextMiddleware],
|
||||
})
|
||||
export class RequestContextModule {}
|
||||
@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export interface AuthenticatedDeviceContextValue {
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface RequestContextState {
|
||||
authenticatedDevice: AuthenticatedDeviceContextValue | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RequestContextService {
|
||||
private readonly storage = new AsyncLocalStorage<RequestContextState>();
|
||||
|
||||
run<T>(callback: () => T): T {
|
||||
return this.storage.run(
|
||||
{
|
||||
authenticatedDevice: null,
|
||||
},
|
||||
callback,
|
||||
);
|
||||
}
|
||||
|
||||
getAuthenticatedDevice(): AuthenticatedDeviceContextValue | null {
|
||||
return this.storage.getStore()?.authenticatedDevice ?? null;
|
||||
}
|
||||
|
||||
setAuthenticatedDevice(device: AuthenticatedDeviceContextValue): void {
|
||||
const store = this.storage.getStore();
|
||||
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.authenticatedDevice = device;
|
||||
}
|
||||
}
|
||||
@ -5,14 +5,23 @@ import {
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { AssetDownloadQueryDto } from '../assets/assets.dto';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import { ArtworkService } from './artwork.service';
|
||||
|
||||
@ApiTags('artwork')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Controller({
|
||||
path: 'artwork',
|
||||
version: '1',
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { ArtworkController } from './artwork.controller';
|
||||
import { ArtworkService } from './artwork.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StorageModule, UsersModule],
|
||||
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
|
||||
controllers: [ArtworkController],
|
||||
providers: [ArtworkService],
|
||||
})
|
||||
|
||||
@ -5,23 +5,17 @@ import { join } from 'node:path';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import { ArtworkService } from './artwork.service';
|
||||
|
||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||
|
||||
function createPrismaMock() {
|
||||
const devices = new Map<string, any>();
|
||||
const artworkAssets = new Map<string, any>();
|
||||
|
||||
return {
|
||||
prismaMock: {
|
||||
device: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return devices.get(where.id) ?? null;
|
||||
}),
|
||||
},
|
||||
artworkAsset: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return artworkAssets.get(where.id) ?? null;
|
||||
@ -29,7 +23,6 @@ function createPrismaMock() {
|
||||
},
|
||||
} as unknown as PrismaService,
|
||||
state: {
|
||||
devices,
|
||||
artworkAssets,
|
||||
},
|
||||
};
|
||||
@ -48,6 +41,9 @@ describe('ArtworkService', () => {
|
||||
let storageRoot: string;
|
||||
let storageService: LocalFilesystemStorageService;
|
||||
let ownerUserId: string;
|
||||
let deviceAuthService: {
|
||||
resolveCurrentDevice: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mock = createPrismaMock();
|
||||
@ -55,14 +51,16 @@ describe('ArtworkService', () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-artwork-spec-'));
|
||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||
ownerUserId = randomUUID();
|
||||
deviceAuthService = {
|
||||
resolveCurrentDevice: jest.fn().mockResolvedValue({
|
||||
deviceId: randomUUID(),
|
||||
userId: ownerUserId,
|
||||
}),
|
||||
};
|
||||
service = new ArtworkService(
|
||||
mock.prismaMock,
|
||||
storageService,
|
||||
{
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
userId: ownerUserId,
|
||||
}),
|
||||
} as OwnerContext,
|
||||
deviceAuthService as unknown as DeviceAuthService,
|
||||
);
|
||||
});
|
||||
|
||||
@ -72,7 +70,6 @@ describe('ArtworkService', () => {
|
||||
|
||||
it('returns a local file path, content length, and mime type for the owning device user', async () => {
|
||||
const userId = ownerUserId;
|
||||
const deviceId = randomUUID();
|
||||
const artworkId = randomUUID();
|
||||
const storageKey = join('users', userId, 'artwork', `${artworkId}.png`);
|
||||
const bytes = Buffer.from(
|
||||
@ -80,7 +77,6 @@ describe('ArtworkService', () => {
|
||||
'base64',
|
||||
);
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
state.artworkAssets.set(artworkId, {
|
||||
userId,
|
||||
storageKey,
|
||||
@ -91,7 +87,7 @@ describe('ArtworkService', () => {
|
||||
await storageService.ensureParentDirectory(filePath);
|
||||
await writeFile(filePath, bytes);
|
||||
|
||||
const download = await service.getOwnedArtworkDownload(artworkId, deviceId);
|
||||
const download = await service.getOwnedArtworkDownload(artworkId, randomUUID());
|
||||
|
||||
expect(download.filePath).toBe(filePath);
|
||||
expect(download.contentLength).toBe(bytes.length);
|
||||
@ -100,10 +96,8 @@ describe('ArtworkService', () => {
|
||||
|
||||
it('rejects cross-owner artwork downloads', async () => {
|
||||
const otherUserId = randomUUID();
|
||||
const ownerDeviceId = randomUUID();
|
||||
const artworkId = randomUUID();
|
||||
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerUserId });
|
||||
state.artworkAssets.set(artworkId, {
|
||||
userId: otherUserId,
|
||||
storageKey: join('users', otherUserId, 'artwork', `${artworkId}.jpg`),
|
||||
@ -111,29 +105,24 @@ describe('ArtworkService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(artworkId, ownerDeviceId),
|
||||
service.getOwnedArtworkDownload(artworkId, randomUUID()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects foreign-owner devices before reading artwork', async () => {
|
||||
const foreignDeviceId = randomUUID();
|
||||
|
||||
state.devices.set(foreignDeviceId, {
|
||||
id: foreignDeviceId,
|
||||
userId: randomUUID(),
|
||||
});
|
||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(randomUUID(), foreignDeviceId),
|
||||
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns not found when the artwork file is missing from storage', async () => {
|
||||
const userId = ownerUserId;
|
||||
const deviceId = randomUUID();
|
||||
const artworkId = randomUUID();
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
state.artworkAssets.set(artworkId, {
|
||||
userId,
|
||||
storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
|
||||
@ -141,18 +130,13 @@ describe('ArtworkService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(artworkId, deviceId),
|
||||
service.getOwnedArtworkDownload(artworkId, randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns not found when the artwork asset does not exist', async () => {
|
||||
const userId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
|
||||
await expect(
|
||||
service.getOwnedArtworkDownload(randomUUID(), deviceId),
|
||||
service.getOwnedArtworkDownload(randomUUID(), randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,8 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
|
||||
export interface ArtworkDownload {
|
||||
filePath: string;
|
||||
@ -19,24 +19,15 @@ export class ArtworkService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly storageService: LocalFilesystemStorageService,
|
||||
private readonly ownerContext: OwnerContext,
|
||||
private readonly deviceAuthService: DeviceAuthService,
|
||||
) {}
|
||||
|
||||
async getOwnedArtworkDownload(
|
||||
artworkId: string,
|
||||
deviceId: string,
|
||||
legacyDeviceId?: string,
|
||||
): Promise<ArtworkDownload> {
|
||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device || device.userId !== ownerUserId) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
const { userId: ownerUserId } =
|
||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
||||
|
||||
const artwork = await this.prismaService.artworkAsset.findUnique({
|
||||
where: { id: artworkId },
|
||||
@ -77,9 +68,4 @@ export class ArtworkService {
|
||||
throw new NotFoundException('Artwork file not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||
const owner = await this.ownerContext.resolve();
|
||||
return owner.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,27 @@
|
||||
import { Controller, Get, Param, Query, Res, StreamableFile } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import { AssetDownloadQueryDto } from './assets.dto';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
@ApiTags('assets')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Controller({
|
||||
path: 'assets',
|
||||
version: '1',
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AssetDownloadQueryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
deviceId!: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StorageModule, UsersModule],
|
||||
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
|
||||
controllers: [AssetsController],
|
||||
providers: [AssetsService],
|
||||
})
|
||||
|
||||
@ -5,23 +5,17 @@ import { join } from 'node:path';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import { AssetsService } from './assets.service';
|
||||
|
||||
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||
|
||||
function createPrismaMock() {
|
||||
const devices = new Map<string, any>();
|
||||
const audioAssets = new Map<string, any>();
|
||||
|
||||
return {
|
||||
prismaMock: {
|
||||
device: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return devices.get(where.id) ?? null;
|
||||
}),
|
||||
},
|
||||
audioAsset: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
return audioAssets.get(where.id) ?? null;
|
||||
@ -29,7 +23,6 @@ function createPrismaMock() {
|
||||
},
|
||||
} as unknown as PrismaService,
|
||||
state: {
|
||||
devices,
|
||||
audioAssets,
|
||||
},
|
||||
};
|
||||
@ -48,6 +41,9 @@ describe('AssetsService', () => {
|
||||
let storageRoot: string;
|
||||
let storageService: LocalFilesystemStorageService;
|
||||
let ownerUserId: string;
|
||||
let deviceAuthService: {
|
||||
resolveCurrentDevice: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mock = createPrismaMock();
|
||||
@ -55,14 +51,16 @@ describe('AssetsService', () => {
|
||||
storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-'));
|
||||
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||
ownerUserId = randomUUID();
|
||||
deviceAuthService = {
|
||||
resolveCurrentDevice: jest.fn().mockResolvedValue({
|
||||
deviceId: randomUUID(),
|
||||
userId: ownerUserId,
|
||||
}),
|
||||
};
|
||||
service = new AssetsService(
|
||||
mock.prismaMock,
|
||||
storageService,
|
||||
{
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
userId: ownerUserId,
|
||||
}),
|
||||
} as OwnerContext,
|
||||
deviceAuthService as unknown as DeviceAuthService,
|
||||
);
|
||||
});
|
||||
|
||||
@ -72,12 +70,10 @@ describe('AssetsService', () => {
|
||||
|
||||
it('returns a local file path and content length for the owning device user', async () => {
|
||||
const userId = ownerUserId;
|
||||
const deviceId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
const storageKey = join('users', userId, 'audio', 'owner.mp3');
|
||||
const assetBytes = Buffer.from('ID3-owner-track', 'utf8');
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
state.audioAssets.set(assetId, {
|
||||
id: assetId,
|
||||
userId,
|
||||
@ -88,7 +84,7 @@ describe('AssetsService', () => {
|
||||
await storageService.ensureParentDirectory(filePath);
|
||||
await writeFile(filePath, assetBytes);
|
||||
|
||||
const download = await service.getOwnedAudioAssetDownload(assetId, deviceId);
|
||||
const download = await service.getOwnedAudioAssetDownload(assetId, randomUUID());
|
||||
|
||||
expect(download.filePath).toBe(filePath);
|
||||
expect(download.contentLength).toBe(assetBytes.length);
|
||||
@ -96,10 +92,8 @@ describe('AssetsService', () => {
|
||||
|
||||
it('rejects cross-owner audio asset downloads', async () => {
|
||||
const otherUserId = randomUUID();
|
||||
const ownerDeviceId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerUserId });
|
||||
state.audioAssets.set(assetId, {
|
||||
id: assetId,
|
||||
userId: otherUserId,
|
||||
@ -107,30 +101,24 @@ describe('AssetsService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(assetId, ownerDeviceId),
|
||||
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects foreign-owner devices before reading audio assets', async () => {
|
||||
const foreignOwnerId = randomUUID();
|
||||
const foreignDeviceId = randomUUID();
|
||||
|
||||
state.devices.set(foreignDeviceId, {
|
||||
id: foreignDeviceId,
|
||||
userId: foreignOwnerId,
|
||||
});
|
||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(randomUUID(), foreignDeviceId),
|
||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns not found when the asset file is missing from storage', async () => {
|
||||
const userId = ownerUserId;
|
||||
const deviceId = randomUUID();
|
||||
const assetId = randomUUID();
|
||||
|
||||
state.devices.set(deviceId, { id: deviceId, userId });
|
||||
state.audioAssets.set(assetId, {
|
||||
id: assetId,
|
||||
userId,
|
||||
@ -138,11 +126,15 @@ describe('AssetsService', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(assetId, deviceId),
|
||||
service.getOwnedAudioAssetDownload(assetId, randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns not found when the device does not exist', async () => {
|
||||
deviceAuthService.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
@ -5,8 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
|
||||
export interface AudioAssetDownload {
|
||||
filePath: string;
|
||||
@ -18,24 +18,15 @@ export class AssetsService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly storageService: LocalFilesystemStorageService,
|
||||
private readonly ownerContext: OwnerContext,
|
||||
private readonly deviceAuthService: DeviceAuthService,
|
||||
) {}
|
||||
|
||||
async getOwnedAudioAssetDownload(
|
||||
assetId: string,
|
||||
deviceId: string,
|
||||
legacyDeviceId?: string,
|
||||
): Promise<AudioAssetDownload> {
|
||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device || device.userId !== ownerUserId) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
const { userId: ownerUserId } =
|
||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
||||
|
||||
const asset = await this.prismaService.audioAsset.findUnique({
|
||||
where: { id: assetId },
|
||||
@ -74,9 +65,4 @@ export class AssetsService {
|
||||
throw new NotFoundException('Audio asset file not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveCurrentOwnerUserId(): Promise<string> {
|
||||
const owner = await this.ownerContext.resolve();
|
||||
return owner.userId;
|
||||
}
|
||||
}
|
||||
|
||||
18
backend/src/modules/auth/auth.module.ts
Normal file
18
backend/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
|
||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { DeviceAuthGuard } from './device-auth.guard';
|
||||
import { DeviceAuthService } from './device-auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RequestContextModule, UsersModule],
|
||||
providers: [DeviceAuthService, DeviceAuthGuard],
|
||||
exports: [DeviceAuthService, DeviceAuthGuard],
|
||||
})
|
||||
export class AuthModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestContextMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
31
backend/src/modules/auth/device-auth.guard.ts
Normal file
31
backend/src/modules/auth/device-auth.guard.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DeviceAuthService } from './device-auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceAuthGuard implements CanActivate {
|
||||
constructor(private readonly deviceAuthService: DeviceAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
131
backend/src/modules/auth/device-auth.service.spec.ts
Normal file
131
backend/src/modules/auth/device-auth.service.spec.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import { DeviceAuthService } from './device-auth.service';
|
||||
|
||||
describe('DeviceAuthService', () => {
|
||||
it('authenticates a valid bearer token and records it in request context', async () => {
|
||||
const userId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
const requestContext = new RequestContextService();
|
||||
const ownerContext = {
|
||||
resolve: jest.fn().mockResolvedValue({ userId }),
|
||||
} as OwnerContext;
|
||||
const service = new DeviceAuthService(
|
||||
{
|
||||
device: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: deviceId,
|
||||
userId,
|
||||
tokenRevokedAt: null,
|
||||
}),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
} as any,
|
||||
requestContext,
|
||||
ownerContext,
|
||||
);
|
||||
const token = service.generateDeviceAccessToken();
|
||||
|
||||
await requestContext.run(async () => {
|
||||
await expect(
|
||||
service.authenticateAuthorizationHeader(`Bearer ${token}`),
|
||||
).resolves.toEqual({
|
||||
deviceId,
|
||||
userId,
|
||||
});
|
||||
await expect(service.resolveCurrentDevice(randomUUID())).resolves.toEqual({
|
||||
deviceId,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid or revoked tokens', async () => {
|
||||
const requestContext = new RequestContextService();
|
||||
const service = new DeviceAuthService(
|
||||
{
|
||||
device: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: randomUUID(),
|
||||
userId: randomUUID(),
|
||||
tokenRevokedAt: new Date(),
|
||||
}),
|
||||
update: jest.fn(),
|
||||
},
|
||||
} as any,
|
||||
requestContext,
|
||||
{
|
||||
resolve: jest.fn(),
|
||||
} as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestContext.run(() =>
|
||||
service.authenticateAuthorizationHeader('Bearer invalid-token'),
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(
|
||||
requestContext.run(() =>
|
||||
service.authenticateAuthorizationHeader('Bearer revoked-token'),
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('falls back to legacy device ids only when no authenticated device is present', async () => {
|
||||
const userId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
const otherDeviceId = randomUUID();
|
||||
const requestContext = new RequestContextService();
|
||||
const ownerContext = {
|
||||
resolve: jest.fn().mockResolvedValue({ userId }),
|
||||
} as any;
|
||||
const service = new DeviceAuthService(
|
||||
{
|
||||
device: {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||
if (where.id === deviceId) {
|
||||
return {
|
||||
id: deviceId,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
|
||||
if (where.id === otherDeviceId) {
|
||||
return {
|
||||
id: otherDeviceId,
|
||||
userId: randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
update: jest.fn(),
|
||||
},
|
||||
} as any,
|
||||
requestContext,
|
||||
ownerContext as OwnerContext,
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestContext.run(() => service.resolveCurrentDevice()),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
requestContext.run(() => service.resolveCurrentDevice(otherDeviceId)),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
requestContext.run(() => service.resolveCurrentDevice(deviceId)),
|
||||
).resolves.toEqual({
|
||||
deviceId,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
123
backend/src/modules/auth/device-auth.service.ts
Normal file
123
backend/src/modules/auth/device-auth.service.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import {
|
||||
AuthenticatedDeviceContextValue,
|
||||
RequestContextService,
|
||||
} from '../../infrastructure/request-context/request-context.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceAuthService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly ownerContext: OwnerContext,
|
||||
) {}
|
||||
|
||||
generateDeviceAccessToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
hashDeviceAccessToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
async authenticateAuthorizationHeader(
|
||||
authorizationHeader: string | string[],
|
||||
): Promise<AuthenticatedDeviceContextValue> {
|
||||
const token = this.extractBearerToken(authorizationHeader);
|
||||
const tokenHash = this.hashDeviceAccessToken(token);
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: {
|
||||
tokenHash,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
tokenRevokedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device || device.tokenRevokedAt) {
|
||||
throw new UnauthorizedException('Invalid device access token');
|
||||
}
|
||||
|
||||
await this.prismaService.device.update({
|
||||
where: {
|
||||
id: device.id,
|
||||
},
|
||||
data: {
|
||||
tokenLastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const authenticatedDevice = {
|
||||
deviceId: device.id,
|
||||
userId: device.userId,
|
||||
};
|
||||
|
||||
this.requestContext.setAuthenticatedDevice(authenticatedDevice);
|
||||
|
||||
return authenticatedDevice;
|
||||
}
|
||||
|
||||
async resolveCurrentDevice(
|
||||
legacyDeviceId?: string,
|
||||
): Promise<AuthenticatedDeviceContextValue> {
|
||||
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
||||
|
||||
if (authenticatedDevice) {
|
||||
return authenticatedDevice;
|
||||
}
|
||||
|
||||
if (!legacyDeviceId) {
|
||||
throw new BadRequestException(
|
||||
'deviceId is required when Authorization is missing.',
|
||||
);
|
||||
}
|
||||
|
||||
const owner = await this.ownerContext.resolve();
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: {
|
||||
id: legacyDeviceId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device || device.userId !== owner.userId) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId: device.id,
|
||||
userId: device.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private extractBearerToken(authorizationHeader: string | string[]): string {
|
||||
const normalizedHeader = Array.isArray(authorizationHeader)
|
||||
? authorizationHeader[0]
|
||||
: authorizationHeader;
|
||||
|
||||
if (!normalizedHeader) {
|
||||
throw new UnauthorizedException('Invalid device access token');
|
||||
}
|
||||
|
||||
const match = normalizedHeader.match(/^Bearer\s+(.+)$/i);
|
||||
|
||||
if (!match || !match[1]?.trim()) {
|
||||
throw new UnauthorizedException('Invalid device access token');
|
||||
}
|
||||
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,11 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import {
|
||||
DeviceHeartbeatRequestDto,
|
||||
DeviceHeartbeatResponseDto,
|
||||
@ -25,6 +31,8 @@ export class DevicesController {
|
||||
}
|
||||
|
||||
@Post('heartbeat')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ type: DeviceHeartbeatResponseDto })
|
||||
async heartbeat(
|
||||
@Body() body: DeviceHeartbeatRequestDto,
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DevicePlatform } from '@prisma/client';
|
||||
import { IsEnum, IsString, IsUUID, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterDeviceRequestDto {
|
||||
@ApiProperty({ enum: DevicePlatform, example: DevicePlatform.MACOS })
|
||||
@ -22,6 +28,12 @@ export class RegisterDeviceResponseDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Raw device access token returned only during registration. Store it securely.',
|
||||
})
|
||||
deviceAccessToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
bootstrapToken!: string;
|
||||
|
||||
@ -30,9 +42,15 @@ export class RegisterDeviceResponseDto {
|
||||
}
|
||||
|
||||
export class DeviceHeartbeatRequestDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
deviceId!: string;
|
||||
deviceId?: string;
|
||||
|
||||
@ApiProperty({ example: '0.1.0' })
|
||||
@IsString()
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule],
|
||||
imports: [PrismaModule, UsersModule, AuthModule],
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
@ -21,12 +22,18 @@ describe('DevicesService', () => {
|
||||
userId: ownerId,
|
||||
}),
|
||||
} as any;
|
||||
const deviceAuthService = {
|
||||
generateDeviceAccessToken: jest.fn().mockReturnValue('device-access-token'),
|
||||
hashDeviceAccessToken: jest.fn().mockReturnValue('device-token-hash'),
|
||||
resolveCurrentDevice: jest.fn(),
|
||||
} as any;
|
||||
const service = new DevicesService(
|
||||
prismaService,
|
||||
ownerContext as OwnerContext,
|
||||
deviceAuthService as DeviceAuthService,
|
||||
);
|
||||
|
||||
await service.register({
|
||||
const response = await service.register({
|
||||
platform: 'MACOS',
|
||||
deviceName: 'Velody Mac',
|
||||
appVersion: '0.1.0',
|
||||
@ -39,30 +46,33 @@ describe('DevicesService', () => {
|
||||
platform: 'MACOS',
|
||||
deviceName: 'Velody Mac',
|
||||
appVersion: '0.1.0',
|
||||
tokenHash: 'device-token-hash',
|
||||
}),
|
||||
});
|
||||
expect(response.deviceAccessToken).toBe('device-access-token');
|
||||
});
|
||||
|
||||
it('rejects heartbeat updates for a foreign-owner device', async () => {
|
||||
const ownerId = randomUUID();
|
||||
const foreignOwnerId = randomUUID();
|
||||
const deviceId = randomUUID();
|
||||
const prismaService = {
|
||||
device: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
userId: foreignOwnerId,
|
||||
}),
|
||||
update: jest.fn(),
|
||||
},
|
||||
} as any;
|
||||
const ownerContext = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
userId: ownerId,
|
||||
}),
|
||||
resolve: jest.fn(),
|
||||
} as any;
|
||||
const deviceAuthService = {
|
||||
generateDeviceAccessToken: jest.fn(),
|
||||
hashDeviceAccessToken: jest.fn(),
|
||||
resolveCurrentDevice: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new NotFoundException('Device not found')),
|
||||
} as any;
|
||||
const service = new DevicesService(
|
||||
prismaService,
|
||||
ownerContext as OwnerContext,
|
||||
deviceAuthService as DeviceAuthService,
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import {
|
||||
DeviceHeartbeatRequestDto,
|
||||
@ -14,15 +15,19 @@ export class DevicesService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly ownerContext: OwnerContext,
|
||||
private readonly deviceAuthService: DeviceAuthService,
|
||||
) {}
|
||||
|
||||
async register(
|
||||
body: RegisterDeviceRequestDto,
|
||||
): Promise<RegisterDeviceResponseDto> {
|
||||
const bootstrapToken = randomBytes(24).toString('hex');
|
||||
const deviceAccessToken = this.deviceAuthService.generateDeviceAccessToken();
|
||||
const installTokenHash = createHash('sha256')
|
||||
.update(bootstrapToken)
|
||||
.digest('hex');
|
||||
const tokenHash =
|
||||
this.deviceAuthService.hashDeviceAccessToken(deviceAccessToken);
|
||||
const owner = await this.ownerContext.resolve();
|
||||
|
||||
const device = await this.prismaService.device.create({
|
||||
@ -32,12 +37,15 @@ export class DevicesService {
|
||||
deviceName: body.deviceName,
|
||||
appVersion: body.appVersion,
|
||||
installTokenHash,
|
||||
tokenHash,
|
||||
tokenCreatedAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deviceId: device.id,
|
||||
deviceAccessToken,
|
||||
bootstrapToken,
|
||||
serverTime: new Date().toISOString(),
|
||||
};
|
||||
@ -46,20 +54,10 @@ export class DevicesService {
|
||||
async heartbeat(
|
||||
body: DeviceHeartbeatRequestDto,
|
||||
): Promise<DeviceHeartbeatResponseDto> {
|
||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||
const existing = await this.prismaService.device.findUnique({
|
||||
where: { id: body.deviceId },
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing || existing.userId !== ownerUserId) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
|
||||
|
||||
await this.prismaService.device.update({
|
||||
where: { id: body.deviceId },
|
||||
where: { id: device.deviceId },
|
||||
data: {
|
||||
appVersion: body.appVersion,
|
||||
lastSeenAt: new Date(),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import {
|
||||
LibraryTracksQueryDto,
|
||||
LibraryTracksResponseDto,
|
||||
@ -7,6 +8,8 @@ import {
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@ApiTags('library')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Controller({
|
||||
path: 'library',
|
||||
version: '1',
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class LibraryTracksQueryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
deviceId!: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export class RemoteArtworkDto {
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { LibraryController } from './library.controller';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule],
|
||||
imports: [PrismaModule, UsersModule, AuthModule],
|
||||
controllers: [LibraryController],
|
||||
providers: [LibraryService],
|
||||
exports: [LibraryService],
|
||||
|
||||
@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@ -58,6 +59,9 @@ describe('LibraryService', () => {
|
||||
let ownerContextMock: {
|
||||
resolve: jest.Mock;
|
||||
};
|
||||
let deviceAuthServiceMock: {
|
||||
resolveCurrentDevice: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const { prismaMock, state: nextState } = createPrismaMock();
|
||||
@ -65,6 +69,9 @@ describe('LibraryService', () => {
|
||||
ownerContextMock = {
|
||||
resolve: jest.fn(),
|
||||
};
|
||||
deviceAuthServiceMock = {
|
||||
resolveCurrentDevice: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
@ -77,6 +84,10 @@ describe('LibraryService', () => {
|
||||
provide: OwnerContext,
|
||||
useValue: ownerContextMock,
|
||||
},
|
||||
{
|
||||
provide: DeviceAuthService,
|
||||
useValue: deviceAuthServiceMock,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@ -151,6 +162,10 @@ describe('LibraryService', () => {
|
||||
userId: ownerId,
|
||||
});
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
||||
deviceId: ownerDeviceId,
|
||||
userId: ownerId,
|
||||
});
|
||||
|
||||
state.audioAssets.set(ownerAssetId, {
|
||||
id: ownerAssetId,
|
||||
@ -251,6 +266,10 @@ describe('LibraryService', () => {
|
||||
userId: ownerId,
|
||||
});
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
||||
deviceId: ownerDeviceId,
|
||||
userId: ownerId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
libraryService.getRemoteLibraryTracks(ownerDeviceId),
|
||||
@ -268,6 +287,10 @@ describe('LibraryService', () => {
|
||||
userId: ownerId,
|
||||
});
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
||||
deviceId: ownerDeviceId,
|
||||
userId: ownerId,
|
||||
});
|
||||
state.audioAssets.set(otherAssetId, {
|
||||
id: otherAssetId,
|
||||
sha256: 'sha-other-owner',
|
||||
@ -291,9 +314,10 @@ describe('LibraryService', () => {
|
||||
});
|
||||
|
||||
it('throws for an unknown device', async () => {
|
||||
ownerContextMock.resolve.mockResolvedValue({
|
||||
userId: randomUUID(),
|
||||
});
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
libraryService.getRemoteLibraryTracks(randomUUID()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
@ -304,13 +328,14 @@ describe('LibraryService', () => {
|
||||
const otherUserId = randomUUID();
|
||||
const foreignDeviceId = randomUUID();
|
||||
|
||||
ownerContextMock.resolve.mockResolvedValue({
|
||||
userId: ownerId,
|
||||
});
|
||||
ownerContextMock.resolve.mockResolvedValue({ userId: ownerId });
|
||||
state.devices.set(foreignDeviceId, {
|
||||
id: foreignDeviceId,
|
||||
userId: otherUserId,
|
||||
});
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockRejectedValueOnce(
|
||||
new NotFoundException('Device not found'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
libraryService.getRemoteLibraryTracks(foreignDeviceId),
|
||||
@ -324,6 +349,10 @@ describe('LibraryService', () => {
|
||||
userId: ownerId,
|
||||
});
|
||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
|
||||
deviceAuthServiceMock.resolveCurrentDevice.mockResolvedValue({
|
||||
deviceId: ownerDeviceId,
|
||||
userId: ownerId,
|
||||
});
|
||||
|
||||
state.tracks.set(randomUUID(), {
|
||||
id: randomUUID(),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { LibraryTrackDto } from '../sync/sync.dto';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import { RemoteLibraryTrackDto } from './library.dto';
|
||||
@ -9,6 +10,7 @@ export class LibraryService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly ownerContext: OwnerContext,
|
||||
private readonly deviceAuthService: DeviceAuthService,
|
||||
) {}
|
||||
|
||||
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
||||
@ -36,19 +38,10 @@ export class LibraryService {
|
||||
}
|
||||
|
||||
async getRemoteLibraryTracks(
|
||||
deviceId: string,
|
||||
legacyDeviceId?: string,
|
||||
): Promise<RemoteLibraryTrackDto[]> {
|
||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device || device.userId !== ownerUserId) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
const { userId: ownerUserId } =
|
||||
await this.deviceAuthService.resolveCurrentDevice(legacyDeviceId);
|
||||
|
||||
const tracks = await this.prismaService.track.findMany({
|
||||
where: {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import {
|
||||
SyncBootstrapResponseDto,
|
||||
SyncChangesQueryDto,
|
||||
@ -8,6 +9,8 @@ import {
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@ApiTags('sync')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Controller({
|
||||
path: 'sync',
|
||||
version: '1',
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, LibraryModule, UsersModule],
|
||||
imports: [PrismaModule, LibraryModule, UsersModule, AuthModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService],
|
||||
})
|
||||
|
||||
@ -6,15 +6,18 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiCreatedResponse,
|
||||
ApiConsumes,
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||
import {
|
||||
UploadFinalizeRequestDto,
|
||||
UploadFinalizeResponseDto,
|
||||
@ -25,6 +28,8 @@ import {
|
||||
import { UploadsService } from './uploads.service';
|
||||
|
||||
@ApiTags('uploads')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Controller({
|
||||
path: 'uploads',
|
||||
version: '1',
|
||||
|
||||
@ -14,9 +14,15 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class UploadPrepareRequestDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
required: false,
|
||||
description:
|
||||
'Legacy migration fallback. Omit when Authorization: Bearer <deviceAccessToken> is provided.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
deviceId!: string;
|
||||
deviceId?: string;
|
||||
|
||||
@ApiProperty({
|
||||
example:
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { UploadsController } from './uploads.controller';
|
||||
import { UploadsService } from './uploads.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StorageModule, UsersModule],
|
||||
imports: [PrismaModule, StorageModule, UsersModule, AuthModule],
|
||||
controllers: [UploadsController],
|
||||
providers: [UploadsService],
|
||||
})
|
||||
|
||||
@ -6,6 +6,7 @@ import { Readable } from 'node:stream';
|
||||
import { NotFoundException, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { UploadSessionStatus } from '@prisma/client';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import { UploadsService } from './uploads.service';
|
||||
@ -284,6 +285,9 @@ describe('UploadsService', () => {
|
||||
let storageService: LocalFilesystemStorageService;
|
||||
let service: UploadsService;
|
||||
let ownerContext: OwnerContext;
|
||||
let deviceAuthService: {
|
||||
resolveCurrentDevice: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mock = createPrismaMock();
|
||||
@ -296,11 +300,30 @@ describe('UploadsService', () => {
|
||||
userId: state.defaultUser.id,
|
||||
}),
|
||||
} as OwnerContext;
|
||||
deviceAuthService = {
|
||||
resolveCurrentDevice: jest.fn().mockImplementation(async (deviceId?: string) => {
|
||||
if (!deviceId) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
const device = state.devices.get(deviceId);
|
||||
|
||||
if (!device || device.userId !== state.defaultUser.id) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId: device.id,
|
||||
userId: device.userId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
service = new UploadsService(
|
||||
prismaMock,
|
||||
createAppConfig(storageRoot),
|
||||
storageService,
|
||||
ownerContext,
|
||||
deviceAuthService as unknown as DeviceAuthService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import { access, open, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { extname } from 'node:path';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
import { OwnerContext } from '../users/owner-context.service';
|
||||
import {
|
||||
@ -44,6 +45,7 @@ export class UploadsService {
|
||||
private readonly configService: AppConfigService,
|
||||
private readonly storageService: LocalFilesystemStorageService,
|
||||
private readonly ownerContext: OwnerContext,
|
||||
private readonly deviceAuthService: DeviceAuthService,
|
||||
) {}
|
||||
|
||||
async prepare(
|
||||
@ -52,8 +54,8 @@ export class UploadsService {
|
||||
this.assertFileSizeWithinLimit(body.sizeBytes);
|
||||
this.assertMp3Filename(body.originalFilename);
|
||||
|
||||
const ownerUserId = await this.resolveCurrentOwnerUserId();
|
||||
await this.getOwnedDeviceOrThrow(body.deviceId, ownerUserId);
|
||||
const device = await this.deviceAuthService.resolveCurrentDevice(body.deviceId);
|
||||
const ownerUserId = device.userId;
|
||||
|
||||
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
||||
where: {
|
||||
@ -70,7 +72,7 @@ export class UploadsService {
|
||||
data: {
|
||||
id: uploadId,
|
||||
userId: ownerUserId,
|
||||
deviceId: body.deviceId,
|
||||
deviceId: device.deviceId,
|
||||
trackId: existingAsset.trackId,
|
||||
audioAssetId: existingAsset.id,
|
||||
expectedSha256: body.sha256,
|
||||
@ -97,7 +99,7 @@ export class UploadsService {
|
||||
data: {
|
||||
id: uploadId,
|
||||
userId: ownerUserId,
|
||||
deviceId: body.deviceId,
|
||||
deviceId: device.deviceId,
|
||||
expectedSha256: body.sha256,
|
||||
originalFilename: body.originalFilename,
|
||||
expectedSizeBytes: BigInt(body.sizeBytes),
|
||||
@ -479,22 +481,6 @@ export class UploadsService {
|
||||
return owner.userId;
|
||||
}
|
||||
|
||||
private async getOwnedDeviceOrThrow(deviceId: string, ownerUserId: string) {
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device || device.userId !== ownerUserId) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
private async getOwnedUploadSessionOrThrow(
|
||||
uploadId: string,
|
||||
ownerUserId: string,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
||||
import { BootstrapOwnerContextService } from './owner-context.service';
|
||||
|
||||
describe('BootstrapOwnerContextService', () => {
|
||||
@ -12,11 +13,39 @@ describe('BootstrapOwnerContextService', () => {
|
||||
const defaultUserService = {
|
||||
getOrCreateDefaultUser: jest.fn().mockResolvedValue(defaultUser),
|
||||
} as any;
|
||||
const service = new BootstrapOwnerContextService(defaultUserService);
|
||||
const service = new BootstrapOwnerContextService(
|
||||
defaultUserService,
|
||||
new RequestContextService(),
|
||||
);
|
||||
|
||||
await expect(service.resolve()).resolves.toEqual({
|
||||
userId: defaultUser.id,
|
||||
});
|
||||
expect(defaultUserService.getOrCreateDefaultUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prefers the authenticated device owner when present', async () => {
|
||||
const requestContext = new RequestContextService();
|
||||
const defaultUserService = {
|
||||
getOrCreateDefaultUser: jest.fn(),
|
||||
} as any;
|
||||
const service = new BootstrapOwnerContextService(
|
||||
defaultUserService,
|
||||
requestContext,
|
||||
);
|
||||
const authenticatedOwnerId = randomUUID();
|
||||
|
||||
await requestContext.run(async () => {
|
||||
requestContext.setAuthenticatedDevice({
|
||||
deviceId: randomUUID(),
|
||||
userId: authenticatedOwnerId,
|
||||
});
|
||||
|
||||
await expect(service.resolve()).resolves.toEqual({
|
||||
userId: authenticatedOwnerId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(defaultUserService.getOrCreateDefaultUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RequestContextService } from '../../infrastructure/request-context/request-context.service';
|
||||
import { DefaultUserService } from './default-user.service';
|
||||
|
||||
export interface ResolvedOwnerContext {
|
||||
@ -11,11 +12,22 @@ export abstract class OwnerContext {
|
||||
|
||||
@Injectable()
|
||||
export class BootstrapOwnerContextService extends OwnerContext {
|
||||
constructor(private readonly defaultUserService: DefaultUserService) {
|
||||
constructor(
|
||||
private readonly defaultUserService: DefaultUserService,
|
||||
private readonly requestContext: RequestContextService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async resolve(): Promise<ResolvedOwnerContext> {
|
||||
const authenticatedDevice = this.requestContext.getAuthenticatedDevice();
|
||||
|
||||
if (authenticatedDevice) {
|
||||
return {
|
||||
userId: authenticatedDevice.userId,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||
|
||||
return {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||
import { DefaultUserService } from './default-user.service';
|
||||
import {
|
||||
BootstrapOwnerContextService,
|
||||
@ -7,7 +8,7 @@ import {
|
||||
} from './owner-context.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, RequestContextModule],
|
||||
providers: [
|
||||
DefaultUserService,
|
||||
BootstrapOwnerContextService,
|
||||
|
||||
@ -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) {
|
||||
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
|
||||
if (where.id) {
|
||||
return applySelect(devices.get(where.id) ?? null, select);
|
||||
}
|
||||
|
||||
if (where.tokenHash) {
|
||||
const matchingDevice =
|
||||
[...devices.values()].find(
|
||||
(device) => device.tokenHash === where.tokenHash,
|
||||
) ?? null;
|
||||
|
||||
return applySelect(matchingDevice, select);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (where.id && typeof where.id === 'string') {
|
||||
return device;
|
||||
}
|
||||
|
||||
return device;
|
||||
}),
|
||||
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',
|
||||
|
||||
@ -93,15 +93,18 @@ public struct DeviceRegistrationPayload: Codable, Hashable, Sendable {
|
||||
|
||||
public struct DeviceRegistrationResponse: Codable, Hashable, Sendable {
|
||||
public var deviceId: String
|
||||
public var deviceAccessToken: String
|
||||
public var bootstrapToken: String
|
||||
public var serverTime: String
|
||||
|
||||
public init(
|
||||
deviceId: String,
|
||||
deviceAccessToken: String,
|
||||
bootstrapToken: String,
|
||||
serverTime: String
|
||||
) {
|
||||
self.deviceId = deviceId
|
||||
self.deviceAccessToken = deviceAccessToken
|
||||
self.bootstrapToken = bootstrapToken
|
||||
self.serverTime = serverTime
|
||||
}
|
||||
|
||||
@ -73,21 +73,26 @@ public protocol VelodyAPIClient: Sendable {
|
||||
) async throws -> UploadFinalizeResponse
|
||||
}
|
||||
|
||||
public typealias DeviceAccessTokenProvider = @Sendable () async throws -> String?
|
||||
|
||||
public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
public let environment: ServerEnvironment
|
||||
|
||||
private let session: URLSession
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let deviceAccessTokenProvider: DeviceAccessTokenProvider?
|
||||
|
||||
public init(
|
||||
environment: ServerEnvironment,
|
||||
session: URLSession = .shared
|
||||
session: URLSession = .shared,
|
||||
deviceAccessTokenProvider: DeviceAccessTokenProvider? = nil
|
||||
) {
|
||||
self.environment = environment
|
||||
self.session = session
|
||||
self.encoder = JSONEncoder()
|
||||
self.decoder = JSONDecoder()
|
||||
self.deviceAccessTokenProvider = deviceAccessTokenProvider
|
||||
}
|
||||
|
||||
public func registerDevice(
|
||||
@ -108,6 +113,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
method: "POST",
|
||||
pathComponents: ["api", "v1", "devices", "heartbeat"],
|
||||
body: payload,
|
||||
includesDeviceAuthorization: true,
|
||||
responseType: DeviceHeartbeatResponse.self
|
||||
)
|
||||
}
|
||||
@ -116,6 +122,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
try await sendRequest(
|
||||
method: "GET",
|
||||
pathComponents: ["api", "v1", "sync", "bootstrap"],
|
||||
includesDeviceAuthorization: true,
|
||||
responseType: SyncBootstrapResponse.self
|
||||
)
|
||||
}
|
||||
@ -129,6 +136,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
queryItems: [
|
||||
URLQueryItem(name: "deviceId", value: deviceId),
|
||||
],
|
||||
includesDeviceAuthorization: true,
|
||||
responseType: RemoteLibraryResponseDTO.self
|
||||
)
|
||||
}
|
||||
@ -137,13 +145,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
assetId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data {
|
||||
let request = try buildRequest(
|
||||
let request = try await buildRequest(
|
||||
method: "GET",
|
||||
pathComponents: ["api", "v1", "assets", assetId, "download"],
|
||||
queryItems: [
|
||||
URLQueryItem(name: "deviceId", value: deviceId),
|
||||
],
|
||||
bodyData: nil,
|
||||
includesDeviceAuthorization: true,
|
||||
acceptType: "audio/mpeg"
|
||||
)
|
||||
|
||||
@ -154,13 +163,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
artworkId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data {
|
||||
let request = try buildRequest(
|
||||
let request = try await buildRequest(
|
||||
method: "GET",
|
||||
pathComponents: ["api", "v1", "artwork", artworkId, "download"],
|
||||
queryItems: [
|
||||
URLQueryItem(name: "deviceId", value: deviceId),
|
||||
],
|
||||
bodyData: nil,
|
||||
includesDeviceAuthorization: true,
|
||||
acceptType: "image/*"
|
||||
)
|
||||
|
||||
@ -174,6 +184,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
method: "POST",
|
||||
pathComponents: ["api", "v1", "uploads", "prepare"],
|
||||
body: payload,
|
||||
includesDeviceAuthorization: true,
|
||||
responseType: UploadPrepareResponse.self
|
||||
)
|
||||
}
|
||||
@ -184,6 +195,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
try await sendRequest(
|
||||
method: "GET",
|
||||
pathComponents: ["api", "v1", "uploads", uploadId],
|
||||
includesDeviceAuthorization: true,
|
||||
responseType: UploadSessionStatusResponse.self
|
||||
)
|
||||
}
|
||||
@ -197,11 +209,12 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
throw VelodyAPIError.requestFailed("The selected file could not be found.")
|
||||
}
|
||||
|
||||
let request = try buildRequest(
|
||||
let request = try await buildRequest(
|
||||
method: "PUT",
|
||||
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
|
||||
queryItems: [],
|
||||
bodyData: nil,
|
||||
includesDeviceAuthorization: true,
|
||||
contentType: mimeType
|
||||
)
|
||||
|
||||
@ -229,6 +242,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
method: "POST",
|
||||
pathComponents: ["api", "v1", "uploads", uploadId, "finalize"],
|
||||
body: payload,
|
||||
includesDeviceAuthorization: true,
|
||||
responseType: UploadFinalizeResponse.self
|
||||
)
|
||||
}
|
||||
@ -237,13 +251,15 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
method: String,
|
||||
pathComponents: [String],
|
||||
queryItems: [URLQueryItem] = [],
|
||||
includesDeviceAuthorization: Bool = false,
|
||||
responseType: Response.Type
|
||||
) async throws -> Response {
|
||||
let request = try buildRequest(
|
||||
let request = try await buildRequest(
|
||||
method: method,
|
||||
pathComponents: pathComponents,
|
||||
queryItems: queryItems,
|
||||
bodyData: nil
|
||||
bodyData: nil,
|
||||
includesDeviceAuthorization: includesDeviceAuthorization
|
||||
)
|
||||
|
||||
return try await execute(request, responseType: responseType)
|
||||
@ -254,6 +270,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
pathComponents: [String],
|
||||
queryItems: [URLQueryItem] = [],
|
||||
body: Body,
|
||||
includesDeviceAuthorization: Bool = false,
|
||||
responseType: Response.Type
|
||||
) async throws -> Response {
|
||||
let bodyData: Data
|
||||
@ -264,11 +281,12 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
throw VelodyAPIError.requestFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let request = try buildRequest(
|
||||
let request = try await buildRequest(
|
||||
method: method,
|
||||
pathComponents: pathComponents,
|
||||
queryItems: queryItems,
|
||||
bodyData: bodyData,
|
||||
includesDeviceAuthorization: includesDeviceAuthorization,
|
||||
contentType: "application/json"
|
||||
)
|
||||
|
||||
@ -280,9 +298,10 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
pathComponents: [String],
|
||||
queryItems: [URLQueryItem],
|
||||
bodyData: Data?,
|
||||
includesDeviceAuthorization: Bool = false,
|
||||
contentType: String? = nil,
|
||||
acceptType: String = "application/json"
|
||||
) throws -> URLRequest {
|
||||
) async throws -> URLRequest {
|
||||
guard let url = endpointURL(
|
||||
pathComponents: pathComponents,
|
||||
queryItems: queryItems
|
||||
@ -302,9 +321,30 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
if includesDeviceAuthorization,
|
||||
let deviceAccessToken = try await loadDeviceAccessToken(),
|
||||
!deviceAccessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
request.setValue(
|
||||
"Bearer \(deviceAccessToken)",
|
||||
forHTTPHeaderField: "Authorization"
|
||||
)
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
private func loadDeviceAccessToken() async throws -> String? {
|
||||
guard let deviceAccessTokenProvider else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try await deviceAccessTokenProvider()
|
||||
} catch {
|
||||
throw VelodyAPIError.requestFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func execute<Response: Decodable>(
|
||||
_ request: URLRequest,
|
||||
responseType: Response.Type
|
||||
@ -404,6 +444,7 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
|
||||
|
||||
return DeviceRegistrationResponse(
|
||||
deviceId: UUID().uuidString,
|
||||
deviceAccessToken: "stub-device-access-token",
|
||||
bootstrapToken: "stub-bootstrap-token",
|
||||
serverTime: ISO8601DateFormatter().string(from: .now)
|
||||
)
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
import VelodyDomain
|
||||
@testable import VelodyNetworking
|
||||
|
||||
final class URLSessionVelodyAPIClientAuthorizationTests: XCTestCase {
|
||||
override func tearDown() {
|
||||
RecordingURLProtocol.handler = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testFetchRemoteLibrarySendsAuthorizationHeaderWhenDeviceTokenIsAvailable() async throws {
|
||||
RecordingURLProtocol.handler = { request in
|
||||
XCTAssertEqual(
|
||||
request.value(forHTTPHeaderField: "Authorization"),
|
||||
"Bearer test-device-access-token"
|
||||
)
|
||||
XCTAssertEqual(request.url?.path, "/api/v1/library/tracks")
|
||||
XCTAssertEqual(request.url?.query, "deviceId=device-123")
|
||||
|
||||
return (
|
||||
HTTPURLResponse(
|
||||
url: try XCTUnwrap(request.url),
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Type": "application/json"]
|
||||
)!,
|
||||
Data(#"{"tracks":[]}"#.utf8)
|
||||
)
|
||||
}
|
||||
|
||||
let client = URLSessionVelodyAPIClient(
|
||||
environment: ServerEnvironment(
|
||||
baseURL: URL(string: "http://127.0.0.1:3007")!,
|
||||
appVersion: "Tests"
|
||||
),
|
||||
session: makeSession(),
|
||||
deviceAccessTokenProvider: {
|
||||
"test-device-access-token"
|
||||
}
|
||||
)
|
||||
|
||||
let response = try await client.fetchRemoteLibrary(deviceId: "device-123")
|
||||
XCTAssertEqual(response.tracks.count, 0)
|
||||
}
|
||||
|
||||
func testRegisterDeviceDoesNotSendAuthorizationHeader() async throws {
|
||||
RecordingURLProtocol.handler = { request in
|
||||
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
|
||||
XCTAssertEqual(request.url?.path, "/api/v1/devices/register")
|
||||
|
||||
return (
|
||||
HTTPURLResponse(
|
||||
url: try XCTUnwrap(request.url),
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Type": "application/json"]
|
||||
)!,
|
||||
Data(
|
||||
"""
|
||||
{
|
||||
"deviceId": "device-123",
|
||||
"deviceAccessToken": "registered-device-token",
|
||||
"bootstrapToken": "bootstrap-token",
|
||||
"serverTime": "2026-06-09T10:00:00.000Z"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let client = URLSessionVelodyAPIClient(
|
||||
environment: ServerEnvironment(
|
||||
baseURL: URL(string: "http://127.0.0.1:3007")!,
|
||||
appVersion: "Tests"
|
||||
),
|
||||
session: makeSession(),
|
||||
deviceAccessTokenProvider: {
|
||||
"should-not-be-sent"
|
||||
}
|
||||
)
|
||||
|
||||
let response = try await client.registerDevice(
|
||||
DeviceRegistrationPayload(
|
||||
platform: .iphone,
|
||||
deviceName: "Test iPhone",
|
||||
appVersion: "Tests"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(response.deviceId, "device-123")
|
||||
XCTAssertEqual(response.deviceAccessToken, "registered-device-token")
|
||||
}
|
||||
|
||||
private func makeSession() -> URLSession {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [RecordingURLProtocol.self]
|
||||
return URLSession(configuration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingURLProtocol: URLProtocol {
|
||||
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
request.url?.host == "127.0.0.1"
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard let handler = Self.handler else {
|
||||
XCTFail("RecordingURLProtocol.handler must be set before use.")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (response, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@ -427,6 +427,7 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
_ = payload
|
||||
return DeviceRegistrationResponse(
|
||||
deviceId: UUID().uuidString,
|
||||
deviceAccessToken: UUID().uuidString,
|
||||
bootstrapToken: UUID().uuidString,
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
)
|
||||
|
||||
@ -345,6 +345,7 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
_ = payload
|
||||
return DeviceRegistrationResponse(
|
||||
deviceId: UUID().uuidString,
|
||||
deviceAccessToken: UUID().uuidString,
|
||||
bootstrapToken: UUID().uuidString,
|
||||
serverTime: "2026-05-29T08:00:00.000Z"
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user