diff --git a/Sources/EffemKit/Configuration.swift b/Sources/EffemKit/Configuration.swift index 9fad9bd..fc7d38d 100644 --- a/Sources/EffemKit/Configuration.swift +++ b/Sources/EffemKit/Configuration.swift @@ -36,18 +36,32 @@ func ensurePDSConfigured() throws { } } -/// Injects the AppView read token into requests to the Effem AppView. +/// Injects the device token into requests to the Effem AppView. /// Separate from the AT Proto router delegate which injects user PDS tokens. @NetworkingKitActor final class AppViewRouterDelegate: NetworkRouterDelegate { func intercept(_ request: inout URLRequest) async { - if let token = await EffemEnvironment.current.readToken { + if let token = await EffemEnvironment.current.deviceToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } } func shouldRetry(error: Error, attempts: Int) async throws -> Bool { - false + // Re-attest on 401 (revoked/expired device token), but only once. + guard attempts <= 1, + let networkError = error as? NetworkError, + case .statusCode(let code, _, _) = networkError, + code == .unauthorized else { + return false + } + + // Ask the app to re-attest and provide a new token. + if let handler = await EffemEnvironment.current.onTokenRejected, + let newToken = await handler() { + await EffemEnvironment.current.updateDeviceToken(newToken) + return true + } + return false } } diff --git a/Sources/EffemKit/EffemAPI.swift b/Sources/EffemKit/EffemAPI.swift index 710dceb..321b2da 100644 --- a/Sources/EffemKit/EffemAPI.swift +++ b/Sources/EffemKit/EffemAPI.swift @@ -20,6 +20,11 @@ enum EffemAPI { case getProfile(did: String) case getBlocks(did: String, cursor: String?, limit: Int) + // MARK: - Device Attestation + + case deviceChallenge + case deviceAttest(body: Data) + // MARK: - Podcast Index Proxy (cached by AppView) case searchPodcasts(query: String, max: Int, searchType: String?) @@ -46,6 +51,9 @@ extension EffemAPI: EndpointType { var path: String { switch self { + // Device + case .deviceChallenge: "/v1/device/challenge" + case .deviceAttest: "/v1/device/attest" // Social case .getSubscriptions: "/xrpc/xyz.effem.feed.getSubscriptions" case .getSubscribers: "/xrpc/xyz.effem.feed.getSubscribers" @@ -73,10 +81,23 @@ extension EffemAPI: EndpointType { } } - var httpMethod: HTTPMethod { .get } + var httpMethod: HTTPMethod { + switch self { + case .deviceChallenge, .deviceAttest: .post + default: .get + } + } var task: HTTPTask { switch self { + // MARK: Device + + case .deviceChallenge: + return .request + + case .deviceAttest(let body): + return .requestParameters(encoding: .jsonDataEncoding(data: body)) + // MARK: Social case .getSubscriptions(let did, let cursor, let limit): diff --git a/Sources/EffemKit/EffemEnvironment.swift b/Sources/EffemKit/EffemEnvironment.swift index 5a89f4e..215dad4 100644 --- a/Sources/EffemKit/EffemEnvironment.swift +++ b/Sources/EffemKit/EffemEnvironment.swift @@ -6,12 +6,19 @@ public final class EffemEnvironment: Sendable { public static let current = EffemEnvironment() public private(set) var appViewHost: String? - public private(set) var readToken: String? + public var deviceToken: String? + + /// Called when the AppView rejects the device token (401). + /// The iOS app sets this to trigger re-attestation and return a new token. + public var onTokenRejected: (@Sendable () async -> String?)? private init() {} - public func setup(appViewHost: String, readToken: String? = nil) { + public func setup(appViewHost: String) { self.appViewHost = appViewHost - self.readToken = readToken + } + + public func updateDeviceToken(_ token: String) { + self.deviceToken = token } } diff --git a/Sources/EffemKit/EffemKit.swift b/Sources/EffemKit/EffemKit.swift index edd39e9..5188948 100644 --- a/Sources/EffemKit/EffemKit.swift +++ b/Sources/EffemKit/EffemKit.swift @@ -5,10 +5,8 @@ import CoreATProtocol /// /// Call this once at app launch before using ``EffemService`` or ``EffemRepoService``. /// -/// - Parameters: -/// - appViewHost: The full URL of your Effem AppView (e.g. `"https://appview.effem.fm"`). -/// - readToken: An optional Bearer token for authenticating read requests to the AppView. +/// - Parameter appViewHost: The full URL of your Effem AppView (e.g. `"https://appview.effem.fm"`). @APActor -public func setup(appViewHost: String, readToken: String? = nil) { - EffemEnvironment.current.setup(appViewHost: appViewHost, readToken: readToken) +public func setup(appViewHost: String) { + EffemEnvironment.current.setup(appViewHost: appViewHost) } diff --git a/Sources/EffemKit/EffemService.swift b/Sources/EffemKit/EffemService.swift index b227d5e..30c3403 100644 --- a/Sources/EffemKit/EffemService.swift +++ b/Sources/EffemKit/EffemService.swift @@ -212,4 +212,28 @@ public struct EffemService: Sendable { public func getStats() async throws -> StatsResponse { try await execute(.getStats) } + + // MARK: - Device Attestation + + /// Requests a one-time challenge nonce for the App Attest flow. + public func requestChallenge() async throws -> DeviceChallengeResponse { + try ensureAppViewConfigured() + return try await RouterCache.effem().execute(EffemAPI.deviceChallenge) + } + + /// Submits an App Attest attestation to the AppView and receives a device token. + public func submitAttestation( + challenge: String, + keyID: String, + attestation: Data + ) async throws -> DeviceAttestResponse { + try ensureAppViewConfigured() + let request = DeviceAttestRequest( + challenge: challenge, + keyID: keyID, + attestation: attestation.base64EncodedString() + ) + let body = try JSONEncoder().encode(request) + return try await RouterCache.effem().execute(EffemAPI.deviceAttest(body: body)) + } } diff --git a/Sources/EffemKit/Models/DeviceAttestation.swift b/Sources/EffemKit/Models/DeviceAttestation.swift new file mode 100644 index 0000000..6f94b39 --- /dev/null +++ b/Sources/EffemKit/Models/DeviceAttestation.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct DeviceChallengeResponse: Codable, Sendable { + public let challenge: String +} + +public struct DeviceAttestResponse: Codable, Sendable { + public let deviceToken: String +} + +struct DeviceAttestRequest: Codable, Sendable { + let challenge: String + let keyID: String + let attestation: String +}