Skip to content

Implement JWT authentication for Intercom in React Native #298

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
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
4 changes: 4 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ jobs:
- restore_cache:
keys:
- pod-dependencies-{{ checksum "~/project/example/ios/Podfile" }}
- run:
working_directory: example/ios
name: Remove Podfile.lock to avoid version conflicts
command: rm -f Podfile.lock
- run:
working_directory: example/ios
name: Install Pods
Expand Down
206 changes: 206 additions & 0 deletions __tests__/intercom-jwt.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* Tests for setUserJwt API specifically
*/

describe('Intercom setUserJwt API', () => {
let mockIntercomModule;
let Intercom;

beforeEach(() => {
jest.resetModules();

// Mock React Native
jest.doMock('react-native', () => ({
NativeModules: {
IntercomModule: {
setUserJwt: jest.fn(),
setUserHash: jest.fn(),
loginUserWithUserAttributes: jest.fn(),
logout: jest.fn(),
updateUser: jest.fn(),
isUserLoggedIn: jest.fn(),
},
IntercomEventEmitter: {
UNREAD_COUNT_CHANGE_NOTIFICATION: 'UNREAD_COUNT_CHANGE_NOTIFICATION',
WINDOW_DID_HIDE_NOTIFICATION: 'WINDOW_DID_HIDE_NOTIFICATION',
WINDOW_DID_SHOW_NOTIFICATION: 'WINDOW_DID_SHOW_NOTIFICATION',
HELP_CENTER_WINDOW_DID_SHOW_NOTIFICATION:
'HELP_CENTER_WINDOW_DID_SHOW_NOTIFICATION',
HELP_CENTER_WINDOW_DID_HIDE_NOTIFICATION:
'HELP_CENTER_WINDOW_DID_HIDE_NOTIFICATION',
startEventListener: jest.fn(),
removeEventListener: jest.fn(),
},
},
NativeEventEmitter: jest.fn().mockImplementation(() => ({
addListener: jest.fn().mockReturnValue({
remove: jest.fn(),
}),
})),
Platform: {
OS: 'ios',
select: jest.fn((obj) => obj.ios || obj.default),
},
}));

const { NativeModules } = require('react-native');
mockIntercomModule = NativeModules.IntercomModule;

// Import Intercom after mocking
Intercom = require('../src/index.tsx').default;
});

afterEach(() => {
jest.clearAllMocks();
});

describe('setUserJwt method', () => {
test('should call native setUserJwt with valid JWT', async () => {
const testJWT =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.test';
mockIntercomModule.setUserJwt.mockResolvedValue(true);

const result = await Intercom.setUserJwt(testJWT);

expect(mockIntercomModule.setUserJwt).toHaveBeenCalledWith(testJWT);
expect(result).toBe(true);
});

test('should handle JWT authentication errors', async () => {
const invalidJWT = 'invalid.jwt';
const error = new Error('JWT validation failed');
mockIntercomModule.setUserJwt.mockRejectedValue(error);

await expect(Intercom.setUserJwt(invalidJWT)).rejects.toThrow(
'JWT validation failed'
);
});

test('should work with empty JWT string', async () => {
const emptyJWT = '';
mockIntercomModule.setUserJwt.mockResolvedValue(true);

const result = await Intercom.setUserJwt(emptyJWT);

expect(mockIntercomModule.setUserJwt).toHaveBeenCalledWith(emptyJWT);
expect(result).toBe(true);
});

test('should handle long JWT tokens', async () => {
const longJWT =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzNDU2Nzg5MCIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsIm5hbWUiOiJKb2huIERvZSIsImN1c3RvbV9hdHRyaWJ1dGVzIjp7InBsYW4iOiJwcmVtaXVtIiwiY29tcGFueSI6IkFjbWUgSW5jIn19.very_long_signature';
mockIntercomModule.setUserJwt.mockResolvedValue(true);

const result = await Intercom.setUserJwt(longJWT);

expect(mockIntercomModule.setUserJwt).toHaveBeenCalledWith(longJWT);
expect(result).toBe(true);
});
});

describe('JWT authentication workflow', () => {
test('should set JWT before user login', async () => {
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzIn0.test';
const userAttributes = { email: '[email protected]' };

mockIntercomModule.setUserJwt.mockResolvedValue(true);
mockIntercomModule.loginUserWithUserAttributes.mockResolvedValue(true);

await Intercom.setUserJwt(jwt);
await Intercom.loginUserWithUserAttributes(userAttributes);

expect(mockIntercomModule.setUserJwt).toHaveBeenCalledWith(jwt);
expect(
mockIntercomModule.loginUserWithUserAttributes
).toHaveBeenCalledWith(userAttributes);
// Verify setUserJwt was called first by checking call counts
expect(mockIntercomModule.setUserJwt).toHaveBeenCalledTimes(1);
expect(
mockIntercomModule.loginUserWithUserAttributes
).toHaveBeenCalledTimes(1);
});

test('should support both JWT and HMAC methods', async () => {
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzIn0.test';
const hash = 'hmac_hash_123';

mockIntercomModule.setUserJwt.mockResolvedValue(true);
mockIntercomModule.setUserHash.mockResolvedValue(true);

const jwtResult = await Intercom.setUserJwt(jwt);
const hashResult = await Intercom.setUserHash(hash);

expect(mockIntercomModule.setUserJwt).toHaveBeenCalledWith(jwt);
expect(mockIntercomModule.setUserHash).toHaveBeenCalledWith(hash);
expect(jwtResult).toBe(true);
expect(hashResult).toBe(true);
});

test('should handle complete authentication flow', async () => {
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzIn0.test';
const userAttributes = { userId: '123', email: '[email protected]' };

mockIntercomModule.setUserJwt.mockResolvedValue(true);
mockIntercomModule.loginUserWithUserAttributes.mockResolvedValue(true);
mockIntercomModule.isUserLoggedIn.mockResolvedValue(true);
mockIntercomModule.updateUser.mockResolvedValue(true);

// Set JWT first
await Intercom.setUserJwt(jwt);

// Login user
await Intercom.loginUserWithUserAttributes(userAttributes);

// Check login status
const isLoggedIn = await Intercom.isUserLoggedIn();

// Update user
await Intercom.updateUser({ name: 'Updated Name' });

expect(mockIntercomModule.setUserJwt).toHaveBeenCalledWith(jwt);
expect(
mockIntercomModule.loginUserWithUserAttributes
).toHaveBeenCalledWith(userAttributes);
expect(isLoggedIn).toBe(true);
expect(mockIntercomModule.updateUser).toHaveBeenCalledWith({
name: 'Updated Name',
});
});
});

describe('Error handling', () => {
test('should handle network errors', async () => {
const jwt = 'test.jwt.token';
const networkError = new Error('Network request failed');
mockIntercomModule.setUserJwt.mockRejectedValue(networkError);

await expect(Intercom.setUserJwt(jwt)).rejects.toThrow(
'Network request failed'
);
});

test('should handle invalid JWT format errors', async () => {
const invalidJWT = 'not.a.valid.jwt';
const formatError = new Error('Invalid JWT format');
mockIntercomModule.setUserJwt.mockRejectedValue(formatError);

await expect(Intercom.setUserJwt(invalidJWT)).rejects.toThrow(
'Invalid JWT format'
);
});

test('should handle JWT signature verification errors', async () => {
const jwtWithBadSignature =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzIn0.bad_signature';
const signatureError = new Error('JWT signature verification failed');
mockIntercomModule.setUserJwt.mockRejectedValue(signatureError);

await expect(Intercom.setUserJwt(jwtWithBadSignature)).rejects.toThrow(
'JWT signature verification failed'
);
});
});
});
70 changes: 70 additions & 0 deletions __tests__/setupTests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Test setup for React Native mocking
*/

