Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Sources/RxAuthSwift/OAuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@ public final class OAuthManager: Sendable {
} else if tokenStorage.getRefreshToken() != nil {
do {
try await refreshTokenIfNeeded()
startTokenRefreshTimer()
logger.info("Refreshed token from existing session")
} catch {
logger.warning("Token refresh failed: \(error.localizedDescription)")
// Ensure stale tokens are fully cleared so that subsequent
// calls to checkExistingAuth() don't retry a failed refresh.
try? tokenStorage.clearAll()
authState = .unauthenticated
Comment on lines +54 to 57

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the refresh-token path, the catch block uses try? tokenStorage.clearAll(). Since KeychainTokenStorage.clearAll() can throw before deleting the refresh token (it short-circuits on the first delete error), ignoring the error here can still leave a stale refresh token behind and allow checkExistingAuth() to retry/loop. Consider reusing the same best-effort cleanup as logout() (attempt individual deletions when clearAll() fails), or call await logout() here to guarantee refresh-token removal.

Suggested change
// Ensure stale tokens are fully cleared so that subsequent
// calls to checkExistingAuth() don't retry a failed refresh.
try? tokenStorage.clearAll()
authState = .unauthenticated
// Use the same best-effort cleanup as logout() to ensure
// the refresh token is removed and avoid repeated retries.
await logout()

Copilot uses AI. Check for mistakes.
}
} else {
Expand Down Expand Up @@ -86,6 +90,10 @@ public final class OAuthManager: Sendable {
try tokenStorage.clearAll()
} catch {
logger.error("Failed to clear token storage: \(error.localizedDescription)")
// Attempt to clear individual tokens even if clearAll() fails,
// so that a partial failure doesn't leave stale tokens behind.
try? tokenStorage.deleteAccessToken()
try? tokenStorage.deleteRefreshToken()

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logout()'s fallback cleanup deletes the access/refresh tokens, but it never removes the stored expiration value (KeychainTokenStorage stores it under a separate expires_at key). If clearAll() failed after partially deleting tokens, this can leave a stale expiresAt entry in Keychain. A low-impact fix is to retry clearAll() after the individual deletions (so expires_at gets cleaned up when possible), or extend the protocol with a dedicated expiresAt deletion method.

Suggested change
try? tokenStorage.deleteRefreshToken()
try? tokenStorage.deleteRefreshToken()
// Retry clearAll() to clean up any remaining metadata keys (e.g., expires_at).
try? tokenStorage.clearAll()

Copilot uses AI. Check for mistakes.
}

currentUser = nil
Expand Down
318 changes: 318 additions & 0 deletions Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
import Foundation
import Testing
@testable import RxAuthSwift

// MARK: - Mock Token Storage (clearAll fails but individual deletes work)

private final class ClearAllFailingTokenStorage: TokenStorageProtocol, @unchecked Sendable {
private let lock = NSLock()
private var accessToken: String?
private var refreshToken: String?
private var expiresAt: Date?

func saveAccessToken(_ token: String) throws {
lock.lock()
defer { lock.unlock() }
accessToken = token
}

func getAccessToken() -> String? {
lock.lock()
defer { lock.unlock() }
return accessToken
}

func deleteAccessToken() throws {
lock.lock()
defer { lock.unlock() }
accessToken = nil
}

func saveRefreshToken(_ token: String) throws {
lock.lock()
defer { lock.unlock() }
refreshToken = token
}

func getRefreshToken() -> String? {
lock.lock()
defer { lock.unlock() }
return refreshToken
}

func deleteRefreshToken() throws {
lock.lock()
defer { lock.unlock() }
refreshToken = nil
}

func saveExpiresAt(_ date: Date) throws {
lock.lock()
defer { lock.unlock() }
expiresAt = date
}

func getExpiresAt() -> Date? {
lock.lock()
defer { lock.unlock() }
return expiresAt
}

func isTokenExpired() -> Bool {
lock.lock()
defer { lock.unlock() }
guard let expiresAt else { return true }
return expiresAt.timeIntervalSinceNow < 600
}

/// Always throws to simulate a storage failure during clearAll().
func clearAll() throws {
throw ClearAllError.simulatedFailure
}

enum ClearAllError: Error {
case simulatedFailure
}
}

// MARK: - Mock URL Protocol

private final class MockURLProtocol: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) -> (HTTPURLResponse, Data))?

Comment on lines +80 to +82

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MockURLProtocol.requestHandler is a global static that isn't reset between tests. Even though you unregister the protocol class, leaving the handler set can create hidden coupling/flakiness if future tests register the protocol without setting a handler, or if tests run in parallel. Consider clearing requestHandler (e.g., defer { MockURLProtocol.requestHandler = nil }) or wrapping register/unregister + handler setup in a helper to ensure isolation.

Copilot uses AI. Check for mistakes.
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

override func startLoading() {
guard let handler = Self.requestHandler else {
client?.urlProtocolDidFinishLoading(self)
return
}
let (response, data) = handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}

override func stopLoading() {}
}

// MARK: - Thread-safe Counter

private final class LockedCounter: @unchecked Sendable {
private let lock = NSLock()
private var _value = 0

var value: Int {
lock.lock()
defer { lock.unlock() }
return _value
}

func increment() {
lock.lock()
defer { lock.unlock() }
_value += 1
}
}

// MARK: - Tests

