Commit 74d095160f
74d095160fa4fd3b3f9aa31606db4f5c6eb18be3
parent: d48039b69f
Verified · cmc
cmc <hello@cleberg.net> · 2026-08-27T05:40:45Z
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
gitbay/Auth/SessionStore.swift
+16 −1
| @@ -75,6 +75,7 @@ final class SessionStore { |
| 75 | 75 | client = candidate |
| 76 | 76 | currentAccountID = account.id |
| 77 | 77 | signedOutMessage = nil |
| 78 | watchForRevocation(candidate, account: account) |
| 78 | 79 | } |
| 79 | 80 | |
| 80 | 81 | /// Switch to another stored account. Returns false if its token is |
| @@ -91,8 +92,22 @@ final class SessionStore { |
| 91 | 92 | |
| 92 | 93 | private func activate(_ account: Account, token: String) { |
| 93 | 94 | current = account |
| 94 | | client = makeClient(account.instance, token) |
| 95 | let client = makeClient(account.instance, token) |
| 96 | self.client = client |
| 95 | 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 | 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 | 26 | private let redirectGuard: RedirectGuard |
| 27 | 27 | /// Cache keys are scoped by account without holding the token. |
| 28 | 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 | 38 | init( |
| 31 | 39 | instance: GitbayInstance, |
| @@ -270,7 +278,9 @@ nonisolated final class GitbayClient: Sendable { |
| 270 | 278 | guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else { |
| 271 | 279 | switch status { |
| 272 | 280 | case 200: return nil |
| 273 | | case 401: return .unauthorized(message) |
| 281 | case 401: |
| 282 | unauthorizedHandler.withLock { $0 }?() |
| 283 | return .unauthorized(message) |
| 274 | 284 | case 403: return .denied(message) |
| 275 | 285 | case 404: return .notFound(message) |
| 276 | 286 | case 429: return .rateLimited(retryAfter: 0) |
gitbayTests/SessionStoreTests.swift
+35
| @@ -150,6 +150,41 @@ struct SessionStoreTests { |
| 150 | 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 | 188 | @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws { |
| 154 | 189 | let (session, store, stub) = makeSession() |
| 155 | 190 | stub.enqueue(.init(status: 200, json: whoamiCMC)) |