import { NativeModules } from 'react-native';

// Mock the native modules
const mockIntercomModule = {
setUserHash: jest.fn(),
setUserJwt: jest.fn(),
loginUnidentifiedUser: jest.fn(),
loginUserWithUserAttributes: jest.fn(),
logout: jest.fn(),
updateUser: jest.fn(),
isUserLoggedIn: jest.fn(),
fetchLoggedInUserAttributes: jest.fn(),
logEvent: jest.fn(),
presentIntercom: jest.fn(),
presentIntercomSpace: jest.fn(),
presentContent: jest.fn(),
presentMessageComposer: jest.fn(),
getUnreadConversationCount: jest.fn(),
hideIntercom: jest.fn(),
setBottomPadding: jest.fn(),
setInAppMessageVisibility: jest.fn(),
setLauncherVisibility: jest.fn(),
setNeedsStatusBarAppearanceUpdate: jest.fn(),
handlePushMessage: jest.fn(),
sendTokenToIntercom: jest.fn(),
setLogLevel: jest.fn(),
fetchHelpCenterCollections: jest.fn(),
fetchHelpCenterCollection: jest.fn(),
searchHelpCenter: jest.fn(),
};

const mockEventEmitter = {
UNREAD_COUNT_CHANGE_NOTIFICATION: 'UNREAD_COUNT_CHANGE_NOTIFICATION',
WINDOW_DID_HIDE_NOTIFICATION: 'WINDOW_DID_HIDE_NOTIFICATION',
WINDOW_DID_SHOW_NOTIFICATION: 'WINDOW_DID_SHOW_NOTIFICATION',
HELP_CENTER_WINDOW_DID_SHOW_NOTIFICATION:
'HELP_CENTER_WINDOW_DID_SHOW_NOTIFICATION',
HELP_CENTER_WINDOW_DID_HIDE_NOTIFICATION:
'HELP_CENTER_WINDOW_DID_HIDE_NOTIFICATION',
startEventListener: jest.fn(),
removeEventListener: jest.fn(),
};

