Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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,

Comment thread
fadidurah marked this conversation as resolved.
/**
* 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, String> mapWithAdditionalEntry = new HashMap<String, String>();

final String ccsRequestId = response.getHeaderValue(XMS_CCS_REQUEST_ID, 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -72,6 +75,13 @@ protected MicrosoftStsAuthorizationResult parseRedirectUriAndCreateAuthorization

final Map<String, String> 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.");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading