From b631f11a0de8a77739bce693e9162af14e89efa2 Mon Sep 17 00:00:00 2001 From: fadidurah <88730756+fadidurah@users.noreply.github.com> Date: Fri, 15 May 2026 16:47:16 -0400 Subject: [PATCH 1/3] Wire ClientDataInfo through AcquireTokenResult, Fixes AB#3604499 (#3109) Propagate the parsed x-ms-clientdata (token endpoint) and clientdata query parameter (authorize endpoint) data from the response handlers through TokenResult, MicrosoftStsAuthorizationResult, and ultimately onto AcquireTokenResult so callers can access server-side telemetry (error, sub-error, account type, cloud instance, data boundary). [AB#3604499](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3604499) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- changelog.txt | 7 +- .../common/internal/broker/BrokerResult.java | 20 ++ .../controllers/LocalMSALController.java | 28 +++ .../result/MsalBrokerResultAdapter.java | 46 +++- .../request/MsalBrokerResultAdapterTests.kt | 79 +++++++ .../java/controllers/BaseController.java | 12 + .../java/controllers/ExceptionAdapter.java | 17 +- .../common/java/exception/BaseException.java | 18 ++ ...tractMicrosoftStsTokenResponseHandler.java | 1 + .../MicrosoftStsAuthorizationResult.java | 24 ++ ...icrosoftStsAuthorizationResultFactory.java | 14 +- .../java/providers/oauth2/TokenResult.java | 24 ++ .../java/result/AcquireTokenResult.java | 36 +++ .../result/LocalAuthenticationResult.java | 23 ++ .../common/java/telemetry/ClientDataInfo.java | 14 +- .../controllers/ExceptionAdapterTests.java | 39 ++++ ...softStsAuthorizationResultFactoryTest.java | 72 ++++++ .../java/result/AcquireTokenResultTest.java | 219 ++++++++++++++++++ .../java/telemetry/ClientDataInfoTest.java | 34 +-- 19 files changed, 702 insertions(+), 25 deletions(-) create mode 100644 common4j/src/test/com/microsoft/identity/common/java/result/AcquireTokenResultTest.java diff --git a/changelog.txt b/changelog.txt index 0a22df9bfd..cbb4f385c1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,9 +1,10 @@ vNext ---------- +- [PATCH] Wire ClientDataInfo through AcquireTokenResult, exceptions (#3109) + +Version 24.2.1-RC1 +---------- -Version 24.2.1-RC1 ----------- - Version 24.2.0 ---------- - [PATCH] Add support for Authenticator app activation links in WebView, enabling account pairing/MFA flows to launch Microsoft Authenticator directly instead of redirecting to the Play Store (#3090) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerResult.java b/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerResult.java index 4c0cc5f3c2..7784c08aee 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerResult.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/broker/BrokerResult.java @@ -97,6 +97,7 @@ private static class SerializedNames { static final String SPE_RING = "spe_ring"; static final String CLI_TELEM_ERRORCODE = "cli_telem_error_code"; static final String CLI_TELEM_SUB_ERROR_CODE = "cli_telem_suberror_code"; + static final String CLIENT_DATA_INFO = "client_data_info"; } private static final long serialVersionUID = 8606631820514878489L; @@ -236,6 +237,13 @@ private static class SerializedNames { @SerializedName(SerializedNames.REFRESH_TOKEN_AGE) private String mRefreshTokenAge; + /** + * Server client data info from x-ms-clientdata response header (pipe-delimited format). + */ + @Nullable + @SerializedName(SerializedNames.CLIENT_DATA_INFO) + private String mClientDataInfoRaw; + /** * Boolean to indicate if the request succeeded without exceptions. */ @@ -347,6 +355,7 @@ private BrokerResult(@NonNull final Builder builder) { mCachedAt = builder.mCachedAt; mSpeRing = builder.mSpeRing; mRefreshTokenAge = builder.mRefreshTokenAge; + mClientDataInfoRaw = builder.mClientDataInfoRaw; mSuccess = builder.mSuccess; mTenantProfileData = builder.mTenantProfileData; mServicedFromCache = builder.mServicedFromCache; @@ -426,6 +435,11 @@ public String getSpeRing() { return mSpeRing; } + @Nullable + public String getClientDataInfoRaw() { + return mClientDataInfoRaw; + } + public long getCachedAt() { return mCachedAt; } @@ -518,6 +532,7 @@ public static class Builder { private long mCachedAt; private String mSpeRing; private String mRefreshTokenAge; + private String mClientDataInfoRaw; private boolean mSuccess; private String mNegotiatedBrokerProtocolVersion; private List mTenantProfileData; @@ -631,6 +646,11 @@ public Builder refreshTokenAge(final String refreshTokenAge) { return this; } + public Builder clientDataInfoRaw(@Nullable final String clientDataInfoRaw) { + this.mClientDataInfoRaw = clientDataInfoRaw; + return this; + } + public Builder success(boolean success) { this.mSuccess = success; return this; diff --git a/common/src/main/java/com/microsoft/identity/common/internal/controllers/LocalMSALController.java b/common/src/main/java/com/microsoft/identity/common/internal/controllers/LocalMSALController.java index 1bff3a7f15..d33b3dfedd 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/controllers/LocalMSALController.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/controllers/LocalMSALController.java @@ -57,11 +57,15 @@ import com.microsoft.identity.common.java.result.AcquireTokenResult; import com.microsoft.identity.common.java.result.GenerateShrResult; import com.microsoft.identity.common.java.result.LocalAuthenticationResult; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; import com.microsoft.identity.common.java.ui.PreferredAuthMethod; import com.microsoft.identity.common.java.util.ThreadUtils; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; import com.microsoft.identity.common.java.providers.RawAuthorizationResult; import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationRequest; import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationResponse; +import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationResult; import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsTokenRequest; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationRequest; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationResult; @@ -205,6 +209,23 @@ public AcquireTokenResult acquireToken( false ) ); + + // Set ClientDataInfo on the LocalAuthenticationResult for IPC propagation. + // Prefer the token-endpoint value (later, more authoritative); fall back to the + // authorize-endpoint value. + final LocalAuthenticationResult localResult = + (LocalAuthenticationResult) acquireTokenResult.getLocalAuthenticationResult(); + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + if (tokenResult != null && tokenResult.getClientDataInfo() != null) { + localResult.setClientDataInfo(tokenResult.getClientDataInfo()); + } else if (result instanceof MicrosoftStsAuthorizationResult) { + final ClientDataInfo authClientData = + ((MicrosoftStsAuthorizationResult) result).getClientDataInfo(); + if (authClientData != null) { + localResult.setClientDataInfo(authClientData); + } + } + } } } @@ -764,6 +785,13 @@ public AcquireTokenResult acquireDeviceCodeFlowToken( false ) ); + + // Set ClientDataInfo on the LocalAuthenticationResult for IPC propagation + if (tokenResult != null && CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + final LocalAuthenticationResult localResult = + (LocalAuthenticationResult) acquireTokenResult.getLocalAuthenticationResult(); + localResult.setClientDataInfo(tokenResult.getClientDataInfo()); + } } catch (Exception error) { Telemetry.emit( new ApiEndEvent() diff --git a/common/src/main/java/com/microsoft/identity/common/internal/result/MsalBrokerResultAdapter.java b/common/src/main/java/com/microsoft/identity/common/internal/result/MsalBrokerResultAdapter.java index d9b31430c9..cb293d2708 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/result/MsalBrokerResultAdapter.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/result/MsalBrokerResultAdapter.java @@ -98,6 +98,7 @@ import com.microsoft.identity.common.java.result.GenerateShrResult; import com.microsoft.identity.common.java.result.ILocalAuthenticationResult; import com.microsoft.identity.common.java.result.LocalAuthenticationResult; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; import com.microsoft.identity.common.java.ui.PreferredAuthMethod; import com.microsoft.identity.common.java.util.BrokerProtocolVersionUtil; import com.microsoft.identity.common.java.util.HeaderSerializationUtil; @@ -284,6 +285,18 @@ public Bundle bundleFromAuthenticationResultForWebApps(@NonNull final ILocalAuth .success(true) .servicedFromCache(authenticationResult.isServicedFromCache()); + // Serialize ClientDataInfo as raw pipe-delimited string for IPC transfer. + // The raw field is populated by ClientDataInfo.fromPipeDelimited(), the only + // path that populates the parsed fields, so it is safe to ship as-is. + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY) + && authenticationResult instanceof LocalAuthenticationResult) { + final ClientDataInfo clientDataInfo = + ((LocalAuthenticationResult) authenticationResult).getClientDataInfo(); + if (clientDataInfo != null) { + brokerResultBuilder.clientDataInfoRaw(clientDataInfo.getRaw()); + } + } + if (shouldRemoveRefreshTokenFromResult(authenticationResult, negotiatedBrokerProtocolVersion)){ brokerResultBuilder.tenantProfileRecords( removeRefreshTokenFromCacheRecords( @@ -411,6 +424,13 @@ public Bundle bundleFromBaseException(@NonNull final BaseException exception, .speRing(exception.getSpeRing()) .refreshTokenAge(exception.getRefreshTokenAge()); + // Serialize ClientDataInfo (server telemetry from x-ms-clientdata) so it + // survives the broker IPC boundary on error paths. + if (exception.getClientDataInfo() != null + && CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + builder.clientDataInfoRaw(exception.getClientDataInfo().getRaw()); + } + if (exception instanceof ServiceException) { final ServiceException serviceException = (ServiceException) exception; builder.subErrorCode(serviceException.getSubErrorCode()) @@ -476,12 +496,23 @@ public ILocalAuthenticationResult authenticationResultFromBundle(@NonNull final throw new ClientException(INVALID_BROKER_BUNDLE, "getTenantProfileData is null."); } - return new LocalAuthenticationResult( + final LocalAuthenticationResult localAuthResult = new LocalAuthenticationResult( tenantProfileCacheRecords.get(0), tenantProfileCacheRecords, SdkType.MSAL, brokerResult.isServicedFromCache() ); + + // Deserialize ClientDataInfo from the broker result if available + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + final ClientDataInfo clientDataInfo = + ClientDataInfo.fromPipeDelimited(brokerResult.getClientDataInfoRaw()); + if (clientDataInfo != null) { + localAuthResult.setClientDataInfo(clientDataInfo); + } + } + + return localAuthResult; } @NonNull @@ -516,6 +547,15 @@ public BaseException getBaseExceptionFromBundle(@NonNull final Bundle resultBund baseException.setBrokerPerformanceMetrics(metrics); } + // Restore ClientDataInfo (server telemetry) from the broker result so callers + // catching the exception can inspect server-side error context. + if (!StringUtil.isNullOrEmpty(brokerResult.getClientDataInfoRaw()) + && CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + baseException.setClientDataInfo( + ClientDataInfo.fromPipeDelimited(brokerResult.getClientDataInfoRaw()) + ); + } + // Set broker app info if available if (resultBundle.containsKey(AuthenticationConstants.Broker.BROKER_VERSION)) { baseException.setBrokerAppVersion( @@ -994,7 +1034,9 @@ public AcquireTokenResult getDeviceCodeFlowTokenResultFromResultBundle(@NonNull if (resultBundle.getBoolean(AuthenticationConstants.Broker.BROKER_REQUEST_V2_SUCCESS)) { final AcquireTokenResult acquireTokenResult = new AcquireTokenResult(); - acquireTokenResult.setLocalAuthenticationResult(authenticationResultFromBundle(resultBundle)); + final ILocalAuthenticationResult authResult = authenticationResultFromBundle(resultBundle); + acquireTokenResult.setLocalAuthenticationResult(authResult); + span.setStatus(StatusCode.OK); return acquireTokenResult; } else if (brokerResult.getErrorCode().equals(ErrorStrings.DEVICE_CODE_FLOW_AUTHORIZATION_PENDING_ERROR_CODE)) { diff --git a/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerResultAdapterTests.kt b/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerResultAdapterTests.kt index 09c720ab7a..a725ed1c61 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerResultAdapterTests.kt +++ b/common/src/test/java/com/microsoft/identity/common/internal/request/MsalBrokerResultAdapterTests.kt @@ -36,6 +36,7 @@ import com.microsoft.identity.common.java.exception.ClientException import com.microsoft.identity.common.java.exception.UiRequiredException import com.microsoft.identity.common.java.request.SdkType import com.microsoft.identity.common.java.result.LocalAuthenticationResult +import com.microsoft.identity.common.java.telemetry.ClientDataInfo import com.microsoft.identity.common.java.util.SchemaUtil import com.microsoft.identity.internal.testutils.MockRecords import lombok.SneakyThrows @@ -658,4 +659,82 @@ class MsalBrokerResultAdapterTests { resultString.contains(SchemaUtil.MISSING_FROM_THE_TOKEN_RESPONSE) ) } + + // ==================== ClientDataInfo IPC round-trip tests (PR #3109) ==================== + + private val clientDataRaw = "m|AADSTS50058|login_required|us|public" + + private fun newCacheRecord() = CacheRecord.builder() + .account(MockRecords.getMockAccountRecord_AAD()) + .idToken(MockRecords.getMockIdTokenRecord_AAD()) + .accessToken(MockRecords.getMockAccessTokenRecord_AAD()) + .refreshToken(MockRecords.getMockRefreshTokenRecord_AAD()) + .build() + + @Test + fun testClientDataInfo_RoundTripsThroughBrokerResult_OnSuccess() { + val cacheRecord = newCacheRecord() + val cacheRecords: MutableList = arrayListOf(cacheRecord) + val authResult = LocalAuthenticationResult(cacheRecord, cacheRecords, SdkType.MSAL, false) + authResult.clientDataInfo = ClientDataInfo.fromPipeDelimited(clientDataRaw) + + val brokerResult = getInstance().buildBrokerResultFromAuthenticationResult(authResult, "16.0") + assertEquals("Raw payload should be serialized into BrokerResult", clientDataRaw, brokerResult.clientDataInfoRaw) + } + + @Test + fun testClientDataInfo_NullOnLocalAuthResult_ResultsInNullOnBrokerResult() { + val cacheRecord = newCacheRecord() + val cacheRecords: MutableList = arrayListOf(cacheRecord) + val authResult = LocalAuthenticationResult(cacheRecord, cacheRecords, SdkType.MSAL, false) + // No ClientDataInfo set + + val brokerResult = getInstance().buildBrokerResultFromAuthenticationResult(authResult, "16.0") + assertNull(brokerResult.clientDataInfoRaw) + } + + @Test + fun testClientDataInfo_RoundTripsThroughBaseExceptionBundle() { + val exception = ClientException("invalid_grant", "token failure") + exception.clientDataInfo = ClientDataInfo.fromPipeDelimited(clientDataRaw) + + val resultAdapter = MsalBrokerResultAdapter() + val resultBundle = resultAdapter.bundleFromBaseException(exception, null) + val brokerResult = resultAdapter.brokerResultFromBundle(resultBundle) + assertEquals(clientDataRaw, brokerResult.clientDataInfoRaw) + + val received = resultAdapter.getBaseExceptionFromBundle(resultBundle) + assertNotNull("ClientDataInfo should be reconstructed on the exception", received.clientDataInfo) + assertEquals("AADSTS50058", received.clientDataInfo!!.error) + assertEquals("login_required", received.clientDataInfo!!.subError) + assertEquals(clientDataRaw, received.clientDataInfo!!.raw) + } + + @Test + fun testClientDataInfo_NullOnException_NotInBundle() { + val exception = ClientException("invalid_grant", "token failure") + // No ClientDataInfo set + + val resultAdapter = MsalBrokerResultAdapter() + val resultBundle = resultAdapter.bundleFromBaseException(exception, null) + val received = resultAdapter.getBaseExceptionFromBundle(resultBundle) + assertNull(received.clientDataInfo) + } + + @Test + fun testClientDataInfo_RoundTripsThroughGetAcquireTokenResultFromResultBundle() { + val cacheRecord = newCacheRecord() + val cacheRecords: MutableList = arrayListOf(cacheRecord) + val authResult = LocalAuthenticationResult(cacheRecord, cacheRecords, SdkType.MSAL, false) + authResult.clientDataInfo = ClientDataInfo.fromPipeDelimited(clientDataRaw) + + val resultAdapter = MsalBrokerResultAdapter() + val resultBundle = resultAdapter.bundleFromAuthenticationResult(authResult, "16.0") + + val acquireTokenResult = resultAdapter.getAcquireTokenResultFromResultBundle(resultBundle) + assertNotNull("ClientDataInfo should be present on AcquireTokenResult", acquireTokenResult.clientDataInfo) + assertEquals("AADSTS50058", acquireTokenResult.clientDataInfo!!.error) + assertEquals(clientDataRaw, acquireTokenResult.clientDataInfo!!.raw) + } + } \ No newline at end of file diff --git a/common4j/src/main/com/microsoft/identity/common/java/controllers/BaseController.java b/common4j/src/main/com/microsoft/identity/common/java/controllers/BaseController.java index 514ed80812..03aea0aee7 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/controllers/BaseController.java +++ b/common4j/src/main/com/microsoft/identity/common/java/controllers/BaseController.java @@ -58,6 +58,8 @@ import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; import com.microsoft.identity.common.java.exception.ServiceException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; import com.microsoft.identity.common.java.foci.FociQueryUtilities; import com.microsoft.identity.common.java.logging.DiagnosticContext; import com.microsoft.identity.common.java.logging.Logger; @@ -251,6 +253,11 @@ public AcquireTokenResult acquireTokenWithPassword(@NonNull final RopcTokenComma Telemetry.emit(new CacheEndEvent()); } + // Set server client data info on the authentication result for IPC propagation + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + authenticationResult.setClientDataInfo(tokenResult.getClientDataInfo()); + } + // Set the AuthenticationResult on the final result object acquireTokenResult.setLocalAuthenticationResult(authenticationResult); } @@ -528,6 +535,11 @@ protected void renewAccessToken(@NonNull final SilentTokenCommandParameters para Telemetry.emit(new CacheEndEvent()); } + // Set server client data info on the authentication result for IPC propagation + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + authenticationResult.setClientDataInfo(tokenResult.getClientDataInfo()); + } + // Set the AuthenticationResult on the final result object acquireTokenSilentResult.setLocalAuthenticationResult(authenticationResult); } else { diff --git a/common4j/src/main/com/microsoft/identity/common/java/controllers/ExceptionAdapter.java b/common4j/src/main/com/microsoft/identity/common/java/controllers/ExceptionAdapter.java index be1f95bba3..442065a911 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/controllers/ExceptionAdapter.java +++ b/common4j/src/main/com/microsoft/identity/common/java/controllers/ExceptionAdapter.java @@ -39,11 +39,14 @@ import com.microsoft.identity.common.java.exception.TerminalException; import com.microsoft.identity.common.java.exception.UiRequiredException; import com.microsoft.identity.common.java.exception.UserCancelException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; import com.microsoft.identity.common.java.logging.Logger; import com.microsoft.identity.common.java.net.HttpResponse; import com.microsoft.identity.common.java.opentelemetry.AttributeName; import com.microsoft.identity.common.java.opentelemetry.SpanExtension; import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationErrorResponse; +import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationResult; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationErrorResponse; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationResult; import com.microsoft.identity.common.java.providers.oauth2.TokenErrorResponse; @@ -84,7 +87,16 @@ public static BaseException exceptionFromAcquireTokenResult(final AcquireTokenRe if (null != authorizationResult) { if (!authorizationResult.getSuccess()) { - return exceptionFromAuthorizationResult(authorizationResult, commandParameters); + final BaseException authException = exceptionFromAuthorizationResult(authorizationResult, commandParameters); + // Attach ClientDataInfo from the authorize redirect (clientdata query param) + // so callers can inspect server-side error context on auth failures. + if (authorizationResult instanceof MicrosoftStsAuthorizationResult + && CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + authException.setClientDataInfo( + ((MicrosoftStsAuthorizationResult) authorizationResult).getClientDataInfo() + ); + } + return authException; } } else { Logger.warn( @@ -183,6 +195,9 @@ public static ServiceException exceptionFromTokenResult(final TokenResult tokenR outErr = getExceptionFromTokenErrorResponse(commandParameters, tokenResult.getErrorResponse()); applyCliTelemInfo(tokenResult.getCliTelemInfo(), outErr); + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + outErr.setClientDataInfo(tokenResult.getClientDataInfo()); + } } else { Logger.warn( TAG + methodName, diff --git a/common4j/src/main/com/microsoft/identity/common/java/exception/BaseException.java b/common4j/src/main/com/microsoft/identity/common/java/exception/BaseException.java index be58d7e019..2ec6b7d139 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/exception/BaseException.java +++ b/common4j/src/main/com/microsoft/identity/common/java/exception/BaseException.java @@ -23,6 +23,7 @@ package com.microsoft.identity.common.java.exception; import com.microsoft.identity.common.java.broker.IBrokerInfoProvider; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; import com.microsoft.identity.common.java.telemetry.ITelemetryAccessor; import com.microsoft.identity.common.java.telemetry.Telemetry; import com.microsoft.identity.common.java.telemetry.events.ErrorEvent; @@ -70,6 +71,14 @@ public class BaseException extends Exception implements IErrorInformation, ITele @Nullable private String mCliTelemSubErrorCode; + /** + * Server-side telemetry from the x-ms-clientdata response header (/token error responses) + * or the clientdata query parameter (/authorize error redirects). Useful for diagnosing + * server-driven failures (account_type, error, sub_error, caller_data_boundary, cloud_instance). + */ + @Nullable + private ClientDataInfo mClientDataInfo; + private String mErrorCode; private String mSubErrorCode; @@ -213,6 +222,15 @@ public void setCliTelemSubErrorCode(@Nullable final String cliTelemSubErrorCode) this.mCliTelemSubErrorCode = cliTelemSubErrorCode; } + @Nullable + public ClientDataInfo getClientDataInfo() { + return mClientDataInfo; + } + + public void setClientDataInfo(@Nullable final ClientDataInfo clientDataInfo) { + this.mClientDataInfo = clientDataInfo; + } + @Nullable public String getCorrelationId() { return mCorrelationId; diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/AbstractMicrosoftStsTokenResponseHandler.java b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/AbstractMicrosoftStsTokenResponseHandler.java index 2c5beb74b4..39249e9d48 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/AbstractMicrosoftStsTokenResponseHandler.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/AbstractMicrosoftStsTokenResponseHandler.java @@ -113,6 +113,7 @@ public TokenResult handleTokenResponse(@NonNull final HttpResponse response) thr final ClientDataInfo clientDataInfo = ClientDataInfo.fromPipeDelimited(clientDataHeader); if (null != clientDataInfo) { clientDataInfo.emitToSpan(); + result.setClientDataInfo(clientDataInfo); } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResult.java b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResult.java index d4f80771e0..58898c7d7e 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResult.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResult.java @@ -26,6 +26,9 @@ import com.microsoft.identity.common.java.providers.oauth2.AuthorizationErrorResponse; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationResponse; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationStatus; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; + +import javax.annotation.Nullable; /** * Sub class of {@link MicrosoftAuthorizationResult}. @@ -34,6 +37,9 @@ public class MicrosoftStsAuthorizationResult extends MicrosoftAuthorizationResult { + @Nullable + private ClientDataInfo mClientDataInfo; + /** * Constructor of {@link MicrosoftStsAuthorizationResult}. * @@ -54,4 +60,22 @@ public MicrosoftStsAuthorizationResult(final AuthorizationStatus authStatus, fin super(authStatus, errorResponse); } + /** + * Gets the {@link ClientDataInfo} parsed from the clientdata redirect query parameter. + * + * @return The ClientDataInfo, or null if the parameter was absent or unparseable. + */ + @Nullable + public ClientDataInfo getClientDataInfo() { + return mClientDataInfo; + } + + /** + * Sets the {@link ClientDataInfo} parsed from the clientdata redirect query parameter. + * + * @param clientDataInfo The ClientDataInfo to set. + */ + public void setClientDataInfo(@Nullable final ClientDataInfo clientDataInfo) { + mClientDataInfo = clientDataInfo; + } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactory.java b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactory.java index 50b03493ce..2fe5762acc 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactory.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactory.java @@ -75,8 +75,9 @@ protected MicrosoftStsAuthorizationResult parseRedirectUriAndCreateAuthorization final Map urlParameters = UrlUtil.getParameters(redirectUri); + ClientDataInfo clientDataInfo = null; if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { - final ClientDataInfo clientDataInfo = ClientDataInfo.fromPipeDelimited(urlParameters.get(ClientDataInfo.CLIENTDATA_QUERY_PARAMETER)); + clientDataInfo = ClientDataInfo.fromPipeDelimited(urlParameters.get(ClientDataInfo.CLIENTDATA_QUERY_PARAMETER)); if (null != clientDataInfo) { clientDataInfo.emitToSpan(); } @@ -107,6 +108,17 @@ protected MicrosoftStsAuthorizationResult parseRedirectUriAndCreateAuthorization ); } + // Attach ClientDataInfo for both success and trusted server errors. + // Exclude state-mismatch (CSRF) redirects only, as they may originate from untrusted sources. + if (null != clientDataInfo) { + final MicrosoftStsAuthorizationErrorResponse errorResponse = result.getAuthorizationErrorResponse(); + final boolean isStateMismatch = errorResponse != null + && ErrorStrings.STATE_MISMATCH.equals(errorResponse.getError()); + if (!isStateMismatch) { + result.setClientDataInfo(clientDataInfo); + } + } + return result; } diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/TokenResult.java b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/TokenResult.java index aa10b49dfc..0d875c962d 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/TokenResult.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/TokenResult.java @@ -23,6 +23,9 @@ package com.microsoft.identity.common.java.providers.oauth2; import com.microsoft.identity.common.java.telemetry.CliTelemInfo; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; + +import javax.annotation.Nullable; /** * Holds the request of a token request. The request will either contain the success result or the error result. @@ -32,6 +35,8 @@ public class TokenResult implements IResult { private TokenResponse mTokenResponse; private TokenErrorResponse mTokenErrorResponse; private CliTelemInfo mCliTelemInfo; + @Nullable + private ClientDataInfo mClientDataInfo; private boolean mSuccess = false; public TokenResult() { @@ -110,6 +115,25 @@ public void setCliTelemInfo(final CliTelemInfo cliTelemInfo) { mCliTelemInfo = cliTelemInfo; } + /** + * Gets the {@link ClientDataInfo} parsed from the x-ms-clientdata response header. + * + * @return The ClientDataInfo, or null if the header was absent or unparseable. + */ + @Nullable + public ClientDataInfo getClientDataInfo() { + return mClientDataInfo; + } + + /** + * Sets the {@link ClientDataInfo} parsed from the x-ms-clientdata response header. + * + * @param clientDataInfo The ClientDataInfo to set. + */ + public void setClientDataInfo(@Nullable final ClientDataInfo clientDataInfo) { + mClientDataInfo = clientDataInfo; + } + /** * Returns whether the token request was successful or not. * diff --git a/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java b/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java index 28fd66a2a3..69164a8b23 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java +++ b/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java @@ -28,6 +28,8 @@ import com.microsoft.identity.common.java.providers.oauth2.AuthorizationResult; import com.microsoft.identity.common.java.broker.BrokerPerformanceMetrics; import com.microsoft.identity.common.java.broker.IBrokerPerformanceMetricsProvider; +import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationResult; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; import javax.annotation.Nullable; @@ -106,4 +108,38 @@ public String getBrokerAppVersion() { public String getBrokerAppPackageName() { return mBrokerAppPackageName; } + + /** + * Gets the {@link ClientDataInfo} containing server-side telemetry data from the + * x-ms-clientdata response header (/token) or clientdata redirect query parameter (/authorize). + * + *

