diff --git a/AppCheckCore/Sources/Core/AppCheckCoreToken.swift b/AppCheckCore/Sources/Core/AppCheckCoreToken.swift index 1446e8f..2284980 100644 --- a/AppCheckCore/Sources/Core/AppCheckCoreToken.swift +++ b/AppCheckCore/Sources/Core/AppCheckCoreToken.swift @@ -20,6 +20,11 @@ public class AppCheckCoreToken: NSObject, @unchecked Sendable { @objc public let expirationDate: Date @objc public let receivedAtDate: Date + /// Creates an App Check token. + /// - Parameters: + /// - token: A Firebase App Check token string. + /// - expirationDate: The token's expiration date in the device's local clock time. + /// - receivedAtDate: The date the token was received in the device's local clock time. @objc(initWithToken:expirationDate:receivedAtDate:) public init(token: String, expirationDate: Date, receivedAt receivedAtDate: Date) { self.token = token @@ -28,6 +33,10 @@ public class AppCheckCoreToken: NSObject, @unchecked Sendable { super.init() } + /// Creates an App Check token. + /// - Parameters: + /// - token: A Firebase App Check token string. + /// - expirationDate: The token's expiration date in the device's local clock time. @objc(initWithToken:expirationDate:) public convenience init(token: String, expirationDate: Date) { self.init(token: token, expirationDate: expirationDate, receivedAt: Date()) diff --git a/AppCheckCore/Sources/Core/Backoff/AppCheckCoreBackoffWrapper.swift b/AppCheckCore/Sources/Core/Backoff/AppCheckCoreBackoffWrapper.swift index e92f62f..5ceb82f 100644 --- a/AppCheckCore/Sources/Core/Backoff/AppCheckCoreBackoffWrapper.swift +++ b/AppCheckCore/Sources/Core/Backoff/AppCheckCoreBackoffWrapper.swift @@ -141,6 +141,11 @@ public class AppCheckCoreBackoffWrapper: NSObject, AppCheckCoreBackoffWrapperPro private func hasTimeIntervalPassedSinceLastFailure(_ timeInterval: TimeInterval) -> Bool { guard let failureDate = lastFailure?.finishDate else { return true } let timeSinceFailure = dateProvider().timeIntervalSince(failureDate) + // A negative interval means the device clock moved backward since the failure was recorded, + // so `failureDate` can no longer bound the backoff window reliably. + if timeSinceFailure < 0 { + return true + } return timeSinceFailure >= timeInterval } diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreBackoffWrapperTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreBackoffWrapperTests.swift index d039b50..53b419c 100644 --- a/AppCheckCore/Tests/Unit/Core/AppCheckCoreBackoffWrapperTests.swift +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreBackoffWrapperTests.swift @@ -212,6 +212,76 @@ class AppCheckCoreBackoffWrapperTests: XCTestCase { } } + func testBackoffWhenClockMovesBackwardAllowsRetryAndReanchorsBackoff() async throws { + // Simulate a failure recorded while the device clock was 48h ahead. + let skew: TimeInterval = 48 * 60 * 60 + let realNow = Date() + currentDate = realNow.addingTimeInterval(skew) + + setUpOperationError() + setUpErrorHandler(with: .exponential) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, operationResult as? NSError) + } + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + + // Clock is corrected back to real time (`timeSinceFailure` is now negative). + // The wrapper should allow an immediate retry rather than locking out for 48h; if that retry + // also fails, `finishDate` re-anchors to the corrected clock and normal backoff resumes. + currentDate = realNow + setUpOperationError() + setUpErrorHandler(with: .exponential) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, operationResult as? NSError) + } + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + + // Subsequent call 0.5s later is now properly throttled against the re-anchored failure date. + currentDate = realNow.addingTimeInterval(0.5) + setUpOperationSuccess() + setUpErrorHandler(with: .none) + operationFinishExpectation.isInverted = true + errorHandlerExpectation.isInverted = true + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected backoff error") + } catch { + XCTAssertTrue(isBackoffError(error as NSError)) + } + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 0.1) + + // Once the re-anchored backoff window for attempt 2 ([2.0s, 3.0s)) elapses, retry succeeds. + currentDate = realNow.addingTimeInterval(3.5) + setUpOperationSuccess() + setUpErrorHandler(with: .none) + errorHandlerExpectation.isInverted = true + + let result = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 0.1) + XCTAssertEqual(result as? NSObject, operationResult as? NSObject) + } + // MARK: - Helpers private func setUpErrorHandler(with backoffType: AppCheckCoreBackoffType) { diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift index ccabf46..55494f9 100644 --- a/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift @@ -132,6 +132,9 @@ class AppCheckCoreTests: XCTestCase { } XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeStorage.lastSetToken, expectedToken) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.lastToken, expectedToken) } func testGetToken_AppCheckProviderError() async { diff --git a/CHANGELOG.md b/CHANGELOG.md index 649603f..d88d0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,16 @@ local storage. More sources may be added later; Swift `switch` statements over `AppCheckCoreDebugTokenSource` should include `@unknown default`. (#134) -- [fixed] Fixed an issue where a cached token fetched while the device clock was ahead kept - being served after the clock was corrected, because its stored dates inherited the offset. - Cached tokens received in the future are now refreshed. - (https://github.com/firebase/firebase-ios-sdk/issues/16939) +- [fixed] Fixed an issue where a cached token fetched while the device clock was + ahead kept being served after the clock was corrected, because its stored + dates inherited the offset. Cached tokens received in the future are now + refreshed. (https://github.com/firebase/firebase-ios-sdk/issues/16939) - [fixed] Fixed an issue where a debug token exchange using the `AppCheckDebugToken` or `FIRAAppCheckDebugToken` environment variable updated the locally generated debug token's registered state, which could suppress or repeat its registration instructions. (#135) +- [fixed] Fixed an issue where provider request backoff could block retries if + the device clock moved backward after a failed request. (#137) # 12.0.0 - [changed] Ported AppCheckCore from Objective-C to Swift.