Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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 @@ -42,7 +42,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,16 @@ actor PassiveCaptchaChallenge {
let passiveCaptchaData: PassiveCaptchaData
private let hcaptchaFactory: HCaptchaFactory
private var tokenTask: Task<String, Error>?
var hasFetchedToken = false
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?

public init(passiveCaptchaData: PassiveCaptchaData) {
self.init(passiveCaptchaData: passiveCaptchaData, hcaptchaFactory: PassiveHCaptchaFactory())
Expand Down Expand Up @@ -80,6 +89,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 {
Expand Down Expand Up @@ -116,18 +126,34 @@ 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 setSessionStartTime(_ sessionStartTime: Date) {
self.sessionStartTime = sessionStartTime
}

private func setValidationComplete() {
hasFetchedToken = true
_hasToken = true
}

}

// 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,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
try await Task.sleep(nanoseconds: 5_000_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")
}
}
Loading