NativeModules.IntercomModule = mockIntercomModule;
NativeModules.IntercomEventEmitter = mockEventEmitter;

// Mock Platform
const mockPlatform = {
OS: 'ios',
select: jest.fn((obj) => obj.ios || obj.default),
};

jest.doMock('react-native', () => ({
NativeModules: {
IntercomModule: mockIntercomModule,
IntercomEventEmitter: mockEventEmitter,
},
NativeEventEmitter: jest.fn().mockImplementation(() => ({
addListener: jest.fn().mockReturnValue({
remove: jest.fn(),
}),
})),
Platform: mockPlatform,
}));

export { mockIntercomModule, mockEventEmitter, mockPlatform };
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,5 @@ dependencies {
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+" // From node_modules
implementation "com.google.firebase:firebase-messaging:${safeExtGet('firebaseMessagingVersion', '20.2.+')}"
implementation 'io.intercom.android:intercom-sdk:15.16.+'
implementation 'io.intercom.android:intercom-sdk:17.0.+'
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ public class IntercomErrorCodes {
public static final String UNIDENTIFIED_REGISTRATION = "101";
public static final String IDENTIFIED_REGISTRATION = "102";
public static final String SET_USER_HASH = "103";
public static final String UPDATE_USER_HASH = "104";
public static final String LOG_EVENT_HASH = "105";
public static final String LOGOUT = "106";
public static final String SET_LOG_LEVEL = "107";
public static final String GET_UNREAD_CONVERSATION = "108";
public static final String SET_USER_JWT = "104";
public static final String UPDATE_USER_HASH = "105";
public static final String LOG_EVENT_HASH = "106";
public static final String LOGOUT = "107";
public static final String SET_LOG_LEVEL = "108";
public static final String GET_UNREAD_CONVERSATION = "109";
public static final String DISPLAY_MESSENGER = "201";
public static final String DISPLAY_MESSENGER_COMPOSER = "202";
public static final String DISPLAY_CONTENT = "203";
Expand Down
12 changes: 12 additions & 0 deletions android/src/main/java/com/intercom/reactnative/IntercomModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,18 @@ public void setUserHash(String userHash, Promise promise) {
}
}

@ReactMethod
public void setUserJwt(String jwt, Promise promise) {
try {
Intercom.client().setUserJwt(jwt);
promise.resolve(true);
} catch (Exception err) {
Log.e(NAME, "setUserJwt error:");
Log.e(NAME, err.toString());
promise.reject(IntercomErrorCodes.SET_USER_JWT, err.toString());
}
}

@ReactMethod
public void updateUser(ReadableMap params, Promise promise) {
UserAttributes userAttributes = IntercomHelpers.buildUserAttributes(params);
Expand Down
2 changes: 1 addition & 1 deletion intercom-react-native.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ Pod::Spec.new do |s|
s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" }

s.dependency "React-Core"
s.dependency "Intercom", '~> 18.6.1'
s.dependency "Intercom", '~> 19.0.0'
end
16 changes: 14 additions & 2 deletions ios/IntercomModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ @implementation IntercomModule
NSString *UNIDENTIFIED_REGISTRATION = @"101";
NSString *IDENTIFIED_REGISTRATION = @"102";
NSString *SET_USER_HASH = @"103";
NSString *UPDATE_USER = @"104";
NSString *LOG_EVENT = @"105";
NSString *SET_USER_JWT = @"104";
NSString *UPDATE_USER = @"105";
NSString *LOG_EVENT = @"106";
NSString *UNREAD_CONVERSATION_COUNT = @"107";
NSString *SEND_TOKEN_TO_INTERCOM = @"302";
NSString *FETCH_HELP_CENTER_COLLECTIONS = @"901";
Expand Down Expand Up @@ -154,6 +155,17 @@ - (NSData *)dataFromHexString:(NSString *)string {
}
};

RCT_EXPORT_METHOD(setUserJwt:(NSString *)jwt
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
@try {
[Intercom setUserJwt:jwt];
resolve(@(YES));
} @catch (NSException *exception) {
reject(UPDATE_USER, @"Error in setUserJwt", [self exceptionToError:exception :SET_USER_JWT :@"setUserJwt"]);
}
};


#pragma mark - Events

Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
},
"jest": {
"preset": "react-native",
"setupFilesAfterEnv": ["<rootDir>/__tests__/setupTests.js"],
"testPathIgnorePatterns": [
"<rootDir>/__tests__/setupTests.js"
],
"modulePathIgnorePatterns": [
"<rootDir>/example/node_modules",
"<rootDir>/sandboxes/node_modules",
Expand Down
Loading