Skip to content

Commit

Permalink
Merge branch 'joyceqin-MOBILESDK-2882' of github.com:stripe/stripe-io…
Browse files Browse the repository at this point in the history
…s into joyceqin-MOBILESDK-2882
  • Loading branch information
joyceqin-stripe committed Dec 20, 2024
2 parents 76a5957 + f0d53b5 commit cf2e776
Show file tree
Hide file tree
Showing 24 changed files with 365 additions and 128 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,10 @@ class ExampleEmbeddedElementCheckoutViewController: UIViewController {
self.computedTotals = ComputedTotals(subtotal: subtotal, tax: tax, total: total)

// MARK: - Create a EmbeddedPaymentElement instance
var configuration = EmbeddedPaymentElement.Configuration(
formSheetAction: .confirm(completion: { [weak self] result in
self?.handlePaymentResult(result)
})
)
var configuration = EmbeddedPaymentElement.Configuration()
configuration.formSheetAction = .confirm(completion: { [weak self] result in
self?.handlePaymentResult(result)
})
// This example displays the buy button in a screen that is separate from screen that displays the embedded view, so we disable the mandate text in the embedded view and show it near our buy button.
configuration.embeddedViewDisplaysMandateText = false
configuration.merchantDisplayName = "Example, Inc."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ class PlaygroundController: ObservableObject {
}
}()

