Repository navigation
feat: pass optional primaryAccountIdentifier to Apple Wallet #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fcc4cdd
794afd5
25154d3
078464e
32556d9
934dc35
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -166,9 +166,11 @@ Here you can find data elements used in the library, essential to work with Goog | |
| - **Certificates** - an array of NSData objects. Each object contains a DER encoded X.509 certificate, with the leaf first and root last. You must download the root CA to validate the entire chain. | ||
| - **Encrypted Pass Data**- an encrypted JSON file containing the sensitive information needed to add a card to Apple Pay (Base 64 encoded). [More details here.](https://developer.apple.com/documentation/passkit/pkaddpaymentpassrequest/encryptedpassdata) | ||
| - **Ephemeral Public Key** - a key used by elliptic curve cryptography (ECC) (Base 64 encoded). | ||
| - **Primary Account Identifier** - an opaque issuer id (also called the FPAN ID) that PassKit uses to recognize the same card across reissues and devices. Pass it in `IOSCardData` and to `canAddCardToAppleWallet`. On iOS, `getCardStatusByIdentifier` uses this same value. If no pass matches the given id exactly, the library uses the id of the only pass with the same last digits. | ||
| - **Device type** - the device the card is being provisioned to: `phone` or `watch`. PassKit does not report the device the user selected, so the library infers it from where the card is already provisioned. When it cannot tell, the value is `unknown`. | ||
|
|
||
| # API Reference | ||
| The library offers seven functions for seamless integration and use of the Apple Wallet and Google Wallet APIs. Additionally, it includes one listener that informs when the added card has been activated. Below, these functions are described along with the data types involved. | ||
| The library offers nine functions for seamless integration and use of the Apple Wallet and Google Wallet APIs. Additionally, it includes one listener that informs when the added card has been activated. Below, these functions are described along with the data types involved. | ||
|
|
||
| ## Functions | ||
|
|
||
|
|
@@ -181,7 +183,8 @@ The library offers seven functions for seamless integration and use of the Apple | |
| | **addCardToGoogleWallet** | Initiates native Push Provisioning flow for adding a card to the Google Wallet. | `data`: `AndroidCardData` | `TokenizationStatus` | ❌ | ✅ | | ||
| | **resumeAddCardToGoogleWallet** | Resumes the Push Provisioning flow for adding a card to the Google Wallet using existing token reference ID. | `data`: `AndroidResumeCardData` | `TokenizationStatus` | ❌ | ✅ | | ||
| | **listTokens** | Lists all tokens currently stored in the Google Wallet. | None | `TokenInfo[]` | ❌ | ✅ | | ||
| | **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. | `data`: `IOSCardData`,<br>`issuerEncrypt-`<br>`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | | ||
| | **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. When `IOSCardData.primaryAccountIdentifier` is set, the device picker only lists devices that do not already have the card. If PassKit cannot present the flow because the card is already on every device, the call fails with a clear error. | `data`: `IOSCardData`,<br>`issuerEncrypt-`<br>`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | | ||
| | **canAddCardToAppleWallet** | Checks whether the card can still be added to any of the user's devices (iPhone or paired Apple Watch). | `primaryAccountIdentifier: string` | `boolean` | ✅ | ❌ | | ||
|
|
||
|
|
||
| ## Data Types | ||
|
|
@@ -192,9 +195,11 @@ The library offers seven functions for seamless integration and use of the Apple | |
| | **AndroidCardData** | Data related to a card that is to be added on Android platform wallets. | `network: string`,<br>`opaquePaymentCard: string`,<br>`cardHolderName: string`,<br>`lastDigits: string`,<br>`userAddress: UserAddress` | | ||
| | **AndroidResumeCardData** | Simplified data structure for resuming card addition to Google Wallet using existing token reference ID. | `network: string`,<br>`tokenReferenceID: string`,<br>`cardHolderName?: string`,<br>`lastDigits?: string` | | ||
| | **UserAddress** | Structured address used for cardholder verification. | `name: string`,<br>`addressOne: string`,<br>`addressTwo: string`,<br>`city: string`,<br>`administrativeArea: string`,<br>`countryCode: string`,<br>`postalCode: string`,<br>`phoneNumber: string` | | ||
| | **IOSCardData** | Data related to a card that is to be added on iOS platform. | `network: string`,<br>`activationData: string`,<br>`encryptedPassData: string`,<br>`ephemeralPublicKey: string`,<br>`cardHolderTitle: string`,<br>`cardHolderName: string`,<br>`lastDigits: string`,<br>`cardDescription: string`,<br>`cardDescriptionComment: string` | | ||
| | **IOSCardData** | Data related to a card that is to be added on iOS. Set `primaryAccountIdentifier` after the first provisioning so the next attempt targets devices that do not already have the card. | `network: string`,<br>`activationData: string`,<br>`encryptedPassData: string`,<br>`ephemeralPublicKey: string`,<br>`cardHolderTitle: string`,<br>`cardHolderName: string`,<br>`lastDigits: string`,<br>`cardDescription: string`,<br>`cardDescriptionComment: string`,<br>`primaryAccountIdentifier?: string` | | ||
| | **onCardActivatedPayload** | Data used by listener to notice when a card's status changes. | `tokenId: string`,<br> `status: 'activated' \| 'canceled'`<br> | | ||
| | **IOSIssuerCallback** | This callback is invoked with a nonce, its signature, and a certificate array obtained from Apple. It is expected that you will forward these details to your server or the card issuer's API to securely encrypt the payload required for adding cards to the Apple Wallet. | `(nonce: string, nonceSignature: string, certificate: string[]) => IOSEncryptPayload` | | ||
| | **IOSIssuerCallback** | This callback is invoked with a nonce, its signature, a certificate array obtained from Apple, and the device the card is being added to. Forward these details to your server or the card issuer's API to securely encrypt the payload required for adding cards to the Apple Wallet. `deviceType` is a best-effort value; see `IOSDeviceType`. | `(nonce: string, nonceSignature: string, certificate: string[], deviceType: IOSDeviceType) => IOSEncryptPayload` | | ||
| | **IOSDeviceType** | Best-effort device the card is being provisioned to. PassKit does not report the selected device, so this is inferred from existing passes when `primaryAccountIdentifier` is set. If no pass matches that id exactly, the library uses the id of the only pass with the same last digits. Passes on a paired Apple Watch are never counted as iPhone passes. `phone` means the card is already on the paired Apple Watch only. `watch` means it is already on the iPhone only. `unknown` means the card is on neither device, or no identifier was provided, so the user may still choose either device. Passes in the `deactivated` state count as not present. | `'phone' \| 'watch' \| 'unknown'` | | ||
| | **IOSAddPaymentPassData** | Data Apple returns when the user continues the provisioning flow, including the device the card is being added to. | `status: number`,<br>`nonce: string`,<br>`nonceSignature: string`,<br>`certificates: string[]`,<br>`deviceType?: string` | | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apple does not return |
||
| | **IOSEncryptPayload** | An object containing the necessary elements to complete the addition of a card to Apple Wallet. | `encryptedPassData: string`,<br>`activationData: string`,<br>`ephemeralPublicKey: string` | | ||
| | **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,<br>`lastDigits: string`,<br>`tokenState: number` | | ||
|
|
||
|
|
@@ -267,6 +272,7 @@ const base64ToHex = (base64: string): string => Buffer.from(base64, "base64").to | |
| nonce: string, | ||
| nonceSignature: string, | ||
| certificate: string[], | ||
| deviceType: IOSDeviceType, | ||
| ): Promise<IOSEncryptPayload> => { | ||
| try { | ||
| const payload = { | ||
|
|
@@ -275,6 +281,7 @@ const base64ToHex = (base64: string): string => Buffer.from(base64, "base64").to | |
| nonceSignature: base64ToHex(nonceSignature), | ||
| wspCertificates: certificate, | ||
| }, | ||
| deviceType, | ||
| serialNumber, | ||
| wspId: "APPLE" as const, | ||
| } | ||
|
|
@@ -301,7 +308,7 @@ The app will need to be reviewed by Google. During this process, it will need to | |
| ### iOS | ||
|
|
||
| When implementing the In-App Push Provisioning feature in your App make sure that your app follows Apple's [branding guidelines connected to Apple Wallet](https://developer.apple.com/wallet/add-to-apple-wallet-guidelines/). Remember that you must not create your own buttons or your app could be rejected at revision. You can use the[ AddWalletButton component](#components) instead! | ||
| When the pass is already provisioned, make sure to hide this button and replace it with text like `Added to Apple Wallet`. The card is fully provisioned once it added to your main device (user's iPhone) and all linked devices (for example Apple Watch). | ||
| Keep the Add to Apple Wallet button visible while `canAddCardToAppleWallet(primaryAccountIdentifier)` returns `true`, including after the card is added to the iPhone but not yet to a paired Apple Watch. Show text like `Added to Apple Wallet` only once that call returns `false`. Pass `primaryAccountIdentifier` in `IOSCardData` so the next attempt targets the remaining device. This matches [Apple's in-app provisioning guidance](https://applepaydemo.apple.com/in-app-provisioning#3.3). | ||
|
|
||
| Next to branding guidelines, please follow the instructions and best practices from [the In-App provisioning documentation]((#ios)) provided by Apple. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,8 @@ open class WalletManager: UIViewController { | |
| private var addPaymentPassCompletionHandler: (CompletionHandler)? | ||
|
|
||
| private var addPassHandler: ((PKAddPaymentPassRequest) -> Void)? | ||
|
|
||
| private var addPassTargetDevice: String? | ||
|
|
||
| @objc public var packageName = "react-native-wallet" | ||
|
|
||
|
|
@@ -111,10 +113,23 @@ open class WalletManager: UIViewController { | |
| configuration.cardholderName = card.cardHolderName | ||
| configuration.primaryAccountSuffix = card.lastDigits | ||
| configuration.localizedDescription = String(card.cardDescription) | ||
| let primaryAccountIdentifier = card.primaryAccountIdentifier.map { | ||
| resolvedPrimaryAccountIdentifier($0, lastDigits: card.lastDigits) | ||
| } | ||
| if let primaryAccountIdentifier = primaryAccountIdentifier { | ||
| configuration.primaryAccountIdentifier = primaryAccountIdentifier | ||
|
JakubKorytko marked this conversation as resolved.
|
||
| } | ||
|
|
||
| let targetDevice = provisioningTargetDevice(for: primaryAccountIdentifier) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This re-reads both pass lists right after |
||
|
|
||
| guard let enrollViewController = PKAddPaymentPassViewController(requestConfiguration: configuration, delegate: self) else { | ||
| let alreadyOnEveryDevice = primaryAccountIdentifier.map { | ||
| !passLibrary.canAddSecureElementPass(primaryAccountIdentifier: $0) | ||
| } ?? false | ||
| completion(.error, [ | ||
| "errorMessage": "InApp enrollment controller configuration fails" | ||
| "errorMessage": alreadyOnEveryDevice | ||
| ? "This card has already been added to all available devices." | ||
| : "InApp enrollment controller configuration fails" | ||
| ]) | ||
| return | ||
| } | ||
|
|
@@ -134,6 +149,7 @@ open class WalletManager: UIViewController { | |
| self.presentAddPaymentPassCompletionHandler = nil | ||
| self.addPaymentPassCompletionHandler = nil | ||
| self.addPassHandler = nil | ||
| self.addPassTargetDevice = nil | ||
|
|
||
| let errorResponse: NSDictionary = [ | ||
| "errorMessage": "EnrollViewController is no longer presented." | ||
|
|
@@ -150,6 +166,7 @@ open class WalletManager: UIViewController { | |
|
|
||
| self.presentAddPaymentPassCompletionHandler = completion | ||
| self.addPassViewController = enrollViewController | ||
| self.addPassTargetDevice = targetDevice | ||
| presentingViewController.present(enrollViewController, animated: true, completion: nil) | ||
| } | ||
| } | ||
|
|
@@ -212,6 +229,65 @@ open class WalletManager: UIViewController { | |
| return pass.primaryAccountIdentifier == identifier as String | ||
| } | ||
| } | ||
|
|
||
| @objc public func canAddCardToAppleWallet(primaryAccountIdentifier: NSString) -> Bool { | ||
| return passLibrary.canAddSecureElementPass(primaryAccountIdentifier: primaryAccountIdentifier as String) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the fallback stays: this checks the raw id, but the add flow may swap it for the last-digits match. Then this returns true, the button shows, and the add fails as "already added". Both should use the same resolved id |
||
| } | ||
|
|
||
| private func provisioningTargetDevice(for primaryAccountIdentifier: String?) -> String { | ||
| guard let primaryAccountIdentifier = primaryAccountIdentifier else { | ||
| return "unknown" | ||
| } | ||
|
|
||
| let onPhone = hasProvisionedPass(identifier: primaryAccountIdentifier, in: passLibrary.passes(of: .secureElement)) | ||
| let onWatch = hasProvisionedPass(identifier: primaryAccountIdentifier, in: passLibrary.remoteSecureElementPasses) | ||
|
|
||
| if onPhone && !onWatch { | ||
| return "watch" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The device names are bare strings here and again in |
||
| } | ||
| if onWatch && !onPhone { | ||
| return "phone" | ||
| } | ||
| return "unknown" | ||
| } | ||
|
|
||
| private func resolvedPrimaryAccountIdentifier(_ identifier: String, lastDigits: String) -> String { | ||
| let passes = allSecureElementPasses() | ||
| if passes.contains(where: { $0.primaryAccountIdentifier == identifier }) { | ||
| return identifier | ||
| } | ||
|
|
||
| let suffixMatches = passes.filter { $0.primaryAccountNumberSuffix.hasSuffix(lastDigits) } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure about this fallback. With no exact match we use the id of the only pass with the same last digits, deactivated passes included. A reissued card (new FPAN ID, same last 4) would get the old card's id, so PassKit hides the devices that have the old card and the add can fail with "already added to all available devices". I would keep the caller's id unless it matches exactly. Is there a real case where the issuer id differs from what PassKit reports? |
||
| let identifiers = Set(suffixMatches.map(\.primaryAccountIdentifier)) | ||
| if identifiers.count == 1, let resolvedIdentifier = identifiers.first { | ||
| return resolvedIdentifier | ||
| } | ||
| return identifier | ||
| } | ||
|
|
||
| private func allSecureElementPasses() -> [PKSecureElementPass] { | ||
| let localPasses = passLibrary.passes(of: .secureElement) | ||
| .filter { !$0.isRemotePass } | ||
| .compactMap(\.secureElementPass) | ||
| return localPasses + passLibrary.remoteSecureElementPasses | ||
| } | ||
|
|
||
| private func hasProvisionedPass(identifier: String, in passes: [PKPass]) -> Bool { | ||
| passes.contains { pass in | ||
| guard !pass.isRemotePass, let securePass = pass.secureElementPass else { | ||
| return false | ||
| } | ||
| return isProvisioned(securePass, identifier: identifier) | ||
| } | ||
| } | ||
|
|
||
| private func hasProvisionedPass(identifier: String, in passes: [PKSecureElementPass]) -> Bool { | ||
| passes.contains { isProvisioned($0, identifier: identifier) } | ||
| } | ||
|
|
||
| private func isProvisioned(_ pass: PKSecureElementPass, identifier: String) -> Bool { | ||
| pass.primaryAccountIdentifier == identifier && pass.passActivationState != .deactivated | ||
| } | ||
|
|
||
| private func isPassKitAvailable() -> Bool { | ||
| return PKAddPaymentPassViewController.canAddPaymentPass() | ||
|
|
@@ -261,7 +337,7 @@ extension WalletManager: PKAddPaymentPassViewControllerDelegate { | |
| let stringCertificates = certificates.map { | ||
| $0.base64EncodedString() as NSString | ||
| } | ||
| let reqestCardData = AddPassResponse(status: .completed, nonce: stringNonce, nonceSignature: stringNonceSignature, certificates: stringCertificates) | ||
| let reqestCardData = AddPassResponse(status: .completed, nonce: stringNonce, nonceSignature: stringNonceSignature, certificates: stringCertificates, deviceType: addPassTargetDevice as NSString?) | ||
| self.addPassHandler = handler | ||
|
|
||
| // Retry the JS issuer callback if the user tries again to add a payment pass | ||
|
|
@@ -293,6 +369,7 @@ extension WalletManager: PKAddPaymentPassViewControllerDelegate { | |
| presentAddPaymentPassCompletionHandler = nil | ||
| addPaymentPassCompletionHandler = nil | ||
| addPassHandler = nil | ||
| addPassTargetDevice = nil | ||
|
|
||
| let errorMessage = error?.localizedDescription ?? "" | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reads like
getCardStatusByIdentifieralso does the last-digits fallback, but it only does an exact match, andcanAddCardToAppleWalletdoes not do it either. So the same id can give different answers from different functions. Either make them match or say exactly which call does the fallback.