import Foundation @Observable final class TokenManager { static let shared = TokenManager() private let keychain = KeychainHelper.shared private let authService = FeishuAuthService() private let accessTokenKey = "feishu.access_token" private let refreshTokenKey = "feishu.refresh_token" private let tokenExpiryKey = "feishu.token_expiry" enum State: Equatable { case unauthenticated case authenticating case authenticated case refreshing case error(String) } private(set) var state: State = .unauthenticated private init() { if keychain.read(accessTokenKey) != nil { state = .authenticated } } var isAuthenticated: Bool { if case .authenticated = state { return true } if keychain.read(accessTokenKey) != nil { return true } return false } var authorizeURL: URL? { guard authService.hasCredentials else { return nil } return authService.authorizeURL } func getAccessToken() async throws -> String { if let token = keychain.read(accessTokenKey), let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date, expiry > Date().addingTimeInterval(60) { state = .authenticated return token } guard let refreshToken = keychain.read(refreshTokenKey) else { state = .unauthenticated throw TokenError.noRefreshToken } state = .refreshing do { let response = try await authService.refreshAccessToken(refreshToken) storeTokens( access: response.accessToken, refresh: response.refreshToken ?? refreshToken, expiresIn: response.expiresIn ) state = .authenticated return response.accessToken } catch { state = .error(error.localizedDescription) throw error } } func storeTokens(access: String, refresh: String, expiresIn: Int) { keychain.write(access, forKey: accessTokenKey) keychain.write(refresh, forKey: refreshTokenKey) let expiry = Date().addingTimeInterval(TimeInterval(expiresIn)) UserDefaults.standard.set(expiry, forKey: tokenExpiryKey) state = .authenticated } func clearTokens() { keychain.delete(accessTokenKey) keychain.delete(refreshTokenKey) UserDefaults.standard.removeObject(forKey: tokenExpiryKey) state = .unauthenticated } func setError(_ message: String) { state = .error(message) } func handleCallback(url: URL) async throws { guard let code = extractCode(from: url) else { throw TokenError.invalidCallback } state = .authenticating let response = try await authService.exchangeCode(code) storeTokens( access: response.accessToken, refresh: response.refreshToken ?? "", expiresIn: response.expiresIn ) state = .authenticated } func resolveAssigneeName(config: AppConfig, accessToken: String) async throws -> String { if !config.assigneeName.isEmpty { return config.assigneeName } return try await authService.fetchCurrentUserName(accessToken: accessToken) } private func extractCode(from url: URL) -> String? { guard url.scheme == "bugger", url.host == "oauth", url.path == "/callback" else { return nil } return URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems? .first(where: { $0.name == "code" })? .value } } enum TokenError: Error, LocalizedError { case noRefreshToken case invalidCallback case refreshFailed var errorDescription: String? { switch self { case .noRefreshToken: return "No Feishu session found. Please connect Feishu." case .invalidCallback: return "Invalid OAuth callback URL." case .refreshFailed: return "Failed to refresh Feishu token." } } }