Resolution order: + *

    + *
  1. {@link LocalAuthenticationResult} — authoritative on success paths; the only carrier + * that survives the broker IPC boundary.
  2. + *
  3. {@link TokenResult} — fallback for paths where token call succeeded but no + * {@code LocalAuthenticationResult} was constructed (e.g., error responses).
  4. + *
  5. {@link MicrosoftStsAuthorizationResult} — fallback for failures before the + * /token call (e.g., authorize-step errors).
  6. + *
+ * + * @return The ClientDataInfo, or null if not available from any source. + */ + @Nullable + public ClientDataInfo getClientDataInfo() { + if (mLocalAuthenticationResult instanceof LocalAuthenticationResult) { + final ClientDataInfo fromLocalAuth = + ((LocalAuthenticationResult) mLocalAuthenticationResult).getClientDataInfo(); + if (fromLocalAuth != null) { + return fromLocalAuth; + } + } + if (mTokenResult != null && mTokenResult.getClientDataInfo() != null) { + return mTokenResult.getClientDataInfo(); + } + if (mAuthorizationResult instanceof MicrosoftStsAuthorizationResult) { + return ((MicrosoftStsAuthorizationResult) mAuthorizationResult).getClientDataInfo(); + } + return null; + } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/result/LocalAuthenticationResult.java b/common4j/src/main/com/microsoft/identity/common/java/result/LocalAuthenticationResult.java index 7ce96b274f..d820e54335 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/result/LocalAuthenticationResult.java +++ b/common4j/src/main/com/microsoft/identity/common/java/result/LocalAuthenticationResult.java @@ -29,6 +29,7 @@ import com.microsoft.identity.common.java.logging.Logger; import com.microsoft.identity.common.java.request.ILocalAuthenticationCallback; import com.microsoft.identity.common.java.request.SdkType; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; import com.microsoft.identity.common.java.telemetry.ITelemetryAccessor; import com.microsoft.identity.common.java.util.StringUtil; @@ -54,6 +55,8 @@ public class LocalAuthenticationResult implements ILocalAuthenticationResult, IT private String mFamilyId; private String mSpeRing; private String mRefreshTokenAge; + @Nullable + private ClientDataInfo mClientDataInfo; private List mCompleteResultFromCache; private boolean mServicedFromCache; private String mCorrelationId; @@ -203,6 +206,26 @@ public void setRefreshTokenAge(final String refreshTokenAge) { mRefreshTokenAge = refreshTokenAge; } + /** + * Gets the server client data info parsed from the {@code x-ms-clientdata} header, which can + * be returned by either the {@code /authorize} or {@code /token} endpoint. + * + * @return The ClientDataInfo, or null if not available. + */ + @Nullable + public ClientDataInfo getClientDataInfo() { + return mClientDataInfo; + } + + /** + * Sets the server client data info. + * + * @param clientDataInfo The ClientDataInfo to set. + */ + public void setClientDataInfo(@Nullable final ClientDataInfo clientDataInfo) { + mClientDataInfo = clientDataInfo; + } + @Override @NonNull public AccessTokenRecord getAccessTokenRecord() { diff --git a/common4j/src/main/com/microsoft/identity/common/java/telemetry/ClientDataInfo.java b/common4j/src/main/com/microsoft/identity/common/java/telemetry/ClientDataInfo.java index 75844688db..fd19094f8e 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/telemetry/ClientDataInfo.java +++ b/common4j/src/main/com/microsoft/identity/common/java/telemetry/ClientDataInfo.java @@ -29,8 +29,9 @@ import edu.umd.cs.findbugs.annotations.Nullable; import io.opentelemetry.api.trace.Span; +import lombok.AccessLevel; import lombok.Getter; -import lombok.Setter; +import lombok.NoArgsConstructor; import lombok.experimental.Accessors; /** @@ -40,7 +41,7 @@ * Contains server-side error codes, account type, cloud instance, and data boundary info. */ @Getter -@Setter +@NoArgsConstructor(access = AccessLevel.PRIVATE) @Accessors(prefix = "m") public class ClientDataInfo { @@ -85,6 +86,14 @@ public class ClientDataInfo { private String mCloudInstance; private String mCallerDataBoundary; + /** + * The original raw pipe-delimited string this instance was parsed from. + * Always set when the instance was constructed via {@link #fromPipeDelimited(String)}, + * which is the only public entry point. Exposed for partner teams that want to + * inspect or forward the unparsed payload. + */ + private String mRaw; + /** * Parses an already-decoded pipe-delimited clientdata query parameter value. * The caller is responsible for URL-decoding before passing (e.g. values from @@ -110,6 +119,7 @@ public static ClientDataInfo fromPipeDelimited(@Nullable final String decodedVal } final ClientDataInfo info = new ClientDataInfo(); + info.mRaw = decodedValue; info.mAccountType = emptyToNull(segments[PIPE_INDEX_ACCOUNT_TYPE]); info.mError = emptyToNull(segments[PIPE_INDEX_ERROR]); info.mSubError = emptyToNull(segments[PIPE_INDEX_SUB_ERROR]); diff --git a/common4j/src/test/com/microsoft/identity/common/java/controllers/ExceptionAdapterTests.java b/common4j/src/test/com/microsoft/identity/common/java/controllers/ExceptionAdapterTests.java index 351ab1249e..0de716fc3d 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/controllers/ExceptionAdapterTests.java +++ b/common4j/src/test/com/microsoft/identity/common/java/controllers/ExceptionAdapterTests.java @@ -43,6 +43,8 @@ import com.microsoft.identity.common.java.exception.UiRequiredException; import com.microsoft.identity.common.java.providers.microsoft.MicrosoftTokenErrorResponse; import com.microsoft.identity.common.java.providers.oauth2.TokenErrorResponse; +import com.microsoft.identity.common.java.providers.oauth2.TokenResult; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; import org.junit.Assert; import org.junit.Test; @@ -212,4 +214,41 @@ public void testGetExceptionFromTokenErrorResponse_NullCommandParameters() { assertEquals(OAuth2ErrorCode.INVALID_GRANT, exception.getErrorCode()); assertEquals("UI required.", exception.getMessage()); } + + // ----------------------------------------------------------------------- + // ClientDataInfo wiring tests (PR #3109) + // ----------------------------------------------------------------------- + + @Test + public void testExceptionFromTokenResult_attachesClientDataInfo() { + final TokenErrorResponse errorResponse = new TokenErrorResponse(); + errorResponse.setError(OAuth2ErrorCode.INVALID_GRANT); + errorResponse.setErrorDescription("token failure"); + + final ClientDataInfo clientDataInfo = ClientDataInfo.fromPipeDelimited("m|AADSTS50058|login_required|us|public"); + Assert.assertNotNull(clientDataInfo); + + final TokenResult tokenResult = new TokenResult(null, errorResponse); + tokenResult.setClientDataInfo(clientDataInfo); + + final ServiceException exception = ExceptionAdapter.exceptionFromTokenResult(tokenResult, null); + + Assert.assertNotNull("ClientDataInfo should be attached to the exception", exception.getClientDataInfo()); + assertEquals("AADSTS50058", exception.getClientDataInfo().getError()); + assertEquals("login_required", exception.getClientDataInfo().getSubError()); + assertEquals("m|AADSTS50058|login_required|us|public", exception.getClientDataInfo().getRaw()); + } + + @Test + public void testExceptionFromTokenResult_nullClientDataInfo_doesNotThrow() { + final TokenErrorResponse errorResponse = new TokenErrorResponse(); + errorResponse.setError(OAuth2ErrorCode.INVALID_GRANT); + errorResponse.setErrorDescription("token failure"); + + final TokenResult tokenResult = new TokenResult(null, errorResponse); + // No ClientDataInfo set + + final ServiceException exception = ExceptionAdapter.exceptionFromTokenResult(tokenResult, null); + Assert.assertNull(exception.getClientDataInfo()); + } } \ No newline at end of file diff --git a/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactoryTest.java b/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactoryTest.java index d5fe1f84ce..38b0af52a1 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactoryTest.java +++ b/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactoryTest.java @@ -337,6 +337,78 @@ public void testClientDataParam_noClientDataParam_doesNotCrash() { assertEquals(AuthorizationStatus.SUCCESS, result.getAuthorizationStatus()); } + @Test + public void testClientDataParam_serverError_clientDataInfoAttached() { + // When the server returns an error in the redirect (e.g., access_denied), clientDataInfo + // must still be attached — this is a trusted failure we especially want telemetry for. + // Pipe-delimited format: account_type|error|sub_error|caller_data_boundary|cloud_instance + final String redirectUrl = MOCK_REDIRECT_URI + + "?error=access_denied" + + "&error_description=user+denied+consent" + + "&" + ClientDataInfo.CLIENTDATA_QUERY_PARAMETER + "=m%7CAADSTS65004%7Cconsent_required%7Cus%7Cpublic"; + + final Span mockSpan = mock(Span.class); + when(mockSpan.setAttribute(Mockito.anyString(), Mockito.anyString())).thenReturn(mockSpan); + + try (MockedStatic mockedExtension = Mockito.mockStatic(SpanExtension.class)) { + mockedExtension.when(SpanExtension::current).thenReturn(mockSpan); + + final MicrosoftStsAuthorizationResult result = (MicrosoftStsAuthorizationResult) + mAuthorizationResultFactory.createAuthorizationResult( + RawAuthorizationResult.fromRedirectUri(redirectUrl), getMstsAuthorizationRequest()); + + assertNotNull(result); + assertEquals(AuthorizationStatus.FAIL, result.getAuthorizationStatus()); + // ClientDataInfo must be attached even for server-error redirects (not state-mismatch) + assertNotNull(result.getClientDataInfo()); + assertEquals("AADSTS65004", result.getClientDataInfo().getError()); + } + } + + @Test + public void testClientDataParam_stateMismatch_clientDataInfoNotAttached() { + // Pipe-delimited format: account_type|error|sub_error|caller_data_boundary|cloud_instance + final String redirectUrl = MOCK_REDIRECT_URI + + "?" + MOCK_AUTH_CODE_AND_STATE + + "&" + ClientDataInfo.CLIENTDATA_QUERY_PARAMETER + "=m%7CAADSTS50058%7Clogin_required%7Cus%7Cpublic"; + + // Pass a mismatched state to trigger state validation failure (potential CSRF redirect) + final MicrosoftStsAuthorizationResult result = (MicrosoftStsAuthorizationResult) + mAuthorizationResultFactory.createAuthorizationResult( + RawAuthorizationResult.fromRedirectUri(redirectUrl), + getMstsAuthorizationRequestWithState("incorrect_state")); + + assertNotNull(result); + assertEquals(AuthorizationStatus.FAIL, result.getAuthorizationStatus()); + // ClientDataInfo must NOT be attached for untrusted/state-mismatch redirects + assertNull(result.getClientDataInfo()); + } + + @Test + public void testClientDataParam_validState_clientDataInfoAttached() { + // Pipe-delimited format: account_type|error|sub_error|caller_data_boundary|cloud_instance + final String redirectUrl = MOCK_REDIRECT_URI + + "?code=auth_code&state=" + MOCK_STATE_ENCODED + + "&" + ClientDataInfo.CLIENTDATA_QUERY_PARAMETER + "=m%7CAADSTS50058%7Clogin_required%7Cus%7Cpublic"; + + final Span mockSpan = mock(Span.class); + when(mockSpan.setAttribute(Mockito.anyString(), Mockito.anyString())).thenReturn(mockSpan); + + try (MockedStatic mockedExtension = Mockito.mockStatic(SpanExtension.class)) { + mockedExtension.when(SpanExtension::current).thenReturn(mockSpan); + + final MicrosoftStsAuthorizationResult result = (MicrosoftStsAuthorizationResult) + mAuthorizationResultFactory.createAuthorizationResult( + RawAuthorizationResult.fromRedirectUri(redirectUrl), getMstsAuthorizationRequest()); + + assertNotNull(result); + assertEquals(AuthorizationStatus.SUCCESS, result.getAuthorizationStatus()); + // ClientDataInfo must be attached when state validation passes + assertNotNull(result.getClientDataInfo()); + } + } + + @Test public void testClientDataParam_flightDisabled_attributesNotEmitted() { final MockFlightsProvider provider = new MockFlightsProvider(); diff --git a/common4j/src/test/com/microsoft/identity/common/java/result/AcquireTokenResultTest.java b/common4j/src/test/com/microsoft/identity/common/java/result/AcquireTokenResultTest.java new file mode 100644 index 0000000000..892e1a0f65 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/result/AcquireTokenResultTest.java @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.result; + +import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationResponse; +import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationResult; +import com.microsoft.identity.common.java.providers.oauth2.AuthorizationStatus; +import com.microsoft.identity.common.java.providers.oauth2.TokenResult; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mockito; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(JUnit4.class) +public class AcquireTokenResultTest { + + private static final String PIPE_DELIMITED_A = "m|AADSTS50058|login_required|us|public"; + private static final String PIPE_DELIMITED_B = "e|AADSTS70011|invalid_scope|eu|sovereign"; + private static final String PIPE_DELIMITED_C = "m|AADSTS50076|mfa_required|us|public"; + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private ClientDataInfo makeClientDataInfo(final String raw) { + return ClientDataInfo.fromPipeDelimited(raw); + } + + private LocalAuthenticationResult makeLocalAuthResult(final ClientDataInfo clientDataInfo) { + final LocalAuthenticationResult localAuthResult = mock(LocalAuthenticationResult.class); + when(localAuthResult.getClientDataInfo()).thenReturn(clientDataInfo); + return localAuthResult; + } + + private MicrosoftStsAuthorizationResult makeAuthResult(final ClientDataInfo clientDataInfo) { + final MicrosoftStsAuthorizationResult authResult = new MicrosoftStsAuthorizationResult( + AuthorizationStatus.SUCCESS, (MicrosoftStsAuthorizationResponse) null); + authResult.setClientDataInfo(clientDataInfo); + return authResult; + } + + // --------------------------------------------------------------------------- + // No sources populated + // --------------------------------------------------------------------------- + + @Test + public void getClientDataInfo_noSources_returnsNull() { + final AcquireTokenResult result = new AcquireTokenResult(); + assertNull(result.getClientDataInfo()); + } + + // --------------------------------------------------------------------------- + // LocalAuthenticationResult source + // --------------------------------------------------------------------------- + + @Test + public void getClientDataInfo_localAuthResultHasData_returnsIt() { + final ClientDataInfo expected = makeClientDataInfo(PIPE_DELIMITED_A); + final AcquireTokenResult result = new AcquireTokenResult(); + result.setLocalAuthenticationResult(makeLocalAuthResult(expected)); + + assertSame(expected, result.getClientDataInfo()); + } + + @Test + public void getClientDataInfo_localAuthResultHasNull_fallsBackToTokenResult() { + final ClientDataInfo tokenData = makeClientDataInfo(PIPE_DELIMITED_A); + final TokenResult tokenResult = new TokenResult(); + tokenResult.setClientDataInfo(tokenData); + + final AcquireTokenResult result = new AcquireTokenResult(); + result.setLocalAuthenticationResult(makeLocalAuthResult(null)); + result.setTokenResult(tokenResult); + + assertSame(tokenData, result.getClientDataInfo()); + } + + // --------------------------------------------------------------------------- + // TokenResult fallback + // --------------------------------------------------------------------------- + + @Test + public void getClientDataInfo_tokenResultHasData_returnsIt() { + final ClientDataInfo expected = makeClientDataInfo(PIPE_DELIMITED_A); + final TokenResult tokenResult = new TokenResult(); + tokenResult.setClientDataInfo(expected); + + final AcquireTokenResult result = new AcquireTokenResult(); + result.setTokenResult(tokenResult); + + assertSame(expected, result.getClientDataInfo()); + } + + @Test + public void getClientDataInfo_tokenResultHasNull_fallsBackToAuthResult() { + final ClientDataInfo authData = makeClientDataInfo(PIPE_DELIMITED_A); + final TokenResult tokenResult = new TokenResult(); + tokenResult.setClientDataInfo(null); + + final AcquireTokenResult result = new AcquireTokenResult(); + result.setTokenResult(tokenResult); + result.setAuthorizationResult(makeAuthResult(authData)); + + assertSame(authData, result.getClientDataInfo()); + } + + @Test + public void getClientDataInfo_noTokenResult_fallsBackToAuthResult() { + final ClientDataInfo authData = makeClientDataInfo(PIPE_DELIMITED_A); + + final AcquireTokenResult result = new AcquireTokenResult(); + result.setAuthorizationResult(makeAuthResult(authData)); + + assertSame(authData, result.getClientDataInfo()); + } + + // --------------------------------------------------------------------------- + // MicrosoftStsAuthorizationResult fallback + // --------------------------------------------------------------------------- + + @Test + public void getClientDataInfo_authResultHasData_returnsIt() { + final ClientDataInfo expected = makeClientDataInfo(PIPE_DELIMITED_A); + + final AcquireTokenResult result = new AcquireTokenResult(); + result.setAuthorizationResult(makeAuthResult(expected)); + + assertSame(expected, result.getClientDataInfo()); + } + + @Test + public void getClientDataInfo_authResultHasNull_returnsNull() { + final AcquireTokenResult result = new AcquireTokenResult(); + result.setAuthorizationResult(makeAuthResult(null)); + + assertNull(result.getClientDataInfo()); + } + + // --------------------------------------------------------------------------- + // Precedence: LocalAuthResult > TokenResult > AuthResult + // --------------------------------------------------------------------------- + + @Test + public void getClientDataInfo_allSourcesPopulated_prefersLocalAuthResult() { + final ClientDataInfo localData = makeClientDataInfo(PIPE_DELIMITED_A); + final ClientDataInfo tokenData = makeClientDataInfo(PIPE_DELIMITED_B); + final ClientDataInfo authData = makeClientDataInfo(PIPE_DELIMITED_C); + + final TokenResult tokenResult = new TokenResult(); + tokenResult.setClientDataInfo(tokenData); + + final AcquireTokenResult result = new AcquireTokenResult(); + result.setLocalAuthenticationResult(makeLocalAuthResult(localData)); + result.setTokenResult(tokenResult); + result.setAuthorizationResult(makeAuthResult(authData)); + + assertSame(localData, result.getClientDataInfo()); + } + + @Test + public void getClientDataInfo_localAuthNullTokenPopulated_prefersTokenResult() { + final ClientDataInfo tokenData = makeClientDataInfo(PIPE_DELIMITED_A); + final ClientDataInfo authData = makeClientDataInfo(PIPE_DELIMITED_B); + + final TokenResult tokenResult = new TokenResult(); + tokenResult.setClientDataInfo(tokenData); + + final AcquireTokenResult result = new AcquireTokenResult(); + result.setLocalAuthenticationResult(makeLocalAuthResult(null)); + result.setTokenResult(tokenResult); + result.setAuthorizationResult(makeAuthResult(authData)); + + assertSame(tokenData, result.getClientDataInfo()); + } + + @Test + public void getClientDataInfo_localAuthNullTokenNull_returnsAuthResult() { + final ClientDataInfo authData = makeClientDataInfo(PIPE_DELIMITED_A); + + final TokenResult tokenResult = new TokenResult(); + tokenResult.setClientDataInfo(null); + + final AcquireTokenResult result = new AcquireTokenResult(); + result.setLocalAuthenticationResult(makeLocalAuthResult(null)); + result.setTokenResult(tokenResult); + result.setAuthorizationResult(makeAuthResult(authData)); + + assertSame(authData, result.getClientDataInfo()); + } + +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/telemetry/ClientDataInfoTest.java b/common4j/src/test/com/microsoft/identity/common/java/telemetry/ClientDataInfoTest.java index 7b4e0f37a4..ef5e4e1330 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/telemetry/ClientDataInfoTest.java +++ b/common4j/src/test/com/microsoft/identity/common/java/telemetry/ClientDataInfoTest.java @@ -54,7 +54,8 @@ public class ClientDataInfoTest { @Test public void fromPipeDelimited_validFiveSegments_allFieldsParsed() { // format: account_type|error|sub_error|caller_data_boundary|cloud_instance - final ClientDataInfo info = ClientDataInfo.fromPipeDelimited("m|AADSTS50058|login_required|us|public"); + final String raw = "m|AADSTS50058|login_required|us|public"; + final ClientDataInfo info = ClientDataInfo.fromPipeDelimited(raw); assertNotNull(info); assertEquals("m", info.getAccountType()); @@ -62,6 +63,7 @@ public void fromPipeDelimited_validFiveSegments_allFieldsParsed() { assertEquals("login_required", info.getSubError()); assertEquals("us", info.getCallerDataBoundary()); assertEquals("public", info.getCloudInstance()); + assertEquals(raw, info.getRaw()); } @Test @@ -108,12 +110,9 @@ public void fromPipeDelimited_emptyString_returnsNull() { @Test public void emitToSpan_allFieldsSet_allAttributesEmitted() { - final ClientDataInfo info = new ClientDataInfo(); - info.setError("AADSTS50058"); - info.setSubError("login_required"); - info.setAccountType("m"); - info.setCloudInstance("public"); - info.setCallerDataBoundary("us"); + // format: account_type|error|sub_error|caller_data_boundary|cloud_instance + final ClientDataInfo info = ClientDataInfo.fromPipeDelimited("m|AADSTS50058|login_required|us|public"); + assertNotNull(info); final Span mockSpan = mock(Span.class); when(mockSpan.setAttribute(Mockito.anyString(), Mockito.anyString())).thenReturn(mockSpan); @@ -133,9 +132,9 @@ public void emitToSpan_allFieldsSet_allAttributesEmitted() { @Test public void emitToSpan_someFieldsNull_nullFieldsNotEmitted() { - final ClientDataInfo info = new ClientDataInfo(); - info.setError("AADSTS50058"); - // subError, accountType, cloudInstance, callerDataBoundary all null + // Only error populated; account_type and sub_error empty + final ClientDataInfo info = ClientDataInfo.fromPipeDelimited("|AADSTS50058|"); + assertNotNull(info); final Span mockSpan = mock(Span.class); when(mockSpan.setAttribute(Mockito.anyString(), Mockito.anyString())).thenReturn(mockSpan); @@ -159,8 +158,9 @@ public void emitToSpan_someFieldsNull_nullFieldsNotEmitted() { @Test public void emitToSpan_accountTypeMsa_mappedToMSA() { - final ClientDataInfo info = new ClientDataInfo(); - info.setAccountType("m"); + // Only account_type populated (m for MSA) + final ClientDataInfo info = ClientDataInfo.fromPipeDelimited("m||"); + assertNotNull(info); final Span mockSpan = mock(Span.class); when(mockSpan.setAttribute(Mockito.anyString(), Mockito.anyString())).thenReturn(mockSpan); @@ -176,8 +176,9 @@ public void emitToSpan_accountTypeMsa_mappedToMSA() { @Test public void emitToSpan_accountTypeAad_mappedToAAD() { - final ClientDataInfo info = new ClientDataInfo(); - info.setAccountType("e"); + // Only account_type populated (e for AAD) + final ClientDataInfo info = ClientDataInfo.fromPipeDelimited("e||"); + assertNotNull(info); final Span mockSpan = mock(Span.class); when(mockSpan.setAttribute(Mockito.anyString(), Mockito.anyString())).thenReturn(mockSpan); @@ -207,8 +208,9 @@ public void emitToSpan_fieldExceeds256Chars_truncatedTo256() { expected256.append('A'); } - final ClientDataInfo info = new ClientDataInfo(); - info.setError(longValue); + // Construct via the only public entry point; long value goes in the error field + final ClientDataInfo info = ClientDataInfo.fromPipeDelimited("|" + longValue + "|"); + assertNotNull(info); final Span mockSpan = mock(Span.class); when(mockSpan.setAttribute(Mockito.anyString(), Mockito.anyString())).thenReturn(mockSpan); From 3ab917276946b79eb950e5f26dc37a80edbf2f8c Mon Sep 17 00:00:00 2001 From: fadidurah <88730756+fadidurah@users.noreply.github.com> Date: Fri, 15 May 2026 21:24:10 -0400 Subject: [PATCH 2/3] Update changelog for version 24.2.1-RC1 Updated changelog to include new version details and patches. --- changelog.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index a959e9ccc9..b60ce20bb7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,9 +1,9 @@ vNext ---------- -- [PATCH] Wire ClientDataInfo through AcquireTokenResult, exceptions (#3109) Version 24.2.1-RC1 ---------- +- [PATCH] Wire ClientDataInfo through AcquireTokenResult, exceptions (#3109) - [MINOR] Add additional step ID and blocking error constants for full onboarding telemetry coverage (#3117) - [MINOR] Add onboarding telemetry blob fields to BrokerRequest/BrokerResult and command parameters for client↔broker IPC transport (#3111) - [MINOR] Add onboarding telemetry recorder, field keys, and session persistence for mobile onboarding flow (#3088) From 0f6908b522d838e0d57e63ce3db3d7e5e68fdb08 Mon Sep 17 00:00:00 2001 From: fadidurah <88730756+fadidurah@users.noreply.github.com> Date: Fri, 15 May 2026 21:24:31 -0400 Subject: [PATCH 3/3] Update changelog for version 24.2.0 Updated changelog with new entries and version 24.2.0 details. --- changelog.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.txt b/changelog.txt index b60ce20bb7..1661a1a458 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,7 @@ Version 24.2.1-RC1 - [MINOR] Add additional step ID and blocking error constants for full onboarding telemetry coverage (#3117) - [MINOR] Add onboarding telemetry blob fields to BrokerRequest/BrokerResult and command parameters for client↔broker IPC transport (#3111) - [MINOR] Add onboarding telemetry recorder, field keys, and session persistence for mobile onboarding flow (#3088) + Version 24.2.0 ---------- - [PATCH] Add support for Authenticator app activation links in WebView, enabling account pairing/MFA flows to launch Microsoft Authenticator directly instead of redirecting to the Play Store (#3090)