@Suite("OAuthManager Token Cleanup", .serialized)
struct OAuthManagerTokenCleanupTests {
private func makeConfig() -> RxAuthConfiguration {
RxAuthConfiguration(
issuer: "https://auth.example.com",
clientID: "test-client",
redirectURI: "testapp://callback"
)
}

// MARK: - logout() Tests

@Test @MainActor func logoutClearsAllTokens() async throws {
let storage = InMemoryTokenStorage()
try storage.saveAccessToken("access-123")
try storage.saveRefreshToken("refresh-456")
try storage.saveExpiresAt(Date().addingTimeInterval(3600))

let manager = OAuthManager(
configuration: makeConfig(),
tokenStorage: storage
)

await manager.logout()

#expect(storage.getAccessToken() == nil)
#expect(storage.getRefreshToken() == nil)
#expect(storage.getExpiresAt() == nil)
#expect(manager.authState == .unauthenticated)
#expect(manager.currentUser == nil)
}

@Test @MainActor func logoutClearsTokensWhenClearAllFails() async throws {
let storage = ClearAllFailingTokenStorage()
try storage.saveAccessToken("access-123")
try storage.saveRefreshToken("refresh-456")
try storage.saveExpiresAt(Date().addingTimeInterval(3600))

let manager = OAuthManager(
configuration: makeConfig(),
tokenStorage: storage
)

await manager.logout()

// Even though clearAll() throws, individual deletes should remove tokens
#expect(storage.getAccessToken() == nil)
#expect(storage.getRefreshToken() == nil)
#expect(manager.authState == .unauthenticated)
#expect(manager.currentUser == nil)
}

// MARK: - checkExistingAuth() Tests

@Test @MainActor func checkExistingAuthUnauthenticatedWithNoTokens() async {
let storage = InMemoryTokenStorage()
let manager = OAuthManager(
configuration: makeConfig(),
tokenStorage: storage
)

await manager.checkExistingAuth()

#expect(manager.authState == .unauthenticated)
}

@Test @MainActor func checkExistingAuthClearsStaleTokensAfterRefreshFailure() async throws {
// Set up mock handler before registering protocol to avoid race conditions
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(
url: request.url!,
statusCode: 400,
httpVersion: nil,
headerFields: nil
)!
return (response, Data())
}

URLProtocol.registerClass(MockURLProtocol.self)
defer { URLProtocol.unregisterClass(MockURLProtocol.self) }

let storage = InMemoryTokenStorage()
// Set up stale tokens: expired access token + refresh token
try storage.saveAccessToken("expired-access")
try storage.saveRefreshToken("stale-refresh")
try storage.saveExpiresAt(Date().addingTimeInterval(-3600)) // expired 1 hour ago

let manager = OAuthManager(
configuration: makeConfig(),
tokenStorage: storage
)

// First call finds stale refresh token, tries refresh → 400 → clears tokens
await manager.checkExistingAuth()

#expect(manager.authState == .unauthenticated)
// Tokens must be cleared so the next checkExistingAuth() won't retry
#expect(storage.getAccessToken() == nil)
#expect(storage.getRefreshToken() == nil)
}

@Test @MainActor func checkExistingAuthDoesNotRetryAfterTokensCleared() async throws {
let counter = LockedCounter()
MockURLProtocol.requestHandler = { request in
counter.increment()
let response = HTTPURLResponse(
url: request.url!,
statusCode: 400,
httpVersion: nil,
headerFields: nil
)!
return (response, Data())
}

URLProtocol.registerClass(MockURLProtocol.self)
defer { URLProtocol.unregisterClass(MockURLProtocol.self) }

let storage = InMemoryTokenStorage()
try storage.saveAccessToken("expired-access")
try storage.saveRefreshToken("stale-refresh")
try storage.saveExpiresAt(Date().addingTimeInterval(-3600))

let manager = OAuthManager(
configuration: makeConfig(),
tokenStorage: storage
)

// First call: finds stale refresh token, tries refresh, fails, clears tokens
await manager.checkExistingAuth()
#expect(manager.authState == .unauthenticated)

let firstCallCount = counter.value

// Second call: no tokens should remain, so no refresh attempt
await manager.checkExistingAuth()
#expect(manager.authState == .unauthenticated)
#expect(counter.value == firstCallCount, "Should not make additional refresh requests after tokens are cleared")
}

@Test @MainActor func fullCycleRefreshFailureThenRecheck() async throws {
// Simulate the full bug scenario:
// 1. User has tokens
// 2. Refresh fails with 400
// 3. Tokens should be fully cleared
// 4. Subsequent checkExistingAuth() should NOT attempt to refresh

MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(
url: request.url!,
statusCode: 400,
httpVersion: nil,
headerFields: nil
)!
return (response, Data())
}

URLProtocol.registerClass(MockURLProtocol.self)
defer { URLProtocol.unregisterClass(MockURLProtocol.self) }

let storage = InMemoryTokenStorage()
try storage.saveAccessToken("old-access")
try storage.saveRefreshToken("old-refresh")
try storage.saveExpiresAt(Date().addingTimeInterval(-100)) // expired

let manager = OAuthManager(
configuration: makeConfig(),
tokenStorage: storage
)

// Step 1: Check existing auth → refresh fails → signs out
await manager.checkExistingAuth()
#expect(manager.authState == .unauthenticated)
#expect(storage.getAccessToken() == nil)
#expect(storage.getRefreshToken() == nil)

// Step 2: Simulate user signing in again by saving new tokens
try storage.saveAccessToken("new-access")
try storage.saveRefreshToken("new-refresh")
try storage.saveExpiresAt(Date().addingTimeInterval(3600)) // valid

// Step 3: Now update the mock to return valid responses for the new session
MockURLProtocol.requestHandler = { request in
let userInfoJSON = #"{"id":"user-1","name":"Test User","email":"test@example.com"}"#
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!
return (response, userInfoJSON.data(using: .utf8)!)
}

// Step 4: Check existing auth with new valid tokens
await manager.checkExistingAuth()
#expect(manager.authState == .authenticated)
#expect(manager.currentUser?.id == "user-1")
}
}