diff --git a/changelog.txt b/changelog.txt index 9cf741dc94..232b742558 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ vNext ---------- +- [MINOR] Add OnboardingBlockingErrorParser bridging x-ms-clitelem and OAuth error_codes into the onboarding telemetry blob, with non-onboarding AADSTS filtering and a multi-value Path B overload (#3122) - [PATCH] Improve switch browser error handling and telemetry: standardize result delivery via setResultAndFinish, add error bundles for all failure paths, fix span/scope lifecycle, and move RESUME_REQUEST to shared SWITCH_BROWSER constants (#3134) - [PATCH] Fix incorrect Chrome Dev (com.chrome.dev) signing certificate thumbprint in AppRegistry (#3153) - [MINOR] Add IS_SWITCH_BROWSER_FLOW flag to PropertyBag so OneAuth can detect when the switch browser protocol was used during interactive auth (#3145) diff --git a/common4j/src/main/com/microsoft/identity/common/java/telemetry/OnboardingBlockingErrorParser.kt b/common4j/src/main/com/microsoft/identity/common/java/telemetry/OnboardingBlockingErrorParser.kt new file mode 100644 index 0000000000..3864c59efc --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/telemetry/OnboardingBlockingErrorParser.kt @@ -0,0 +1,158 @@ +// 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.providers.microsoft.MicrosoftTokenResponse + +/** + * Bridges eSTS-emitted error codes (carried via the `x-ms-clitelem` header and parsed + * into [MicrosoftTokenResponse] by [com.microsoft.identity.common.java.providers.microsoft.microsoftsts.AbstractMicrosoftStsTokenResponseHandler]) + * into the onboarding telemetry blob's `last_blocking_error` / `blocking_errors` fields + * (see [OnboardingTelemetryConstants.LAST_BLOCKING_ERROR] / [OnboardingTelemetryConstants.BLOCKING_ERRORS]). + * + * Callers (OneAuth navigation fragment, broker error handler, etc.) invoke + * [extractBlockingError] on the [MicrosoftTokenResponse] of a failed token request + * to obtain a string suitable for + * [com.microsoft.identity.common.internal.telemetry.OnboardingTelemetryRecorder.addBlockingError]. + * + * Returns null when the response carries no error or the error is not blocking. + * + * Per design spec (Mobile Onboarding Telemetry §10), the `x-ms-clitelem` header + * supplies both an `errorCode` (position 2) and a `subErrorCode` (position 3). + * This parser prefers the sub-error code when present (it is the most specific + * attribution signal) and falls back to the server error code; both are surfaced + * to the onboarding blob via `addBlockingError`. + */ +object OnboardingBlockingErrorParser { + + /** + * AADSTS error codes that look like blocking errors syntactically (5-digit + * server error codes from eSTS) but are NOT onboarding-remediation signals. + * These flow through the parser the same as any other server error, so we + * filter them here at the policy boundary so callers don't have to. + * + * - 50058 UserInformationNotProvided (no SSO session — user just needs to sign in) + * - 50097 DeviceAuthenticationRequired (in-flow device auth challenge; if WPJ runs we + * already record DeviceRegistrationStarted as a step) + * - 50126 InvalidUserNameOrPassword (wrong credentials — user error) + */ + private val NON_ONBOARDING_AADSTS_CODES = setOf("50058", "50097", "50126") + + /** + * Returns true if the candidate error code should be excluded from the + * onboarding blob's `blocking_errors[]`. See [NON_ONBOARDING_AADSTS_CODES]. + */ + private fun isExcluded(candidate: String): Boolean = candidate in NON_ONBOARDING_AADSTS_CODES + + /** + * Extract a blocking-error attribution string from a [MicrosoftTokenResponse]. + * + * Returns the most specific available identifier: + * 1. `serverSubErrorCode` (e.g. `interaction_required`) if present and non-zero, else + * 2. `serverErrorCode` (e.g. `65001`) if present and non-zero, else + * 3. `null` (no blocking error to record). + * + * Position-2 of the `x-ms-clitelem` header is `0` when there is no error — those + * cases are filtered out so callers don't pollute the blob with `"0"`. + * + * Codes in [NON_ONBOARDING_AADSTS_CODES] are also filtered out as they are not + * onboarding-remediation signals. + * + * @return blocking error identifier suitable for `addBlockingError(...)`, or null + */ + @JvmStatic + fun extractBlockingError(tokenResponse: MicrosoftTokenResponse?): String? { + if (tokenResponse == null) return null + + val subError = tokenResponse.cliTelemSubErrorCode + if (!subError.isNullOrBlank() && subError != "0" && !isExcluded(subError)) { + return subError + } + + val error = tokenResponse.cliTelemErrorCode + if (!error.isNullOrBlank() && error != "0" && !isExcluded(error)) { + return error + } + + return null + } + + /** + * Convenience overload that parses a raw `x-ms-clitelem` header string directly. + * Useful when the caller does not have a [MicrosoftTokenResponse] in hand + * (e.g. parsing a redirect response in a WebView client). + * + * Codes in [NON_ONBOARDING_AADSTS_CODES] are filtered out the same as in the + * [MicrosoftTokenResponse] overload. + * + * @return blocking error identifier suitable for `addBlockingError(...)`, or null + */ + @JvmStatic + fun extractBlockingError(xMsCliTelemHeader: String?): String? { + if (xMsCliTelemHeader.isNullOrBlank()) return null + + @Suppress("DEPRECATION") + val cliTelemInfo = CliTelemInfo.fromXMsCliTelemHeader(xMsCliTelemHeader) ?: return null + + val subError = cliTelemInfo.serverSubErrorCode + if (!subError.isNullOrBlank() && subError != "0" && !isExcluded(subError)) { + return subError + } + + val error = cliTelemInfo.serverErrorCode + if (!error.isNullOrBlank() && error != "0" && !isExcluded(error)) { + return error + } + + return null + } + + /** + * Extract blocking-error attribution codes from the OAuth `error_codes` query parameter + * of an authorization redirect (Microsoft extension to OAuth — comma-separated AADSTS codes, + * available on `MicrosoftStsAuthorizationErrorResponse.getErrorCodes()`). + * + * Unlike the single-value overloads (which return the most-specific identifier from the + * `x-ms-clitelem` header), eSTS frequently emits multiple AADSTS codes here when one + * authorization failure has multiple contributing causes (e.g. `"50058,53003"` = + * "no SSO session AND CA-blocked"). Returning all qualified codes lets callers add + * each via [com.microsoft.identity.common.internal.telemetry.OnboardingTelemetryRecorder.addBlockingError] + * without losing attribution detail; the schema's `blocking_errors[]` is already an array. + * + * Filters out: + * - empty entries (e.g. trailing commas) + * - the literal `"0"` (eSTS's "no error" sentinel) + * - codes in [NON_ONBOARDING_AADSTS_CODES] + * - duplicates (preserves first-occurrence order) + * + * @return ordered list of qualifying AADSTS codes; empty if none qualify + */ + @JvmStatic + fun extractBlockingErrorsFromAuthorizationErrorCodes(errorCodes: String?): List { + if (errorCodes.isNullOrBlank()) return emptyList() + return errorCodes.split(",") + .map { it.trim() } + .filter { it.isNotEmpty() && it != "0" && !isExcluded(it) } + .distinct() + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/telemetry/OnboardingBlockingErrorParserTest.kt b/common4j/src/test/com/microsoft/identity/common/java/telemetry/OnboardingBlockingErrorParserTest.kt new file mode 100644 index 0000000000..d9e5cc51f7 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/telemetry/OnboardingBlockingErrorParserTest.kt @@ -0,0 +1,243 @@ +// 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.providers.microsoft.MicrosoftTokenResponse +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class OnboardingBlockingErrorParserTest { + + // --- MicrosoftTokenResponse overload --- + + @Test + fun nullResponseReturnsNull() { + assertNull(OnboardingBlockingErrorParser.extractBlockingError(null as MicrosoftTokenResponse?)) + } + + @Test + fun emptyResponseReturnsNull() { + val response = MicrosoftTokenResponse() + assertNull(OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + @Test + fun zeroErrorCodeReturnsNull() { + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("0") + setCliTelemSubErrorCode("0") + } + assertNull(OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + @Test + fun subErrorPreferredOverError() { + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("65001") + setCliTelemSubErrorCode("interaction_required") + } + assertEquals("interaction_required", OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + @Test + fun errorReturnedWhenNoSubError() { + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("53000") + } + assertEquals("53000", OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + @Test + fun errorReturnedWhenSubErrorIsZero() { + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("53000") + setCliTelemSubErrorCode("0") + } + assertEquals("53000", OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + // --- Header string overload --- + + @Test + fun nullHeaderReturnsNull() { + assertNull(OnboardingBlockingErrorParser.extractBlockingError(null as String?)) + } + + @Test + fun blankHeaderReturnsNull() { + assertNull(OnboardingBlockingErrorParser.extractBlockingError("")) + } + + @Test + fun headerWithErrorReturnsErrorCode() { + // Format: version,errorCode,subErrorCode,timeSinceTokenIssuance,tokenRoutingHint + val header = "1,53000,0,1234,routinghint" + assertEquals("53000", OnboardingBlockingErrorParser.extractBlockingError(header)) + } + + @Test + fun headerWithZeroErrorReturnsNull() { + val header = "1,0,0,1234,routinghint" + assertNull(OnboardingBlockingErrorParser.extractBlockingError(header)) + } + + // --- Non-onboarding AADSTS code exclusion list --- + + @Test + fun excludedAadstsCode_50058_FilteredFromResponse() { + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("50058") // UserInformationNotProvided + } + assertNull(OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + @Test + fun excludedAadstsCode_50097_FilteredFromResponse() { + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("50097") // DeviceAuthenticationRequired + } + assertNull(OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + @Test + fun excludedAadstsCode_50126_FilteredFromResponse() { + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("50126") // InvalidUserNameOrPassword + } + assertNull(OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + @Test + fun excludedAadstsCode_AsSubError_AlsoFiltered() { + // Even when the excluded code is in the sub-error position, it is filtered. + // This also means the parser falls through to the (non-excluded) top-level error. + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("65001") + setCliTelemSubErrorCode("50126") + } + assertEquals("65001", OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + @Test + fun excludedAadstsCode_FilteredFromHeader() { + val header = "1,50058,0,1234,routinghint" + assertNull(OnboardingBlockingErrorParser.extractBlockingError(header)) + } + + @Test + fun nonExcludedAadstsCode_StillReturned() { + // Sanity check: 65001 is a real onboarding-related blocker (interaction_required-ish). + // It must still pass through the filter. + val response = MicrosoftTokenResponse().apply { + setCliTelemErrorCode("65001") + } + assertEquals("65001", OnboardingBlockingErrorParser.extractBlockingError(response)) + } + + // --- extractBlockingErrorsFromAuthorizationErrorCodes (Path B / OAuth error_codes) --- + + @Test + fun authzErrorCodes_NullReturnsEmpty() { + Assert.assertTrue( + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes(null).isEmpty() + ) + } + + @Test + fun authzErrorCodes_BlankReturnsEmpty() { + Assert.assertTrue( + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes("").isEmpty() + ) + Assert.assertTrue( + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes(" ").isEmpty() + ) + } + + @Test + fun authzErrorCodes_SingleCodeReturned() { + Assert.assertEquals( + listOf("53003"), + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes("53003") + ) + } + + @Test + fun authzErrorCodes_MultipleCodesAllReturnedInOrder() { + // eSTS commonly emits multiple codes when one failure has multiple causes. + Assert.assertEquals( + listOf("53003", "65001"), + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes("53003,65001") + ) + } + + @Test + fun authzErrorCodes_ExcludedCodesFilteredOut() { + // 50058 is in the non-onboarding exclusion list; 53003 is a real CA block. + Assert.assertEquals( + listOf("53003"), + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes("50058,53003") + ) + } + + @Test + fun authzErrorCodes_AllExcludedReturnsEmpty() { + Assert.assertTrue( + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes("50058,50097,50126").isEmpty() + ) + } + + @Test + fun authzErrorCodes_ZeroSentinelFilteredOut() { + Assert.assertEquals( + listOf("53003"), + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes("0,53003") + ) + } + + @Test + fun authzErrorCodes_EmptyEntriesFilteredOut() { + // Trailing/leading commas → empty entries → ignored. + Assert.assertEquals( + listOf("53003", "65001"), + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes(",53003,,65001,") + ) + } + + @Test + fun authzErrorCodes_WhitespaceTrimmed() { + Assert.assertEquals( + listOf("53003", "65001"), + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes(" 53003 , 65001 ") + ) + } + + @Test + fun authzErrorCodes_DuplicatesDeduped() { + Assert.assertEquals( + listOf("53003", "65001"), + OnboardingBlockingErrorParser.extractBlockingErrorsFromAuthorizationErrorCodes("53003,65001,53003") + ) + } +}