|
|
@@ -1,20 +1,22 @@
|
|
|
import Foundation
|
|
|
import Security
|
|
|
|
|
|
-/// Stores OAuth tokens in macOS Keychain.
|
|
|
+/// 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 accessGroup: String?
|
|
|
+ private let useDataProtectionKeychain: Bool
|
|
|
|
|
|
- init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
|
|
|
- account: String = "googleOAuthTokens",
|
|
|
- accessGroup: String? = nil) {
|
|
|
+ init(
|
|
|
+ service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
|
|
|
+ account: String = "googleOAuthTokens",
|
|
|
+ useDataProtectionKeychain: Bool = true
|
|
|
+ ) {
|
|
|
self.service = service
|
|
|
self.account = account
|
|
|
- self.accessGroup = accessGroup
|
|
|
+ self.useDataProtectionKeychain = useDataProtectionKeychain
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
private func makeKeychainError(status: OSStatus, message: String) -> NSError {
|
|
|
let statusMessage = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)"
|
|
|
return NSError(
|
|
|
@@ -26,50 +28,66 @@ final class KeychainTokenStore {
|
|
|
"keychainStatus": Int(status),
|
|
|
"keychainStatusMessage": statusMessage,
|
|
|
"keychainService": service,
|
|
|
- "keychainAccount": account,
|
|
|
- "keychainAccessGroup": accessGroup as Any
|
|
|
+ "keychainAccount": account
|
|
|
]
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- func readTokens() throws -> GoogleOAuthTokens? {
|
|
|
+ private func baseQuery() -> [String: Any] {
|
|
|
var query: [String: Any] = [
|
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
|
kSecAttrService as String: service,
|
|
|
- kSecAttrAccount as String: account,
|
|
|
- kSecReturnData as String: true,
|
|
|
- kSecMatchLimit as String: kSecMatchLimitOne
|
|
|
+ kSecAttrAccount as String: account
|
|
|
]
|
|
|
- if let accessGroup {
|
|
|
- query[kSecAttrAccessGroup as String] = accessGroup
|
|
|
+ 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)
|
|
|
+ throw makeKeychainError(status: status, message: "Failed to read OAuth tokens from Keychain.")
|
|
|
}
|
|
|
guard let data = result as? Data else {
|
|
|
throw NSError(
|
|
|
domain: NSOSStatusErrorDomain,
|
|
|
code: Int(errSecDecode),
|
|
|
- userInfo: [NSLocalizedDescriptionKey: "Keychain returned an invalid token payload.".localized]
|
|
|
+ userInfo: [NSLocalizedDescriptionKey: "Keychain returned an invalid token payload."]
|
|
|
)
|
|
|
}
|
|
|
return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
|
|
|
}
|
|
|
|
|
|
- func writeTokens(_ tokens: GoogleOAuthTokens) throws {
|
|
|
- let data = try JSONEncoder().encode(tokens)
|
|
|
- var baseQuery: [String: Any] = [
|
|
|
+ /// 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
|
|
|
+ kSecAttrAccount as String: account,
|
|
|
+ kSecReturnData as String: true,
|
|
|
+ kSecMatchLimit as String: kSecMatchLimitOne
|
|
|
]
|
|
|
- if let accessGroup {
|
|
|
- baseQuery[kSecAttrAccessGroup as String] = accessGroup
|
|
|
- }
|
|
|
+
|
|
|
+ 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
|
|
|
@@ -78,7 +96,7 @@ final class KeychainTokenStore {
|
|
|
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)
|
|
|
+ throw makeKeychainError(status: updateStatus, message: "Failed to update OAuth tokens in Keychain.")
|
|
|
}
|
|
|
|
|
|
var addQuery = baseQuery
|
|
|
@@ -86,24 +104,24 @@ final class KeychainTokenStore {
|
|
|
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)
|
|
|
+ throw makeKeychainError(status: addStatus, message: "Failed to save OAuth tokens to Keychain.")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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.")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func deleteLegacyFileBasedTokens() {
|
|
|
+ guard useDataProtectionKeychain else { return }
|
|
|
var query: [String: Any] = [
|
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
|
kSecAttrService as String: service,
|
|
|
kSecAttrAccount as String: account
|
|
|
]
|
|
|
- if let accessGroup {
|
|
|
- query[kSecAttrAccessGroup as String] = accessGroup
|
|
|
- }
|
|
|
-
|
|
|
- let status = SecItemDelete(query as CFDictionary)
|
|
|
- guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
|
- throw makeKeychainError(status: status, message: "Failed to delete OAuth tokens from Keychain.".localized)
|
|
|
- }
|
|
|
+ _ = SecItemDelete(query as CFDictionary)
|
|
|
}
|
|
|
}
|
|
|
-
|