Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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 */; };
Expand Down Expand Up @@ -68,6 +70,8 @@
6A774DD02B58023400C8EF7E /* CountryCode+inferRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CountryCode+inferRegion.swift"; sourceTree = "<group>"; };
6AE865492CE3BB6500A4971C /* MobileBuyIntegration.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MobileBuyIntegration.entitlements; sourceTree = "<group>"; };
86250DE32AD5521C002E45C2 /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = "<group>"; };
C0908DEA2EA670B900998333 /* AccessTokenEncryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessTokenEncryptor.swift; sourceTree = "<group>"; };
C0908DEB2EA670B900998333 /* JWTTokenGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTTokenGenerator.swift; sourceTree = "<group>"; };
CB05E6BE2D4951D700466376 /* InfoDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDictionary.swift; sourceTree = "<group>"; };
CB05E6C32D4954E400466376 /* Storefront.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storefront.swift; sourceTree = "<group>"; };
CB3613482E98054B00BBE31D /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -111,6 +115,7 @@
4EBBA7692A5F0CE200193E19 /* MobileBuyIntegration */ = {
isa = PBXGroup;
children = (
C0908DEE2EA6716300998333 /* Authentication */,
4EBBA77F2A5F0DA300193E19 /* Application */,
4EBBA7B82A5F26ED00193E19 /* Extensions */,
4EBBA77E2A5F0D8E00193E19 /* Resources */,
Expand Down Expand Up @@ -175,6 +180,15 @@
name = Extensions;
sourceTree = "<group>";
};
C0908DEE2EA6716300998333 /* Authentication */ = {
isa = PBXGroup;
children = (
C0908DEA2EA670B900998333 /* AccessTokenEncryptor.swift */,
C0908DEB2EA670B900998333 /* JWTTokenGenerator.swift */,
);
path = Authentication;
sourceTree = "<group>";
};
CB05E6C22D4954DF00466376 /* Extensions */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SHA256>.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: "")
}
}
Original file line number Diff line number Diff line change
@@ -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<SHA256>.authenticationCode(for: signingInputData, using: key)
let signatureBase64 = Data(signature).base64URLEncodedString()

// Return complete JWT: "header.payload.signature"
return "\(signingInput).\(signatureBase64)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
<string>$(EMAIL)</string>
<key>Phone</key>
<string>$(PHONE)</string>
<key>AppApiKey</key>
<string>$(APP_API_KEY)</string>
<key>AppSharedSecret</key>
<string>$(APP_SHARED_SECRET)</string>
<key>AppAccessToken</key>
<string>$(APP_ACCESS_TOKEN)</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location may be required to locate pickup points near you when you request this shipping option.</string>
<key>ITSAppUsesNonExemptEncryption</key>
Expand Down
Loading
Loading