a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Auth/SessionStore.swift

ui-smoke
gitbay-ios/gitbay/Auth/SessionStore.swift history · blame · raw

150 lines · 5554 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    }
 80
 81    /// Switch to another stored account. Returns false if its token is
 82    /// gone from the Keychain, in which case the account is dropped too.
 83    @discardableResult
 84    func activate(_ account: Account) -> Bool {
 85        guard let token = store.token(for: account.id) else {
 86            remove(account)
 87            return false
 88        }
 89        activate(account, token: token)
 90        return true
 91    }
 92
 93    private func activate(_ account: Account, token: String) {
 94        current = account
 95        let client = makeClient(account.instance, token)
 96        self.client = client
 97        currentAccountID = account.id
 98        watchForRevocation(client, account: account)
 99    }
100
101    /// Any 401 from this account's client  some screen, any time 
102    /// becomes a clean sign-out. Attached only after a token has worked
103    /// once, so sign-in's own failures stay on the sign-in screen.
104    private func watchForRevocation(_ client: GitbayClient, account: Account) {
105        client.onUnauthorized { [weak self] in
106            Task { @MainActor [weak self] in
107                guard let self, self.accounts.contains(account) else { return }
108                self.expire(account)
109            }
110        }
111    }
112
113    /// Forget an account: token out of the Keychain, account out of the
114    /// list, and if it was active, over to the next one or signed out.
115    func remove(_ account: Account) {
116        store.deleteToken(for: account.id)
117        accounts.removeAll { $0.id == account.id }
118        try? store.saveAccounts(accounts)
119        if current?.id == account.id {
120            current = nil
121            client = nil
122            currentAccountID = ""
123            restore()
124        }
125    }
126
127    /// The server said 401: the token is expired or revoked. Sign the
128    /// account out cleanly and say why  never crash, never loop.
129    func expire(_ account: Account) {
130        remove(account)
131        if current == nil {
132            signedOutMessage =
133                "The token for \(account.label) is no longer valid. Mint a new one and sign in again."
134        }
135    }
136
137    /// Route an error from any screen: a 401 expires the current account;
138    /// everything else is the caller's to show.
139    func handle(_ error: any Error) {
140        guard let error = error as? GitbayError, error.requiresReauthentication,
141              let current else {
142            return
143        }
144        expire(current)
145    }
146
147    nonisolated private struct WhoamiResponse: Decodable, Sendable {
148        let username: String
149    }
150}