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