diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index ba80f552..1eb8549b 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -47,7 +47,7 @@ final class AppEnvironment { let handoffManager: HandoffManager let invidiousCredentialsManager: InvidiousCredentialsManager let pipedCredentialsManager: PipedCredentialsManager - let yatteeServerCredentialsManager: YatteeServerCredentialsManager + let basicAuthCredentialsManager: BasicAuthCredentialsManager let homeInstanceCache: HomeInstanceCache let invidiousAPI: InvidiousAPI let pipedAPI: PipedAPI @@ -82,12 +82,12 @@ final class AppEnvironment { instances.setSettingsManager(settings) self.instancesManager = instances - // Initialize Yattee Server Credentials Manager early (needed for ContentService) - let yatteeServerCreds = YatteeServerCredentialsManager() - yatteeServerCreds.settingsManager = settings - self.yatteeServerCredentialsManager = yatteeServerCreds + // Initialize Basic Auth Credentials Manager early (needed for ContentService) + let basicAuthCreds = BasicAuthCredentialsManager() + basicAuthCreds.settingsManager = settings + self.basicAuthCredentialsManager = basicAuthCreds - let contentSvc = ContentService(httpClient: client, yatteeServerCredentialsManager: yatteeServerCreds) + let contentSvc = ContentService(httpClient: client, basicAuthCredentialsManager: basicAuthCreds) self.contentService = contentSvc self.instanceDetector = InstanceDetector(httpClient: client) self.navigationCoordinator = navigationCoordinator ?? NavigationCoordinator() @@ -374,7 +374,7 @@ final class AppEnvironment { case .piped: return pipedCredentialsManager case .yatteeServer: - return yatteeServerCredentialsManager + return basicAuthCredentialsManager default: return nil } diff --git a/Yattee/Services/API/ContentService.swift b/Yattee/Services/API/ContentService.swift index 47d9a059..0df1efa9 100644 --- a/Yattee/Services/API/ContentService.swift +++ b/Yattee/Services/API/ContentService.swift @@ -41,10 +41,10 @@ actor ContentService: ContentServiceProtocol { private let defaultPeerTubeAPI: PeerTubeAPI private let defaultYatteeServerAPI: YatteeServerAPI - /// Credentials manager for fetching Yattee Server auth headers on demand. - private let yatteeServerCredentialsManager: YatteeServerCredentialsManager? + /// Credentials manager for fetching basic auth headers on demand. + private let basicAuthCredentialsManager: BasicAuthCredentialsManager? - init(httpClient: HTTPClient, yatteeServerCredentialsManager: YatteeServerCredentialsManager? = nil) { + init(httpClient: HTTPClient, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) { // Legacy init - create factory internally self.httpClientFactory = HTTPClientFactory() self.defaultHTTPClient = httpClient @@ -52,10 +52,10 @@ actor ContentService: ContentServiceProtocol { self.defaultPipedAPI = PipedAPI(httpClient: httpClient) self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: httpClient) self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: httpClient) - self.yatteeServerCredentialsManager = yatteeServerCredentialsManager + self.basicAuthCredentialsManager = basicAuthCredentialsManager } - init(httpClientFactory: HTTPClientFactory, yatteeServerCredentialsManager: YatteeServerCredentialsManager? = nil) { + init(httpClientFactory: HTTPClientFactory, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) { self.httpClientFactory = httpClientFactory // Create default client for instances that don't need insecure SSL self.defaultHTTPClient = httpClientFactory.createClient(allowInvalidCertificates: false) @@ -63,7 +63,7 @@ actor ContentService: ContentServiceProtocol { self.defaultPipedAPI = PipedAPI(httpClient: defaultHTTPClient) self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: defaultHTTPClient) self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: defaultHTTPClient) - self.yatteeServerCredentialsManager = yatteeServerCredentialsManager + self.basicAuthCredentialsManager = basicAuthCredentialsManager } // MARK: - Routing @@ -114,7 +114,7 @@ actor ContentService: ContentServiceProtocol { } // Fetch auth header directly from credentials manager (avoids race condition on app startup) - let authHeader = await yatteeServerCredentialsManager?.basicAuthHeader(for: instance) + let authHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) await api.setAuthHeader(authHeader) return api diff --git a/Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift b/Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift index 210a8867..ebaf885c 100644 --- a/Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift +++ b/Yattee/Services/BackgroundRefresh/BackgroundFeedRefresher.swift @@ -102,7 +102,7 @@ final class BackgroundFeedRefresher { do { let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient()) - let authHeader = appEnvironment.yatteeServerCredentialsManager.basicAuthHeader(for: yatteeServer) + let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) await yatteeServerAPI.setAuthHeader(authHeader) let response = try await yatteeServerAPI.postFeed( channels: channelRequests, diff --git a/Yattee/Services/Credentials/YatteeServerCredentialsManager.swift b/Yattee/Services/Credentials/BasicAuthCredentialsManager.swift similarity index 58% rename from Yattee/Services/Credentials/YatteeServerCredentialsManager.swift rename to Yattee/Services/Credentials/BasicAuthCredentialsManager.swift index b55f7148..fa955759 100644 --- a/Yattee/Services/Credentials/YatteeServerCredentialsManager.swift +++ b/Yattee/Services/Credentials/BasicAuthCredentialsManager.swift @@ -1,24 +1,30 @@ // -// YatteeServerCredentialsManager.swift +// BasicAuthCredentialsManager.swift // Yattee // -// Manages Yattee Server credentials (username/password) stored securely in the Keychain. +// Manages HTTP Basic Auth credentials (username/password) stored securely in the Keychain. +// Works for any instance type (Invidious, Piped, PeerTube, Yattee Server) — used when an +// instance sits behind a reverse proxy that requires HTTP Basic Auth. // import Foundation import Security -/// Credential structure for Yattee Server basic authentication. -struct YatteeServerCredential: Codable { +/// Credential structure for HTTP Basic authentication. +struct BasicAuthCredential: Codable { let username: String let password: String } -/// Manages Yattee Server credentials stored securely in the Keychain. +/// Manages HTTP Basic Auth credentials stored securely in the Keychain. @MainActor @Observable -final class YatteeServerCredentialsManager: InstanceCredentialsManager { - private let keychainServiceName = "com.yattee.yatteeserver" +final class BasicAuthCredentialsManager: InstanceCredentialsManager { + private let keychainServiceName = "com.yattee.basicauth" + + /// Legacy Keychain service name from when basic auth was Yattee-Server-only. + /// We migrate items from this service to `keychainServiceName` on init. + private let legacyKeychainServiceName = "com.yattee.yatteeserver" /// Tracks which instances have stored credentials (for reactive UI updates) private(set) var loggedInInstanceIDs: Set = [] @@ -31,22 +37,24 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true } - init() {} + init() { + migrateLegacyKeychainItems() + } // MARK: - Public API - /// Stores credentials for a Yattee Server instance. + /// Stores credentials for an instance. /// Credentials sync to iCloud Keychain when iCloud sync is enabled for instances. /// - Parameters: /// - username: The username for basic auth /// - password: The password for basic auth - /// - instance: The Yattee Server instance + /// - instance: The instance func setCredentials(username: String, password: String, for instance: Instance) { let account = instance.id.uuidString - let credential = YatteeServerCredential(username: username, password: password) + let credential = BasicAuthCredential(username: username, password: password) guard let data = try? JSONEncoder().encode(credential) else { - LoggingService.shared.error("Failed to encode Yattee Server credentials", category: .keychain) + LoggingService.shared.error("Failed to encode basic auth credentials", category: .keychain) return } @@ -74,7 +82,7 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { if status == errSecSuccess { LoggingService.shared.info( - "Stored credentials for Yattee Server instance", + "Stored basic auth credentials for instance", category: .keychain, details: "instanceID=\(instance.id), iCloudSync=\(syncEnabled)" ) @@ -82,18 +90,18 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { loggedInInstanceIDs.insert(instance.id) } else { LoggingService.shared.error( - "Failed to store credentials for Yattee Server instance", + "Failed to store basic auth credentials for instance", category: .keychain, details: "instanceID=\(instance.id), status=\(status)" ) } } - /// Retrieves credentials for a Yattee Server instance. + /// Retrieves credentials for an instance. /// Searches both synced and non-synced items. - /// - Parameter instance: The Yattee Server instance + /// - Parameter instance: The instance /// - Returns: The credentials if stored, nil otherwise - func credentials(for instance: Instance) -> YatteeServerCredential? { + func credentials(for instance: Instance) -> BasicAuthCredential? { let account = instance.id.uuidString let query: [String: Any] = [ @@ -110,9 +118,9 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { guard status == errSecSuccess, let data = result as? Data, - let credential = try? JSONDecoder().decode(YatteeServerCredential.self, from: data) else { + let credential = try? JSONDecoder().decode(BasicAuthCredential.self, from: data) else { LoggingService.shared.debug( - "No credentials found for Yattee Server instance", + "No basic auth credentials found for instance", category: .keychain, details: "instanceID=\(instance.id), status=\(status)" ) @@ -124,7 +132,7 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { /// Deletes credentials for an instance. /// Deletes both synced and non-synced items. - /// - Parameter instance: The Yattee Server instance + /// - Parameter instance: The instance func deleteCredentials(for instance: Instance) { let account = instance.id.uuidString @@ -139,13 +147,13 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { if status == errSecSuccess || status == errSecItemNotFound { LoggingService.shared.info( - "Deleted credentials for Yattee Server instance", + "Deleted basic auth credentials for instance", category: .keychain, details: "instanceID=\(instance.id)" ) } else { LoggingService.shared.error( - "Failed to delete credentials for Yattee Server instance", + "Failed to delete basic auth credentials for instance", category: .keychain, details: "instanceID=\(instance.id), status=\(status)" ) @@ -156,7 +164,7 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { } /// Checks if an instance has stored credentials. - /// - Parameter instance: The Yattee Server instance + /// - Parameter instance: The instance /// - Returns: true if credentials exist, false otherwise func hasCredentials(for instance: Instance) -> Bool { // Check tracked set first for performance @@ -184,15 +192,15 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { // MARK: - InstanceCredentialsManager Protocol /// Stores a credential for an instance (protocol conformance). - /// The credential is expected to be a JSON-encoded YatteeServerCredential. + /// The credential is expected to be a JSON-encoded BasicAuthCredential. /// - Parameters: /// - credential: JSON string containing {"username": "...", "password": "..."} /// - instance: The instance to associate the credential with func setCredential(_ credential: String, for instance: Instance) { guard let data = credential.data(using: .utf8), - let creds = try? JSONDecoder().decode(YatteeServerCredential.self, from: data) else { + let creds = try? JSONDecoder().decode(BasicAuthCredential.self, from: data) else { LoggingService.shared.error( - "Failed to decode credential string for Yattee Server", + "Failed to decode basic auth credential string", category: .keychain ) return @@ -229,7 +237,7 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { // MARK: - Basic Auth Header Generation /// Generates the HTTP Basic Auth header value for an instance. - /// - Parameter instance: The Yattee Server instance + /// - Parameter instance: The instance /// - Returns: The Authorization header value (e.g., "Basic dXNlcjpwYXNz") or nil if no credentials func basicAuthHeader(for instance: Instance) -> String? { guard let creds = credentials(for: instance) else { return nil } @@ -237,4 +245,81 @@ final class YatteeServerCredentialsManager: InstanceCredentialsManager { guard let data = credentials.data(using: .utf8) else { return nil } return "Basic \(data.base64EncodedString())" } + + // MARK: - Legacy Migration + + /// One-time migration of credentials from the legacy Yattee-Server-only Keychain service + /// (`com.yattee.yatteeserver`) to the generic basic-auth service (`com.yattee.basicauth`). + /// Items are copied (preserving sync attribute) and then deleted from the legacy service. + private func migrateLegacyKeychainItems() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: legacyKeychainServiceName, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let items = result as? [[String: Any]], !items.isEmpty else { + return + } + + var migrated = 0 + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + let data = item[kSecValueData as String] as? Data else { + continue + } + let synchronizable = (item[kSecAttrSynchronizable as String] as? Bool) ?? false + + // Skip add if a new-style item already exists for this account. + let existsQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainServiceName, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecMatchLimit as String: kSecMatchLimitOne + ] + if SecItemCopyMatching(existsQuery as CFDictionary, nil) != errSecSuccess { + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainServiceName, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: synchronizable, + kSecValueData as String: data + ] + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + LoggingService.shared.error( + "Failed to migrate legacy basic auth credential", + category: .keychain, + details: "account=\(account), status=\(addStatus)" + ) + continue + } + migrated += 1 + } + + // Delete the legacy item for this account + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: legacyKeychainServiceName, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + SecItemDelete(deleteQuery as CFDictionary) + } + + if migrated > 0 { + LoggingService.shared.info( + "Migrated legacy basic auth credentials to generic Keychain service", + category: .keychain, + details: "count=\(migrated)" + ) + } + } } diff --git a/Yattee/Services/SubscriptionFeedCache.swift b/Yattee/Services/SubscriptionFeedCache.swift index 9be4d4a2..45237546 100644 --- a/Yattee/Services/SubscriptionFeedCache.swift +++ b/Yattee/Services/SubscriptionFeedCache.swift @@ -492,7 +492,7 @@ final class SubscriptionFeedCache { do { let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient()) - let authHeader = appEnvironment.yatteeServerCredentialsManager.basicAuthHeader(for: instance) + let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) await yatteeServerAPI.setAuthHeader(authHeader) LoggingService.shared.debug("refreshFromStatelessServer: Calling postFeed for \(channelRequests.count) channels", category: .general) let response = try await yatteeServerAPI.postFeed( @@ -556,7 +556,7 @@ final class SubscriptionFeedCache { StatelessChannelStatusRequest(channelId: $0.channelId, site: $0.site) } let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient()) - let authHeader = appEnvironment.yatteeServerCredentialsManager.basicAuthHeader(for: instance) + let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) await yatteeServerAPI.setAuthHeader(authHeader) let maxRetries = 5 diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index 9970c700..c169e5bf 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -76,13 +76,13 @@ struct InstanceBrowseView: View { /// Auth header for Yattee Server instances (when browsing a Yattee Server directly) private var yatteeServerAuthHeader: String? { guard instance.type == .yatteeServer else { return nil } - return appEnvironment?.yatteeServerCredentialsManager.basicAuthHeader(for: instance) + return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: instance) } /// Auth header for avatar loading (uses Yattee Server for YouTube channel avatars) private var avatarAuthHeader: String? { guard let server = yatteeServer else { return nil } - return appEnvironment?.yatteeServerCredentialsManager.basicAuthHeader(for: server) + return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) } enum BrowseTab: String, CaseIterable, Identifiable { diff --git a/Yattee/Views/Navigation/UnifiedTabView.swift b/Yattee/Views/Navigation/UnifiedTabView.swift index df3cb409..29d4f679 100644 --- a/Yattee/Views/Navigation/UnifiedTabView.swift +++ b/Yattee/Views/Navigation/UnifiedTabView.swift @@ -54,7 +54,7 @@ struct UnifiedTabView: View { private var yatteeServerAuthHeader: String? { guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil } - return appEnvironment?.yatteeServerCredentialsManager.basicAuthHeader(for: server) + return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) } var body: some View { @@ -329,7 +329,7 @@ struct UnifiedTabView: View { private var yatteeServerAuthHeader: String? { guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil } - return appEnvironment?.yatteeServerCredentialsManager.basicAuthHeader(for: server) + return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) } var body: some View { @@ -554,7 +554,7 @@ struct UnifiedTabView: View { private var yatteeServerAuthHeader: String? { guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil } - return appEnvironment?.yatteeServerCredentialsManager.basicAuthHeader(for: server) + return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) } var body: some View { diff --git a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift index 69fed3b6..963783e7 100644 --- a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift +++ b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift @@ -529,7 +529,7 @@ struct AddRemoteServerView: View { allowInvalidCertificates: allowInvalidCertificates ) - appEnvironment.yatteeServerCredentialsManager.setCredentials( + appEnvironment.basicAuthCredentialsManager.setCredentials( username: yatteeServerUsername, password: yatteeServerPassword, for: instance diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index 67d4a1a4..9ccbc234 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -346,7 +346,7 @@ private struct EditRemoteServerContent: View { // Load existing Yattee Server credentials if instance.type == .yatteeServer, - let credentials = appEnvironment?.yatteeServerCredentialsManager.credentials(for: instance) { + let credentials = appEnvironment?.basicAuthCredentialsManager.credentials(for: instance) { yatteeServerUsername = credentials.username yatteeServerPassword = credentials.password } @@ -438,7 +438,7 @@ private struct EditRemoteServerContent: View { // Save Yattee Server credentials if provided if instance.type == .yatteeServer && !yatteeServerUsername.isEmpty && !yatteeServerPassword.isEmpty { - appEnvironment?.yatteeServerCredentialsManager.setCredentials( + appEnvironment?.basicAuthCredentialsManager.setCredentials( username: yatteeServerUsername, password: yatteeServerPassword, for: instance diff --git a/Yattee/Views/Settings/NotificationSettingsView.swift b/Yattee/Views/Settings/NotificationSettingsView.swift index 8a85b1d2..e2df318a 100644 --- a/Yattee/Views/Settings/NotificationSettingsView.swift +++ b/Yattee/Views/Settings/NotificationSettingsView.swift @@ -290,7 +290,7 @@ private struct ChannelNotificationToggle: View { private var authHeader: String? { guard let server = yatteeServer else { return nil } - return appEnvironment?.yatteeServerCredentialsManager.basicAuthHeader(for: server) + return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) } private var toggleBinding: Binding { diff --git a/Yattee/Views/Subscriptions/ManageChannelsView.swift b/Yattee/Views/Subscriptions/ManageChannelsView.swift index d9d45443..bfae920a 100644 --- a/Yattee/Views/Subscriptions/ManageChannelsView.swift +++ b/Yattee/Views/Subscriptions/ManageChannelsView.swift @@ -45,7 +45,7 @@ struct ManageChannelsView: View { private var yatteeServerURL: URL? { yatteeServer?.url } private var yatteeServerAuthHeader: String? { guard let server = yatteeServer else { return nil } - return appEnvironment?.yatteeServerCredentialsManager.basicAuthHeader(for: server) + return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) } /// Channels filtered by search query and sorted by selected order. @@ -439,7 +439,7 @@ struct ManageChannelsView: View { do { let api = YatteeServerAPI(httpClient: HTTPClient()) - let authHeader = appEnvironment.yatteeServerCredentialsManager.basicAuthHeader(for: yatteeServer) + let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) await api.setAuthHeader(authHeader) let response = try await api.channelsMetadata(channelIDs: channelIDs, instance: yatteeServer) diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index b0231276..7054aee7 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -52,7 +52,7 @@ struct SubscriptionsView: View { private var yatteeServerURL: URL? { yatteeServer?.url } private var yatteeServerAuthHeader: String? { guard let server = yatteeServer else { return nil } - return appEnvironment?.yatteeServerCredentialsManager.basicAuthHeader(for: server) + return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) } /// Generates a unique ID based on instances configuration.