From 5b93d0b0712aae1d58abcc76bc4b2f6425b171b0 Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Wed, 17 Sep 2025 14:53:50 +0100 Subject: [PATCH] Submit auth token from the sample when enabled --- .../project.pbxproj | 16 +++ .../AppConfiguration.swift | 45 ++++++ .../MobileBuyIntegration/AppDelegate.swift | 12 ++ .../Authentication/AccessTokenEncryptor.swift | 128 ++++++++++++++++++ .../Authentication/JWTTokenGenerator.swift | 117 ++++++++++++++++ .../MobileBuyIntegration/CartManager.swift | 3 +- .../MobileBuyIntegration/Info.plist | 6 + .../MobileBuyIntegration/InfoDictionary.swift | 19 +++ .../Localizable.xcstrings | 8 ++ .../MobileBuyIntegration/SceneDelegate.swift | 3 +- .../ViewControllers/CartViewController.swift | 9 +- .../ViewControllers/CheckoutController.swift | 8 +- .../ShopifyCheckoutViewController.swift | 4 +- .../MobileBuyIntegration/Views/CartView.swift | 6 +- .../Views/SettingsView.swift | 18 +++ .../Storefront.xcconfig.example | 10 ++ .../ShopifyCheckoutSheetKit/CheckoutURL.swift | 28 ++++ .../CheckoutWebView.swift | 30 +--- 18 files changed, 430 insertions(+), 40 deletions(-) create mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/Authentication/AccessTokenEncryptor.swift create mode 100644 Samples/MobileBuyIntegration/MobileBuyIntegration/Authentication/JWTTokenGenerator.swift diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj index 727887d56..b9f7d938d 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 6A3D7ADC2B8E01460010EB27 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 6A3D7ADB2B8E01460010EB27 /* Localizable.xcstrings */; }; 6A774DD12B58023400C8EF7E /* CountryCode+inferRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A774DD02B58023400C8EF7E /* CountryCode+inferRegion.swift */; }; 86250DE42AD5521C002E45C2 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86250DE32AD5521C002E45C2 /* AppConfiguration.swift */; }; + C0908DEC2EA670B900998333 /* JWTTokenGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0908DEB2EA670B900998333 /* JWTTokenGenerator.swift */; }; + C0908DED2EA670B900998333 /* AccessTokenEncryptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0908DEA2EA670B900998333 /* AccessTokenEncryptor.swift */; }; CB05E6BF2D4951DA00466376 /* InfoDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB05E6BE2D4951D700466376 /* InfoDictionary.swift */; }; CB05E6C42D4954E800466376 /* Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB05E6C32D4954E400466376 /* Storefront.swift */; }; CB1B10B32E4CDDB0001713F8 /* ShopifyAcceleratedCheckouts in Frameworks */ = {isa = PBXBuildFile; productRef = CB1B10B22E4CDDB0001713F8 /* ShopifyAcceleratedCheckouts */; }; @@ -68,6 +70,8 @@ 6A774DD02B58023400C8EF7E /* CountryCode+inferRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CountryCode+inferRegion.swift"; sourceTree = ""; }; 6AE865492CE3BB6500A4971C /* MobileBuyIntegration.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MobileBuyIntegration.entitlements; sourceTree = ""; }; 86250DE32AD5521C002E45C2 /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; + C0908DEA2EA670B900998333 /* AccessTokenEncryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessTokenEncryptor.swift; sourceTree = ""; }; + C0908DEB2EA670B900998333 /* JWTTokenGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTTokenGenerator.swift; sourceTree = ""; }; CB05E6BE2D4951D700466376 /* InfoDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDictionary.swift; sourceTree = ""; }; CB05E6C32D4954E400466376 /* Storefront.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront.swift; sourceTree = ""; }; CB3613482E98054B00BBE31D /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; @@ -111,6 +115,7 @@ 4EBBA7692A5F0CE200193E19 /* MobileBuyIntegration */ = { isa = PBXGroup; children = ( + C0908DEE2EA6716300998333 /* Authentication */, 4EBBA77F2A5F0DA300193E19 /* Application */, 4EBBA7B82A5F26ED00193E19 /* Extensions */, 4EBBA77E2A5F0D8E00193E19 /* Resources */, @@ -175,6 +180,15 @@ name = Extensions; sourceTree = ""; }; + C0908DEE2EA6716300998333 /* Authentication */ = { + isa = PBXGroup; + children = ( + C0908DEA2EA670B900998333 /* AccessTokenEncryptor.swift */, + C0908DEB2EA670B900998333 /* JWTTokenGenerator.swift */, + ); + path = Authentication; + sourceTree = ""; + }; CB05E6C22D4954DF00466376 /* Extensions */ = { isa = PBXGroup; children = ( @@ -322,6 +336,8 @@ CB05E6C42D4954E800466376 /* Storefront.swift in Sources */, 4EBBA76F2A5F0CE200193E19 /* ProductView.swift in Sources */, 4EF54F272A6F4C4F00F5E407 /* CartViewController.swift in Sources */, + C0908DEC2EA670B900998333 /* JWTTokenGenerator.swift in Sources */, + C0908DED2EA670B900998333 /* AccessTokenEncryptor.swift in Sources */, 4EBBA76B2A5F0CE200193E19 /* AppDelegate.swift in Sources */, 86250DE42AD5521C002E45C2 /* AppConfiguration.swift in Sources */, 4EBBA7AA2A5F124F00193E19 /* StorefrontClient.swift in Sources */, diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppConfiguration.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppConfiguration.swift index 2855007c4..dd386ac3e 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppConfiguration.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppConfiguration.swift @@ -34,6 +34,9 @@ public final class AppConfiguration: ObservableObject { /// Prefill buyer information @AppStorage("useVaultedState") public var useVaultedState: Bool = false + /// Use app authentication for checkouts (requires authentication configuration) + @AppStorage("useAppAuthentication") public var useAppAuthentication: Bool = false + /// Logger to retain Web Pixel events let webPixelsLogger = FileLogger("analytics.txt") @@ -47,6 +50,48 @@ public final class AppConfiguration: ObservableObject { merchantIdentifier: InfoDictionary.shared.merchantIdentifier, contactFields: [.email] ) + + /// Returns true if authentication configuration is available + public var isAuthenticationConfigured: Bool { + InfoDictionary.shared.isAuthenticationConfigured + } + + /// Generates a fresh JWT authentication token + /// + /// WARNING: This is for SAMPLE APP demonstration only. + /// Production apps MUST generate tokens server-side. + /// + /// - Returns: JWT token string, or nil if configuration is missing or generation fails + public func generateAuthToken() -> String? { + guard isAuthenticationConfigured, + let apiKey = InfoDictionary.shared.appApiKey, + let secret = InfoDictionary.shared.appSharedSecret, + let token = InfoDictionary.shared.appAccessToken + else { + return nil + } + + return JWTTokenGenerator.generateAuthToken( + apiKey: apiKey, + sharedSecret: secret, + accessToken: token + ) + } + + /// Generates checkout options with authentication if enabled and available + /// + /// - Returns: CheckoutOptions with authentication token if enabled, or nil otherwise + public func createCheckoutOptions() -> CheckoutOptions? { + guard useAppAuthentication else { + return nil + } + + guard let token = generateAuthToken() else { + return nil + } + + return CheckoutOptions(authentication: .token(token)) + } } public var appConfiguration = AppConfiguration() { diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift index a6ad8b9a4..b5bd0557c 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift @@ -73,6 +73,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { print("[MobileBuyIntegration] AcceleratedCheckout Log level set to \(acceleratedCheckoutsLogLevel)") print("[MobileBuyIntegration] CheckoutSheetKit Log level set to \(checkoutSheetKitLogLevel)") + // Log app authentication configuration status + if appConfiguration.isAuthenticationConfigured { + print("[MobileBuyIntegration] App authentication configuration: ✓ Configured") + if appConfiguration.useAppAuthentication { + print("[MobileBuyIntegration] App authentication: ✓ Enabled") + } else { + print("[MobileBuyIntegration] App authentication: ✗ Disabled (toggle in Settings)") + } + } else { + print("[MobileBuyIntegration] App authentication configuration: ✗ Not configured (set APP_API_KEY, APP_SHARED_SECRET, APP_ACCESS_TOKEN in Storefront.xcconfig)") + } + UIBarButtonItem.appearance().tintColor = ColorPalette.primaryColor return true diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Authentication/AccessTokenEncryptor.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Authentication/AccessTokenEncryptor.swift new file mode 100644 index 000000000..3b8815923 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Authentication/AccessTokenEncryptor.swift @@ -0,0 +1,128 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import CommonCrypto +import CryptoKit +import Foundation + +/// Encrypts access tokens for use in JWT authentication payloads +enum AccessTokenEncryptor { + /// AES-128 key and block size in bytes (128 bits = 16 bytes) + private static let aes128KeySize = 16 + + /// Encrypts plaintext, returning base64url-encoded result + /// Using AES-128-CBC + /// + /// - Parameters: + /// - plaintext: The string to encrypt + /// - secret: The shared secret used for encryption + /// - Returns: Base64url-encoded encrypted data, or nil if encryption fails + static func encryptAndSignBase64URLSafe(plaintext: String, secret: String) -> String? { + guard let plaintextData = plaintext.data(using: .utf8), + let secretData = secret.data(using: .utf8) + else { + return nil + } + + // Derive keys from the shared secret using SHA-256 + // Splits the 32-byte hash into two 16-byte keys: + // - Bytes 0-15: encryption key + // - Bytes 16-31: signature key + let keyHash = SHA256.hash(data: secretData) + let encryptionKey = Data(keyHash.prefix(aes128KeySize)) + let signatureKey = Data(keyHash.dropFirst(aes128KeySize)) + + var iv = Data(count: aes128KeySize) + let randomStatus = iv.withUnsafeMutableBytes { ivBytes in + guard let baseAddress = ivBytes.baseAddress else { + return errSecParam + } + return SecRandomCopyBytes(kSecRandomDefault, aes128KeySize, baseAddress) + } + + guard randomStatus == errSecSuccess else { + return nil + } + + guard let ciphertext = encryptAES128CBC(data: plaintextData, key: encryptionKey, iv: iv) else { + return nil + } + + let combined = iv + ciphertext + let signature = signData(combined, signatureKey: signatureKey) + let signedData = combined + signature + return signedData.base64URLEncodedString() + } + + /// Signs data using HMAC-SHA256 + private static func signData(_ data: Data, signatureKey: Data) -> Data { + let key = SymmetricKey(data: signatureKey) + let signature = HMAC.authenticationCode(for: data, using: key) + return Data(signature) + } + + /// Performs AES-128-CBC encryption + private static func encryptAES128CBC(data: Data, key: Data, iv: Data) -> Data? { + let cryptLength = data.count + kCCBlockSizeAES128 + var cryptData = Data(count: cryptLength) + + var numBytesEncrypted: size_t = 0 + + let cryptStatus = cryptData.withUnsafeMutableBytes { cryptBytes in + data.withUnsafeBytes { dataBytes in + iv.withUnsafeBytes { ivBytes in + key.withUnsafeBytes { keyBytes in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, key.count, + ivBytes.baseAddress, + dataBytes.baseAddress, data.count, + cryptBytes.baseAddress, cryptLength, + &numBytesEncrypted + ) + } + } + } + } + + guard cryptStatus == kCCSuccess else { + return nil + } + + cryptData.count = numBytesEncrypted + return cryptData + } +} + +extension Data { + /// Encodes data as base64url (RFC 4648 Section 5) - no padding, URL-safe characters + func base64URLEncodedString() -> String { + let base64 = base64EncodedString() + return base64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Authentication/JWTTokenGenerator.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Authentication/JWTTokenGenerator.swift new file mode 100644 index 000000000..03245aa26 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Authentication/JWTTokenGenerator.swift @@ -0,0 +1,117 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import CryptoKit +import Foundation + +/// JWT header field keys +private enum JWTHeaderKey { + static let algorithm = "alg" +} + +/// JWT header field values +private enum JWTHeaderValue { + static let hmacSHA256 = "HS256" +} + +/// JWT payload field keys +private enum JWTPayloadKey { + static let apiKey = "api_key" + static let accessToken = "access_token" + static let issuedAt = "iat" + static let jwtID = "jti" +} + +/// Generates JWT authentication tokens for authenticated checkouts +/// +/// WARNING: This is for SAMPLE APP demonstration purposes only. +/// Production apps MUST generate tokens server-side to protect secrets. +enum JWTTokenGenerator { + /// Generates a JWT auth token + /// - Parameters: + /// - apiKey: The app's API key + /// - sharedSecret: The app's shared secret + /// - accessToken: The app's access token + /// - Returns: JWT token string, or nil if generation fails + static func generateAuthToken( + apiKey: String, + sharedSecret: String, + accessToken: String + ) -> String? { + guard let encryptedAccessToken = AccessTokenEncryptor.encryptAndSignBase64URLSafe( + plaintext: accessToken, + secret: sharedSecret + ) else { + return nil + } + + let issuedAt = Int(Date().timeIntervalSince1970) + let jti = UUID().uuidString + + let payload: [String: Any] = [ + JWTPayloadKey.apiKey: apiKey, + JWTPayloadKey.accessToken: encryptedAccessToken, + JWTPayloadKey.issuedAt: issuedAt, + JWTPayloadKey.jwtID: jti + ] + + return encodeJWT(payload: payload, secret: sharedSecret) + } + + /// Encodes a JWT with HS256 (HMAC-SHA256) signature + /// + /// - Parameters: + /// - payload: The JWT payload as a dictionary + /// - secret: The shared secret for HMAC signing + /// - Returns: Complete JWT string (header.payload.signature), or nil if encoding fails + private static func encodeJWT(payload: [String: Any], secret: String) -> String? { + let header: [String: Any] = [ + JWTHeaderKey.algorithm: JWTHeaderValue.hmacSHA256 + ] + + guard let headerJSON = try? JSONSerialization.data(withJSONObject: header, options: .sortedKeys), + let payloadJSON = try? JSONSerialization.data(withJSONObject: payload, options: .sortedKeys), + let secretData = secret.data(using: .utf8) + else { + return nil + } + + let headerBase64 = headerJSON.base64URLEncodedString() + let payloadBase64 = payloadJSON.base64URLEncodedString() + + // Create signing input: "header.payload" + let signingInput = "\(headerBase64).\(payloadBase64)" + + guard let signingInputData = signingInput.data(using: .utf8) else { + return nil + } + + // Sign with HMAC-SHA256 + let key = SymmetricKey(data: secretData) + let signature = HMAC.authenticationCode(for: signingInputData, using: key) + let signatureBase64 = Data(signature).base64URLEncodedString() + + // Return complete JWT: "header.payload.signature" + return "\(signingInput).\(signatureBase64)" + } +} diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift index f81d26eb4..318d93164 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift @@ -51,7 +51,8 @@ class CartManager: ObservableObject { public func preloadCheckout() { /// Only preload checkout if cart is dirty, meaning it has changes since checkout was last preloaded if let url = cart?.checkoutUrl, isDirty { - ShopifyCheckoutSheetKit.preload(checkout: url) + let options = appConfiguration.createCheckoutOptions() + ShopifyCheckoutSheetKit.preload(checkout: url, options: options) markCartAsReady() } } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Info.plist b/Samples/MobileBuyIntegration/MobileBuyIntegration/Info.plist index b225e8db0..f820f2b3b 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Info.plist +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Info.plist @@ -28,6 +28,12 @@ $(EMAIL) Phone $(PHONE) + AppApiKey + $(APP_API_KEY) + AppSharedSecret + $(APP_SHARED_SECRET) + AppAccessToken + $(APP_ACCESS_TOKEN) NSLocationWhenInUseUsageDescription Your location may be required to locate pickup points near you when you request this shipping option. ITSAppUsesNonExemptEncryption diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/InfoDictionary.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/InfoDictionary.swift index eaab9f7f8..ffb4e96c6 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/InfoDictionary.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/InfoDictionary.swift @@ -33,6 +33,22 @@ class InfoDictionary { let address1, address2, city, country, firstName, lastName, province, zip, email, phone, domain, accessToken, version, buildNumber, merchantIdentifier: String + // Optional - Authentication configuration + let appApiKey: String? + let appSharedSecret: String? + let appAccessToken: String? + + /// Returns true if all authentication configuration values are present and non-empty + var isAuthenticationConfigured: Bool { + guard let apiKey = appApiKey, !apiKey.isEmpty, + let secret = appSharedSecret, !secret.isEmpty, + let token = appAccessToken, !token.isEmpty + else { + return false + } + return true + } + init() { guard let infoPlist = Bundle.main.infoDictionary, @@ -70,5 +86,8 @@ class InfoDictionary { self.version = version self.buildNumber = buildNumber self.merchantIdentifier = merchantIdentifier + appApiKey = infoPlist["AppApiKey"] as? String + appSharedSecret = infoPlist["AppSharedSecret"] as? String + appAccessToken = infoPlist["AppAccessToken"] as? String } } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings index 098ec4b95..240938bde 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings @@ -21,6 +21,10 @@ }, "Adding..." : { + }, + "App authentication" : { + "comment" : "A toggle that enables or disables app authentication.", + "isCommentAutoGenerated" : true }, "Buy Now" : { @@ -76,6 +80,10 @@ }, "No logs available" : { + }, + "Not configured" : { + "comment" : "A description of the app authentication feature that indicates it is not currently configured.", + "isCommentAutoGenerated" : true }, "OK" : { "comment" : "Default action" diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift index a67bbfc93..5c25e02dd 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift @@ -261,7 +261,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } public func presentBuyNow(checkoutURL: URL) { - let embeddedCheckout = ShopifyCheckoutViewController(checkoutURL: checkoutURL) + let options = appConfiguration.createCheckoutOptions() + let embeddedCheckout = ShopifyCheckoutViewController(checkoutURL: checkoutURL, options: options) let navController = UINavigationController(rootViewController: embeddedCheckout) navController.modalPresentationStyle = UIModalPresentationStyle.fullScreen diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CartViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CartViewController.swift index 37dfcc8ba..af9f98127 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CartViewController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CartViewController.swift @@ -431,7 +431,8 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData tableView.reloadData() if let url = CartManager.shared.cart?.checkoutUrl { - ShopifyCheckoutSheetKit.preload(checkout: url) + let options = appConfiguration.createCheckoutOptions() + ShopifyCheckoutSheetKit.preload(checkout: url, options: options) } } @@ -469,7 +470,8 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData self.setupCheckoutButtonContent() // Update button appearance for enabled state cell.quantityLabel.text = "\(cart.lines.nodes[indexPath.item].quantity)" - ShopifyCheckoutSheetKit.preload(checkout: cart.checkoutUrl) + let options = appConfiguration.createCheckoutOptions() + ShopifyCheckoutSheetKit.preload(checkout: cart.checkoutUrl, options: options) } } return cell @@ -508,7 +510,8 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData @objc private func presentCheckout() { guard let url = CartManager.shared.cart?.checkoutUrl else { return } - ShopifyCheckoutSheetKit.present(checkout: url, from: self, delegate: self) + let options = appConfiguration.createCheckoutOptions() + ShopifyCheckoutSheetKit.present(checkout: url, from: self, delegate: self, options: options) } @objc private func resetCart() { diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CheckoutController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CheckoutController.swift index 363d79e0a..af12b153c 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CheckoutController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/CheckoutController.swift @@ -43,7 +43,13 @@ class CheckoutController: UIViewController { public func present(checkout url: URL) { if let rootViewController = window?.topMostViewController() { - ShopifyCheckoutSheetKit.present(checkout: url, from: rootViewController, delegate: self) + let options = appConfiguration.createCheckoutOptions() + ShopifyCheckoutSheetKit.present( + checkout: url, + from: rootViewController, + delegate: self, + options: options + ) root = rootViewController } } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/ShopifyCheckoutViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/ShopifyCheckoutViewController.swift index e64ab2122..7c0bf5f42 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/ShopifyCheckoutViewController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/ViewControllers/ShopifyCheckoutViewController.swift @@ -29,9 +29,9 @@ class ShopifyCheckoutViewController: UIViewController { private var checkoutURL: URL private var checkoutWebViewController: CheckoutWebViewController - init(checkoutURL: URL) { + init(checkoutURL: URL, options: CheckoutOptions? = nil) { self.checkoutURL = checkoutURL - checkoutWebViewController = CheckoutWebViewController(checkoutURL: checkoutURL) + checkoutWebViewController = CheckoutWebViewController(checkoutURL: checkoutURL, options: options) super.init(nibName: nil, bundle: nil) // ShopifyCheckoutViewController conforms to CheckoutDelegate to respond to lifecycle events checkoutWebViewController.delegate = self diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/CartView.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/CartView.swift index ddfd3964e..8bb657882 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/CartView.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/CartView.swift @@ -122,7 +122,7 @@ struct CartView: View { } .sheet(isPresented: $showCheckoutSheet) { if let url = cartManager.cart?.checkoutUrl { - CheckoutSheet(checkout: url) + CheckoutSheet(checkout: url, options: config.createCheckoutOptions()) .colorScheme(.automatic) .onCancel { showCheckoutSheet = false @@ -328,8 +328,8 @@ struct CartLines: View { CartManager.shared.cart = cart updating = nil - ShopifyCheckoutSheetKit.preload( - checkout: cart.checkoutUrl) + let options = appConfiguration.createCheckoutOptions() + ShopifyCheckoutSheetKit.preload(checkout: cart.checkoutUrl, options: options) } }, label: { diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/SettingsView.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/SettingsView.swift index 16d0bb86b..98005ddf3 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/SettingsView.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/SettingsView.swift @@ -64,6 +64,24 @@ struct SettingsView: View { ShopifyCheckoutSheetKit.configuration.preloading.enabled = newValue } Toggle("Prefill buyer information", isOn: $config.useVaultedState) + + if config.isAuthenticationConfigured { + Toggle("App authentication", isOn: $config.useAppAuthentication) + .onChange(of: config.useAppAuthentication) { newValue in + if newValue { + // Invalidate cached checkout when enabling authentication + ShopifyCheckoutSheetKit.invalidate() + } + } + } else { + HStack { + Text("App authentication") + Spacer() + Text("Not configured") + .font(.caption) + .foregroundColor(.secondary) + } + } } Section(header: Text("Universal Links")) { diff --git a/Samples/MobileBuyIntegration/Storefront.xcconfig.example b/Samples/MobileBuyIntegration/Storefront.xcconfig.example index 468321a0f..dfc4649a4 100644 --- a/Samples/MobileBuyIntegration/Storefront.xcconfig.example +++ b/Samples/MobileBuyIntegration/Storefront.xcconfig.example @@ -6,6 +6,16 @@ STOREFRONT_DOMAIN = STOREFRONT_ACCESS_TOKEN = STOREFRONT_MERCHANT_IDENTIFIER = +// --- App Authentication (Optional - authenticates the calling app to Shopify) +// --- These values should be obtained from your Shopify app settings +// --- Leave blank to run without app authentication +// --- WARNING: Client-side token generation is FOR SAMPLE APP ONLY +// --- Production apps MUST generate tokens server-side + +APP_API_KEY = +APP_SHARED_SECRET = +APP_ACCESS_TOKEN = + // --- Buyer preference data EMAIL = test-checkout-sdk@shopify.com diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutURL.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutURL.swift index def968d85..dd11cdb00 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutURL.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutURL.swift @@ -60,3 +60,31 @@ public struct CheckoutURL { return !["http", "https"].contains(scheme) } } + +extension URL { + /// Returns a sanitized URL string safe for logging by redacting sensitive authentication data + internal var sanitizedString: String { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return absoluteString + } + + // Redact authentication value from embed parameter + if let embedIndex = components.queryItems?.firstIndex(where: { $0.name == EmbedQueryParamKey.embed }), + let embedValue = components.queryItems?[embedIndex].value + { + let sanitizedEmbed = embedValue + .split(separator: ",") + .map { field -> String in + if field.starts(with: "\(EmbedFieldKey.authentication)=") { + return "\(EmbedFieldKey.authentication)=\(EmbedFieldValue.redacted)" + } + return String(field) + } + .joined(separator: ",") + + components.queryItems?[embedIndex] = URLQueryItem(name: EmbedQueryParamKey.embed, value: sanitizedEmbed) + } + + return components.url?.absoluteString ?? absoluteString + } +} diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift index 8b1cd02cd..a384dc36a 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift @@ -72,7 +72,7 @@ public class CheckoutWebView: WKWebView { return CheckoutWebView(recovery: true, options: options) } - let cacheKey = "\(url.absoluteString)_\(options?.entryPoint?.rawValue ?? "nil")" + let cacheKey = "\(url.sanitizedString)_\(options?.entryPoint?.rawValue ?? "nil")" guard ShopifyCheckoutSheetKit.configuration.preloading.enabled else { OSLogger.shared.debug("Preloading not enabled") @@ -632,31 +632,3 @@ extension CheckoutWebView { } } } - -extension URL { - /// Returns a sanitized URL string safe for logging by redacting sensitive authentication data - internal var sanitizedString: String { - guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { - return absoluteString - } - - // Redact authentication value from embed parameter - if let embedIndex = components.queryItems?.firstIndex(where: { $0.name == EmbedQueryParamKey.embed }), - let embedValue = components.queryItems?[embedIndex].value - { - let sanitizedEmbed = embedValue - .split(separator: ",") - .map { field -> String in - if field.starts(with: "\(EmbedFieldKey.authentication)=") { - return "\(EmbedFieldKey.authentication)=\(EmbedFieldValue.redacted)" - } - return String(field) - } - .joined(separator: ",") - - components.queryItems?[embedIndex] = URLQueryItem(name: EmbedQueryParamKey.embed, value: sanitizedEmbed) - } - - return components.url?.absoluteString ?? absoluteString - } -}