var configuration = EmbeddedPaymentElement.Configuration(formSheetAction: formSheetAction)
var configuration = EmbeddedPaymentElement.Configuration()
configuration.formSheetAction = formSheetAction
configuration.embeddedViewDisplaysMandateText = settings.embeddedViewDisplaysMandateText == .on
configuration.externalPaymentMethodConfiguration = externalPaymentMethodConfiguration
switch settings.externalPaymentMethods {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import DeviceCheck
import Foundation

@_spi(STP) public protocol AppAttestService {
var isSupported: Bool { get }
nonisolated var isSupported: Bool { get }
func generateKey() async throws -> String
func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data
func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data
Expand Down
39 changes: 26 additions & 13 deletions StripeCore/StripeCore/Source/Attestation/StripeAttest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import DeviceCheck
import Foundation
import UIKit

@_spi(STP) public class StripeAttest {
@_spi(STP) public actor StripeAttest {
/// Initialize a new StripeAttest object with the specified STPAPIClient.
@_spi(STP) public convenience init(apiClient: STPAPIClient = .shared) {
@_spi(STP) public init(apiClient: STPAPIClient = .shared) {
self.init(appAttestService: AppleAppAttestService.shared,
appAttestBackend: StripeAPIAttestationBackend(apiClient: apiClient),
apiClient: apiClient)
Expand All @@ -29,7 +29,7 @@ import UIKit
STPAnalyticsClient.sharedClient.log(analytic: errorAnalytic, apiClient: apiClient)
if apiClient.isTestmode {
// In testmode, we can provide a test assertion even if the real assertion fails
return testmodeAssertion
return await testmodeAssertion()
} else {
throw error
}
Expand Down Expand Up @@ -61,7 +61,7 @@ import UIKit
}

/// Returns whether the device is capable of performing attestation.
@_spi(STP) public var isSupported: Bool {
@_spi(STP) nonisolated public var isSupported: Bool {
return appAttestService.isSupported
}

Expand Down Expand Up @@ -163,9 +163,12 @@ import UIKit
}

/// A wrapper for the DCAppAttestService service.
var appAttestService: AppAttestService
/// Marked as nonisolated as it can not be reassigned during the lifetime
/// of StripeAttest, and isolation is handled by the AppAttestService itself
/// (Either DCAppAttestService or our MockAppAttestService)
nonisolated let appAttestService: AppAttestService
/// A network backend for the /challenge and /attest endpoints.
var appAttestBackend: StripeAttestBackend
let appAttestBackend: StripeAttestBackend
/// The API client to use for network requests
var apiClient: STPAPIClient

Expand All @@ -180,16 +183,26 @@ import UIKit
/// You should not call this directly, it'll be called automatically during assert.
/// Returns nothing on success, throws on failure.
func attest() async throws {
do {
if let existingTask = attestationTask {
return try await existingTask.value
}

let task = Task<Void, Error> {
try await _attest()
let successAnalytic = GenericAnalytic(event: .attestationSucceeded, params: [:])
STPAnalyticsClient.sharedClient.log(analytic: successAnalytic, apiClient: apiClient)
}
attestationTask = task
defer { attestationTask = nil } // Clear the task after it's done
do {
try await task.value
} catch {
let errorAnalytic = ErrorAnalytic(event: .attestationFailed, error: error)
STPAnalyticsClient.sharedClient.log(analytic: errorAnalytic, apiClient: apiClient)
throw error
}
}
private var attestationTask: Task<Void, Error>?

func _assert() async throws -> Assertion {
let keyId = try await self.getOrCreateKeyID()
Expand All @@ -209,7 +222,7 @@ import UIKit
throw AttestationError.shouldAttestButKeyIsAlreadyAttested
}

let deviceId = try getDeviceID()
let deviceId = try await getDeviceID()
let appId = try getAppID()

let assertion = try await generateAssertion(keyId: keyId, challenge: challenge.challenge)
Expand Down Expand Up @@ -238,7 +251,7 @@ import UIKit
}
let hash = Data(SHA256.hash(data: challengeData))

let deviceId = try getDeviceID()
let deviceId = try await getDeviceID()
let appId = try getAppID()

do {
Expand Down Expand Up @@ -303,8 +316,8 @@ import UIKit
throw AttestationError.noAppID
}

func getDeviceID() throws -> String {
if let deviceID = UIDevice.current.identifierForVendor?.uuidString {
func getDeviceID() async throws -> String {
if let deviceID = await UIDevice.current.identifierForVendor?.uuidString {
return deviceID
}
throw AttestationError.noDeviceID
Expand Down Expand Up @@ -352,9 +365,9 @@ import UIKit
}
}

private var testmodeAssertion: Assertion {
private func testmodeAssertion() async -> Assertion {
Assertion(assertionData: Data(bytes: [0x01, 0x02, 0x03], count: 3),
deviceID: (try? getDeviceID()) ?? "test-device-id",
deviceID: (try? await getDeviceID()) ?? "test-device-id",
appID: (try? getAppID()) ?? "com.example.test",
keyID: "TestKeyID")
}
Expand Down
22 changes: 19 additions & 3 deletions StripeCore/StripeCoreTests/Attestation/MockAppAttestService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import DeviceCheck
@testable @_spi(STP) import StripeCore
import UIKit

class MockAppAttestService: AppAttestService {
actor MockAppAttestService: AppAttestService {
@_spi(STP) public static var shared = MockAppAttestService()

@_spi(STP) public var isSupported: Bool {
@_spi(STP) public nonisolated var isSupported: Bool {
if #available(iOS 14.0, *) {
return true
} else {
Expand All @@ -26,6 +26,22 @@ class MockAppAttestService: AppAttestService {
var shouldFailAttestationWithError: Error?
var attestationUsingDevelopmentEnvironment: Bool = false

func setShouldFailKeygenWithError(_ error: Error?) async {
shouldFailKeygenWithError = error
}

func setShouldFailAssertionWithError(_ error: Error?) async {
shouldFailAssertionWithError = error
}

func setShouldFailAttestationWithError(_ error: Error?) async {
shouldFailAttestationWithError = error
}

func setAttestationUsingDevelopmentEnvironment(_ value: Bool) async {
attestationUsingDevelopmentEnvironment = value
}

var keys: [String: FakeKey] = [:]

struct FakeKey: Codable {
Expand Down Expand Up @@ -71,7 +87,7 @@ class MockAppAttestService: AppAttestService {
return try JSONSerialization.data(withJSONObject: attestation)
}

@_spi(STP) public func attestationDataIsDevelopmentEnvironment(_ data: Data) -> Bool {
@_spi(STP) public nonisolated func attestationDataIsDevelopmentEnvironment(_ data: Data) -> Bool {
let decodedKey = try! JSONSerialization.jsonObject(with: data) as! [String: Any]
return decodedKey["isDevelopmentEnvironment"] as! Bool
}
Expand Down
39 changes: 28 additions & 11 deletions StripeCore/StripeCoreTests/Attestation/StripeAttestTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ class StripeAttestTest: XCTestCase {
self.mockAttestService = MockAppAttestService()
self.stripeAttest = StripeAttest(appAttestService: mockAttestService, appAttestBackend: mockAttestBackend, apiClient: apiClient)

let expectation = self.expectation(description: "Wait for setup")
// Reset storage
UserDefaults.standard.removeObject(forKey: self.stripeAttest.defaultsKeyForSetting(.lastAttestedDate))
stripeAttest.resetKey()
Task { @MainActor in
await UserDefaults.standard.removeObject(forKey: self.stripeAttest.defaultsKeyForSetting(.lastAttestedDate))
await stripeAttest.resetKey()
expectation.fulfill()
}
waitForExpectations(timeout: 5)
}

func testAppAttestService() async {
Expand All @@ -39,7 +44,7 @@ class StripeAttestTest: XCTestCase {
try! await stripeAttest.attest()
let invalidKeyError = NSError(domain: DCErrorDomain, code: DCError.invalidKey.rawValue, userInfo: nil)
// But fail the assertion, causing the key to be reset
mockAttestService.shouldFailAssertionWithError = invalidKeyError
await mockAttestService.setShouldFailAssertionWithError(invalidKeyError)
do {
_ = try await stripeAttest.assert()
XCTFail("Should not succeed")
Expand All @@ -51,11 +56,11 @@ class StripeAttestTest: XCTestCase {

func testCanAttestAsMuchAsNeededInDev() async {
// Create and attest a key in the dev environment
mockAttestService.attestationUsingDevelopmentEnvironment = true
await mockAttestService.setAttestationUsingDevelopmentEnvironment(true)
try! await stripeAttest.attest()
let invalidKeyError = NSError(domain: DCErrorDomain, code: DCError.invalidKey.rawValue, userInfo: nil)
// But fail the assertion, which will cause us to try to re-attest the key
mockAttestService.shouldFailAssertionWithError = invalidKeyError
await mockAttestService.setShouldFailAssertionWithError(invalidKeyError)
do {
_ = try await stripeAttest.assert()
XCTFail("Should not succeed")
Expand All @@ -74,10 +79,10 @@ class StripeAttestTest: XCTestCase {
// Create and attest a key
try! await stripeAttest.attest()
// But it's an old key, so we'll be allowed to attest a new one
UserDefaults.standard.set(Date.distantPast, forKey: self.stripeAttest.defaultsKeyForSetting(.lastAttestedDate))
await UserDefaults.standard.set(Date.distantPast, forKey: self.stripeAttest.defaultsKeyForSetting(.lastAttestedDate))
// Always fail the assertions and don't remember attestations:
let invalidKeyError = NSError(domain: DCErrorDomain, code: DCError.invalidKey.rawValue, userInfo: nil)
mockAttestService.shouldFailAssertionWithError = invalidKeyError
await mockAttestService.setShouldFailAssertionWithError(invalidKeyError)

_ = try await stripeAttest.assert()
XCTFail("Should not succeed")
Expand All @@ -87,7 +92,7 @@ class StripeAttestTest: XCTestCase {
}

func testNoPublishableKey() async {
stripeAttest.apiClient.publishableKey = nil
await stripeAttest.apiClient.publishableKey = nil
do {
// Create and attest a key
try await stripeAttest.attest()
Expand All @@ -99,14 +104,26 @@ class StripeAttestTest: XCTestCase {

func testAssertionsNotRequiredInTestMode() async {
// Configure a test merchant PK:
stripeAttest.apiClient.publishableKey = "pk_test_abc123"
await stripeAttest.apiClient.publishableKey = "pk_test_abc123"
// And reset the last attestation date:
UserDefaults.standard.removeObject(forKey: self.stripeAttest.defaultsKeyForSetting(.lastAttestedDate))
await UserDefaults.standard.removeObject(forKey: self.stripeAttest.defaultsKeyForSetting(.lastAttestedDate))
// Fail the assertion, which will cause us to try to re-attest the key, but then the
// assertions still won't work, so we'll send the testmode data instead.
let invalidKeyError = NSError(domain: DCErrorDomain, code: DCError.invalidKey.rawValue, userInfo: nil)
mockAttestService.shouldFailAssertionWithError = invalidKeyError
await mockAttestService.setShouldFailAssertionWithError(invalidKeyError)
let assertion = try! await stripeAttest.assert()
XCTAssertEqual(assertion.keyID, "TestKeyID")
}

func testConcurrentAssertionsAndAttestations() async {
let iterations = 500
try! await withThrowingTaskGroup(of: Void.self) { group in
for _ in 0..<iterations {
group.addTask {
try await self.stripeAttest.assert()
}
}
try await group.waitForAll()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -437,4 +437,11 @@ extension String.Localized {
"Promotional text for Affirm, displayed in a button that lets the customer pay with Affirm"
)
}

static var default_text: String {
STPLocalizedString(
"Default",
"Label for identifying the default payment method."
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@ extension STPAPIClient {
parameters: parameters,
ephemeralKeySecret: publishableKey
) { (result: Result<ConsumerSession.LookupResponse, Error>) in
// If there's an assertion error, send it to StripeAttest
if useMobileEndpoints,
case .failure(let error) = result,
Self.isLinkAssertionError(error: error) {
StripeAttest(apiClient: self).receivedAssertionError(error)
Task { @MainActor in
// If there's an assertion error, send it to StripeAttest
if useMobileEndpoints,
case .failure(let error) = result,
Self.isLinkAssertionError(error: error) {
await StripeAttest(apiClient: self).receivedAssertionError(error)
}
completion(result)
}
completion(result)
}
}
}
Expand Down Expand Up @@ -115,14 +117,16 @@ extension STPAPIClient {
resource: useMobileEndpoints ? modernEndpoint : legacyEndpoint,
parameters: parameters
) { (result: Result<ConsumerSession.SessionWithPublishableKey, Error>) in
// If there's an assertion error, send it to StripeAttest
if useMobileEndpoints,
case .failure(let error) = result,
Self.isLinkAssertionError(error: error) {
StripeAttest(apiClient: self).receivedAssertionError(error)
Task { @MainActor in
// If there's an assertion error, send it to StripeAttest
if useMobileEndpoints,
case .failure(let error) = result,
Self.isLinkAssertionError(error: error) {
await StripeAttest(apiClient: self).receivedAssertionError(error)
}

completion(result)
}

completion(result)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ struct ElementsCustomer: Equatable, Hashable {
return ElementsCustomer(paymentMethods: paymentMethods, defaultPaymentMethod: defaultPaymentMethod, customerSession: customerSession)
}

func getDefaultPaymentMethod() -> STPPaymentMethod? {
return paymentMethods.first { $0.stripeId == defaultPaymentMethod }
}

func getDefaultOrFirstPaymentMethod() -> STPPaymentMethod? {
// if customer has a default payment method from the elements session, return the default payment method
let defaultSavedPaymentMethod = paymentMethods.first { $0.stripeId == defaultPaymentMethod }
if let defaultSavedPaymentMethod = defaultSavedPaymentMethod {
return defaultSavedPaymentMethod
}
// otherwise, return the first payment method from the customer's list of saved payment methods
return paymentMethods.first
return getDefaultPaymentMethod() ?? paymentMethods.first
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ extension LinkPaymentMethodPicker {

private let defaultBadge = LinkBadgeView(
type: .neutral,
text: STPLocalizedString("Default", "Label for identifying the default payment method.")
text: String.Localized.default_text
)

private let alertIconView: UIImageView = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,12 @@ extension EmbeddedPaymentElement {
completion: (EmbeddedPaymentElementResult) -> Void
)

/// The button says “Continue”. When tapped, the form sheet closes.
/// The button says “Continue”. When tapped, the form sheet closes. The customer can confirm payment or setup back in your app.
case `continue`
}

/// The view can display payment methods like “Card” that, when tapped, open a sheet where customers enter their payment method details. The sheet has a button at the bottom. `formSheetAction` controls the action the button performs.
public var formSheetAction: FormSheetAction
public var formSheetAction: FormSheetAction = .continue

/// Controls whether the view displays mandate text at the bottom for payment methods that require it. If set to `false`, your integration must display `PaymentOptionDisplayData.mandateText` to the customer near your “Buy” button to comply with regulations.
/// - Note: This doesn't affect mandates displayed in the form sheet.
Expand All @@ -161,8 +161,6 @@ extension EmbeddedPaymentElement {
internal var linkPaymentMethodsOnly: Bool = false

/// Initializes a Configuration with default values
public init(formSheetAction: FormSheetAction) {
self.formSheetAction = formSheetAction
}
public init() {}
}
}
Loading

0 comments on commit cf2e776

Please sign in to comment.