Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### Added
- Added `updateAuthToken(String)` method for updating the auth token without triggering login side effects (push registration, in-app sync, embedded sync). Use this when you only need to refresh the token for an already logged-in user.

### Deprecated
- `setAuthToken(String)` is now deprecated. It still triggers login operations (push registration, in-app sync, embedded sync) for backward compatibility, but will be changed to only store the token in a future release. Migrate to `updateAuthToken(String)` to update the token without side effects, or use `setEmail(email, authToken)` / `setUserId(userId, authToken)` to set credentials and trigger login operations.

## [3.7.0]
- Replaced the deprecated `AsyncTask`-based push notification handling with `WorkManager` for improved reliability and compatibility with modern Android versions. No action is required.
- Fixed lost event tracking and missed API calls with an auto-retry feature for JWT token failures.
Expand Down
61 changes: 42 additions & 19 deletions iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@
private void checkAndUpdateAuthToken(@Nullable String authToken) {
// If authHandler exists and if authToken is new, it will be considered as a call to update the authToken.
if (config.authHandler != null && authToken != null && authToken != _authToken) {
setAuthToken(authToken);
updateAuthToken(authToken);
}
}

Expand Down Expand Up @@ -425,20 +425,24 @@
@Nullable IterableHelper.FailureHandler failureHandler
) {
if (!isInitialized()) {
setAuthToken(null);
updateAuthToken(null);
return;
}

getAuthManager().pauseAuthRetries(false);
if (authToken != null) {
setAuthToken(authToken);
updateAuthToken(authToken);
completeUserLogin();
attemptMergeAndEventReplay(userIdOrEmail, isEmail, merge, replay, isUnknown, failureHandler);
} else {
getAuthManager().requestNewAuthToken(false, data -> attemptMergeAndEventReplay(userIdOrEmail, isEmail, merge, replay, isUnknown, failureHandler));
getAuthManager().requestNewAuthToken(false, data -> {
completeUserLogin();
attemptMergeAndEventReplay(userIdOrEmail, isEmail, merge, replay, isUnknown, failureHandler);
});
}
}

private void completeUserLogin() {
void completeUserLogin() {
completeUserLogin(_email, _userId, _authToken);
}

Expand Down Expand Up @@ -679,19 +683,16 @@

//region API functions (private/internal)
//---------------------------------------------------------------------------------------
void setAuthToken(String authToken, boolean bypassAuth) {

/**
* Updates the auth token without triggering login side effects (push registration, in-app sync, etc.).
* Use this method when you only need to update the token for an already logged-in user.
* For initial login, use {@code setEmail(email, authToken)} or {@code setUserId(userId, authToken)}.
*/
public void updateAuthToken(@Nullable String authToken) {
if (isInitialized()) {
if ((authToken != null && !authToken.equalsIgnoreCase(_authToken)) || (_authToken != null && !_authToken.equalsIgnoreCase(authToken))) {
_authToken = authToken;
// SECURITY: Use completion handler to atomically store and pass validated credentials.
// The completion handler receives exact values stored to keychain, preventing TOCTOU
// attacks where keychain could be modified between storage and completeUserLogin execution.
storeAuthData((email, userId, token) -> completeUserLogin(email, userId, token));
} else if (bypassAuth) {
// SECURITY: Pass current credentials directly to completeUserLogin.
// completeUserLogin will validate authToken presence when JWT auth is enabled.
completeUserLogin(_email, _userId, _authToken);
}
_authToken = authToken;
storeAuthData();
Comment thread
franco-zalamena-iterable marked this conversation as resolved.
Dismissed
}
}

Expand Down Expand Up @@ -1075,6 +1076,9 @@

if (_email != null && _email.equals(email)) {
checkAndUpdateAuthToken(authToken);
_setUserSuccessCallbackHandler = successHandler;
_setUserFailureCallbackHandler = failureHandler;
onLogin(authToken, email, true, merge, replay, false, failureHandler);
Comment thread
franco-zalamena-iterable marked this conversation as resolved.
Outdated
return;
}

Expand Down Expand Up @@ -1145,6 +1149,9 @@

if (_userId != null && _userId.equals(userId)) {
checkAndUpdateAuthToken(authToken);
_setUserSuccessCallbackHandler = successHandler;
_setUserFailureCallbackHandler = failureHandler;
onLogin(authToken, userId, false, merge, replay, isUnknown, failureHandler);
return;
}

Expand Down Expand Up @@ -1211,8 +1218,24 @@
});
}

