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 | 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 | 94 | /// Switch to another stored account. Returns false if its token is |
| 82 | 95 | /// gone from the Keychain, in which case the account is dropped too. |
| 83 | 96 | @discardableResult |
| 84 | 97 | func activate(_ account: Account) -> Bool { |
| 85 | 98 | guard let token = store.token(for: account.id) else { |
| 86 | | remove(account) |
| 99 | removeAccountState(account) |
| 87 | 100 | return false |
| 88 | 101 | } |
| 89 | 102 | activate(account, token: token) |
| @@ -112,7 +125,12 @@ final class SessionStore { |
| 112 | 125 | |
| 113 | 126 | /// Forget an account: token out of the Keychain, account out of the |
| 114 | 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 | 134 | store.deleteToken(for: account.id) |
| 117 | 135 | accounts.removeAll { $0.id == account.id } |
| 118 | 136 | try? store.saveAccounts(accounts) |
| @@ -127,7 +145,7 @@ final class SessionStore { |
| 127 | 145 | /// The server said 401: the token is expired or revoked. Sign the |
| 128 | 146 | /// account out cleanly and say why — never crash, never loop. |
| 129 | 147 | func expire(_ account: Account) { |
| 130 | | remove(account) |
| 148 | removeAccountState(account) |
| 131 | 149 | if current == nil { |
| 132 | 150 | signedOutMessage = |
| 133 | 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 | 280 | NavigationLink("Add Account", value: RepoRoute.addAccount) |
| 281 | 281 | if let current = session.current { |
| 282 | 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 @@ |
| 1 | import Foundation |
| 2 | import Testing |
| 3 | @testable import gitbay |
| 4 | |
| 5 | private let whoami = """ |
| 6 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} |
| 7 | """ |
| 8 | |
| 9 | @MainActor |
| 10 | private 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 |
| 21 | private 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 | 131 | try await session.signIn(instanceURL: "https://forge.example", token: "gb_two") |
| 132 | 132 | let active = try #require(session.current) |
| 133 | 133 | |
| 134 | | session.remove(active) |
| 134 | await session.remove(active) |
| 135 | 135 | |
| 136 | 136 | #expect(session.current?.instance.baseURL.host() == "gitbay.org") |
| 137 | 137 | #expect(session.accounts.count == 1) |