diff --git a/Sources/EffemKit/Configuration.swift b/Sources/EffemKit/Configuration.swift index fc7d38d..541ff91 100644 --- a/Sources/EffemKit/Configuration.swift +++ b/Sources/EffemKit/Configuration.swift @@ -36,18 +36,30 @@ func ensurePDSConfigured() throws { } } -/// Injects the device token into requests to the Effem AppView. -/// Separate from the AT Proto router delegate which injects user PDS tokens. +// MARK: - Router Delegates + +/// Delegate for public AppView routes — sends no auth headers, does not retry on 401. +@NetworkingKitActor +final class PublicRouterDelegate: NetworkRouterDelegate { + func intercept(_ request: inout URLRequest) async {} + + func shouldRetry(error: Error, attempts: Int) async throws -> Bool { + false + } +} + +/// Delegate for authenticated AppView routes — uses DID-bound session token. +/// On 401, attempts to refresh the session via the app-provided callback. @NetworkingKitActor -final class AppViewRouterDelegate: NetworkRouterDelegate { +final class AuthenticatedAppViewRouterDelegate: NetworkRouterDelegate { func intercept(_ request: inout URLRequest) async { - if let token = await EffemEnvironment.current.deviceToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + if let session = await EffemEnvironment.current.appViewSession, + !session.isExpired { + request.setValue("Bearer \(session.token)", forHTTPHeaderField: "Authorization") } } func shouldRetry(error: Error, attempts: Int) async throws -> Bool { - // Re-attest on 401 (revoked/expired device token), but only once. guard attempts <= 1, let networkError = error as? NetworkError, case .statusCode(let code, _, _) = networkError, @@ -55,30 +67,45 @@ final class AppViewRouterDelegate: NetworkRouterDelegate { 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) + if let handler = await EffemEnvironment.current.onSessionRefreshNeeded, + let newSession = await handler() { + await EffemEnvironment.current.updateAppViewSession(newSession) return true } return false } } +// MARK: - Router Cache + @NetworkingKitActor enum RouterCache { - private static var _effemRouter: NetworkRouter? + private static var _publicEffemRouter: NetworkRouter? + private static var _authenticatedEffemRouter: NetworkRouter? private static var _repoRouter: NetworkRouter? - private static let appViewDelegate = AppViewRouterDelegate() - static func effem() -> NetworkRouter { - if let router = _effemRouter { return router } + private static let publicDelegate = PublicRouterDelegate() + private static let authenticatedDelegate = AuthenticatedAppViewRouterDelegate() + + /// Router for public AppView reads — no auth headers. + static func publicEffem() -> NetworkRouter { + if let router = _publicEffemRouter { return router } + let router = NetworkRouter(decoder: .atDecoder) + router.delegate = publicDelegate + _publicEffemRouter = router + return router + } + + /// Router for authenticated AppView reads — uses DID-bound session. + static func authenticatedEffem() -> NetworkRouter { + if let router = _authenticatedEffemRouter { return router } let router = NetworkRouter(decoder: .atDecoder) - router.delegate = appViewDelegate - _effemRouter = router + router.delegate = authenticatedDelegate + _authenticatedEffemRouter = router return router } + /// Router for PDS repo operations — uses AT Proto OAuth + DPoP. static func repo(delegate: NetworkRouterDelegate) -> NetworkRouter { if let router = _repoRouter { router.delegate = delegate diff --git a/Sources/EffemKit/EffemAPI.swift b/Sources/EffemKit/EffemAPI.swift index 321b2da..d19d78b 100644 --- a/Sources/EffemKit/EffemAPI.swift +++ b/Sources/EffemKit/EffemAPI.swift @@ -208,4 +208,28 @@ extension EffemAPI: EndpointType { } var headers: HTTPHeaders? { nil } + + /// Classifies the auth requirement for this endpoint. + /// + /// - anonymous: Public read — no auth header needed. + /// - userSession: User-scoped read — requires a DID-bound AppView session. + var authRequirement: AppViewAuthRequirement { + switch self { + // User-scoped reads (Category C) — require DID-bound session + case .getBookmarks, .getEpisodeStates, .getBlocks, .getInbox: + .userSession + + // Everything else is public (Category A/B) or device enrollment + case .getSubscriptions, .getSubscribers, + .getComments, .getCommentThread, + .getRecommendations, .getPopular, + .getList, .getLists, + .getProfile, + .searchPodcasts, .searchEpisodes, + .getPodcast, .getEpisodes, .getEpisode, + .getTrending, .getCategories, .getRecentEpisodes, .getStats, + .deviceChallenge, .deviceAttest: + .anonymous + } + } } diff --git a/Sources/EffemKit/EffemEnvironment.swift b/Sources/EffemKit/EffemEnvironment.swift index 215dad4..a2227ac 100644 --- a/Sources/EffemKit/EffemEnvironment.swift +++ b/Sources/EffemKit/EffemEnvironment.swift @@ -6,11 +6,25 @@ public final class EffemEnvironment: Sendable { public static let current = EffemEnvironment() public private(set) var appViewHost: 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?)? + // MARK: - Optional App/Device Trust + + /// Optional device token from App Attest enrollment. + /// Used only for elevated trust (rate limits, abuse control), not for general AppView reads. + public private(set) var deviceToken: String? + + /// Current attestation enrollment state. + public private(set) var attestationState: AttestationEnrollment = .notEnrolled + + // MARK: - AppView User Session (DID-bound) + + /// Optional DID-bound AppView session for user-scoped reads. + /// Separate from OAuth (which targets the PDS) and device tokens (which represent app trust). + public private(set) var appViewSession: AppViewSession? + + /// Called when the AppView rejects the user session (401 on an authenticated route). + /// The iOS app sets this to refresh or re-mint the DID-bound session. + public var onSessionRefreshNeeded: (@Sendable () async -> AppViewSession?)? private init() {} @@ -18,7 +32,23 @@ public final class EffemEnvironment: Sendable { self.appViewHost = appViewHost } + // MARK: - Device Trust Mutations + public func updateDeviceToken(_ token: String) { self.deviceToken = token } + + public func clearDeviceToken() { + self.deviceToken = nil + } + + public func updateAttestationState(_ state: AttestationEnrollment) { + self.attestationState = state + } + + // MARK: - AppView Session Mutations + + public func updateAppViewSession(_ session: AppViewSession?) { + self.appViewSession = session + } } diff --git a/Sources/EffemKit/EffemService.swift b/Sources/EffemKit/EffemService.swift index 30c3403..7fbafc2 100644 --- a/Sources/EffemKit/EffemService.swift +++ b/Sources/EffemKit/EffemService.swift @@ -9,17 +9,30 @@ import NetworkingKit /// /// All endpoints are read-only GET requests against the AppView. /// For write operations, use ``EffemRepoService``. +/// +/// Endpoints are classified as either anonymous (public reads) or authenticated +/// (user-scoped reads requiring a DID-bound AppView session). The classification +/// is defined on ``EffemAPI/authRequirement`` and determines which router is used. @APActor public struct EffemService: Sendable { public init() {} + /// Routes the request through the appropriate router based on the endpoint's auth requirement. + /// + /// - anonymous: Uses the public router (no auth header). + /// - userSession: Uses the authenticated router (DID-bound session token). private func execute(_ endpoint: EffemAPI) async throws -> T { try ensureAppViewConfigured() - return try await RouterCache.effem().execute(endpoint) + switch endpoint.authRequirement { + case .anonymous: + return try await RouterCache.publicEffem().execute(endpoint) + case .userSession: + return try await RouterCache.authenticatedEffem().execute(endpoint) + } } - // MARK: - Subscriptions + // MARK: - Subscriptions (Public) /// Returns a user's podcast subscriptions. public func getSubscriptions( @@ -39,7 +52,7 @@ public struct EffemService: Sendable { try await execute(.getSubscribers(feedId: feedId, cursor: cursor, limit: limit)) } - // MARK: - Comments + // MARK: - Comments (Public) /// Returns comments for a specific episode. public func getComments( @@ -56,7 +69,7 @@ public struct EffemService: Sendable { try await execute(.getCommentThread(uri: uri)) } - // MARK: - Recommendations + // MARK: - Recommendations (Public) /// Returns recommendations for a specific episode. public func getRecommendations( @@ -76,7 +89,7 @@ public struct EffemService: Sendable { try await execute(.getPopular(period: period, limit: limit)) } - // MARK: - Lists + // MARK: - Lists (Public) /// Returns a specific curated podcast list. public func getList(uri: String) async throws -> ListResponse { @@ -92,7 +105,7 @@ public struct EffemService: Sendable { try await execute(.getLists(did: did, cursor: cursor, limit: limit)) } - // MARK: - Bookmarks + // MARK: - Bookmarks (User-Scoped) /// Returns a user's episode bookmarks, optionally filtered by podcast or episode. public func getBookmarks( @@ -105,7 +118,7 @@ public struct EffemService: Sendable { try await execute(.getBookmarks(did: did, feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) } - // MARK: - Episode States + // MARK: - Episode States (User-Scoped) /// Returns a user's episode states, optionally filtered by podcast, saved status, or hidden. public func getEpisodeStates( @@ -119,7 +132,7 @@ public struct EffemService: Sendable { try await execute(.getEpisodeStates(did: did, feedId: feedId, saved: saved, showHidden: showHidden, cursor: cursor, limit: limit)) } - // MARK: - Inbox + // MARK: - Inbox (User-Scoped) /// Returns new episodes from the user's subscribed podcasts. public func getInbox( @@ -130,14 +143,14 @@ public struct EffemService: Sendable { try await execute(.getInbox(did: did, cursor: cursor, limit: limit)) } - // MARK: - Profile + // MARK: - Profile (Public) /// Returns an Effem user profile with social stats. public func getProfile(did: String) async throws -> ProfileResponse { try await execute(.getProfile(did: did)) } - // MARK: - Blocks + // MARK: - Blocks (User-Scoped) /// Returns the authenticated user's block list. public func getBlocks( @@ -148,7 +161,7 @@ public struct EffemService: Sendable { try await execute(.getBlocks(did: did, cursor: cursor, limit: limit)) } - // MARK: - Podcast Index Proxy + // MARK: - Podcast Index Proxy (Public) /// Searches for podcasts via the AppView's Podcast Index proxy. public func searchPodcasts( @@ -213,12 +226,12 @@ public struct EffemService: Sendable { try await execute(.getStats) } - // MARK: - Device Attestation + // MARK: - Device Attestation (Optional Enrollment) /// 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) + return try await RouterCache.publicEffem().execute(EffemAPI.deviceChallenge) } /// Submits an App Attest attestation to the AppView and receives a device token. @@ -234,6 +247,6 @@ public struct EffemService: Sendable { attestation: attestation.base64EncodedString() ) let body = try JSONEncoder().encode(request) - return try await RouterCache.effem().execute(EffemAPI.deviceAttest(body: body)) + return try await RouterCache.publicEffem().execute(EffemAPI.deviceAttest(body: body)) } } diff --git a/Sources/EffemKit/Models/DeviceAttestation.swift b/Sources/EffemKit/Models/DeviceAttestation.swift index 6f94b39..bb1020f 100644 --- a/Sources/EffemKit/Models/DeviceAttestation.swift +++ b/Sources/EffemKit/Models/DeviceAttestation.swift @@ -1,5 +1,7 @@ import Foundation +// MARK: - Device Attestation Request/Response + public struct DeviceChallengeResponse: Codable, Sendable { public let challenge: String } @@ -13,3 +15,44 @@ struct DeviceAttestRequest: Codable, Sendable { let keyID: String let attestation: String } + +// MARK: - Auth State Types + +/// Tracks the state of optional App Attest enrollment. +/// +/// App Attest enrollment is separate from user authentication and AppView sessions. +/// It represents optional device/app trust used for elevated rate limits or abuse control. +public enum AttestationEnrollment: Sendable { + case notEnrolled + case enrolled(keyID: String) + case failed(description: String) +} + +/// A DID-bound AppView session for user-scoped reads. +/// +/// Distinct from device tokens (app trust) and OAuth tokens (PDS access). +/// Used when AppView needs to serve personalized data that requires knowing which user is asking. +public struct AppViewSession: Sendable { + public let did: String + public let token: String + public let expiresAt: Date? + + public init(did: String, token: String, expiresAt: Date? = nil) { + self.did = did + self.token = token + self.expiresAt = expiresAt + } + + public var isExpired: Bool { + guard let expiresAt else { return false } + return Date() >= expiresAt + } +} + +/// Classifies how an AppView endpoint should be authenticated. +public enum AppViewAuthRequirement: Sendable { + /// No authentication required — anonymous public read. + case anonymous + /// Requires a DID-bound AppView session for user-scoped data. + case userSession +} diff --git a/Tests/EffemKitTests/EffemKitTests.swift b/Tests/EffemKitTests/EffemKitTests.swift index b39a3f6..cd08adf 100644 --- a/Tests/EffemKitTests/EffemKitTests.swift +++ b/Tests/EffemKitTests/EffemKitTests.swift @@ -278,3 +278,115 @@ struct CommentReplyRefTests { #expect(ref.parentCID == "bafyreib") } } + +// MARK: - Auth Requirement Classification + +@Suite("AppViewAuthRequirement") +struct AppViewAuthRequirementTests { + @Test func publicEndpointsAreAnonymous() { + let publicEndpoints: [EffemAPI] = [ + .getSubscriptions(did: "did:plc:test", cursor: nil, limit: 50), + .getSubscribers(feedId: 1, cursor: nil, limit: 50), + .getComments(feedId: 1, episodeId: 1, cursor: nil, limit: 50), + .getCommentThread(uri: "at://did:plc:test/xyz.effem.feed.comment/abc"), + .getRecommendations(feedId: 1, episodeId: 1, cursor: nil, limit: 50), + .getPopular(period: "week", limit: 25), + .getList(uri: "at://did:plc:test/xyz.effem.feed.list/abc"), + .getLists(did: "did:plc:test", cursor: nil, limit: 50), + .getProfile(did: "did:plc:test"), + .searchPodcasts(query: "test", max: 20, searchType: nil), + .searchEpisodes(query: "test", max: 20), + .getPodcast(feedId: 1, itunesId: nil), + .getEpisodes(feedId: 1, feedUrl: nil, max: 20), + .getEpisode(episodeId: 1), + .getTrending(max: 20, lang: nil, cat: nil), + .getCategories, + .getRecentEpisodes(max: 20), + .getStats, + .deviceChallenge, + .deviceAttest(body: Data()), + ] + + for endpoint in publicEndpoints { + #expect(endpoint.authRequirement == .anonymous, "Expected \(endpoint) to be anonymous") + } + } + + @Test func userScopedEndpointsRequireSession() { + let userEndpoints: [EffemAPI] = [ + .getBookmarks(did: "did:plc:test", feedId: nil, episodeId: nil, cursor: nil, limit: 50), + .getEpisodeStates(did: "did:plc:test", feedId: nil, saved: nil, showHidden: nil, cursor: nil, limit: 50), + .getBlocks(did: "did:plc:test", cursor: nil, limit: 50), + .getInbox(did: "did:plc:test", cursor: nil, limit: 50), + ] + + for endpoint in userEndpoints { + #expect(endpoint.authRequirement == .userSession, "Expected \(endpoint) to require userSession") + } + } +} + +// MARK: - Auth State Types + +@Suite("AttestationEnrollment") +struct AttestationEnrollmentTests { + @Test func defaultStateIsNotEnrolled() { + let state: AttestationEnrollment = .notEnrolled + if case .notEnrolled = state { + // expected + } else { + Issue.record("Expected .notEnrolled") + } + } + + @Test func enrolledCarriesKeyID() { + let state: AttestationEnrollment = .enrolled(keyID: "test-key-123") + if case .enrolled(let keyID) = state { + #expect(keyID == "test-key-123") + } else { + Issue.record("Expected .enrolled") + } + } + + @Test func failedCarriesDescription() { + let state: AttestationEnrollment = .failed(description: "entitlement missing") + if case .failed(let description) = state { + #expect(description == "entitlement missing") + } else { + Issue.record("Expected .failed") + } + } +} + +@Suite("AppViewSession") +struct AppViewSessionTests { + @Test func sessionWithNoExpiryIsNotExpired() { + let session = AppViewSession(did: "did:plc:test", token: "tok_123") + #expect(!session.isExpired) + } + + @Test func sessionWithFutureExpiryIsNotExpired() { + let session = AppViewSession( + did: "did:plc:test", + token: "tok_123", + expiresAt: Date().addingTimeInterval(3600) + ) + #expect(!session.isExpired) + } + + @Test func sessionWithPastExpiryIsExpired() { + let session = AppViewSession( + did: "did:plc:test", + token: "tok_123", + expiresAt: Date().addingTimeInterval(-60) + ) + #expect(session.isExpired) + } + + @Test func sessionStoresCorrectFields() { + let session = AppViewSession(did: "did:plc:abc", token: "tok_xyz") + #expect(session.did == "did:plc:abc") + #expect(session.token == "tok_xyz") + #expect(session.expiresAt == nil) + } +}