From 2749af0f692bb945b67248e593af6b11cd26b3ef Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Wed, 13 May 2026 17:40:12 +0100 Subject: [PATCH 01/18] Add NativeAuthRequestInterceptor for custom per-request headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement custom HTTP headers request interceptor for native auth CIAM requests, matching the iOS PR (microsoft-authentication-library-for-objc#2862). - Add NativeAuthRequestInterceptor interface for injecting custom x-* headers - Add NativeAuthHeaderValidator to enforce header naming rules - Pass interceptor through the full config propagation chain: OAuth2StrategyParameters → NativeAuthCIAMAuthority → NativeAuthOAuth2Configuration → NativeAuthOAuth2StrategyFactory → all 4 interactors (SignIn, SignUp, ResetPassword, JIT) - Add interceptor field to BaseNativeAuthCommandParameters - Wire interceptor in NativeAuthMsalController.createOAuth2Strategy() - Add unit tests for header validation (11 tests) - Add integration tests for interceptor in SignInInteractor (6 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- changelog.txt | 1 + .../controllers/NativeAuthMsalController.kt | 1 + .../authorities/NativeAuthCIAMAuthority.kt | 7 +- .../BaseNativeAuthCommandParameters.java | 8 + .../providers/NativeAuthHeaderValidator.kt | 77 +++++ .../NativeAuthOAuth2Configuration.kt | 1 + .../NativeAuthOAuth2StrategyFactory.kt | 13 +- .../providers/NativeAuthRequestInterceptor.kt | 46 +++ .../providers/interactors/JITInteractor.kt | 22 +- .../interactors/ResetPasswordInteractor.kt | 27 +- .../providers/interactors/SignInInteractor.kt | 25 +- .../providers/interactors/SignUpInteractor.kt | 22 +- .../oauth2/OAuth2StrategyParameters.java | 7 + .../NativeAuthHeaderValidatorTest.kt | 182 ++++++++++++ .../SignInInteractorRequestInterceptorTest.kt | 278 ++++++++++++++++++ 15 files changed, 691 insertions(+), 26 deletions(-) create mode 100644 common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt create mode 100644 common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt create mode 100644 common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt create mode 100644 common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt diff --git a/changelog.txt b/changelog.txt index b189ce0a82..dfecc2beae 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,6 +2,7 @@ vNext ---------- - [PATCH] Extend filter-then-clone optimization to load() and getIdTokensForAccountRecord() in MsalOAuth2TokenCache: when ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE flight is enabled, skip clone-all preload and call direct flight-gated overloads that clone only matching credentials; add new getCredentialsFilteredBy overload with kid support (#3100) - [PATCH] Move Multiple Listening apps check to the authorization layer (#3070) +- [MINOR] Add HttpRequest interceptor (#) Version 24.2.0 ---------- diff --git a/common/src/main/java/com/microsoft/identity/common/nativeauth/internal/controllers/NativeAuthMsalController.kt b/common/src/main/java/com/microsoft/identity/common/nativeauth/internal/controllers/NativeAuthMsalController.kt index 4270c1b1ac..b911bd8b8b 100644 --- a/common/src/main/java/com/microsoft/identity/common/nativeauth/internal/controllers/NativeAuthMsalController.kt +++ b/common/src/main/java/com/microsoft/identity/common/nativeauth/internal/controllers/NativeAuthMsalController.kt @@ -2710,6 +2710,7 @@ class NativeAuthMsalController : BaseNativeAuthController() { .platformComponents(parameters.platformComponents) .challengeTypes(parameters.challengeType) .capabilities(parameters.capabilities) + .requestInterceptor(parameters.requestInterceptor) .build() return parameters diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt index ecab9f5612..aced879ba5 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt @@ -76,7 +76,7 @@ class NativeAuthCIAMAuthority ( mAuthorityUrlString = authorityUrl } - private fun createNativeAuthOAuth2Configuration(challengeTypes: List?, capabilities: List?): NativeAuthOAuth2Configuration { + private fun createNativeAuthOAuth2Configuration(challengeTypes: List?, capabilities: List?, requestInterceptor: com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor?): NativeAuthOAuth2Configuration { LogSession.logMethodCall( tag = TAG, correlationId = null, @@ -86,7 +86,8 @@ class NativeAuthCIAMAuthority ( authorityUrl = this.authorityURL, clientId = this.clientId, challengeType = getChallengeTypesWithDefault(challengeTypes), - capabilities = getCapabilities(capabilities) + capabilities = getCapabilities(capabilities), + requestInterceptor = requestInterceptor ) } @@ -123,7 +124,7 @@ class NativeAuthCIAMAuthority ( @Throws(ClientException::class) override fun createOAuth2Strategy(parameters: OAuth2StrategyParameters): NativeAuthOAuth2Strategy { - val config = createNativeAuthOAuth2Configuration(parameters.mChallengeTypes, parameters.mCapabilities) + val config = createNativeAuthOAuth2Configuration(parameters.mChallengeTypes, parameters.mCapabilities, parameters.mRequestInterceptor) // CIAM Authorities can fetch endpoints from open id configuration. We disable this option. parameters.setUsingOpenIdConfiguration(NATIVE_AUTH_USE_OPENID_CONFIGURATION) diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/commands/parameters/BaseNativeAuthCommandParameters.java b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/commands/parameters/BaseNativeAuthCommandParameters.java index 1a61e4b827..0e299adc29 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/commands/parameters/BaseNativeAuthCommandParameters.java +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/commands/parameters/BaseNativeAuthCommandParameters.java @@ -26,6 +26,7 @@ import com.microsoft.identity.common.java.commands.parameters.CommandParameters; import com.microsoft.identity.common.java.logging.Logger; import com.microsoft.identity.common.java.nativeauth.authorities.NativeAuthCIAMAuthority; +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor; import com.microsoft.identity.common.java.nativeauth.util.ILoggable; import java.util.List; @@ -62,6 +63,13 @@ public abstract class BaseNativeAuthCommandParameters extends CommandParameters @Nullable public final List capabilities; + /** + * An optional interceptor for injecting custom HTTP headers into native auth requests. + */ + @Nullable + @EqualsAndHashCode.Exclude + public final transient NativeAuthRequestInterceptor requestInterceptor; + @Override public void logParameters(String tag, String correlationId) { Logger.infoWithObject(tag, null, correlationId, this); diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt new file mode 100644 index 0000000000..d20db33dc6 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt @@ -0,0 +1,77 @@ +// 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.nativeauth.providers + +import com.microsoft.identity.common.java.logging.Logger + +/** + * Validates custom headers provided by a [NativeAuthRequestInterceptor]. + * Enforces that header names start with "x-" and do not use reserved prefixes. + */ +object NativeAuthHeaderValidator { + + private val TAG = NativeAuthHeaderValidator::class.java.simpleName + + private val RESERVED_PREFIXES = listOf("x-ms-", "x-client-", "x-broker-", "x-app-") + + /** + * Filters a map of headers, returning only those that are valid per the interceptor contract. + * Invalid headers are logged as warnings and excluded from the result. + * + * @param headers The raw headers provided by the interceptor. + * @return A map containing only valid headers, or an empty map if none are valid. + */ + fun filterValidHeaders(headers: Map): Map { + val validHeaders = mutableMapOf() + + for ((field, value) in headers) { + val lowerField = field.lowercase() + + if (!lowerField.startsWith("x-")) { + Logger.warn( + TAG, + "Additional header field \"$field\" must start with the \"x-\" prefix. Ignoring." + ) + continue + } + + var isReserved = false + for (reserved in RESERVED_PREFIXES) { + if (lowerField.startsWith(reserved)) { + Logger.warn( + TAG, + "Additional header field \"$field\" uses reserved prefix \"$reserved\". Ignoring." + ) + isReserved = true + break + } + } + + if (!isReserved) { + validHeaders[field] = value + } + } + + return validHeaders + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2Configuration.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2Configuration.kt index 865ed7db32..8ace436847 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2Configuration.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2Configuration.kt @@ -41,6 +41,7 @@ class NativeAuthOAuth2Configuration( val clientId: String, val challengeType: String, val capabilities: String?, + val requestInterceptor: NativeAuthRequestInterceptor? = null, // Need this to decide whether or not to return mock api authority or actual authority supplied in configuration // Turn this on if you plan to use web auth and/or open id configuration val useMockApiForNativeAuth: Boolean = BuildValues.shouldUseMockApiForNativeAuth(), diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2StrategyFactory.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2StrategyFactory.kt index 05e645307c..9e143149c9 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2StrategyFactory.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2StrategyFactory.kt @@ -39,28 +39,33 @@ class NativeAuthOAuth2StrategyFactory { config: NativeAuthOAuth2Configuration, strategyParameters: OAuth2StrategyParameters, ): NativeAuthOAuth2Strategy { + val requestInterceptor = config.requestInterceptor return NativeAuthOAuth2Strategy( strategyParameters = strategyParameters, config = config, signInInteractor = SignInInteractor( httpClient = UrlConnectionHttpClient.getDefaultInstance(), nativeAuthRequestProvider = NativeAuthRequestProvider(config = config), - nativeAuthResponseHandler = NativeAuthResponseHandler() + nativeAuthResponseHandler = NativeAuthResponseHandler(), + requestInterceptor = requestInterceptor ), signUpInteractor = SignUpInteractor( httpClient = UrlConnectionHttpClient.getDefaultInstance(), nativeAuthRequestProvider = NativeAuthRequestProvider(config = config), - nativeAuthResponseHandler = NativeAuthResponseHandler() + nativeAuthResponseHandler = NativeAuthResponseHandler(), + requestInterceptor = requestInterceptor ), resetPasswordInteractor = ResetPasswordInteractor( httpClient = UrlConnectionHttpClient.getDefaultInstance(), nativeAuthRequestProvider = NativeAuthRequestProvider(config = config), - nativeAuthResponseHandler = NativeAuthResponseHandler() + nativeAuthResponseHandler = NativeAuthResponseHandler(), + requestInterceptor = requestInterceptor ), jitInteractor = JITInteractor( httpClient = UrlConnectionHttpClient.getDefaultInstance(), nativeAuthRequestProvider = NativeAuthRequestProvider(config = config), - nativeAuthResponseHandler = NativeAuthResponseHandler() + nativeAuthResponseHandler = NativeAuthResponseHandler(), + requestInterceptor = requestInterceptor ) ) } diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt new file mode 100644 index 0000000000..56deaf9efe --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt @@ -0,0 +1,46 @@ +// 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.nativeauth.providers + +import java.net.URL + +/** + * An interceptor that is called before each native auth network request, allowing + * the application to inject custom HTTP header fields. + * + * All custom header field names must start with the "x-" prefix. + * The prefixes "x-ms-", "x-client-", "x-broker-", and "x-app-" are reserved and must not be used. + * Headers that violate these rules will be ignored by the SDK. + */ +interface NativeAuthRequestInterceptor { + + /** + * Called before each native auth network request to retrieve additional HTTP header fields. + * + * Inspect [requestUrl] to determine the request endpoint and conditionally apply headers. + * + * @param requestUrl The URL of the outgoing request. + * @return A map of header field names to values to inject, or null if no additional headers are needed. + */ + fun additionalHeaders(requestUrl: URL): Map? +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt index 5ebf3ac24a..3a24d185e6 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt @@ -27,6 +27,8 @@ import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITChallengeAuthMethodCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITIntrospectCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITContinueCommandParameters +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.jit.JITChallengeRequest @@ -37,6 +39,7 @@ import com.microsoft.identity.common.java.nativeauth.providers.responses.jit.JIT import com.microsoft.identity.common.java.nativeauth.providers.responses.jit.JITIntrospectApiResult import com.microsoft.identity.common.java.net.UrlConnectionHttpClient import com.microsoft.identity.common.java.util.ObjectMapper +import java.net.URL /** * Acts as a binding layer between the request providers and response handlers for a given request. @@ -51,10 +54,21 @@ import com.microsoft.identity.common.java.util.ObjectMapper class JITInteractor( private val httpClient: UrlConnectionHttpClient, private val nativeAuthRequestProvider: NativeAuthRequestProvider, - private val nativeAuthResponseHandler: NativeAuthResponseHandler + private val nativeAuthResponseHandler: NativeAuthResponseHandler, + private val requestInterceptor: NativeAuthRequestInterceptor? = null ) { private val TAG: String = this::class.java.simpleName + private fun applyInterceptorHeaders(requestUrl: URL, headers: Map): Map { + if (requestInterceptor == null) return headers + val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers + val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) + if (validHeaders.isEmpty()) return headers + val mergedHeaders = headers.toMutableMap() + mergedHeaders.putAll(validHeaders) + return mergedHeaders + } + //region /register/introspect fun performIntrospect( parameters: JITIntrospectCommandParameters @@ -94,7 +108,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( @@ -169,7 +183,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( @@ -243,7 +257,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt index b4f3f23c36..41b48387db 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt @@ -28,6 +28,8 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPa import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.net.UrlConnectionHttpClient +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.resetpassword.ResetPasswordChallengeRequest @@ -42,6 +44,7 @@ import com.microsoft.identity.common.java.nativeauth.providers.responses.resetpa import com.microsoft.identity.common.java.nativeauth.providers.responses.resetpassword.ResetPasswordSubmitApiResult import com.microsoft.identity.common.java.util.ObjectMapper import com.microsoft.identity.common.java.util.StringUtil +import java.net.URL /** * Acts as a binding layer between the request providers and response handlers for a given request. @@ -56,9 +59,21 @@ import com.microsoft.identity.common.java.util.StringUtil class ResetPasswordInteractor( private val httpClient: UrlConnectionHttpClient, private val nativeAuthRequestProvider: NativeAuthRequestProvider, - private val nativeAuthResponseHandler: NativeAuthResponseHandler + private val nativeAuthResponseHandler: NativeAuthResponseHandler, + private val requestInterceptor: NativeAuthRequestInterceptor? = null ) { private val TAG:String = ResetPasswordInteractor::class.java.simpleName + + private fun applyInterceptorHeaders(requestUrl: URL, headers: Map): Map { + if (requestInterceptor == null) return headers + val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers + val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) + if (validHeaders.isEmpty()) return headers + val mergedHeaders = headers.toMutableMap() + mergedHeaders.putAll(validHeaders) + return mergedHeaders + } + //region /resetpassword/start fun performResetPasswordStart( parameters: ResetPasswordStartCommandParameters @@ -95,7 +110,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -169,7 +184,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -240,7 +255,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -317,7 +332,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -391,7 +406,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val httpResponse = httpClient.post( diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt index 47f3a8b640..d3175a3d55 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt @@ -29,6 +29,8 @@ import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInStartCommandParameters import com.microsoft.identity.common.java.net.UrlConnectionHttpClient +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.signin.SignInChallengeRequest @@ -41,6 +43,7 @@ import com.microsoft.identity.common.java.nativeauth.providers.responses.signin. import com.microsoft.identity.common.java.nativeauth.providers.responses.signin.SignInTokenApiResult import com.microsoft.identity.common.java.util.ObjectMapper import com.microsoft.identity.common.java.util.StringUtil +import java.net.URL /** * Acts as a binding layer between the request providers and response handlers for a given request. @@ -55,9 +58,21 @@ import com.microsoft.identity.common.java.util.StringUtil class SignInInteractor( private val httpClient: UrlConnectionHttpClient, private val nativeAuthRequestProvider: NativeAuthRequestProvider, - private val nativeAuthResponseHandler: NativeAuthResponseHandler + private val nativeAuthResponseHandler: NativeAuthResponseHandler, + private val requestInterceptor: NativeAuthRequestInterceptor? = null ) { private val TAG:String = SignInInteractor::class.java.simpleName + + private fun applyInterceptorHeaders(requestUrl: URL, headers: Map): Map { + if (requestInterceptor == null) return headers + val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers + val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) + if (validHeaders.isEmpty()) return headers + val mergedHeaders = headers.toMutableMap() + mergedHeaders.putAll(validHeaders) + return mergedHeaders + } + //region /oauth/v2.0/initiate fun performSignInInitiate( parameters: SignInStartCommandParameters @@ -95,7 +110,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInInitiate" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( @@ -168,7 +183,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInIntrospect" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( @@ -270,7 +285,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInChallenge" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( @@ -396,7 +411,7 @@ class SignInInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt index acb8af3136..4de754788c 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt @@ -28,6 +28,8 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpS import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.net.UrlConnectionHttpClient +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.signup.SignUpChallengeRequest @@ -39,6 +41,7 @@ import com.microsoft.identity.common.java.nativeauth.providers.responses.signup. import com.microsoft.identity.common.java.util.ObjectMapper import com.microsoft.identity.common.java.util.StringUtil import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpSubmitPasswordCommandParameters as SignUpSubmitPasswordCommandParameters1 +import java.net.URL /** * Acts as a binding layer between the request providers and response handlers for a given request. @@ -53,10 +56,21 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpS class SignUpInteractor( private val httpClient: UrlConnectionHttpClient, private val nativeAuthRequestProvider: NativeAuthRequestProvider, - private val nativeAuthResponseHandler: NativeAuthResponseHandler + private val nativeAuthResponseHandler: NativeAuthResponseHandler, + private val requestInterceptor: NativeAuthRequestInterceptor? = null ) { private val TAG:String = SignUpInteractor::class.java.simpleName + private fun applyInterceptorHeaders(requestUrl: URL, headers: Map): Map { + if (requestInterceptor == null) return headers + val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers + val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) + if (validHeaders.isEmpty()) return headers + val mergedHeaders = headers.toMutableMap() + mergedHeaders.putAll(validHeaders) + return mergedHeaders + } + //region /signup/start fun performSignUpStart( commandParameters: SignUpStartCommandParameters @@ -99,7 +113,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( @@ -173,7 +187,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( @@ -295,7 +309,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers) val requestUrl = request.requestUrl val response = httpClient.post( diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2StrategyParameters.java b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2StrategyParameters.java index 402d9231aa..ba8112e984 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2StrategyParameters.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2StrategyParameters.java @@ -24,6 +24,7 @@ import com.microsoft.identity.common.java.authscheme.AbstractAuthenticationScheme; import com.microsoft.identity.common.java.interfaces.IPlatformComponents; +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor; import java.util.List; @@ -61,6 +62,12 @@ public class OAuth2StrategyParameters { @Nullable public final List mCapabilities; + /** + * An optional interceptor for injecting custom HTTP headers into native auth requests. + */ + @Nullable + public final transient NativeAuthRequestInterceptor mRequestInterceptor; + // TODO: Consider moving this field into MicrosoftStsOAuth2Configuration and updating it's endpoint methods // to use OpenId Configuration. private transient boolean mUsingOpenIdConfiguration; diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt new file mode 100644 index 0000000000..daf6984324 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt @@ -0,0 +1,182 @@ +// 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.nativeauth.providers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeAuthHeaderValidatorTest { + + @Test + fun testValidCustomHeaders() { + val headers = mapOf( + "x-custom-header" to "value1", + "x-akamai-sensor" to "sensor-data", + "x-fraud-signal" to "signal123" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(3, result.size) + assertEquals("value1", result["x-custom-header"]) + assertEquals("sensor-data", result["x-akamai-sensor"]) + assertEquals("signal123", result["x-fraud-signal"]) + } + + @Test + fun testHeadersWithoutXPrefixAreRejected() { + val headers = mapOf( + "x-valid" to "keep", + "Authorization" to "Bearer token", + "Content-Type" to "application/json", + "custom-header" to "no-x-prefix" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(1, result.size) + assertEquals("keep", result["x-valid"]) + } + + @Test + fun testReservedXMsPrefixIsRejected() { + val headers = mapOf( + "x-ms-correlation-id" to "abc", + "x-ms-request-id" to "def", + "x-valid-header" to "keep" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(1, result.size) + assertEquals("keep", result["x-valid-header"]) + } + + @Test + fun testReservedXClientPrefixIsRejected() { + val headers = mapOf( + "x-client-SKU" to "Android", + "x-client-Ver" to "1.0", + "x-custom" to "keep" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(1, result.size) + assertEquals("keep", result["x-custom"]) + } + + @Test + fun testReservedXBrokerPrefixIsRejected() { + val headers = mapOf( + "x-broker-version" to "1.0", + "x-valid" to "keep" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(1, result.size) + assertEquals("keep", result["x-valid"]) + } + + @Test + fun testReservedXAppPrefixIsRejected() { + val headers = mapOf( + "x-app-name" to "MyApp", + "x-valid" to "keep" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(1, result.size) + assertEquals("keep", result["x-valid"]) + } + + @Test + fun testCaseInsensitivePrefixCheck() { + val headers = mapOf( + "X-Custom-Header" to "value1", + "X-MS-Reserved" to "rejected", + "X-CLIENT-Info" to "rejected", + "X-BROKER-Data" to "rejected", + "X-APP-Version" to "rejected" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(1, result.size) + assertEquals("value1", result["X-Custom-Header"]) + } + + @Test + fun testEmptyMapReturnsEmpty() { + val result = NativeAuthHeaderValidator.filterValidHeaders(emptyMap()) + assertTrue(result.isEmpty()) + } + + @Test + fun testAllInvalidHeadersReturnsEmpty() { + val headers = mapOf( + "Authorization" to "Bearer token", + "x-ms-foo" to "bar", + "x-client-bar" to "baz" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertTrue(result.isEmpty()) + } + + @Test + fun testOriginalHeaderCaseIsPreserved() { + val headers = mapOf( + "X-Akamai-Sensor-Data" to "encoded-value" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(1, result.size) + assertEquals("encoded-value", result["X-Akamai-Sensor-Data"]) + } + + @Test + fun testMixedValidAndInvalidHeaders() { + val headers = mapOf( + "x-fraud-signal" to "signal", + "Authorization" to "secret", + "x-ms-telemetry" to "rejected", + "x-akamai-data" to "keep", + "x-client-id" to "rejected", + "Content-Type" to "text/plain", + "x-custom-trace" to "trace123" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(3, result.size) + assertEquals("signal", result["x-fraud-signal"]) + assertEquals("keep", result["x-akamai-data"]) + assertEquals("trace123", result["x-custom-trace"]) + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt new file mode 100644 index 0000000000..dfda491130 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt @@ -0,0 +1,278 @@ +// 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.nativeauth.providers.interactors + +import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInStartCommandParameters +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler +import com.microsoft.identity.common.java.nativeauth.providers.requests.signin.SignInInitiateRequest +import com.microsoft.identity.common.java.net.HttpResponse +import com.microsoft.identity.common.java.net.UrlConnectionHttpClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URL + +/** + * Tests verifying that [SignInInteractor] correctly applies custom headers + * from a [NativeAuthRequestInterceptor] to outgoing HTTP requests. + */ +class SignInInteractorRequestInterceptorTest { + + private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/initiate") + + private val mockHttpClient = mockk() + private val mockRequestProvider = mockk() + private val mockResponseHandler = mockk() + + private fun createMockRequest( + url: URL = testUrl, + headers: Map = mapOf("Content-Type" to "application/x-www-form-urlencoded") + ): SignInInitiateRequest { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns url + every { mockRequest.headers } returns headers + return mockRequest + } + + private fun setupMocks(mockRequest: SignInInitiateRequest) { + every { mockRequestProvider.createSignInInitiateRequest(any()) } returns mockRequest + every { + mockResponseHandler.getSignInInitiateResultFromHttpResponse(any(), any()) + } returns mockk(relaxed = true) + } + + @Test + fun testInterceptorHeadersAreMergedIntoRequest() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-akamai-sensor" to "sensor-data-123", + "x-fraud-signal" to "signal-abc" + ) + } + } + + val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android" + ) + val mockRequest = createMockRequest(headers = baseHeaders) + setupMocks(mockRequest) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + val mockParameters = mockk(relaxed = true) + interactor.performSignInInitiate(mockParameters) + + assertTrue("Headers should have been captured", capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + assertEquals("sensor-data-123", headers["x-akamai-sensor"]) + assertEquals("signal-abc", headers["x-fraud-signal"]) + assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) + assertEquals("MSAL.Android", headers["x-client-SKU"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeaders() { + val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded" + ) + val mockRequest = createMockRequest(headers = baseHeaders) + setupMocks(mockRequest) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = null + ) + + val mockParameters = mockk(relaxed = true) + interactor.performSignInInitiate(mockParameters) + + assertTrue("Headers should have been captured", capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + assertEquals(1, headers.size) + assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) + } + + @Test + fun testInterceptorReturningNullDoesNotModifyHeaders() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? = null + } + + val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded" + ) + val mockRequest = createMockRequest(headers = baseHeaders) + setupMocks(mockRequest) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + val mockParameters = mockk(relaxed = true) + interactor.performSignInInitiate(mockParameters) + + assertTrue("Headers should have been captured", capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + assertEquals(1, headers.size) + } + + @Test + fun testInterceptorReservedHeadersAreFiltered() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-akamai-sensor" to "valid", + "x-ms-evil" to "should-be-filtered", + "x-client-override" to "should-be-filtered", + "Authorization" to "should-be-filtered" + ) + } + } + + val mockRequest = createMockRequest() + setupMocks(mockRequest) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + val mockParameters = mockk(relaxed = true) + interactor.performSignInInitiate(mockParameters) + + assertTrue("Headers should have been captured", capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + assertTrue(headers.containsKey("x-akamai-sensor")) + assertEquals("valid", headers["x-akamai-sensor"]) + assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-evil")) + assertFalse("x-client- prefix should be filtered", headers.containsKey("x-client-override")) + assertFalse("Non x- prefix should be filtered", headers.containsKey("Authorization")) + } + + @Test + fun testInterceptorReceivesCorrectRequestUrl() { + val capturedUrls = mutableListOf() + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + capturedUrls.add(requestUrl) + return mapOf("x-test" to "value") + } + } + + val mockRequest = createMockRequest() + setupMocks(mockRequest) + + every { + mockHttpClient.post(any(), any>(), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + val mockParameters = mockk(relaxed = true) + interactor.performSignInInitiate(mockParameters) + + assertEquals("Interceptor should have been called once", 1, capturedUrls.size) + assertEquals(testUrl, capturedUrls[0]) + } + + @Test + fun testInterceptorEmptyHeadersDoNotModifyRequest() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return emptyMap() + } + } + + val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded" + ) + val mockRequest = createMockRequest(headers = baseHeaders) + setupMocks(mockRequest) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + val mockParameters = mockk(relaxed = true) + interactor.performSignInInitiate(mockParameters) + + assertTrue("Headers should have been captured", capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + assertEquals(1, headers.size) + assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) + } +} From f450877c64096008792e7e059a9bb4a2b05eeba2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 16:53:30 +0000 Subject: [PATCH 02/18] Address PR review feedback for interceptor wiring and docs Agent-Logs-Url: https://github.com/AzureAD/microsoft-authentication-library-common-for-android/sessions/3a466cba-db2f-42cd-afa9-92e96cef6792 Co-authored-by: spetrescu84 <111577419+spetrescu84@users.noreply.github.com> --- changelog.txt | 2 +- .../authorities/NativeAuthCIAMAuthority.kt | 9 +- .../providers/NativeAuthHeaderValidator.kt | 4 +- .../providers/NativeAuthRequestInterceptor.kt | 8 +- .../providers/interactors/JITInteractor.kt | 20 +-- .../RequestInterceptorHeaderUtils.kt | 50 ++++++++ .../interactors/ResetPasswordInteractor.kt | 22 +--- .../providers/interactors/SignInInteractor.kt | 20 +-- .../providers/interactors/SignUpInteractor.kt | 18 +-- .../oauth2/OAuth2RequestInterceptor.kt | 32 +++++ .../oauth2/OAuth2StrategyParameters.java | 3 +- .../NativeAuthHeaderValidatorTest.kt | 19 ++- .../RequestInterceptorHeaderUtilsTest.kt | 120 ++++++++++++++++++ 13 files changed, 251 insertions(+), 76 deletions(-) create mode 100644 common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt create mode 100644 common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt create mode 100644 common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt diff --git a/changelog.txt b/changelog.txt index e59303041c..0bf8f18f24 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,7 +2,7 @@ vNext ---------- - [PATCH] Extend filter-then-clone optimization to load() and getIdTokensForAccountRecord() in MsalOAuth2TokenCache: when ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE flight is enabled, skip clone-all preload and call direct flight-gated overloads that clone only matching credentials; add new getCredentialsFilteredBy overload with kid support (#3100) - [PATCH] Move Multiple Listening apps check to the authorization layer (#3070) -- [MINOR] Add HttpRequest interceptor (#) +- [MINOR] Add NativeAuthRequestInterceptor for custom per-request headers in native auth flows (#3112) - [PATCH] Edge TB: Fix lookup mode (#3108) Version 24.2.0 diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt index aced879ba5..08972b3450 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt @@ -32,6 +32,7 @@ import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthConstan import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthOAuth2Configuration import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthOAuth2Strategy import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthOAuth2StrategyFactory +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.providers.oauth2.OAuth2StrategyParameters /** @@ -76,7 +77,7 @@ class NativeAuthCIAMAuthority ( mAuthorityUrlString = authorityUrl } - private fun createNativeAuthOAuth2Configuration(challengeTypes: List?, capabilities: List?, requestInterceptor: com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor?): NativeAuthOAuth2Configuration { + private fun createNativeAuthOAuth2Configuration(challengeTypes: List?, capabilities: List?, requestInterceptor: NativeAuthRequestInterceptor?): NativeAuthOAuth2Configuration { LogSession.logMethodCall( tag = TAG, correlationId = null, @@ -124,7 +125,11 @@ class NativeAuthCIAMAuthority ( @Throws(ClientException::class) override fun createOAuth2Strategy(parameters: OAuth2StrategyParameters): NativeAuthOAuth2Strategy { - val config = createNativeAuthOAuth2Configuration(parameters.mChallengeTypes, parameters.mCapabilities, parameters.mRequestInterceptor) + val config = createNativeAuthOAuth2Configuration( + parameters.mChallengeTypes, + parameters.mCapabilities, + parameters.mRequestInterceptor as? NativeAuthRequestInterceptor + ) // CIAM Authorities can fetch endpoints from open id configuration. We disable this option. parameters.setUsingOpenIdConfiguration(NATIVE_AUTH_USE_OPENID_CONFIGURATION) diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt index d20db33dc6..d2c9429fe7 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt @@ -39,7 +39,7 @@ object NativeAuthHeaderValidator { * Invalid headers are logged as warnings and excluded from the result. * * @param headers The raw headers provided by the interceptor. - * @return A map containing only valid headers, or an empty map if none are valid. + * @return A map containing only valid headers using lowercase field names, or an empty map if none are valid. */ fun filterValidHeaders(headers: Map): Map { val validHeaders = mutableMapOf() @@ -68,7 +68,7 @@ object NativeAuthHeaderValidator { } if (!isReserved) { - validHeaders[field] = value + validHeaders[lowerField] = value } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt index 56deaf9efe..c7d1e707e1 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt @@ -22,6 +22,7 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.nativeauth.providers +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import java.net.URL /** @@ -32,15 +33,18 @@ import java.net.URL * The prefixes "x-ms-", "x-client-", "x-broker-", and "x-app-" are reserved and must not be used. * Headers that violate these rules will be ignored by the SDK. */ -interface NativeAuthRequestInterceptor { +interface NativeAuthRequestInterceptor : OAuth2RequestInterceptor { /** * Called before each native auth network request to retrieve additional HTTP header fields. * + * This callback executes synchronously on the thread performing the request (typically a + * background/network thread), so implementations must be thread-safe and return quickly. * Inspect [requestUrl] to determine the request endpoint and conditionally apply headers. + * Any exception thrown from this method will propagate to the caller and fail the request. * * @param requestUrl The URL of the outgoing request. * @return A map of header field names to values to inject, or null if no additional headers are needed. */ - fun additionalHeaders(requestUrl: URL): Map? + override fun additionalHeaders(requestUrl: URL): Map? } diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt index 3a24d185e6..8ecd097b75 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt @@ -27,7 +27,6 @@ import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITChallengeAuthMethodCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITIntrospectCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITContinueCommandParameters -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler @@ -39,7 +38,6 @@ import com.microsoft.identity.common.java.nativeauth.providers.responses.jit.JIT import com.microsoft.identity.common.java.nativeauth.providers.responses.jit.JITIntrospectApiResult import com.microsoft.identity.common.java.net.UrlConnectionHttpClient import com.microsoft.identity.common.java.util.ObjectMapper -import java.net.URL /** * Acts as a binding layer between the request providers and response handlers for a given request. @@ -59,16 +57,6 @@ class JITInteractor( ) { private val TAG: String = this::class.java.simpleName - private fun applyInterceptorHeaders(requestUrl: URL, headers: Map): Map { - if (requestInterceptor == null) return headers - val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers - val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) - if (validHeaders.isEmpty()) return headers - val mergedHeaders = headers.toMutableMap() - mergedHeaders.putAll(validHeaders) - return mergedHeaders - } - //region /register/introspect fun performIntrospect( parameters: JITIntrospectCommandParameters @@ -108,7 +96,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -183,7 +171,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -257,7 +245,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -289,4 +277,4 @@ class JITInteractor( return result } //endregion -} \ No newline at end of file +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt new file mode 100644 index 0000000000..1fb3a8aa4b --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt @@ -0,0 +1,50 @@ +// 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.nativeauth.providers.interactors + +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import java.net.URL + +internal fun applyInterceptorHeaders( + requestUrl: URL, + headers: Map, + requestInterceptor: NativeAuthRequestInterceptor? +): Map { + if (requestInterceptor == null) return headers + + val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers + val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) + if (validHeaders.isEmpty()) return headers + + val mergedHeaders = headers.toMutableMap() + for ((field, value) in validHeaders) { + val existingHeader = mergedHeaders.keys.firstOrNull { it.equals(field, ignoreCase = true) } + if (existingHeader != null) { + mergedHeaders.remove(existingHeader) + } + mergedHeaders[field] = value + } + + return mergedHeaders +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt index 41b48387db..81e21e7d7e 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt @@ -28,7 +28,6 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPa import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.net.UrlConnectionHttpClient -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler @@ -44,7 +43,6 @@ import com.microsoft.identity.common.java.nativeauth.providers.responses.resetpa import com.microsoft.identity.common.java.nativeauth.providers.responses.resetpassword.ResetPasswordSubmitApiResult import com.microsoft.identity.common.java.util.ObjectMapper import com.microsoft.identity.common.java.util.StringUtil -import java.net.URL /** * Acts as a binding layer between the request providers and response handlers for a given request. @@ -64,16 +62,6 @@ class ResetPasswordInteractor( ) { private val TAG:String = ResetPasswordInteractor::class.java.simpleName - private fun applyInterceptorHeaders(requestUrl: URL, headers: Map): Map { - if (requestInterceptor == null) return headers - val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers - val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) - if (validHeaders.isEmpty()) return headers - val mergedHeaders = headers.toMutableMap() - mergedHeaders.putAll(validHeaders) - return mergedHeaders - } - //region /resetpassword/start fun performResetPasswordStart( parameters: ResetPasswordStartCommandParameters @@ -110,7 +98,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -184,7 +172,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -255,7 +243,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -332,7 +320,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -406,7 +394,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt index d3175a3d55..e5b3eeb557 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt @@ -29,7 +29,6 @@ import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInStartCommandParameters import com.microsoft.identity.common.java.net.UrlConnectionHttpClient -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler @@ -43,7 +42,6 @@ import com.microsoft.identity.common.java.nativeauth.providers.responses.signin. import com.microsoft.identity.common.java.nativeauth.providers.responses.signin.SignInTokenApiResult import com.microsoft.identity.common.java.util.ObjectMapper import com.microsoft.identity.common.java.util.StringUtil -import java.net.URL /** * Acts as a binding layer between the request providers and response handlers for a given request. @@ -63,16 +61,6 @@ class SignInInteractor( ) { private val TAG:String = SignInInteractor::class.java.simpleName - private fun applyInterceptorHeaders(requestUrl: URL, headers: Map): Map { - if (requestInterceptor == null) return headers - val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers - val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) - if (validHeaders.isEmpty()) return headers - val mergedHeaders = headers.toMutableMap() - mergedHeaders.putAll(validHeaders) - return mergedHeaders - } - //region /oauth/v2.0/initiate fun performSignInInitiate( parameters: SignInStartCommandParameters @@ -110,7 +98,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInInitiate" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -183,7 +171,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInIntrospect" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -285,7 +273,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInChallenge" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -411,7 +399,7 @@ class SignInInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt index 4de754788c..83fdf01382 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt @@ -28,7 +28,6 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpS import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.net.UrlConnectionHttpClient -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler @@ -41,7 +40,6 @@ import com.microsoft.identity.common.java.nativeauth.providers.responses.signup. import com.microsoft.identity.common.java.util.ObjectMapper import com.microsoft.identity.common.java.util.StringUtil import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpSubmitPasswordCommandParameters as SignUpSubmitPasswordCommandParameters1 -import java.net.URL /** * Acts as a binding layer between the request providers and response handlers for a given request. @@ -61,16 +59,6 @@ class SignUpInteractor( ) { private val TAG:String = SignUpInteractor::class.java.simpleName - private fun applyInterceptorHeaders(requestUrl: URL, headers: Map): Map { - if (requestInterceptor == null) return headers - val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers - val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) - if (validHeaders.isEmpty()) return headers - val mergedHeaders = headers.toMutableMap() - mergedHeaders.putAll(validHeaders) - return mergedHeaders - } - //region /signup/start fun performSignUpStart( commandParameters: SignUpStartCommandParameters @@ -113,7 +101,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -187,7 +175,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -309,7 +297,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = applyInterceptorHeaders(request.requestUrl, request.headers) + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt new file mode 100644 index 0000000000..d134c38569 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt @@ -0,0 +1,32 @@ +// 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.providers.oauth2 + +import java.net.URL + +/** + * Generic OAuth2 request interceptor used to add request-specific HTTP headers. + */ +interface OAuth2RequestInterceptor { + fun additionalHeaders(requestUrl: URL): Map? +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2StrategyParameters.java b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2StrategyParameters.java index ba8112e984..4898d79233 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2StrategyParameters.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2StrategyParameters.java @@ -24,7 +24,6 @@ import com.microsoft.identity.common.java.authscheme.AbstractAuthenticationScheme; import com.microsoft.identity.common.java.interfaces.IPlatformComponents; -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor; import java.util.List; @@ -66,7 +65,7 @@ public class OAuth2StrategyParameters { * An optional interceptor for injecting custom HTTP headers into native auth requests. */ @Nullable - public final transient NativeAuthRequestInterceptor mRequestInterceptor; + public final transient OAuth2RequestInterceptor mRequestInterceptor; // TODO: Consider moving this field into MicrosoftStsOAuth2Configuration and updating it's endpoint methods // to use OpenId Configuration. diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt index daf6984324..aee028f958 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt @@ -126,7 +126,7 @@ class NativeAuthHeaderValidatorTest { val result = NativeAuthHeaderValidator.filterValidHeaders(headers) assertEquals(1, result.size) - assertEquals("value1", result["X-Custom-Header"]) + assertEquals("value1", result["x-custom-header"]) } @Test @@ -149,7 +149,7 @@ class NativeAuthHeaderValidatorTest { } @Test - fun testOriginalHeaderCaseIsPreserved() { + fun testHeaderFieldNamesAreNormalizedToLowercase() { val headers = mapOf( "X-Akamai-Sensor-Data" to "encoded-value" ) @@ -157,7 +157,20 @@ class NativeAuthHeaderValidatorTest { val result = NativeAuthHeaderValidator.filterValidHeaders(headers) assertEquals(1, result.size) - assertEquals("encoded-value", result["X-Akamai-Sensor-Data"]) + assertEquals("encoded-value", result["x-akamai-sensor-data"]) + } + + @Test + fun testDuplicateHeaderWithDifferentCaseKeepsSingleEntry() { + val headers = linkedMapOf( + "X-Custom-Header" to "first", + "x-custom-header" to "second" + ) + + val result = NativeAuthHeaderValidator.filterValidHeaders(headers) + + assertEquals(1, result.size) + assertEquals("second", result["x-custom-header"]) } @Test diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt new file mode 100644 index 0000000000..c8a8751f58 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt @@ -0,0 +1,120 @@ +// 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.nativeauth.providers.interactors + +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URL + +class RequestInterceptorHeaderUtilsTest { + + private val requestUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/initiate") + + @Test + fun testApplyInterceptorHeadersReturnsOriginalMapWhenInterceptorIsNull() { + val headers = mapOf("Content-Type" to "application/x-www-form-urlencoded") + + val result = applyInterceptorHeaders(requestUrl, headers, null) + + assertSame(headers, result) + } + + @Test + fun testApplyInterceptorHeadersMergesValidCustomHeaders() { + val headers = mapOf("Content-Type" to "application/x-www-form-urlencoded") + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map { + return mapOf("x-test-header" to "value") + } + } + + val result = applyInterceptorHeaders(requestUrl, headers, interceptor) + + assertEquals(2, result.size) + assertEquals("application/x-www-form-urlencoded", result["Content-Type"]) + assertEquals("value", result["x-test-header"]) + } + + @Test + fun testApplyInterceptorHeadersFiltersReservedAndNonCustomHeaders() { + val headers = mapOf("Content-Type" to "application/x-www-form-urlencoded") + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map { + return mapOf( + "x-ms-client-request-id" to "reserved", + "Authorization" to "rejected", + "x-valid" to "kept" + ) + } + } + + val result = applyInterceptorHeaders(requestUrl, headers, interceptor) + + assertEquals(2, result.size) + assertTrue(result.containsKey("x-valid")) + assertFalse(result.containsKey("Authorization")) + assertFalse(result.containsKey("x-ms-client-request-id")) + } + + @Test + fun testApplyInterceptorHeadersMergesCaseInsensitiveWithBaseHeaders() { + val headers = mapOf( + "X-Custom-Header" to "base", + "Content-Type" to "application/x-www-form-urlencoded" + ) + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map { + return mapOf("x-custom-header" to "override") + } + } + + val result = applyInterceptorHeaders(requestUrl, headers, interceptor) + + assertEquals(2, result.size) + assertEquals("override", result["x-custom-header"]) + assertFalse(result.containsKey("X-Custom-Header")) + } + + @Test + fun testApplyInterceptorHeadersPassesRequestUrlToInterceptor() { + var capturedUrl: URL? = null + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + capturedUrl = requestUrl + return emptyMap() + } + } + + applyInterceptorHeaders( + requestUrl = requestUrl, + headers = mapOf("Content-Type" to "application/x-www-form-urlencoded"), + requestInterceptor = interceptor + ) + + assertEquals(requestUrl, capturedUrl) + } +} From d415f28617d6795f3c90502e11f637cc0fd00d53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 16:57:04 +0000 Subject: [PATCH 03/18] Add docs and interceptor type guard per review feedback Agent-Logs-Url: https://github.com/AzureAD/microsoft-authentication-library-common-for-android/sessions/3a466cba-db2f-42cd-afa9-92e96cef6792 Co-authored-by: spetrescu84 <111577419+spetrescu84@users.noreply.github.com> --- .../nativeauth/authorities/NativeAuthCIAMAuthority.kt | 4 ++++ .../interactors/RequestInterceptorHeaderUtils.kt | 11 +++++++++++ .../java/providers/oauth2/OAuth2RequestInterceptor.kt | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt index 08972b3450..2228316b1b 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt @@ -125,6 +125,10 @@ class NativeAuthCIAMAuthority ( @Throws(ClientException::class) override fun createOAuth2Strategy(parameters: OAuth2StrategyParameters): NativeAuthOAuth2Strategy { + if (parameters.mRequestInterceptor != null && parameters.mRequestInterceptor !is NativeAuthRequestInterceptor) { + Logger.warn(TAG, "Ignoring non-native OAuth2RequestInterceptor for NativeAuthCIAMAuthority.") + } + val config = createNativeAuthOAuth2Configuration( parameters.mChallengeTypes, parameters.mCapabilities, diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt index 1fb3a8aa4b..4bfe2184f1 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt @@ -26,6 +26,17 @@ import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderV import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import java.net.URL +/** + * Applies additional interceptor headers to the base request headers for native auth interactors. + * + * Uses case-insensitive merge semantics: interceptor headers replace matching base headers. + * Interceptor headers are validated and normalized to lowercase by [NativeAuthHeaderValidator]. + * + * @param requestUrl The outbound request URL. + * @param headers The base request headers. + * @param requestInterceptor Optional interceptor providing additional headers. + * @return The merged headers map with interceptor values taking precedence. + */ internal fun applyInterceptorHeaders( requestUrl: URL, headers: Map, diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt index d134c38569..4b35939f2e 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt @@ -26,6 +26,11 @@ import java.net.URL /** * Generic OAuth2 request interceptor used to add request-specific HTTP headers. + * + * This callback executes synchronously on the thread performing the request (typically a + * background/network thread), so implementations must be thread-safe and return quickly. + * Any exception thrown from this method will propagate to the caller and fail the request. + * Returning null and returning an empty map both mean that no additional headers are added. */ interface OAuth2RequestInterceptor { fun additionalHeaders(requestUrl: URL): Map? From 1611df94548697bcbb0ff0e67d0357e2b833a3d4 Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Wed, 13 May 2026 18:21:40 +0100 Subject: [PATCH 04/18] Add comprehensive interceptor tests for all interactors - Add SignUpInteractorRequestInterceptorTest (8 tests) - Add ResetPasswordInteractorRequestInterceptorTest (7 tests) - Add JITInteractorRequestInterceptorTest (5 tests) - Expand SignInInteractorRequestInterceptorTest to cover all 7 methods (12 tests) - Total: 43 tests (32 interactor + 11 validator), all passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JITInteractorRequestInterceptorTest.kt | 236 +++++++++++++++ ...asswordInteractorRequestInterceptorTest.kt | 246 ++++++++++++++++ .../SignInInteractorRequestInterceptorTest.kt | 210 ++++++++++++++ .../SignUpInteractorRequestInterceptorTest.kt | 274 ++++++++++++++++++ 4 files changed, 966 insertions(+) create mode 100644 common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt create mode 100644 common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt create mode 100644 common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt new file mode 100644 index 0000000000..094db6cd74 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt @@ -0,0 +1,236 @@ +// 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.nativeauth.providers.interactors + +import com.microsoft.identity.common.java.interfaces.IPlatformComponents +import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITChallengeAuthMethodCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITContinueCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITIntrospectCommandParameters +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler +import com.microsoft.identity.common.java.nativeauth.providers.requests.jit.JITChallengeRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.jit.JITContinueRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.jit.JITIntrospectRequest +import com.microsoft.identity.common.java.net.HttpResponse +import com.microsoft.identity.common.java.net.UrlConnectionHttpClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URL + +/** + * Tests verifying that [JITInteractor] correctly applies custom headers + * from a [NativeAuthRequestInterceptor] to outgoing HTTP requests. + */ +class JITInteractorRequestInterceptorTest { + + private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/register/introspect") + + private val mockHttpClient = mockk() + private val mockRequestProvider = mockk() + private val mockResponseHandler = mockk() + + private val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android" + ) + + private val testInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-akamai-sensor" to "sensor-data-123", + "x-fraud-signal" to "signal-abc" + ) + } + } + + private fun createInteractor( + interceptor: NativeAuthRequestInterceptor? = testInterceptor + ): JITInteractor { + return JITInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + } + + private fun setupHttpClientCapture(): io.mockk.CapturingSlot> { + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + return capturedHeaders + } + + private val mockPlatformComponents = mockk(relaxed = true) + + private fun createJITIntrospectParams(): JITIntrospectCommandParameters { + return JITIntrospectCommandParameters.builder() + .platformComponents(mockPlatformComponents) + .correlationId("test-correlation-id") + .continuationToken("test-continuation-token") + .build() + } + + private fun createJITChallengeParams(): JITChallengeAuthMethodCommandParameters { + return JITChallengeAuthMethodCommandParameters.builder() + .platformComponents(mockPlatformComponents) + .correlationId("test-correlation-id") + .continuationToken("test-continuation-token") + .authMethodChallengeType("oob") + .verificationContact("user@contoso.com") + .challengeChannel("email") + .build() + } + + private fun createJITContinueParams(): JITContinueCommandParameters { + return JITContinueCommandParameters.builder() + .platformComponents(mockPlatformComponents) + .correlationId("test-correlation-id") + .continuationToken("test-continuation-token") + .grantType("oob") + .code("123456") + .build() + } + + // region performIntrospect + @Test + fun testInterceptorHeadersAreMergedInPerformIntrospect() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createJITIntrospectRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getJITIntrospectApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performIntrospect(createJITIntrospectParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + assertEquals("signal-abc", capturedHeaders.captured["x-fraud-signal"]) + assertEquals("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformIntrospect() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createJITIntrospectRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getJITIntrospectApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performIntrospect(createJITIntrospectParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } + // endregion + + // region performChallenge + @Test + fun testInterceptorHeadersAreMergedInPerformChallenge() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createJITChallengeRequest(any(), any(), any(), any(), any()) } returns mockRequest + every { mockResponseHandler.getJITChallengeApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performChallenge(createJITChallengeParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performContinue + @Test + fun testInterceptorHeadersAreMergedInPerformContinue() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createJITContinueRequest(any(), any(), any(), any()) } returns mockRequest + every { mockResponseHandler.getJITContinueApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performContinue(createJITContinueParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region reserved header filtering + @Test + fun testInterceptorReservedHeadersAreFilteredInJIT() { + val filteringInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-akamai-sensor" to "valid", + "x-ms-evil" to "should-be-filtered", + "x-client-override" to "should-be-filtered", + "x-app-secret" to "should-be-filtered", + "x-broker-bypass" to "should-be-filtered", + "Authorization" to "should-be-filtered" + ) + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createJITIntrospectRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getJITIntrospectApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = filteringInterceptor) + + interactor.performIntrospect(createJITIntrospectParams()) + + assertTrue(capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + assertTrue(headers.containsKey("x-akamai-sensor")) + assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-evil")) + assertFalse("x-client- prefix should be filtered", headers.containsKey("x-client-override")) + assertFalse("x-app- prefix should be filtered", headers.containsKey("x-app-secret")) + assertFalse("x-broker- prefix should be filtered", headers.containsKey("x-broker-bypass")) + assertFalse("Non x- prefix should be filtered", headers.containsKey("Authorization")) + } + // endregion +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt new file mode 100644 index 0000000000..278d635b97 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt @@ -0,0 +1,246 @@ +// 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.nativeauth.providers.interactors + +import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPasswordStartCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPasswordSubmitCodeCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPasswordSubmitNewPasswordCommandParameters +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler +import com.microsoft.identity.common.java.nativeauth.providers.requests.resetpassword.ResetPasswordChallengeRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.resetpassword.ResetPasswordContinueRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.resetpassword.ResetPasswordPollCompletionRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.resetpassword.ResetPasswordStartRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.resetpassword.ResetPasswordSubmitRequest +import com.microsoft.identity.common.java.net.HttpResponse +import com.microsoft.identity.common.java.net.UrlConnectionHttpClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URL + +/** + * Tests verifying that [ResetPasswordInteractor] correctly applies custom headers + * from a [NativeAuthRequestInterceptor] to outgoing HTTP requests. + */ +class ResetPasswordInteractorRequestInterceptorTest { + + private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/resetpassword/start") + + private val mockHttpClient = mockk() + private val mockRequestProvider = mockk() + private val mockResponseHandler = mockk() + + private val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android" + ) + + private val testInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-akamai-sensor" to "sensor-data-123", + "x-fraud-signal" to "signal-abc" + ) + } + } + + private fun createInteractor( + interceptor: NativeAuthRequestInterceptor? = testInterceptor + ): ResetPasswordInteractor { + return ResetPasswordInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + } + + private fun setupHttpClientCapture(): io.mockk.CapturingSlot> { + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + return capturedHeaders + } + + // region performResetPasswordStart + @Test + fun testInterceptorHeadersAreMergedInPerformResetPasswordStart() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordStartApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performResetPasswordStart(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + assertEquals("signal-abc", capturedHeaders.captured["x-fraud-signal"]) + assertEquals("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordStart() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordStartApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performResetPasswordStart(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } + // endregion + + // region performResetPasswordChallenge + @Test + fun testInterceptorHeadersAreMergedInPerformResetPasswordChallenge() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordChallengeRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordChallengeApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performResetPasswordChallenge(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performResetPasswordContinue + @Test + fun testInterceptorHeadersAreMergedInPerformResetPasswordContinue() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordContinueRequest(any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordContinueApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performResetPasswordContinue(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performResetPasswordSubmit + @Test + fun testInterceptorHeadersAreMergedInPerformResetPasswordSubmit() { + val mockRequestParams = mockk(relaxed = true) + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequest.parameters } returns mockRequestParams + every { mockRequestProvider.createResetPasswordSubmitRequest(any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordSubmitApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performResetPasswordSubmit(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performResetPasswordPollCompletion + @Test + fun testInterceptorHeadersAreMergedInPerformResetPasswordPollCompletion() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordPollCompletionRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordPollCompletionApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performResetPasswordPollCompletion(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region reserved header filtering + @Test + fun testInterceptorReservedHeadersAreFilteredInResetPassword() { + val filteringInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-akamai-sensor" to "valid", + "x-ms-evil" to "should-be-filtered", + "x-client-override" to "should-be-filtered", + "x-app-secret" to "should-be-filtered", + "x-broker-bypass" to "should-be-filtered", + "Authorization" to "should-be-filtered" + ) + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordStartApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = filteringInterceptor) + + interactor.performResetPasswordStart(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + assertTrue(headers.containsKey("x-akamai-sensor")) + assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-evil")) + assertFalse("x-client- prefix should be filtered", headers.containsKey("x-client-override")) + assertFalse("x-app- prefix should be filtered", headers.containsKey("x-app-secret")) + assertFalse("x-broker- prefix should be filtered", headers.containsKey("x-broker-bypass")) + assertFalse("Non x- prefix should be filtered", headers.containsKey("Authorization")) + } + // endregion +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt index dfda491130..c97e06b6d0 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt @@ -23,10 +23,16 @@ package com.microsoft.identity.common.java.nativeauth.providers.interactors import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInStartCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInSubmitCodeCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInSubmitPasswordCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInWithContinuationTokenCommandParameters import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler +import com.microsoft.identity.common.java.nativeauth.providers.requests.signin.SignInChallengeRequest import com.microsoft.identity.common.java.nativeauth.providers.requests.signin.SignInInitiateRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.signin.SignInIntrospectRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.signin.SignInTokenRequest import com.microsoft.identity.common.java.net.HttpResponse import com.microsoft.identity.common.java.net.UrlConnectionHttpClient import io.mockk.every @@ -275,4 +281,208 @@ class SignInInteractorRequestInterceptorTest { assertEquals(1, headers.size) assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) } + + // region performIntrospect + @Test + fun testInterceptorHeadersAreMergedInPerformIntrospect() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequestProvider.createIntrospectRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getSignInIntrospectResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + interactor.performIntrospect(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performSignInDefaultChallenge + @Test + fun testInterceptorHeadersAreMergedInPerformSignInDefaultChallenge() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequestProvider.createSignInDefaultChallengeRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + interactor.performSignInDefaultChallenge(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performSignInSelectedChallenge + @Test + fun testInterceptorHeadersAreMergedInPerformSignInSelectedChallenge() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequestProvider.createSignInSelectedChallengeRequest(any(), any(), any()) } returns mockRequest + every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + interactor.performSignInSelectedChallenge(continuationToken = "token", challengeId = "challenge-1", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performOOBTokenRequest + @Test + fun testInterceptorHeadersAreMergedInPerformOOBTokenRequest() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequestProvider.createOOBTokenRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + interactor.performOOBTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performContinuationTokenTokenRequest + @Test + fun testInterceptorHeadersAreMergedInPerformContinuationTokenTokenRequest() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequestProvider.createContinuationTokenTokenRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + interactor.performContinuationTokenTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performPasswordTokenRequest + @Test + fun testInterceptorHeadersAreMergedInPerformPasswordTokenRequest() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequestProvider.createPasswordTokenRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = SignInInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + + interactor.performPasswordTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion } diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt new file mode 100644 index 0000000000..74d4d38de4 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt @@ -0,0 +1,274 @@ +// 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.nativeauth.providers.interactors + +import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpStartCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpSubmitCodeCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpSubmitPasswordCommandParameters +import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpSubmitUserAttributesCommandParameters +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider +import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler +import com.microsoft.identity.common.java.nativeauth.providers.requests.signup.SignUpChallengeRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.signup.SignUpContinueRequest +import com.microsoft.identity.common.java.nativeauth.providers.requests.signup.SignUpStartRequest +import com.microsoft.identity.common.java.net.HttpResponse +import com.microsoft.identity.common.java.net.UrlConnectionHttpClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URL + +/** + * Tests verifying that [SignUpInteractor] correctly applies custom headers + * from a [NativeAuthRequestInterceptor] to outgoing HTTP requests. + */ +class SignUpInteractorRequestInterceptorTest { + + private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/signup/start") + + private val mockHttpClient = mockk() + private val mockRequestProvider = mockk() + private val mockResponseHandler = mockk() + + private val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android" + ) + + private val testInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-akamai-sensor" to "sensor-data-123", + "x-fraud-signal" to "signal-abc" + ) + } + } + + private fun createInteractor( + interceptor: NativeAuthRequestInterceptor? = testInterceptor + ): SignUpInteractor { + return SignUpInteractor( + httpClient = mockHttpClient, + nativeAuthRequestProvider = mockRequestProvider, + nativeAuthResponseHandler = mockResponseHandler, + requestInterceptor = interceptor + ) + } + + private fun setupHttpClientCapture(): io.mockk.CapturingSlot> { + val capturedHeaders = slot>() + every { + mockHttpClient.post(any(), capture(capturedHeaders), any()) + } returns HttpResponse(200, "{}", emptyMap()) + return capturedHeaders + } + + // region performSignUpStart + @Test + fun testInterceptorHeadersAreMergedInPerformSignUpStart() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignUpStart(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + assertEquals("signal-abc", capturedHeaders.captured["x-fraud-signal"]) + assertEquals("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpStart() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performSignUpStart(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } + // endregion + + // region performSignUpChallenge + @Test + fun testInterceptorHeadersAreMergedInPerformSignUpChallenge() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpChallengeRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getSignUpChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignUpChallenge(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + assertEquals("signal-abc", capturedHeaders.captured["x-fraud-signal"]) + } + // endregion + + // region performSignUpSubmitCode + @Test + fun testInterceptorHeadersAreMergedInPerformSignUpSubmitCode() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpSubmitCodeRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignUpSubmitCode(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performSignUpSubmitPassword + @Test + fun testInterceptorHeadersAreMergedInPerformSignUpSubmitPassword() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpSubmitPasswordRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignUpSubmitPassword(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region performSignUpSubmitUserAttributes + @Test + fun testInterceptorHeadersAreMergedInPerformSignUpSubmitUserAttributes() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpSubmitUserAttributesRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignUpSubmitUserAttributes(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + // endregion + + // region reserved header filtering + @Test + fun testInterceptorReservedHeadersAreFilteredInSignUp() { + val filteringInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-akamai-sensor" to "valid", + "x-ms-evil" to "should-be-filtered", + "x-client-override" to "should-be-filtered", + "x-app-secret" to "should-be-filtered", + "x-broker-bypass" to "should-be-filtered", + "Authorization" to "should-be-filtered" + ) + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = filteringInterceptor) + + interactor.performSignUpStart(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + assertTrue(headers.containsKey("x-akamai-sensor")) + assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-evil")) + assertFalse("x-client- prefix should be filtered", headers.containsKey("x-client-override")) + assertFalse("x-app- prefix should be filtered", headers.containsKey("x-app-secret")) + assertFalse("x-broker- prefix should be filtered", headers.containsKey("x-broker-bypass")) + assertFalse("Non x- prefix should be filtered", headers.containsKey("Authorization")) + } + // endregion + + // region interceptor receives correct URL + @Test + fun testInterceptorReceivesCorrectRequestUrl() { + val capturedUrls = mutableListOf() + val urlCapturingInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + capturedUrls.add(requestUrl) + return mapOf("x-test" to "value") + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + every { + mockHttpClient.post(any(), any>(), any()) + } returns HttpResponse(200, "{}", emptyMap()) + + val interactor = createInteractor(interceptor = urlCapturingInterceptor) + + interactor.performSignUpStart(mockk(relaxed = true)) + + assertEquals(1, capturedUrls.size) + assertEquals(testUrl, capturedUrls[0]) + } + // endregion +} From d98d3e1f924f79d7980d520309a220b99b6186b4 Mon Sep 17 00:00:00 2001 From: fadidurah Date: Thu, 14 May 2026 03:03:47 -0400 Subject: [PATCH 05/18] Build: publish libraries directly to NewAndroid feed Switches Maven publishing from the AndroidADAL feed to the NewAndroid feed to eliminate the upstream-feed traversal latency that occurs when downstream pipeline stages resolve freshly-published artifacts. Background: - Builds publish to AndroidADAL but consumers fetch from NewAndroid. - NewAndroid resolves AndroidADAL only via its upstream chain, after walking Maven Central, Google, etc., causing first-fetch latency on every newly published version (which CI agents always pay due to ephemeral caches). - AndroidADAL will be added as an upstream of NewAndroid (done outside this change) so any external consumers still resolving from AndroidADAL keep working transparently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- LabApiUtilities/build.gradle | 2 +- common/build.gradle | 2 +- common4j/build.gradle | 2 +- keyvault/build.gradle | 2 +- labapi/build.gradle | 2 +- testutils/build.gradle | 2 +- uiautomationutilities/build.gradle | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/LabApiUtilities/build.gradle b/LabApiUtilities/build.gradle index b991327e96..790d6b0601 100644 --- a/LabApiUtilities/build.gradle +++ b/LabApiUtilities/build.gradle @@ -114,7 +114,7 @@ publishing { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/common/build.gradle b/common/build.gradle index 8ef607f715..5da8792b1f 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -420,7 +420,7 @@ afterEvaluate { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" credentials { username project.vstsUsername password project.vstsMavenAccessToken diff --git a/common4j/build.gradle b/common4j/build.gradle index 60dc352ecf..4a17ca2aea 100644 --- a/common4j/build.gradle +++ b/common4j/build.gradle @@ -197,7 +197,7 @@ publishing { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/keyvault/build.gradle b/keyvault/build.gradle index ace7611de8..32ff9cab88 100644 --- a/keyvault/build.gradle +++ b/keyvault/build.gradle @@ -29,7 +29,7 @@ publishing { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/labapi/build.gradle b/labapi/build.gradle index c7be9b067b..58d2d923cc 100644 --- a/labapi/build.gradle +++ b/labapi/build.gradle @@ -30,7 +30,7 @@ publishing { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/testutils/build.gradle b/testutils/build.gradle index f03c50da2e..ba77838c57 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -129,7 +129,7 @@ project.afterEvaluate{ repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/uiautomationutilities/build.gradle b/uiautomationutilities/build.gradle index 0dabacbe89..897c14dedd 100644 --- a/uiautomationutilities/build.gradle +++ b/uiautomationutilities/build.gradle @@ -175,7 +175,7 @@ project.afterEvaluate{ repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken From ce8c4e7027b163824e4c7cd6774f57e89d87181e Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Thu, 14 May 2026 17:22:19 +0100 Subject: [PATCH 06/18] Address PR review comments: improve warn message, document overwrite behavior, add tests - NativeAuthCIAMAuthority: Improve misleading WARN log message to clearly explain that custom headers will not be applied when interceptor type does not match - RequestInterceptorHeaderUtils: Add detailed KDoc explaining case-insensitive merge semantics matching iOS behavior and why reserved SDK headers cannot be overwritten - JITInteractorRequestInterceptorTest: Use 3 base headers (was 2) so null interceptor size assertion is more meaningful - SignUpInteractorRequestInterceptorTest: Add test for reserved header overwrite protection and case-insensitive header merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../authorities/NativeAuthCIAMAuthority.kt | 2 +- .../RequestInterceptorHeaderUtils.kt | 12 ++- .../JITInteractorRequestInterceptorTest.kt | 5 +- .../SignUpInteractorRequestInterceptorTest.kt | 79 +++++++++++++++++++ 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt index 2228316b1b..5c1a2af751 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt @@ -126,7 +126,7 @@ class NativeAuthCIAMAuthority ( @Throws(ClientException::class) override fun createOAuth2Strategy(parameters: OAuth2StrategyParameters): NativeAuthOAuth2Strategy { if (parameters.mRequestInterceptor != null && parameters.mRequestInterceptor !is NativeAuthRequestInterceptor) { - Logger.warn(TAG, "Ignoring non-native OAuth2RequestInterceptor for NativeAuthCIAMAuthority.") + Logger.warn(TAG, "Request interceptor is not a NativeAuthRequestInterceptor instance; custom headers will not be applied to native auth requests.") } val config = createNativeAuthOAuth2Configuration( diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt index 4bfe2184f1..513c9fc4f5 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt @@ -29,13 +29,17 @@ import java.net.URL /** * Applies additional interceptor headers to the base request headers for native auth interactors. * - * Uses case-insensitive merge semantics: interceptor headers replace matching base headers. - * Interceptor headers are validated and normalized to lowercase by [NativeAuthHeaderValidator]. + * Uses case-insensitive merge semantics matching iOS behavior: interceptor headers replace + * matching base headers regardless of casing. Interceptor headers are first validated and + * normalized to lowercase by [NativeAuthHeaderValidator], which filters out any non-`x-` prefixed + * headers and reserved prefixes (`x-ms-`, `x-client-`, `x-broker-`, `x-app-`). This ensures that + * mandatory SDK headers (e.g., `Content-Type`, `x-client-SKU`) cannot be overwritten by the + * interceptor, since they either lack the `x-` prefix or use a reserved prefix. * * @param requestUrl The outbound request URL. * @param headers The base request headers. * @param requestInterceptor Optional interceptor providing additional headers. - * @return The merged headers map with interceptor values taking precedence. + * @return The merged headers map with interceptor values taking precedence for valid custom headers. */ internal fun applyInterceptorHeaders( requestUrl: URL, @@ -48,6 +52,8 @@ internal fun applyInterceptorHeaders( val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) if (validHeaders.isEmpty()) return headers + // Case-insensitive merge: matches iOS's [NSMutableURLRequest setValue:forHTTPHeaderField:] + // which replaces existing headers case-insensitively. val mergedHeaders = headers.toMutableMap() for ((field, value) in validHeaders) { val existingHeader = mergedHeaders.keys.firstOrNull { it.equals(field, ignoreCase = true) } diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt index 094db6cd74..cdd41061db 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt @@ -57,7 +57,8 @@ class JITInteractorRequestInterceptorTest { private val baseHeaders = mapOf( "Content-Type" to "application/x-www-form-urlencoded", - "x-client-SKU" to "MSAL.Android" + "x-client-SKU" to "MSAL.Android", + "Accept" to "application/json" ) private val testInterceptor = object : NativeAuthRequestInterceptor { @@ -153,7 +154,7 @@ class JITInteractorRequestInterceptorTest { interactor.performIntrospect(createJITIntrospectParams()) assertTrue(capturedHeaders.isCaptured) - assertEquals(2, capturedHeaders.captured.size) + assertEquals(3, capturedHeaders.captured.size) assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) } // endregion diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt index 74d4d38de4..c811740545 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt @@ -271,4 +271,83 @@ class SignUpInteractorRequestInterceptorTest { assertEquals(testUrl, capturedUrls[0]) } // endregion + + // region reserved header overwrite protection + @Test + fun testInterceptorCannotOverwriteReservedBaseHeaders() { + val overwriteInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "x-client-SKU" to "Evil.SDK", + "x-ms-request-id" to "fake-id", + "x-akamai-sensor" to "valid-data" + ) + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = overwriteInterceptor) + + interactor.performSignUpStart(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + // Reserved prefix headers from interceptor should be filtered, preserving base values + assertEquals("MSAL.Android", headers["x-client-SKU"]) + assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-request-id")) + // Valid custom header should be merged + assertEquals("valid-data", headers["x-akamai-sensor"]) + } + // endregion + + // region case-insensitive header merge + @Test + fun testCaseInsensitiveHeaderMerge() { + val baseHeadersWithCustom = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android", + "x-existing-custom" to "old-value" + ) + + val caseInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf( + "X-Existing-Custom" to "new-value", + "x-new-header" to "new-data" + ) + } + } + + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeadersWithCustom + every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = caseInterceptor) + + interactor.performSignUpStart(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + val headers = capturedHeaders.captured + // The original casing key should be replaced by the normalized (lowercase) key from validator + assertFalse( + "Original casing key should be removed", + headers.containsKey("x-existing-custom") && headers.containsKey("x-Existing-Custom") + ) + // The value should be the interceptor's new value (validator normalizes to lowercase) + assertEquals("new-value", headers["x-existing-custom"]) + // New header should be added + assertEquals("new-data", headers["x-new-header"]) + // Base reserved headers should be preserved + assertEquals("MSAL.Android", headers["x-client-SKU"]) + } + // endregion } From 4dd84068c5c3526f296daa69a97931f58d93167b Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Thu, 14 May 2026 17:34:46 +0100 Subject: [PATCH 07/18] Consolidate duplicated test logic into RequestInterceptorHeaderUtilsTest Move shared merge logic tests (reserved filtering, case-insensitive merge, overwrite protection, URL passthrough, null/empty interceptor) out of individual interactor tests into RequestInterceptorHeaderUtilsTest. Each interactor test now only verifies wiring: that the interceptor is called for each public method and that null interceptor passes through unchanged. This eliminates ~370 lines of duplicated test code. Test distribution: - RequestInterceptorHeaderUtilsTest: 9 tests (shared merge contract) - NativeAuthHeaderValidatorTest: 12 tests (validation rules) - 4 interactor tests: 24 tests total (per-method wiring) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JITInteractorRequestInterceptorTest.kt | 49 +-- .../RequestInterceptorHeaderUtilsTest.kt | 222 ++++++++--- ...asswordInteractorRequestInterceptorTest.kt | 49 +-- .../SignInInteractorRequestInterceptorTest.kt | 367 +++--------------- .../SignUpInteractorRequestInterceptorTest.kt | 159 +------- 5 files changed, 236 insertions(+), 610 deletions(-) diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt index cdd41061db..c86b85539b 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt @@ -44,8 +44,9 @@ import org.junit.Test import java.net.URL /** - * Tests verifying that [JITInteractor] correctly applies custom headers - * from a [NativeAuthRequestInterceptor] to outgoing HTTP requests. + * Tests verifying that [JITInteractor] correctly wires the request interceptor + * to each public method. Merge logic, filtering, and edge cases are covered by + * [RequestInterceptorHeaderUtilsTest] and [com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidatorTest]. */ class JITInteractorRequestInterceptorTest { @@ -63,10 +64,7 @@ class JITInteractorRequestInterceptorTest { private val testInterceptor = object : NativeAuthRequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-akamai-sensor" to "sensor-data-123", - "x-fraud-signal" to "signal-abc" - ) + return mapOf("x-akamai-sensor" to "sensor-data-123") } } @@ -136,7 +134,6 @@ class JITInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) - assertEquals("signal-abc", capturedHeaders.captured["x-fraud-signal"]) assertEquals("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) } @@ -196,42 +193,4 @@ class JITInteractorRequestInterceptorTest { assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } // endregion - - // region reserved header filtering - @Test - fun testInterceptorReservedHeadersAreFilteredInJIT() { - val filteringInterceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-akamai-sensor" to "valid", - "x-ms-evil" to "should-be-filtered", - "x-client-override" to "should-be-filtered", - "x-app-secret" to "should-be-filtered", - "x-broker-bypass" to "should-be-filtered", - "Authorization" to "should-be-filtered" - ) - } - } - - val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns baseHeaders - every { mockRequestProvider.createJITIntrospectRequest(any(), any()) } returns mockRequest - every { mockResponseHandler.getJITIntrospectApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - - val capturedHeaders = setupHttpClientCapture() - val interactor = createInteractor(interceptor = filteringInterceptor) - - interactor.performIntrospect(createJITIntrospectParams()) - - assertTrue(capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - assertTrue(headers.containsKey("x-akamai-sensor")) - assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-evil")) - assertFalse("x-client- prefix should be filtered", headers.containsKey("x-client-override")) - assertFalse("x-app- prefix should be filtered", headers.containsKey("x-app-secret")) - assertFalse("x-broker- prefix should be filtered", headers.containsKey("x-broker-bypass")) - assertFalse("Non x- prefix should be filtered", headers.containsKey("Authorization")) - } - // endregion } diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt index c8a8751f58..2ea9c4cd91 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt @@ -1,25 +1,25 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. +// Copyright (c) Microsoft Corporation. +// All rights reserved. // -// This code is licensed under the MIT License. +// 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 : +// 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 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. +// 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.nativeauth.providers.interactors import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor @@ -30,91 +30,197 @@ import org.junit.Assert.assertTrue import org.junit.Test import java.net.URL +/** + * Unit tests for [applyInterceptorHeaders], the shared helper that merges + * interceptor-provided custom headers into base request headers. + * + * These tests cover the helper's contract directly, so interactor-level tests + * only need to verify that each interactor method passes headers through + * to the HTTP client (i.e., the wiring, not the merge logic). + */ class RequestInterceptorHeaderUtilsTest { - private val requestUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/initiate") + private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/initiate") + + private val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android", + "Accept" to "application/json" + ) + + // region null / empty interceptor scenarios @Test - fun testApplyInterceptorHeadersReturnsOriginalMapWhenInterceptorIsNull() { - val headers = mapOf("Content-Type" to "application/x-www-form-urlencoded") + fun testNullInterceptorReturnsSameHeaders() { + val result = applyInterceptorHeaders(testUrl, baseHeaders, null) + assertSame("Null interceptor should return the exact same map instance", baseHeaders, result) + } - val result = applyInterceptorHeaders(requestUrl, headers, null) + @Test + fun testInterceptorReturningNullReturnsSameHeaders() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? = null + } + val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) + assertSame("Interceptor returning null should return the exact same map instance", baseHeaders, result) + } - assertSame(headers, result) + @Test + fun testInterceptorReturningEmptyMapReturnsSameHeaders() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? = emptyMap() + } + val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) + assertSame("Interceptor returning empty map should return the exact same map instance", baseHeaders, result) } + // endregion + + // region valid header merge + @Test - fun testApplyInterceptorHeadersMergesValidCustomHeaders() { - val headers = mapOf("Content-Type" to "application/x-www-form-urlencoded") + fun testValidCustomHeadersAreMergedWithBaseHeaders() { val interceptor = object : NativeAuthRequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map { - return mapOf("x-test-header" to "value") + return mapOf( + "x-akamai-sensor" to "sensor-data-123", + "x-fraud-signal" to "signal-abc" + ) } } - val result = applyInterceptorHeaders(requestUrl, headers, interceptor) + val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) - assertEquals(2, result.size) + assertEquals(5, result.size) + assertEquals("sensor-data-123", result["x-akamai-sensor"]) + assertEquals("signal-abc", result["x-fraud-signal"]) assertEquals("application/x-www-form-urlencoded", result["Content-Type"]) - assertEquals("value", result["x-test-header"]) + assertEquals("MSAL.Android", result["x-client-SKU"]) + assertEquals("application/json", result["Accept"]) } + // endregion + + // region reserved header filtering (integration with NativeAuthHeaderValidator) + @Test - fun testApplyInterceptorHeadersFiltersReservedAndNonCustomHeaders() { - val headers = mapOf("Content-Type" to "application/x-www-form-urlencoded") + fun testReservedPrefixHeadersAreFiltered() { val interceptor = object : NativeAuthRequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map { return mapOf( - "x-ms-client-request-id" to "reserved", - "Authorization" to "rejected", - "x-valid" to "kept" + "x-akamai-sensor" to "valid", + "x-ms-evil" to "should-be-filtered", + "x-client-override" to "should-be-filtered", + "x-app-secret" to "should-be-filtered", + "x-broker-bypass" to "should-be-filtered", + "Authorization" to "should-be-filtered" ) } } - val result = applyInterceptorHeaders(requestUrl, headers, interceptor) + val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) - assertEquals(2, result.size) - assertTrue(result.containsKey("x-valid")) - assertFalse(result.containsKey("Authorization")) - assertFalse(result.containsKey("x-ms-client-request-id")) + assertTrue(result.containsKey("x-akamai-sensor")) + assertFalse("x-ms- prefix should be filtered", result.containsKey("x-ms-evil")) + assertFalse("x-client- prefix should be filtered", result.containsKey("x-client-override")) + assertFalse("x-app- prefix should be filtered", result.containsKey("x-app-secret")) + assertFalse("x-broker- prefix should be filtered", result.containsKey("x-broker-bypass")) + assertFalse("Non x- prefix should be filtered", result.containsKey("authorization")) } @Test - fun testApplyInterceptorHeadersMergesCaseInsensitiveWithBaseHeaders() { - val headers = mapOf( - "X-Custom-Header" to "base", - "Content-Type" to "application/x-www-form-urlencoded" + fun testInterceptorCannotOverwriteReservedBaseHeaders() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map { + return mapOf( + "x-client-SKU" to "Evil.SDK", + "x-ms-request-id" to "fake-id", + "x-akamai-sensor" to "valid-data" + ) + } + } + + val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) + + // Reserved prefix headers from interceptor should be filtered, preserving base values + assertEquals("MSAL.Android", result["x-client-SKU"]) + assertFalse("x-ms- prefix should be filtered", result.containsKey("x-ms-request-id")) + // Valid custom header should be merged + assertEquals("valid-data", result["x-akamai-sensor"]) + } + + @Test + fun testAllInvalidHeadersReturnsSameBaseSize() { + val interceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map { + return mapOf( + "x-ms-evil" to "filtered", + "Authorization" to "filtered", + "Content-Type" to "filtered" + ) + } + } + + val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) + + assertEquals(baseHeaders.size, result.size) + assertEquals("application/x-www-form-urlencoded", result["Content-Type"]) + assertEquals("MSAL.Android", result["x-client-SKU"]) + assertEquals("application/json", result["Accept"]) + } + + // endregion + + // region case-insensitive merge + + @Test + fun testCaseInsensitiveHeaderMerge() { + val baseHeadersWithCustom = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android", + "x-existing-custom" to "old-value" ) + val interceptor = object : NativeAuthRequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map { - return mapOf("x-custom-header" to "override") + return mapOf( + "X-Existing-Custom" to "new-value", + "x-new-header" to "new-data" + ) } } - val result = applyInterceptorHeaders(requestUrl, headers, interceptor) + val result = applyInterceptorHeaders(testUrl, baseHeadersWithCustom, interceptor) - assertEquals(2, result.size) - assertEquals("override", result["x-custom-header"]) - assertFalse(result.containsKey("X-Custom-Header")) + // Original casing key should be replaced by the normalized (lowercase) key from validator + assertFalse( + "Original and new casing keys should not both exist", + result.containsKey("x-existing-custom") && result.containsKey("X-Existing-Custom") + ) + assertEquals("new-value", result["x-existing-custom"]) + assertEquals("new-data", result["x-new-header"]) + assertEquals("MSAL.Android", result["x-client-SKU"]) } + // endregion + + // region URL passthrough + @Test - fun testApplyInterceptorHeadersPassesRequestUrlToInterceptor() { - var capturedUrl: URL? = null + fun testInterceptorReceivesCorrectRequestUrl() { + val capturedUrls = mutableListOf() val interceptor = object : NativeAuthRequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { - capturedUrl = requestUrl - return emptyMap() + capturedUrls.add(requestUrl) + return mapOf("x-test" to "value") } } - applyInterceptorHeaders( - requestUrl = requestUrl, - headers = mapOf("Content-Type" to "application/x-www-form-urlencoded"), - requestInterceptor = interceptor - ) + applyInterceptorHeaders(testUrl, baseHeaders, interceptor) - assertEquals(requestUrl, capturedUrl) + assertEquals(1, capturedUrls.size) + assertEquals(testUrl, capturedUrls[0]) } + + // endregion } diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt index 278d635b97..9ac4e38a39 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt @@ -45,8 +45,9 @@ import org.junit.Test import java.net.URL /** - * Tests verifying that [ResetPasswordInteractor] correctly applies custom headers - * from a [NativeAuthRequestInterceptor] to outgoing HTTP requests. + * Tests verifying that [ResetPasswordInteractor] correctly wires the request interceptor + * to each public method. Merge logic, filtering, and edge cases are covered by + * [RequestInterceptorHeaderUtilsTest] and [com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidatorTest]. */ class ResetPasswordInteractorRequestInterceptorTest { @@ -63,10 +64,7 @@ class ResetPasswordInteractorRequestInterceptorTest { private val testInterceptor = object : NativeAuthRequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-akamai-sensor" to "sensor-data-123", - "x-fraud-signal" to "signal-abc" - ) + return mapOf("x-akamai-sensor" to "sensor-data-123") } } @@ -105,7 +103,6 @@ class ResetPasswordInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) - assertEquals("signal-abc", capturedHeaders.captured["x-fraud-signal"]) assertEquals("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) } @@ -205,42 +202,4 @@ class ResetPasswordInteractorRequestInterceptorTest { assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } // endregion - - // region reserved header filtering - @Test - fun testInterceptorReservedHeadersAreFilteredInResetPassword() { - val filteringInterceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-akamai-sensor" to "valid", - "x-ms-evil" to "should-be-filtered", - "x-client-override" to "should-be-filtered", - "x-app-secret" to "should-be-filtered", - "x-broker-bypass" to "should-be-filtered", - "Authorization" to "should-be-filtered" - ) - } - } - - val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns baseHeaders - every { mockRequestProvider.createResetPasswordStartRequest(any()) } returns mockRequest - every { mockResponseHandler.getResetPasswordStartApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - - val capturedHeaders = setupHttpClientCapture() - val interactor = createInteractor(interceptor = filteringInterceptor) - - interactor.performResetPasswordStart(mockk(relaxed = true)) - - assertTrue(capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - assertTrue(headers.containsKey("x-akamai-sensor")) - assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-evil")) - assertFalse("x-client- prefix should be filtered", headers.containsKey("x-client-override")) - assertFalse("x-app- prefix should be filtered", headers.containsKey("x-app-secret")) - assertFalse("x-broker- prefix should be filtered", headers.containsKey("x-broker-bypass")) - assertFalse("Non x- prefix should be filtered", headers.containsKey("Authorization")) - } - // endregion } diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt index c97e06b6d0..c56d820690 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt @@ -38,7 +38,6 @@ import com.microsoft.identity.common.java.net.UrlConnectionHttpClient import io.mockk.every import io.mockk.mockk import io.mockk.slot -import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -46,8 +45,9 @@ import org.junit.Test import java.net.URL /** - * Tests verifying that [SignInInteractor] correctly applies custom headers - * from a [NativeAuthRequestInterceptor] to outgoing HTTP requests. + * Tests verifying that [SignInInteractor] correctly wires the request interceptor + * to each public method. Merge logic, filtering, and edge cases are covered by + * [RequestInterceptorHeaderUtilsTest] and [com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidatorTest]. */ class SignInInteractorRequestInterceptorTest { @@ -57,257 +57,85 @@ class SignInInteractorRequestInterceptorTest { private val mockRequestProvider = mockk() private val mockResponseHandler = mockk() - private fun createMockRequest( - url: URL = testUrl, - headers: Map = mapOf("Content-Type" to "application/x-www-form-urlencoded") - ): SignInInitiateRequest { - val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns url - every { mockRequest.headers } returns headers - return mockRequest - } - - private fun setupMocks(mockRequest: SignInInitiateRequest) { - every { mockRequestProvider.createSignInInitiateRequest(any()) } returns mockRequest - every { - mockResponseHandler.getSignInInitiateResultFromHttpResponse(any(), any()) - } returns mockk(relaxed = true) - } + private val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android" + ) - @Test - fun testInterceptorHeadersAreMergedIntoRequest() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-akamai-sensor" to "sensor-data-123", - "x-fraud-signal" to "signal-abc" - ) - } + private val testInterceptor = object : NativeAuthRequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") } - - val baseHeaders = mapOf( - "Content-Type" to "application/x-www-form-urlencoded", - "x-client-SKU" to "MSAL.Android" - ) - val mockRequest = createMockRequest(headers = baseHeaders) - setupMocks(mockRequest) - - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) - - val mockParameters = mockk(relaxed = true) - interactor.performSignInInitiate(mockParameters) - - assertTrue("Headers should have been captured", capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - assertEquals("sensor-data-123", headers["x-akamai-sensor"]) - assertEquals("signal-abc", headers["x-fraud-signal"]) - assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) - assertEquals("MSAL.Android", headers["x-client-SKU"]) } - @Test - fun testNullInterceptorDoesNotModifyHeaders() { - val baseHeaders = mapOf( - "Content-Type" to "application/x-www-form-urlencoded" - ) - val mockRequest = createMockRequest(headers = baseHeaders) - setupMocks(mockRequest) - - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = null - ) - - val mockParameters = mockk(relaxed = true) - interactor.performSignInInitiate(mockParameters) - - assertTrue("Headers should have been captured", capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - assertEquals(1, headers.size) - assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) - } - - @Test - fun testInterceptorReturningNullDoesNotModifyHeaders() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? = null - } - - val baseHeaders = mapOf( - "Content-Type" to "application/x-www-form-urlencoded" - ) - val mockRequest = createMockRequest(headers = baseHeaders) - setupMocks(mockRequest) - - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( + private fun createInteractor( + interceptor: NativeAuthRequestInterceptor? = testInterceptor + ): SignInInteractor { + return SignInInteractor( httpClient = mockHttpClient, nativeAuthRequestProvider = mockRequestProvider, nativeAuthResponseHandler = mockResponseHandler, requestInterceptor = interceptor ) - - val mockParameters = mockk(relaxed = true) - interactor.performSignInInitiate(mockParameters) - - assertTrue("Headers should have been captured", capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - assertEquals(1, headers.size) } - @Test - fun testInterceptorReservedHeadersAreFiltered() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-akamai-sensor" to "valid", - "x-ms-evil" to "should-be-filtered", - "x-client-override" to "should-be-filtered", - "Authorization" to "should-be-filtered" - ) - } - } - - val mockRequest = createMockRequest() - setupMocks(mockRequest) - + private fun setupHttpClientCapture(): io.mockk.CapturingSlot> { val capturedHeaders = slot>() every { mockHttpClient.post(any(), capture(capturedHeaders), any()) } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) - - val mockParameters = mockk(relaxed = true) - interactor.performSignInInitiate(mockParameters) - - assertTrue("Headers should have been captured", capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - assertTrue(headers.containsKey("x-akamai-sensor")) - assertEquals("valid", headers["x-akamai-sensor"]) - assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-evil")) - assertFalse("x-client- prefix should be filtered", headers.containsKey("x-client-override")) - assertFalse("Non x- prefix should be filtered", headers.containsKey("Authorization")) + return capturedHeaders } + // region performSignInInitiate @Test - fun testInterceptorReceivesCorrectRequestUrl() { - val capturedUrls = mutableListOf() - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - capturedUrls.add(requestUrl) - return mapOf("x-test" to "value") - } - } - - val mockRequest = createMockRequest() - setupMocks(mockRequest) + fun testInterceptorHeadersAreMergedInPerformSignInInitiate() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignInInitiateRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignInInitiateResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - every { - mockHttpClient.post(any(), any>(), any()) - } returns HttpResponse(200, "{}", emptyMap()) + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) + interactor.performSignInInitiate(mockk(relaxed = true)) - val mockParameters = mockk(relaxed = true) - interactor.performSignInInitiate(mockParameters) - - assertEquals("Interceptor should have been called once", 1, capturedUrls.size) - assertEquals(testUrl, capturedUrls[0]) + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + assertEquals("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) } @Test - fun testInterceptorEmptyHeadersDoNotModifyRequest() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return emptyMap() - } - } - - val baseHeaders = mapOf( - "Content-Type" to "application/x-www-form-urlencoded" - ) - val mockRequest = createMockRequest(headers = baseHeaders) - setupMocks(mockRequest) - - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) + fun testNullInterceptorDoesNotModifyHeaders() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignInInitiateRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignInInitiateResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) - val mockParameters = mockk(relaxed = true) - interactor.performSignInInitiate(mockParameters) + interactor.performSignInInitiate(mockk(relaxed = true)) - assertTrue("Headers should have been captured", capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - assertEquals(1, headers.size) - assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) } + // endregion // region performIntrospect @Test fun testInterceptorHeadersAreMergedInPerformIntrospect() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf("x-akamai-sensor" to "sensor-data-123") - } - } - val mockRequest = mockk(relaxed = true) every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createIntrospectRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getSignInIntrospectResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() interactor.performIntrospect(continuationToken = "token", correlationId = "corr-id") @@ -319,29 +147,14 @@ class SignInInteractorRequestInterceptorTest { // region performSignInDefaultChallenge @Test fun testInterceptorHeadersAreMergedInPerformSignInDefaultChallenge() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf("x-akamai-sensor" to "sensor-data-123") - } - } - val mockRequest = mockk(relaxed = true) every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignInDefaultChallengeRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() interactor.performSignInDefaultChallenge(continuationToken = "token", correlationId = "corr-id") @@ -353,29 +166,14 @@ class SignInInteractorRequestInterceptorTest { // region performSignInSelectedChallenge @Test fun testInterceptorHeadersAreMergedInPerformSignInSelectedChallenge() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf("x-akamai-sensor" to "sensor-data-123") - } - } - val mockRequest = mockk(relaxed = true) every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignInSelectedChallengeRequest(any(), any(), any()) } returns mockRequest every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() interactor.performSignInSelectedChallenge(continuationToken = "token", challengeId = "challenge-1", correlationId = "corr-id") @@ -387,29 +185,14 @@ class SignInInteractorRequestInterceptorTest { // region performOOBTokenRequest @Test fun testInterceptorHeadersAreMergedInPerformOOBTokenRequest() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf("x-akamai-sensor" to "sensor-data-123") - } - } - val mockRequest = mockk(relaxed = true) every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createOOBTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() interactor.performOOBTokenRequest(mockk(relaxed = true)) @@ -421,29 +204,14 @@ class SignInInteractorRequestInterceptorTest { // region performContinuationTokenTokenRequest @Test fun testInterceptorHeadersAreMergedInPerformContinuationTokenTokenRequest() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf("x-akamai-sensor" to "sensor-data-123") - } - } - val mockRequest = mockk(relaxed = true) every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createContinuationTokenTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() interactor.performContinuationTokenTokenRequest(mockk(relaxed = true)) @@ -455,29 +223,14 @@ class SignInInteractorRequestInterceptorTest { // region performPasswordTokenRequest @Test fun testInterceptorHeadersAreMergedInPerformPasswordTokenRequest() { - val interceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf("x-akamai-sensor" to "sensor-data-123") - } - } - val mockRequest = mockk(relaxed = true) every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns mapOf("Content-Type" to "application/x-www-form-urlencoded") + every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createPasswordTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - val capturedHeaders = slot>() - every { - mockHttpClient.post(any(), capture(capturedHeaders), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = SignInInteractor( - httpClient = mockHttpClient, - nativeAuthRequestProvider = mockRequestProvider, - nativeAuthResponseHandler = mockResponseHandler, - requestInterceptor = interceptor - ) + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() interactor.performPasswordTokenRequest(mockk(relaxed = true)) diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt index c811740545..0461b56dc3 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt @@ -37,7 +37,6 @@ import com.microsoft.identity.common.java.net.UrlConnectionHttpClient import io.mockk.every import io.mockk.mockk import io.mockk.slot -import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -45,8 +44,9 @@ import org.junit.Test import java.net.URL /** - * Tests verifying that [SignUpInteractor] correctly applies custom headers - * from a [NativeAuthRequestInterceptor] to outgoing HTTP requests. + * Tests verifying that [SignUpInteractor] correctly wires the request interceptor + * to each public method. Merge logic, filtering, and edge cases are covered by + * [RequestInterceptorHeaderUtilsTest] and [com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidatorTest]. */ class SignUpInteractorRequestInterceptorTest { @@ -63,10 +63,7 @@ class SignUpInteractorRequestInterceptorTest { private val testInterceptor = object : NativeAuthRequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-akamai-sensor" to "sensor-data-123", - "x-fraud-signal" to "signal-abc" - ) + return mapOf("x-akamai-sensor" to "sensor-data-123") } } @@ -105,7 +102,6 @@ class SignUpInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) - assertEquals("signal-abc", capturedHeaders.captured["x-fraud-signal"]) assertEquals("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) } @@ -144,7 +140,6 @@ class SignUpInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) - assertEquals("signal-abc", capturedHeaders.captured["x-fraud-signal"]) } // endregion @@ -204,150 +199,4 @@ class SignUpInteractorRequestInterceptorTest { assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } // endregion - - // region reserved header filtering - @Test - fun testInterceptorReservedHeadersAreFilteredInSignUp() { - val filteringInterceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-akamai-sensor" to "valid", - "x-ms-evil" to "should-be-filtered", - "x-client-override" to "should-be-filtered", - "x-app-secret" to "should-be-filtered", - "x-broker-bypass" to "should-be-filtered", - "Authorization" to "should-be-filtered" - ) - } - } - - val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns baseHeaders - every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest - every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - - val capturedHeaders = setupHttpClientCapture() - val interactor = createInteractor(interceptor = filteringInterceptor) - - interactor.performSignUpStart(mockk(relaxed = true)) - - assertTrue(capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - assertTrue(headers.containsKey("x-akamai-sensor")) - assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-evil")) - assertFalse("x-client- prefix should be filtered", headers.containsKey("x-client-override")) - assertFalse("x-app- prefix should be filtered", headers.containsKey("x-app-secret")) - assertFalse("x-broker- prefix should be filtered", headers.containsKey("x-broker-bypass")) - assertFalse("Non x- prefix should be filtered", headers.containsKey("Authorization")) - } - // endregion - - // region interceptor receives correct URL - @Test - fun testInterceptorReceivesCorrectRequestUrl() { - val capturedUrls = mutableListOf() - val urlCapturingInterceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - capturedUrls.add(requestUrl) - return mapOf("x-test" to "value") - } - } - - val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns baseHeaders - every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest - every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - every { - mockHttpClient.post(any(), any>(), any()) - } returns HttpResponse(200, "{}", emptyMap()) - - val interactor = createInteractor(interceptor = urlCapturingInterceptor) - - interactor.performSignUpStart(mockk(relaxed = true)) - - assertEquals(1, capturedUrls.size) - assertEquals(testUrl, capturedUrls[0]) - } - // endregion - - // region reserved header overwrite protection - @Test - fun testInterceptorCannotOverwriteReservedBaseHeaders() { - val overwriteInterceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "x-client-SKU" to "Evil.SDK", - "x-ms-request-id" to "fake-id", - "x-akamai-sensor" to "valid-data" - ) - } - } - - val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns baseHeaders - every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest - every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - - val capturedHeaders = setupHttpClientCapture() - val interactor = createInteractor(interceptor = overwriteInterceptor) - - interactor.performSignUpStart(mockk(relaxed = true)) - - assertTrue(capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - // Reserved prefix headers from interceptor should be filtered, preserving base values - assertEquals("MSAL.Android", headers["x-client-SKU"]) - assertFalse("x-ms- prefix should be filtered", headers.containsKey("x-ms-request-id")) - // Valid custom header should be merged - assertEquals("valid-data", headers["x-akamai-sensor"]) - } - // endregion - - // region case-insensitive header merge - @Test - fun testCaseInsensitiveHeaderMerge() { - val baseHeadersWithCustom = mapOf( - "Content-Type" to "application/x-www-form-urlencoded", - "x-client-SKU" to "MSAL.Android", - "x-existing-custom" to "old-value" - ) - - val caseInterceptor = object : NativeAuthRequestInterceptor { - override fun additionalHeaders(requestUrl: URL): Map? { - return mapOf( - "X-Existing-Custom" to "new-value", - "x-new-header" to "new-data" - ) - } - } - - val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl - every { mockRequest.headers } returns baseHeadersWithCustom - every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest - every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) - - val capturedHeaders = setupHttpClientCapture() - val interactor = createInteractor(interceptor = caseInterceptor) - - interactor.performSignUpStart(mockk(relaxed = true)) - - assertTrue(capturedHeaders.isCaptured) - val headers = capturedHeaders.captured - // The original casing key should be replaced by the normalized (lowercase) key from validator - assertFalse( - "Original casing key should be removed", - headers.containsKey("x-existing-custom") && headers.containsKey("x-Existing-Custom") - ) - // The value should be the interceptor's new value (validator normalizes to lowercase) - assertEquals("new-value", headers["x-existing-custom"]) - // New header should be added - assertEquals("new-data", headers["x-new-header"]) - // Base reserved headers should be preserved - assertEquals("MSAL.Android", headers["x-client-SKU"]) - } - // endregion } From baae2796cc17dde60fbdc0122df76ac8a0a1894a Mon Sep 17 00:00:00 2001 From: fadidurah Date: Thu, 14 May 2026 15:46:16 -0400 Subject: [PATCH 08/18] common4j: don't tie sourcesJar/javadocJar to assemble These tasks are already wired into the publish graph through components.java + the maven-publish plugin, so they will be generated automatically when publish runs. Tying them to assemble made every CI flow that just compiles or runs tests also build javadoc, which is one of the slowest tasks in the common4j build. Local developers who want the artifacts can still run ./gradlew sourcesJar javadocJar explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- common4j/build.gradle | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/common4j/build.gradle b/common4j/build.gradle index 4a17ca2aea..716ae1c664 100644 --- a/common4j/build.gradle +++ b/common4j/build.gradle @@ -339,8 +339,11 @@ buildConfig { } afterEvaluate { - assemble.dependsOn sourcesJar, javadocJar - + // sourcesJar and javadocJar are wired into the publish graph via components.java + maven-publish, + // so they will run automatically as part of `publish`. There is no need to also tie them to + // `assemble`, which made every CI build (including test-only / spotbugs-only flows) generate + // javadoc unnecessarily. Generating javadoc for common4j is one of the slower tasks in the build. + // Local devs who explicitly want the artifacts can still run `./gradlew sourcesJar javadocJar`. // these tasks are generated by the buildconfig plugin..for more details, read comment about it // above in the plugins block. compileJava.dependsOn generateBuildConfig, generateTestBuildConfig From 46c3b3110ef4e4e05af90b6be9319c6004c4f7f1 Mon Sep 17 00:00:00 2001 From: fadidurah Date: Thu, 14 May 2026 23:32:00 -0400 Subject: [PATCH 09/18] Add gradle configure-on-demand for build perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When configure-on-demand is enabled, Gradle only configures the projects that are actually in the dependency graph of the requested tasks. Previously building AADAuthenticator triggered configure of broker4j, brokerHost, java-linux-test-app, LinuxBroker, brokerautomationapp etc. — none of which are needed for AADAuthenticator:assembleDistRelease. Logs showed ~16 min spent in unnecessary configure phases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gradle.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle.properties b/gradle.properties index ae84629666..f652101c39 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,6 +6,7 @@ android.useAndroidX=true # https://office.visualstudio.com/Outlook%20Mobile/_wiki/wikis/Outlook-Mobile.wiki/3780/Android-Studio-Gradle-Performance-tips-and-tricks org.gradle.parallel=true org.gradle.daemon=true +org.gradle.configureondemand=true # See https://stackoverflow.com/questions/56075455/expiring-daemon-because-jvm-heap-space-is-exhausted # we must make sure that the total size is <7G, as that's the RAM size of VM on the build pipeline. From bf80e0b66641c7e30d6805439df5caf443032664 Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Fri, 15 May 2026 09:42:30 +0100 Subject: [PATCH 10/18] Updated message --- .../providers/interactors/RequestInterceptorHeaderUtils.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt index 513c9fc4f5..c4655502dc 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt @@ -49,11 +49,10 @@ internal fun applyInterceptorHeaders( if (requestInterceptor == null) return headers val additionalHeaders = requestInterceptor.additionalHeaders(requestUrl) ?: return headers + // For case-insensitive merge, the headers in RESERVED_PREFIXES are filtered out val validHeaders = NativeAuthHeaderValidator.filterValidHeaders(additionalHeaders) if (validHeaders.isEmpty()) return headers - - // Case-insensitive merge: matches iOS's [NSMutableURLRequest setValue:forHTTPHeaderField:] - // which replaces existing headers case-insensitively. + val mergedHeaders = headers.toMutableMap() for ((field, value) in validHeaders) { val existingHeader = mergedHeaders.keys.firstOrNull { it.equals(field, ignoreCase = true) } From 521100e86c8a88d188ca990869d9a8fd00d51a17 Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Fri, 15 May 2026 09:46:24 +0100 Subject: [PATCH 11/18] Updated comment --- .../interactors/RequestInterceptorHeaderUtils.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt index c4655502dc..40fde3c48c 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt @@ -29,12 +29,11 @@ import java.net.URL /** * Applies additional interceptor headers to the base request headers for native auth interactors. * - * Uses case-insensitive merge semantics matching iOS behavior: interceptor headers replace - * matching base headers regardless of casing. Interceptor headers are first validated and - * normalized to lowercase by [NativeAuthHeaderValidator], which filters out any non-`x-` prefixed - * headers and reserved prefixes (`x-ms-`, `x-client-`, `x-broker-`, `x-app-`). This ensures that - * mandatory SDK headers (e.g., `Content-Type`, `x-client-SKU`) cannot be overwritten by the - * interceptor, since they either lack the `x-` prefix or use a reserved prefix. + * Interceptor headers replace matching base headers regardless of casing. Interceptor headers are + * first validated and normalized to lowercase by [NativeAuthHeaderValidator], which filters out + * any non-`x-` prefixed headers and reserved prefixes (`x-ms-`, `x-client-`, `x-broker-`, `x-app-`). + * This ensures that mandatory SDK headers (e.g., `Content-Type`, `x-client-SKU`) cannot be + * overwritten by the interceptor, since they either lack the `x-` prefix or use a reserved prefix. * * @param requestUrl The outbound request URL. * @param headers The base request headers. From d91d6bfdb707d3a779f31e720cb1e3c960972407 Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Fri, 15 May 2026 09:59:44 +0100 Subject: [PATCH 12/18] Add symmetric null-interceptor tests for all JIT methods Each interactor method now has both a merge test and a null-interceptor test for full symmetry across SignIn, SignUp, ResetPassword, and JIT. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JITInteractorRequestInterceptorTest.kt | 36 ++++++ ...asswordInteractorRequestInterceptorTest.kt | 74 ++++++++++++ .../SignInInteractorRequestInterceptorTest.kt | 108 ++++++++++++++++++ .../SignUpInteractorRequestInterceptorTest.kt | 72 ++++++++++++ 4 files changed, 290 insertions(+) diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt index c86b85539b..82c37a460c 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt @@ -173,6 +173,24 @@ class JITInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformChallenge() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createJITChallengeRequest(any(), any(), any(), any(), any()) } returns mockRequest + every { mockResponseHandler.getJITChallengeApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performChallenge(createJITChallengeParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(3, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performContinue @@ -192,5 +210,23 @@ class JITInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformContinue() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createJITContinueRequest(any(), any(), any(), any()) } returns mockRequest + every { mockResponseHandler.getJITContinueApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performContinue(createJITContinueParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(3, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion } diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt index 9ac4e38a39..df828dbcb3 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt @@ -142,6 +142,24 @@ class ResetPasswordInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordChallenge() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordChallengeRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordChallengeApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performResetPasswordChallenge(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performResetPasswordContinue @@ -161,6 +179,24 @@ class ResetPasswordInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordContinue() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordContinueRequest(any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordContinueApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performResetPasswordContinue(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performResetPasswordSubmit @@ -182,6 +218,26 @@ class ResetPasswordInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordSubmit() { + val mockRequestParams = mockk(relaxed = true) + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequest.parameters } returns mockRequestParams + every { mockRequestProvider.createResetPasswordSubmitRequest(any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordSubmitApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performResetPasswordSubmit(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performResetPasswordPollCompletion @@ -201,5 +257,23 @@ class ResetPasswordInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordPollCompletion() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createResetPasswordPollCompletionRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getResetPasswordPollCompletionApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performResetPasswordPollCompletion(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion } diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt index c56d820690..3e1028c81f 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt @@ -142,6 +142,24 @@ class SignInInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformIntrospect() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createIntrospectRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getSignInIntrospectResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performIntrospect(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performSignInDefaultChallenge @@ -161,6 +179,24 @@ class SignInInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignInDefaultChallenge() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignInDefaultChallengeRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performSignInDefaultChallenge(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performSignInSelectedChallenge @@ -180,6 +216,24 @@ class SignInInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignInSelectedChallenge() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignInSelectedChallengeRequest(any(), any(), any()) } returns mockRequest + every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performSignInSelectedChallenge(continuationToken = "token", challengeId = "challenge-1", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performOOBTokenRequest @@ -199,6 +253,24 @@ class SignInInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformOOBTokenRequest() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createOOBTokenRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performOOBTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performContinuationTokenTokenRequest @@ -218,6 +290,24 @@ class SignInInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformContinuationTokenTokenRequest() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createContinuationTokenTokenRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performContinuationTokenTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performPasswordTokenRequest @@ -237,5 +327,23 @@ class SignInInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformPasswordTokenRequest() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createPasswordTokenRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performPasswordTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion } diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt index 0461b56dc3..3c3a1477c2 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt @@ -141,6 +141,24 @@ class SignUpInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpChallenge() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpChallengeRequest(any(), any()) } returns mockRequest + every { mockResponseHandler.getSignUpChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performSignUpChallenge(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performSignUpSubmitCode @@ -160,6 +178,24 @@ class SignUpInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitCode() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpSubmitCodeRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performSignUpSubmitCode(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performSignUpSubmitPassword @@ -179,6 +215,24 @@ class SignUpInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitPassword() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpSubmitPasswordRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performSignUpSubmitPassword(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion // region performSignUpSubmitUserAttributes @@ -198,5 +252,23 @@ class SignUpInteractorRequestInterceptorTest { assertTrue(capturedHeaders.isCaptured) assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitUserAttributes() { + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns testUrl + every { mockRequest.headers } returns baseHeaders + every { mockRequestProvider.createSignUpSubmitUserAttributesRequest(any()) } returns mockRequest + every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performSignUpSubmitUserAttributes(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } // endregion } From 89f5860bb8d1a60d6ecf2b718ed4965b3971495d Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Fri, 15 May 2026 11:30:57 +0100 Subject: [PATCH 13/18] Use realistic per-method endpoint URLs in interceptor tests Each test now uses the actual production endpoint URL for its method (e.g., /signup/v1.0/challenge for performChallenge) instead of a single shared URL per file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JITInteractorRequestInterceptorTest.kt | 16 +++++---- ...asswordInteractorRequestInterceptorTest.kt | 26 ++++++++------- .../SignInInteractorRequestInterceptorTest.kt | 33 ++++++++++--------- .../SignUpInteractorRequestInterceptorTest.kt | 24 +++++++------- 4 files changed, 55 insertions(+), 44 deletions(-) diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt index 82c37a460c..fa28f668cc 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt @@ -50,7 +50,9 @@ import java.net.URL */ class JITInteractorRequestInterceptorTest { - private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/register/introspect") + private val introspectUrl = URL("https://contoso.ciamlogin.com/register/v1.0/introspect") + private val challengeUrl = URL("https://contoso.ciamlogin.com/register/v1.0/challenge") + private val continueUrl = URL("https://contoso.ciamlogin.com/register/v1.0/continue") private val mockHttpClient = mockk() private val mockRequestProvider = mockk() @@ -122,7 +124,7 @@ class JITInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformIntrospect() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns introspectUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createJITIntrospectRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getJITIntrospectApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -140,7 +142,7 @@ class JITInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformIntrospect() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns introspectUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createJITIntrospectRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getJITIntrospectApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -160,7 +162,7 @@ class JITInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createJITChallengeRequest(any(), any(), any(), any(), any()) } returns mockRequest every { mockResponseHandler.getJITChallengeApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -177,7 +179,7 @@ class JITInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createJITChallengeRequest(any(), any(), any(), any(), any()) } returns mockRequest every { mockResponseHandler.getJITChallengeApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -197,7 +199,7 @@ class JITInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformContinue() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createJITContinueRequest(any(), any(), any(), any()) } returns mockRequest every { mockResponseHandler.getJITContinueApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -214,7 +216,7 @@ class JITInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformContinue() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createJITContinueRequest(any(), any(), any(), any()) } returns mockRequest every { mockResponseHandler.getJITContinueApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt index df828dbcb3..062bc22d59 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt @@ -51,7 +51,11 @@ import java.net.URL */ class ResetPasswordInteractorRequestInterceptorTest { - private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/resetpassword/start") + private val startUrl = URL("https://contoso.ciamlogin.com/resetpassword/v1.0/start") + private val challengeUrl = URL("https://contoso.ciamlogin.com/resetpassword/v1.0/challenge") + private val continueUrl = URL("https://contoso.ciamlogin.com/resetpassword/v1.0/continue") + private val submitUrl = URL("https://contoso.ciamlogin.com/resetpassword/v1.0/submit") + private val pollCompletionUrl = URL("https://contoso.ciamlogin.com/resetpassword/v1.0/poll_completion") private val mockHttpClient = mockk() private val mockRequestProvider = mockk() @@ -91,7 +95,7 @@ class ResetPasswordInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformResetPasswordStart() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns startUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createResetPasswordStartRequest(any()) } returns mockRequest every { mockResponseHandler.getResetPasswordStartApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -109,7 +113,7 @@ class ResetPasswordInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordStart() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns startUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createResetPasswordStartRequest(any()) } returns mockRequest every { mockResponseHandler.getResetPasswordStartApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -129,7 +133,7 @@ class ResetPasswordInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformResetPasswordChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createResetPasswordChallengeRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getResetPasswordChallengeApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -146,7 +150,7 @@ class ResetPasswordInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createResetPasswordChallengeRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getResetPasswordChallengeApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -166,7 +170,7 @@ class ResetPasswordInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformResetPasswordContinue() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createResetPasswordContinueRequest(any()) } returns mockRequest every { mockResponseHandler.getResetPasswordContinueApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -183,7 +187,7 @@ class ResetPasswordInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordContinue() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createResetPasswordContinueRequest(any()) } returns mockRequest every { mockResponseHandler.getResetPasswordContinueApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -204,7 +208,7 @@ class ResetPasswordInteractorRequestInterceptorTest { fun testInterceptorHeadersAreMergedInPerformResetPasswordSubmit() { val mockRequestParams = mockk(relaxed = true) val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns submitUrl every { mockRequest.headers } returns baseHeaders every { mockRequest.parameters } returns mockRequestParams every { mockRequestProvider.createResetPasswordSubmitRequest(any()) } returns mockRequest @@ -223,7 +227,7 @@ class ResetPasswordInteractorRequestInterceptorTest { fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordSubmit() { val mockRequestParams = mockk(relaxed = true) val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns submitUrl every { mockRequest.headers } returns baseHeaders every { mockRequest.parameters } returns mockRequestParams every { mockRequestProvider.createResetPasswordSubmitRequest(any()) } returns mockRequest @@ -244,7 +248,7 @@ class ResetPasswordInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformResetPasswordPollCompletion() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns pollCompletionUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createResetPasswordPollCompletionRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getResetPasswordPollCompletionApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -261,7 +265,7 @@ class ResetPasswordInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordPollCompletion() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns pollCompletionUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createResetPasswordPollCompletionRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getResetPasswordPollCompletionApiResponseFromHttpResponse(any(), any()) } returns mockk(relaxed = true) diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt index 3e1028c81f..23ca9bcc1c 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt @@ -51,7 +51,10 @@ import java.net.URL */ class SignInInteractorRequestInterceptorTest { - private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/initiate") + private val initiateUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/initiate") + private val introspectUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/introspect") + private val challengeUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/challenge") + private val tokenUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/token") private val mockHttpClient = mockk() private val mockRequestProvider = mockk() @@ -91,7 +94,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformSignInInitiate() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns initiateUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignInInitiateRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInInitiateResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -109,7 +112,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeaders() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns initiateUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignInInitiateRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInInitiateResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -129,7 +132,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformIntrospect() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns introspectUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createIntrospectRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getSignInIntrospectResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -146,7 +149,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformIntrospect() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns introspectUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createIntrospectRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getSignInIntrospectResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -166,7 +169,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformSignInDefaultChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignInDefaultChallengeRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -183,7 +186,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformSignInDefaultChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignInDefaultChallengeRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -203,7 +206,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformSignInSelectedChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignInSelectedChallengeRequest(any(), any(), any()) } returns mockRequest every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -220,7 +223,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformSignInSelectedChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignInSelectedChallengeRequest(any(), any(), any()) } returns mockRequest every { mockResponseHandler.getSignInChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -240,7 +243,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformOOBTokenRequest() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns tokenUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createOOBTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -257,7 +260,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformOOBTokenRequest() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns tokenUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createOOBTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -277,7 +280,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformContinuationTokenTokenRequest() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns tokenUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createContinuationTokenTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -294,7 +297,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformContinuationTokenTokenRequest() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns tokenUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createContinuationTokenTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -314,7 +317,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformPasswordTokenRequest() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns tokenUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createPasswordTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -331,7 +334,7 @@ class SignInInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformPasswordTokenRequest() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns tokenUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createPasswordTokenRequest(any()) } returns mockRequest every { mockResponseHandler.getSignInTokenApiResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt index 3c3a1477c2..125c86aea7 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt @@ -50,7 +50,9 @@ import java.net.URL */ class SignUpInteractorRequestInterceptorTest { - private val testUrl = URL("https://contoso.ciamlogin.com/oauth2/v2.0/signup/start") + private val startUrl = URL("https://contoso.ciamlogin.com/signup/v1.0/start") + private val challengeUrl = URL("https://contoso.ciamlogin.com/signup/v1.0/challenge") + private val continueUrl = URL("https://contoso.ciamlogin.com/signup/v1.0/continue") private val mockHttpClient = mockk() private val mockRequestProvider = mockk() @@ -90,7 +92,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformSignUpStart() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns startUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -108,7 +110,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpStart() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns startUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpStartRequest(any()) } returns mockRequest every { mockResponseHandler.getSignUpStartResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -128,7 +130,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformSignUpChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpChallengeRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getSignUpChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -145,7 +147,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpChallenge() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns challengeUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpChallengeRequest(any(), any()) } returns mockRequest every { mockResponseHandler.getSignUpChallengeResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -165,7 +167,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformSignUpSubmitCode() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpSubmitCodeRequest(any()) } returns mockRequest every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -182,7 +184,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitCode() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpSubmitCodeRequest(any()) } returns mockRequest every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -202,7 +204,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformSignUpSubmitPassword() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpSubmitPasswordRequest(any()) } returns mockRequest every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -219,7 +221,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitPassword() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpSubmitPasswordRequest(any()) } returns mockRequest every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -239,7 +241,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testInterceptorHeadersAreMergedInPerformSignUpSubmitUserAttributes() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpSubmitUserAttributesRequest(any()) } returns mockRequest every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) @@ -256,7 +258,7 @@ class SignUpInteractorRequestInterceptorTest { @Test fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitUserAttributes() { val mockRequest = mockk(relaxed = true) - every { mockRequest.requestUrl } returns testUrl + every { mockRequest.requestUrl } returns continueUrl every { mockRequest.headers } returns baseHeaders every { mockRequestProvider.createSignUpSubmitUserAttributesRequest(any()) } returns mockRequest every { mockResponseHandler.getSignUpContinueResultFromHttpResponse(any(), any()) } returns mockk(relaxed = true) From d313d386c3046ddc621e16f9df145193fe1b4a45 Mon Sep 17 00:00:00 2001 From: fadidurah Date: Thu, 21 May 2026 12:27:34 -0400 Subject: [PATCH 14/18] Fix POM: write current version for project deps to avoid 'unspecified' The publishing block in testutils and uiautomationutilities iterates implementation dependencies to populate the generated POM. For project deps like project(':keyvault'), it.version reads project(':keyvault').version -- which Gradle defaults to the string 'unspecified' if that sibling project hasn't been configured yet. With Gradle configure-on-demand enabled, the publish task for common may run without configuring sibling projects like :keyvault, :labapi, :common, etc. The published POM then contained 'keyvault:unspecified', which caused downstream consumers (AADAuthenticator distDebugUnitTest) to fail at dependency resolution. Fix: for project dependencies, write the current artifact's version (getAppVersionName()) since all sibling library projects share the same version. External dependencies still use it.version as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testutils/build.gradle | 9 ++++++++- uiautomationutilities/build.gradle | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/testutils/build.gradle b/testutils/build.gradle index ba77838c57..89c8a55e8e 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -119,7 +119,14 @@ project.afterEvaluate{ def dependencyNode = dependenciesNode.appendNode('dependency') dependencyNode.appendNode('groupId', it.group) dependencyNode.appendNode('artifactId', it.name) - dependencyNode.appendNode('version', it.version) + // For project dependencies (e.g. project(':keyvault')), it.version + // reads the sibling project's `version` field — which is + // "unspecified" if that project hasn't been configured yet. + // With Gradle configure-on-demand enabled, that's common. + // All sibling library projects share the same version, so write + // the current artifact's version for project deps. + def depVersion = (it instanceof org.gradle.api.artifacts.ProjectDependency) ? getAppVersionName() : it.version + dependencyNode.appendNode('version', depVersion) } } } diff --git a/uiautomationutilities/build.gradle b/uiautomationutilities/build.gradle index 897c14dedd..5266b05c11 100644 --- a/uiautomationutilities/build.gradle +++ b/uiautomationutilities/build.gradle @@ -165,7 +165,14 @@ project.afterEvaluate{ def dependencyNode = dependenciesNode.appendNode('dependency') dependencyNode.appendNode('groupId', it.group) dependencyNode.appendNode('artifactId', it.name) - dependencyNode.appendNode('version', it.version) + // For project dependencies (e.g. project(':keyvault')), it.version + // reads the sibling project's `version` field — which is + // "unspecified" if that project hasn't been configured yet. + // With Gradle configure-on-demand enabled, that's common. + // All sibling library projects share the same version, so write + // the current artifact's version for project deps. + def depVersion = (it instanceof org.gradle.api.artifacts.ProjectDependency) ? getAppVersionName() : it.version + dependencyNode.appendNode('version', depVersion) } } } From c8cd68b6971efba3af604ccf6ff648558140ddb6 Mon Sep 17 00:00:00 2001 From: fadidurah Date: Fri, 22 May 2026 05:08:40 -0400 Subject: [PATCH 15/18] fix token endpoint parsing --- ...tractMicrosoftStsTokenResponseHandler.java | 26 +++++++-- .../MicrosoftStsTokenResponseHandlerTest.java | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) 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 39249e9d48..01af2ebead 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 @@ -31,6 +31,7 @@ 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.logging.Logger; import com.microsoft.identity.common.java.net.HttpResponse; import com.microsoft.identity.common.java.opentelemetry.AttributeName; import com.microsoft.identity.common.java.opentelemetry.SpanExtension; @@ -43,6 +44,7 @@ import com.microsoft.identity.common.java.util.HeaderSerializationUtil; import com.microsoft.identity.common.java.util.ObjectMapper; import com.microsoft.identity.common.java.util.ResultUtil; +import com.microsoft.identity.common.java.util.StringUtil; import java.net.HttpURLConnection; import java.util.HashMap; @@ -109,11 +111,25 @@ 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(); - result.setClientDataInfo(clientDataInfo); + if (!StringUtil.isNullOrEmpty(clientDataHeader) + && CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_SERVER_CLIENT_DATA_TELEMETRY)) { + // eSTS URL-encodes the pipe-delimited value in the response header (e.g. "m%7C0x800482A5%7C%7C..."). + // ClientDataInfo.fromPipeDelimited expects an already-decoded value (its contract matches the + // authorize-endpoint path, where UrlUtil#getParameters has already decoded the query param). + // Decode here so the token-endpoint header path produces the same shape. + String decodedClientDataHeader = null; + try { + decodedClientDataHeader = StringUtil.urlFormDecode(clientDataHeader); + } catch (final Exception e) { + // Malformed percent-encoding shouldn't break token parsing; swallow and fall through. + Logger.warn(methodTag, "Failed to URL-decode x-ms-clientdata header: " + e.getMessage()); + } + if (decodedClientDataHeader != null) { + final ClientDataInfo clientDataInfo = ClientDataInfo.fromPipeDelimited(decodedClientDataHeader); + if (null != clientDataInfo) { + clientDataInfo.emitToSpan(); + result.setClientDataInfo(clientDataInfo); + } } } 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 0f440f0161..bc9c448cae 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 @@ -139,6 +139,59 @@ public void testHandleTokenResponse_withClientDataHeader_attributesEmitted() { } } + @SneakyThrows + @Test + public void testHandleTokenResponse_withUrlEncodedClientDataHeader_attributesEmitted() { + // eSTS URL-encodes pipe separators in the response header (e.g. "%7C" for "|"). + // Real-world example: x-ms-clientdata=[m%7C0x800482A5%7C%7Cmicrosoftonline.com%7Cnone] + final String encodedClientDataHeader = "m%7C0x800482A5%7C%7Cmicrosoftonline.com%7Cnone"; + + 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(encodedClientDataHeader)); + + 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()); + Assert.assertNotNull(tokenResult.getClientDataInfo()); + verify(mockSpan).setAttribute(AttributeName.server_error.name(), "0x800482A5"); + verify(mockSpan).setAttribute(AttributeName.account_type.name(), "MSA"); + } + } + + @SneakyThrows + @Test + public void testHandleTokenResponse_withMalformedPercentEncoding_doesNotCrash() { + // Lone '%' is invalid percent-encoding; decode should fail gracefully and skip ClientDataInfo. + final String malformedHeader = "e|AADSTS50058|%ZZ|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(malformedHeader)); + + 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()); + // Malformed encoding => null ClientDataInfo, but token result still valid. + Assert.assertNull(tokenResult.getClientDataInfo()); + } + @SneakyThrows @Test public void testHandleTokenResponse_noClientDataHeader_doesNotCrash() { From 62b6e0526b94e150ab96e11464dd8ea42c2290a7 Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Tue, 26 May 2026 15:25:22 +0100 Subject: [PATCH 16/18] Move NativeAuthRequestInterceptor to MSAL module Replace all usages of NativeAuthRequestInterceptor in common with the base OAuth2RequestInterceptor type. The NativeAuthRequestInterceptor interface is a public-facing API that customers implement, so it belongs in the MSAL module (com.microsoft.identity.nativeauth package) following the established pattern for public nativeauth types. Common now uses OAuth2RequestInterceptor internally, which is the parent interface with the same contract. Any MSAL-level NativeAuthRequestInterceptor implementation will satisfy this type since it extends OAuth2RequestInterceptor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../authorities/NativeAuthCIAMAuthority.kt | 10 ++-- .../BaseNativeAuthCommandParameters.java | 4 +- .../providers/NativeAuthHeaderValidator.kt | 2 +- .../NativeAuthOAuth2Configuration.kt | 3 +- .../providers/NativeAuthRequestInterceptor.kt | 50 ------------------- .../providers/interactors/JITInteractor.kt | 4 +- .../RequestInterceptorHeaderUtils.kt | 4 +- .../interactors/ResetPasswordInteractor.kt | 4 +- .../providers/interactors/SignInInteractor.kt | 4 +- .../providers/interactors/SignUpInteractor.kt | 4 +- .../JITInteractorRequestInterceptorTest.kt | 6 +-- .../RequestInterceptorHeaderUtilsTest.kt | 18 +++---- ...asswordInteractorRequestInterceptorTest.kt | 6 +-- .../SignInInteractorRequestInterceptorTest.kt | 6 +-- .../SignUpInteractorRequestInterceptorTest.kt | 6 +-- 15 files changed, 39 insertions(+), 92 deletions(-) delete mode 100644 common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt index 5c1a2af751..4b6546c45c 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/authorities/NativeAuthCIAMAuthority.kt @@ -32,7 +32,7 @@ import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthConstan import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthOAuth2Configuration import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthOAuth2Strategy import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthOAuth2StrategyFactory -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.providers.oauth2.OAuth2StrategyParameters /** @@ -77,7 +77,7 @@ class NativeAuthCIAMAuthority ( mAuthorityUrlString = authorityUrl } - private fun createNativeAuthOAuth2Configuration(challengeTypes: List?, capabilities: List?, requestInterceptor: NativeAuthRequestInterceptor?): NativeAuthOAuth2Configuration { + private fun createNativeAuthOAuth2Configuration(challengeTypes: List?, capabilities: List?, requestInterceptor: OAuth2RequestInterceptor?): NativeAuthOAuth2Configuration { LogSession.logMethodCall( tag = TAG, correlationId = null, @@ -125,14 +125,10 @@ class NativeAuthCIAMAuthority ( @Throws(ClientException::class) override fun createOAuth2Strategy(parameters: OAuth2StrategyParameters): NativeAuthOAuth2Strategy { - if (parameters.mRequestInterceptor != null && parameters.mRequestInterceptor !is NativeAuthRequestInterceptor) { - Logger.warn(TAG, "Request interceptor is not a NativeAuthRequestInterceptor instance; custom headers will not be applied to native auth requests.") - } - val config = createNativeAuthOAuth2Configuration( parameters.mChallengeTypes, parameters.mCapabilities, - parameters.mRequestInterceptor as? NativeAuthRequestInterceptor + parameters.mRequestInterceptor ) // CIAM Authorities can fetch endpoints from open id configuration. We disable this option. diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/commands/parameters/BaseNativeAuthCommandParameters.java b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/commands/parameters/BaseNativeAuthCommandParameters.java index 0e299adc29..3e6f0b45e0 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/commands/parameters/BaseNativeAuthCommandParameters.java +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/commands/parameters/BaseNativeAuthCommandParameters.java @@ -26,7 +26,7 @@ import com.microsoft.identity.common.java.commands.parameters.CommandParameters; import com.microsoft.identity.common.java.logging.Logger; import com.microsoft.identity.common.java.nativeauth.authorities.NativeAuthCIAMAuthority; -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor; +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor; import com.microsoft.identity.common.java.nativeauth.util.ILoggable; import java.util.List; @@ -68,7 +68,7 @@ public abstract class BaseNativeAuthCommandParameters extends CommandParameters */ @Nullable @EqualsAndHashCode.Exclude - public final transient NativeAuthRequestInterceptor requestInterceptor; + public final transient OAuth2RequestInterceptor requestInterceptor; @Override public void logParameters(String tag, String correlationId) { diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt index d2c9429fe7..82b03c0aac 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidator.kt @@ -25,7 +25,7 @@ package com.microsoft.identity.common.java.nativeauth.providers import com.microsoft.identity.common.java.logging.Logger /** - * Validates custom headers provided by a [NativeAuthRequestInterceptor]. + * Validates custom headers provided by an [OAuth2RequestInterceptor]. * Enforces that header names start with "x-" and do not use reserved prefixes. */ object NativeAuthHeaderValidator { diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2Configuration.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2Configuration.kt index 8ace436847..040221a041 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2Configuration.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthOAuth2Configuration.kt @@ -26,6 +26,7 @@ package com.microsoft.identity.common.java.nativeauth.providers import com.microsoft.identity.common.java.nativeauth.BuildValues import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsOAuth2Configuration +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.util.UrlUtil import java.net.MalformedURLException import java.net.URISyntaxException @@ -41,7 +42,7 @@ class NativeAuthOAuth2Configuration( val clientId: String, val challengeType: String, val capabilities: String?, - val requestInterceptor: NativeAuthRequestInterceptor? = null, + val requestInterceptor: OAuth2RequestInterceptor? = null, // Need this to decide whether or not to return mock api authority or actual authority supplied in configuration // Turn this on if you plan to use web auth and/or open id configuration val useMockApiForNativeAuth: Boolean = BuildValues.shouldUseMockApiForNativeAuth(), diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt deleted file mode 100644 index c7d1e707e1..0000000000 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthRequestInterceptor.kt +++ /dev/null @@ -1,50 +0,0 @@ -// 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.nativeauth.providers - -import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor -import java.net.URL - -/** - * An interceptor that is called before each native auth network request, allowing - * the application to inject custom HTTP header fields. - * - * All custom header field names must start with the "x-" prefix. - * The prefixes "x-ms-", "x-client-", "x-broker-", and "x-app-" are reserved and must not be used. - * Headers that violate these rules will be ignored by the SDK. - */ -interface NativeAuthRequestInterceptor : OAuth2RequestInterceptor { - - /** - * Called before each native auth network request to retrieve additional HTTP header fields. - * - * This callback executes synchronously on the thread performing the request (typically a - * background/network thread), so implementations must be thread-safe and return quickly. - * Inspect [requestUrl] to determine the request endpoint and conditionally apply headers. - * Any exception thrown from this method will propagate to the caller and fail the request. - * - * @param requestUrl The URL of the outgoing request. - * @return A map of header field names to values to inject, or null if no additional headers are needed. - */ - override fun additionalHeaders(requestUrl: URL): Map? -} diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt index 8ecd097b75..560ee85164 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt @@ -27,7 +27,7 @@ import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITChallengeAuthMethodCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITIntrospectCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITContinueCommandParameters -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.jit.JITChallengeRequest @@ -53,7 +53,7 @@ class JITInteractor( private val httpClient: UrlConnectionHttpClient, private val nativeAuthRequestProvider: NativeAuthRequestProvider, private val nativeAuthResponseHandler: NativeAuthResponseHandler, - private val requestInterceptor: NativeAuthRequestInterceptor? = null + private val requestInterceptor: OAuth2RequestInterceptor? = null ) { private val TAG: String = this::class.java.simpleName diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt index 40fde3c48c..a9e56bd38a 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt @@ -23,7 +23,7 @@ package com.microsoft.identity.common.java.nativeauth.providers.interactors import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthHeaderValidator -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import java.net.URL /** @@ -43,7 +43,7 @@ import java.net.URL internal fun applyInterceptorHeaders( requestUrl: URL, headers: Map, - requestInterceptor: NativeAuthRequestInterceptor? + requestInterceptor: OAuth2RequestInterceptor? ): Map { if (requestInterceptor == null) return headers diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt index 81e21e7d7e..dcae460450 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractor.kt @@ -28,7 +28,7 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPa import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.net.UrlConnectionHttpClient -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.resetpassword.ResetPasswordChallengeRequest @@ -58,7 +58,7 @@ class ResetPasswordInteractor( private val httpClient: UrlConnectionHttpClient, private val nativeAuthRequestProvider: NativeAuthRequestProvider, private val nativeAuthResponseHandler: NativeAuthResponseHandler, - private val requestInterceptor: NativeAuthRequestInterceptor? = null + private val requestInterceptor: OAuth2RequestInterceptor? = null ) { private val TAG:String = ResetPasswordInteractor::class.java.simpleName diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt index e5b3eeb557..d8fdea8146 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractor.kt @@ -29,7 +29,7 @@ import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInStartCommandParameters import com.microsoft.identity.common.java.net.UrlConnectionHttpClient -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.signin.SignInChallengeRequest @@ -57,7 +57,7 @@ class SignInInteractor( private val httpClient: UrlConnectionHttpClient, private val nativeAuthRequestProvider: NativeAuthRequestProvider, private val nativeAuthResponseHandler: NativeAuthResponseHandler, - private val requestInterceptor: NativeAuthRequestInterceptor? = null + private val requestInterceptor: OAuth2RequestInterceptor? = null ) { private val TAG:String = SignInInteractor::class.java.simpleName diff --git a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt index 83fdf01382..63ca75d0f1 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractor.kt @@ -28,7 +28,7 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpS import com.microsoft.identity.common.java.logging.LogSession import com.microsoft.identity.common.java.logging.Logger import com.microsoft.identity.common.java.net.UrlConnectionHttpClient -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.signup.SignUpChallengeRequest @@ -55,7 +55,7 @@ class SignUpInteractor( private val httpClient: UrlConnectionHttpClient, private val nativeAuthRequestProvider: NativeAuthRequestProvider, private val nativeAuthResponseHandler: NativeAuthResponseHandler, - private val requestInterceptor: NativeAuthRequestInterceptor? = null + private val requestInterceptor: OAuth2RequestInterceptor? = null ) { private val TAG:String = SignUpInteractor::class.java.simpleName diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt index fa28f668cc..bd7d890632 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt @@ -26,7 +26,7 @@ import com.microsoft.identity.common.java.interfaces.IPlatformComponents import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITChallengeAuthMethodCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITContinueCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.JITIntrospectCommandParameters -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.jit.JITChallengeRequest @@ -64,14 +64,14 @@ class JITInteractorRequestInterceptorTest { "Accept" to "application/json" ) - private val testInterceptor = object : NativeAuthRequestInterceptor { + private val testInterceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { return mapOf("x-akamai-sensor" to "sensor-data-123") } } private fun createInteractor( - interceptor: NativeAuthRequestInterceptor? = testInterceptor + interceptor: OAuth2RequestInterceptor? = testInterceptor ): JITInteractor { return JITInteractor( httpClient = mockHttpClient, diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt index 2ea9c4cd91..887fbbf4fe 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt @@ -22,7 +22,7 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.nativeauth.providers.interactors -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame @@ -58,7 +58,7 @@ class RequestInterceptorHeaderUtilsTest { @Test fun testInterceptorReturningNullReturnsSameHeaders() { - val interceptor = object : NativeAuthRequestInterceptor { + val interceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? = null } val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) @@ -67,7 +67,7 @@ class RequestInterceptorHeaderUtilsTest { @Test fun testInterceptorReturningEmptyMapReturnsSameHeaders() { - val interceptor = object : NativeAuthRequestInterceptor { + val interceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? = emptyMap() } val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) @@ -80,7 +80,7 @@ class RequestInterceptorHeaderUtilsTest { @Test fun testValidCustomHeadersAreMergedWithBaseHeaders() { - val interceptor = object : NativeAuthRequestInterceptor { + val interceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map { return mapOf( "x-akamai-sensor" to "sensor-data-123", @@ -105,7 +105,7 @@ class RequestInterceptorHeaderUtilsTest { @Test fun testReservedPrefixHeadersAreFiltered() { - val interceptor = object : NativeAuthRequestInterceptor { + val interceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map { return mapOf( "x-akamai-sensor" to "valid", @@ -130,7 +130,7 @@ class RequestInterceptorHeaderUtilsTest { @Test fun testInterceptorCannotOverwriteReservedBaseHeaders() { - val interceptor = object : NativeAuthRequestInterceptor { + val interceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map { return mapOf( "x-client-SKU" to "Evil.SDK", @@ -151,7 +151,7 @@ class RequestInterceptorHeaderUtilsTest { @Test fun testAllInvalidHeadersReturnsSameBaseSize() { - val interceptor = object : NativeAuthRequestInterceptor { + val interceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map { return mapOf( "x-ms-evil" to "filtered", @@ -181,7 +181,7 @@ class RequestInterceptorHeaderUtilsTest { "x-existing-custom" to "old-value" ) - val interceptor = object : NativeAuthRequestInterceptor { + val interceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map { return mapOf( "X-Existing-Custom" to "new-value", @@ -209,7 +209,7 @@ class RequestInterceptorHeaderUtilsTest { @Test fun testInterceptorReceivesCorrectRequestUrl() { val capturedUrls = mutableListOf() - val interceptor = object : NativeAuthRequestInterceptor { + val interceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { capturedUrls.add(requestUrl) return mapOf("x-test" to "value") diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt index 062bc22d59..07bd411e15 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt @@ -25,7 +25,7 @@ package com.microsoft.identity.common.java.nativeauth.providers.interactors import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPasswordStartCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPasswordSubmitCodeCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.ResetPasswordSubmitNewPasswordCommandParameters -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.resetpassword.ResetPasswordChallengeRequest @@ -66,14 +66,14 @@ class ResetPasswordInteractorRequestInterceptorTest { "x-client-SKU" to "MSAL.Android" ) - private val testInterceptor = object : NativeAuthRequestInterceptor { + private val testInterceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { return mapOf("x-akamai-sensor" to "sensor-data-123") } } private fun createInteractor( - interceptor: NativeAuthRequestInterceptor? = testInterceptor + interceptor: OAuth2RequestInterceptor? = testInterceptor ): ResetPasswordInteractor { return ResetPasswordInteractor( httpClient = mockHttpClient, diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt index 23ca9bcc1c..29ff1e6bf3 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt @@ -26,7 +26,7 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInS import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInSubmitCodeCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInSubmitPasswordCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignInWithContinuationTokenCommandParameters -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.signin.SignInChallengeRequest @@ -65,14 +65,14 @@ class SignInInteractorRequestInterceptorTest { "x-client-SKU" to "MSAL.Android" ) - private val testInterceptor = object : NativeAuthRequestInterceptor { + private val testInterceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { return mapOf("x-akamai-sensor" to "sensor-data-123") } } private fun createInteractor( - interceptor: NativeAuthRequestInterceptor? = testInterceptor + interceptor: OAuth2RequestInterceptor? = testInterceptor ): SignInInteractor { return SignInInteractor( httpClient = mockHttpClient, diff --git a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt index 125c86aea7..050e06f5cd 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt @@ -26,7 +26,7 @@ import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpS import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpSubmitCodeCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpSubmitPasswordCommandParameters import com.microsoft.identity.common.java.nativeauth.commands.parameters.SignUpSubmitUserAttributesCommandParameters -import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestInterceptor +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthRequestProvider import com.microsoft.identity.common.java.nativeauth.providers.NativeAuthResponseHandler import com.microsoft.identity.common.java.nativeauth.providers.requests.signup.SignUpChallengeRequest @@ -63,14 +63,14 @@ class SignUpInteractorRequestInterceptorTest { "x-client-SKU" to "MSAL.Android" ) - private val testInterceptor = object : NativeAuthRequestInterceptor { + private val testInterceptor = object : OAuth2RequestInterceptor { override fun additionalHeaders(requestUrl: URL): Map? { return mapOf("x-akamai-sensor" to "sensor-data-123") } } private fun createInteractor( - interceptor: NativeAuthRequestInterceptor? = testInterceptor + interceptor: OAuth2RequestInterceptor? = testInterceptor ): SignUpInteractor { return SignUpInteractor( httpClient = mockHttpClient, From f005147a7d82ec5b3de1e5c673cd7fd965b431a3 Mon Sep 17 00:00:00 2001 From: fadidurah Date: Wed, 27 May 2026 15:56:29 -0400 Subject: [PATCH 17/18] Revert "Build: publish libraries directly to NewAndroid feed" This reverts commit d98d3e1f924f79d7980d520309a220b99b6186b4. --- LabApiUtilities/build.gradle | 2 +- common/build.gradle | 2 +- common4j/build.gradle | 2 +- keyvault/build.gradle | 2 +- labapi/build.gradle | 2 +- testutils/build.gradle | 2 +- uiautomationutilities/build.gradle | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/LabApiUtilities/build.gradle b/LabApiUtilities/build.gradle index 790d6b0601..b991327e96 100644 --- a/LabApiUtilities/build.gradle +++ b/LabApiUtilities/build.gradle @@ -114,7 +114,7 @@ publishing { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/common/build.gradle b/common/build.gradle index 43e5370878..95ce168bac 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -420,7 +420,7 @@ afterEvaluate { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" credentials { username project.vstsUsername password project.vstsMavenAccessToken diff --git a/common4j/build.gradle b/common4j/build.gradle index 716ae1c664..fa56a27ca3 100644 --- a/common4j/build.gradle +++ b/common4j/build.gradle @@ -197,7 +197,7 @@ publishing { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/keyvault/build.gradle b/keyvault/build.gradle index 32ff9cab88..ace7611de8 100644 --- a/keyvault/build.gradle +++ b/keyvault/build.gradle @@ -29,7 +29,7 @@ publishing { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/labapi/build.gradle b/labapi/build.gradle index 58d2d923cc..c7be9b067b 100644 --- a/labapi/build.gradle +++ b/labapi/build.gradle @@ -30,7 +30,7 @@ publishing { repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/testutils/build.gradle b/testutils/build.gradle index 89c8a55e8e..c811c23cf5 100644 --- a/testutils/build.gradle +++ b/testutils/build.gradle @@ -136,7 +136,7 @@ project.afterEvaluate{ repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken diff --git a/uiautomationutilities/build.gradle b/uiautomationutilities/build.gradle index 5266b05c11..29364bc0f5 100644 --- a/uiautomationutilities/build.gradle +++ b/uiautomationutilities/build.gradle @@ -182,7 +182,7 @@ project.afterEvaluate{ repositories { maven { name "vsts-maven-adal-android" - url "https://identitydivision.pkgs.visualstudio.com/_packaging/NewAndroid/maven/v1" + url "https://identitydivision.pkgs.visualstudio.com/_packaging/AndroidADAL/maven/v1" credentials { username project.ext.vstsUsername password project.ext.vstsMavenAccessToken From 7a4b22b967af6c3f9e9754c66560c013f0211d3a Mon Sep 17 00:00:00 2001 From: Silviu Petrescu Date: Wed, 27 May 2026 21:27:20 +0100 Subject: [PATCH 18/18] Add Native Auth to installed apps --- .../utilities/constants/LabConstants.java | 1 + .../labapi/utilities/constants/UserType.java | 3 +- .../automation/app/NativeAuthSampleApp.java | 53 +++++++++++++++++++ .../ui/automation/rules/CopyFileRule.java | 4 +- 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 uiautomationutilities/src/main/java/com/microsoft/identity/client/ui/automation/app/NativeAuthSampleApp.java diff --git a/LabApiUtilities/src/main/com/microsoft/identity/labapi/utilities/constants/LabConstants.java b/LabApiUtilities/src/main/com/microsoft/identity/labapi/utilities/constants/LabConstants.java index 82fbf8163c..1063937a42 100644 --- a/LabApiUtilities/src/main/com/microsoft/identity/labapi/utilities/constants/LabConstants.java +++ b/LabApiUtilities/src/main/com/microsoft/identity/labapi/utilities/constants/LabConstants.java @@ -62,6 +62,7 @@ static final class UserType { public static final String ONPREM = "onprem"; public static final String GUEST = "guest"; public static final String B2C = "b2c"; + public static final String CIAM = "ciam"; } static final class UserRole { diff --git a/LabApiUtilities/src/main/com/microsoft/identity/labapi/utilities/constants/UserType.java b/LabApiUtilities/src/main/com/microsoft/identity/labapi/utilities/constants/UserType.java index 86a65ed4e8..76f7e2aa42 100644 --- a/LabApiUtilities/src/main/com/microsoft/identity/labapi/utilities/constants/UserType.java +++ b/LabApiUtilities/src/main/com/microsoft/identity/labapi/utilities/constants/UserType.java @@ -52,7 +52,8 @@ public enum UserType { CLOUD(LabConstants.UserType.CLOUD), B2C(LabConstants.UserType.B2C), GUEST(LabConstants.UserType.GUEST), - ONPREM(LabConstants.UserType.ONPREM); + ONPREM(LabConstants.UserType.ONPREM), + CIAM(LabConstants.UserType.CIAM); final String value; diff --git a/uiautomationutilities/src/main/java/com/microsoft/identity/client/ui/automation/app/NativeAuthSampleApp.java b/uiautomationutilities/src/main/java/com/microsoft/identity/client/ui/automation/app/NativeAuthSampleApp.java new file mode 100644 index 0000000000..0b9b9e9cf0 --- /dev/null +++ b/uiautomationutilities/src/main/java/com/microsoft/identity/client/ui/automation/app/NativeAuthSampleApp.java @@ -0,0 +1,53 @@ +// 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.client.ui.automation.app; + +import com.microsoft.identity.client.ui.automation.installer.LocalApkInstaller; + +import lombok.NonNull; + +/** + * A model for interacting with the NativeAuth Sample App during UI automation tests. + */ +public class NativeAuthSampleApp extends App { + + public static final String NATIVE_AUTH_SAMPLE_PACKAGE_NAME = "com.azuresamples.msalnativeauthandroidkotlinsampleapp"; + public static final String NATIVE_AUTH_SAMPLE_APP_NAME = "NativeAuth Sample App"; + public static final String NATIVE_AUTH_SAMPLE_APK = "NativeAuthSampleApp.apk"; + + public NativeAuthSampleApp() { + super(NATIVE_AUTH_SAMPLE_PACKAGE_NAME, NATIVE_AUTH_SAMPLE_APP_NAME, new LocalApkInstaller()); + localApkFileName = NATIVE_AUTH_SAMPLE_APK; + localUpdateApkFileName = NATIVE_AUTH_SAMPLE_APK; + } + + @Override + protected void initialiseAppImpl() { + // No version-specific implementation needed for NativeAuthSampleApp + } + + @Override + public void handleFirstRun() { + // No first-run dialog to handle for NativeAuthSampleApp + } +} diff --git a/uiautomationutilities/src/main/java/com/microsoft/identity/client/ui/automation/rules/CopyFileRule.java b/uiautomationutilities/src/main/java/com/microsoft/identity/client/ui/automation/rules/CopyFileRule.java index 5405d2b1c6..e65d02596c 100644 --- a/uiautomationutilities/src/main/java/com/microsoft/identity/client/ui/automation/rules/CopyFileRule.java +++ b/uiautomationutilities/src/main/java/com/microsoft/identity/client/ui/automation/rules/CopyFileRule.java @@ -27,6 +27,7 @@ import com.microsoft.identity.client.ui.automation.app.AzureSampleApp; import com.microsoft.identity.client.ui.automation.app.OneAuthTestApp; import com.microsoft.identity.client.ui.automation.app.MsalTestApp; +import com.microsoft.identity.client.ui.automation.app.NativeAuthSampleApp; import com.microsoft.identity.client.ui.automation.app.OneDriveApp; import com.microsoft.identity.client.ui.automation.app.OutlookApp; import com.microsoft.identity.client.ui.automation.app.TeamsApp; @@ -75,7 +76,8 @@ public class CopyFileRule implements TestRule { OneAuthTestApp.ONEAUTH_TESTAPP_APK, OneAuthTestApp.OLD_ONEAUTH_TESTAPP_APK, MsalTestApp.MSAL_TEST_APP_APK, - MsalTestApp.OLD_MSAL_TEST_APP_APK + MsalTestApp.OLD_MSAL_TEST_APP_APK, + NativeAuthSampleApp.NATIVE_AUTH_SAMPLE_APK }; public CopyFileRule() {