bugger/Sources/Services/TokenManager.swift

209 lines
6.8 KiB
Swift

import AppKit
import CoreGraphics
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"
private let openIdKey = "feishu.open_id"
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
}
enum KeychainGate {
case ready
case locked
case unavailable
}
/// The login-keychain item is tied to the code signature that wrote it.
/// An ad-hoc rebuild cannot read it until the user clicks Allow. The main
/// thread has to stay free so that dialog can appear. A locked screen
/// cannot present it at all.
@MainActor
func approveKeychainIfNeeded() async -> KeychainGate {
if isAuthenticated { return .ready }
guard keychain.needsApproval(accessTokenKey) else { return .unavailable }
if Self.screenIsLocked() {
return .locked
}
BuggerLog.info("Keychain approval required for the Feishu token")
let previous = NSApp.activationPolicy()
NSApp.setActivationPolicy(.regular)
NSApp.activate()
try? await Task.sleep(nanoseconds: 300_000_000)
let key = accessTokenKey
let helper = keychain
let token = await Task.detached {
helper.readPrompting(key)
}.value
if previous == .accessory {
NSApp.setActivationPolicy(.accessory)
}
if token != nil {
state = .authenticated
BuggerLog.info("Keychain approval granted")
return .ready
}
BuggerLog.error("Keychain approval was not granted")
return .unavailable
}
private static func screenIsLocked() -> Bool {
guard let raw = CGSessionCopyCurrentDictionary() else { return false }
let value = (raw as NSDictionary)["CGSSessionScreenIsLocked"]
if let locked = value as? Bool { return locked }
if let number = value as? NSNumber { return number.intValue != 0 }
return false
}
var authorizeURL: URL? {
guard authService.hasCredentials else { return nil }
return authService.authorizeURL
}
/// The user's Feishu open_id, cached after the first OAuth callback or
/// user-info fetch. Used for server-side filtering of Person-type fields
/// on the Feishu search endpoint. Nil if the user hasn't authenticated.
var cachedOpenId: String? {
keychain.read(openIdKey)
}
func storeOpenId(_ openId: String) {
guard !openId.isEmpty else { return }
keychain.write(openId, forKey: openIdKey)
}
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)
keychain.delete(openIdKey)
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
)
// Fetch and cache the user's open_id for server-side record filtering.
if let userInfo = try? await authService.fetchUserInfo(accessToken: response.accessToken),
let openId = userInfo.openId {
storeOpenId(openId)
}
state = .authenticated
BuggerLog.info("handleCallback: done, authenticated ✓")
}
func resolveAssigneeName(config: AppConfig, accessToken: String) async throws -> String {
if !config.assigneeName.isEmpty {
return config.assigneeName
}
let userInfo = try await authService.fetchUserInfo(accessToken: accessToken)
if let openId = userInfo.openId {
storeOpenId(openId)
}
return userInfo.name ?? userInfo.enName ?? "Unknown"
}
}
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."
}
}
}