From c5b614cf5da36385edf70fe7768173d2ef4f1c37 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Wed, 19 Nov 2025 15:52:41 -0500 Subject: [PATCH 01/17] retry --- .../ConfirmationChallenge.swift | 4 +- .../PassiveCaptchaChallenge.swift | 70 ++++++++++++------- .../Source/Captcha/HCaptcha.swift | 2 +- .../Source/Captcha/HCaptchaError.swift | 4 +- .../Source/Captcha/HCaptchaEvent.swift | 8 +-- 5 files changed, 54 insertions(+), 34 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift index 74ff27b0f134..bee8456dd605 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift @@ -42,12 +42,12 @@ 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 } - return try await passiveCaptchaChallenge.fetchToken() + return try await passiveCaptchaChallenge.fetchToken().dematerialize() } let getAttestationAssertion: () async throws -> StripeAttest.Assertion? = { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index be0511beecc0..2938ef0666e4 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -42,9 +42,12 @@ struct PassiveCaptchaData: Equatable, Hashable { actor PassiveCaptchaChallenge { let passiveCaptchaData: PassiveCaptchaData + var isTokenReady: Bool = false private let hcaptchaFactory: HCaptchaFactory - private var tokenTask: Task? - var hasFetchedToken = false + private var hcaptcha: HCaptcha? + private var tokenTask: Task? + private var retryCount = 0 + private let maxRetries = 6 public init(passiveCaptchaData: PassiveCaptchaData) { self.init(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory()) @@ -53,10 +56,37 @@ actor PassiveCaptchaChallenge { init(passiveCaptchaData: PassiveCaptchaData, hcaptchaFactory: HCaptchaFactory) { self.passiveCaptchaData = passiveCaptchaData self.hcaptchaFactory = hcaptchaFactory - Task { try await fetchToken() } // Intentionally not blocking loading/initialization! + Task { // Intentionally not blocking loading/initialization! + try await createHCaptcha() + _ = try await fetchToken() + } + } + + private func createHCaptcha() async throws { + STPAnalyticsClient.sharedClient.logPassiveCaptchaInit(siteKey: passiveCaptchaData.siteKey) + let startTime = Date() + do { + self.hcaptcha = try hcaptchaFactory.create(siteKey: passiveCaptchaData.siteKey, rqdata: passiveCaptchaData.rqdata) + self.hcaptcha?.onEvent { event, _ in + // if the token expires, reset and retry + if case .expired = event { + self.tokenTask = nil + self.isTokenReady = false + if self.retryCount < self.maxRetries { + self.retryCount += 1 + Task { + try await self.fetchToken() + } + } + } + } + } catch { + STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: passiveCaptchaData.siteKey, duration: Date().timeIntervalSince(startTime)) + throw error + } } - public func fetchToken() async throws -> String { + public func fetchToken() async throws -> HCaptchaResult { if let tokenTask { return try await withTaskCancellationHandler { try await tokenTask.value @@ -65,46 +95,36 @@ actor PassiveCaptchaChallenge { } } - let tokenTask = Task { [siteKey = passiveCaptchaData.siteKey, rqdata = passiveCaptchaData.rqdata, hcaptchaFactory, weak self] () -> String in - STPAnalyticsClient.sharedClient.logPassiveCaptchaInit(siteKey: siteKey) + let tokenTask = Task { [siteKey = passiveCaptchaData.siteKey, hcaptcha, weak self] () -> HCaptchaResult in + STPAnalyticsClient.sharedClient.logPassiveCaptchaExecute(siteKey: siteKey) let startTime = Date() do { - let hcaptcha = try hcaptchaFactory.create(siteKey: siteKey, rqdata: rqdata) - STPAnalyticsClient.sharedClient.logPassiveCaptchaExecute(siteKey: siteKey) let result = try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in // Prevent Swift Task continuation misuse - the validate completion block can get called multiple times - var nillableContinuation: CheckedContinuation? = continuation + var nillableContinuation: CheckedContinuation? = continuation - hcaptcha.validate { result in + hcaptcha?.validate(resetOnError: false) { result in Task { @MainActor in // MainActor to prevent continuation from different threads - do { - let token = try result.dematerialize() - nillableContinuation?.resume(returning: token) - nillableContinuation = nil - } catch { - nillableContinuation?.resume(throwing: error) - nillableContinuation = nil - } + nillableContinuation?.resume(returning: result) + nillableContinuation = nil } } } } onCancel: { Task { @MainActor in - hcaptcha.stop() + hcaptcha?.stop() } } // Check cancellation after continuation try Task.checkCancellation() // Mark as complete await self?.setValidationComplete() - let duration = Date().timeIntervalSince(startTime) - STPAnalyticsClient.sharedClient.logPassiveCaptchaSuccess(siteKey: siteKey, duration: duration) + STPAnalyticsClient.sharedClient.logPassiveCaptchaSuccess(siteKey: siteKey, duration: Date().timeIntervalSince(startTime)) return result } catch { try Task.checkCancellation() - let duration = Date().timeIntervalSince(startTime) - STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: siteKey, duration: duration) + STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: siteKey, duration: Date().timeIntervalSince(startTime)) throw error } } @@ -117,7 +137,7 @@ actor PassiveCaptchaChallenge { } private func setValidationComplete() { - hasFetchedToken = true + self.isTokenReady = true } } diff --git a/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift b/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift index bed510a70668..ef842583e05a 100644 --- a/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift +++ b/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift @@ -124,7 +124,7 @@ import WebKit onEvent allow to subscribe to SDK's events */ @objc - func onEvent(_ reciever: ((HCaptchaEvent, Any?) -> Void)? = nil) { + public func onEvent(_ reciever: ((HCaptchaEvent, Any?) -> Void)? = nil) { Log.debug(".onEvent") manager.onEvent = reciever diff --git a/StripePayments/StripePayments/Source/Captcha/HCaptchaError.swift b/StripePayments/StripePayments/Source/Captcha/HCaptchaError.swift index 6f0c4223a5bd..587fe7daa328 100644 --- a/StripePayments/StripePayments/Source/Captcha/HCaptchaError.swift +++ b/StripePayments/StripePayments/Source/Captcha/HCaptchaError.swift @@ -9,7 +9,7 @@ import Foundation /// The codes of possible errors thrown by HCaptcha -enum HCaptchaError: Error, CustomStringConvertible { +@_spi(STP) public enum HCaptchaError: Error, CustomStringConvertible { /// Unexpected error case unexpected(Error) @@ -52,7 +52,7 @@ enum HCaptchaError: Error, CustomStringConvertible { } /// A human-readable description for each error - var description: String { + public var description: String { switch self { case .unexpected(let error): return "Unexpected Error: \(error)" diff --git a/StripePayments/StripePayments/Source/Captcha/HCaptchaEvent.swift b/StripePayments/StripePayments/Source/Captcha/HCaptchaEvent.swift index 0929ce8bf3fa..2f3c7d28637b 100644 --- a/StripePayments/StripePayments/Source/Captcha/HCaptchaEvent.swift +++ b/StripePayments/StripePayments/Source/Captcha/HCaptchaEvent.swift @@ -10,16 +10,16 @@ import Foundation /** Events which can be received from HCaptcha SDK */ @objc -enum HCaptchaEvent: Int, RawRepresentable { +@_spi(STP) public enum HCaptchaEvent: Int, RawRepresentable { case open case expired case challengeExpired case close case error - typealias RawValue = String + public typealias RawValue = String - var rawValue: RawValue { + public var rawValue: RawValue { switch self { case .open: return "open" @@ -34,7 +34,7 @@ enum HCaptchaEvent: Int, RawRepresentable { } } - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { switch rawValue { case "open": self = .open From ff13db2972965f00054a1da106843e67abecae75 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Mon, 24 Nov 2025 12:17:25 -0500 Subject: [PATCH 02/17] retry if expired once --- .../ConfirmationChallenge.swift | 2 +- .../PassiveCaptchaChallenge.swift | 69 +++++++++++-------- .../Source/Captcha/HCaptcha.swift | 2 +- 3 files changed, 43 insertions(+), 30 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift index bee8456dd605..b32979c10ac7 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/ConfirmationChallenge.swift @@ -47,7 +47,7 @@ actor ConfirmationChallenge { guard let passiveCaptchaChallenge = self.passiveCaptchaChallenge else { return nil } - return try await passiveCaptchaChallenge.fetchToken().dematerialize() + return try await passiveCaptchaChallenge.fetchToken() } let getAttestationAssertion: () async throws -> StripeAttest.Assertion? = { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index 2938ef0666e4..d230296c6de6 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -42,12 +42,22 @@ struct PassiveCaptchaData: Equatable, Hashable { actor PassiveCaptchaChallenge { let passiveCaptchaData: PassiveCaptchaData - var isTokenReady: Bool = false + var isTokenReady: Bool { // Fetched for the attach analytic. If session is expired, reset before the next fetch. + let isSessionExpired = isSessionExpired() + if isSessionExpired { + self.tokenTask = nil + self._hasToken = false + self.hcaptcha?.reset() + } + return _hasToken + } private let hcaptchaFactory: HCaptchaFactory private var hcaptcha: HCaptcha? - private var tokenTask: Task? - private var retryCount = 0 - private let maxRetries = 6 + private var tokenTask: Task? + private var _hasToken: Bool = false + private var sessionStartTime: Date? + + private static let sessionExpiration: TimeInterval = 29 * 60 public init(passiveCaptchaData: PassiveCaptchaData) { self.init(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory()) @@ -56,37 +66,29 @@ actor PassiveCaptchaChallenge { init(passiveCaptchaData: PassiveCaptchaData, hcaptchaFactory: HCaptchaFactory) { self.passiveCaptchaData = passiveCaptchaData self.hcaptchaFactory = hcaptchaFactory - Task { // Intentionally not blocking loading/initialization! - try await createHCaptcha() - _ = try await fetchToken() + Task { [weak self] in // Intentionally not blocking loading/initialization! + try await self?.createHCaptcha() + _ = try await self?.fetchToken() } } - private func createHCaptcha() async throws { + private func createHCaptcha() throws { STPAnalyticsClient.sharedClient.logPassiveCaptchaInit(siteKey: passiveCaptchaData.siteKey) let startTime = Date() do { self.hcaptcha = try hcaptchaFactory.create(siteKey: passiveCaptchaData.siteKey, rqdata: passiveCaptchaData.rqdata) - self.hcaptcha?.onEvent { event, _ in - // if the token expires, reset and retry - if case .expired = event { - self.tokenTask = nil - self.isTokenReady = false - if self.retryCount < self.maxRetries { - self.retryCount += 1 - Task { - try await self.fetchToken() - } - } - } - } } catch { STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: passiveCaptchaData.siteKey, duration: Date().timeIntervalSince(startTime)) throw error } } - public func fetchToken() async throws -> HCaptchaResult { + private func isSessionExpired() -> Bool { + guard let sessionStartTime else { return true } + return Date().timeIntervalSince(sessionStartTime) >= Self.sessionExpiration + } + + public func fetchToken() async throws -> String { if let tokenTask { return try await withTaskCancellationHandler { try await tokenTask.value @@ -95,19 +97,26 @@ actor PassiveCaptchaChallenge { } } - let tokenTask = Task { [siteKey = passiveCaptchaData.siteKey, hcaptcha, weak self] () -> HCaptchaResult in + let tokenTask = Task { [siteKey = passiveCaptchaData.siteKey, hcaptcha, weak self] () -> String in STPAnalyticsClient.sharedClient.logPassiveCaptchaExecute(siteKey: siteKey) let startTime = Date() + await self?.setSessionStartTime(startTime) do { let result = try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in // Prevent Swift Task continuation misuse - the validate completion block can get called multiple times - var nillableContinuation: CheckedContinuation? = continuation + var nillableContinuation: CheckedContinuation? = continuation hcaptcha?.validate(resetOnError: false) { result in Task { @MainActor in // MainActor to prevent continuation from different threads - nillableContinuation?.resume(returning: result) - nillableContinuation = nil + do { + let token = try result.dematerialize() + nillableContinuation?.resume(returning: token) + nillableContinuation = nil + } catch { + nillableContinuation?.resume(throwing: error) + nillableContinuation = nil + } } } } @@ -136,8 +145,12 @@ actor PassiveCaptchaChallenge { } } + private func setSessionStartTime(_ sessionStartTime: Date) { + self.sessionStartTime = sessionStartTime + } + private func setValidationComplete() { - self.isTokenReady = true + self._hasToken = true } } diff --git a/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift b/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift index ef842583e05a..b3fa063ee37b 100644 --- a/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift +++ b/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift @@ -177,7 +177,7 @@ import WebKit The reset is achieved by calling `hcaptcha.reset()` on the JS API. */ @objc - func reset() { + public func reset() { Log.debug(".reset") manager.reset() From 95e7d7e7a91cd02e69b0f41d3e4dacbb3a7dee01 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Mon, 24 Nov 2025 12:20:34 -0500 Subject: [PATCH 03/17] revert hcaptcharesult public --- .../StripePayments/Source/Captcha/HCaptcha.swift | 2 +- .../StripePayments/Source/Captcha/HCaptchaError.swift | 4 ++-- .../StripePayments/Source/Captcha/HCaptchaEvent.swift | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift b/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift index b3fa063ee37b..e0d89a4cfdc2 100644 --- a/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift +++ b/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift @@ -124,7 +124,7 @@ import WebKit onEvent allow to subscribe to SDK's events */ @objc - public func onEvent(_ reciever: ((HCaptchaEvent, Any?) -> Void)? = nil) { + func onEvent(_ reciever: ((HCaptchaEvent, Any?) -> Void)? = nil) { Log.debug(".onEvent") manager.onEvent = reciever diff --git a/StripePayments/StripePayments/Source/Captcha/HCaptchaError.swift b/StripePayments/StripePayments/Source/Captcha/HCaptchaError.swift index 587fe7daa328..6f0c4223a5bd 100644 --- a/StripePayments/StripePayments/Source/Captcha/HCaptchaError.swift +++ b/StripePayments/StripePayments/Source/Captcha/HCaptchaError.swift @@ -9,7 +9,7 @@ import Foundation /// The codes of possible errors thrown by HCaptcha -@_spi(STP) public enum HCaptchaError: Error, CustomStringConvertible { +enum HCaptchaError: Error, CustomStringConvertible { /// Unexpected error case unexpected(Error) @@ -52,7 +52,7 @@ import Foundation } /// A human-readable description for each error - public var description: String { + var description: String { switch self { case .unexpected(let error): return "Unexpected Error: \(error)" diff --git a/StripePayments/StripePayments/Source/Captcha/HCaptchaEvent.swift b/StripePayments/StripePayments/Source/Captcha/HCaptchaEvent.swift index 2f3c7d28637b..0929ce8bf3fa 100644 --- a/StripePayments/StripePayments/Source/Captcha/HCaptchaEvent.swift +++ b/StripePayments/StripePayments/Source/Captcha/HCaptchaEvent.swift @@ -10,16 +10,16 @@ import Foundation /** Events which can be received from HCaptcha SDK */ @objc -@_spi(STP) public enum HCaptchaEvent: Int, RawRepresentable { +enum HCaptchaEvent: Int, RawRepresentable { case open case expired case challengeExpired case close case error - public typealias RawValue = String + typealias RawValue = String - public var rawValue: RawValue { + var rawValue: RawValue { switch self { case .open: return "open" @@ -34,7 +34,7 @@ import Foundation } } - public init?(rawValue: RawValue) { + init?(rawValue: RawValue) { switch rawValue { case "open": self = .open From 9417f296e50a2995087d32d077360f265cc1eacc Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Mon, 24 Nov 2025 13:07:37 -0500 Subject: [PATCH 04/17] update session expiry logic --- .../ConfirmationChallenge/PassiveCaptchaChallenge.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index d230296c6de6..c732962a7055 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -84,7 +84,8 @@ actor PassiveCaptchaChallenge { } private func isSessionExpired() -> Bool { - guard let sessionStartTime else { return true } + // The session starts when we get our first token + guard let sessionStartTime else { return false } // If sessionStartTime is nil, then we haven't gotten our first token back yet return Date().timeIntervalSince(sessionStartTime) >= Self.sessionExpiration } @@ -100,7 +101,6 @@ actor PassiveCaptchaChallenge { let tokenTask = Task { [siteKey = passiveCaptchaData.siteKey, hcaptcha, weak self] () -> String in STPAnalyticsClient.sharedClient.logPassiveCaptchaExecute(siteKey: siteKey) let startTime = Date() - await self?.setSessionStartTime(startTime) do { let result = try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -111,6 +111,7 @@ actor PassiveCaptchaChallenge { Task { @MainActor in // MainActor to prevent continuation from different threads do { let token = try result.dematerialize() + await self?.setSessionStartTime(Date()) nillableContinuation?.resume(returning: token) nillableContinuation = nil } catch { From 1822bd8794d77f209283e95e4904e84cd1a6b714 Mon Sep 17 00:00:00 2001 From: joyceqin-stripe Date: Mon, 24 Nov 2025 13:14:41 -0500 Subject: [PATCH 05/17] Remove resetOnError --- .../ConfirmationChallenge/PassiveCaptchaChallenge.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index c732962a7055..afcc37d15b90 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -107,7 +107,7 @@ actor PassiveCaptchaChallenge { // Prevent Swift Task continuation misuse - the validate completion block can get called multiple times var nillableContinuation: CheckedContinuation? = continuation - hcaptcha?.validate(resetOnError: false) { result in + hcaptcha?.validate { result in Task { @MainActor in // MainActor to prevent continuation from different threads do { let token = try result.dematerialize() From 84e4b66d9ecc4a955ff1fc708cf1d97da7d4c0c0 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Tue, 23 Dec 2025 11:47:50 -0500 Subject: [PATCH 06/17] add test and ensure hcaptcha created --- .../PassiveCaptchaChallenge.swift | 65 ++++++++++++++----- .../ConfirmationChallengeTests.swift | 2 + .../PassiveCaptchaChallengeTests.swift | 55 ++++++++++++++-- 3 files changed, 98 insertions(+), 24 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index afcc37d15b90..dcbbc2ae8314 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -47,17 +47,21 @@ actor PassiveCaptchaChallenge { if isSessionExpired { self.tokenTask = nil self._hasToken = false - self.hcaptcha?.reset() + if let hcaptcha = self.hcaptcha { + Task { @MainActor in + hcaptcha.reset() + } + } } return _hasToken } private let hcaptchaFactory: HCaptchaFactory private var hcaptcha: HCaptcha? + private var hcaptchaCreationTask: Task? private var tokenTask: Task? private var _hasToken: Bool = false private var sessionStartTime: Date? - - private static let sessionExpiration: TimeInterval = 29 * 60 + private let sessionExpiration: TimeInterval public init(passiveCaptchaData: PassiveCaptchaData) { self.init(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory()) @@ -66,30 +70,41 @@ actor PassiveCaptchaChallenge { init(passiveCaptchaData: PassiveCaptchaData, hcaptchaFactory: HCaptchaFactory) { self.passiveCaptchaData = passiveCaptchaData self.hcaptchaFactory = hcaptchaFactory + self.sessionExpiration = hcaptchaFactory.sessionExpiration Task { [weak self] in // Intentionally not blocking loading/initialization! - try await self?.createHCaptcha() + _ = try await self?.ensureHCaptchaCreated() _ = try await self?.fetchToken() } } - private func createHCaptcha() throws { - STPAnalyticsClient.sharedClient.logPassiveCaptchaInit(siteKey: passiveCaptchaData.siteKey) - let startTime = Date() - do { - self.hcaptcha = try hcaptchaFactory.create(siteKey: passiveCaptchaData.siteKey, rqdata: passiveCaptchaData.rqdata) - } catch { - STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: passiveCaptchaData.siteKey, duration: Date().timeIntervalSince(startTime)) - throw error + private func ensureHCaptchaCreated() async throws { + // If we already have a creation task, wait for it + if let hcaptchaCreationTask { + return try await hcaptchaCreationTask.value } - } - private func isSessionExpired() -> Bool { - // The session starts when we get our first token - guard let sessionStartTime else { return false } // If sessionStartTime is nil, then we haven't gotten our first token back yet - return Date().timeIntervalSince(sessionStartTime) >= Self.sessionExpiration + // Create the HCaptcha creation task + let creationTask = Task { [weak self] in + guard let self else { return } + STPAnalyticsClient.sharedClient.logPassiveCaptchaInit(siteKey: self.passiveCaptchaData.siteKey) + let startTime = Date() + do { + let hcaptcha = try await self.hcaptchaFactory.create(siteKey: self.passiveCaptchaData.siteKey, rqdata: self.passiveCaptchaData.rqdata) + await self.setHCaptcha(hcaptcha) + } catch { + STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: self.passiveCaptchaData.siteKey, duration: Date().timeIntervalSince(startTime)) + throw error + } + } + + self.hcaptchaCreationTask = creationTask + try await creationTask.value } public func fetchToken() async throws -> String { + // Ensure HCaptcha is created before fetching token + try await ensureHCaptchaCreated() + if let tokenTask { return try await withTaskCancellationHandler { try await tokenTask.value @@ -146,6 +161,16 @@ actor PassiveCaptchaChallenge { } } + private func setHCaptcha(_ hcaptcha: HCaptcha) { + self.hcaptcha = hcaptcha + } + + private func isSessionExpired() -> Bool { + // The session starts when we get our first token + guard let sessionStartTime else { return false } // If sessionStartTime is nil, then we haven't gotten our first token back yet + return Date().timeIntervalSince(sessionStartTime) >= sessionExpiration + } + private func setSessionStartTime(_ sessionStartTime: Date) { self.sessionStartTime = sessionStartTime } @@ -159,9 +184,15 @@ actor PassiveCaptchaChallenge { // Protocol for creating HCaptcha instances protocol HCaptchaFactory { func create(siteKey: String, rqdata: String?) throws -> HCaptcha + var sessionExpiration: TimeInterval { get } } struct PassiveHCaptchaFactory: 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 + // After 29 minutes, we reset HCaptcha, and on confirmation, we fetch a new token + let sessionExpiration: TimeInterval = 29 * 60 + func create(siteKey: String, rqdata: String?) throws -> HCaptcha { return try HCaptcha(apiKey: siteKey, passiveApiKey: true, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/ConfirmationChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/ConfirmationChallengeTests.swift index 23762a575091..26bf5e52bb29 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/ConfirmationChallengeTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/ConfirmationChallengeTests.swift @@ -68,6 +68,8 @@ class ConfirmationChallengeTests: XCTestCase { /// A test-only HCaptcha factory that delays token responses to ensure timeout behavior struct TestDelayHCaptchaFactory: HCaptchaFactory { + let sessionExpiration: TimeInterval = 29 * 60 + func create(siteKey: String, rqdata: String?) throws -> HCaptcha { let hcaptcha = try HCaptcha(apiKey: siteKey, passiveApiKey: true, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift index afd1325e8efe..9008fbce0abd 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift @@ -32,26 +32,39 @@ class PassiveCaptchaChallengeTests: XCTestCase { super.tearDown() } - struct TestDelayHCaptchaFactory: HCaptchaFactory { + struct TestHCaptchaFactory: HCaptchaFactory { + let shouldInjectDelay: Bool + let sessionExpiration: TimeInterval + + init(shouldInjectDelay: Bool = false, sessionExpiration: TimeInterval = 29 * 60) { + self.shouldInjectDelay = shouldInjectDelay + self.sessionExpiration = sessionExpiration + } + func create(siteKey: String, rqdata: String?) throws -> HCaptcha { let hcaptcha = try HCaptcha(apiKey: siteKey, passiveApiKey: true, rqdata: rqdata, host: "stripecdn.com") - hcaptcha.manager.shouldDelayToken = true + if shouldInjectDelay { + hcaptcha.manager.shouldDelayToken = true + } return hcaptcha } } + // 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,9 +72,8 @@ 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 passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: TestHCaptchaFactory(shouldInjectDelay: true)) let startTime = Date() let hcaptchaTokenResult = await withTimeout(1) { try await passiveCaptchaChallenge.fetchToken() @@ -75,7 +87,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 +97,34 @@ 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 testFactory = TestHCaptchaFactory(sessionExpiration: 5.0) + let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: testFactory) + + // Fetch first token + let firstToken = try await passiveCaptchaChallenge.fetchToken() + XCTAssertNotNil(firstToken) + + // Verify token is ready + let isReadyBefore = await passiveCaptchaChallenge.isTokenReady + XCTAssertTrue(isReadyBefore, "Token should be ready after first fetch") + + // Wait for session to expire (5 seconds + small buffer) + try await Task.sleep(nanoseconds: 5_500_000_000) + + // Check that isTokenReady 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 secondToken = try await passiveCaptchaChallenge.fetchToken() + XCTAssertNotNil(secondToken) + + // Verify token is ready again after successful refetch + let isReadyFinal = await passiveCaptchaChallenge.isTokenReady + XCTAssertTrue(isReadyFinal, "Token should be ready after refetch") + } } From 7fe7dcf879ddb23f0f9d84da9b609c70633d2e00 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Tue, 23 Dec 2025 11:47:56 -0500 Subject: [PATCH 07/17] lint --- .../ConfirmationChallenge/PassiveCaptchaChallengeTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift index 9008fbce0abd..1ef33e19a342 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 { From fdd6a4008a42af6ab347bce5671412882b758687 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Tue, 23 Dec 2025 12:17:04 -0500 Subject: [PATCH 08/17] not reusing hcaptcha on refetch --- .../PassiveCaptchaChallenge.swift | 73 +++++-------------- .../PassiveCaptchaChallengeTests.swift | 4 +- 2 files changed, 19 insertions(+), 58 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index dcbbc2ae8314..a347c28c3c26 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -42,26 +42,18 @@ struct PassiveCaptchaData: Equatable, Hashable { actor PassiveCaptchaChallenge { let passiveCaptchaData: PassiveCaptchaData + private let hcaptchaFactory: HCaptchaFactory + private var tokenTask: Task? var isTokenReady: Bool { // Fetched for the attach analytic. If session is expired, reset before the next fetch. let isSessionExpired = isSessionExpired() if isSessionExpired { self.tokenTask = nil self._hasToken = false - if let hcaptcha = self.hcaptcha { - Task { @MainActor in - hcaptcha.reset() - } - } } return _hasToken } - private let hcaptchaFactory: HCaptchaFactory - private var hcaptcha: HCaptcha? - private var hcaptchaCreationTask: Task? - private var tokenTask: Task? private var _hasToken: Bool = false private var sessionStartTime: Date? - private let sessionExpiration: TimeInterval public init(passiveCaptchaData: PassiveCaptchaData) { self.init(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory()) @@ -70,41 +62,10 @@ actor PassiveCaptchaChallenge { init(passiveCaptchaData: PassiveCaptchaData, hcaptchaFactory: HCaptchaFactory) { self.passiveCaptchaData = passiveCaptchaData self.hcaptchaFactory = hcaptchaFactory - self.sessionExpiration = hcaptchaFactory.sessionExpiration - Task { [weak self] in // Intentionally not blocking loading/initialization! - _ = try await self?.ensureHCaptchaCreated() - _ = try await self?.fetchToken() - } - } - - private func ensureHCaptchaCreated() async throws { - // If we already have a creation task, wait for it - if let hcaptchaCreationTask { - return try await hcaptchaCreationTask.value - } - - // Create the HCaptcha creation task - let creationTask = Task { [weak self] in - guard let self else { return } - STPAnalyticsClient.sharedClient.logPassiveCaptchaInit(siteKey: self.passiveCaptchaData.siteKey) - let startTime = Date() - do { - let hcaptcha = try await self.hcaptchaFactory.create(siteKey: self.passiveCaptchaData.siteKey, rqdata: self.passiveCaptchaData.rqdata) - await self.setHCaptcha(hcaptcha) - } catch { - STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: self.passiveCaptchaData.siteKey, duration: Date().timeIntervalSince(startTime)) - throw error - } - } - - self.hcaptchaCreationTask = creationTask - try await creationTask.value + Task { try await fetchToken() } // Intentionally not blocking loading/initialization! } public func fetchToken() async throws -> String { - // Ensure HCaptcha is created before fetching token - try await ensureHCaptchaCreated() - if let tokenTask { return try await withTaskCancellationHandler { try await tokenTask.value @@ -113,16 +74,18 @@ actor PassiveCaptchaChallenge { } } - let tokenTask = Task { [siteKey = passiveCaptchaData.siteKey, hcaptcha, weak self] () -> String in - STPAnalyticsClient.sharedClient.logPassiveCaptchaExecute(siteKey: siteKey) + let tokenTask = Task { [siteKey = passiveCaptchaData.siteKey, rqdata = passiveCaptchaData.rqdata, hcaptchaFactory, weak self] () -> String in + STPAnalyticsClient.sharedClient.logPassiveCaptchaInit(siteKey: siteKey) let startTime = Date() do { + let hcaptcha = try hcaptchaFactory.create(siteKey: siteKey, rqdata: rqdata) + STPAnalyticsClient.sharedClient.logPassiveCaptchaExecute(siteKey: siteKey) let result = try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in // Prevent Swift Task continuation misuse - the validate completion block can get called multiple times var nillableContinuation: CheckedContinuation? = continuation - hcaptcha?.validate { result in + hcaptcha.validate { result in Task { @MainActor in // MainActor to prevent continuation from different threads do { let token = try result.dematerialize() @@ -138,18 +101,20 @@ actor PassiveCaptchaChallenge { } } onCancel: { Task { @MainActor in - hcaptcha?.stop() + hcaptcha.stop() } } // Check cancellation after continuation try Task.checkCancellation() // Mark as complete await self?.setValidationComplete() - STPAnalyticsClient.sharedClient.logPassiveCaptchaSuccess(siteKey: siteKey, duration: Date().timeIntervalSince(startTime)) + let duration = Date().timeIntervalSince(startTime) + STPAnalyticsClient.sharedClient.logPassiveCaptchaSuccess(siteKey: siteKey, duration: duration) return result } catch { try Task.checkCancellation() - STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: siteKey, duration: Date().timeIntervalSince(startTime)) + let duration = Date().timeIntervalSince(startTime) + STPAnalyticsClient.sharedClient.logPassiveCaptchaError(error: error, siteKey: siteKey, duration: duration) throw error } } @@ -161,14 +126,10 @@ actor PassiveCaptchaChallenge { } } - private func setHCaptcha(_ hcaptcha: HCaptcha) { - self.hcaptcha = hcaptcha - } - private func isSessionExpired() -> Bool { // The session starts when we get our first token guard let sessionStartTime else { return false } // If sessionStartTime is nil, then we haven't gotten our first token back yet - return Date().timeIntervalSince(sessionStartTime) >= sessionExpiration + return Date().timeIntervalSince(sessionStartTime) >= hcaptchaFactory.sessionExpiration } private func setSessionStartTime(_ sessionStartTime: Date) { @@ -176,22 +137,22 @@ actor PassiveCaptchaChallenge { } private func setValidationComplete() { - self._hasToken = true + _hasToken = true } } // Protocol for creating HCaptcha instances protocol HCaptchaFactory { - func create(siteKey: String, rqdata: String?) throws -> HCaptcha var sessionExpiration: TimeInterval { get } + func create(siteKey: String, rqdata: String?) throws -> HCaptcha } struct PassiveHCaptchaFactory: 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 // After 29 minutes, we reset HCaptcha, and on confirmation, we fetch a new token - let sessionExpiration: TimeInterval = 29 * 60 + let sessionExpiration: TimeInterval = 10 func create(siteKey: String, rqdata: String?) throws -> HCaptcha { return try HCaptcha(apiKey: siteKey, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift index 1ef33e19a342..109e3dc6d591 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift @@ -112,8 +112,8 @@ class PassiveCaptchaChallengeTests: XCTestCase { let isReadyBefore = await passiveCaptchaChallenge.isTokenReady XCTAssertTrue(isReadyBefore, "Token should be ready after first fetch") - // Wait for session to expire (5 seconds + small buffer) - try await Task.sleep(nanoseconds: 5_500_000_000) + // Wait for session to expire + try await Task.sleep(nanoseconds: 5_000_000_000) // Check that isTokenReady triggers reset let isReadyAfter = await passiveCaptchaChallenge.isTokenReady From c7852970cadb22c315d37744d26975576e618105 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Tue, 23 Dec 2025 12:17:55 -0500 Subject: [PATCH 09/17] update sessionExpiration --- .../ConfirmationChallenge/PassiveCaptchaChallenge.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index a347c28c3c26..817bdcddd598 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -152,7 +152,7 @@ struct PassiveHCaptchaFactory: 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 // After 29 minutes, we reset HCaptcha, and on confirmation, we fetch a new token - let sessionExpiration: TimeInterval = 10 + let sessionExpiration: TimeInterval = 29 * 60 func create(siteKey: String, rqdata: String?) throws -> HCaptcha { return try HCaptcha(apiKey: siteKey, From dd59708ebf0aa0d5556a0ce9bbfb20a61021958e Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Tue, 23 Dec 2025 12:18:46 -0500 Subject: [PATCH 10/17] remove unused change --- StripePayments/StripePayments/Source/Captcha/HCaptcha.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift b/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift index e0d89a4cfdc2..bed510a70668 100644 --- a/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift +++ b/StripePayments/StripePayments/Source/Captcha/HCaptcha.swift @@ -177,7 +177,7 @@ import WebKit The reset is achieved by calling `hcaptcha.reset()` on the JS API. */ @objc - public func reset() { + func reset() { Log.debug(".reset") manager.reset() From 9e16d6c87412aa55ea5834b9804c2a1ebcc5869d Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Tue, 23 Dec 2025 12:20:15 -0500 Subject: [PATCH 11/17] update sessionStartTime --- .../ConfirmationChallenge/PassiveCaptchaChallenge.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index 817bdcddd598..c01b174da88c 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -48,6 +48,7 @@ actor PassiveCaptchaChallenge { let isSessionExpired = isSessionExpired() if isSessionExpired { self.tokenTask = nil + self.sessionStartTime = nil self._hasToken = false } return _hasToken @@ -127,8 +128,8 @@ actor PassiveCaptchaChallenge { } private func isSessionExpired() -> Bool { - // The session starts when we get our first token - guard let sessionStartTime else { return false } // If sessionStartTime is nil, then we haven't gotten our first token back yet + // The session starts when we get our token + guard let sessionStartTime else { return false } // If sessionStartTime is nil, then we haven't gotten our token back yet return Date().timeIntervalSince(sessionStartTime) >= hcaptchaFactory.sessionExpiration } From e8050cf653dcc2f9e2870b17bb9f981706ff0232 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Tue, 23 Dec 2025 12:43:29 -0500 Subject: [PATCH 12/17] simplify --- .../ConfirmationChallenge/PassiveCaptchaChallenge.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index c01b174da88c..279d2eda1c5b 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -45,8 +45,7 @@ actor PassiveCaptchaChallenge { private let hcaptchaFactory: HCaptchaFactory private var tokenTask: Task? var isTokenReady: Bool { // Fetched for the attach analytic. If session is expired, reset before the next fetch. - let isSessionExpired = isSessionExpired() - if isSessionExpired { + if isSessionExpired() { self.tokenTask = nil self.sessionStartTime = nil self._hasToken = false From f1f492733a09d62612f8dd528bfa2d64eadbf1ef Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Mon, 5 Jan 2026 10:17:43 -0500 Subject: [PATCH 13/17] pr feedback --- .../PassiveCaptchaChallenge.swift | 38 ++++++++++--------- .../PassiveCaptchaChallengeTests.swift | 6 +-- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index 279d2eda1c5b..fa0fa9f663bc 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -44,16 +44,8 @@ actor PassiveCaptchaChallenge { let passiveCaptchaData: PassiveCaptchaData private let hcaptchaFactory: HCaptchaFactory private var tokenTask: Task? - var isTokenReady: Bool { // Fetched for the attach analytic. If session is expired, reset before the next fetch. - if isSessionExpired() { - self.tokenTask = nil - self.sessionStartTime = nil - self._hasToken = false - } - return _hasToken - } - private var _hasToken: Bool = false - private var sessionStartTime: Date? + var isTokenReady: Bool = false + private var expirationResetTask: Task? public init(passiveCaptchaData: PassiveCaptchaData) { self.init(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory()) @@ -89,7 +81,7 @@ actor PassiveCaptchaChallenge { Task { @MainActor in // MainActor to prevent continuation from different threads do { let token = try result.dematerialize() - await self?.setSessionStartTime(Date()) + await self?.scheduleSessionExpirationReset() nillableContinuation?.resume(returning: token) nillableContinuation = nil } catch { @@ -126,18 +118,28 @@ actor PassiveCaptchaChallenge { } } - private func isSessionExpired() -> Bool { - // The session starts when we get our token - guard let sessionStartTime else { return false } // If sessionStartTime is nil, then we haven't gotten our token back yet - return Date().timeIntervalSince(sessionStartTime) >= hcaptchaFactory.sessionExpiration + private func scheduleSessionExpirationReset() { + let sessionExpiration = hcaptchaFactory.sessionExpiration + + // Cancel any existing expiration reset task + expirationResetTask?.cancel() + + // Schedule a task to automatically reset the token when the session expires + expirationResetTask = Task { [sessionExpiration] in + try? await Task.sleep(nanoseconds: UInt64(sessionExpiration * 1_000_000_000)) + guard !Task.isCancelled else { return } + self.resetSession() + } } - private func setSessionStartTime(_ sessionStartTime: Date) { - self.sessionStartTime = sessionStartTime + private func resetSession() { + self.tokenTask = nil + self.isTokenReady = false + self.expirationResetTask = nil } private func setValidationComplete() { - _hasToken = true + isTokenReady = true } } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift index 109e3dc6d591..7a47ad021eb3 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift @@ -112,10 +112,10 @@ class PassiveCaptchaChallengeTests: XCTestCase { let isReadyBefore = await passiveCaptchaChallenge.isTokenReady XCTAssertTrue(isReadyBefore, "Token should be ready after first fetch") - // Wait for session to expire - try await Task.sleep(nanoseconds: 5_000_000_000) + // Wait for session to expire (add small buffer for scheduling overhead) + try await Task.sleep(nanoseconds: 5_100_000_000) - // Check that isTokenReady triggers reset + // Check that expiration triggers reset let isReadyAfter = await passiveCaptchaChallenge.isTokenReady XCTAssertFalse(isReadyAfter, "Token should not be ready after session expiration") From 916a0d77061de81a163add8b9d2c80ecc124e767 Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Thu, 8 Jan 2026 15:35:33 -0500 Subject: [PATCH 14/17] pr feedback --- .../PassiveCaptchaChallenge.swift | 41 ++++++++++--------- .../PassiveCaptchaChallengeTests.swift | 29 +++++++++---- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index fa0fa9f663bc..bb6b01b19d7a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -44,8 +44,15 @@ actor PassiveCaptchaChallenge { let passiveCaptchaData: PassiveCaptchaData private let hcaptchaFactory: HCaptchaFactory private var tokenTask: Task? - var isTokenReady: Bool = false - private var expirationResetTask: Task? + 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()) @@ -58,6 +65,10 @@ actor PassiveCaptchaChallenge { } public func fetchToken() async throws -> String { + if hasSessionExpired { + resetSession() + } + if let tokenTask { return try await withTaskCancellationHandler { try await tokenTask.value @@ -81,7 +92,7 @@ actor PassiveCaptchaChallenge { Task { @MainActor in // MainActor to prevent continuation from different threads do { let token = try result.dematerialize() - await self?.scheduleSessionExpirationReset() + await self?.setSessionExpiration() nillableContinuation?.resume(returning: token) nillableContinuation = nil } catch { @@ -118,28 +129,18 @@ actor PassiveCaptchaChallenge { } } - private func scheduleSessionExpirationReset() { - let sessionExpiration = hcaptchaFactory.sessionExpiration - - // Cancel any existing expiration reset task - expirationResetTask?.cancel() - - // Schedule a task to automatically reset the token when the session expires - expirationResetTask = Task { [sessionExpiration] in - try? await Task.sleep(nanoseconds: UInt64(sessionExpiration * 1_000_000_000)) - guard !Task.isCancelled else { return } - self.resetSession() - } - } - private func resetSession() { self.tokenTask = nil - self.isTokenReady = false - self.expirationResetTask = nil + self.hasFetchedToken = false + self.sessionExpirationDate = nil + } + + private func setSessionExpiration() { + self.sessionExpirationDate = Date().addingTimeInterval(hcaptchaFactory.sessionExpiration) } private func setValidationComplete() { - isTokenReady = true + hasFetchedToken = true } } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift index 7a47ad021eb3..ead097ab5f9c 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift @@ -105,26 +105,41 @@ class PassiveCaptchaChallengeTests: XCTestCase { let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: testFactory) // Fetch first token - let firstToken = try await passiveCaptchaChallenge.fetchToken() - XCTAssertNotNil(firstToken) + let token = try await passiveCaptchaChallenge.fetchToken() + XCTAssertNotNil(token) // Verify token is ready - let isReadyBefore = await passiveCaptchaChallenge.isTokenReady + var isReadyBefore = await passiveCaptchaChallenge.isTokenReady XCTAssertTrue(isReadyBefore, "Token should be ready after first fetch") - // Wait for session to expire (add small buffer for scheduling overhead) - try await Task.sleep(nanoseconds: 5_100_000_000) + // 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 secondToken = try await passiveCaptchaChallenge.fetchToken() - XCTAssertNotNil(secondToken) + 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") } } From 3d916bfecae9f980f90bb7f5bade952b98a1817c Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Fri, 9 Jan 2026 09:51:40 -0500 Subject: [PATCH 15/17] pr feedback --- .../PassiveCaptchaChallenge.swift | 14 ++++++-------- .../ConfirmationChallengeTests.swift | 2 -- .../PassiveCaptchaChallengeTests.swift | 19 ++++--------------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index bb6b01b19d7a..d1b57693cc1a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -43,6 +43,7 @@ struct PassiveCaptchaData: Equatable, Hashable { actor PassiveCaptchaChallenge { let passiveCaptchaData: PassiveCaptchaData private let hcaptchaFactory: HCaptchaFactory + private let sessionExpiration: TimeInterval private var tokenTask: Task? var isTokenReady: Bool { // used for the attach analytic to indicate whether it's blocking checkout return hasFetchedToken && !hasSessionExpired @@ -58,9 +59,12 @@ actor PassiveCaptchaChallenge { 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! } @@ -136,7 +140,7 @@ actor PassiveCaptchaChallenge { } private func setSessionExpiration() { - self.sessionExpirationDate = Date().addingTimeInterval(hcaptchaFactory.sessionExpiration) + self.sessionExpirationDate = Date().addingTimeInterval(sessionExpiration) } private func setValidationComplete() { @@ -147,16 +151,10 @@ actor PassiveCaptchaChallenge { // Protocol for creating HCaptcha instances protocol HCaptchaFactory { - var sessionExpiration: TimeInterval { get } func create(siteKey: String, rqdata: String?) throws -> HCaptcha } struct PassiveHCaptchaFactory: 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 - // After 29 minutes, we reset HCaptcha, and on confirmation, we fetch a new token - let sessionExpiration: TimeInterval = 29 * 60 - func create(siteKey: String, rqdata: String?) throws -> HCaptcha { return try HCaptcha(apiKey: siteKey, passiveApiKey: true, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/ConfirmationChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/ConfirmationChallengeTests.swift index 88ce89e2950f..dcec2dce415d 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/ConfirmationChallengeTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/ConfirmationChallengeTests.swift @@ -68,8 +68,6 @@ class ConfirmationChallengeTests: XCTestCase { /// A test-only HCaptcha factory that delays token responses to ensure timeout behavior struct TestDelayHCaptchaFactory: HCaptchaFactory { - let sessionExpiration: TimeInterval = 29 * 60 - func create(siteKey: String, rqdata: String?) throws -> HCaptcha { let hcaptcha = try HCaptcha(apiKey: siteKey, passiveApiKey: true, diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift index ead097ab5f9c..aa23a8012dc9 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallengeTests.swift @@ -32,23 +32,13 @@ class PassiveCaptchaChallengeTests: XCTestCase { super.tearDown() } - struct TestHCaptchaFactory: HCaptchaFactory { - let shouldInjectDelay: Bool - let sessionExpiration: TimeInterval - - init(shouldInjectDelay: Bool = false, sessionExpiration: TimeInterval = 29 * 60) { - self.shouldInjectDelay = shouldInjectDelay - self.sessionExpiration = sessionExpiration - } - + struct TestDelayHCaptchaFactory: HCaptchaFactory { func create(siteKey: String, rqdata: String?) throws -> HCaptcha { let hcaptcha = try HCaptcha(apiKey: siteKey, passiveApiKey: true, rqdata: rqdata, host: "stripecdn.com") - if shouldInjectDelay { - hcaptcha.manager.shouldDelayToken = true - } + hcaptcha.manager.shouldDelayToken = true return hcaptcha } } @@ -73,7 +63,7 @@ class PassiveCaptchaChallengeTests: XCTestCase { func testPassiveCaptchaTimeout() async throws { let passiveCaptchaData = PassiveCaptchaData(siteKey: siteKey, rqdata: nil) - let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: TestHCaptchaFactory(shouldInjectDelay: true)) + let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: TestDelayHCaptchaFactory()) let startTime = Date() let hcaptchaTokenResult = await withTimeout(1) { try await passiveCaptchaChallenge.fetchToken() @@ -101,8 +91,7 @@ class PassiveCaptchaChallengeTests: XCTestCase { func testTokenResetAndRefetchAfterExpiration() async throws { let passiveCaptchaData = PassiveCaptchaData(siteKey: siteKey, rqdata: nil) // Use a very short expiration time (5 seconds) for testing - let testFactory = TestHCaptchaFactory(sessionExpiration: 5.0) - let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: testFactory) + let passiveCaptchaChallenge = PassiveCaptchaChallenge(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory(), sessionExpiration: 5.0) // Fetch first token let token = try await passiveCaptchaChallenge.fetchToken() From 55e7c79d78c53b3c61733d2d587f7b8389328b8f Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Fri, 9 Jan 2026 09:52:35 -0500 Subject: [PATCH 16/17] rename; --- .../ConfirmationChallenge/PassiveCaptchaChallenge.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index d1b57693cc1a..ca70aa2c11f3 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -96,7 +96,7 @@ actor PassiveCaptchaChallenge { Task { @MainActor in // MainActor to prevent continuation from different threads do { let token = try result.dematerialize() - await self?.setSessionExpiration() + await self?.setSessionExpirationDate() nillableContinuation?.resume(returning: token) nillableContinuation = nil } catch { @@ -139,7 +139,7 @@ actor PassiveCaptchaChallenge { self.sessionExpirationDate = nil } - private func setSessionExpiration() { + private func setSessionExpirationDate() { self.sessionExpirationDate = Date().addingTimeInterval(sessionExpiration) } From 7fb045d44c86bed22580a93ccde0279a9cb7b39e Mon Sep 17 00:00:00 2001 From: Joyce Qin Date: Fri, 9 Jan 2026 09:55:42 -0500 Subject: [PATCH 17/17] lint --- .../ConfirmationChallenge/PassiveCaptchaChallenge.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift index ca70aa2c11f3..d1ae0594523f 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ConfirmationChallenge/PassiveCaptchaChallenge.swift @@ -59,8 +59,7 @@ actor PassiveCaptchaChallenge { self.init(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory()) } - init(passiveCaptchaData: PassiveCaptchaData, hcaptchaFactory: HCaptchaFactory, - sessionExpiration: TimeInterval = 29 * 60) { + 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