bugger/Sources/Services/Feishu/FeishuAuthService.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.debug("\(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "?")")
if let body = request.httpBody, let bodyStr = String(data: body, encoding: .utf8) {
BuggerLog.debug(" body: \(bodyStr)")
}
if let auth = request.value(forHTTPHeaderField: "Authorization") {
BuggerLog.debug(" 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.debug("\(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.debug(" decoded as \(T.self)")
return decoded
} catch {
BuggerLog.error(" decode failed: \(error.localizedDescription)")
BuggerLog.error(" raw body: \(bodyStr.prefix(1000))")
throw FeishuError.decodingError(error)
}
}
}