Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,11 @@ example/.yarn/install-state.gz

# Coverage
coverage/

# Agent tooling
.agent/
.artifacts/
.claude/
.cursor/
.opencode/
.pi/
13 changes: 11 additions & 2 deletions example/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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
32 changes: 32 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
24 changes: 21 additions & 3 deletions example/src/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +18,12 @@ import {
} from 'react-native';

import { colors } from './constants';
import {
alertJwtPrefetchFailure,
applyJwtToConfig,
getDemoAuthToken,
isJwtConfigured,
} from './jwt/demoAuth';

interface LoginProps {
/**
Expand All @@ -39,9 +45,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 () => {
if (isJwtConfigured()) {
try {
const token = await getDemoAuthToken(email);
Iterable.setEmail(email, token);
} catch {
alertJwtPrefetchFailure();
}
} else {
Iterable.setEmail(email);
}
setTimeout(() => {
onLoggedIn();
}, 300);
Expand All @@ -57,6 +74,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,
Expand Down
160 changes: 160 additions & 0 deletions example/src/jwt/demoAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Alert.alert>;
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<typeof Alert.alert>;
alert.mockClear();

alertJwtPrefetchFailure();

expect(alert).toHaveBeenCalledWith(
'JWT authentication failed',
'AUTH_TOKEN_GENERATION_ERROR'
);
});
});
Loading
Loading