Skip to content

AB#268288 Federated Authentication#87

Open
graciecooper wants to merge 7 commits into
masterfrom
feature/268288-federated-auth
Open

AB#268288 Federated Authentication#87
graciecooper wants to merge 7 commits into
masterfrom
feature/268288-federated-auth

Conversation

@graciecooper
Copy link
Copy Markdown
Contributor

@graciecooper graciecooper commented Mar 25, 2026

Description of Changes

Adds Federated JWT authentication to the Android SDK. When OptimoveConfig.Builder.enableAuth(AuthTokenProvider) is used, the SDK asks the app for a JWT per user context and attaches it as X-User-JWT on user-identified outbound traffic (anonymous / install-scoped traffic omits the JWT where applicable).

Key changes

  • AuthManager + enableAuth(AuthTokenProvider) on OptimoveConfig.Builder
  • X-Optimove-Auth-Capable: 1 on SDK HTTP traffic (HttpClient + OptimobileHttpClient)
  • JWT for user-identified calls: Optistream (OptiTrack), Realtime, Preference Center, Embedded Messaging, Optimobile analytics, in-app (inbox fetch)
  • OptistreamHandler groups batches by customer so each request has one JWT
  • Optimobile analytics pulls the next queue slice per userIdentifier so the JWT matches the batch
  • Token failures: Optistream / Realtime skip sending that attempt; blocking-JWT Optimobile paths may still send without X-User-JWT

Usage

Kotlin

OptimoveConfig.Builder(/**/)
    .enableAuth { userId, callback ->
        MyAuthService.getJwt(userId) { jwt, error ->
            callback.onComplete(jwt, error)
        }
    }
    //
    .build()

Breaking Changes

  • None

Release Checklist

Prepare:

  • Detail any breaking changes. Breaking changes require a new major version number, and a migration guide in wiki / README.md

Bump versions in:

  • CHANGELOG.md
  • gradle.properties
  • add links to newly created wiki pages to readme
  • Update major version numbers in wiki (basic integration + push guides)

Integration tests

T&T Only

  • Init SDK with only optimove credentials
  • Associate customer
  • Associate email
  • Track events

Mobile Only

  • Init SDK with all credentials
  • Track events
  • Associate customer (verify both backends)
  • Register for push
  • Opt-in for In-App
  • Send test push
  • Send test In-App
  • Receive / trigger deep link handler (In-App/Push)
  • Receive / trigger the content extension, render image and action buttons for push
  • Verify push opened handler

Deferred Deep Links

  • With app installed, trigger deep link handler
  • With app uninstalled, follow deep link, install test bundle, verify deep link read from Clipboard, trigger deep link handler

Combined

  • Track event for T&T, verify push received
  • Trigger scheduled campaign, verify push received
  • Trigger scheduled campaign, verify In-App received

Release Procedure

  • Squash and merge dev to master
  • Delete branch once merged

@graciecooper graciecooper force-pushed the feature/268288-federated-auth branch from 7e18f19 to db27d29 Compare April 2, 2026 11:16
@graciecooper graciecooper marked this pull request as ready for review April 2, 2026 11:46
}
result = super.postSync(url, postBody, true);
String jwt = super.resolveJwt(this.customerId);
if (AuthJwtResolver.isMissingRequiredJwt(Optimove.getConfig().getAuthTokenProvider(), this.customerId, jwt)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See line 99, could this cause all visitors to start failing requests? (userId is either userId or visitor here)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@k-antipochkin
Copy link
Copy Markdown
Collaborator

@graciecooper Can we add some more test coverage for the auth flows (Embedded, grouping etc) or that would require significant refactoring?

error != null ? error.getMessage() : "null token");
dispatchRequestWaitsForResponse = false;
scheduleTheNextDispatch();
return;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we abort all the remaining groups if one fails?


Runnable postOnExecutor = () -> postGroupJson(group, groups, index, null);

if (authManager != null && !customerKey.isEmpty()) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reduce the indentation here?

if (authManager == null || customerIsEmpty) {
 postOnExecutor.run();
 return;
}

// rest of auth

@k-antipochkin
Copy link
Copy Markdown
Collaborator

@graciecooper Is it possible to introduce a single dispatcher layer for RT and optistream like we did in iOS? or it would require a big refactoring?

