Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Fixed `ConcurrentModificationException` crash during device token registration caused by concurrent access to `deviceAttributes`.
- Fixed possible `NoSuchMethodException` crash on Android 5-10 caused by using `Map.of()` which is unavailable on those versions

### 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.iterable.iterableapi;

/**
* Syncs in-app and embedded messages when the auth token recovers from a 401 JWT rejection.
* Registered as an {@link IterableAuthManager.AuthTokenReadyListener}, which only fires on
* INVALID → UNKNOWN/VALID transitions (i.e., after a 401, not on routine token refreshes).
*
* Also triggers push re-registration when autoPushRegistration is enabled.
*/
class AuthRecoverySyncManager implements IterableAuthManager.AuthTokenReadyListener {
private static final String TAG = "AuthRecoverySyncMgr";

private final IterableApi api;

AuthRecoverySyncManager(IterableApi api) {
this.api = api;
}

@Override
public void onAuthTokenReady() {
IterableLogger.d(TAG, "Auth token ready after recovery - syncing messages");

if (api.config.autoPushRegistration) {
api.registerForPush();
}

api.getInAppManager().syncInApp();
api.getEmbeddedManager().syncMessages();
}
}
75 changes: 48 additions & 27 deletions iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,6 @@ public String getAuthToken() {
return _authToken;
}

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);
}
}

/**
* Stores attribution information.
* @param attributionInfo Attribution information object
Expand Down Expand Up @@ -181,6 +174,7 @@ Context getMainActivityContext() {
IterableAuthManager getAuthManager() {
if (authManager == null) {
authManager = new IterableAuthManager(this, config.authHandler, config.retryPolicy, config.expiringAuthTokenRefreshPeriod);
authManager.addAuthTokenReadyListener(new AuthRecoverySyncManager(this));
}
return authManager;
}
Expand Down Expand Up @@ -426,20 +420,24 @@ private void onLogin(
@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 @@ -680,19 +678,16 @@ public void resetAuth() {

//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,7 +1070,9 @@ public void setEmail(@Nullable String email, @Nullable String authToken, @Nullab
boolean merge = isMerge(iterableIdentityResolution);

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

Expand Down Expand Up @@ -1145,7 +1142,9 @@ public void setUserId(@Nullable String userId, @Nullable String authToken, @Null
boolean merge = isMerge(iterableIdentityResolution);

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 @@ -1212,8 +1211,30 @@ private void attemptAndProcessMerge(@NonNull String destinationUser, boolean isE
});
}

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.");
boolean tokenChanged = (authToken != null && !authToken.equalsIgnoreCase(_authToken))
|| (_authToken != null && !_authToken.equalsIgnoreCase(authToken));
_authToken = authToken;
if (tokenChanged) {
storeAuthData(this::completeUserLogin);
} else {
storeAuthData();
}
}
}

/**
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 @@ -204,24 +205,24 @@ public void run() {
}

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

private void handleAuthTokenSuccess(String authToken, IterableHelper.SuccessHandler successCallback) {
if (authToken != null) {
// Token obtained but not yet verified by a request - set state to UNKNOWN.
// setAuthState will notify listeners only if previous state was INVALID.
// Store the new token before notifying listeners, so any requests
// triggered by listeners (e.g. AuthRecoverySyncManager) use the new token.
IterableApi.getInstance().updateAuthToken(authToken);
setAuthState(AuthState.UNKNOWN);
IterableApi.getInstance().setAuthToken(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 +293,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 +366,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 @@ -270,10 +270,8 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque
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 Expand Up @@ -426,6 +424,7 @@ private void handleErrorResponse(IterableApiResponse response) {

private static void requestNewAuthTokenAndRetry(IterableApiRequest iterableApiRequest) {
IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(false);
IterableApi.getInstance().getAuthManager().setAuthTokenInvalid();
long retryInterval = IterableApi.getInstance().getAuthManager().getNextRetryInterval();
IterableApi.getInstance().getAuthManager().scheduleAuthTokenRefresh(retryInterval, false, data -> {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.iterable.iterableapi;

import org.junit.Test;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class AuthRecoverySyncManagerTest {

private IterableApi createMockApi(boolean autoPushRegistration) {
IterableApi mockApi = mock(IterableApi.class);
mockApi.config = new IterableConfig.Builder()
.setAutoPushRegistration(autoPushRegistration)
.build();
IterableInAppManager mockInApp = mock(IterableInAppManager.class);
IterableEmbeddedManager mockEmbedded = mock(IterableEmbeddedManager.class);
when(mockApi.getInAppManager()).thenReturn(mockInApp);
when(mockApi.getEmbeddedManager()).thenReturn(mockEmbedded);
return mockApi;
}

@Test
public void testOnAuthTokenReadySyncsInApp() {
IterableApi mockApi = createMockApi(false);
AuthRecoverySyncManager manager = new AuthRecoverySyncManager(mockApi);

manager.onAuthTokenReady();

verify(mockApi.getInAppManager()).syncInApp();
}

@Test
public void testOnAuthTokenReadySyncsEmbeddedMessages() {
IterableApi mockApi = createMockApi(false);
AuthRecoverySyncManager manager = new AuthRecoverySyncManager(mockApi);

manager.onAuthTokenReady();

verify(mockApi.getEmbeddedManager()).syncMessages();
}

@Test
public void testOnAuthTokenReadyRegistersForPushWhenEnabled() {
IterableApi mockApi = createMockApi(true);
AuthRecoverySyncManager manager = new AuthRecoverySyncManager(mockApi);

manager.onAuthTokenReady();

verify(mockApi).registerForPush();
}

@Test
public void testOnAuthTokenReadySkipsPushWhenDisabled() {
IterableApi mockApi = createMockApi(false);
AuthRecoverySyncManager manager = new AuthRecoverySyncManager(mockApi);

manager.onAuthTokenReady();

verify(mockApi, never()).registerForPush();
}
}
Loading
Loading