diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift index 74e38ad6cae..59a69a10c75 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift @@ -47,7 +47,7 @@ actor ConfirmationChallenge { func fetchTokensWithTimeout() async -> ChallengeTokens { let startTime = Date() - let isReady = await passiveCaptchaChallenge?.hasFetchedToken ?? false + let isReady = await passiveCaptchaChallenge?.isTokenReady ?? false let getPassiveCaptchaToken: () async throws -> String? = { guard let passiveCaptchaChallenge = self.passiveCaptchaChallenge else { return nil diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index be0511beecc..d1ae0594523 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -43,20 +43,35 @@ struct PassiveCaptchaData: Equatable, Hashable { actor PassiveCaptchaChallenge { let passiveCaptchaData: PassiveCaptchaData private let hcaptchaFactory: HCaptchaFactory + private let sessionExpiration: TimeInterval private var tokenTask: Task? - var hasFetchedToken = false + var isTokenReady: Bool { // used for the attach analytic to indicate whether it's blocking checkout + return hasFetchedToken && !hasSessionExpired + } + private var hasFetchedToken: Bool = false + private var sessionExpirationDate: Date? + private var hasSessionExpired: Bool { + guard let sessionExpirationDate else { return false } // if we don't have a session expiration date, then we don't have a token yet + return Date() >= sessionExpirationDate + } public init(passiveCaptchaData: PassiveCaptchaData) { self.init(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory()) } - init(passiveCaptchaData: PassiveCaptchaData, hcaptchaFactory: HCaptchaFactory) { + init(passiveCaptchaData: PassiveCaptchaData, hcaptchaFactory: HCaptchaFactory, sessionExpiration: TimeInterval = 29 * 60) { self.passiveCaptchaData = passiveCaptchaData self.hcaptchaFactory = hcaptchaFactory + // The max_age of the token set on the backend is 1800 seconds, or 30 minutes. As a preventative measure, we expire the token a minute early so a user won't send an expired token + self.sessionExpiration = sessionExpiration Task { try await fetchToken() } // Intentionally not blocking loading/initialization! } public func fetchToken() async throws -> String { + if hasSessionExpired { + resetSession() + } + if let tokenTask { return try await withTaskCancellationHandler { try await tokenTask.value @@ -80,6 +95,7 @@ actor PassiveCaptchaChallenge { Task { @MainActor in // MainActor to prevent continuation from different threads do { let token = try result.dematerialize() + await self?.setSessionExpirationDate() nillableContinuation?.resume(returning: token) nillableContinuation = nil } catch { @@ -116,6 +132,16 @@ actor PassiveCaptchaChallenge { } } + private func resetSession() { + self.tokenTask = nil + self.hasFetchedToken = false + self.sessionExpirationDate = nil + } + + private func setSessionExpirationDate() { + self.sessionExpirationDate = Date().addingTimeInterval(sessionExpiration) + } + private func setValidationComplete() { hasFetchedToken = true } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift index afd1325e8ef..aa23a8012dc 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift @@ -5,8 +5,8 @@ // Created by Joyce Qin on 8/21/25. // -@_spi(STP) @testable import StripePaymentSheet @_spi(STP) @testable import StripePayments +@_spi(STP) @testable import StripePaymentSheet import XCTest class PassiveCaptchaChallengeTests: XCTestCase { @@ -43,15 +43,18 @@ class PassiveCaptchaChallengeTests: XCTestCase { } } + // OCS mobile test key from https://dashboard.hcaptcha.com/sites/edit/143aadb6-fb60-4ab6-b128-f7fe53426d4a + let siteKey = "143aadb6-fb60-4ab6-b128-f7fe53426d4a" + func testPassiveCaptcha() async throws { - // OCS mobile test key from https://dashboard.hcaptcha.com/sites/edit/143aadb6-fb60-4ab6-b128-f7fe53426d4a - let siteKey = "143aadb6-fb60-4ab6-b128-f7fe53426d4a" let passiveCaptchaData = PassiveCaptchaData(siteKey: siteKey, rqdata: nil) let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData) // wait to make sure that the token will be ready by the time we call fetchToken try await Task.sleep(nanoseconds: 6_000_000_000) let hcaptchaToken = try await passiveCaptchaChallenge.fetchToken() XCTAssertNotNil(hcaptchaToken) + let isReady = await passiveCaptchaChallenge.isTokenReady + XCTAssertTrue(isReady, "Token should be ready if not expired") let passiveCaptchaEvents = STPAnalyticsClient.sharedClient._testLogHistory.map({ $0["event"] as? String }).filter({ $0?.starts(with: "elements.captcha.passive") ?? false }) XCTAssertEqual(passiveCaptchaEvents, ["elements.captcha.passive.init", "elements.captcha.passive.execute", "elements.captcha.passive.success"]) let successAnalytic = STPAnalyticsClient.sharedClient._testLogHistory.first(where: { $0["event"] as? String == "elements.captcha.passive.success" }) @@ -59,7 +62,6 @@ class PassiveCaptchaChallengeTests: XCTestCase { } func testPassiveCaptchaTimeout() async throws { - let siteKey = "143aadb6-fb60-4ab6-b128-f7fe53426d4a" let passiveCaptchaData = PassiveCaptchaData(siteKey: siteKey, rqdata: nil) let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: TestDelayHCaptchaFactory()) let startTime = Date() @@ -75,7 +77,6 @@ class PassiveCaptchaChallengeTests: XCTestCase { } func testPassiveCaptchaLongTimeout() async throws { - let siteKey = "143aadb6-fb60-4ab6-b128-f7fe53426d4a" let passiveCaptchaData = PassiveCaptchaData(siteKey: siteKey, rqdata: nil) let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData) let startTime = Date() @@ -86,4 +87,48 @@ class PassiveCaptchaChallengeTests: XCTestCase { XCTAssertLessThan(Date().timeIntervalSince(startTime), 10) XCTAssertNotNil(hcaptchaToken) } + + func testTokenResetAndRefetchAfterExpiration() async throws { + let passiveCaptchaData = PassiveCaptchaData(siteKey: siteKey, rqdata: nil) + // Use a very short expiration time (5 seconds) for testing + let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory(), sessionExpiration: 5.0) + + // Fetch first token + let token = try await passiveCaptchaChallenge.fetchToken() + XCTAssertNotNil(token) + + // Verify token is ready + var isReadyBefore = await passiveCaptchaChallenge.isTokenReady + XCTAssertTrue(isReadyBefore, "Token should be ready after first fetch") + + // Fetch a token before expiration - should succeed without fetching a new token + let sameToken = try await passiveCaptchaChallenge.fetchToken() + XCTAssertNotNil(sameToken) + + // Verify token is ready + isReadyBefore = await passiveCaptchaChallenge.isTokenReady + XCTAssertTrue(isReadyBefore, "Token should be ready after first fetch") + + let passiveCaptchaEvents = STPAnalyticsClient.sharedClient._testLogHistory.map({ $0["event"] as? String }).filter({ $0?.starts(with: "elements.captcha.passive") ?? false }) + // We should not see these events more than once because we shouldn't need to fetch more than once + XCTAssertEqual(passiveCaptchaEvents, ["elements.captcha.passive.init", "elements.captcha.passive.execute", "elements.captcha.passive.success"]) + + // Wait for session to expire + try await Task.sleep(nanoseconds: 5_000_000_000) + + // Check that expiration triggers reset + let isReadyAfter = await passiveCaptchaChallenge.isTokenReady + XCTAssertFalse(isReadyAfter, "Token should not be ready after session expiration") + + // Fetch a new token after expiration - should succeed with a new token + let newToken = try await passiveCaptchaChallenge.fetchToken() + XCTAssertNotNil(newToken) + + // Verify token is ready again after successful refetch + let isReadyFinal = await passiveCaptchaChallenge.isTokenReady + XCTAssertTrue(isReadyFinal, "Token should be ready after refetch") + + let passiveCaptchaExecuteEvents = STPAnalyticsClient.sharedClient._testLogHistory.map({ $0["event"] as? String }).filter({ $0?.starts(with: "elements.captcha.passive.execute") ?? false }) + XCTAssertEqual(passiveCaptchaExecuteEvents.count, 2, "Should have re-fetched token after expiration") + } }