Skip to content

Commit 3b53d1a

Browse files
fadidurahCopilotCopilot
authored
Wire Server Telemetry response header, Fixes AB#3556246 (#3062)
ESTS will begin sending telemetry data in a response, which we will be emitting in broker telemetry. Here's the spec: https://microsoft.sharepoint-df.com/:w:/s/AuthSTSDocs/IQDIrGUMsdxVSZE3GlsMkqaBAXVzDyEJg2wCZk1JN_DeSF0?e=teY6EN&wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=72f988bf-86f1-41af-91ab-2d7cd011db47%2Cfadidurah%40microsoft.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjAzMTIyMzAwNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D **Changes:** - Introduces `ClientDataInfo` to parse pipe-delimited `x-ms-clientdata` header (/token) and `clientdata` redirect query parameter (/authorize) values, and emit them as OpenTelemetry span attributes. - Wires `clidata=1` into the authorization request and parses/emits telemetry on both authorize redirects and token responses. - All three call sites are gated behind a new `ENABLE_SERVER_CLIENT_DATA_TELEMETRY` flight (`CommonFlight`), which is **enabled by default** and can be turned off via ECS if issues arise in production. - Promotes `"clientdata"` to a shared constant (`ClientDataInfo.CLIENTDATA_QUERY_PARAMETER`) used at all reference sites. - Extends OpenTelemetry `AttributeName` with new server telemetry attributes (`server_error`, `server_sub_error`, `server_cloud_instance`, `server_caller_data_boundary`); reuses existing `account_type`. - Unit tests cover parsing, attribute emission, and flight-disabled behavior for all three call sites. <a href="https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3556246">[AB#3556246](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3556246)</a> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent cd914e4 commit 3b53d1a

11 files changed

Lines changed: 704 additions & 5 deletions

File tree

common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,14 @@ public enum CommonFlight implements IFlightConfig {
244244
* This provides a more granular enabled check that distinguishes
245245
* DISABLED_USER, DISABLED_UNTIL_USED, etc.
246246
*/
247-
USE_ENABLED_SETTING_FOR_PACKAGE_CHECK("UseEnabledSettingForPackageCheck", false);
247+
USE_ENABLED_SETTING_FOR_PACKAGE_CHECK("UseEnabledSettingForPackageCheck", false),
248+
249+
/**
250+
* Flight to enable server-side client data telemetry from the x-ms-clientdata response
251+
* header (/token endpoint) and the clientdata redirect query parameter (/authorize endpoint).
252+
* Enabled by default; can be turned off via ECS if any issues arise in production.
253+
*/
254+
ENABLE_SERVER_CLIENT_DATA_TELEMETRY("EnableServerClientDataTelemetry", true);
248255

249256
private String key;
250257
private Object defaultValue;

common4j/src/main/com/microsoft/identity/common/java/net/HttpConstants.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ public static final class HeaderField {
5858
* Header to track if Cached Credential Service (CCS) request sequence
5959
*/
6060
public static final String XMS_CCS_REQUEST_SEQUENCE = "x-ms-srs";
61+
62+
/**
63+
* Header carrying server-side telemetry data (error codes, account type, cloud instance,
64+
* data boundary) returned by eSTS and MSA on /token responses.
65+
*/
66+
public static final String X_MS_CLIENTDATA = "x-ms-clientdata";
6167
}
6268

6369
/**

common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ public enum AttributeName {
147147
* The content type of the response returned by eSTS for the request.
148148
*/
149149
response_content_type,
150+
150151
/**
151152
* The http status code of the operation.
152153
*/
@@ -665,4 +666,32 @@ public enum AttributeName {
665666
target_blank_navigation_route,
666667

667668
//endregion
669+
670+
//region x-ms-clientdata server telemetry attributes
671+
672+
/**
673+
* The server-side error code returned in the x-ms-clientdata header or clientdata
674+
* query parameter from eSTS / MSA.
675+
*/
676+
server_error,
677+
678+
/**
679+
* The server-side sub-error code returned in the x-ms-clientdata header or clientdata
680+
* query parameter from eSTS / MSA.
681+
*/
682+
server_sub_error,
683+
684+
/**
685+
* The cloud instance returned in the x-ms-clientdata header or clientdata query
686+
* parameter from eSTS / MSA (e.g. "public", "usgov").
687+
*/
688+
server_cloud_instance,
689+
690+
/**
691+
* The caller data boundary returned in the x-ms-clientdata header or clientdata
692+
* query parameter from eSTS / MSA, indicating the data residency boundary.
693+
*/
694+
server_caller_data_boundary,
695+
696+
//endregion
668697
}

common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/AbstractMicrosoftStsTokenResponseHandler.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import static com.microsoft.identity.common.java.net.HttpConstants.HeaderField.XMS_CCS_REQUEST_ID;
2626
import static com.microsoft.identity.common.java.net.HttpConstants.HeaderField.XMS_CCS_REQUEST_SEQUENCE;
2727
import static com.microsoft.identity.common.java.net.HttpConstants.HeaderField.X_MS_CLITELEM;
28+
import static com.microsoft.identity.common.java.net.HttpConstants.HeaderField.X_MS_CLIENTDATA;
2829

2930
import com.google.gson.JsonParseException;
3031
import com.microsoft.identity.common.java.exception.ClientException;
@@ -38,6 +39,7 @@
3839
import com.microsoft.identity.common.java.providers.oauth2.ITokenResponseHandler;
3940
import com.microsoft.identity.common.java.providers.oauth2.TokenResult;
4041
import com.microsoft.identity.common.java.telemetry.CliTelemInfo;
42+
import com.microsoft.identity.common.java.telemetry.ClientDataInfo;
4143
import com.microsoft.identity.common.java.util.HeaderSerializationUtil;
4244
import com.microsoft.identity.common.java.util.ObjectMapper;
4345
import com.microsoft.identity.common.java.util.ResultUtil;
@@ -106,6 +108,14 @@ public TokenResult handleTokenResponse(@NonNull final HttpResponse response) thr
106108
}
107109
}
108110

