import Foundation import Security import LilithLogging // MARK: - Keychain Auth Provider /// Keychain-backed authentication provider. /// /// Stores access and refresh tokens securely in the iOS/macOS Keychain. /// Handles SSO token exchange, JWT-based user extraction, and automatic /// retry with exponential backoff on refresh failures. public final class KeychainAuthProvider: AuthProvider, @unchecked Sendable { // MARK: - Configuration private let keychainService: String private let accessTokenKey = "access_token" private let refreshTokenKey = "refresh_token" private let baseURL: String // MARK: - State private var _isAuthenticated: Bool = false private var isRefreshing: Bool = false private let lock = NSLock() public var isAuthenticated: Bool { lock.lock() defer { lock.unlock() } return _isAuthenticated } public var accessToken: String? { readKeychain(key: accessTokenKey) } // MARK: - Initialization /// Create a Keychain-backed auth provider. /// - Parameters: /// - keychainService: The Keychain service identifier (defaults to app bundle ID). /// - baseURL: The base API URL for token exchange and refresh endpoints. public init( keychainService: String = Bundle.main.bundleIdentifier ?? "com.lilith.messenger", baseURL: String ) { self.keychainService = keychainService self.baseURL = baseURL // Restore authentication state from existing Keychain tokens if let token = readKeychain(key: accessTokenKey), isTokenValid(token) { lock.lock() _isAuthenticated = true lock.unlock() } else { clearCredentials() } } // MARK: - AuthProvider public func getAccessToken() async throws -> String { guard let token = accessToken else { throw AuthError.noRefreshToken } return token } public func refreshToken() async throws { lock.lock() guard !isRefreshing else { lock.unlock() return } isRefreshing = true lock.unlock() defer { lock.lock() isRefreshing = false lock.unlock() } guard let currentRefreshToken = readKeychain(key: refreshTokenKey) else { clearCredentials() throw AuthError.noRefreshToken } let url = URL(string: "\(baseURL)/api/auth/refresh")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(["refreshToken": currentRefreshToken]) let maxRetries = 3 var lastError: Error? for attempt in 0.. Bool { let segments = token.split(separator: ".") guard segments.count >= 2 else { return false } var base64 = String(segments[1]) let remainder = base64.count % 4 if remainder > 0 { base64 += String(repeating: "=", count: 4 - remainder) } guard let data = Data(base64Encoded: base64), let claims = try? JSONDecoder.apiDecoder.decode(JWTClaims.self, from: data) else { return false } // Check expiration if present if let exp = claims.exp { return Date().timeIntervalSince1970 < exp } return true } // MARK: - Keychain Operations private func saveKeychain(key: String, value: String) { let data = Data(value.utf8) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: key, ] SecItemDelete(query as CFDictionary) var addQuery = query addQuery[kSecValueData as String] = data addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly SecItemAdd(addQuery as CFDictionary, nil) } private func readKeychain(key: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: key, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } } // MARK: - Supporting Types struct TokenResponse: Codable { let accessToken: String let refreshToken: String } struct JWTClaims: Codable { let sub: String let username: String? let displayName: String? let avatarURL: String? let provider: String? let exp: TimeInterval? }