From 8a8f5f86433d3c71b355f44803c7c833d1f7cefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 27 Aug 2026 12:59:58 +0100 Subject: [PATCH 1/5] feat(example): add opt-in JWT auth demo for JWT-enabled API keys Enable the Expo example to exercise Iterable JWT login without changing the default email path or the plugin public API. Co-authored-by: Cursor --- example/.env | 13 ++++- example/README.md | 32 +++++++++++ example/src/Login.tsx | 23 +++++++- example/src/jwt/demoAuth.ts | 85 +++++++++++++++++++++++++++++ example/src/jwt/signDemoJwt.test.ts | 54 ++++++++++++++++++ example/src/jwt/signDemoJwt.ts | 81 +++++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 example/src/jwt/demoAuth.ts create mode 100644 example/src/jwt/signDemoJwt.test.ts create mode 100644 example/src/jwt/signDemoJwt.ts diff --git a/example/.env b/example/.env index 16aaee78..54bf9e3a 100644 --- a/example/.env +++ b/example/.env @@ -3,9 +3,18 @@ # Replace `YOUR_ITERABLE_API_KEY` with your **mobile** iterable API key. This # will allow the example app to establish a connection with your Iterable -# project. +# project. Use a non-JWT key unless you enable the JWT demo below. EXPO_PUBLIC_ITERABLE_API_KEY=YOUR_ITERABLE_API_KEY # For the login email address to be pre-filled, uncomment the below and replace # `YOUR_ITERABLE_EMAIL` an email address. -# EXPO_PUBLIC_ITERABLE_EMAIL=YOUR_ITERABLE_EMAIL \ No newline at end of file +# EXPO_PUBLIC_ITERABLE_EMAIL=YOUR_ITERABLE_EMAIL + +# Opt-in JWT demo. Default is disabled (email login with a non-JWT API key). +# Set to true only when using a JWT-enabled **mobile** API key. +# EXPO_PUBLIC_ITERABLE_JWT_ENABLED=true + +# JWT secret from a JWT-enabled **mobile** API key. Required only when JWT is +# enabled. DEMO ONLY — never embed this secret in a production app. +# EXPO_PUBLIC_* values are inlined into the JS bundle. +# EXPO_PUBLIC_ITERABLE_JWT_SECRET=YOUR_ITERABLE_JWT_SECRET \ No newline at end of file diff --git a/example/README.md b/example/README.md index 0e2b8519..ab698452 100644 --- a/example/README.md +++ b/example/README.md @@ -12,6 +12,7 @@ Expo. - [@iterable/expo-plugin Example](#iterableexpo-plugin-example) - [Prerequisites](#prerequisites) - [Setup](#setup) + - [JWT authentication (optional)](#jwt-authentication-optional) - [Running the App](#running-the-app) - [iOS](#ios) - [Android](#android) @@ -47,6 +48,7 @@ Expo. - Create a file called `.env.local` in the *example* directory - Copy the contents of `.env` to the new `.env.local` - Replace `YOUR_ITERABLE_API_KEY` with your actual Iterable API key + (a non-JWT **mobile** key unless you follow [JWT authentication](#jwt-authentication-optional)) - If desired, uncomment `EXPO_PUBLIC_ITERABLE_EMAIL=YOUR_ITERABLE_EMAIL` and replace `YOUR_ITERABLE_EMAIL` with your actual Iterable email 4. Push Notifications (Optional) @@ -58,6 +60,36 @@ Expo. [README](https://github.com/Iterable/iterable-expo-plugin/blob/main/README.md#deep-links-optional) to add deep link support to the example app. +## JWT authentication (optional) + +Email login with a non-JWT API key is the default. The example can also exercise +a JWT-enabled **mobile** API key using a **JavaScript demo signer** in +`example/src/jwt/`. This is demo-only. + +**Never embed the Iterable JWT secret in a production app.** `EXPO_PUBLIC_*` +values are inlined into the JavaScript bundle. Production apps must return a +token from `authHandler` that was fetched from a backend that holds the secret. + +To try the JWT path: + +1. Create a JWT-enabled **mobile** API key: + 1. Sign into your Iterable account + 2. Go to [Integrations > API Keys](https://app.iterable.com/settings/apiKeys) + 3. Click **New API Key** + 4. Name: a descriptive name + 5. Type: **Mobile** + 6. JWT authentication: **checked** + 7. Create the key and copy both the API key and the JWT secret +2. In `.env.local`: + - Set `EXPO_PUBLIC_ITERABLE_API_KEY` to that JWT-enabled mobile key + - Uncomment and set `EXPO_PUBLIC_ITERABLE_JWT_ENABLED=true` + - Uncomment and set `EXPO_PUBLIC_ITERABLE_JWT_SECRET` to the JWT secret +3. Rebuild / reload the example app + +The demo `authHandler` is structured so you can replace the local signer with a +`fetch` to your backend. See the comment on `getDemoAuthToken` in +`example/src/jwt/demoAuth.ts`. + ## Running the App ### iOS diff --git a/example/src/Login.tsx b/example/src/Login.tsx index d272cff3..959dcef9 100644 --- a/example/src/Login.tsx +++ b/example/src/Login.tsx @@ -4,7 +4,7 @@ import { IterableInAppShowResponse, IterableLogLevel, } from '@iterable/react-native-sdk'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Platform, @@ -18,6 +18,11 @@ import { } from 'react-native'; import { colors } from './constants'; +import { + applyJwtToConfig, + getDemoAuthToken, + isJwtConfigured, +} from './jwt/demoAuth'; interface LoginProps { /** @@ -39,9 +44,20 @@ export const Login = ({ onLoggedIn = () => {} }: LoginProps) => { const [email, setEmail] = useState( process.env.EXPO_PUBLIC_ITERABLE_EMAIL ?? '' ); + const emailRef = useRef(email); + emailRef.current = email; - const onPress = () => { - Iterable.setEmail(email); + const onPress = async () => { + try { + if (isJwtConfigured()) { + const token = await getDemoAuthToken(email); + Iterable.setEmail(email, token); + } else { + Iterable.setEmail(email); + } + } catch { + Iterable.setEmail(email); + } setTimeout(() => { onLoggedIn(); }, 300); @@ -57,6 +73,7 @@ export const Login = ({ onLoggedIn = () => {} }: LoginProps) => { config.allowedProtocols = ['app', 'iterable']; config.logLevel = IterableLogLevel.info; config.inAppHandler = () => IterableInAppShowResponse.show; + applyJwtToConfig(config, () => emailRef.current); Iterable.initialize( process.env.EXPO_PUBLIC_ITERABLE_API_KEY as string, diff --git a/example/src/jwt/demoAuth.ts b/example/src/jwt/demoAuth.ts new file mode 100644 index 00000000..6758435a --- /dev/null +++ b/example/src/jwt/demoAuth.ts @@ -0,0 +1,85 @@ +import { + IterableAuthFailureReason, + IterableRetryBackoff, + type IterableAuthFailure, + type IterableConfig, +} from '@iterable/react-native-sdk'; +import { Alert } from 'react-native'; + +import { signDemoJwt } from './signDemoJwt'; + +const PLACEHOLDER_JWT_SECRET = 'YOUR_ITERABLE_JWT_SECRET'; + +export function isJwtEnabled(): boolean { + return process.env.EXPO_PUBLIC_ITERABLE_JWT_ENABLED === 'true'; +} + +export function getJwtSecret(): string | undefined { + const secret = process.env.EXPO_PUBLIC_ITERABLE_JWT_SECRET; + if (!secret || secret === PLACEHOLDER_JWT_SECRET) { + return undefined; + } + return secret; +} + +export function isJwtConfigured(): boolean { + return isJwtEnabled() && getJwtSecret() !== undefined; +} + +/** + * DEMO ONLY. Production apps should replace this local signer with a backend + * fetch, for example: + * + * ``` + * const response = await fetch('https://your-backend.example/iterable-jwt', { + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: JSON.stringify({ email }), + * }); + * return response.text(); + * ``` + */ +export async function getDemoAuthToken(email: string): Promise { + const secret = getJwtSecret(); + if (!secret) { + throw new Error('JWT secret is not configured'); + } + return signDemoJwt({ email, secret }); +} + +export function jwtFailureReasonLabel( + failureReason: IterableAuthFailure['failureReason'] +): string { + if (typeof failureReason === 'string') { + return failureReason; + } + return IterableAuthFailureReason[failureReason] ?? 'Unknown error'; +} + +export function applyJwtToConfig( + config: IterableConfig, + getEmail: () => string +): void { + if (!isJwtEnabled()) { + return; + } + + config.retryPolicy = { + maxRetry: 5, + retryInterval: 5, + retryBackoff: IterableRetryBackoff.linear, + }; + + config.onJwtError = (authFailure: IterableAuthFailure) => { + Alert.alert( + 'JWT authentication failed', + jwtFailureReasonLabel(authFailure.failureReason) + ); + }; + + if (!isJwtConfigured()) { + return; + } + + config.authHandler = () => getDemoAuthToken(getEmail()); +} diff --git a/example/src/jwt/signDemoJwt.test.ts b/example/src/jwt/signDemoJwt.test.ts new file mode 100644 index 00000000..467f9731 --- /dev/null +++ b/example/src/jwt/signDemoJwt.test.ts @@ -0,0 +1,54 @@ +import { DEMO_JWT_TTL_SECONDS, signDemoJwt } from './signDemoJwt'; + +function decodeJwtPayload(token: string): { + email?: string; + iat?: number; + exp?: number; + userId?: string; +} { + const payload = token.split('.')[1]; + if (!payload) { + throw new Error('JWT is missing a payload'); + } + const padded = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padLength = (4 - (padded.length % 4)) % 4; + const json = Buffer.from(padded + '='.repeat(padLength), 'base64').toString( + 'utf8' + ); + return JSON.parse(json) as { + email?: string; + iat?: number; + exp?: number; + userId?: string; + }; +} + +describe('signDemoJwt', () => { + const email = 'demo@example.com'; + const secret = 'test-secret'; + const nowSeconds = 1_700_000_000; + + it('returns a three-part HS256 token', async () => { + const token = await signDemoJwt({ email, secret, nowSeconds }); + expect(token.split('.')).toHaveLength(3); + }); + + it('includes email, iat, and exp, and omits userId', async () => { + const token = await signDemoJwt({ email, secret, nowSeconds }); + const payload = decodeJwtPayload(token); + expect(payload.email).toBe(email); + expect(payload.iat).toBe(nowSeconds); + expect(payload.exp).toBe(nowSeconds + DEMO_JWT_TTL_SECONDS); + expect(payload).not.toHaveProperty('userId'); + }); + + it('produces a different signature for a different secret', async () => { + const valid = await signDemoJwt({ email, secret, nowSeconds }); + const invalid = await signDemoJwt({ + email, + secret: 'other-secret', + nowSeconds, + }); + expect(valid.split('.')[2]).not.toBe(invalid.split('.')[2]); + }); +}); diff --git a/example/src/jwt/signDemoJwt.ts b/example/src/jwt/signDemoJwt.ts new file mode 100644 index 00000000..cced7e48 --- /dev/null +++ b/example/src/jwt/signDemoJwt.ts @@ -0,0 +1,81 @@ +/** + * DEMO ONLY. Signs an Iterable JWT with HS256 in JavaScript so the example + * survives `expo prebuild --clean`. Production apps must fetch JWTs from a + * backend and must never embed the Iterable JWT secret. + * + * Algorithm matches the RN example's IterableJwtGenerator: HS256, URL-safe + * base64 without padding, payload `{ email, iat, exp }` (no userId). + */ + +export const DEMO_JWT_TTL_SECONDS = 86_400; + +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +export type SignDemoJwtParams = { + email: string; + secret: string; + /** Unix timestamp in seconds. Defaults to now. Exposed for tests. */ + nowSeconds?: number; +}; + +export async function signDemoJwt({ + email, + secret, + nowSeconds, +}: SignDemoJwtParams): Promise { + const iat = nowSeconds ?? Math.floor(Date.now() / 1000); + const exp = iat + DEMO_JWT_TTL_SECONDS; + + const header = base64UrlEncodeUtf8('{"alg":"HS256","typ":"JWT"}'); + const payload = base64UrlEncodeUtf8(JSON.stringify({ email, iat, exp })); + const signingInput = `${header}.${payload}`; + const signature = base64UrlEncode(await hmacSha256(secret, signingInput)); + + return `${signingInput}.${signature}`; +} + +async function hmacSha256(secret: string, data: string): Promise { + const cryptoApi = globalThis.crypto; + if (cryptoApi?.subtle == null) { + throw new Error('Web Crypto is not available for demo JWT signing'); + } + + const encoder = new TextEncoder(); + const key = await cryptoApi.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await cryptoApi.subtle.sign( + 'HMAC', + key, + encoder.encode(data) + ); + return new Uint8Array(signature); +} + +function base64UrlEncodeUtf8(value: string): string { + return base64UrlEncode(new TextEncoder().encode(value)); +} + +function base64UrlEncode(bytes: Uint8Array): string { + let result = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i] ?? 0; + const b = bytes[i + 1] ?? 0; + const c = bytes[i + 2] ?? 0; + const triplet = (a << 16) | (b << 8) | c; + result += BASE64_ALPHABET.charAt((triplet >> 18) & 63); + result += BASE64_ALPHABET.charAt((triplet >> 12) & 63); + result += BASE64_ALPHABET.charAt((triplet >> 6) & 63); + result += BASE64_ALPHABET.charAt(triplet & 63); + } + const padding = (3 - (bytes.length % 3)) % 3; + if (padding > 0) { + result = result.slice(0, result.length - padding); + } + return result.replace(/[+]/g, '-').replace(/\//g, '_'); +} From 120dfa620094e431de9b99376d5212ca6290b25b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 27 Aug 2026 16:24:02 +0100 Subject: [PATCH 2/5] chore: ignore local agent tooling directories Keep .agent, .cursor, and similar editor/agent folders out of version control. Co-authored-by: Cursor --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index e49b359b..73fbe83d 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,10 @@ example/.yarn/install-state.gz # Coverage coverage/ + +# Agent tooling +.agent/ +.claude/ +.cursor/ +.opencode/ +.pi/ From ec002e97d18c2ccf89c66cec7cc49cdc67b0e751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 27 Aug 2026 16:40:53 +0100 Subject: [PATCH 3/5] chore: ignore .artifacts in git Keep local review and design artifacts out of version control. Co-authored-by: Cursor --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 73fbe83d..738dccc8 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ coverage/ # Agent tooling .agent/ +.artifacts/ .claude/ .cursor/ .opencode/ From ca676103116c85166a16524fe44cdcf737a158f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 27 Aug 2026 16:45:54 +0100 Subject: [PATCH 4/5] fix(example): sign demo JWTs with pure JS HMAC-SHA256 Hermes has no SubtleCrypto, so the Web Crypto signer failed on iOS and Android. Co-authored-by: Cursor --- example/src/jwt/hmacSha256.ts | 132 ++++++++++++++++++++++++++++ example/src/jwt/signDemoJwt.test.ts | 15 ++++ example/src/jwt/signDemoJwt.ts | 26 +----- 3 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 example/src/jwt/hmacSha256.ts diff --git a/example/src/jwt/hmacSha256.ts b/example/src/jwt/hmacSha256.ts new file mode 100644 index 00000000..b362374f --- /dev/null +++ b/example/src/jwt/hmacSha256.ts @@ -0,0 +1,132 @@ +const SHA256_K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const HMAC_BLOCK_SIZE = 64; + +function rotr(n: number, x: number): number { + return ((x >>> n) | (x << (32 - n))) >>> 0; +} + +function sha256(message: Uint8Array): Uint8Array { + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; + + const bitLen = message.length * 8; + const paddedLen = ((message.length + 9 + 63) & ~63) >>> 0; + const block = new Uint8Array(paddedLen); + block.set(message); + block[message.length] = 0x80; + const view = new DataView(block.buffer); + view.setUint32(paddedLen - 4, bitLen, false); + + const w = new Uint32Array(64); + for (let offset = 0; offset < paddedLen; offset += 64) { + for (let i = 0; i < 16; i += 1) { + w[i] = view.getUint32(offset + i * 4, false); + } + for (let i = 16; i < 64; i += 1) { + const w15 = w[i - 15] ?? 0; + const w2 = w[i - 2] ?? 0; + const s0 = rotr(7, w15) ^ rotr(18, w15) ^ (w15 >>> 3); + const s1 = rotr(17, w2) ^ rotr(19, w2) ^ (w2 >>> 10); + w[i] = (s1 + (w[i - 7] ?? 0) + s0 + (w[i - 16] ?? 0)) >>> 0; + } + + let a = h0; + let b = h1; + let c = h2; + let d = h3; + let e = h4; + let f = h5; + let g = h6; + let h = h7; + + for (let i = 0; i < 64; i += 1) { + const S1 = rotr(6, e) ^ rotr(11, e) ^ rotr(25, e); + const ch = (e & f) ^ (~e & g); + const temp1 = + (h + S1 + ch + (SHA256_K[i] ?? 0) + (w[i] ?? 0)) >>> 0; + const S0 = rotr(2, a) ^ rotr(13, a) ^ rotr(22, a); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) >>> 0; + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + } + + const out = new Uint8Array(32); + const outView = new DataView(out.buffer); + outView.setUint32(0, h0, false); + outView.setUint32(4, h1, false); + outView.setUint32(8, h2, false); + outView.setUint32(12, h3, false); + outView.setUint32(16, h4, false); + outView.setUint32(20, h5, false); + outView.setUint32(24, h6, false); + outView.setUint32(28, h7, false); + return out; +} + +/** + * HMAC-SHA256 over UTF-8 strings. Pure JS so Hermes / RN can sign JWTs + * without Web Crypto SubtleCrypto. + */ +export function hmacSha256(secret: string, data: string): Uint8Array { + const encoder = new TextEncoder(); + let key = encoder.encode(secret); + const dataBytes = encoder.encode(data); + + if (key.length > HMAC_BLOCK_SIZE) { + key = sha256(key); + } + + const ipad = new Uint8Array(HMAC_BLOCK_SIZE); + const opad = new Uint8Array(HMAC_BLOCK_SIZE); + ipad.set(key); + opad.set(key); + for (let i = 0; i < HMAC_BLOCK_SIZE; i += 1) { + ipad[i] = (ipad[i] ?? 0) ^ 0x36; + opad[i] = (opad[i] ?? 0) ^ 0x5c; + } + + const inner = new Uint8Array(HMAC_BLOCK_SIZE + dataBytes.length); + inner.set(ipad); + inner.set(dataBytes, HMAC_BLOCK_SIZE); + + const outer = new Uint8Array(HMAC_BLOCK_SIZE + 32); + outer.set(opad); + outer.set(sha256(inner), HMAC_BLOCK_SIZE); + return sha256(outer); +} diff --git a/example/src/jwt/signDemoJwt.test.ts b/example/src/jwt/signDemoJwt.test.ts index 467f9731..16c8a813 100644 --- a/example/src/jwt/signDemoJwt.test.ts +++ b/example/src/jwt/signDemoJwt.test.ts @@ -1,3 +1,5 @@ +import { createHmac } from 'crypto'; + import { DEMO_JWT_TTL_SECONDS, signDemoJwt } from './signDemoJwt'; function decodeJwtPayload(token: string): { @@ -51,4 +53,17 @@ describe('signDemoJwt', () => { }); expect(valid.split('.')[2]).not.toBe(invalid.split('.')[2]); }); + + it('matches Node crypto HMAC-SHA256', async () => { + const token = await signDemoJwt({ email, secret, nowSeconds }); + const [header, payload, signature] = token.split('.'); + expect(header).toBeDefined(); + expect(payload).toBeDefined(); + expect(signature).toBeDefined(); + const signingInput = `${header}.${payload}`; + const expected = createHmac('sha256', secret) + .update(signingInput) + .digest('base64url'); + expect(signature).toBe(expected); + }); }); diff --git a/example/src/jwt/signDemoJwt.ts b/example/src/jwt/signDemoJwt.ts index cced7e48..c56fca31 100644 --- a/example/src/jwt/signDemoJwt.ts +++ b/example/src/jwt/signDemoJwt.ts @@ -7,6 +7,8 @@ * base64 without padding, payload `{ email, iat, exp }` (no userId). */ +import { hmacSha256 } from './hmacSha256'; + export const DEMO_JWT_TTL_SECONDS = 86_400; const BASE64_ALPHABET = @@ -30,33 +32,11 @@ export async function signDemoJwt({ const header = base64UrlEncodeUtf8('{"alg":"HS256","typ":"JWT"}'); const payload = base64UrlEncodeUtf8(JSON.stringify({ email, iat, exp })); const signingInput = `${header}.${payload}`; - const signature = base64UrlEncode(await hmacSha256(secret, signingInput)); + const signature = base64UrlEncode(hmacSha256(secret, signingInput)); return `${signingInput}.${signature}`; } -async function hmacSha256(secret: string, data: string): Promise { - const cryptoApi = globalThis.crypto; - if (cryptoApi?.subtle == null) { - throw new Error('Web Crypto is not available for demo JWT signing'); - } - - const encoder = new TextEncoder(); - const key = await cryptoApi.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - const signature = await cryptoApi.subtle.sign( - 'HMAC', - key, - encoder.encode(data) - ); - return new Uint8Array(signature); -} - function base64UrlEncodeUtf8(value: string): string { return base64UrlEncode(new TextEncoder().encode(value)); } From 756da3f910e55095decc0893a45c8cd900b2579d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 27 Aug 2026 16:50:45 +0100 Subject: [PATCH 5/5] test(example): cover JWT flags and prefetch Alert Helpers take explicit enabled/secret args so tests do not depend on inlined EXPO_PUBLIC env. Prefetch failures Alert without PII and skip setEmail without a token. Co-authored-by: Cursor --- example/.env | 2 +- example/src/Login.tsx | 11 ++- example/src/jwt/demoAuth.test.ts | 160 +++++++++++++++++++++++++++++++ example/src/jwt/demoAuth.ts | 58 +++++++---- 4 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 example/src/jwt/demoAuth.test.ts diff --git a/example/.env b/example/.env index 54bf9e3a..e950c3ab 100644 --- a/example/.env +++ b/example/.env @@ -17,4 +17,4 @@ EXPO_PUBLIC_ITERABLE_API_KEY=YOUR_ITERABLE_API_KEY # JWT secret from a JWT-enabled **mobile** API key. Required only when JWT is # enabled. DEMO ONLY — never embed this secret in a production app. # EXPO_PUBLIC_* values are inlined into the JS bundle. -# EXPO_PUBLIC_ITERABLE_JWT_SECRET=YOUR_ITERABLE_JWT_SECRET \ No newline at end of file +# EXPO_PUBLIC_ITERABLE_JWT_SECRET=YOUR_ITERABLE_JWT_SECRET diff --git a/example/src/Login.tsx b/example/src/Login.tsx index 959dcef9..3a51037b 100644 --- a/example/src/Login.tsx +++ b/example/src/Login.tsx @@ -19,6 +19,7 @@ import { import { colors } from './constants'; import { + alertJwtPrefetchFailure, applyJwtToConfig, getDemoAuthToken, isJwtConfigured, @@ -48,14 +49,14 @@ export const Login = ({ onLoggedIn = () => {} }: LoginProps) => { emailRef.current = email; const onPress = async () => { - try { - if (isJwtConfigured()) { + if (isJwtConfigured()) { + try { const token = await getDemoAuthToken(email); Iterable.setEmail(email, token); - } else { - Iterable.setEmail(email); + } catch { + alertJwtPrefetchFailure(); } - } catch { + } else { Iterable.setEmail(email); } setTimeout(() => { diff --git a/example/src/jwt/demoAuth.test.ts b/example/src/jwt/demoAuth.test.ts new file mode 100644 index 00000000..ed0eb0de --- /dev/null +++ b/example/src/jwt/demoAuth.test.ts @@ -0,0 +1,160 @@ +import { + IterableAuthFailureReason, + IterableRetryBackoff, + type IterableConfig, +} from '@iterable/react-native-sdk'; +import { Alert } from 'react-native'; + +import { + PLACEHOLDER_JWT_SECRET, + alertJwtPrefetchFailure, + applyJwtToConfig, + isJwtConfigured, + isJwtEnabled, + jwtFailureReasonLabel, + resolveJwtSecret, +} from './demoAuth'; + +jest.mock('react-native', () => ({ + Alert: { alert: jest.fn() }, +})); + +jest.mock('@iterable/react-native-sdk', () => ({ + IterableRetryBackoff: { linear: 'LINEAR' }, + IterableAuthFailureReason: { + AUTH_TOKEN_GENERATION_ERROR: 3, + AUTH_TOKEN_SIGNATURE_INVALID: 9, + 3: 'AUTH_TOKEN_GENERATION_ERROR', + 9: 'AUTH_TOKEN_SIGNATURE_INVALID', + }, +})); + +jest.mock('./signDemoJwt', () => ({ + signDemoJwt: jest.fn(), +})); + +function emptyConfig(): IterableConfig { + return {} as IterableConfig; +} + +describe('demoAuth flags', () => { + it('is disabled when the flag is missing or not true', () => { + expect(isJwtEnabled(undefined)).toBe(false); + expect(isJwtEnabled('false')).toBe(false); + expect(isJwtConfigured(undefined, 'secret')).toBe(false); + }); + + it('is not configured when enabled without a secret', () => { + expect(isJwtEnabled('true')).toBe(true); + expect(isJwtConfigured('true', undefined)).toBe(false); + expect(isJwtConfigured('true', '')).toBe(false); + expect(resolveJwtSecret(PLACEHOLDER_JWT_SECRET)).toBeUndefined(); + expect(isJwtConfigured('true', PLACEHOLDER_JWT_SECRET)).toBe(false); + }); + + it('is configured when enabled with a real secret', () => { + expect(isJwtConfigured('true', 'real-secret')).toBe(true); + }); +}); + +describe('applyJwtToConfig', () => { + it('does not attach JWT handlers when JWT is disabled', () => { + const config = emptyConfig(); + applyJwtToConfig(config, () => 'user@example.com', { enabled: 'false' }); + expect(config.authHandler).toBeUndefined(); + expect(config.onJwtError).toBeUndefined(); + expect(config.retryPolicy).toBeUndefined(); + }); + + it('does not attach authHandler when the secret is missing', () => { + const config = emptyConfig(); + applyJwtToConfig(config, () => 'user@example.com', { + enabled: 'true', + }); + expect(config.authHandler).toBeUndefined(); + expect(config.onJwtError).toBeDefined(); + expect(config.retryPolicy).toEqual({ + maxRetry: 5, + retryInterval: 5, + retryBackoff: IterableRetryBackoff.linear, + }); + }); + + it('does not attach authHandler when the secret is the placeholder', () => { + const config = emptyConfig(); + applyJwtToConfig(config, () => 'user@example.com', { + enabled: 'true', + secret: PLACEHOLDER_JWT_SECRET, + }); + expect(config.authHandler).toBeUndefined(); + }); + + it('attaches authHandler, retryPolicy, and onJwtError when configured', () => { + const config = emptyConfig(); + applyJwtToConfig(config, () => 'user@example.com', { + enabled: 'true', + secret: 'real-secret', + }); + expect(config.authHandler).toBeDefined(); + expect(config.onJwtError).toBeDefined(); + expect(config.retryPolicy).toEqual({ + maxRetry: 5, + retryInterval: 5, + retryBackoff: IterableRetryBackoff.linear, + }); + }); + + it('shows only the failure reason on JWT error', () => { + const config = emptyConfig(); + applyJwtToConfig(config, () => 'user@example.com', { enabled: 'true' }); + const alert = Alert.alert as jest.MockedFunction; + alert.mockClear(); + + config.onJwtError?.({ + userKey: 'user@example.com', + failedAuthToken: 'header.payload.sig', + failedRequestTime: 0, + failureReason: IterableAuthFailureReason.AUTH_TOKEN_SIGNATURE_INVALID, + }); + + expect(alert).toHaveBeenCalledWith( + 'JWT authentication failed', + 'AUTH_TOKEN_SIGNATURE_INVALID' + ); + const alertArgs = JSON.stringify(alert.mock.calls); + expect(alertArgs).not.toContain('user@example.com'); + expect(alertArgs).not.toContain('header.payload.sig'); + }); +}); + +describe('jwtFailureReasonLabel', () => { + it('returns the enum key for numeric reasons', () => { + expect( + jwtFailureReasonLabel( + IterableAuthFailureReason.AUTH_TOKEN_SIGNATURE_INVALID + ) + ).toBe('AUTH_TOKEN_SIGNATURE_INVALID'); + }); + + it('returns Android string reasons unchanged', () => { + expect( + jwtFailureReasonLabel( + 'AUTH_TOKEN_SIGNATURE_INVALID' as unknown as IterableAuthFailureReason + ) + ).toBe('AUTH_TOKEN_SIGNATURE_INVALID'); + }); +}); + +describe('alertJwtPrefetchFailure', () => { + it('shows only a generation-error reason', () => { + const alert = Alert.alert as jest.MockedFunction; + alert.mockClear(); + + alertJwtPrefetchFailure(); + + expect(alert).toHaveBeenCalledWith( + 'JWT authentication failed', + 'AUTH_TOKEN_GENERATION_ERROR' + ); + }); +}); diff --git a/example/src/jwt/demoAuth.ts b/example/src/jwt/demoAuth.ts index 6758435a..78a8d83e 100644 --- a/example/src/jwt/demoAuth.ts +++ b/example/src/jwt/demoAuth.ts @@ -8,22 +8,33 @@ import { Alert } from 'react-native'; import { signDemoJwt } from './signDemoJwt'; -const PLACEHOLDER_JWT_SECRET = 'YOUR_ITERABLE_JWT_SECRET'; +export const PLACEHOLDER_JWT_SECRET = 'YOUR_ITERABLE_JWT_SECRET'; -export function isJwtEnabled(): boolean { - return process.env.EXPO_PUBLIC_ITERABLE_JWT_ENABLED === 'true'; +export type JwtDemoEnv = { + enabled?: string; + secret?: string; +}; + +export function isJwtEnabled( + enabled: string | undefined = process.env.EXPO_PUBLIC_ITERABLE_JWT_ENABLED +): boolean { + return enabled === 'true'; } -export function getJwtSecret(): string | undefined { - const secret = process.env.EXPO_PUBLIC_ITERABLE_JWT_SECRET; +export function resolveJwtSecret( + secret: string | undefined = process.env.EXPO_PUBLIC_ITERABLE_JWT_SECRET +): string | undefined { if (!secret || secret === PLACEHOLDER_JWT_SECRET) { return undefined; } return secret; } -export function isJwtConfigured(): boolean { - return isJwtEnabled() && getJwtSecret() !== undefined; +export function isJwtConfigured( + enabled: string | undefined = process.env.EXPO_PUBLIC_ITERABLE_JWT_ENABLED, + secret: string | undefined = process.env.EXPO_PUBLIC_ITERABLE_JWT_SECRET +): boolean { + return isJwtEnabled(enabled) && resolveJwtSecret(secret) !== undefined; } /** @@ -39,8 +50,10 @@ export function isJwtConfigured(): boolean { * return response.text(); * ``` */ -export async function getDemoAuthToken(email: string): Promise { - const secret = getJwtSecret(); +export async function getDemoAuthToken( + email: string, + secret: string | undefined = resolveJwtSecret() +): Promise { if (!secret) { throw new Error('JWT secret is not configured'); } @@ -56,11 +69,25 @@ export function jwtFailureReasonLabel( return IterableAuthFailureReason[failureReason] ?? 'Unknown error'; } +export function alertJwtFailure(reason: string): void { + Alert.alert('JWT authentication failed', reason); +} + +export function alertJwtPrefetchFailure(): void { + alertJwtFailure( + jwtFailureReasonLabel(IterableAuthFailureReason.AUTH_TOKEN_GENERATION_ERROR) + ); +} + export function applyJwtToConfig( config: IterableConfig, - getEmail: () => string + getEmail: () => string, + env: JwtDemoEnv = { + enabled: process.env.EXPO_PUBLIC_ITERABLE_JWT_ENABLED, + secret: process.env.EXPO_PUBLIC_ITERABLE_JWT_SECRET, + } ): void { - if (!isJwtEnabled()) { + if (!isJwtEnabled(env.enabled)) { return; } @@ -71,15 +98,12 @@ export function applyJwtToConfig( }; config.onJwtError = (authFailure: IterableAuthFailure) => { - Alert.alert( - 'JWT authentication failed', - jwtFailureReasonLabel(authFailure.failureReason) - ); + alertJwtFailure(jwtFailureReasonLabel(authFailure.failureReason)); }; - if (!isJwtConfigured()) { + if (!isJwtConfigured(env.enabled, env.secret)) { return; } - config.authHandler = () => getDemoAuthToken(getEmail()); + config.authHandler = () => getDemoAuthToken(getEmail(), env.secret); }