diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index fa9cce24a1..2a57736709 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -244,7 +244,14 @@ public enum CommonFlight implements IFlightConfig { * This provides a more granular enabled check that distinguishes * DISABLED_USER, DISABLED_UNTIL_USED, etc. */ - USE_ENABLED_SETTING_FOR_PACKAGE_CHECK("UseEnabledSettingForPackageCheck", false); + USE_ENABLED_SETTING_FOR_PACKAGE_CHECK("UseEnabledSettingForPackageCheck", false), + + /** + * Flight to enable server-side client data telemetry from the x-ms-clientdata response + * header (/token endpoint) and the clientdata redirect query parameter (/authorize endpoint). + * Enabled by default; can be turned off via ECS if any issues arise in production. + */ + ENABLE_SERVER_CLIENT_DATA_TELEMETRY("EnableServerClientDataTelemetry", true); private String key; private Object defaultValue; diff --git a/common4j/src/main/com/microsoft/identity/common/java/net/HttpConstants.java b/common4j/src/main/com/microsoft/identity/common/java/net/HttpConstants.java index 56c8ba2e49..195e54b16f 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/net/HttpConstants.java +++ b/common4j/src/main/com/microsoft/identity/common/java/net/HttpConstants.java @@ -58,6 +58,12 @@ public static final class HeaderField { * Header to track if Cached Credential Service (CCS) request sequence */ public static final String XMS_CCS_REQUEST_SEQUENCE = "x-ms-srs"; + + /** + * Header carrying server-side telemetry data (error codes, account type, cloud instance, + * data boundary) returned by eSTS and MSA on /token responses. + */ + public static final String X_MS_CLIENTDATA = "x-ms-clientdata"; } /** diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java index a445c00585..bfaf5db58c 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java @@ -147,6 +147,7 @@ public enum AttributeName { * The content type of the response returned by eSTS for the request. */ response_content_type, + /** * The http status code of the operation. */ @@ -665,4 +666,32 @@ public enum AttributeName { target_blank_navigation_route, //endregion + + //region x-ms-clientdata server telemetry attributes + + /** + * The server-side error code returned in the x-ms-clientdata header or clientdata + * query parameter from eSTS / MSA. + */ + server_error, + + /** + * The server-side sub-error code returned in the x-ms-clientdata header or clientdata + * query parameter from eSTS / MSA. + */ + server_sub_error, + + /** + * The cloud instance returned in the x-ms-clientdata header or clientdata query + * parameter from eSTS / MSA (e.g. "public", "usgov"). + */ + server_cloud_instance, + + /** + * The caller data boundary returned in the x-ms-clientdata header or clientdata + * query parameter from eSTS / MSA, indicating the data residency boundary. + */ + server_caller_data_boundary, + + //endregion } 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 9d834fa2ab..2c5beb74b4 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 @@ -25,6 +25,7 @@ import static com.microsoft.identity.common.java.net.HttpConstants.HeaderField.XMS_CCS_REQUEST_ID; import static com.microsoft.identity.common.java.net.HttpConstants.HeaderField.XMS_CCS_REQUEST_SEQUENCE; import static com.microsoft.identity.common.java.net.HttpConstants.HeaderField.X_MS_CLITELEM; +import static com.microsoft.identity.common.java.net.HttpConstants.HeaderField.X_MS_CLIENTDATA; import com.google.gson.JsonParseException; import com.microsoft.identity.common.java.exception.ClientException; @@ -38,6 +39,7 @@ import com.microsoft.identity.common.java.providers.oauth2.ITokenResponseHandler; import com.microsoft.identity.common.java.providers.oauth2.TokenResult; import com.microsoft.identity.common.java.telemetry.CliTelemInfo; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; import com.microsoft.identity.common.java.util.HeaderSerializationUtil; import com.microsoft.identity.common.java.util.ObjectMapper; import com.microsoft.identity.common.java.util.ResultUtil; @@ -106,6 +108,14 @@ public TokenResult handleTokenResponse(@NonNull final HttpResponse response) thr } } + final String clientDataHeader = response.getHeaderValue(X_MS_CLIENTDATA, 0); + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + final ClientDataInfo clientDataInfo = ClientDataInfo.fromPipeDelimited(clientDataHeader); + if (null != clientDataInfo) { + clientDataInfo.emitToSpan(); + } + } + final Map mapWithAdditionalEntry = new HashMap(); final String ccsRequestId = response.getHeaderValue(XMS_CCS_REQUEST_ID, 0); diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequest.java b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequest.java index 4d62f5f79d..71e4d7254b 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequest.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequest.java @@ -26,6 +26,8 @@ import com.google.gson.annotations.SerializedName; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.logging.Logger; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationRequest; import com.microsoft.identity.common.java.providers.microsoft.azureactivedirectory.AzureActiveDirectorySlice; import com.microsoft.identity.common.java.providers.oauth2.OpenIdProviderConfiguration; @@ -156,6 +158,13 @@ public static final class Prompt { */ public static final String HIDE_SWITCH_USER_QUERY_PARAMETER = "hsu"; + /** + * When clidata=1 is passed, eSTS/MSA includes a {@code clientdata} query parameter in the + * authorize redirect URI containing server-side telemetry (error, sub-error, account type, + * cloud instance, data boundary). + */ + public static final String CLIDATA_QUERY_PARAMETER = "clidata"; + /** * Store the openIdConfiguration passed as part of the builder. * This will be used to fetch the authorization endpoint from OpenID Configuration rather than @@ -286,6 +295,11 @@ public URI getAuthorizationRequestAsHttpRequest() throws ClientException { builder.addParameterIfAbsent(HIDE_SWITCH_USER_QUERY_PARAMETER, "1"); } + // Request server-side telemetry in the clientdata redirect query parameter. + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + builder.addParameterIfAbsent(CLIDATA_QUERY_PARAMETER, "1"); + } + try { return builder.build(); } catch (final URISyntaxException e) { 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 7ba8c2a7d8..50b03493ce 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 @@ -27,9 +27,12 @@ import com.microsoft.identity.common.java.exception.ErrorStrings; import com.microsoft.identity.common.java.logging.Logger; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationErrorResponse; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationResultFactory; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationStatus; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; import com.microsoft.identity.common.java.util.StringUtil; import com.microsoft.identity.common.java.util.UrlUtil; @@ -72,6 +75,13 @@ protected MicrosoftStsAuthorizationResult parseRedirectUriAndCreateAuthorization final Map urlParameters = UrlUtil.getParameters(redirectUri); + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + final ClientDataInfo clientDataInfo = ClientDataInfo.fromPipeDelimited(urlParameters.get(ClientDataInfo.CLIENTDATA_QUERY_PARAMETER)); + if (null != clientDataInfo) { + clientDataInfo.emitToSpan(); + } + } + MicrosoftStsAuthorizationResult result; if (urlParameters.isEmpty()) { Logger.warn(methodTag, "Invalid server response, empty query string from the webview redirect."); 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 new file mode 100644 index 0000000000..75844688db --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/telemetry/ClientDataInfo.java @@ -0,0 +1,175 @@ +// 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.telemetry; + +import com.microsoft.identity.common.java.logging.Logger; +import com.microsoft.identity.common.java.opentelemetry.AttributeName; +import com.microsoft.identity.common.java.opentelemetry.SpanExtension; +import com.microsoft.identity.common.java.util.StringUtil; + +import edu.umd.cs.findbugs.annotations.Nullable; +import io.opentelemetry.api.trace.Span; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +/** + * Model representing server telemetry data from the x-ms-clientdata response header + * (/token responses) and the clientdata query parameter (/authorize redirect URLs). + * Both use a pipe-delimited format: account_type|error|sub_error|caller_data_boundary|cloud_instance. + * Contains server-side error codes, account type, cloud instance, and data boundary info. + */ +@Getter +@Setter +@Accessors(prefix = "m") +public class ClientDataInfo { + + private static final String TAG = ClientDataInfo.class.getSimpleName(); + + /** Maximum length for any individual field when emitting to a span. */ + private static final int MAX_FIELD_LENGTH = 256; + + /** + * The name of the {@code clientdata} query parameter added to /authorize redirect URIs + * by eSTS/MSA when {@code clidata=1} is included in the authorization request. + * Use this constant everywhere the parameter name is referenced to avoid typos. + */ + public static final String CLIENTDATA_QUERY_PARAMETER = "clientdata"; + + /** Account type value for MSA accounts. */ + private static final String ACCOUNT_TYPE_MSA_RAW = "m"; + + /** Account type value for AAD accounts. */ + private static final String ACCOUNT_TYPE_AAD_RAW = "e"; + + /** Display value for MSA account type. */ + private static final String ACCOUNT_TYPE_MSA = "MSA"; + + /** Display value for AAD account type. */ + private static final String ACCOUNT_TYPE_AAD = "AAD"; + + /** + * Positional index for account_type in the pipe-delimited format: + * account_type|error|sub_error|caller_data_boundary|cloud_instance + */ + private static final int PIPE_INDEX_ACCOUNT_TYPE = 0; + private static final int PIPE_INDEX_ERROR = 1; + private static final int PIPE_INDEX_SUB_ERROR = 2; + private static final int PIPE_INDEX_CALLER_DATA_BOUNDARY = 3; + private static final int PIPE_INDEX_CLOUD_INSTANCE = 4; + private static final int PIPE_MIN_SEGMENTS = 3; + + private String mError; + private String mSubError; + private String mAccountType; + private String mCloudInstance; + private String mCallerDataBoundary; + + /** + * Parses an already-decoded pipe-delimited clientdata query parameter value. + * The caller is responsible for URL-decoding before passing (e.g. values from + * {@link com.microsoft.identity.common.java.util.UrlUtil#getParameters} are + * already decoded). Decoding twice would corrupt values containing '+' or '%'. + * Positional format: account_type|error|sub_error|caller_data_boundary|cloud_instance. + * Requires at least 3 segments. + * + * @param decodedValue already-decoded pipe-delimited string, may be null. + * @return parsed {@link ClientDataInfo}, or null on failure/empty input. + */ + @Nullable + public static ClientDataInfo fromPipeDelimited(@Nullable final String decodedValue) { + if (StringUtil.isNullOrEmpty(decodedValue)) { + return null; + } + try { + final String[] segments = decodedValue.split("\\|", -1); + + if (segments.length < PIPE_MIN_SEGMENTS) { + Logger.warn(TAG, "clientdata pipe-delimited value has fewer than " + PIPE_MIN_SEGMENTS + " segments."); + return null; + } + + final ClientDataInfo info = new ClientDataInfo(); + info.mAccountType = emptyToNull(segments[PIPE_INDEX_ACCOUNT_TYPE]); + info.mError = emptyToNull(segments[PIPE_INDEX_ERROR]); + info.mSubError = emptyToNull(segments[PIPE_INDEX_SUB_ERROR]); + info.mCallerDataBoundary = segments.length > PIPE_INDEX_CALLER_DATA_BOUNDARY + ? emptyToNull(segments[PIPE_INDEX_CALLER_DATA_BOUNDARY]) : null; + info.mCloudInstance = segments.length > PIPE_INDEX_CLOUD_INSTANCE + ? emptyToNull(segments[PIPE_INDEX_CLOUD_INSTANCE]) : null; + return info; + } catch (final Exception e) { + Logger.warn(TAG, "Failed to parse clientdata pipe-delimited value: " + e.getMessage()); + return null; + } + } + + /** + * Sets each non-null field as a span attribute on the current span via {@link SpanExtension}. + * account_type values are mapped: "m" -> "MSA", "e" -> "AAD". + * Each field is truncated to {@value #MAX_FIELD_LENGTH} characters. + */ + public void emitToSpan() { + final Span span = SpanExtension.current(); + if (mError != null) { + span.setAttribute(AttributeName.server_error.name(), truncate(mError)); + } + if (mSubError != null) { + span.setAttribute(AttributeName.server_sub_error.name(), truncate(mSubError)); + } + if (mAccountType != null) { + // account_type is an existing AttributeName; reuse it (m -> MSA, e -> AAD). + final String mappedAccountType = mapAccountType(mAccountType); + span.setAttribute(AttributeName.account_type.name(), truncate(mappedAccountType)); + } + if (mCloudInstance != null) { + span.setAttribute(AttributeName.server_cloud_instance.name(), truncate(mCloudInstance)); + } + if (mCallerDataBoundary != null) { + span.setAttribute(AttributeName.server_caller_data_boundary.name(), truncate(mCallerDataBoundary)); + } + } + + private static String mapAccountType(final String raw) { + if (ACCOUNT_TYPE_MSA_RAW.equalsIgnoreCase(raw)) { + return ACCOUNT_TYPE_MSA; + } else if (ACCOUNT_TYPE_AAD_RAW.equalsIgnoreCase(raw)) { + return ACCOUNT_TYPE_AAD; + } + Logger.warn(TAG, "Unknown account_type value in clientdata; emitting as UNKNOWN."); + return "UNKNOWN"; + } + + @Nullable + private static String truncate(@Nullable final String value) { + if (value == null || value.length() <= MAX_FIELD_LENGTH) { + return value; + } + return value.substring(0, MAX_FIELD_LENGTH); + } + + @Nullable + private static String emptyToNull(final String value) { + return StringUtil.isNullOrEmpty(value) ? null : value; + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequestTests.java b/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequestTests.java index 827528e2db..cb63be2204 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequestTests.java +++ b/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequestTests.java @@ -26,6 +26,8 @@ import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.flighting.CommonFlight; import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.MockFlightsManager; +import com.microsoft.identity.common.java.flighting.MockFlightsProvider; import com.microsoft.identity.common.java.platform.Device; import com.microsoft.identity.common.java.platform.MockDeviceMetadata; import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationRequest; @@ -51,6 +53,7 @@ import static com.microsoft.identity.common.java.providers.Constants.MOCK_STATE; import static com.microsoft.identity.common.java.providers.Constants.MOCK_STATE_ENCODED; import static com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationRequest.HIDE_SWITCH_USER_QUERY_PARAMETER; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class MicrosoftStsAuthorizationRequestTests { @@ -99,6 +102,7 @@ public class MicrosoftStsAuthorizationRequestTests { public void tearDown() { Device.clearDeviceMetadata(); Device.setIsInPersonalProfileButClouddpcWorkProfileAvailable(false); + CommonFlightsManager.INSTANCE.resetFlightsManager(); } static URL getValidRequestUrl() throws MalformedURLException { @@ -155,7 +159,8 @@ public void testCreateUriFromAuthorizationRequest() throws MalformedURLException "&" + MOCK_FLIGHT_QUERY_1 + "=" + MOCK_FLIGHT_VALUE_1 + "&" + MOCK_FLIGHT_QUERY_2 + "=" + MOCK_FLIGHT_VALUE_2 + "&slice=" + DEFAULT_TEST_SLICE_PARAMETER + - "&dc=" + DEFAULT_TEST_DATA_CENTER, + "&dc=" + DEFAULT_TEST_DATA_CENTER + + "&clidata=1", request.getAuthorizationRequestAsHttpRequest().toString()); } else { Assert.assertEquals(DEFAULT_TEST_AUTHORIZATION_ENDPOINT + @@ -178,7 +183,8 @@ public void testCreateUriFromAuthorizationRequest() throws MalformedURLException "&" + MOCK_FLIGHT_QUERY_1 + "=" + MOCK_FLIGHT_VALUE_1 + "&" + MOCK_FLIGHT_QUERY_2 + "=" + MOCK_FLIGHT_VALUE_2 + "&slice=" + DEFAULT_TEST_SLICE_PARAMETER + - "&dc=" + DEFAULT_TEST_DATA_CENTER, + "&dc=" + DEFAULT_TEST_DATA_CENTER + + "&clidata=1", request.getAuthorizationRequestAsHttpRequest().toString()); } @@ -234,7 +240,8 @@ public void testCreateUriFromAuthorizationRequestWithWPAvailable() throws Malfor "&" + MOCK_FLIGHT_QUERY_1 + "=" + MOCK_FLIGHT_VALUE_1 + "&" + MOCK_FLIGHT_QUERY_2 + "=" + MOCK_FLIGHT_VALUE_2 + "&slice=" + DEFAULT_TEST_SLICE_PARAMETER + - "&dc=" + DEFAULT_TEST_DATA_CENTER, + "&dc=" + DEFAULT_TEST_DATA_CENTER + + "&clidata=1", request.getAuthorizationRequestAsHttpRequest().toString()); } else { Assert.assertEquals(DEFAULT_TEST_AUTHORIZATION_ENDPOINT + @@ -257,7 +264,8 @@ public void testCreateUriFromAuthorizationRequestWithWPAvailable() throws Malfor "&" + MOCK_FLIGHT_QUERY_1 + "=" + MOCK_FLIGHT_VALUE_1 + "&" + MOCK_FLIGHT_QUERY_2 + "=" + MOCK_FLIGHT_VALUE_2 + "&slice=" + DEFAULT_TEST_SLICE_PARAMETER + - "&dc=" + DEFAULT_TEST_DATA_CENTER, + "&dc=" + DEFAULT_TEST_DATA_CENTER + + "&clidata=1", request.getAuthorizationRequestAsHttpRequest().toString()); } @@ -418,4 +426,34 @@ public void testRequestWithSwitchBrowser() throws ClientException, MalformedURLE final String actualCodeRequestUrl = request.getAuthorizationRequestAsHttpRequest().toString(); assertTrue(actualCodeRequestUrl.contains("switch_browser=1")); } + + @Test + public void testCliDataParam_urlContainsCliData() + throws MalformedURLException, URISyntaxException, ClientException { + final MicrosoftStsAuthorizationRequest request = new MicrosoftStsAuthorizationRequest.Builder() + .setAuthority(getValidRequestUrl()) + .build(); + + final String url = request.getAuthorizationRequestAsHttpRequest().toString(); + assertTrue("URL should always contain clidata=1", + url.contains(MicrosoftStsAuthorizationRequest.CLIDATA_QUERY_PARAMETER + "=1")); + } + + @Test + public void testCliDataParam_flightDisabled_urlDoesNotContainCliData() + throws MalformedURLException, URISyntaxException, ClientException { + final MockFlightsProvider provider = new MockFlightsProvider(); + provider.addFlight(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY.getKey(), "false"); + final MockFlightsManager manager = new MockFlightsManager(); + manager.setMockBrokerFlightsProvider(provider); + CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(manager); + + final MicrosoftStsAuthorizationRequest request = new MicrosoftStsAuthorizationRequest.Builder() + .setAuthority(getValidRequestUrl()) + .build(); + + final String url = request.getAuthorizationRequestAsHttpRequest().toString(); + assertFalse("URL should not contain clidata=1 when flight is disabled", + url.contains(MicrosoftStsAuthorizationRequest.CLIDATA_QUERY_PARAMETER + "=1")); + } } 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 ae9d7f1057..d5fe1f84ce 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 @@ -24,21 +24,37 @@ import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.MockFlightsManager; +import com.microsoft.identity.common.java.flighting.MockFlightsProvider; +import com.microsoft.identity.common.java.opentelemetry.AttributeName; +import com.microsoft.identity.common.java.opentelemetry.SpanExtension; import com.microsoft.identity.common.java.providers.RawAuthorizationResult; import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationErrorResponse; 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.AuthorizationResultFactory; import com.microsoft.identity.common.java.providers.oauth2.AuthorizationStatus; +import com.microsoft.identity.common.java.telemetry.ClientDataInfo; +import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import io.opentelemetry.api.trace.Span; import lombok.NonNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import static com.microsoft.identity.common.java.providers.Constants.BROKER_INSTALLATION_REQUIRED_WEBVIEW_REDIRECT_URI; import static com.microsoft.identity.common.java.providers.Constants.CANCEL_RESPONSE_REDIRECT_URI; import static com.microsoft.identity.common.java.providers.Constants.MOCK_AUTH_CODE_AND_STATE; @@ -64,6 +80,11 @@ public void setUp() { mAuthorizationResultFactory = new MicrosoftStsAuthorizationResultFactory(); } + @After + public void tearDown() { + CommonFlightsManager.INSTANCE.resetFlightsManager(); + } + private MicrosoftStsAuthorizationRequest getMstsAuthorizationRequest() { return new MicrosoftStsAuthorizationRequest.Builder().setState(MOCK_STATE).build(); } @@ -279,4 +300,68 @@ public void testUrlWithInvalidAuthCodeAndFragmentParas() { assertEquals(errorResponse.getError(), MicrosoftAuthorizationErrorResponse.AUTHORIZATION_FAILED); assertEquals(errorResponse.getErrorDescription(), MicrosoftAuthorizationErrorResponse.AUTHORIZATION_SERVER_INVALID_RESPONSE); } + + @Test + public void testClientDataParam_attributesEmitted() { + // 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 AuthorizationResult result = mAuthorizationResultFactory.createAuthorizationResult( + RawAuthorizationResult.fromRedirectUri(redirectUrl), getMstsAuthorizationRequest()); + + assertNotNull(result); + assertEquals(AuthorizationStatus.SUCCESS, result.getAuthorizationStatus()); + verify(mockSpan).setAttribute(AttributeName.server_error.name(), "AADSTS50058"); + verify(mockSpan).setAttribute(AttributeName.account_type.name(), "MSA"); + } + } + + @Test + public void testClientDataParam_noClientDataParam_doesNotCrash() { + final String redirectUrl = MOCK_REDIRECT_URI + + "?code=auth_code&state=" + MOCK_STATE_ENCODED; + + // Verify no exception is thrown even when clientdata parameter is absent + final AuthorizationResult result = mAuthorizationResultFactory.createAuthorizationResult( + RawAuthorizationResult.fromRedirectUri(redirectUrl), getMstsAuthorizationRequest()); + + assertNotNull(result); + assertEquals(AuthorizationStatus.SUCCESS, result.getAuthorizationStatus()); + } + + @Test + public void testClientDataParam_flightDisabled_attributesNotEmitted() { + final MockFlightsProvider provider = new MockFlightsProvider(); + provider.addFlight(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY.getKey(), "false"); + final MockFlightsManager manager = new MockFlightsManager(); + manager.setMockBrokerFlightsProvider(provider); + CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(manager); + + 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 AuthorizationResult result = mAuthorizationResultFactory.createAuthorizationResult( + RawAuthorizationResult.fromRedirectUri(redirectUrl), getMstsAuthorizationRequest()); + + assertNotNull(result); + assertEquals(AuthorizationStatus.SUCCESS, result.getAuthorizationStatus()); + Mockito.verify(mockSpan, Mockito.never()).setAttribute( + AttributeName.server_error.name(), "AADSTS50058"); + } + } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsTokenResponseHandlerTest.java b/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsTokenResponseHandlerTest.java index a22e767bca..0f440f0161 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsTokenResponseHandlerTest.java +++ b/common4j/src/test/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsTokenResponseHandlerTest.java @@ -22,21 +22,37 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.providers.microsoft.microsoftsts; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.MockFlightsManager; +import com.microsoft.identity.common.java.flighting.MockFlightsProvider; +import com.microsoft.identity.common.java.net.HttpConstants; 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.MicrosoftTokenErrorResponse; import com.microsoft.identity.common.java.providers.oauth2.TokenResult; +import org.junit.After; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import java.util.Collections; import java.util.HashMap; import java.util.List; +import io.opentelemetry.api.trace.Span; + import lombok.SneakyThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + /** * Tests for {@link MicrosoftStsTokenResponseHandler} */ @@ -53,6 +69,11 @@ public class MicrosoftStsTokenResponseHandlerTest { "\t\"client_info\": \"2245f73e-287a-41c4-ba87-560809ad06b9\"\n" + "}"; + @After + public void tearDown() { + CommonFlightsManager.INSTANCE.resetFlightsManager(); + } + @SneakyThrows @Test public void testHandleTokenResponse_Success() { @@ -88,4 +109,82 @@ public void testHandleTokenResponse_Error() { Assert.assertEquals(400, errorResponse.getStatusCode()); Assert.assertEquals("Bad Request", errorResponse.getResponseBody()); } + + @SneakyThrows + @Test + public void testHandleTokenResponse_withClientDataHeader_attributesEmitted() { + // Header value is a pipe-delimited string: account_type|error|sub_error|caller_data_boundary|cloud_instance + final String clientDataHeader = "e|AADSTS50058|login_required|us|public"; + + final HashMap> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json; charset=utf-8")); + headers.put(HttpConstants.HeaderField.X_MS_CLIENTDATA, + Collections.singletonList(clientDataHeader)); + + final HttpResponse response = new HttpResponse(200, MOCK_TOKEN_SUCCESS_RESPONSE, headers); + final MicrosoftStsTokenResponseHandler handler = new MicrosoftStsTokenResponseHandler(); + + 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 TokenResult tokenResult = handler.handleTokenResponse(response); + + Assert.assertNotNull(tokenResult); + Assert.assertTrue(tokenResult.getSuccess()); + verify(mockSpan).setAttribute(AttributeName.server_error.name(), "AADSTS50058"); + verify(mockSpan).setAttribute(AttributeName.account_type.name(), "AAD"); + } + } + + @SneakyThrows + @Test + public void testHandleTokenResponse_noClientDataHeader_doesNotCrash() { + final HashMap> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json; charset=utf-8")); + + final HttpResponse response = new HttpResponse(200, MOCK_TOKEN_SUCCESS_RESPONSE, headers); + final MicrosoftStsTokenResponseHandler handler = new MicrosoftStsTokenResponseHandler(); + + final TokenResult tokenResult = handler.handleTokenResponse(response); + + Assert.assertNotNull(tokenResult); + Assert.assertTrue(tokenResult.getSuccess()); + } + + @SneakyThrows + @Test + public void testHandleTokenResponse_flightDisabled_attributesNotEmitted() { + final MockFlightsProvider provider = new MockFlightsProvider(); + provider.addFlight(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY.getKey(), "false"); + final MockFlightsManager manager = new MockFlightsManager(); + manager.setMockBrokerFlightsProvider(provider); + CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(manager); + + final String clientDataHeader = "e|AADSTS50058|login_required|us|public"; + final HashMap> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList("application/json; charset=utf-8")); + headers.put(HttpConstants.HeaderField.X_MS_CLIENTDATA, + Collections.singletonList(clientDataHeader)); + + final HttpResponse response = new HttpResponse(200, MOCK_TOKEN_SUCCESS_RESPONSE, headers); + final MicrosoftStsTokenResponseHandler handler = new MicrosoftStsTokenResponseHandler(); + + 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 TokenResult tokenResult = handler.handleTokenResponse(response); + + Assert.assertNotNull(tokenResult); + Assert.assertTrue(tokenResult.getSuccess()); + Mockito.verify(mockSpan, Mockito.never()).setAttribute( + AttributeName.server_error.name(), "AADSTS50058"); + } + } + } 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 new file mode 100644 index 0000000000..7b4e0f37a4 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/telemetry/ClientDataInfoTest.java @@ -0,0 +1,226 @@ +// 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.telemetry; + +import com.microsoft.identity.common.java.opentelemetry.AttributeName; +import com.microsoft.identity.common.java.opentelemetry.SpanExtension; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import io.opentelemetry.api.trace.Span; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link ClientDataInfo}. + */ +@RunWith(JUnit4.class) +public class ClientDataInfoTest { + + // ------------------------------------------------------------------------- + // fromPipeDelimited() tests + // ------------------------------------------------------------------------- + + @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"); + + assertNotNull(info); + assertEquals("m", info.getAccountType()); + assertEquals("AADSTS50058", info.getError()); + assertEquals("login_required", info.getSubError()); + assertEquals("us", info.getCallerDataBoundary()); + assertEquals("public", info.getCloudInstance()); + } + + @Test + public void fromPipeDelimited_threeSegments_firstThreeFieldsSet() { + final ClientDataInfo info = ClientDataInfo.fromPipeDelimited("e|AADSTS65001|consent_required"); + + assertNotNull(info); + assertEquals("e", info.getAccountType()); + assertEquals("AADSTS65001", info.getError()); + assertEquals("consent_required", info.getSubError()); + assertNull(info.getCallerDataBoundary()); + assertNull(info.getCloudInstance()); + } + + @Test + public void fromPipeDelimited_fewerThanThreeSegments_returnsNull() { + assertNull(ClientDataInfo.fromPipeDelimited("m|AADSTS50058")); + } + + @Test + public void fromPipeDelimited_emptySegments_fieldsAreNull() { + // Three empty segments – meets minimum count but values are empty → null + final ClientDataInfo info = ClientDataInfo.fromPipeDelimited("||"); + + assertNotNull(info); + assertNull(info.getAccountType()); + assertNull(info.getError()); + assertNull(info.getSubError()); + } + + @Test + public void fromPipeDelimited_nullInput_returnsNull() { + assertNull(ClientDataInfo.fromPipeDelimited(null)); + } + + @Test + public void fromPipeDelimited_emptyString_returnsNull() { + assertNull(ClientDataInfo.fromPipeDelimited("")); + } + + // ------------------------------------------------------------------------- + // emitToSpan() tests + // ------------------------------------------------------------------------- + + @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"); + + 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); + + info.emitToSpan(); + + verify(mockSpan).setAttribute(AttributeName.server_error.name(), "AADSTS50058"); + verify(mockSpan).setAttribute(AttributeName.server_sub_error.name(), "login_required"); + verify(mockSpan).setAttribute(AttributeName.account_type.name(), "MSA"); + verify(mockSpan).setAttribute(AttributeName.server_cloud_instance.name(), "public"); + verify(mockSpan).setAttribute(AttributeName.server_caller_data_boundary.name(), "us"); + } + } + + @Test + public void emitToSpan_someFieldsNull_nullFieldsNotEmitted() { + final ClientDataInfo info = new ClientDataInfo(); + info.setError("AADSTS50058"); + // subError, accountType, cloudInstance, callerDataBoundary all null + + 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); + + info.emitToSpan(); + + verify(mockSpan).setAttribute(AttributeName.server_error.name(), "AADSTS50058"); + verify(mockSpan, never()).setAttribute( + Mockito.eq(AttributeName.server_sub_error.name()), Mockito.anyString()); + verify(mockSpan, never()).setAttribute( + Mockito.eq(AttributeName.account_type.name()), Mockito.anyString()); + verify(mockSpan, never()).setAttribute( + Mockito.eq(AttributeName.server_cloud_instance.name()), Mockito.anyString()); + verify(mockSpan, never()).setAttribute( + Mockito.eq(AttributeName.server_caller_data_boundary.name()), Mockito.anyString()); + } + } + + @Test + public void emitToSpan_accountTypeMsa_mappedToMSA() { + final ClientDataInfo info = new ClientDataInfo(); + info.setAccountType("m"); + + 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); + + info.emitToSpan(); + + verify(mockSpan).setAttribute(AttributeName.account_type.name(), "MSA"); + } + } + + @Test + public void emitToSpan_accountTypeAad_mappedToAAD() { + final ClientDataInfo info = new ClientDataInfo(); + info.setAccountType("e"); + + 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); + + info.emitToSpan(); + + verify(mockSpan).setAttribute(AttributeName.account_type.name(), "AAD"); + } + } + + // ------------------------------------------------------------------------- + // Field truncation test + // ------------------------------------------------------------------------- + + @Test + public void emitToSpan_fieldExceeds256Chars_truncatedTo256() { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 300; i++) { + sb.append('A'); + } + final String longValue = sb.toString(); + final StringBuilder expected256 = new StringBuilder(); + for (int i = 0; i < 256; i++) { + expected256.append('A'); + } + + final ClientDataInfo info = new ClientDataInfo(); + info.setError(longValue); + + 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); + + info.emitToSpan(); + + verify(mockSpan).setAttribute( + AttributeName.server_error.name(), + expected256.toString()); + } + } +}