Commit 809306694d
Verified · cmc
Layout: unified · split
gitbay/Auth/SessionStore.swift +20
| @@ -96,6 +96,9 @@ final class SessionStore { | ||
| 96 | 96 | @discardableResult |
| 97 | 97 | func activate(_ account: Account) -> Bool { |
| 98 | 98 | guard let token = store.token(for: account.id) else { |
| 99 | // Bypasses willRemoveAccount deliberately: store.token(for:) | |
| 100 | // just returned nil, so there is no credential left to | |
| 101 | // deregister a device with. | |
| 99 | 102 | removeAccountState(account) |
| 100 | 103 | return false |
| 101 | 104 | } |
| @@ -130,6 +133,14 @@ final class SessionStore { | ||
| 130 | 133 | removeAccountState(account) |
| 131 | 134 | } |
| 132 | 135 | |
| 136 | /// The state change behind `remove`, minus the hook. `remove` is the | |
| 137 | /// only path that awaits `willRemoveAccount` — this does NOT, on | |
| 138 | /// purpose, and every other caller of this method is an intentional | |
| 139 | /// exemption from that hook, commented at the call site with why a | |
| 140 | /// deregistration attempt there could not succeed anyway. Do not | |
| 141 | /// call this from a new site without asking the same question: does | |
| 142 | /// the account's token still work here? If it might, route through | |
| 143 | /// `remove` instead so the hook gets a chance to run. | |
| 133 | 144 | private func removeAccountState(_ account: Account) { |
| 134 | 145 | store.deleteToken(for: account.id) |
| 135 | 146 | accounts.removeAll { $0.id == account.id } |
| @@ -144,6 +155,15 @@ final class SessionStore { | ||
| 144 | 155 | |
| 145 | 156 | /// The server said 401: the token is expired or revoked. Sign the |
| 146 | 157 | /// account out cleanly and say why — never crash, never loop. |
| 158 | /// | |
| 159 | /// Bypasses willRemoveAccount deliberately: expire only runs after | |
| 160 | /// the server has already rejected this token with a 401 (via | |
| 161 | /// watchForRevocation or handle's requiresReauthentication check), | |
| 162 | /// so a deregistration call carrying that same token would be | |
| 163 | /// rejected the same way. The device row this account's push | |
| 164 | /// registration left on the server survives this and is not | |
| 165 | /// cleaned up here — the user clears it from the web settings page, | |
| 166 | /// same as any other stale device. | |
| 147 | 167 | func expire(_ account: Account) { |
| 148 | 168 | removeAccountState(account) |
| 149 | 169 | if current == nil { |
gitbayTests/SessionClientTests.swift +18
| @@ -57,3 +57,21 @@ private func twoAccounts() async throws -> (SessionStore, StubProtocol.Box) { | ||
| 57 | 57 | #expect(sawTokenDuringHook == true) |
| 58 | 58 | #expect(session.client(for: victim) == nil) |
| 59 | 59 | } |
| 60 | ||
| 61 | // expire is reached only after the server has already rejected the | |
| 62 | // account's token with a 401 (revocation, or handle's | |
| 63 | // requiresReauthentication path), so a deregistration call carrying | |
| 64 | // that token would fail the same way. It deliberately does not run | |
| 65 | // the hook; the stale device row is cleared from the web settings | |
| 66 | // page instead. See the comment on expire(_:) in SessionStore. | |
| 67 | @Test @MainActor func expireDoesNotRunTheRemovalHook() async throws { | |
| 68 | let (session, _) = try await twoAccounts() | |
| 69 | let account = try #require(session.current) | |
| 70 | var hookRan = false | |
| 71 | session.willRemoveAccount = { _ in hookRan = true } | |
| 72 | ||
| 73 | session.handle(GitbayError.unauthorized("invalid or expired token")) | |
| 74 | ||
| 75 | #expect(hookRan == false) | |
| 76 | #expect(session.accounts.contains(account) == false) | |
| 77 | } | |