134 lines
5.2 KiB
Swift
134 lines
5.2 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 redirectURI = "bugger://oauth/callback"
|
|
private let session: URLSession
|
|
private let decoder: JSONDecoder
|
|
|
|
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")
|
|
]
|
|
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
|
|
])
|
|
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<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 decodeAPIResponse<T: Decodable>(_ request: URLRequest) async throws -> T {
|
|
let response: FeishuAPIResponse<T> = 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<T: Decodable>(_ 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)
|
|
}
|
|
}
|
|
}
|