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
@@ -93,15 +93,18 @@ public struct DeviceRegistrationPayload: Codable, Hashable, Sendable {
public struct DeviceRegistrationResponse: Codable, Hashable, Sendable {
public var deviceId: String
public var deviceAccessToken: String
public var bootstrapToken: String
public var serverTime: String
public init(
deviceId: String,
deviceAccessToken: String,
bootstrapToken: String,
serverTime: String
) {
self.deviceId = deviceId
self.deviceAccessToken = deviceAccessToken
self.bootstrapToken = bootstrapToken
self.serverTime = serverTime
}
@@ -73,21 +73,26 @@ public protocol VelodyAPIClient: Sendable {
) async throws -> UploadFinalizeResponse
}
public typealias DeviceAccessTokenProvider = @Sendable () async throws -> String?
public struct URLSessionVelodyAPIClient: VelodyAPIClient {
public let environment: ServerEnvironment
private let session: URLSession
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let deviceAccessTokenProvider: DeviceAccessTokenProvider?
public init(
environment: ServerEnvironment,
session: URLSession = .shared
session: URLSession = .shared,
deviceAccessTokenProvider: DeviceAccessTokenProvider? = nil
) {
self.environment = environment
self.session = session
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
self.deviceAccessTokenProvider = deviceAccessTokenProvider
}
public func registerDevice(
@@ -108,6 +113,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
method: "POST",
pathComponents: ["api", "v1", "devices", "heartbeat"],
body: payload,
includesDeviceAuthorization: true,
responseType: DeviceHeartbeatResponse.self
)
}
@@ -116,6 +122,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "sync", "bootstrap"],
includesDeviceAuthorization: true,
responseType: SyncBootstrapResponse.self
)
}
@@ -129,6 +136,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
includesDeviceAuthorization: true,
responseType: RemoteLibraryResponseDTO.self
)
}
@@ -137,13 +145,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
assetId: String,
deviceId: String
) async throws -> Data {
let request = try buildRequest(
let request = try await buildRequest(
method: "GET",
pathComponents: ["api", "v1", "assets", assetId, "download"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
bodyData: nil,
includesDeviceAuthorization: true,
acceptType: "audio/mpeg"
)
@@ -154,13 +163,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
artworkId: String,
deviceId: String
) async throws -> Data {
let request = try buildRequest(
let request = try await buildRequest(
method: "GET",
pathComponents: ["api", "v1", "artwork", artworkId, "download"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
bodyData: nil,
includesDeviceAuthorization: true,
acceptType: "image/*"
)
@@ -174,6 +184,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
method: "POST",
pathComponents: ["api", "v1", "uploads", "prepare"],
body: payload,
includesDeviceAuthorization: true,
responseType: UploadPrepareResponse.self
)
}
@@ -184,6 +195,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "uploads", uploadId],
includesDeviceAuthorization: true,
responseType: UploadSessionStatusResponse.self
)
}
@@ -197,11 +209,12 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
throw VelodyAPIError.requestFailed("The selected file could not be found.")
}
let request = try buildRequest(
let request = try await buildRequest(
method: "PUT",
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
queryItems: [],
bodyData: nil,
includesDeviceAuthorization: true,
contentType: mimeType
)
@@ -229,6 +242,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
method: "POST",
pathComponents: ["api", "v1", "uploads", uploadId, "finalize"],
body: payload,
includesDeviceAuthorization: true,
responseType: UploadFinalizeResponse.self
)
}
@@ -237,13 +251,15 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
method: String,
pathComponents: [String],
queryItems: [URLQueryItem] = [],
includesDeviceAuthorization: Bool = false,
responseType: Response.Type
) async throws -> Response {
let request = try buildRequest(
let request = try await buildRequest(
method: method,
pathComponents: pathComponents,
queryItems: queryItems,
bodyData: nil
bodyData: nil,
includesDeviceAuthorization: includesDeviceAuthorization
)
return try await execute(request, responseType: responseType)
@@ -254,6 +270,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
pathComponents: [String],
queryItems: [URLQueryItem] = [],
body: Body,
includesDeviceAuthorization: Bool = false,
responseType: Response.Type
) async throws -> Response {
let bodyData: Data
@@ -264,11 +281,12 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
let request = try buildRequest(
let request = try await buildRequest(
method: method,
pathComponents: pathComponents,
queryItems: queryItems,
bodyData: bodyData,
includesDeviceAuthorization: includesDeviceAuthorization,
contentType: "application/json"
)
@@ -280,9 +298,10 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
pathComponents: [String],
queryItems: [URLQueryItem],
bodyData: Data?,
includesDeviceAuthorization: Bool = false,
contentType: String? = nil,
acceptType: String = "application/json"
) throws -> URLRequest {
) async throws -> URLRequest {
guard let url = endpointURL(
pathComponents: pathComponents,
queryItems: queryItems
@@ -302,9 +321,30 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
if includesDeviceAuthorization,
let deviceAccessToken = try await loadDeviceAccessToken(),
!deviceAccessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
request.setValue(
"Bearer \(deviceAccessToken)",
forHTTPHeaderField: "Authorization"
)
}
return request
}
private func loadDeviceAccessToken() async throws -> String? {
guard let deviceAccessTokenProvider else {
return nil
}
do {
return try await deviceAccessTokenProvider()
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
}
private func execute<Response: Decodable>(
_ request: URLRequest,
responseType: Response.Type
@@ -404,6 +444,7 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
deviceAccessToken: "stub-device-access-token",
bootstrapToken: "stub-bootstrap-token",
serverTime: ISO8601DateFormatter().string(from: .now)
)
@@ -0,0 +1,130 @@
import Foundation
import XCTest
import VelodyDomain
@testable import VelodyNetworking
final class URLSessionVelodyAPIClientAuthorizationTests: XCTestCase {
override func tearDown() {
RecordingURLProtocol.handler = nil
super.tearDown()
}
func testFetchRemoteLibrarySendsAuthorizationHeaderWhenDeviceTokenIsAvailable() async throws {
RecordingURLProtocol.handler = { request in
XCTAssertEqual(
request.value(forHTTPHeaderField: "Authorization"),
"Bearer test-device-access-token"
)
XCTAssertEqual(request.url?.path, "/api/v1/library/tracks")
XCTAssertEqual(request.url?.query, "deviceId=device-123")
return (
HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!,
Data(#"{"tracks":[]}"#.utf8)
)
}
let client = URLSessionVelodyAPIClient(
environment: ServerEnvironment(
baseURL: URL(string: "http://127.0.0.1:3007")!,
appVersion: "Tests"
),
session: makeSession(),
deviceAccessTokenProvider: {
"test-device-access-token"
}
)
let response = try await client.fetchRemoteLibrary(deviceId: "device-123")
XCTAssertEqual(response.tracks.count, 0)
}
func testRegisterDeviceDoesNotSendAuthorizationHeader() async throws {
RecordingURLProtocol.handler = { request in
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
XCTAssertEqual(request.url?.path, "/api/v1/devices/register")
return (
HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!,
Data(
"""
{
"deviceId": "device-123",
"deviceAccessToken": "registered-device-token",
"bootstrapToken": "bootstrap-token",
"serverTime": "2026-06-09T10:00:00.000Z"
}
""".utf8
)
)
}
let client = URLSessionVelodyAPIClient(
environment: ServerEnvironment(
baseURL: URL(string: "http://127.0.0.1:3007")!,
appVersion: "Tests"
),
session: makeSession(),
deviceAccessTokenProvider: {
"should-not-be-sent"
}
)
let response = try await client.registerDevice(
DeviceRegistrationPayload(
platform: .iphone,
deviceName: "Test iPhone",
appVersion: "Tests"
)
)
XCTAssertEqual(response.deviceId, "device-123")
XCTAssertEqual(response.deviceAccessToken, "registered-device-token")
}
private func makeSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [RecordingURLProtocol.self]
return URLSession(configuration: configuration)
}
}
private final class RecordingURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "127.0.0.1"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let handler = Self.handler else {
XCTFail("RecordingURLProtocol.handler must be set before use.")
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
@@ -427,6 +427,7 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
deviceAccessToken: UUID().uuidString,
bootstrapToken: UUID().uuidString,
serverTime: "2026-05-30T08:00:00.000Z"
)
@@ -345,6 +345,7 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
deviceAccessToken: UUID().uuidString,
bootstrapToken: UUID().uuidString,
serverTime: "2026-05-29T08:00:00.000Z"
)