111+
final String clientDataHeader = response.getHeaderValue(X_MS_CLIENTDATA, 0);
112+
if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) {
113+
final ClientDataInfo clientDataInfo = ClientDataInfo.fromPipeDelimited(clientDataHeader);
114+
if (null != clientDataInfo) {
115+
clientDataInfo.emitToSpan();
116+
}
117+
}
118+
109119
final Map<String, String> mapWithAdditionalEntry = new HashMap<String, String>();
110120

111121
final String ccsRequestId = response.getHeaderValue(XMS_CCS_REQUEST_ID, 0);

common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationRequest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
import com.google.gson.annotations.SerializedName;
2727
import com.microsoft.identity.common.java.exception.ClientException;
2828
import com.microsoft.identity.common.java.logging.Logger;
29+
import com.microsoft.identity.common.java.flighting.CommonFlight;
30+
import com.microsoft.identity.common.java.flighting.CommonFlightsManager;
2931
import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationRequest;
3032
import com.microsoft.identity.common.java.providers.microsoft.azureactivedirectory.AzureActiveDirectorySlice;
3133
import com.microsoft.identity.common.java.providers.oauth2.OpenIdProviderConfiguration;
@@ -156,6 +158,13 @@ public static final class Prompt {
156158
*/
157159
public static final String HIDE_SWITCH_USER_QUERY_PARAMETER = "hsu";
158160

161+
/**
162+
* When clidata=1 is passed, eSTS/MSA includes a {@code clientdata} query parameter in the
163+
* authorize redirect URI containing server-side telemetry (error, sub-error, account type,
164+
* cloud instance, data boundary).
165+
*/
166+
public static final String CLIDATA_QUERY_PARAMETER = "clidata";
167+
159168
/**
160169
* Store the openIdConfiguration passed as part of the builder.
161170
* This will be used to fetch the authorization endpoint from OpenID Configuration rather than
@@ -286,6 +295,11 @@ public URI getAuthorizationRequestAsHttpRequest() throws ClientException {
286295
builder.addParameterIfAbsent(HIDE_SWITCH_USER_QUERY_PARAMETER, "1");
287296
}
288297

298+
// Request server-side telemetry in the clientdata redirect query parameter.
299+
if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) {
300+
builder.addParameterIfAbsent(CLIDATA_QUERY_PARAMETER, "1");
301+
}
302+
289303
try {
290304
return builder.build();
291305
} catch (final URISyntaxException e) {

common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/MicrosoftStsAuthorizationResultFactory.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,12 @@
2727

2828
import com.microsoft.identity.common.java.exception.ErrorStrings;
2929
import com.microsoft.identity.common.java.logging.Logger;
30+
import com.microsoft.identity.common.java.flighting.CommonFlight;
31+
import com.microsoft.identity.common.java.flighting.CommonFlightsManager;
3032
import com.microsoft.identity.common.java.providers.microsoft.MicrosoftAuthorizationErrorResponse;
3133
import com.microsoft.identity.common.java.providers.oauth2.AuthorizationResultFactory;
3234
import com.microsoft.identity.common.java.providers.oauth2.AuthorizationStatus;
35+
import com.microsoft.identity.common.java.telemetry.ClientDataInfo;
3336
import com.microsoft.identity.common.java.util.StringUtil;
3437
import com.microsoft.identity.common.java.util.UrlUtil;
3538

@@ -72,6 +75,13 @@ protected MicrosoftStsAuthorizationResult parseRedirectUriAndCreateAuthorization
7275

7376
final Map<String, String> urlParameters = UrlUtil.getParameters(redirectUri);
7477

78+
if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) {
79+
final ClientDataInfo clientDataInfo = ClientDataInfo.fromPipeDelimited(urlParameters.get(ClientDataInfo.CLIENTDATA_QUERY_PARAMETER));
80+
if (null != clientDataInfo) {
81+
clientDataInfo.emitToSpan();
82+
}
83+
}
84+
7585
MicrosoftStsAuthorizationResult result;
7686
if (urlParameters.isEmpty()) {
7787
Logger.warn(methodTag, "Invalid server response, empty query string from the webview redirect.");
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// All rights reserved.
3+
//
4+
// This code is licensed under the MIT License.
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files(the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions :
12+
//
13+
// The above copyright notice and this permission notice shall be included in
14+
// all copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
// THE SOFTWARE.
23+
package com.microsoft.identity.common.java.telemetry;
24+
25+
import com.microsoft.identity.common.java.logging.Logger;
26+
import com.microsoft.identity.common.java.opentelemetry.AttributeName;
27+
import com.microsoft.identity.common.java.opentelemetry.SpanExtension;
28+
import com.microsoft.identity.common.java.util.StringUtil;
29+
30+
import edu.umd.cs.findbugs.annotations.Nullable;
31+
import io.opentelemetry.api.trace.Span;
32+
import lombok.Getter;
33+
import lombok.Setter;
34+
import lombok.experimental.Accessors;
35+
36+
/**
37+
* Model representing server telemetry data from the x-ms-clientdata response header
38+
* (/token responses) and the clientdata query parameter (/authorize redirect URLs).
39+
* Both use a pipe-delimited format: account_type|error|sub_error|caller_data_boundary|cloud_instance.
40+
* Contains server-side error codes, account type, cloud instance, and data boundary info.
41+
*/
42+
@Getter
43+
@Setter
44+
@Accessors(prefix = "m")
45+
public class ClientDataInfo {
46+
47+
private static final String TAG = ClientDataInfo.class.getSimpleName();
48+
49+
/** Maximum length for any individual field when emitting to a span. */
50+
private static final int MAX_FIELD_LENGTH = 256;
51+
52+
/**
53+
* The name of the {@code clientdata} query parameter added to /authorize redirect URIs
54+
* by eSTS/MSA when {@code clidata=1} is included in the authorization request.
55+
* Use this constant everywhere the parameter name is referenced to avoid typos.
56+
*/
57+
public static final String CLIENTDATA_QUERY_PARAMETER = "clientdata";
58+
59+
/** Account type value for MSA accounts. */
60+
private static final String ACCOUNT_TYPE_MSA_RAW = "m";
61+
62+
/** Account type value for AAD accounts. */
63+
private static final String ACCOUNT_TYPE_AAD_RAW = "e";
64+
65+
/** Display value for MSA account type. */
66+
private static final String ACCOUNT_TYPE_MSA = "MSA";
67+
68+
/** Display value for AAD account type. */
69+
private static final String ACCOUNT_TYPE_AAD = "AAD";
70+
71+
/**
72+
* Positional index for account_type in the pipe-delimited format:
73+
* account_type|error|sub_error|caller_data_boundary|cloud_instance
74+
*/
75+
private static final int PIPE_INDEX_ACCOUNT_TYPE = 0;
76+
private static final int PIPE_INDEX_ERROR = 1;
77+
private static final int PIPE_INDEX_SUB_ERROR = 2;
78+
private static final int PIPE_INDEX_CALLER_DATA_BOUNDARY = 3;
79+
private static final int PIPE_INDEX_CLOUD_INSTANCE = 4;
80+
private static final int PIPE_MIN_SEGMENTS = 3;
81+
82+
private String mError;
83+
private String mSubError;
84+
private String mAccountType;
85+
private String mCloudInstance;
86+
private String mCallerDataBoundary;
87+
88+
/**
89+
* Parses an already-decoded pipe-delimited clientdata query parameter value.
90+
* The caller is responsible for URL-decoding before passing (e.g. values from
91+
* {@link com.microsoft.identity.common.java.util.UrlUtil#getParameters} are
92+
* already decoded). Decoding twice would corrupt values containing '+' or '%'.
93+
* Positional format: account_type|error|sub_error|caller_data_boundary|cloud_instance.
94+
* Requires at least 3 segments.
95+
*
96+
* @param decodedValue already-decoded pipe-delimited string, may be null.
97+
* @return parsed {@link ClientDataInfo}, or null on failure/empty input.
98+
*/
99+
@Nullable
100+
public static ClientDataInfo fromPipeDelimited(@Nullable final String decodedValue) {
101+
if (StringUtil.isNullOrEmpty(decodedValue)) {
102+
return null;
103+
}
104+
try {
105+
final String[] segments = decodedValue.split("\\|", -1);
106+
107+
if (segments.length < PIPE_MIN_SEGMENTS) {
108+
Logger.warn(TAG, "clientdata pipe-delimited value has fewer than " + PIPE_MIN_SEGMENTS + " segments.");
109+
return null;
110+
}
111+
112+
final ClientDataInfo info = new ClientDataInfo();
113+
info.mAccountType = emptyToNull(segments[PIPE_INDEX_ACCOUNT_TYPE]);
114+
info.mError = emptyToNull(segments[PIPE_INDEX_ERROR]);
115+
info.mSubError = emptyToNull(segments[PIPE_INDEX_SUB_ERROR]);
116+
info.mCallerDataBoundary = segments.length > PIPE_INDEX_CALLER_DATA_BOUNDARY
117+
? emptyToNull(segments[PIPE_INDEX_CALLER_DATA_BOUNDARY]) : null;
118+
info.mCloudInstance = segments.length > PIPE_INDEX_CLOUD_INSTANCE
119+
? emptyToNull(segments[PIPE_INDEX_CLOUD_INSTANCE]) : null;
120+
return info;
121+
} catch (final Exception e) {
122+
Logger.warn(TAG, "Failed to parse clientdata pipe-delimited value: " + e.getMessage());
123+
return null;
124+
}
125+
}
126+
127+
/**
128+
* Sets each non-null field as a span attribute on the current span via {@link SpanExtension}.
129+
* account_type values are mapped: "m" -> "MSA", "e" -> "AAD".
130+
* Each field is truncated to {@value #MAX_FIELD_LENGTH} characters.
131+
*/
132+
public void emitToSpan() {
133+
final Span span = SpanExtension.current();
134+
if (mError != null) {
135+
span.setAttribute(AttributeName.server_error.name(), truncate(mError));
136+
}
137+
if (mSubError != null) {
138+
span.setAttribute(AttributeName.server_sub_error.name(), truncate(mSubError));
139+
}
140+
if (mAccountType != null) {
141+
// account_type is an existing AttributeName; reuse it (m -> MSA, e -> AAD).
142+
final String mappedAccountType = mapAccountType(mAccountType);
143+
span.setAttribute(AttributeName.account_type.name(), truncate(mappedAccountType));
144+
}
145+
if (mCloudInstance != null) {
146+
span.setAttribute(AttributeName.server_cloud_instance.name(), truncate(mCloudInstance));
147+
}
148+
if (mCallerDataBoundary != null) {
149+
span.setAttribute(AttributeName.server_caller_data_boundary.name(), truncate(mCallerDataBoundary));
150+
}
151+
}
152+
153+
private static String mapAccountType(final String raw) {
154+
if (ACCOUNT_TYPE_MSA_RAW.equalsIgnoreCase(raw)) {
155+
return ACCOUNT_TYPE_MSA;
156+
} else if (ACCOUNT_TYPE_AAD_RAW.equalsIgnoreCase(raw)) {
157+
return ACCOUNT_TYPE_AAD;
158+
}
159+
Logger.warn(TAG, "Unknown account_type value in clientdata; emitting as UNKNOWN.");
160+
return "UNKNOWN";
161+
}
162+
163+
@Nullable
164+
private static String truncate(@Nullable final String value) {
165+
if (value == null || value.length() <= MAX_FIELD_LENGTH) {
166+
return value;
167+
}
168+
return value.substring(0, MAX_FIELD_LENGTH);
169+
}
170+
171+
@Nullable
172+
private static String emptyToNull(final String value) {
173+
return StringUtil.isNullOrEmpty(value) ? null : value;
174+
}
175+
}

0 commit comments

Comments
 (0)