Commit 98eceac833
98eceac8334ecabf7add61e9d8465825733f30b5
parent: bdf7296bc6
Unsigned
cmc <hello@cleberg.net> · 2026-08-27 05:40 UTC
auth: a 401 anywhere signs the account out cleanly
SessionStore.handle existed but nothing called it, so a revoked token
showed error banners on every screen without ever signing out. The
client now carries an onUnauthorized hook fired on any 401; the session
attaches it when an account is activated, so revocation from any screen
becomes the clean sign-out with the explanation on the sign-in screen.
The sign-in probe client has no hook — a mistyped token stays a local
error.
Verified live: launched with a genuinely revoked token, first dashboard
request met the 401, landed signed out with the message.
Ref #11
Layout: unified · split
gitbay/Auth/SessionStore.swift
+16 −1
| @@ -75,6 +75,7 @@ final class SessionStore { |
| 75 | client = candidate |
75 | client = candidate |
| 76 | currentAccountID = account.id |
76 | currentAccountID = account.id |
| 77 | signedOutMessage = nil |
77 | signedOutMessage = nil |
| |
78 | watchForRevocation(candidate, account: account) |
| 78 | } |
79 | } |
| 79 | |
80 | |
| 80 | /// Switch to another stored account. Returns false if its token is |
81 | /// Switch to another stored account. Returns false if its token is |
| @@ -91,8 +92,22 @@ final class SessionStore { |
| 91 | |
92 | |
| 92 | private func activate(_ account: Account, token: String) { |
93 | private func activate(_ account: Account, token: String) { |
| 93 | current = account |
94 | current = account |
| 94 | client = makeClient(account.instance, token) |
95 | let client = makeClient(account.instance, token) |
| |
96 | self.client = client |
| 95 | currentAccountID = account.id |
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 | } |
| 96 | } |
111 | } |
| 97 | |
112 | |
| 98 | /// Forget an account: token out of the Keychain, account out of the |
113 | /// Forget an account: token out of the Keychain, account out of the |
gitbay/Networking/GitbayClient.swift
+11 −1
| @@ -26,6 +26,14 @@ nonisolated final class GitbayClient: Sendable { |
| 26 | private let redirectGuard: RedirectGuard |
26 | private let redirectGuard: RedirectGuard |
| 27 | /// Cache keys are scoped by account without holding the token. |
27 | /// Cache keys are scoped by account without holding the token. |
| 28 | private let accountKey: String |
28 | private let accountKey: String |
| |
29 | /// Fired on any 401 — the token is expired or revoked. The session |
| |
30 | /// layer attaches this after sign-in succeeds, so the sign-in probe's |
| |
31 | /// own 401 (a mistyped token) never signs anyone out. |
| |
32 | private let unauthorizedHandler = OSAllocatedUnfairLock<(@Sendable () -> Void)?>(initialState: nil) |
| |
33 | |
| |
34 | func onUnauthorized(_ handler: @escaping @Sendable () -> Void) { |
| |
35 | unauthorizedHandler.withLock { $0 = handler } |
| |
36 | } |
| 29 | |
37 | |
| 30 | init( |
38 | init( |
| 31 | instance: GitbayInstance, |
39 | instance: GitbayInstance, |
| @@ -270,7 +278,9 @@ nonisolated final class GitbayClient: Sendable { |
| 270 | guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else { |
278 | guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else { |
| 271 | switch status { |
279 | switch status { |
| 272 | case 200: return nil |
280 | case 200: return nil |
| 273 | case 401: return .unauthorized(message) |
281 | case 401: |
| |
282 | unauthorizedHandler.withLock { $0 }?() |
| |
283 | return .unauthorized(message) |
| 274 | case 403: return .denied(message) |
284 | case 403: return .denied(message) |
| 275 | case 404: return .notFound(message) |
285 | case 404: return .notFound(message) |
| 276 | case 429: return .rateLimited(retryAfter: 0) |
286 | case 429: return .rateLimited(retryAfter: 0) |
gitbayTests/SessionStoreTests.swift
+35
| @@ -150,6 +150,41 @@ struct SessionStoreTests { |
| 150 | #expect(second.client != nil) |
150 | #expect(second.client != nil) |
| 151 | } |
151 | } |
| 152 | |
152 | |
| |
153 | @Test func aRevokedTokenOnAnyLaterRequestSignsOutCleanly() async throws { |
| |
154 | let (session, store, stub) = makeSession() |
| |
155 | stub.enqueue(.init(status: 200, json: whoamiCMC)) |
| |
156 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret") |
| |
157 | let account = try #require(session.current) |
| |
158 | let client = try #require(session.client) |
| |
159 | stub.enqueue(.init(status: 401, json: badToken)) |
| |
160 | |
| |
161 | // Some screen's read, long after sign-in, meets the revocation. |
| |
162 | await #expect(throws: GitbayError.self) { |
| |
163 | _ = try await client.readList(["repo", "list"], of: RepoSummary.self) |
| |
164 | } |
| |
165 | // The sign-out hops through the main actor; give it a beat. |
| |
166 | try await Task.sleep(for: .milliseconds(200)) |
| |
167 | |
| |
168 | #expect(session.current == nil) |
| |
169 | #expect(session.client == nil) |
| |
170 | #expect(store.tokenCount == 0) |
| |
171 | let message = try #require(session.signedOutMessage) |
| |
172 | #expect(message.contains(account.username)) |
| |
173 | } |
| |
174 | |
| |
175 | @Test func aRejectedSignInDoesNotTripTheRevocationWatcher() async throws { |
| |
176 | let (session, _, stub) = makeSession() |
| |
177 | stub.enqueue(.init(status: 401, json: badToken)) |
| |
178 | |
| |
179 | await #expect(throws: GitbayError.self) { |
| |
180 | try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong") |
| |
181 | } |
| |
182 | try await Task.sleep(for: .milliseconds(200)) |
| |
183 | |
| |
184 | // A bad pasted token is a sign-in error, not a revocation notice. |
| |
185 | #expect(session.signedOutMessage == nil) |
| |
186 | } |
| |
187 | |
| 153 | @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws { |
188 | @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws { |
| 154 | let (session, store, stub) = makeSession() |
189 | let (session, store, stub) = makeSession() |
| 155 | stub.enqueue(.init(status: 200, json: whoamiCMC)) |
190 | stub.enqueue(.init(status: 200, json: whoamiCMC)) |