diff --git a/swift-sdk/Internal/AuthManager.swift b/swift-sdk/Internal/AuthManager.swift index 165704144..731ec9f35 100644 --- a/swift-sdk/Internal/AuthManager.swift +++ b/swift-sdk/Internal/AuthManager.swift @@ -67,8 +67,16 @@ class AuthManager: IterableAuthManagerProtocol { } private func shouldPauseRetry(_ shouldIgnoreRetryPolicy: Bool) -> Bool { - return (!shouldIgnoreRetryPolicy && pauseAuthRetry) || - (retryCount >= authRetryPolicy.maxRetry && !shouldIgnoreRetryPolicy) + if pauseAuthRetry { + return true + } + + // Scheduled refresh should never bypass retry safety limits. + if isInScheduledRefreshCallback { + return retryCount >= authRetryPolicy.maxRetry + } + + return retryCount >= authRetryPolicy.maxRetry && !shouldIgnoreRetryPolicy } private func shouldUseLastValidToken(_ shouldIgnoreRetryPolicy: Bool) -> Bool { @@ -113,6 +121,7 @@ class AuthManager: IterableAuthManagerProtocol { private var isLastAuthTokenValid: Bool = false private var pauseAuthRetry: Bool = false private var isTimerScheduled: Bool = false + private var isInScheduledRefreshCallback: Bool = false private var pendingSuccessCallbacks: [AuthTokenRetrievalHandler] = [] private let callbackQueue = DispatchQueue(label: "com.iterable.authCallbackQueue") @@ -205,6 +214,7 @@ class AuthManager: IterableAuthManagerProtocol { let timeIntervalToRefresh = TimeInterval(expirationDate) - dateProvider.currentDate.timeIntervalSince1970 - expirationRefreshPeriod if timeIntervalToRefresh > 0 { scheduleAuthTokenRefreshTimer(interval: timeIntervalToRefresh, isScheduledRefresh: true, successCallback: onSuccess) + resetRetryCount() return true // Only return true when we successfully queue a refresh } return false @@ -228,14 +238,17 @@ class AuthManager: IterableAuthManagerProtocol { addPendingCallback(successCallback) expirationRefreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in - self?.isTimerScheduled = false - if self?.localStorage.email != nil || self?.localStorage.userId != nil { - self?.requestNewAuthToken(hasFailedPriorAuth: false, onSuccess: { [weak self] token in + guard let self else { return } + self.isTimerScheduled = false + if self.localStorage.email != nil || self.localStorage.userId != nil { + self.isInScheduledRefreshCallback = isScheduledRefresh + self.requestNewAuthToken(hasFailedPriorAuth: false, onSuccess: { [weak self] token in self?.invokePendingCallbacks(with: token) }, shouldIgnoreRetryPolicy: isScheduledRefresh) + self.isInScheduledRefreshCallback = false } else { ITBDebug("Email or userId is not available. Skipping token refresh") - self?.clearPendingCallbacks() + self.clearPendingCallbacks() } } diff --git a/tests/unit-tests/AuthTests.swift b/tests/unit-tests/AuthTests.swift index 0574a2779..74577e546 100644 --- a/tests/unit-tests/AuthTests.swift +++ b/tests/unit-tests/AuthTests.swift @@ -425,7 +425,9 @@ class AuthTests: XCTestCase { let expirationRefreshPeriod: TimeInterval = 0 let waitTime: TimeInterval = 1.0 - let expirationTimeSinceEpoch = Date(timeIntervalSinceNow: expirationRefreshPeriod + waitTime).timeIntervalSince1970 + let mockDateProvider = MockDateProvider() + mockDateProvider.currentDate = Date(timeIntervalSince1970: 1_000_000_000) + let expirationTimeSinceEpoch = mockDateProvider.currentDate.timeIntervalSince1970 + expirationRefreshPeriod + waitTime let mockEncodedPayload = createMockEncodedPayload(exp: Int(expirationTimeSinceEpoch)) let localStorage = MockLocalStorage() @@ -437,12 +439,83 @@ class AuthTests: XCTestCase { authRetryPolicy: RetryPolicy(maxRetry: 1, retryInterval: 0, retryBackoff: .linear), expirationRefreshPeriod: expirationRefreshPeriod, localStorage: localStorage, - dateProvider: MockDateProvider()) + dateProvider: mockDateProvider) let _ = authManager wait(for: [condition1], timeout: testExpectationTimeout) } + + func testPauseAuthRetriesBlocksScheduledRefresh() { + let callbackNotCalledExpectation = expectation(description: "\(#function) - callback got called when it shouldn't while paused") + callbackNotCalledExpectation.isInverted = true + + let authDelegate = createAuthDelegate({ + callbackNotCalledExpectation.fulfill() + return nil + }) + + let expirationRefreshPeriod: TimeInterval = 0 + let waitTime: TimeInterval = 1.0 + + let mockDateProvider = MockDateProvider() + mockDateProvider.currentDate = Date(timeIntervalSince1970: 1_000_000_000) + let expirationTimeSinceEpoch = mockDateProvider.currentDate.timeIntervalSince1970 + expirationRefreshPeriod + waitTime + let mockEncodedPayload = createMockEncodedPayload(exp: Int(expirationTimeSinceEpoch)) + + let localStorage = MockLocalStorage() + localStorage.authToken = mockEncodedPayload + localStorage.userId = AuthTests.userId + + let authManager = AuthManager(delegate: authDelegate, + authRetryPolicy: RetryPolicy(maxRetry: 1, retryInterval: 0, retryBackoff: .linear), + expirationRefreshPeriod: expirationRefreshPeriod, + localStorage: localStorage, + dateProvider: mockDateProvider) + + authManager.pauseAuthRetries(true) + + wait(for: [callbackNotCalledExpectation], timeout: waitTime + 1.0) + } + + func testMaxRetryLimitRespectedForScheduledRefresh() { + let secondCallbackNotCalledExpectation = expectation(description: "\(#function) - scheduled refresh requested auth token beyond maxRetry") + secondCallbackNotCalledExpectation.isInverted = true + + let expirationRefreshPeriod: TimeInterval = 0 + let mockDateProvider = MockDateProvider() + mockDateProvider.currentDate = Date(timeIntervalSince1970: 1_000_000_000) + + let expiredExp = Int(mockDateProvider.currentDate.timeIntervalSince1970 - 10) + let expiredToken = createMockEncodedPayload(exp: expiredExp) + + var callbackCount = 0 + let authDelegate = createAuthDelegate({ + callbackCount += 1 + if callbackCount > 1 { + secondCallbackNotCalledExpectation.fulfill() + } + return expiredToken + }) + + let localStorage = MockLocalStorage() + localStorage.userId = AuthTests.userId + + let authManager = AuthManager(delegate: authDelegate, + authRetryPolicy: RetryPolicy(maxRetry: 1, retryInterval: 0, retryBackoff: .linear), + expirationRefreshPeriod: expirationRefreshPeriod, + localStorage: localStorage, + dateProvider: mockDateProvider) + + // 1st attempt increments retryCount to maxRetry + authManager.requestNewAuthToken(hasFailedPriorAuth: false, onSuccess: nil, shouldIgnoreRetryPolicy: true) + + // Scheduled refresh attempt must respect maxRetry even when shouldIgnoreRetryPolicy is true. + authManager.scheduleAuthTokenRefreshTimer(interval: 0.01, isScheduledRefresh: true, successCallback: nil) + + wait(for: [secondCallbackNotCalledExpectation], timeout: 1.0) + XCTAssertEqual(callbackCount, 1) + } func testAuthTokenRefreshOnInit() { let condition1 = expectation(description: "\(#function) - callback didn't get called when refresh was fired")