import Foundation @Observable final class TokenManager { static let shared = TokenManager() private let keychain = KeychainHelper.shared let authService = FeishuAuthService() private let accessTokenKey = "feishu.access_token" private let refreshTokenKey = "feishu.refresh_token" private let tokenExpiryKey = "feishu.token_expiry" private let openIdKey = "feishu.open_id" 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 } /// The user's Feishu open_id, cached after the first OAuth callback or /// user-info fetch. Used for server-side filtering of Person-type fields /// on the Feishu search endpoint. Nil if the user hasn't authenticated. var cachedOpenId: String? { keychain.read(openIdKey) } func storeOpenId(_ openId: String) { guard !openId.isEmpty else { return } keychain.write(openId, forKey: openIdKey) } 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), !refreshToken.isEmpty 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) if !refresh.isEmpty { keychain.write(refresh, forKey: refreshTokenKey) } else { keychain.delete(refreshTokenKey) } let expiry = Date().addingTimeInterval(TimeInterval(expiresIn)) UserDefaults.standard.set(expiry, forKey: tokenExpiryKey) state = .authenticated } func clearTokens() { keychain.delete(accessTokenKey) keychain.delete(refreshTokenKey) keychain.delete(openIdKey) UserDefaults.standard.removeObject(forKey: tokenExpiryKey) state = .unauthenticated } func setError(_ message: String) { state = .error(message) } func handleCallback(code: String) async throws { guard !code.isEmpty else { BuggerLog.error("handleCallback: empty code") throw TokenError.invalidCallback } BuggerLog.info("handleCallback: exchanging code...") state = .authenticating let response = try await authService.exchangeCode(code) BuggerLog.info("handleCallback: got tokens, storing...") storeTokens( access: response.accessToken, refresh: response.refreshToken ?? "", expiresIn: response.expiresIn ) // Fetch and cache the user's open_id for server-side record filtering. if let userInfo = try? await authService.fetchUserInfo(accessToken: response.accessToken), let openId = userInfo.openId { storeOpenId(openId) } state = .authenticated BuggerLog.info("handleCallback: done, authenticated ✓") } func resolveAssigneeName(config: AppConfig, accessToken: String) async throws -> String { if !config.assigneeName.isEmpty { return config.assigneeName } let userInfo = try await authService.fetchUserInfo(accessToken: accessToken) if let openId = userInfo.openId { storeOpenId(openId) } return userInfo.name ?? userInfo.enName ?? "Unknown" } } 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." } } }