import Foundation final class FeishuAuthService { private let baseURL = "https://open.feishu.cn/open-apis" private let appId: String private let appSecret: String private let session: URLSession private let decoder: JSONDecoder /// The redirect URI used for the current OAuth flow. Set before calling /// `authorizeURL` by the caller (OAuthSetupView) after starting the local /// server so the port matches. var redirectURI: String = "http://127.0.0.1:18923/callback" private var cachedTenantToken: String? private var tenantTokenExpiry: Date? init(session: URLSession = .shared) { self.session = session let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder = decoder let id = Bundle.main.object(forInfoDictionaryKey: "FEISHU_APP_ID") as? String ?? "" let secret = Bundle.main.object(forInfoDictionaryKey: "FEISHU_APP_SECRET") as? String ?? "" self.appId = id self.appSecret = secret } var hasCredentials: Bool { !appId.isEmpty && !appSecret.isEmpty && appId != "$(FEISHU_APP_ID)" } var authorizeURL: URL { var components = URLComponents(string: "\(baseURL)/authen/v1/authorize")! components.queryItems = [ URLQueryItem(name: "app_id", value: appId), URLQueryItem(name: "redirect_uri", value: redirectURI), URLQueryItem(name: "scope", value: "bitable:app:readonly offline_access") ] return components.url! } func exchangeCode(_ code: String) async throws -> OAuthTokenData { let tenantToken = try await tenantAccessToken() let url = URL(string: "\(baseURL)/authen/v1/oidc/access_token")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(tenantToken)", forHTTPHeaderField: "Authorization") request.httpBody = try JSONEncoder().encode([ "grant_type": "authorization_code", "code": code, "redirect_uri": redirectURI, ] as [String: String]) return try await decodeAPIResponse(request) } func refreshAccessToken(_ refreshToken: String) async throws -> OAuthTokenData { let tenantToken = try await tenantAccessToken() let url = URL(string: "\(baseURL)/authen/v1/oidc/refresh_access_token")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(tenantToken)", forHTTPHeaderField: "Authorization") request.httpBody = try JSONEncoder().encode([ "grant_type": "refresh_token", "refresh_token": refreshToken ]) return try await decodeAPIResponse(request) } func fetchCurrentUserName(accessToken: String) async throws -> String { let url = URL(string: "\(baseURL)/authen/v1/user_info")! var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let response: FeishuAPIResponse = try await send(request) guard let data = response.data else { throw FeishuError.apiError(code: response.code, message: response.msg) } return data.name ?? data.enName ?? "Unknown" } private func tenantAccessToken() async throws -> String { if let cachedTenantToken, let tenantTokenExpiry, tenantTokenExpiry > Date().addingTimeInterval(60) { return cachedTenantToken } guard hasCredentials else { throw FeishuError.missingCredentials } let url = URL(string: "\(baseURL)/auth/v3/tenant_access_token/internal")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode([ "app_id": appId, "app_secret": appSecret ]) let response: FeishuAPIResponse = try await send(request) guard let data = response.data else { throw FeishuError.apiError(code: response.code, message: response.msg) } cachedTenantToken = data.tenantAccessToken tenantTokenExpiry = Date().addingTimeInterval(TimeInterval(data.expire)) return data.tenantAccessToken } private func decodeAPIResponse(_ request: URLRequest) async throws -> T { let response: FeishuAPIResponse = try await send(request) guard response.code == 0, let data = response.data else { throw FeishuError.apiError(code: response.code, message: response.msg) } return data } private func send(_ request: URLRequest) async throws -> T { let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } do { return try decoder.decode(T.self, from: data) } catch { throw FeishuError.decodingError(error) } } }