137 lines
4.1 KiB
Swift
137 lines
4.1 KiB
Swift
import Foundation
|
|
|
|
@Observable
|
|
final class TokenManager {
|
|
static let shared = TokenManager()
|
|
|
|
private let keychain = KeychainHelper.shared
|
|
let authService = FeishuAuthService()
|
|
|
|
private let accessTokenKey = "feishu.access_token"
|
|
private let refreshTokenKey = "feishu.refresh_token"
|
|
private let tokenExpiryKey = "feishu.token_expiry"
|
|
|
|
enum State: Equatable {
|
|
case unauthenticated
|
|
case authenticating
|
|
case authenticated
|
|
case refreshing
|
|
case error(String)
|
|
}
|
|
|
|
private(set) var state: State = .unauthenticated
|
|
|
|
private init() {
|
|
if keychain.read(accessTokenKey) != nil {
|
|
state = .authenticated
|
|
}
|
|
}
|
|
|
|
var isAuthenticated: Bool {
|
|
if case .authenticated = state { return true }
|
|
if keychain.read(accessTokenKey) != nil { return true }
|
|
return false
|
|
}
|
|
|
|
var authorizeURL: URL? {
|
|
guard authService.hasCredentials else { return nil }
|
|
return authService.authorizeURL
|
|
}
|
|
|
|
func getAccessToken() async throws -> String {
|
|
if let token = keychain.read(accessTokenKey),
|
|
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
|
|
expiry > Date().addingTimeInterval(60) {
|
|
state = .authenticated
|
|
return token
|
|
}
|
|
|
|
guard let refreshToken = keychain.read(refreshTokenKey),
|
|
!refreshToken.isEmpty else {
|
|
state = .unauthenticated
|
|
throw TokenError.noRefreshToken
|
|
}
|
|
|
|
state = .refreshing
|
|
do {
|
|
let response = try await authService.refreshAccessToken(refreshToken)
|
|
storeTokens(
|
|
access: response.accessToken,
|
|
refresh: response.refreshToken ?? refreshToken,
|
|
expiresIn: response.expiresIn
|
|
)
|
|
state = .authenticated
|
|
return response.accessToken
|
|
} catch {
|
|
state = .error(error.localizedDescription)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func storeTokens(access: String, refresh: String, expiresIn: Int) {
|
|
keychain.write(access, forKey: accessTokenKey)
|
|
if !refresh.isEmpty {
|
|
keychain.write(refresh, forKey: refreshTokenKey)
|
|
} else {
|
|
keychain.delete(refreshTokenKey)
|
|
}
|
|
let expiry = Date().addingTimeInterval(TimeInterval(expiresIn))
|
|
UserDefaults.standard.set(expiry, forKey: tokenExpiryKey)
|
|
state = .authenticated
|
|
}
|
|
|
|
func clearTokens() {
|
|
keychain.delete(accessTokenKey)
|
|
keychain.delete(refreshTokenKey)
|
|
UserDefaults.standard.removeObject(forKey: tokenExpiryKey)
|
|
state = .unauthenticated
|
|
}
|
|
|
|
func setError(_ message: String) {
|
|
state = .error(message)
|
|
}
|
|
|
|
func handleCallback(code: String) async throws {
|
|
guard !code.isEmpty else {
|
|
BuggerLog.error("handleCallback: empty code")
|
|
throw TokenError.invalidCallback
|
|
}
|
|
BuggerLog.info("handleCallback: exchanging code...")
|
|
state = .authenticating
|
|
let response = try await authService.exchangeCode(code)
|
|
BuggerLog.info("handleCallback: got tokens, storing...")
|
|
storeTokens(
|
|
access: response.accessToken,
|
|
refresh: response.refreshToken ?? "",
|
|
expiresIn: response.expiresIn
|
|
)
|
|
state = .authenticated
|
|
BuggerLog.info("handleCallback: done, authenticated ✓")
|
|
}
|
|
|
|
func resolveAssigneeName(config: AppConfig, accessToken: String) async throws -> String {
|
|
if !config.assigneeName.isEmpty {
|
|
return config.assigneeName
|
|
}
|
|
return try await authService.fetchCurrentUserName(accessToken: accessToken)
|
|
}
|
|
|
|
}
|
|
|
|
enum TokenError: Error, LocalizedError {
|
|
case noRefreshToken
|
|
case invalidCallback
|
|
case refreshFailed
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noRefreshToken:
|
|
return "No Feishu session found. Please connect Feishu."
|
|
case .invalidCallback:
|
|
return "Invalid OAuth callback URL."
|
|
case .refreshFailed:
|
|
return "Failed to refresh Feishu token."
|
|
}
|
|
}
|
|
}
|