209 lines
6.8 KiB
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."
|
|
}
|
|
}
|
|
}
|