From cdb321dedc11a4e80548cea516e45511ac6be7ea Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Tue, 21 Oct 2025 18:12:14 +0100 Subject: [PATCH] pass in app auth token from sample app --- .../ios/RCTCheckoutWebView.swift | 15 +- sample/.env.example | 4 + sample/ios/Podfile.lock | 2 +- sample/package.json | 4 + sample/src/config/authConfig.ts | 85 ++++++++++ sample/src/context/Config.tsx | 2 + sample/src/screens/BuyNow/CheckoutScreen.tsx | 66 +++++--- sample/src/screens/SettingsScreen.tsx | 16 ++ .../src/utils/crypto/accessTokenEncryptor.ts | 120 +++++++++++++ sample/src/utils/crypto/crypto.ts | 109 ++++++++++++ sample/src/utils/crypto/jwtTokenGenerator.ts | 159 ++++++++++++++++++ tsconfig.json | 1 + yarn.lock | 59 +++++-- 13 files changed, 605 insertions(+), 37 deletions(-) create mode 100644 sample/src/config/authConfig.ts create mode 100644 sample/src/utils/crypto/accessTokenEncryptor.ts create mode 100644 sample/src/utils/crypto/crypto.ts create mode 100644 sample/src/utils/crypto/jwtTokenGenerator.ts diff --git a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift index 0baeb349..7b3cd420 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift @@ -53,6 +53,17 @@ class RCTCheckoutWebView: UIView { private var events: EventBus = .init() + private var parentViewController: UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UIViewController { + return viewController + } + responder = nextResponder + } + return nil + } + /// Public Properties @objc var checkoutUrl: String? @objc var checkoutOptions: [AnyHashable: Any]? @@ -255,7 +266,7 @@ extension RCTCheckoutWebView: CheckoutDelegate { error.isRecoverable } - func checkoutDidRequestAddressChange(event: AddressChangeRequest) { + func checkoutDidRequestAddressChange(event: AddressChangeRequested) { guard let id = event.id else { return } self.events.set(key: id, event: event) @@ -263,7 +274,7 @@ extension RCTCheckoutWebView: CheckoutDelegate { onAddressChangeIntent?([ "id": event.id, "type": "addressChangeIntent", - "addressType": event.addressType, + "addressType": event.params.addressType, ]) } } diff --git a/sample/.env.example b/sample/.env.example index 8d3e7369..83c9a7ce 100644 --- a/sample/.env.example +++ b/sample/.env.example @@ -18,3 +18,7 @@ LAST_NAME="Hartley" PROVINCE="ON" ZIP="M5V 1M7" PHONE="1-888-746-7439" + +APP_API_KEY= +APP_SHARED_SECRET= +APP_ACCESS_TOKEN= diff --git a/sample/ios/Podfile.lock b/sample/ios/Podfile.lock index c3dc11eb..4ddba50d 100644 --- a/sample/ios/Podfile.lock +++ b/sample/ios/Podfile.lock @@ -2891,6 +2891,6 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 -PODFILE CHECKSUM: 178001eab8e8a8100c91d0b91866b5a76e0291fb +PODFILE CHECKSUM: d64592b0776174ac530c063d582a7b9439ff7a5a COCOAPODS: 1.15.2 diff --git a/sample/package.json b/sample/package.json index 5761549c..4d34c91c 100644 --- a/sample/package.json +++ b/sample/package.json @@ -21,6 +21,8 @@ "@react-navigation/bottom-tabs": "^7.4.6", "@react-navigation/stack": "^7.4.8", "@shopify/checkout-sheet-kit": "link:../modules/@shopify/checkout-sheet-kit", + "buffer": "^6.0.3", + "crypto-js": "^4.2.0", "graphql": "^16.8.2", "jotai": "^2.13.1", "react-native-config": "1.5.6", @@ -49,6 +51,8 @@ "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.27.6", "@react-native-masked-view/masked-view": "^0.3.2", + "@types/crypto-js": "^4.2.2", + "@types/node": "^24.9.1", "@types/react-native-vector-icons": "^6.4.18", "@types/setimmediate": "^1", "babel-plugin-module-resolver": "^5.0.0", diff --git a/sample/src/config/authConfig.ts b/sample/src/config/authConfig.ts new file mode 100644 index 00000000..758641f2 --- /dev/null +++ b/sample/src/config/authConfig.ts @@ -0,0 +1,85 @@ +/* +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 Config from 'react-native-config'; + +const { + APP_API_KEY, + APP_SHARED_SECRET, + APP_ACCESS_TOKEN, +} = Config; + +/** + * ⚠️ WARNING: FOR TESTING ONLY ⚠️ + * + * This configuration is for local testing of authentication flows. + * DO NOT USE IN PRODUCTION. JWT tokens must be generated server-side. + * + * To enable authentication testing: + * 1. Add your test app credentials to .env file: + * APP_API_KEY=your-api-key + * APP_SHARED_SECRET=your-shared-secret + * APP_ACCESS_TOKEN=your-access-token + * 2. Run the sample app + * 3. Go to Settings and toggle "App authentication" ON + * + * These values should match what you configure in your Shopify app settings. + */ + +export interface AuthConfig { + /** + * Your app's API key + * Found in your Shopify Partner dashboard under app settings + */ + apiKey: string; + + /** + * Your app's shared secret + * Found in your Shopify Partner dashboard under app settings + */ + sharedSecret: string; + + /** + * Your app's access token + * This would typically be obtained during app installation + */ + accessToken: string; +} + +export const authConfig: AuthConfig = { + apiKey: APP_API_KEY || '', + sharedSecret: APP_SHARED_SECRET || '', + accessToken: APP_ACCESS_TOKEN || '', +}; + +/** + * Validates that all required auth configuration is present + */ +export function hasAuthCredentials(): boolean { + return !!( + authConfig.apiKey && + authConfig.sharedSecret && + authConfig.accessToken + ); +} + diff --git a/sample/src/context/Config.tsx b/sample/src/context/Config.tsx index c697ad11..03fe345b 100644 --- a/sample/src/context/Config.tsx +++ b/sample/src/context/Config.tsx @@ -14,6 +14,7 @@ export interface AppConfig { enablePreloading: boolean; prefillBuyerInformation: boolean; customerAuthenticated: boolean; + appAuthenticationEnabled: boolean; } interface Context { @@ -26,6 +27,7 @@ const defaultAppConfig: AppConfig = { enablePreloading: true, prefillBuyerInformation: true, customerAuthenticated: false, + appAuthenticationEnabled: false, }; const ConfigContext = createContext({ diff --git a/sample/src/screens/BuyNow/CheckoutScreen.tsx b/sample/src/screens/BuyNow/CheckoutScreen.tsx index 1fdc3929..1c23c440 100644 --- a/sample/src/screens/BuyNow/CheckoutScreen.tsx +++ b/sample/src/screens/BuyNow/CheckoutScreen.tsx @@ -23,7 +23,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import type {NavigationProp, RouteProp} from '@react-navigation/native'; import {useNavigation} from '@react-navigation/native'; -import React, {useRef} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import { Checkout, type CheckoutRef, @@ -31,19 +31,41 @@ import { } from '@shopify/checkout-sheet-kit'; import type {BuyNowStackParamList} from './types'; import {StyleSheet} from 'react-native'; +import {authConfig, hasAuthCredentials} from '../../config/authConfig'; +import {generateAuthToken} from '../../utils/crypto/jwtTokenGenerator'; +import {useConfig} from '../../context/Config'; /** - * Hook that fetches an authentication token from the authorization server. + * Hook that generates/fetches an authentication token. + * + * For testing: Uses client-side JWT generation (NOT production safe) + * For production: Replace with API call to your authorization server + * + * @param enabled - Whether authentication is enabled (from Settings toggle) */ -function useAuth(): string | undefined { - // Example: - // const [token, setToken] = useState(); - // useEffect(() => { - // fetchTokenFromServer().then(setToken); - // }, []); - // return token; - - return undefined; +function useAuth(enabled: boolean): string | undefined { + const [token, setToken] = useState(); + + useEffect(() => { + if (!enabled || !hasAuthCredentials()) { + setToken(undefined); + return; + } + + try { + const generatedToken = generateAuthToken( + authConfig.apiKey, + authConfig.sharedSecret, + authConfig.accessToken, + ); + setToken(generatedToken ?? undefined); + } catch (error) { + console.error('[CheckoutScreen] Auth token generation error:', error); + setToken(undefined); + } + }, [enabled]); + + return token; } // This component represents a screen in the consumers app that @@ -53,15 +75,19 @@ export default function CheckoutScreen(props: { }) { const navigation = useNavigation>(); const ref = useRef(null); - const authToken = useAuth(); - - const checkoutOptions: CheckoutOptions | undefined = authToken - ? { - authentication: { - token: authToken, - }, - } - : undefined; + const {appConfig} = useConfig(); + const authToken = useAuth(appConfig.appAuthenticationEnabled); + + const checkoutOptions = useMemo(() => { + if (!authToken) { + return undefined; + } + return { + authentication: { + token: authToken, + }, + }; + }, [authToken]); const onAddressChangeIntent = (event: {id: string}) => { navigation.navigate('Address', {id: event.id}); diff --git a/sample/src/screens/SettingsScreen.tsx b/sample/src/screens/SettingsScreen.tsx index d77c6918..4ca48c7f 100644 --- a/sample/src/screens/SettingsScreen.tsx +++ b/sample/src/screens/SettingsScreen.tsx @@ -126,6 +126,13 @@ function SettingsScreen() { }); }, [appConfig, setAppConfig]); + const handleToggleAppAuthentication = useCallback(() => { + setAppConfig({ + ...appConfig, + appAuthenticationEnabled: !appConfig.appAuthenticationEnabled, + }); + }, [appConfig, setAppConfig]); + const configurationOptions: readonly SwitchItem[] = useMemo( () => [ { @@ -148,12 +155,21 @@ function SettingsScreen() { value: appConfig.customerAuthenticated, handler: handleToggleCustomerAuthenticated, }, + { + title: 'App authentication', + description: + 'Provide an app authentication token with checkout requests. Allows applying app specific checkout customizations and prevents redaction of checkout event data.', + type: SectionType.Switch, + value: appConfig.appAuthenticationEnabled, + handler: handleToggleAppAuthentication, + }, ], [ appConfig, handleTogglePrefill, handleTogglePreloading, handleToggleCustomerAuthenticated, + handleToggleAppAuthentication, ], ); diff --git a/sample/src/utils/crypto/accessTokenEncryptor.ts b/sample/src/utils/crypto/accessTokenEncryptor.ts new file mode 100644 index 00000000..ef14776e --- /dev/null +++ b/sample/src/utils/crypto/accessTokenEncryptor.ts @@ -0,0 +1,120 @@ +/* +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. +*/ + +/** + * ⚠️ WARNING: FOR TESTING ONLY ⚠️ + * + * This is a sample implementation for testing authentication flows. + * DO NOT USE IN PRODUCTION. JWT tokens must be generated server-side. + * + * This mirrors the Swift AccessTokenEncryptor implementation for compatibility. + * Uses crypto-js for pure JavaScript implementation (no native dependencies). + */ + +import CryptoJS from 'crypto-js'; +import {Buffer} from 'buffer'; +import { + sha256, + hmacSha256, + wordArrayToUint8Array, + uint8ArrayToWordArray, + getRandomBytes, +} from './crypto'; + +const AES_128_KEY_SIZE = 16; // 128 bits = 16 bytes + +/** + * Encrypts an access token using AES-128-CBC with HMAC-SHA256 signature + * Matches the Swift implementation in AccessTokenEncryptor.swift + * + * @param plaintext - The access token to encrypt + * @param secret - The shared secret + * @returns Base64url-encoded encrypted data, or null if encryption fails + */ +export function encryptAndSignBase64URLSafe( + plaintext: string, + secret: string, +): string | null { + try { + // 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 + const secretHash = sha256(secret); + const secretHashBytes = wordArrayToUint8Array(secretHash); + + const encryptionKeyBytes = secretHashBytes.slice(0, AES_128_KEY_SIZE); + const signatureKeyBytes = secretHashBytes.slice(AES_128_KEY_SIZE); + + const encryptionKey = uint8ArrayToWordArray(encryptionKeyBytes); + const signatureKey = uint8ArrayToWordArray(signatureKeyBytes); + + // Generate random IV (16 bytes) + const ivBytes = getRandomBytes(AES_128_KEY_SIZE); + const iv = uint8ArrayToWordArray(ivBytes); + + // Encrypt using AES-128-CBC + const encrypted = CryptoJS.AES.encrypt(plaintext, encryptionKey, { + iv: iv, + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }); + + const ciphertextBytes = wordArrayToUint8Array(encrypted.ciphertext); + + // Combine IV + ciphertext + const combined = new Uint8Array(ivBytes.length + ciphertextBytes.length); + combined.set(ivBytes, 0); + combined.set(ciphertextBytes, ivBytes.length); + + // Sign the combined data + const combinedWordArray = uint8ArrayToWordArray(combined); + const signatureWordArray = hmacSha256(combinedWordArray, signatureKey); + const signatureBytes = wordArrayToUint8Array(signatureWordArray); + + // Combine everything: IV + ciphertext + signature + const signedData = new Uint8Array( + combined.length + signatureBytes.length, + ); + signedData.set(combined, 0); + signedData.set(signatureBytes, combined.length); + + // Return base64url-encoded result + return base64URLEncode(signedData); + } catch (error) { + console.error('[AccessTokenEncryptor] Encryption failed:', error); + return null; + } +} + +/** + * Encodes Uint8Array as base64url (RFC 4648 Section 5) + */ +function base64URLEncode(data: Uint8Array): string { + // Use Buffer for base64 encoding (available in React Native) + const base64 = Buffer.from(data).toString('base64'); + + // Convert to base64url (no padding, URL-safe characters) + // eslint-disable-next-line no-div-regex + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} diff --git a/sample/src/utils/crypto/crypto.ts b/sample/src/utils/crypto/crypto.ts new file mode 100644 index 00000000..242d652a --- /dev/null +++ b/sample/src/utils/crypto/crypto.ts @@ -0,0 +1,109 @@ +/* +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. +*/ + +/** + * ⚠️ WARNING: FOR TESTING ONLY ⚠️ + * + * Pure JavaScript crypto utilities for testing authentication flows. + * This is slower than native implementations but simpler and has no native dependencies. + * + * Uses crypto-js library (https://www.npmjs.com/package/crypto-js) + */ + +import CryptoJS from 'crypto-js'; + +/** + * Computes SHA-256 hash of input string + * @returns WordArray (crypto-js format) + */ +export function sha256(input: string): CryptoJS.lib.WordArray { + return CryptoJS.SHA256(input); +} + +/** + * Computes HMAC-SHA256 of data with given key + * @returns WordArray (crypto-js format) + */ +export function hmacSha256( + data: CryptoJS.lib.WordArray, + key: CryptoJS.lib.WordArray, +): CryptoJS.lib.WordArray { + return CryptoJS.HmacSHA256(data, key); +} + +/** + * Converts WordArray to Uint8Array + */ +export function wordArrayToUint8Array( + wordArray: CryptoJS.lib.WordArray, +): Uint8Array { + const words = wordArray.words; + const sigBytes = wordArray.sigBytes; + const u8 = new Uint8Array(sigBytes); + + for (let i = 0; i < sigBytes; i++) { + // eslint-disable-next-line no-bitwise + const word = words[i >>> 2]; + if (word !== undefined) { + // eslint-disable-next-line no-bitwise + u8[i] = (word >>> (24 - (i % 4) * 8)) & 0xff; + } + } + + return u8; +} + +/** + * Converts Uint8Array to WordArray + */ +export function uint8ArrayToWordArray(u8arr: Uint8Array): CryptoJS.lib.WordArray { + const len = u8arr.length; + const words: number[] = []; + + for (let i = 0; i < len; i++) { + // eslint-disable-next-line no-bitwise + const idx = i >>> 2; + if (words[idx] === undefined) { + words[idx] = 0; + } + const byte = u8arr[i]; + if (byte !== undefined) { + // eslint-disable-next-line no-bitwise + words[idx] = (words[idx] ?? 0) | ((byte & 0xff) << (24 - (i % 4) * 8)); + } + } + + return CryptoJS.lib.WordArray.create(words, len); +} + +/** + * Generates random bytes (using Math.random - NOT cryptographically secure) + * For testing only! + */ +export function getRandomBytes(size: number): Uint8Array { + const bytes = new Uint8Array(size); + for (let i = 0; i < size; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + return bytes; +} diff --git a/sample/src/utils/crypto/jwtTokenGenerator.ts b/sample/src/utils/crypto/jwtTokenGenerator.ts new file mode 100644 index 00000000..8ed3aea1 --- /dev/null +++ b/sample/src/utils/crypto/jwtTokenGenerator.ts @@ -0,0 +1,159 @@ +/* +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. +*/ + +/** + * ⚠️ WARNING: FOR TESTING ONLY ⚠️ + * + * This is a sample implementation for testing authentication flows. + * DO NOT USE IN PRODUCTION. JWT tokens must be generated server-side. + * + * This mirrors the Swift JWTTokenGenerator implementation for compatibility. + * Uses crypto-js for pure JavaScript implementation (no native dependencies). + */ + +import CryptoJS from 'crypto-js'; +import {Buffer} from 'buffer'; +import {encryptAndSignBase64URLSafe} from './accessTokenEncryptor'; + +/** JWT header field keys */ +const JWTHeaderKey = { + algorithm: 'alg', +} as const; + +/** JWT header field values */ +const JWTHeaderValue = { + hmacSHA256: 'HS256', +} as const; + +/** JWT payload field keys */ +const JWTPayloadKey = { + apiKey: 'api_key', + accessToken: 'access_token', + issuedAt: 'iat', + jwtID: 'jti', +} as const; + +/** + * Generates a JWT authentication token for authenticated checkouts + * + * @param apiKey - The app's API key + * @param sharedSecret - The app's shared secret + * @param accessToken - The app's access token + * @returns JWT token string, or null if generation fails + */ +export function generateAuthToken( + apiKey: string, + sharedSecret: string, + accessToken: string, +): string | null { + try { + // Encrypt the access token + const encryptedAccessToken = encryptAndSignBase64URLSafe( + accessToken, + sharedSecret, + ); + + if (!encryptedAccessToken) { + console.error('[JWTTokenGenerator] Failed to encrypt access token'); + return null; + } + + const issuedAt = Math.floor(Date.now() / 1000); + const jti = generateUUID(); + + const payload = { + [JWTPayloadKey.apiKey]: apiKey, + [JWTPayloadKey.accessToken]: encryptedAccessToken, + [JWTPayloadKey.issuedAt]: issuedAt, + [JWTPayloadKey.jwtID]: jti, + }; + + return encodeJWT(payload, sharedSecret); + } catch (error) { + console.error('[JWTTokenGenerator] Token generation failed:', error); + return null; + } +} + +/** + * Encodes a JWT with HS256 (HMAC-SHA256) signature + * + * @param payload - The JWT payload as an object + * @param secret - The shared secret for HMAC signing + * @returns Complete JWT string (header.payload.signature), or null if encoding fails + */ +function encodeJWT( + payload: Record, + secret: string, +): string | null { + try { + const header = { + [JWTHeaderKey.algorithm]: JWTHeaderValue.hmacSHA256, + }; + + // Create base64url-encoded header and payload + const headerBase64 = base64URLEncode(JSON.stringify(header)); + const payloadBase64 = base64URLEncode(JSON.stringify(payload)); + + // Create signing input: "header.payload" + const signingInput = `${headerBase64}.${payloadBase64}`; + + // Sign with HMAC-SHA256 using crypto-js + const signature = CryptoJS.HmacSHA256(signingInput, secret); + + // Convert signature to base64url + const signatureBase64 = signature + .toString(CryptoJS.enc.Base64) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/[=]/g, ''); + + // Return complete JWT: "header.payload.signature" + return `${signingInput}.${signatureBase64}`; + } catch (error) { + console.error('[JWTTokenGenerator] JWT encoding failed:', error); + return null; + } +} + +/** + * Encodes string as base64url (RFC 4648 Section 5) + */ +function base64URLEncode(input: string): string { + // Use Buffer for base64 encoding (available in React Native) + const base64 = Buffer.from(input, 'utf-8').toString('base64'); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]/g, ''); +} + +/** + * Generates a UUID v4 string + */ +function generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + // eslint-disable-next-line no-bitwise + const r = (Math.random() * 16) | 0; + // eslint-disable-next-line no-bitwise + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/tsconfig.json b/tsconfig.json index 8a05ab81..f47f73f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "forceConsistentCasingInFileNames": true, "jsx": "react", "lib": ["esnext"], + "types": ["node"], "module": "esnext", "moduleResolution": "node", "noFallthroughCasesInSwitch": true, diff --git a/yarn.lock b/yarn.lock index db22691b..104a22d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4527,21 +4527,8 @@ __metadata: react-native-builder-bob: "npm:^0.23.2" typescript: "npm:^5.9.2" peerDependencies: - "@react-navigation/native": ^7.0.0 - "@react-navigation/native-stack": ^7.0.0 react: "*" react-native: "*" - react-native-safe-area-context: ^5.0.0 - react-native-screens: ^4.0.0 - peerDependenciesMeta: - "@react-navigation/native": - optional: true - "@react-navigation/native-stack": - optional: true - react-native-safe-area-context: - optional: true - react-native-screens: - optional: true languageName: unknown linkType: soft @@ -4695,6 +4682,13 @@ __metadata: languageName: node linkType: hard +"@types/crypto-js@npm:^4.2.2": + version: 4.2.2 + resolution: "@types/crypto-js@npm:4.2.2" + checksum: 10c0/760a2078f36f2a3a1089ef367b0d13229876adcf4bcd6e8824d00d9e9bfad8118dc7e6a3cc66322b083535e12be3a29044ccdc9603bfb12519ff61551a3322c6 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" @@ -4762,6 +4756,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.9.1": + version: 24.9.1 + resolution: "@types/node@npm:24.9.1" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/c52f8168080ef9a7c3dc23d8ac6061fab5371aad89231a0f6f4c075869bc3de7e89b075b1f3e3171d9e5143d0dda1807c3dab8e32eac6d68f02e7480e7e78576 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.2 resolution: "@types/parse-json@npm:4.0.2" @@ -5971,6 +5974,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + "bytes@npm:3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" @@ -6505,6 +6518,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:^4.2.0": + version: 4.2.0 + resolution: "crypto-js@npm:4.2.0" + checksum: 10c0/8fbdf9d56f47aea0794ab87b0eb9833baf80b01a7c5c1b0edc7faf25f662fb69ab18dc2199e2afcac54670ff0cd9607a9045a3f7a80336cccd18d77a55b9fdf0 + languageName: node + linkType: hard + "csstype@npm:^3.0.2": version: 3.1.2 resolution: "csstype@npm:3.1.2" @@ -8165,7 +8185,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -11736,9 +11756,13 @@ __metadata: "@react-navigation/bottom-tabs": "npm:^7.4.6" "@react-navigation/stack": "npm:^7.4.8" "@shopify/checkout-sheet-kit": "link:../modules/@shopify/checkout-sheet-kit" + "@types/crypto-js": "npm:^4.2.2" + "@types/node": "npm:^24.9.1" "@types/react-native-vector-icons": "npm:^6.4.18" "@types/setimmediate": "npm:^1" babel-plugin-module-resolver: "npm:^5.0.0" + buffer: "npm:^6.0.3" + crypto-js: "npm:^4.2.0" graphql: "npm:^16.8.2" jotai: "npm:^2.13.1" react-native-config: "npm:1.5.6" @@ -12759,6 +12783,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0"