public void setAuthToken(String authToken) {
setAuthToken(authToken, false);
/**
* Sets the auth token and triggers login operations (push registration, in-app sync, embedded sync).
*
* @deprecated This method triggers login side effects beyond just setting the token.
* To update the auth token without login side effects, use {@link #updateAuthToken(String)}.
* To set credentials and trigger login operations, use {@code setEmail(email, authToken)}
* or {@code setUserId(userId, authToken)}.
* In a future release, this method will only store the auth token without triggering login operations.
*/
@Deprecated
public void setAuthToken(@Nullable String authToken) {
if (isInitialized()) {
IterableLogger.w(TAG, "setAuthToken() is deprecated. Use updateAuthToken() to update the token, " +
"or setEmail(email, authToken) / setUserId(userId, authToken) for login. " +
"In a future release, this method will only store the auth token without triggering login operations.");
_authToken = authToken;
storeAuthData(this::completeUserLogin);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface AuthTokenReadyListener {
private volatile boolean isInForeground = true; // Assume foreground initially

private volatile AuthState authState = AuthState.UNKNOWN;
private final Object timerLock = new Object();
private final ArrayList<AuthTokenReadyListener> authTokenReadyListeners = new ArrayList<>();

private final ExecutorService executor = Executors.newSingleThreadExecutor();
Expand Down Expand Up @@ -95,6 +96,21 @@ void setAuthTokenInvalid() {
setAuthState(AuthState.INVALID);
}

/**
* Handles a server-side JWT rejection (401). Invalidates the current token,
* clears any pending refresh, and schedules a new token request using the retry policy.
* When the new token arrives, AuthTokenReadyListeners are notified via the
* INVALID → UNKNOWN state transition.
*/
void handleAuthTokenRejection() {
Comment thread
franco-zalamena-iterable marked this conversation as resolved.
Outdated
setAuthState(AuthState.INVALID);
setIsLastAuthTokenValid(false);
clearRefreshTimer();
resetFailedAuth();
long retryInterval = getNextRetryInterval();
scheduleAuthTokenRefresh(retryInterval, false, null);
}

AuthState getAuthState() {
return authState;
}
Expand Down Expand Up @@ -204,7 +220,7 @@ public void run() {
}

} else {
IterableApi.getInstance().setAuthToken(null, true);
IterableApi.getInstance().completeUserLogin();
}
}

Expand All @@ -213,15 +229,15 @@ private void handleAuthTokenSuccess(String authToken, IterableHelper.SuccessHand
// Token obtained but not yet verified by a request - set state to UNKNOWN.
// setAuthState will notify listeners only if previous state was INVALID.
setAuthState(AuthState.UNKNOWN);
IterableApi.getInstance().setAuthToken(authToken);
IterableApi.getInstance().updateAuthToken(authToken);
queueExpirationRefresh(authToken);

if (successCallback != null) {
handleSuccessForAuthToken(authToken, successCallback);
}
} else {
handleAuthFailure(authToken, AuthFailureReason.AUTH_TOKEN_NULL);
IterableApi.getInstance().setAuthToken(authToken);
IterableApi.getInstance().updateAuthToken(authToken);
scheduleAuthTokenRefresh(getNextRetryInterval(), false, null);
return;
}
Expand Down Expand Up @@ -292,29 +308,31 @@ long getNextRetryInterval() {
}

void scheduleAuthTokenRefresh(long timeDuration, boolean isScheduledRefresh, final IterableHelper.SuccessHandler successCallback) {
if ((pauseAuthRetry && !isScheduledRefresh) || isTimerScheduled) {
// we only stop schedule token refresh if it is called from retry (in case of failure). The normal auth token refresh schedule would work
return;
}
if (timer == null) {
timer = new Timer(true);
}
synchronized (timerLock) {
if ((pauseAuthRetry && !isScheduledRefresh) || isTimerScheduled) {
// we only stop schedule token refresh if it is called from retry (in case of failure). The normal auth token refresh schedule would work
return;
}
if (timer == null) {
timer = new Timer(true);
}

try {
timer.schedule(new TimerTask() {
@Override
public void run() {
if (api.getEmail() != null || api.getUserId() != null) {
api.getAuthManager().requestNewAuthToken(false, successCallback, isScheduledRefresh);
} else {
IterableLogger.w(TAG, "Email or userId is not available. Skipping token refresh");
try {
timer.schedule(new TimerTask() {
@Override
public void run() {
if (api.getEmail() != null || api.getUserId() != null) {
api.getAuthManager().requestNewAuthToken(false, successCallback, isScheduledRefresh);
} else {
IterableLogger.w(TAG, "Email or userId is not available. Skipping token refresh");
}
isTimerScheduled = false;
}
isTimerScheduled = false;
}
}, timeDuration);
isTimerScheduled = true;
} catch (Exception e) {
IterableLogger.e(TAG, "timer exception: " + timer, e);
}, timeDuration);
isTimerScheduled = true;
} catch (Exception e) {
IterableLogger.e(TAG, "timer exception: " + timer, e);
}
}
}

Expand Down Expand Up @@ -363,10 +381,12 @@ private void checkAndHandleAuthRefresh() {
}

void clearRefreshTimer() {
if (timer != null) {
timer.cancel();
timer = null;
isTimerScheduled = false;
synchronized (timerLock) {
if (timer != null) {
timer.cancel();
timer = null;
isTimerScheduled = false;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,18 +262,17 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque
}

/**
* When autoRetry is enabled and this is an offline task, skip the inline retry.
* The task stays in the DB and IterableTaskRunner will retry it once a valid JWT
* is obtained via the AuthTokenReadyListener callback.
* When autoRetry is enabled and this is an offline task, do nothing here.
* IterableTaskRunner.processTask() is the sole owner of 401 handling for offline tasks:
* it calls setAuthTokenInvalid() which invalidates the token and schedules a refresh.
* When the new token arrives, onAuthTokenReady() resumes the queue.
* For online requests or when autoRetry is disabled, use the existing inline retry.
*/
private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) {
boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure();
if (autoRetry && iterableApiRequest.getProcessorType() == IterableApiRequest.ProcessorType.OFFLINE) {
IterableAuthManager authManager = IterableApi.getInstance().getAuthManager();
authManager.setIsLastAuthTokenValid(false);
long retryInterval = authManager.getNextRetryInterval();
authManager.scheduleAuthTokenRefresh(retryInterval, false, null);
IterableLogger.d(TAG, "Offline task 401 - deferring retry to IterableTaskRunner");
return;
} else {
requestNewAuthTokenAndRetry(iterableApiRequest);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ private boolean processTask(@NonNull IterableTask task, boolean autoRetry) {
// retain the task and pause processing until a valid JWT is obtained.
if (autoRetry && isJwtFailure(response)) {
IterableLogger.d(TAG, "JWT auth failure on task " + task.id + ". Retaining task and pausing processing.");
IterableApi.getInstance().getAuthManager().setAuthTokenInvalid();
IterableApi.getInstance().getAuthManager().handleAuthTokenRejection();
isPausedForAuth = true;
callTaskCompletedListeners(task.id, TaskResult.RETRY, response);
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,13 @@ public void testCompleteUserLogin_WithJWTAuth_NoToken_SkipsSensitiveOps() throws
when(api.getInAppManager()).thenReturn(mockInAppManager);
when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager);

// Directly call setAuthToken with null and bypassAuth=true to simulate
// Directly call updateAuthToken with null to simulate
// attempting to bypass with no token (user-controlled bypass scenario)
api.setAuthToken(null, true);
api.updateAuthToken(null);

shadowOf(getMainLooper()).idle();

// Verify sensitive operations were NOT called (JWT auth enabled, no token)
// Verify sensitive operations were NOT called (updateAuthToken only stores, no login side effects)
verify(mockInAppManager, never()).syncInApp();
verify(mockEmbeddedManager, never()).syncMessages();
}
Expand Down Expand Up @@ -141,6 +141,53 @@ public void testCompleteUserLogin_WithJWTAuth_WithToken_ExecutesSensitiveOps() t
verify(mockEmbeddedManager).syncMessages();
}

/**
* Regression test: calling setEmail with the same email that's already set (e.g. after app
* restart where email is restored from keychain) should still trigger the full login flow
* (request auth token, syncInApp, syncMessages).
*
* Previously, the same-email path called checkAndUpdateAuthToken(null) which did nothing,
* so no login side effects occurred.
*/
@Test
public void testSetEmail_SameEmail_StillTriggersLogin() throws Exception {
initIterableWithAuth();

dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}"));
doReturn(validJWT).when(authHandler).onAuthTokenRequested();

IterableApi api = spy(IterableApi.getInstance());
IterableApi.sharedInstance = api;

IterableInAppManager mockInAppManager = mock(IterableInAppManager.class);
IterableEmbeddedManager mockEmbeddedManager = mock(IterableEmbeddedManager.class);
when(api.getInAppManager()).thenReturn(mockInAppManager);
when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager);

// First login — triggers full flow
api.setEmail("user@example.com");
server.takeRequest(1, TimeUnit.SECONDS);
shadowOf(getMainLooper()).idle();

verify(mockInAppManager).syncInApp();
verify(mockEmbeddedManager).syncMessages();

// Clear invocations so we can verify the second call independently
org.mockito.Mockito.clearInvocations(mockInAppManager, mockEmbeddedManager);

// Enqueue another response for the second login's /users/update call
dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}"));

// Second login with SAME email — simulates app restart where email is in keychain
api.setEmail("user@example.com");
server.takeRequest(1, TimeUnit.SECONDS);
shadowOf(getMainLooper()).idle();

// This SHOULD still trigger login side effects
verify(mockInAppManager).syncInApp();
verify(mockEmbeddedManager).syncMessages();
}

/**
* Test that completeUserLogin executes sensitive operations when JWT auth is NOT enabled,
* even without an authToken.
Expand Down Expand Up @@ -246,14 +293,16 @@ public void testSetAuthToken_UsesCompletionHandlerPattern() throws Exception {
org.mockito.Mockito.clearInvocations(mockInAppManager, mockEmbeddedManager);

// Now update auth token (simulating token refresh)
// updateAuthToken just stores the token — it does not trigger completeUserLogin.
// Sensitive operations (syncInApp, syncMessages) are only triggered during login flow.
final String newToken = "new_jwt_token_here";
api.setAuthToken(newToken, false);
api.updateAuthToken(newToken);

shadowOf(getMainLooper()).idle();

// Verify sensitive operations were called with updated token
verify(mockInAppManager).syncInApp();
verify(mockEmbeddedManager).syncMessages();
// Verify sensitive operations were NOT called (updateAuthToken only stores, doesn't trigger login)
verify(mockInAppManager, never()).syncInApp();
verify(mockEmbeddedManager, never()).syncMessages();
assertEquals("Token should be updated", newToken, api.getAuthToken());
}

Expand All @@ -274,7 +323,7 @@ public void testSetAuthToken_BypassAuth_StillValidatesToken() throws Exception {
when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager);

// Try to bypass with no token set
api.setAuthToken(null, true);
api.updateAuthToken(null);

shadowOf(getMainLooper()).idle();

Expand Down
Loading
Loading