Comment thread CHANGELOG.md
- Added Federated JWT authentication; OptimoveConfig.Builder.enableAuth(AuthTokenProvider) supplies tokens; the SDK adds X-User-JWT for user-identified Optistream, realtime, Preference Center, Embedded Messaging, Optimobile analytics, and in-app network calls.
- Added Auth-capable signaling; Outbound SDK requests sent through HttpClient and OptimobileHttpClient include X-Optimove-Auth-Capable: 1 so backends can detect JWT-capable SDK versions.
- Optistream and realtime paths now group events by customer identity so each request can carry a single JWT. Optimobile analytics drains queued events per stored user id (install/visitor-scoped batches do not attach a user JWT).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

Map<String, List<OptistreamEvent>> map = new LinkedHashMap<>();
for (OptistreamEvent ev : events) {
String key = userKey(ev);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(ev);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it require API 24? I think our minimum is 21?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

}
httpClient.postJson(realtimeConfigs.getRealtimeGateway(), realtimeGson.toJson(group))
.userJwt(token)
.successListener(jsonResponse -> dispatchGroupAtIndex(groups, index + 1, allForFailure))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be allForFailure or just the one group?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed :)

@graciecooper graciecooper force-pushed the feature/268288-federated-auth branch from ed28f78 to 33558ba Compare May 1, 2026 09:08
}

@Nullable
public static String getAssociatedUserIdentifier(@NonNull Context context) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just add a fallback param / or something like boolean excludeFallback as a second param to getCurrentUserIdentifier ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i've went with a separate method because think it makes the intent more obvious than a boolean flag, especially around auth vs visitor fallback? but am happy to switch if you prefer the flag approach ? :)

private InAppMessageView view;

private boolean interceptionInProgress = false;
private int lastShownByInterceptorId = -1;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this safe to delete? I think it was added as an idempotency key that prevented applyMessageInterception from running the second time

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think I've accidentally committed a prev version of the file stored locally, didn't intend to delete this. Reverted!

Map<String, List<AnalyticsEventRow>> groups = new LinkedHashMap<>();
for (AnalyticsEventRow row : rows) {
String key = analyticsUserKey(row.event);
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like computeIfAbsent requires API 24, we are still on 21

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah !! 😨 replaced with an explicit get/null-check/put

return "";
}
String u = event.optString("userId", "");
return u == null ? "" : u.trim();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can just return u.trim() ?

deletePersistedEventsByIds(context, ids);
continue;
}
return false;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we fail all groups?

}
} catch (IOException e) {
e.printStackTrace();
return false;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question here - do we fail all groups in this case?

.errorListener(e -> onRealtimeRequestFailed(e, groups, index, group))
.destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE)
.send();
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we return here to reduce the indentation of the else block? feel free to ignore

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's probs more readable, done!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think now just indented back? Created a wrong indentation. I meant if we just return on the if (authManager != null && !key.isEmpty()) block. Something like:

if (authManager != null && !key.isEmpty()) {
         authManager.getToken(key, (token, error) -> {
             if (error != null || token == null) {
                 dispatchingFailed(error != null ? error : new Exception("null token"), group);
                 return;
             }
             httpClient.postJson(realtimeConfigs.getRealtimeGateway(), realtimeGson.toJson(group))
                     .userJwt(token)
                     .successListener(jsonResponse -> dispatchGroupAtIndex(groups, index + 1))
                     .errorListener(e -> onRealtimeRequestFailed(e, groups, index, group))
                     .destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE)
                     .send();
         });
         return;
     }
     
     httpClient.postJson(realtimeConfigs.getRealtimeGateway(), realtimeGson.toJson(group))
         .userJwt(null)
         .successListener(jsonResponse -> dispatchGroupAtIndex(groups, index + 1))
         .errorListener(e -> onRealtimeRequestFailed(e, groups, index, group))
         .destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE)
         .send();

Up to you though

@graciecooper
Copy link
Copy Markdown
Contributor Author

@graciecooper Is it possible to introduce a single dispatcher layer for RT and optistream like we did in iOS? or it would require a big refactoring?

It's a bit footery in that I think it would involve a lot more time retro-fitting tests ...? I think it would overcomplicate this PR but happy to revisit if we feel strongly about completely aligning with iOS.

if (authManager != null && !key.isEmpty()) {
authManager.getToken(key, (token, error) -> {
if (error != null || token == null) {
dispatchingFailed(error != null ? error : new Exception("null token"), group);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we still call dispatchGroupAtIndex(groups, index + 1) here? Otherwise one user's token-fetch failure halts the whole batch (iOS's OptistreamDispatcher always advances on per-group failure). In practice, seeing the big picture, this shouldnt really happen and even if it happens, there are 2 batches - user and visitor so in worst case we might halt the visitor batch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, let's add it so that it advances the non-failing group and bring it in line with iOS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants