153 lines
6.4 KiB
Swift
153 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 {
|
|
BuggerLog.info("Exchanging auth code (len=\(code.count)) for user token...")
|
|
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,
|
|
] as [String: String])
|
|
let tokenData: OAuthTokenData = try await send(request)
|
|
BuggerLog.info("Got token: access=\(tokenData.accessToken.prefix(8))..., refresh=\(tokenData.refreshToken?.prefix(8) ?? "nil")..., expires=\(tokenData.expiresIn)")
|
|
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")
|
|
request.httpBody = try JSONEncoder().encode([
|
|
"app_id": appId,
|
|
"app_secret": appSecret
|
|
])
|
|
|
|
let response: FeishuAPIResponse<TenantAccessTokenData> = 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 send<T: Decodable>(_ request: URLRequest) async throws -> T {
|
|
BuggerLog.info("→ \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "?")")
|
|
if let body = request.httpBody, let bodyStr = String(data: body, encoding: .utf8) {
|
|
BuggerLog.info(" body: \(bodyStr)")
|
|
}
|
|
if let auth = request.value(forHTTPHeaderField: "Authorization") {
|
|
BuggerLog.info(" auth: \(auth.prefix(20))...")
|
|
}
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
BuggerLog.error("← not an HTTP response")
|
|
throw FeishuError.networkError(nil)
|
|
}
|
|
|
|
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary \(data.count)B>"
|
|
BuggerLog.info("← \(httpResponse.statusCode) \(bodyStr.prefix(500))")
|
|
|
|
if httpResponse.statusCode == 401 {
|
|
BuggerLog.error("← 401 Unauthorized")
|
|
throw FeishuError.unauthorized
|
|
}
|
|
|
|
do {
|
|
let decoded = try decoder.decode(T.self, from: data)
|
|
BuggerLog.info(" decoded as \(T.self) ✓")
|
|
return decoded
|
|
} catch {
|
|
BuggerLog.error(" decode failed: \(error.localizedDescription)")
|
|
BuggerLog.error(" raw body: \(bodyStr.prefix(1000))")
|
|
throw FeishuError.decodingError(error)
|
|
}
|
|
}
|
|
}
|