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 { print("[BUGGER] exchangeCode: start, code len=\(code.count)") let tenantToken = try await tenantAccessToken() print("[BUGGER] exchangeCode: got tenantToken=\(tenantToken.prefix(8))..., building request...") 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") let jsonBody = "{\"grant_type\":\"authorization_code\",\"code\":\"\(code)\"}" request.httpBody = jsonBody.data(using: .utf8) print("[BUGGER] exchangeCode: sending to \(url.absoluteString)...") // OAuth token response is wrapped in {code, message, data: {...}} let response: FeishuAPIResponse = try await send(request) guard response.code == 0, let tokenData = response.data else { throw FeishuError.apiError(code: response.code, message: response.msg) } print("[BUGGER] exchangeCode: SUCCESS access=\(tokenData.accessToken.prefix(8))... refresh=\(tokenData.refreshToken?.prefix(8) ?? "nil")...") return tokenData } 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") let jsonBody = "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"\(refreshToken)\"}" request.httpBody = jsonBody.data(using: .utf8) // Uses same {code, message, data: {...}} wrapper as exchangeCode let response: FeishuAPIResponse = try await send(request) guard response.code == 0, let tokenData = response.data else { throw FeishuError.apiError(code: response.code, message: response.msg) } return tokenData } func fetchCurrentUserName(accessToken: String) async throws -> String { let info = try await fetchUserInfo(accessToken: accessToken) return info.name ?? info.enName ?? "Unknown" } func fetchUserInfo(accessToken: String) async throws -> UserInfoData { 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 } 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") let body: [String: String] = ["app_id": appId, "app_secret": appSecret] request.httpBody = try JSONEncoder().encode(body) // This endpoint returns code/msg/tenant_access_token/expire all at top level let data: TenantAccessTokenData = try await send(request) guard data.code == 0 else { throw FeishuError.apiError(code: data.code, message: data.msg) } cachedTenantToken = data.tenantAccessToken tenantTokenExpiry = Date().addingTimeInterval(TimeInterval(data.expire)) return data.tenantAccessToken } private func send(_ request: URLRequest) async throws -> T { print("[BUGGER] → \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "?")") if let body = request.httpBody, let bodyStr = String(data: body, encoding: .utf8) { print("[BUGGER] body: \(bodyStr)") } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { print("[BUGGER] ← not an HTTP response") throw FeishuError.networkError(nil) } let bodyStr = String(data: data, encoding: .utf8) ?? "" print("[BUGGER] ← \(httpResponse.statusCode) \(bodyStr.prefix(500))") if httpResponse.statusCode == 401 { print("[BUGGER] ← 401 Unauthorized") throw FeishuError.unauthorized } do { let decoded = try decoder.decode(T.self, from: data) print("[BUGGER] decoded as \(T.self) ✓") return decoded } catch { print("[BUGGER] decode FAILED: \(error)") print("[BUGGER] raw body: \(bodyStr.prefix(1000))") throw FeishuError.decodingError(error) } } }