Commit 9e8931e4c5

9e8931e4c5571f5ede0bd91cd17fa79ab4231f5c

parent: c8d3f71d5d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 22:10 UTC

auth: vend clients per account, announce removals

Push registration talks to every signed-in account, not just the
active one, and deregistration needs the token remove() discards.

Ref krz/gitbay#89

Layout: unified · split

gitbay/Auth/SessionStore.swift +21 −3
@@ -78,12 +78,25 @@ final class SessionStore {
78 watchForRevocation(candidate, account: account) 78 watchForRevocation(candidate, account: account)
79 } 79 }
80 80
81 /// A client for any signed-in account, active or not. Push
82 /// registration talks to every account on the device, and `store`
83 /// and `makeClient` are private. Vending does not change `current`.
84 func client(for account: Account) -> GitbayClient? {
85 guard let token = store.token(for: account.id) else { return nil }
86 return makeClient(account.instance, token)
87 }
88
89 /// Runs before an account is removed, while its token still exists.
90 /// `gitbayApp` points this at push deregistration; this type knows
91 /// nothing about push beyond that something wants to run first.
92 var willRemoveAccount: ((Account) async -> Void)?
93
81 /// Switch to another stored account. Returns false if its token is 94 /// Switch to another stored account. Returns false if its token is
82 /// gone from the Keychain, in which case the account is dropped too. 95 /// gone from the Keychain, in which case the account is dropped too.
83 @discardableResult 96 @discardableResult
84 func activate(_ account: Account) -> Bool { 97 func activate(_ account: Account) -> Bool {
85 guard let token = store.token(for: account.id) else { 98 guard let token = store.token(for: account.id) else {
86 remove(account) 99 removeAccountState(account)
87 return false 100 return false
88 } 101 }
89 activate(account, token: token) 102 activate(account, token: token)
@@ -112,7 +125,12 @@ final class SessionStore {
112 125
113 /// Forget an account: token out of the Keychain, account out of the 126 /// 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. 127 /// list, and if it was active, over to the next one or signed out.
115 func remove(_ account: Account) { 128 func remove(_ account: Account) async {
129 await willRemoveAccount?(account)
130 removeAccountState(account)
131 }
132
133 private func removeAccountState(_ account: Account) {
116 store.deleteToken(for: account.id) 134 store.deleteToken(for: account.id)
117 accounts.removeAll { $0.id == account.id } 135 accounts.removeAll { $0.id == account.id }
118 try? store.saveAccounts(accounts) 136 try? store.saveAccounts(accounts)
@@ -127,7 +145,7 @@ final class SessionStore {
127 /// The server said 401: the token is expired or revoked. Sign the 145 /// The server said 401: the token is expired or revoked. Sign the
128 /// account out cleanly and say why — never crash, never loop. 146 /// account out cleanly and say why — never crash, never loop.
129 func expire(_ account: Account) { 147 func expire(_ account: Account) {
130 remove(account) 148 removeAccountState(account)
131 if current == nil { 149 if current == nil {
132 signedOutMessage = 150 signedOutMessage =
133 "The token for \(account.label) is no longer valid. Mint a new one and sign in again." 151 "The token for \(account.label) is no longer valid. Mint a new one and sign in again."
gitbay/Views/Repos/RepoListView.swift +1 −1
@@ -280,7 +280,7 @@ struct AccountMenu: ToolbarContent {
280 NavigationLink("Add Account", value: RepoRoute.addAccount) 280 NavigationLink("Add Account", value: RepoRoute.addAccount)
281 if let current = session.current { 281 if let current = session.current {
282 Button("Sign Out", role: .destructive) { 282 Button("Sign Out", role: .destructive) {
283 session.remove(current) 283 Task { await session.remove(current) }
284 } 284 }
285 } 285 }
286 } 286 }
gitbayTests/SessionClientTests.swift added +59
@@ -0,0 +1,59 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private let whoami = """
6 {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
7 """
8
9@MainActor
10private func makeSession() -> (SessionStore, StubProtocol.Box) {
11 let box = StubProtocol.box()
12 let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")!
13 let session = SessionStore(store: MemoryTokenStore(), defaults: defaults) { instance, token in
14 GitbayClient(instance: instance, token: token, session: box.session())
15 }
16 return (session, box)
17}
18
19/// Signs two accounts in, on different instances.
20@MainActor
21private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) {
22 let (session, box) = makeSession()
23 box.enqueue(.init(status: 200, json: whoami))
24 try await session.signIn(instanceURL: "https://gitbay.org", token: "t1")
25 box.enqueue(.init(status: 200, json: whoami))
26 try await session.signIn(instanceURL: "https://dev.local", token: "t2")
27 return (session, box)
28}
29
30@Test @MainActor func sessionVendsAClientForAnyStoredAccount() async throws {
31 let (session, _) = try await twoAccounts()
32 let inactive = try #require(session.accounts.first { $0.id != session.current?.id })
33 #expect(session.client(for: inactive) != nil)
34 // Vending does not change which account is active.
35 #expect(session.current?.id != inactive.id)
36}
37
38@Test @MainActor func sessionVendsNothingForAnUnknownAccount() async throws {
39 let (session, _) = try await twoAccounts()
40 let stranger = Account(instance: try GitbayInstance(url: "https://nowhere.example"), username: "x")
41 #expect(session.client(for: stranger) == nil)
42}
43
44// The hook runs before the token is discarded: deregistering a device
45// needs the credential remove() is about to delete.
46@Test @MainActor func removeAwaitsTheHookBeforeDiscardingTheToken() async throws {
47 let (session, _) = try await twoAccounts()
48 let victim = try #require(session.accounts.first)
49 var sawTokenDuringHook: Bool?
50 // [weak session] because this closure is stored ON session; the
51 // production wiring in gitbayApp captures the registrar weakly for
52 // the same reason.
53 session.willRemoveAccount = { [weak session] account in
54 sawTokenDuringHook = session?.client(for: account) != nil
55 }
56 await session.remove(victim)
57 #expect(sawTokenDuringHook == true)
58 #expect(session.client(for: victim) == nil)
59}
gitbayTests/SessionStoreTests.swift +1 −1
@@ -131,7 +131,7 @@ struct SessionStoreTests {
131 try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") 131 try await session.signIn(instanceURL: "https://forge.example", token: "gb_two")
132 let active = try #require(session.current) 132 let active = try #require(session.current)
133 133
134 session.remove(active) 134 await session.remove(active)
135 135
136 #expect(session.current?.instance.baseURL.host() == "gitbay.org") 136 #expect(session.current?.instance.baseURL.host() == "gitbay.org")
137 #expect(session.accounts.count == 1) 137 #expect(session.accounts.count == 1)