Add device access token authentication
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user