a native ios client for gitbay

client ios swift

https://gitbay.org

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}