gitbay/Auth/SessionStore.swift

v1.6.0
gitbay-ios/gitbay/Auth/SessionStore.swift history · blame · raw

195 lines · 8030 bytes

  1import Foundation
  2import Observation
  3
  4/// Which accounts exist, which one is active, and the client for it.
  5///
  6/// Signing in validates the pasted token with `whoami` before anything is
  7/// stored, so a stored account is always one that worked at least once. A
  8/// revoked token later comes back as 401 on some request; `expire` turns
  9/// that into a clean sign-out with a plain sentence on the sign-in screen.
 10@Observable
 11@MainActor
 12final class SessionStore {
 13
 14    private(set) var accounts: [Account]
 15    private(set) var current: Account?
 16    private(set) var client: GitbayClient?
 17
 18    /// Why the user is looking at the sign-in screen, when there is a
 19    /// reason beyond never having signed in.
 20    private(set) var signedOutMessage: String?
 21
 22    private let store: any TokenStore
 23    private let makeClient: @Sendable (GitbayInstance, String) -> GitbayClient
 24
 25    /// The active account survives relaunch in UserDefaults. An account id
 26    /// is not a secret — the token it points at stays in the Keychain.
 27    private let defaults: UserDefaults
 28    private var currentAccountID: String {
 29        get { defaults.string(forKey: "currentAccountID") ?? "" }
 30        set { defaults.set(newValue, forKey: "currentAccountID") }
 31    }
 32
 33    init(
 34        store: any TokenStore = KeychainTokenStore(),
 35        defaults: UserDefaults = .standard,
 36        makeClient: @escaping @Sendable (GitbayInstance, String) -> GitbayClient = {
 37            GitbayClient(instance: $0, token: $1)
 38        }
 39    ) {
 40        self.store = store
 41        self.makeClient = makeClient
 42        self.defaults = defaults
 43        self.accounts = store.loadAccounts()
 44        restore()
 45    }
 46
 47    private func restore() {
 48        let candidate = accounts.first { $0.id == currentAccountID } ?? accounts.first
 49        guard let candidate, let token = store.token(for: candidate.id) else {
 50            current = nil
 51            client = nil
 52            return
 53        }
 54        activate(candidate, token: token)
 55    }
 56
 57    /// Validate a pasted token against an instance and store the account.
 58    /// Throws `GitbayError` with a user-facing message on every failure.
 59    func signIn(instanceURL: String, token: String) async throws {
 60        let instance = try GitbayInstance(url: instanceURL)
 61        let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
 62        guard !trimmed.isEmpty else {
 63            throw GitbayError.unauthorized("empty token")
 64        }
 65        let candidate = makeClient(instance, trimmed)
 66        let who = try await candidate.read(["whoami"], as: WhoamiResponse.self)
 67
 68        let account = Account(instance: instance, username: who.username)
 69        try store.saveToken(trimmed, for: account.id)
 70        if !accounts.contains(account) {
 71            accounts.append(account)
 72            try store.saveAccounts(accounts)
 73        }
 74        current = account
 75        client = candidate
 76        currentAccountID = account.id
 77        signedOutMessage = nil
 78        watchForRevocation(candidate, account: account)
 79        await didAddAccount?(account)
 80    }
 81
 82    /// A client for any signed-in account, active or not. Push
 83    /// registration talks to every account on the device, and `store`
 84    /// and `makeClient` are private. Vending does not change `current`.
 85    func client(for account: Account) -> GitbayClient? {
 86        guard let token = store.token(for: account.id) else { return nil }
 87        return makeClient(account.instance, token)
 88    }
 89
 90    /// Runs before an account is removed, while its token still exists.
 91    /// `gitbayApp` points this at push deregistration; this type knows
 92    /// nothing about push beyond that something wants to run first.
 93    var willRemoveAccount: ((Account) async -> Void)?
 94
 95    /// Runs after an account signs in, with its token stored. The
 96    /// counterpart to `willRemoveAccount`: an account added while the
 97    /// app runs has no push device row until something registers it,
 98    /// and nothing else would until the next cold launch.
 99    var didAddAccount: ((Account) async -> Void)?
100
101    /// Switch to another stored account. Returns false if its token is
102    /// gone from the Keychain, in which case the account is dropped too.
103    @discardableResult
104    func activate(_ account: Account) -> Bool {
105        guard let token = store.token(for: account.id) else {
106            // Bypasses willRemoveAccount deliberately: store.token(for:)
107            // just returned nil, so there is no credential left to
108            // deregister a device with.
109            removeAccountState(account)
110            return false
111        }
112        activate(account, token: token)
113        return true
114    }
115
116    private func activate(_ account: Account, token: String) {
117        current = account
118        let client = makeClient(account.instance, token)
119        self.client = client
120        currentAccountID = account.id
121        watchForRevocation(client, account: account)
122    }
123
124    /// Any 401 from this account's client — some screen, any time —
125    /// becomes a clean sign-out. Attached only after a token has worked
126    /// once, so sign-in's own failures stay on the sign-in screen.
127    private func watchForRevocation(_ client: GitbayClient, account: Account) {
128        client.onUnauthorized { [weak self] in
129            Task { @MainActor [weak self] in
130                guard let self, self.accounts.contains(account) else { return }
131                self.expire(account)
132            }
133        }
134    }
135
136    /// Forget an account: token out of the Keychain, account out of the
137    /// list, and if it was active, over to the next one or signed out.
138    func remove(_ account: Account) async {
139        await willRemoveAccount?(account)
140        removeAccountState(account)
141    }
142
143    /// The state change behind `remove`, minus the hook. `remove` is the
144    /// only path that awaits `willRemoveAccount` — this does NOT, on
145    /// purpose, and every other caller of this method is an intentional
146    /// exemption from that hook, commented at the call site with why a
147    /// deregistration attempt there could not succeed anyway. Do not
148    /// call this from a new site without asking the same question: does
149    /// the account's token still work here? If it might, route through
150    /// `remove` instead so the hook gets a chance to run.
151    private func removeAccountState(_ account: Account) {
152        store.deleteToken(for: account.id)
153        accounts.removeAll { $0.id == account.id }
154        try? store.saveAccounts(accounts)
155        if current?.id == account.id {
156            current = nil
157            client = nil
158            currentAccountID = ""
159            restore()
160        }
161    }
162
163    /// The server said 401: the token is expired or revoked. Sign the
164    /// account out cleanly and say why — never crash, never loop.
165    ///
166    /// Bypasses willRemoveAccount deliberately: expire only runs after
167    /// the server has already rejected this token with a 401 (via
168    /// watchForRevocation or handle's requiresReauthentication check),
169    /// so a deregistration call carrying that same token would be
170    /// rejected the same way. The device row this account's push
171    /// registration left on the server survives this and is not
172    /// cleaned up here — the user clears it from the web settings page,
173    /// same as any other stale device.
174    func expire(_ account: Account) {
175        removeAccountState(account)
176        if current == nil {
177            signedOutMessage =
178                "The token for \(account.label) is no longer valid. Mint a new one and sign in again."
179        }
180    }
181
182    /// Route an error from any screen: a 401 expires the current account;
183    /// everything else is the caller's to show.
184    func handle(_ error: any Error) {
185        guard let error = error as? GitbayError, error.requiresReauthentication,
186              let current else {
187            return
188        }
189        expire(current)
190    }
191
192    nonisolated private struct WhoamiResponse: Decodable, Sendable {
193        let username: String
194    }
195}