gitbay/Auth/TokenStore.swift
98 lines · 3385 bytes
1import Foundation
2@preconcurrency import Security
3
4/// Persistence for accounts and their tokens. The Keychain implementation
5/// is the real one; tests use an in-memory stand-in so no test touches the
6/// host Keychain.
7nonisolated protocol TokenStore: Sendable {
8 func saveToken(_ token: String, for accountID: String) throws
9 func token(for accountID: String) -> String?
10 func deleteToken(for accountID: String)
11 func saveAccounts(_ accounts: [Account]) throws
12 func loadAccounts() -> [Account]
13}
14
15enum TokenStoreError: LocalizedError, Sendable {
16 case keychain(OSStatus)
17
18 var errorDescription: String? {
19 switch self {
20 case .keychain(let status):
21 "Could not store the token securely (\(status))."
22 }
23 }
24}
25
26/// Tokens and the account list live in the Keychain and nowhere else —
27/// nothing git tracks, nothing in UserDefaults, no iCloud sync
28/// (ThisDeviceOnly): a token names this phone, not the account.
29nonisolated struct KeychainTokenStore: TokenStore {
30
31 private static let service = "org.gitbay.gitbay"
32 private static let accountsKey = "accounts"
33
34 func saveToken(_ token: String, for accountID: String) throws {
35 try save(Data(token.utf8), account: "token." + accountID)
36 }
37
38 func token(for accountID: String) -> String? {
39 load(account: "token." + accountID).flatMap { String(data: $0, encoding: .utf8) }
40 }
41
42 func deleteToken(for accountID: String) {
43 delete(account: "token." + accountID)
44 }
45
46 func saveAccounts(_ accounts: [Account]) throws {
47 try save(JSONEncoder().encode(accounts), account: Self.accountsKey)
48 }
49
50 func loadAccounts() -> [Account] {
51 guard let data = load(account: Self.accountsKey),
52 let accounts = try? JSONDecoder().decode([Account].self, from: data) else {
53 return []
54 }
55 return accounts
56 }
57
58 // MARK: - SecItem
59
60 private func save(_ data: Data, account: String) throws {
61 delete(account: account)
62 let query: [String: Any] = [
63 kSecClass as String: kSecClassGenericPassword,
64 kSecAttrService as String: Self.service,
65 kSecAttrAccount as String: account,
66 kSecValueData as String: data,
67 kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
68 ]
69 let status = SecItemAdd(query as CFDictionary, nil)
70 guard status == errSecSuccess else {
71 throw TokenStoreError.keychain(status)
72 }
73 }
74
75 private func load(account: String) -> Data? {
76 let query: [String: Any] = [
77 kSecClass as String: kSecClassGenericPassword,
78 kSecAttrService as String: Self.service,
79 kSecAttrAccount as String: account,
80 kSecReturnData as String: true,
81 kSecMatchLimit as String: kSecMatchLimitOne,
82 ]
83 var result: AnyObject?
84 guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
85 return nil
86 }
87 return result as? Data
88 }
89
90 private func delete(account: String) {
91 let query: [String: Any] = [
92 kSecClass as String: kSecClassGenericPassword,
93 kSecAttrService as String: Self.service,
94 kSecAttrAccount as String: account,
95 ]
96 SecItemDelete(query as CFDictionary)
97 }
98}