// // SpotifyAuthManager.swift // iris // // OAuth 2.0 PKCE authentication for Spotify Web API. // import AuthenticationServices import CommonCrypto import Foundation import os enum SpotifyConfig { static let redirectUri = "iris-spotify-auth://callback" static let scopes = "user-read-playback-state user-read-currently-playing" static let authUrl = "https://accounts.spotify.com/authorize" static let tokenUrl = "https://accounts.spotify.com/api/token" } @available(iOS 16.0, *) @MainActor final class SpotifyAuthManager: NSObject, ObservableObject { @Published private(set) var isConnected: Bool = false @Published private(set) var isAuthenticating: Bool = false @Published private(set) var error: String? = nil @Published private(set) var clientId: String = "" private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyAuth") private let tokensKey = "spotify_tokens" private let clientIdKey = "spotify_client_id" private var codeVerifier: String? = nil private var authSession: ASWebAuthenticationSession? = nil var isConfigured: Bool { !clientId.isEmpty } override init() { super.init() clientId = UserDefaults.standard.string(forKey: clientIdKey) ?? "" loadTokens() } func setClientId(_ id: String) { let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) clientId = trimmed UserDefaults.standard.set(trimmed, forKey: clientIdKey) logger.info("Spotify Client ID updated") } var accessToken: String? { loadStoredTokens()?.accessToken } func startAuth() { guard !isAuthenticating else { return } isAuthenticating = true error = nil let verifier = generateCodeVerifier() codeVerifier = verifier let challenge = generateCodeChallenge(verifier: verifier) var components = URLComponents(string: SpotifyConfig.authUrl)! components.queryItems = [ URLQueryItem(name: "client_id", value: clientId), URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "redirect_uri", value: SpotifyConfig.redirectUri), URLQueryItem(name: "scope", value: SpotifyConfig.scopes), URLQueryItem(name: "code_challenge_method", value: "S256"), URLQueryItem(name: "code_challenge", value: challenge) ] guard let authUrl = components.url else { error = "Failed to build auth URL" isAuthenticating = false return } let session = ASWebAuthenticationSession( url: authUrl, callbackURLScheme: "iris-spotify-auth" ) { [weak self] callbackUrl, authError in Task { @MainActor in self?.handleAuthCallback(callbackUrl: callbackUrl, error: authError) } } session.presentationContextProvider = self session.prefersEphemeralWebBrowserSession = false authSession = session if !session.start() { error = "Failed to start auth session" isAuthenticating = false } } func handleCallback(url: URL) { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { error = "Invalid callback URL" isAuthenticating = false return } Task { await exchangeCodeForTokens(code: code) } } func disconnect() { KeychainHelper.delete(key: tokensKey) isConnected = false logger.info("Disconnected from Spotify") } func refreshTokenIfNeeded() async -> Bool { guard let tokens = loadStoredTokens() else { return false } if !tokens.expiresWithinMinutes { return true } return await refreshToken(refreshToken: tokens.refreshToken) } private func handleAuthCallback(callbackUrl: URL?, error authError: Error?) { isAuthenticating = false authSession = nil if let authError = authError as? ASWebAuthenticationSessionError, authError.code == .canceledLogin { logger.info("User cancelled Spotify login") return } if let authError { error = authError.localizedDescription return } guard let callbackUrl else { error = "No callback URL received" return } handleCallback(url: callbackUrl) } private func exchangeCodeForTokens(code: String) async { guard let verifier = codeVerifier else { error = "Missing code verifier" isAuthenticating = false return } let body = [ "grant_type": "authorization_code", "code": code, "redirect_uri": SpotifyConfig.redirectUri, "client_id": clientId, "code_verifier": verifier ] do { let tokenResponse = try await postTokenRequest(body: body) let tokens = SpotifyTokens( accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken ?? "", expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)) ) try saveTokens(tokens) isConnected = true logger.info("Successfully authenticated with Spotify") } catch { self.error = "Token exchange failed: \(error.localizedDescription)" logger.error("Token exchange failed: \(error.localizedDescription)") } codeVerifier = nil isAuthenticating = false } private func refreshToken(refreshToken: String) async -> Bool { let body = [ "grant_type": "refresh_token", "refresh_token": refreshToken, "client_id": clientId ] do { let tokenResponse = try await postTokenRequest(body: body) let newRefreshToken = tokenResponse.refreshToken ?? refreshToken let tokens = SpotifyTokens( accessToken: tokenResponse.accessToken, refreshToken: newRefreshToken, expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)) ) try saveTokens(tokens) logger.info("Refreshed Spotify token") return true } catch { logger.error("Token refresh failed: \(error.localizedDescription)") return false } } private func postTokenRequest(body: [String: String]) async throws -> SpotifyTokenResponse { var request = URLRequest(url: URL(string: SpotifyConfig.tokenUrl)!) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let bodyString = body.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" } .joined(separator: "&") request.httpBody = bodyString.data(using: .utf8) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 throw SpotifyAuthError.tokenRequestFailed(statusCode) } return try JSONDecoder().decode(SpotifyTokenResponse.self, from: data) } private func loadTokens() { isConnected = loadStoredTokens() != nil } private func loadStoredTokens() -> SpotifyTokens? { guard let data = KeychainHelper.load(key: tokensKey) else { return nil } return try? JSONDecoder().decode(SpotifyTokens.self, from: data) } private func saveTokens(_ tokens: SpotifyTokens) throws { let data = try JSONEncoder().encode(tokens) try KeychainHelper.save(key: tokensKey, data: data) } private func generateCodeVerifier() -> String { var bytes = [UInt8](repeating: 0, count: 32) _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) return Data(bytes).base64URLEncodedString() } private func generateCodeChallenge(verifier: String) -> String { guard let data = verifier.data(using: .utf8) else { return "" } var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) data.withUnsafeBytes { buffer in _ = CC_SHA256(buffer.baseAddress, CC_LONG(data.count), &hash) } return Data(hash).base64URLEncodedString() } enum SpotifyAuthError: Error { case tokenRequestFailed(Int) } } @available(iOS 16.0, *) extension SpotifyAuthManager: ASWebAuthenticationPresentationContextProviding { nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { ASPresentationAnchor() } } extension Data { func base64URLEncodedString() -> String { base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } }