gitbay/Auth/SessionStore.swift
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}