Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c5b614c
retry
joyceqin-stripe Nov 19, 2025
ff13db2
retry if expired once
joyceqin-stripe Nov 24, 2025
95e7d7e
revert hcaptcharesult public
joyceqin-stripe Nov 24, 2025
fc3c6fc
Merge branch 'master' into joyceqin/captcha-token-retry
joyceqin-stripe Nov 24, 2025
9417f29
update session expiry logic
joyceqin-stripe Nov 24, 2025
1822bd8
Remove resetOnError
joyceqin-stripe Nov 24, 2025
b99f8bb
Merge branch 'master' into joyceqin/captcha-token-retry
joyceqin-stripe Dec 23, 2025
84e4b66
add test and ensure hcaptcha created
joyceqin-stripe Dec 23, 2025
7fe7dcf
lint
joyceqin-stripe Dec 23, 2025
fdd6a40
not reusing hcaptcha on refetch
joyceqin-stripe Dec 23, 2025
c785297
update sessionExpiration
joyceqin-stripe Dec 23, 2025
dd59708
remove unused change
joyceqin-stripe Dec 23, 2025
9e16d6c
update sessionStartTime
joyceqin-stripe Dec 23, 2025
e8050cf
simplify
joyceqin-stripe Dec 23, 2025
f1f4927
pr feedback
joyceqin-stripe Jan 5, 2026
cea2abb
Merge branch 'master' into joyceqin/captcha-token-retry
joyceqin-stripe Jan 5, 2026
7ae40fe
Merge branch 'master' into joyceqin/captcha-token-retry
joyceqin-stripe Jan 5, 2026
321968c
Merge branch 'master' into joyceqin/captcha-token-retry
joyceqin-stripe Jan 6, 2026
4737a34
Merge branch 'master' into joyceqin/captcha-token-retry
joyceqin-stripe Jan 7, 2026
916a0d7
pr feedback
joyceqin-stripe Jan 8, 2026
41ea079
Merge branch 'master' into joyceqin/captcha-token-retry
joyceqin-stripe Jan 8, 2026
3d916bf
pr feedback
joyceqin-stripe Jan 9, 2026
55e7c79
rename;
joyceqin-stripe Jan 9, 2026
8e7e8b4
Merge branch 'master' into joyceqin/captcha-token-retry
joyceqin-stripe Jan 9, 2026
7fb045d
lint
joyceqin-stripe Jan 9, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

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

IsTokenReady seems to be used for analytics, and we are changing the underlying definition of it -- I'm guessing we're prepared to update any dashboards/queries to filter out older clients with the older definition?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

HCaptcha on MPE is not public yet, so this isn't affecting anything.

let getPassiveCaptchaToken: () async throws -> String? = {
guard let passiveCaptchaChallenge = self.passiveCaptchaChallenge else {
return nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,15 @@ actor PassiveCaptchaChallenge {
let passiveCaptchaData: PassiveCaptchaData
private let hcaptchaFactory: HCaptchaFactory
private var tokenTask: Task<String, Error>?
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())
Expand All @@ -57,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
Expand All @@ -80,6 +92,7 @@ actor PassiveCaptchaChallenge {
Task { @MainActor in // MainActor to prevent continuation from different threads
do {
let token = try result.dematerialize()
await self?.setSessionExpiration()
nillableContinuation?.resume(returning: token)
nillableContinuation = nil
} catch {
Expand Down Expand Up @@ -116,6 +129,16 @@ actor PassiveCaptchaChallenge {
}
}

private func resetSession() {
self.tokenTask = nil
self.hasFetchedToken = false
self.sessionExpirationDate = nil
}

private func setSessionExpiration() {
self.sessionExpirationDate = Date().addingTimeInterval(hcaptchaFactory.sessionExpiration)
}

private func setValidationComplete() {
hasFetchedToken = true
}
Expand All @@ -124,10 +147,16 @@ 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -32,36 +32,48 @@ 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" })
XCTAssertEqual(successAnalytic?["site_key"] as? String, siteKey)
}

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()
Expand All @@ -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()
Expand All @@ -86,4 +97,49 @@ 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 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")
}
}
Loading