diff --git a/.gitignore b/.gitignore index 356417c19..f042551bd 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ ios/generated android/generated # Iterable +.env.local .env .xcode.env.local coverage/ diff --git a/example/.env.example b/example/.env.example index e1efc4169..5b5663df6 100644 --- a/example/.env.example +++ b/example/.env.example @@ -9,11 +9,19 @@ # 4. Fill in the following fields: # - Name: A descriptive name for the API key # - Type: Mobile -# - JWT authentication: Leave **unchecked** (IMPORTANT) +# - JWT authentication: Whether or not you want to use JWT # 5. Click "Create API Key" -# 6. Copy the generated API key -# 7. Replace the placeholder text next to `ITBL_API_KEY=` with the copied API key +# 6. Copy the generated API key and replace the placeholder text next to +# `ITBL_API_KEY=` with the copied API key +# 7. If you chose to enable JWT authentication, copy the JWT secret and and +# replace the placeholder text next to `ITBL_JWT_SECRET=` with the copied +# JWT secret ITBL_API_KEY=replace_this_with_your_iterable_api_key +# Your JWT Secret, created when making your API key (see above) +ITBL_JWT_SECRET=replace_this_with_your_jwt_secret +# Is your api token JWT Enabled? +# Must be set to 'true' to enable JWT authentication +ITBL_IS_JWT_ENABLED=true # Your Iterable user ID or email address -ITBL_ID=replace_this_with_your_user_id_or_email \ No newline at end of file +ITBL_ID=replace_this_with_your_user_id_or_email diff --git a/example/README.md b/example/README.md index 4ba5d0e6d..819bc6826 100644 --- a/example/README.md +++ b/example/README.md @@ -23,7 +23,8 @@ _example app directory_. To do so, run the following: ```bash cd ios -pod install +bundle install +bundle exec pod install ``` Once this is done, `cd` back into the _example app directory_: @@ -40,12 +41,18 @@ In it, you will find: ```shell ITBL_API_KEY=replace_this_with_your_iterable_api_key +ITBL_JWT_SECRET=replace_this_with_your_jwt_secret +ITBL_IS_JWT_ENABLED=true ITBL_ID=replace_this_with_your_user_id_or_email ``` -Replace `replace_this_with_your_iterable_api_key` with your _mobile_ Iterable API key, -and replace `replace_this_with_your_user_id_or_email` with the email or user id -that you use to log into Iterable. +- Replace `replace_this_with_your_iterable_api_key` with your **_mobile_ +Iterable API key** +- Replace `replace_this_with_your_jwt_secret` with your **JWT Secret** (if you +have a JWT-enabled API key) +- Set `ITBL_IS_JWT_ENABLED` to true if you have a JWT-enabled key, and false if you do not. +- Replace `replace_this_with_your_user_id_or_email` with the **email or user +id** that you use to log into Iterable. Follow the steps below if you do not have a mobile Iterable API key. @@ -54,12 +61,12 @@ To add an API key, do the following: 1. Sign into your Iterable account 2. Go to [Integrations > API Keys](https://app.iterable.com/settings/apiKeys) 3. Click "New API Key" in the top right corner - 4. Fill in the followsing fields: + 4. Fill in the following fields: - Name: A descriptive name for the API key - Type: Mobile - - JWT authentication: Leave **unchecked** (IMPORTANT) + - JWT authentication: Check to enable JWT authentication. If enabled, will need to create a [JWT generator](https://support.iterable.com/hc/en-us/articles/360050801231-JWT-Enabled-API-Keys#sample-python-code-for-jwt-generation) to generate the JWT token. 5. Click "Create API Key" - 6. Copy the generated API key + 6. Copy the generated API key and JWT secret into your _.env_ file ## Step 3: Start the Metro Server diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java b/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java new file mode 100644 index 000000000..4fa760e33 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java @@ -0,0 +1,106 @@ +package com.iterable; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Base64.Encoder; + +/** +* Utility class to generate JWTs for use with the Iterable API +* +* @author engineering@iterable.com +*/ +public class IterableJwtGenerator { + static Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + + private static final String algorithm = "HmacSHA256"; + + // Iterable enforces a 1-year maximum token lifetime + private static final Duration maxTokenLifetime = Duration.ofDays(365); + + private static long millisToSeconds(long millis) { + return millis / 1000; + } + + private static final String encodedHeader = encoder.encodeToString( + "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8) + ); + + /** + * Generates a JWT from the provided secret, header, and payload. Does not + * validate the header or payload. + * + * @param secret Your organization's shared secret with Iterable + * @param payload The JSON payload + * + * @return a signed JWT + */ + public static String generateToken(String secret, String payload) { + try { + String encodedPayload = encoder.encodeToString( + payload.getBytes(StandardCharsets.UTF_8) + ); + String encodedHeaderAndPayload = encodedHeader + "." + encodedPayload; + + // HMAC setup + Mac hmac = Mac.getInstance(algorithm); + SecretKeySpec keySpec = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), algorithm + ); + hmac.init(keySpec); + + String signature = encoder.encodeToString( + hmac.doFinal( + encodedHeaderAndPayload.getBytes(StandardCharsets.UTF_8) + ) + ); + + return encodedHeaderAndPayload + "." + signature; + + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } + + /** + * Generates a JWT (issued now, expires after the provided duration). + * + * @param secret Your organization's shared secret with Iterable. + * @param duration The token's expiration time. Up to one year. + * @param email The email to included in the token, or null. + * @param userId The userId to include in the token, or null. + * + * @return A JWT string + */ + public static String generateToken( + String secret, Duration duration, String email, String userId) { + + if (duration.compareTo(maxTokenLifetime) > 0) + throw new IllegalArgumentException( + "Duration must be one year or less." + ); + + if ((userId != null && email != null) || (userId == null && email == null)) + throw new IllegalArgumentException( + "The token must include a userId or email, but not both." + ); + + long now = millisToSeconds(System.currentTimeMillis()); + + String payload; + if (userId != null) + payload = String.format( + "{ \"userId\": \"%s\", \"iat\": %d, \"exp\": %d }", + userId, now, now + millisToSeconds(duration.toMillis()) + ); + else + payload = String.format( + "{ \"email\": \"%s\", \"iat\": %d, \"exp\": %d }", + email, now, now + millisToSeconds(duration.toMillis()) + ); + + return generateToken(secret, payload); + } +} diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt new file mode 100644 index 000000000..8a6f7f018 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt @@ -0,0 +1,37 @@ +package iterable.reactnativesdk.example + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.Promise +import com.iterable.IterableJwtGenerator +import java.time.Duration + +class JwtTokenModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return NAME + } + + @ReactMethod + fun generateJwtToken( + secret: String, + durationMs: Double, + email: String?, + userId: String?, + promise: Promise + ) { + try { + val duration = Duration.ofMillis(durationMs.toLong()) + val token = IterableJwtGenerator.generateToken(secret, duration, email, userId) + promise.resolve(token) + } catch (e: Exception) { + promise.reject("JWT_GENERATION_ERROR", e.message, e) + } + } + + companion object { + const val NAME = "JwtTokenModule" + } +} + diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt new file mode 100644 index 000000000..e05384909 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt @@ -0,0 +1,34 @@ +package iterable.reactnativesdk.example + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class JwtTokenPackage : BaseReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == JwtTokenModule.NAME) { + JwtTokenModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[JwtTokenModule.NAME] = ReactModuleInfo( + JwtTokenModule.NAME, + JwtTokenModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + true, // hasConstants + false // isCxxModule + ) + moduleInfos + } + } +} + diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt index d0fba2035..9c004c88b 100644 --- a/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt @@ -20,6 +20,7 @@ class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: // add(MyReactNativePackage()) + add(JwtTokenPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/example/ios/IterableJwtGenerator.swift b/example/ios/IterableJwtGenerator.swift new file mode 100644 index 000000000..8e4a9cbe7 --- /dev/null +++ b/example/ios/IterableJwtGenerator.swift @@ -0,0 +1,95 @@ +// +// IterableJwtGenerator.swift +// ReactNativeSdkExample +// +// Utility class to generate JWTs for use with the Iterable API +// + +import CryptoKit +import Foundation + +@objcMembers public final class IterableJwtGenerator: NSObject { + + private struct Header: Encodable { + let alg = "HS256" + let typ = "JWT" + } + + /// Base64 URL encode without padding (URL-safe base64 encoding for JWT) + private static func urlEncodedBase64(_ data: Data) -> String { + let base64 = data.base64EncodedString() + return + base64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// Generic JWT generation helper that works with any Encodable payload + private static func generateJwt(secret: String, payload: T) -> String { + let headerJsonData = try! JSONEncoder().encode(Header()) + let headerBase64 = urlEncodedBase64(headerJsonData) + + let payloadJsonData = try! JSONEncoder().encode(payload) + let payloadBase64 = urlEncodedBase64(payloadJsonData) + + let toSign = Data((headerBase64 + "." + payloadBase64).utf8) + + if #available(iOS 13.0, *) { + let privateKey = SymmetricKey(data: Data(secret.utf8)) + let signature = HMAC.authenticationCode(for: toSign, using: privateKey) + let signatureBase64 = urlEncodedBase64(Data(signature)) + + let token = [headerBase64, payloadBase64, signatureBase64].joined(separator: ".") + + return token + } + return "" + } + + public static func generateJwtForEmail(secret: String, iat: Int, exp: Int, email: String) + -> String + { + struct Payload: Encodable { + var email: String + var iat: Int + var exp: Int + } + + return generateJwt(secret: secret, payload: Payload(email: email, iat: iat, exp: exp)) + } + + public static func generateJwtForUserId(secret: String, iat: Int, exp: Int, userId: String) + -> String + { + struct Payload: Encodable { + var userId: String + var iat: Int + var exp: Int + } + + return generateJwt(secret: secret, payload: Payload(userId: userId, iat: iat, exp: exp)) + } + + public static func generateToken( + secret: String, durationMs: Int64, email: String?, userId: String? + ) throws -> String { + // Convert durationMs from milliseconds to seconds + let durationSeconds = Double(durationMs) / 1000.0 + let currentTime = Date().timeIntervalSince1970 + + if userId != nil { + return generateJwtForUserId( + secret: secret, iat: Int(currentTime), + exp: Int(currentTime + durationSeconds), userId: userId!) + } else if email != nil { + return generateJwtForEmail( + secret: secret, iat: Int(currentTime), + exp: Int(currentTime + durationSeconds), email: email!) + } else { + throw NSError( + domain: "JWTGenerator", code: 6, + userInfo: [NSLocalizedDescriptionKey: "No email or userId provided"]) + } + } +} diff --git a/example/ios/JwtTokenModule.mm b/example/ios/JwtTokenModule.mm new file mode 100644 index 000000000..10390bc43 --- /dev/null +++ b/example/ios/JwtTokenModule.mm @@ -0,0 +1,25 @@ +// +// JwtTokenModule.m +// ReactNativeSdkExample +// +// React Native module bridge for JWT token generation +// + +#import + +@interface RCT_EXTERN_MODULE(JwtTokenModule, NSObject) + +RCT_EXTERN_METHOD(generateJwtToken:(NSString *)secret + durationMs:(double)durationMs + email:(NSString *)email + userId:(NSString *)userId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +@end + diff --git a/example/ios/JwtTokenModule.swift b/example/ios/JwtTokenModule.swift new file mode 100644 index 000000000..5c121143b --- /dev/null +++ b/example/ios/JwtTokenModule.swift @@ -0,0 +1,40 @@ +// +// JwtTokenModule.swift +// ReactNativeSdkExample +// +// React Native module to generate JWT tokens +// + +import Foundation +import React + +@objc(JwtTokenModule) +class JwtTokenModule: NSObject { + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } + + @objc + func generateJwtToken( + _ secret: String, + durationMs: Double, + email: String?, + userId: String?, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + do { + let token = try IterableJwtGenerator.generateToken( + secret: secret, + durationMs: Int64(durationMs), + email: email, + userId: userId + ) + resolve(token) + } catch { + reject("JWT_GENERATION_ERROR", error.localizedDescription, error) + } + } +} diff --git a/example/ios/ReactNativeSdkExample-Bridging-Header.h b/example/ios/ReactNativeSdkExample-Bridging-Header.h index 339994e93..856694030 100644 --- a/example/ios/ReactNativeSdkExample-Bridging-Header.h +++ b/example/ios/ReactNativeSdkExample-Bridging-Header.h @@ -2,3 +2,6 @@ // Use this file to import your target's public headers that you would like to // expose to Swift. // + +#import +#import diff --git a/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj b/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj index 74e4dc4c9..766c9b681 100644 --- a/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj +++ b/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ 00E356F31AD99517003FC87E /* ReactNativeSdkExampleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* ReactNativeSdkExampleTests.m */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 779227342DFA3FB500D69EC0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779227332DFA3FB500D69EC0 /* AppDelegate.swift */; }; + 77E3B5772EA71A4B001449CE /* IterableJwtGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */; }; + 77E3B5782EA71A4B001449CE /* JwtTokenModule.mm in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */; }; + 77E3B5792EA71A4B001449CE /* JwtTokenModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 81F6A9EA0E1CCC1AD730C5D9 /* libPods-ReactNativeSdkExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 56080B9DEED42A97AD1B3D5C /* libPods-ReactNativeSdkExample.a */; }; A3A40C20801B8F02005FA4C0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1FC6B09E65A7BD9F6864C5D8 /* PrivacyInfo.xcprivacy */; }; @@ -39,6 +42,9 @@ 779227312DFA3FB500D69EC0 /* ReactNativeSdkExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReactNativeSdkExample-Bridging-Header.h"; sourceTree = ""; }; 779227322DFA3FB500D69EC0 /* ReactNativeSdkExampleTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReactNativeSdkExampleTests-Bridging-Header.h"; sourceTree = ""; }; 779227332DFA3FB500D69EC0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = ReactNativeSdkExample/AppDelegate.swift; sourceTree = ""; }; + 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableJwtGenerator.swift; sourceTree = ""; }; + 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = JwtTokenModule.mm; sourceTree = ""; }; + 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JwtTokenModule.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ReactNativeSdkExample/LaunchScreen.storyboard; sourceTree = ""; }; EA19B65827A1D757CC5AAC97 /* Pods-ReactNativeSdkExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.release.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; @@ -114,6 +120,9 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */, + 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */, + 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */, 13B07FAE1A68108700A75B9A /* ReactNativeSdkExample */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 00E356EF1AD99517003FC87E /* ReactNativeSdkExampleTests */, @@ -268,10 +277,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks.sh\"\n"; @@ -307,10 +320,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources.sh\"\n"; @@ -332,6 +349,9 @@ buildActionMask = 2147483647; files = ( 779227342DFA3FB500D69EC0 /* AppDelegate.swift in Sources */, + 77E3B5772EA71A4B001449CE /* IterableJwtGenerator.swift in Sources */, + 77E3B5782EA71A4B001449CE /* JwtTokenModule.mm in Sources */, + 77E3B5792EA71A4B001449CE /* JwtTokenModule.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -535,10 +555,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -611,10 +628,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/example/src/NativeJwtTokenModule.ts b/example/src/NativeJwtTokenModule.ts new file mode 100644 index 000000000..464cdc37b --- /dev/null +++ b/example/src/NativeJwtTokenModule.ts @@ -0,0 +1,45 @@ +import { NativeModules, TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + generateJwtToken( + secret: string, + durationMs: number, + email: string | null, + userId: string | null + ): Promise; +} + +// Try to use TurboModule if available (New Architecture) +// Fall back to NativeModules (Old Architecture) +const isTurboModuleEnabled = + '__turboModuleProxy' in global && + (global as Record).__turboModuleProxy != null; + +let JwtTokenModule: Spec | null = null; + +try { + JwtTokenModule = isTurboModuleEnabled + ? TurboModuleRegistry.getEnforcing('JwtTokenModule') + : NativeModules.JwtTokenModule; +} catch { + // Module not available - will throw error when used + console.warn('JwtTokenModule native module is not available yet'); +} + +// Create a proxy that throws a helpful error when methods are called +const createModuleProxy = (): Spec => { + const handler: ProxyHandler = { + get(_target, prop) { + if (!JwtTokenModule) { + throw new Error( + `JwtTokenModule native module is not available. Make sure the native module is properly linked and the app has been rebuilt.\n\nFor iOS: Add Swift files to Xcode project (see SETUP_GUIDE.md)\nFor Android: Ensure JwtTokenPackage is registered in MainApplication.kt` + ); + } + return JwtTokenModule[prop as keyof Spec]; + }, + }; + return new Proxy({} as Spec, handler); +}; + +export default createModuleProxy(); diff --git a/example/src/hooks/useIterableApp.tsx b/example/src/hooks/useIterableApp.tsx index d648dd25c..c215ff22f 100644 --- a/example/src/hooks/useIterableApp.tsx +++ b/example/src/hooks/useIterableApp.tsx @@ -20,6 +20,7 @@ import { import { Route } from '../constants/routes'; import type { RootStackParamList } from '../types/navigation'; +import NativeJwtTokenModule from '../NativeJwtTokenModule'; type Navigation = StackNavigationProp; @@ -86,6 +87,8 @@ const IterableAppContext = createContext({ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const getIsEmail = (id: string) => EMAIL_REGEX.test(id); + export const IterableAppProvider: FunctionComponent< React.PropsWithChildren > = ({ children }) => { @@ -105,6 +108,21 @@ export const IterableAppProvider: FunctionComponent< const getUserId = useCallback(() => userId ?? process.env.ITBL_ID, [userId]); + const getJwtToken = useCallback(async () => { + const id = userId ?? process.env.ITBL_ID; + const idType = getIsEmail(id as string) ? 'email' : 'userId'; + const secret = process.env.ITBL_JWT_SECRET ?? ''; + const duration = 1000 * 60 * 60 * 24; // 1 day in milliseconds + const jwtToken = await NativeJwtTokenModule.generateJwtToken( + secret, + duration, + idType === 'email' ? (id as string) : null, // Email (can be null if userId is provided) + idType === 'userId' ? (id as string) : null // UserId (can be null if email is provided) + ); + + return jwtToken; + }, [userId]); + const login = useCallback(() => { const id = userId ?? process.env.ITBL_ID; @@ -112,8 +130,7 @@ export const IterableAppProvider: FunctionComponent< setLoginInProgress(true); - const isEmail = EMAIL_REGEX.test(id); - const fn = isEmail ? Iterable.setEmail : Iterable.setUserId; + const fn = getIsEmail(id) ? Iterable.setEmail : Iterable.setUserId; fn(id); setIsLoggedIn(true); @@ -124,6 +141,10 @@ export const IterableAppProvider: FunctionComponent< const initialize = useCallback( (navigation: Navigation) => { + if (getUserId()) { + login(); + } + const config = new IterableConfig(); config.inAppDisplayInterval = 1.0; // Min gap between in-apps. No need to set this in production. @@ -173,21 +194,16 @@ export const IterableAppProvider: FunctionComponent< config.inAppHandler = () => IterableInAppShowResponse.show; - // NOTE: Uncomment to test authHandler failure - // config.authHandler = () => { - // console.log(`authHandler`); - - // return Promise.resolve({ - // authToken: 'SomethingNotValid', - // successCallback: () => { - // console.log(`authHandler > success`); - // }, - // // This is not firing - // failureCallback: () => { - // console.log(`authHandler > failure`); - // }, - // }); - // }; + if ( + process.env.ITBL_IS_JWT_ENABLED === 'true' && + process.env.ITBL_JWT_SECRET + ) { + config.authHandler = async () => { + const token = await getJwtToken(); + // return 'SomethingNotValid'; // Uncomment this to test the failure callback + return token; + }; + } setItblConfig(config); @@ -203,11 +219,8 @@ export const IterableAppProvider: FunctionComponent< .then((isSuccessful) => { setIsInitialized(isSuccessful); - if (!isSuccessful) + if (!isSuccessful) { return Promise.reject('`Iterable.initialize` failed'); - - if (getUserId()) { - login(); } return isSuccessful; @@ -220,19 +233,10 @@ export const IterableAppProvider: FunctionComponent< setIsInitialized(false); setLoginInProgress(false); return Promise.reject(err); - }) - .finally(() => { - // For some reason, ios is throwing an error on initialize. - // To temporarily fix this, we're using the finally block to login. - // MOB-10419: Find out why initialize is throwing an error on ios - setIsInitialized(true); - if (getUserId()) { - login(); - } - return Promise.resolve(true); }); }, - [apiKey, getUserId, login] + // eslint-disable-next-line react-hooks/exhaustive-deps + [getUserId, apiKey, login, getJwtToken, userId] ); const logout = useCallback(() => { diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index f04b08e42..80c0a9cab 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -215,7 +215,8 @@ import React ITBError("Could not find message with id: \(messageId)") return } - IterableAPI.track(inAppOpen: message, location: InAppLocation.from(number: locationNumber as NSNumber)) + IterableAPI.track( + inAppOpen: message, location: InAppLocation.from(number: locationNumber as NSNumber)) } @objc(trackInAppClick:location:clickedUrl:) @@ -414,8 +415,10 @@ import React templateId: Double ) { ITBInfo() - let finalCampaignId: NSNumber? = (campaignId as NSNumber).intValue <= 0 ? nil : campaignId as NSNumber - let finalTemplateId: NSNumber? = (templateId as NSNumber).intValue <= 0 ? nil : templateId as NSNumber + let finalCampaignId: NSNumber? = + (campaignId as NSNumber).intValue <= 0 ? nil : campaignId as NSNumber + let finalTemplateId: NSNumber? = + (templateId as NSNumber).intValue <= 0 ? nil : templateId as NSNumber IterableAPI.updateSubscriptions( emailListIds, unsubscribedChannelIds: unsubscribedChannelIds, @@ -480,7 +483,7 @@ import React @objc(passAlongAuthToken:) public func passAlongAuthToken(authToken: String?) { ITBInfo() - passedAuthToken = authToken + self.passedAuthToken = authToken authHandlerSemaphore.signal() } @@ -537,7 +540,9 @@ import React iterableConfig.inAppDelegate = self } - if let authHandlerPresent = configDict["authHandlerPresent"] as? Bool, authHandlerPresent { + if let authHandlerPresent = configDict["authHandlerPresent"] as? Bool, + authHandlerPresent == true + { iterableConfig.authDelegate = self } diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 9ef784679..75321d4bc 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -957,7 +957,7 @@ export class Iterable { (promiseResult as IterableAuthResponse).authToken ); - const timeoutId = setTimeout(() => { + setTimeout(() => { if ( authResponseCallback === IterableAuthResponseResult.SUCCESS ) { @@ -976,8 +976,6 @@ export class Iterable { IterableLogger?.log('No callback received from native layer'); } }, 1000); - // Use unref() to prevent the timeout from keeping the process alive - timeoutId.unref(); } else if (typeof promiseResult === 'string') { //If promise only returns string Iterable.authManager.passAlongAuthToken(promiseResult as string); diff --git a/yarn.lock b/yarn.lock index eb5b0a861..348741043 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3555,24 +3555,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:^6.21.0": - version: 6.21.0 - resolution: "@typescript-eslint/parser@npm:6.21.0" - dependencies: - "@typescript-eslint/scope-manager": 6.21.0 - "@typescript-eslint/types": 6.21.0 - "@typescript-eslint/typescript-estree": 6.21.0 - "@typescript-eslint/visitor-keys": 6.21.0 - debug: ^4.3.4 - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 162fe3a867eeeffda7328bce32dae45b52283c68c8cb23258fb9f44971f761991af61f71b8c9fe1aa389e93dfe6386f8509c1273d870736c507d76dd40647b68 - languageName: node - linkType: hard - "@typescript-eslint/parser@npm:^7.1.1": version: 7.18.0 resolution: "@typescript-eslint/parser@npm:7.18.0"