From 45c270c18785ba6f202204940696b19266058f4e Mon Sep 17 00:00:00 2001 From: diyaa Date: Tue, 9 Jun 2026 12:05:15 +0200 Subject: [PATCH] Add device access token authentication --- .../xcschemes/VelodyiPhone.xcscheme | 14 +- .../Sources/MacLibraryViewModel.swift | 66 +++- .../Sources/iPhoneLibraryViewModel.swift | 17 +- .../iPhoneLibraryViewModelPlaybackTests.swift | 65 ++++ .../migration.sql | 7 + backend/prisma/schema.prisma | 4 + backend/src/app.module.ts | 2 + .../request-context.middleware.ts | 12 + .../request-context/request-context.module.ts | 9 + .../request-context.service.ts | 39 ++ .../src/modules/artwork/artwork.controller.ts | 11 +- backend/src/modules/artwork/artwork.module.ts | 3 +- .../modules/artwork/artwork.service.spec.ts | 54 +-- .../src/modules/artwork/artwork.service.ts | 24 +- .../src/modules/assets/assets.controller.ts | 20 +- backend/src/modules/assets/assets.dto.ts | 12 +- backend/src/modules/assets/assets.module.ts | 3 +- .../src/modules/assets/assets.service.spec.ts | 52 ++- backend/src/modules/assets/assets.service.ts | 24 +- backend/src/modules/auth/auth.module.ts | 18 + backend/src/modules/auth/device-auth.guard.ts | 31 ++ .../modules/auth/device-auth.service.spec.ts | 131 +++++++ .../src/modules/auth/device-auth.service.ts | 123 ++++++ .../src/modules/devices/devices.controller.ts | 12 +- backend/src/modules/devices/devices.dto.ts | 24 +- backend/src/modules/devices/devices.module.ts | 3 +- .../modules/devices/devices.service.spec.ts | 28 +- .../src/modules/devices/devices.service.ts | 22 +- .../src/modules/library/library.controller.ts | 7 +- backend/src/modules/library/library.dto.ts | 12 +- backend/src/modules/library/library.module.ts | 3 +- .../modules/library/library.service.spec.ts | 41 +- .../src/modules/library/library.service.ts | 19 +- backend/src/modules/sync/sync.controller.ts | 7 +- backend/src/modules/sync/sync.module.ts | 3 +- .../src/modules/uploads/uploads.controller.ts | 5 + backend/src/modules/uploads/uploads.dto.ts | 10 +- backend/src/modules/uploads/uploads.module.ts | 3 +- .../modules/uploads/uploads.service.spec.ts | 23 ++ .../src/modules/uploads/uploads.service.ts | 26 +- .../users/owner-context.service.spec.ts | 31 +- .../modules/users/owner-context.service.ts | 14 +- backend/src/modules/users/users.module.ts | 3 +- backend/test/e2e/app.e2e-spec.ts | 349 +++++++++++++++++- .../Sources/VelodyDomain/Models.swift | 3 + .../VelodyNetworking/VelodyAPIClient.swift | 57 ++- ...ionVelodyAPIClientAuthorizationTests.swift | 130 +++++++ .../OfflineLibraryServiceTests.swift | 1 + .../RemoteLibrarySyncServiceTests.swift | 1 + 49 files changed, 1345 insertions(+), 233 deletions(-) create mode 100644 backend/prisma/migrations/20260609120000_milestone92_device_access_tokens/migration.sql create mode 100644 backend/src/infrastructure/request-context/request-context.middleware.ts create mode 100644 backend/src/infrastructure/request-context/request-context.module.ts create mode 100644 backend/src/infrastructure/request-context/request-context.service.ts create mode 100644 backend/src/modules/auth/auth.module.ts create mode 100644 backend/src/modules/auth/device-auth.guard.ts create mode 100644 backend/src/modules/auth/device-auth.service.spec.ts create mode 100644 backend/src/modules/auth/device-auth.service.ts create mode 100644 packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/URLSessionVelodyAPIClientAuthorizationTests.swift diff --git a/apps/apple/Velody.xcodeproj/xcshareddata/xcschemes/VelodyiPhone.xcscheme b/apps/apple/Velody.xcodeproj/xcshareddata/xcschemes/VelodyiPhone.xcscheme index 4dc98f1..7cc8014 100644 --- a/apps/apple/Velody.xcodeproj/xcshareddata/xcschemes/VelodyiPhone.xcscheme +++ b/apps/apple/Velody.xcodeproj/xcshareddata/xcschemes/VelodyiPhone.xcscheme @@ -1,11 +1,10 @@ + version = "1.3"> + buildImplicitDependencies = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> - - - - - - diff --git a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift index b5fdde3..95d4dee 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift @@ -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 { - return registeredDeviceId + 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" diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 9811093..fa5b18f 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -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", diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift index 244e983..cee0002 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift @@ -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 diff --git a/backend/prisma/migrations/20260609120000_milestone92_device_access_tokens/migration.sql b/backend/prisma/migrations/20260609120000_milestone92_device_access_tokens/migration.sql new file mode 100644 index 0000000..1ce3ace --- /dev/null +++ b/backend/prisma/migrations/20260609120000_milestone92_device_access_tokens/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ed7e112..8e5a67b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3cc417b..0801d4f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/infrastructure/request-context/request-context.middleware.ts b/backend/src/infrastructure/request-context/request-context.middleware.ts new file mode 100644 index 0000000..c3c01fa --- /dev/null +++ b/backend/src/infrastructure/request-context/request-context.middleware.ts @@ -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()); + } +} diff --git a/backend/src/infrastructure/request-context/request-context.module.ts b/backend/src/infrastructure/request-context/request-context.module.ts new file mode 100644 index 0000000..9c1c310 --- /dev/null +++ b/backend/src/infrastructure/request-context/request-context.module.ts @@ -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 {} diff --git a/backend/src/infrastructure/request-context/request-context.service.ts b/backend/src/infrastructure/request-context/request-context.service.ts new file mode 100644 index 0000000..7059ca2 --- /dev/null +++ b/backend/src/infrastructure/request-context/request-context.service.ts @@ -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(); + + run(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; + } +} diff --git a/backend/src/modules/artwork/artwork.controller.ts b/backend/src/modules/artwork/artwork.controller.ts index eae708c..ad8207d 100644 --- a/backend/src/modules/artwork/artwork.controller.ts +++ b/backend/src/modules/artwork/artwork.controller.ts @@ -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', diff --git a/backend/src/modules/artwork/artwork.module.ts b/backend/src/modules/artwork/artwork.module.ts index c7ce75c..cede834 100644 --- a/backend/src/modules/artwork/artwork.module.ts +++ b/backend/src/modules/artwork/artwork.module.ts @@ -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], }) diff --git a/backend/src/modules/artwork/artwork.service.spec.ts b/backend/src/modules/artwork/artwork.service.spec.ts index 8e25edb..5c146cb 100644 --- a/backend/src/modules/artwork/artwork.service.spec.ts +++ b/backend/src/modules/artwork/artwork.service.spec.ts @@ -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['state']; function createPrismaMock() { - const devices = new Map(); const artworkAssets = new Map(); 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); }); }); diff --git a/backend/src/modules/artwork/artwork.service.ts b/backend/src/modules/artwork/artwork.service.ts index 42c3eb0..a5d13b2 100644 --- a/backend/src/modules/artwork/artwork.service.ts +++ b/backend/src/modules/artwork/artwork.service.ts @@ -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 { - 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 { - const owner = await this.ownerContext.resolve(); - return owner.userId; - } } diff --git a/backend/src/modules/assets/assets.controller.ts b/backend/src/modules/assets/assets.controller.ts index f129012..30e7c9c 100644 --- a/backend/src/modules/assets/assets.controller.ts +++ b/backend/src/modules/assets/assets.controller.ts @@ -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', diff --git a/backend/src/modules/assets/assets.dto.ts b/backend/src/modules/assets/assets.dto.ts index d3bc0e4..3b2f27a 100644 --- a/backend/src/modules/assets/assets.dto.ts +++ b/backend/src/modules/assets/assets.dto.ts @@ -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 is provided.', + }) + @IsOptional() @IsUUID() - deviceId!: string; + deviceId?: string; } diff --git a/backend/src/modules/assets/assets.module.ts b/backend/src/modules/assets/assets.module.ts index f06095e..9062c68 100644 --- a/backend/src/modules/assets/assets.module.ts +++ b/backend/src/modules/assets/assets.module.ts @@ -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], }) diff --git a/backend/src/modules/assets/assets.service.spec.ts b/backend/src/modules/assets/assets.service.spec.ts index d9a5fc0..89b7064 100644 --- a/backend/src/modules/assets/assets.service.spec.ts +++ b/backend/src/modules/assets/assets.service.spec.ts @@ -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['state']; function createPrismaMock() { - const devices = new Map(); const audioAssets = new Map(); 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); diff --git a/backend/src/modules/assets/assets.service.ts b/backend/src/modules/assets/assets.service.ts index c6553f9..c2b12e7 100644 --- a/backend/src/modules/assets/assets.service.ts +++ b/backend/src/modules/assets/assets.service.ts @@ -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 { - 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 { - const owner = await this.ownerContext.resolve(); - return owner.userId; - } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..83788ac --- /dev/null +++ b/backend/src/modules/auth/auth.module.ts @@ -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('*'); + } +} diff --git a/backend/src/modules/auth/device-auth.guard.ts b/backend/src/modules/auth/device-auth.guard.ts new file mode 100644 index 0000000..328b01c --- /dev/null +++ b/backend/src/modules/auth/device-auth.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/backend/src/modules/auth/device-auth.service.spec.ts b/backend/src/modules/auth/device-auth.service.spec.ts new file mode 100644 index 0000000..e0d9a33 --- /dev/null +++ b/backend/src/modules/auth/device-auth.service.spec.ts @@ -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, + }); + }); +}); diff --git a/backend/src/modules/auth/device-auth.service.ts b/backend/src/modules/auth/device-auth.service.ts new file mode 100644 index 0000000..85c71b2 --- /dev/null +++ b/backend/src/modules/auth/device-auth.service.ts @@ -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 { + 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 { + 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(); + } +} diff --git a/backend/src/modules/devices/devices.controller.ts b/backend/src/modules/devices/devices.controller.ts index 71ffa6a..cbaed4a 100644 --- a/backend/src/modules/devices/devices.controller.ts +++ b/backend/src/modules/devices/devices.controller.ts @@ -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, diff --git a/backend/src/modules/devices/devices.dto.ts b/backend/src/modules/devices/devices.dto.ts index ed7be69..b2d412a 100644 --- a/backend/src/modules/devices/devices.dto.ts +++ b/backend/src/modules/devices/devices.dto.ts @@ -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 is provided.', + }) + @IsOptional() @IsUUID() - deviceId!: string; + deviceId?: string; @ApiProperty({ example: '0.1.0' }) @IsString() diff --git a/backend/src/modules/devices/devices.module.ts b/backend/src/modules/devices/devices.module.ts index cc1faa9..f3092bf 100644 --- a/backend/src/modules/devices/devices.module.ts +++ b/backend/src/modules/devices/devices.module.ts @@ -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], diff --git a/backend/src/modules/devices/devices.service.spec.ts b/backend/src/modules/devices/devices.service.spec.ts index 42c68eb..522f128 100644 --- a/backend/src/modules/devices/devices.service.spec.ts +++ b/backend/src/modules/devices/devices.service.spec.ts @@ -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( diff --git a/backend/src/modules/devices/devices.service.ts b/backend/src/modules/devices/devices.service.ts index 9c27ec5..2ff9eff 100644 --- a/backend/src/modules/devices/devices.service.ts +++ b/backend/src/modules/devices/devices.service.ts @@ -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 { 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 { - 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(), diff --git a/backend/src/modules/library/library.controller.ts b/backend/src/modules/library/library.controller.ts index 049f84f..1a503a0 100644 --- a/backend/src/modules/library/library.controller.ts +++ b/backend/src/modules/library/library.controller.ts @@ -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', diff --git a/backend/src/modules/library/library.dto.ts b/backend/src/modules/library/library.dto.ts index c0b9ec7..a6f599b 100644 --- a/backend/src/modules/library/library.dto.ts +++ b/backend/src/modules/library/library.dto.ts @@ -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 is provided.', + }) + @IsOptional() @IsUUID() - deviceId!: string; + deviceId?: string; } export class RemoteArtworkDto { diff --git a/backend/src/modules/library/library.module.ts b/backend/src/modules/library/library.module.ts index 4b3d74c..7abf4ca 100644 --- a/backend/src/modules/library/library.module.ts +++ b/backend/src/modules/library/library.module.ts @@ -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], diff --git a/backend/src/modules/library/library.service.spec.ts b/backend/src/modules/library/library.service.spec.ts index fe34add..4f86cc9 100644 --- a/backend/src/modules/library/library.service.spec.ts +++ b/backend/src/modules/library/library.service.spec.ts @@ -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(), diff --git a/backend/src/modules/library/library.service.ts b/backend/src/modules/library/library.service.ts index bf5f1f7..4798a46 100644 --- a/backend/src/modules/library/library.service.ts +++ b/backend/src/modules/library/library.service.ts @@ -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 { @@ -36,19 +38,10 @@ export class LibraryService { } async getRemoteLibraryTracks( - deviceId: string, + legacyDeviceId?: string, ): Promise { - 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: { diff --git a/backend/src/modules/sync/sync.controller.ts b/backend/src/modules/sync/sync.controller.ts index 3939579..2a042b9 100644 --- a/backend/src/modules/sync/sync.controller.ts +++ b/backend/src/modules/sync/sync.controller.ts @@ -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', diff --git a/backend/src/modules/sync/sync.module.ts b/backend/src/modules/sync/sync.module.ts index b92c868..7e5f94c 100644 --- a/backend/src/modules/sync/sync.module.ts +++ b/backend/src/modules/sync/sync.module.ts @@ -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], }) diff --git a/backend/src/modules/uploads/uploads.controller.ts b/backend/src/modules/uploads/uploads.controller.ts index bfe0936..53b78da 100644 --- a/backend/src/modules/uploads/uploads.controller.ts +++ b/backend/src/modules/uploads/uploads.controller.ts @@ -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', diff --git a/backend/src/modules/uploads/uploads.dto.ts b/backend/src/modules/uploads/uploads.dto.ts index e3c8078..2c89644 100644 --- a/backend/src/modules/uploads/uploads.dto.ts +++ b/backend/src/modules/uploads/uploads.dto.ts @@ -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 is provided.', + }) + @IsOptional() @IsUUID() - deviceId!: string; + deviceId?: string; @ApiProperty({ example: diff --git a/backend/src/modules/uploads/uploads.module.ts b/backend/src/modules/uploads/uploads.module.ts index c85ca81..b7cdf7d 100644 --- a/backend/src/modules/uploads/uploads.module.ts +++ b/backend/src/modules/uploads/uploads.module.ts @@ -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], }) diff --git a/backend/src/modules/uploads/uploads.service.spec.ts b/backend/src/modules/uploads/uploads.service.spec.ts index e4aed8e..3eb3e7a 100644 --- a/backend/src/modules/uploads/uploads.service.spec.ts +++ b/backend/src/modules/uploads/uploads.service.spec.ts @@ -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, ); }); diff --git a/backend/src/modules/uploads/uploads.service.ts b/backend/src/modules/uploads/uploads.service.ts index e7a128e..82d15ca 100644 --- a/backend/src/modules/uploads/uploads.service.ts +++ b/backend/src/modules/uploads/uploads.service.ts @@ -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, diff --git a/backend/src/modules/users/owner-context.service.spec.ts b/backend/src/modules/users/owner-context.service.spec.ts index 3322218..f2a427e 100644 --- a/backend/src/modules/users/owner-context.service.spec.ts +++ b/backend/src/modules/users/owner-context.service.spec.ts @@ -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(); + }); }); diff --git a/backend/src/modules/users/owner-context.service.ts b/backend/src/modules/users/owner-context.service.ts index ff0e47e..240c863 100644 --- a/backend/src/modules/users/owner-context.service.ts +++ b/backend/src/modules/users/owner-context.service.ts @@ -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 { + const authenticatedDevice = this.requestContext.getAuthenticatedDevice(); + + if (authenticatedDevice) { + return { + userId: authenticatedDevice.userId, + }; + } + const defaultUser = await this.defaultUserService.getOrCreateDefaultUser(); return { diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index f282e82..a2f37d5 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -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, diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index aef2d03..ec22ff4 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -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>( + record: T | null, + select?: Record, +) { + if (!record) { + return null; + } + + if (!select) { + return record; + } + + return Object.fromEntries( + Object.entries(select) + .filter(([, enabled]) => enabled) + .map(([key]) => [key, record[key]]), + ); +} + function createUploadRequest(data: Buffer): any { const request = Readable.from([data]) as any; request.headers = { @@ -96,17 +118,21 @@ function createPrismaMock() { devices.set(record.id, record); return record; }), - findUnique: jest.fn().mockImplementation(async ({ where }) => { - const device = devices.get(where.id) ?? null; - if (!device) { - return null; + findUnique: jest.fn().mockImplementation(async ({ where, select }) => { + if (where.id) { + return applySelect(devices.get(where.id) ?? null, select); } - if (where.id && typeof where.id === 'string') { - return device; + if (where.tokenHash) { + const matchingDevice = + [...devices.values()].find( + (device) => device.tokenHash === where.tokenHash, + ) ?? null; + + return applySelect(matchingDevice, select); } - return device; + return null; }), update: jest.fn().mockImplementation(async ({ where, data }) => { const current = devices.get(where.id); @@ -322,6 +348,8 @@ describe('Velody API wiring (e2e)', () => { let syncController: SyncController; let uploadsController: UploadsController; let uploadsService: UploadsService; + let requestContextService: RequestContextService; + let deviceAuthService: DeviceAuthService; let prismaState: ReturnType['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', diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift index 5d56aaa..883c1d4 100644 --- a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift @@ -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 } diff --git a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift index 4d6b85f..6eeac2b 100644 --- a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift +++ b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift @@ -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( _ 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) ) diff --git a/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/URLSessionVelodyAPIClientAuthorizationTests.swift b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/URLSessionVelodyAPIClientAuthorizationTests.swift new file mode 100644 index 0000000..6f7a7ec --- /dev/null +++ b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/URLSessionVelodyAPIClientAuthorizationTests.swift @@ -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() {} +} diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift index 912aa5f..c3f6bc6 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift @@ -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" ) diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift index feee178..086f332 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift @@ -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" )