bugger/Sources/Services/Feishu/FeishuAuthService.swift

150 lines
6.4 KiB
Swift

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")
// Use manual JSON to rule out JSONEncoder issues
let jsonBody = "{\"grant_type\":\"authorization_code\",\"code\":\"\(code)\"}"
request.httpBody = jsonBody.data(using: .utf8)
print("[BUGGER] exchangeCode: sending to \(url.absoluteString)...")
let tokenData: OAuthTokenData = try await send(request)
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")
request.httpBody = try JSONEncoder().encode([
"grant_type": "refresh_token",
"refresh_token": refreshToken,
] as [String: String])
// Token endpoints return data at top level, not wrapped in {code, msg, data}
let tokenData: OAuthTokenData = try await send(request)
return tokenData
}
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<UserInfoData> = 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")
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<T: Decodable>(_ 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) ?? "<binary \(data.count)B>"
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)
}
}
}