auth: a 401 anywhere signs the account out cleanly !2
3 files changed, +62 −2
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)) |