| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127 |
- import Foundation
- import Security
- /// Stores OAuth tokens in the macOS Data Protection keychain (stable across Xcode rebuilds).
- final class KeychainTokenStore {
- private let service: String
- private let account: String
- private let useDataProtectionKeychain: Bool
- init(
- service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
- account: String = "googleOAuthTokens",
- useDataProtectionKeychain: Bool = true
- ) {
- self.service = service
- self.account = account
- self.useDataProtectionKeychain = useDataProtectionKeychain
- }
- private func makeKeychainError(status: OSStatus, message: String) -> NSError {
- let statusMessage = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)"
- return NSError(
- domain: NSOSStatusErrorDomain,
- code: Int(status),
- userInfo: [
- NSLocalizedDescriptionKey: message,
- NSLocalizedFailureReasonErrorKey: statusMessage,
- "keychainStatus": Int(status),
- "keychainStatusMessage": statusMessage,
- "keychainService": service,
- "keychainAccount": account
- ]
- )
- }
- private func baseQuery() -> [String: Any] {
- var query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account
- ]
- if useDataProtectionKeychain {
- query[kSecUseDataProtectionKeychain as String] = true
- }
- return query
- }
- func readTokens() throws -> GoogleOAuthTokens? {
- var query = baseQuery()
- query[kSecReturnData as String] = true
- query[kSecMatchLimit as String] = kSecMatchLimitOne
- var result: CFTypeRef?
- let status = SecItemCopyMatching(query as CFDictionary, &result)
- if status == errSecItemNotFound { return nil }
- guard status == errSecSuccess else {
- throw makeKeychainError(status: status, message: "Failed to read OAuth tokens from Keychain.".localized)
- }
- guard let data = result as? Data else {
- throw NSError(
- domain: NSOSStatusErrorDomain,
- code: Int(errSecDecode),
- userInfo: [NSLocalizedDescriptionKey: "Keychain returned an invalid token payload.".localized]
- )
- }
- return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
- }
- /// Reads tokens written by older builds in the legacy file-based login keychain.
- func readLegacyFileBasedTokens() throws -> GoogleOAuthTokens? {
- guard useDataProtectionKeychain else { return nil }
- var query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account,
- kSecReturnData as String: true,
- kSecMatchLimit as String: kSecMatchLimitOne
- ]
- var result: CFTypeRef?
- let status = SecItemCopyMatching(query as CFDictionary, &result)
- if status == errSecItemNotFound { return nil }
- guard status == errSecSuccess else { return nil }
- guard let data = result as? Data else { return nil }
- return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
- }
- func writeTokens(_ tokens: GoogleOAuthTokens) throws {
- let data = try JSONEncoder().encode(tokens)
- let baseQuery = baseQuery()
- let attributesToUpdate: [String: Any] = [
- kSecValueData as String: data
- ]
- let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributesToUpdate as CFDictionary)
- if updateStatus == errSecSuccess { return }
- if updateStatus != errSecItemNotFound {
- throw makeKeychainError(status: updateStatus, message: "Failed to update OAuth tokens in Keychain.".localized)
- }
- var addQuery = baseQuery
- addQuery[kSecValueData as String] = data
- addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
- let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
- guard addStatus == errSecSuccess else {
- throw makeKeychainError(status: addStatus, message: "Failed to save OAuth tokens to Keychain.".localized)
- }
- }
- func deleteTokens() throws {
- let status = SecItemDelete(baseQuery() as CFDictionary)
- guard status == errSecSuccess || status == errSecItemNotFound else {
- throw makeKeychainError(status: status, message: "Failed to delete OAuth tokens from Keychain.".localized)
- }
- }
- func deleteLegacyFileBasedTokens() {
- guard useDataProtectionKeychain else { return }
- var query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account
- ]
- _ = SecItemDelete(query as CFDictionary)
- }
- }
|