KeychainTokenStore.swift 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import Foundation
  2. import Security
  3. /// Stores OAuth tokens in the macOS Data Protection keychain (stable across Xcode rebuilds).
  4. final class KeychainTokenStore {
  5. private let service: String
  6. private let account: String
  7. private let useDataProtectionKeychain: Bool
  8. init(
  9. service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
  10. account: String = "googleOAuthTokens",
  11. useDataProtectionKeychain: Bool = true
  12. ) {
  13. self.service = service
  14. self.account = account
  15. self.useDataProtectionKeychain = useDataProtectionKeychain
  16. }
  17. private func makeKeychainError(status: OSStatus, message: String) -> NSError {
  18. let statusMessage = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)"
  19. return NSError(
  20. domain: NSOSStatusErrorDomain,
  21. code: Int(status),
  22. userInfo: [
  23. NSLocalizedDescriptionKey: message,
  24. NSLocalizedFailureReasonErrorKey: statusMessage,
  25. "keychainStatus": Int(status),
  26. "keychainStatusMessage": statusMessage,
  27. "keychainService": service,
  28. "keychainAccount": account
  29. ]
  30. )
  31. }
  32. private func baseQuery() -> [String: Any] {
  33. var query: [String: Any] = [
  34. kSecClass as String: kSecClassGenericPassword,
  35. kSecAttrService as String: service,
  36. kSecAttrAccount as String: account
  37. ]
  38. if useDataProtectionKeychain {
  39. query[kSecUseDataProtectionKeychain as String] = true
  40. }
  41. return query
  42. }
  43. func readTokens() throws -> GoogleOAuthTokens? {
  44. var query = baseQuery()
  45. query[kSecReturnData as String] = true
  46. query[kSecMatchLimit as String] = kSecMatchLimitOne
  47. var result: CFTypeRef?
  48. let status = SecItemCopyMatching(query as CFDictionary, &result)
  49. if status == errSecItemNotFound { return nil }
  50. guard status == errSecSuccess else {
  51. throw makeKeychainError(status: status, message: "Failed to read OAuth tokens from Keychain.".localized)
  52. }
  53. guard let data = result as? Data else {
  54. throw NSError(
  55. domain: NSOSStatusErrorDomain,
  56. code: Int(errSecDecode),
  57. userInfo: [NSLocalizedDescriptionKey: "Keychain returned an invalid token payload.".localized]
  58. )
  59. }
  60. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  61. }
  62. /// Reads tokens written by older builds in the legacy file-based login keychain.
  63. func readLegacyFileBasedTokens() throws -> GoogleOAuthTokens? {
  64. guard useDataProtectionKeychain else { return nil }
  65. var query: [String: Any] = [
  66. kSecClass as String: kSecClassGenericPassword,
  67. kSecAttrService as String: service,
  68. kSecAttrAccount as String: account,
  69. kSecReturnData as String: true,
  70. kSecMatchLimit as String: kSecMatchLimitOne
  71. ]
  72. var result: CFTypeRef?
  73. let status = SecItemCopyMatching(query as CFDictionary, &result)
  74. if status == errSecItemNotFound { return nil }
  75. guard status == errSecSuccess else { return nil }
  76. guard let data = result as? Data else { return nil }
  77. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  78. }
  79. func writeTokens(_ tokens: GoogleOAuthTokens) throws {
  80. let data = try JSONEncoder().encode(tokens)
  81. let baseQuery = baseQuery()
  82. let attributesToUpdate: [String: Any] = [
  83. kSecValueData as String: data
  84. ]
  85. let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributesToUpdate as CFDictionary)
  86. if updateStatus == errSecSuccess { return }
  87. if updateStatus != errSecItemNotFound {
  88. throw makeKeychainError(status: updateStatus, message: "Failed to update OAuth tokens in Keychain.".localized)
  89. }
  90. var addQuery = baseQuery
  91. addQuery[kSecValueData as String] = data
  92. addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
  93. let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
  94. guard addStatus == errSecSuccess else {
  95. throw makeKeychainError(status: addStatus, message: "Failed to save OAuth tokens to Keychain.".localized)
  96. }
  97. }
  98. func deleteTokens() throws {
  99. let status = SecItemDelete(baseQuery() as CFDictionary)
  100. guard status == errSecSuccess || status == errSecItemNotFound else {
  101. throw makeKeychainError(status: status, message: "Failed to delete OAuth tokens from Keychain.".localized)
  102. }
  103. }
  104. func deleteLegacyFileBasedTokens() {
  105. guard useDataProtectionKeychain else { return }
  106. var query: [String: Any] = [
  107. kSecClass as String: kSecClassGenericPassword,
  108. kSecAttrService as String: service,
  109. kSecAttrAccount as String: account
  110. ]
  111. _ = SecItemDelete(query as CFDictionary)
  112. }
  113. }