bugger/Sources/Services/TokenManager.swift

128 lines
3.7 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) 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)
keychain.write(refresh, forKey: 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 {
throw TokenError.invalidCallback
}
state = .authenticating
let response = try await authService.exchangeCode(code)
storeTokens(
access: response.accessToken,
refresh: response.refreshToken ?? "",
expiresIn: response.expiresIn
)
state = .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."
}
}
}