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))