Add device access token authentication

This commit is contained in:
diyaa
2026-06-09 12:05:15 +02:00
parent 2945257ea7
commit 45c270c187
49 changed files with 1345 additions and 233 deletions
@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -27,8 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@@ -51,8 +49,6 @@
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -74,8 +70,6 @@
ReferencedContainer = "container:Velody.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -93,8 +87,6 @@
ReferencedContainer = "container:Velody.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
@@ -345,12 +345,7 @@ final class MacLibraryViewModel {
appVersion: environment.appVersion
)
let response = try await makeAPIClient(for: environment).registerDevice(payload)
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
try await keychainService.save(response.bootstrapToken, forKey: Self.bootstrapTokenKey)
registeredDeviceId = response.deviceId
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
try await persistRegisteredDevice(response)
} catch {
deviceRegistrationStatus = "Registration failed: \(error.localizedDescription)"
}
@@ -365,7 +360,7 @@ final class MacLibraryViewModel {
do {
let environment = try currentEnvironment()
let deviceId = try await currentDeviceId()
let deviceId = try await currentOrRegisterDeviceId(for: environment)
let response = try await makeAPIClient(for: environment).sendHeartbeat(
DeviceHeartbeatPayload(
deviceId: deviceId,
@@ -421,7 +416,7 @@ final class MacLibraryViewModel {
do {
let environment = try currentEnvironment()
let deviceId = try await currentDeviceId()
let deviceId = try await currentOrRegisterDeviceId(for: environment)
let fileURL = URL(fileURLWithPath: initialTrack.localFilePath)
try await withStoredFolderAccess {
@@ -589,13 +584,18 @@ final class MacLibraryViewModel {
private func restoreDeviceIdentity() async {
do {
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
let deviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
)
let bootstrapToken = try await keychainService.loadValue(forKey: Self.bootstrapTokenKey)
registeredDeviceId = deviceId
if let deviceId {
if let bootstrapToken, !bootstrapToken.isEmpty {
if let deviceAccessToken, !deviceAccessToken.isEmpty {
deviceRegistrationStatus = "Registered locally."
} else if let bootstrapToken, !bootstrapToken.isEmpty {
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Re-register to get a device access token."
} else {
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Bootstrap token is missing."
}
@@ -621,23 +621,62 @@ final class MacLibraryViewModel {
)
}
private func currentDeviceId() async throws -> String {
private func currentOrRegisterDeviceId(
for environment: ServerEnvironment
) async throws -> String {
if let registeredDeviceId, !registeredDeviceId.isEmpty {
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"
@@ -469,7 +469,12 @@ final class iPhoneLibraryViewModel {
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "0.1.0"
)
let apiClient = URLSessionVelodyAPIClient(environment: environment)
let apiClient = URLSessionVelodyAPIClient(
environment: environment,
deviceAccessTokenProvider: {
try await keychainService.loadValue(forKey: Self.deviceAccessTokenKey)
}
)
let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
@@ -657,7 +662,10 @@ final class iPhoneLibraryViewModel {
private func currentOrRegisterDeviceID() async throws -> String {
if let existingDeviceID = try await keychainService.loadValue(
forKey: Self.deviceIDKey
), !existingDeviceID.isEmpty {
), !existingDeviceID.isEmpty,
let existingDeviceAccessToken = try await keychainService.loadValue(
forKey: Self.deviceAccessTokenKey
), !existingDeviceAccessToken.isEmpty {
return existingDeviceID
}
@@ -670,6 +678,10 @@ final class iPhoneLibraryViewModel {
)
try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey)
try await keychainService.save(
response.deviceAccessToken,
forKey: Self.deviceAccessTokenKey
)
try await keychainService.save(
response.bootstrapToken,
forKey: Self.bootstrapTokenKey
@@ -914,6 +926,7 @@ final class iPhoneLibraryViewModel {
#endif
private static let deviceIDKey = "velody.iphone.device-id"
private static let deviceAccessTokenKey = "velody.iphone.device-access-token"
private static let bootstrapTokenKey = "velody.iphone.bootstrap-token"
private static let remoteLibraryEmptyMessage = LibrarySectionMessage(
title: "No music synced yet",
@@ -454,6 +454,71 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase {
}
}
@MainActor
final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
func testRefreshSyncStoresDeviceAccessTokenWhenRegistrationRuns() async throws {
let keychain = MemoryKeychainService()
let counter = RegisterCallCounter()
let viewModel = makeViewModel(
remoteTracks: [
makeRemoteTrack(
trackId: "remote-auth-store",
assetId: "asset-auth-store",
title: "Store Token"
),
],
apiClient: TestRegisterAPIClient(counter: counter),
keychainService: keychain
)
await viewModel.loadIfNeeded()
await viewModel.refreshSync()
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
let storedDeviceAccessToken = try await keychain.loadValue(
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
func testExistingDeviceWithoutAccessTokenReregistersCleanly() async throws {
let keychain = MemoryKeychainService()
let legacyDeviceID = "legacy-device-id"
try await keychain.save(legacyDeviceID, forKey: "velody.iphone.device-id")
try await keychain.save(
"legacy-bootstrap-token",
forKey: "velody.iphone.bootstrap-token"
)
let counter = RegisterCallCounter()
let viewModel = makeViewModel(
remoteTracks: [
makeRemoteTrack(
trackId: "remote-auth-reregister",
assetId: "asset-auth-reregister",
title: "Re-register Token"
),
],
apiClient: TestRegisterAPIClient(counter: counter),
keychainService: keychain
)
await viewModel.loadIfNeeded()
await viewModel.refreshSync()
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
let storedDeviceAccessToken = try await keychain.loadValue(
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
}
private actor RegisterCallCounter {
private(set) var count = 0