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/changelog.txt b/changelog.txt index d3f87f4823..235afad733 100644 --- a/changelog.txt +++ b/changelog.txt @@ -15,6 +15,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) - [MINOR] Add onboarding telemetry recorder, field keys, and session persistence for mobile onboarding flow (#3088) - [PATCH] Move Multiple Listening apps check to the authorization layer (#3070) +- [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/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..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,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.providers.oauth2.OAuth2RequestInterceptor import com.microsoft.identity.common.java.providers.oauth2.OAuth2StrategyParameters /** @@ -76,7 +77,7 @@ class NativeAuthCIAMAuthority ( mAuthorityUrlString = authorityUrl } - private fun createNativeAuthOAuth2Configuration(challengeTypes: List?, capabilities: List?): NativeAuthOAuth2Configuration { + private fun createNativeAuthOAuth2Configuration(challengeTypes: List?, capabilities: List?, requestInterceptor: OAuth2RequestInterceptor?): NativeAuthOAuth2Configuration { LogSession.logMethodCall( tag = TAG, correlationId = null, @@ -86,7 +87,8 @@ class NativeAuthCIAMAuthority ( authorityUrl = this.authorityURL, clientId = this.clientId, challengeType = getChallengeTypesWithDefault(challengeTypes), - capabilities = getCapabilities(capabilities) + capabilities = getCapabilities(capabilities), + requestInterceptor = requestInterceptor ) } @@ -123,7 +125,11 @@ 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..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,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.providers.oauth2.OAuth2RequestInterceptor; 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 OAuth2RequestInterceptor 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..82b03c0aac --- /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 an [OAuth2RequestInterceptor]. + * 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 using lowercase field names, 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[lowerField] = 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..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,6 +42,7 @@ class NativeAuthOAuth2Configuration( val clientId: String, val challengeType: String, val capabilities: String?, + 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/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/interactors/JITInteractor.kt b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractor.kt index 5ebf3ac24a..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,6 +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.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 @@ -51,7 +52,8 @@ 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: OAuth2RequestInterceptor? = null ) { private val TAG: String = this::class.java.simpleName @@ -94,7 +96,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -169,7 +171,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -243,7 +245,7 @@ class JITInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -275,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..a9e56bd38a --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtils.kt @@ -0,0 +1,65 @@ +// 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.providers.oauth2.OAuth2RequestInterceptor +import java.net.URL + +/** + * Applies additional interceptor headers to the base request headers for native auth interactors. + * + * 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 for valid custom headers. + */ +internal fun applyInterceptorHeaders( + requestUrl: URL, + headers: Map, + requestInterceptor: OAuth2RequestInterceptor? +): Map { + 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 + + 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 b4f3f23c36..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,6 +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.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 @@ -56,9 +57,11 @@ 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: OAuth2RequestInterceptor? = null ) { private val TAG:String = ResetPasswordInteractor::class.java.simpleName + //region /resetpassword/start fun performResetPasswordStart( parameters: ResetPasswordStartCommandParameters @@ -95,7 +98,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -169,7 +172,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -240,7 +243,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -317,7 +320,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val httpResponse = httpClient.post( @@ -391,7 +394,7 @@ class ResetPasswordInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = 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 47f3a8b640..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,6 +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.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 @@ -55,9 +56,11 @@ 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: OAuth2RequestInterceptor? = null ) { private val TAG:String = SignInInteractor::class.java.simpleName + //region /oauth/v2.0/initiate fun performSignInInitiate( parameters: SignInStartCommandParameters @@ -95,7 +98,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInInitiate" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -168,7 +171,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInIntrospect" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -270,7 +273,7 @@ class SignInInteractor( methodName = "${TAG}.performSignInChallenge" ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -396,7 +399,7 @@ class SignInInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = 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 acb8af3136..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,6 +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.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 @@ -53,7 +54,8 @@ 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: OAuth2RequestInterceptor? = null ) { private val TAG:String = SignUpInteractor::class.java.simpleName @@ -99,7 +101,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -173,7 +175,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = request.headers + val headers = applyInterceptorHeaders(request.requestUrl, request.headers, requestInterceptor) val requestUrl = request.requestUrl val response = httpClient.post( @@ -295,7 +297,7 @@ class SignUpInteractor( ) val encodedRequest: String = ObjectMapper.serializeObjectToFormUrlEncoded(request.parameters) - val headers = 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/microsoft/microsoftsts/AbstractMicrosoftStsTokenResponseHandler.java b/common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/microsoftsts/AbstractMicrosoftStsTokenResponseHandler.java index 2fd7cb3761..fda8bb7f08 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; @@ -44,7 +45,7 @@ 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 com.microsoft.identity.common.java.logging.Logger; + import java.net.HttpURLConnection; import java.util.HashMap; 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..4b35939f2e --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/oauth2/OAuth2RequestInterceptor.kt @@ -0,0 +1,37 @@ +// 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. + * + * 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? +} 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..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 @@ -61,6 +61,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 OAuth2RequestInterceptor 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..aee028f958 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/NativeAuthHeaderValidatorTest.kt @@ -0,0 +1,195 @@ +// 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 testHeaderFieldNamesAreNormalizedToLowercase() { + 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 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 + 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/JITInteractorRequestInterceptorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt new file mode 100644 index 0000000000..bd7d890632 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/JITInteractorRequestInterceptorTest.kt @@ -0,0 +1,234 @@ +// 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.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 +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 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 { + + 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() + private val mockResponseHandler = mockk() + + private val baseHeaders = mapOf( + "Content-Type" to "application/x-www-form-urlencoded", + "x-client-SKU" to "MSAL.Android", + "Accept" to "application/json" + ) + + private val testInterceptor = object : OAuth2RequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + private fun createInteractor( + interceptor: OAuth2RequestInterceptor? = 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 introspectUrl + 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("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformIntrospect() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performIntrospect(createJITIntrospectParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(3, 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 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performChallenge(createJITChallengeParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformChallenge() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformContinue() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performContinue(createJITContinueParams()) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformContinue() { + val mockRequest = mockk(relaxed = true) + 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) + + 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/RequestInterceptorHeaderUtilsTest.kt b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt new file mode 100644 index 0000000000..887fbbf4fe --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/RequestInterceptorHeaderUtilsTest.kt @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.nativeauth.providers.interactors + +import com.microsoft.identity.common.java.providers.oauth2.OAuth2RequestInterceptor +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 + +/** + * 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 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 testNullInterceptorReturnsSameHeaders() { + val result = applyInterceptorHeaders(testUrl, baseHeaders, null) + assertSame("Null interceptor should return the exact same map instance", baseHeaders, result) + } + + @Test + fun testInterceptorReturningNullReturnsSameHeaders() { + val interceptor = object : OAuth2RequestInterceptor { + 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) + } + + @Test + fun testInterceptorReturningEmptyMapReturnsSameHeaders() { + val interceptor = object : OAuth2RequestInterceptor { + 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 testValidCustomHeadersAreMergedWithBaseHeaders() { + val interceptor = object : OAuth2RequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map { + return mapOf( + "x-akamai-sensor" to "sensor-data-123", + "x-fraud-signal" to "signal-abc" + ) + } + } + + val result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) + + 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("MSAL.Android", result["x-client-SKU"]) + assertEquals("application/json", result["Accept"]) + } + + // endregion + + // region reserved header filtering (integration with NativeAuthHeaderValidator) + + @Test + fun testReservedPrefixHeadersAreFiltered() { + val interceptor = object : OAuth2RequestInterceptor { + 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 result = applyInterceptorHeaders(testUrl, baseHeaders, interceptor) + + 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 testInterceptorCannotOverwriteReservedBaseHeaders() { + val interceptor = object : OAuth2RequestInterceptor { + 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 : OAuth2RequestInterceptor { + 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 : OAuth2RequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map { + return mapOf( + "X-Existing-Custom" to "new-value", + "x-new-header" to "new-data" + ) + } + } + + val result = applyInterceptorHeaders(testUrl, baseHeadersWithCustom, interceptor) + + // 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 testInterceptorReceivesCorrectRequestUrl() { + val capturedUrls = mutableListOf() + val interceptor = object : OAuth2RequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + capturedUrls.add(requestUrl) + return mapOf("x-test" to "value") + } + } + + applyInterceptorHeaders(testUrl, baseHeaders, interceptor) + + 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 new file mode 100644 index 0000000000..07bd411e15 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/ResetPasswordInteractorRequestInterceptorTest.kt @@ -0,0 +1,283 @@ +// 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.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 +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 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 { + + 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() + 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 : OAuth2RequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + private fun createInteractor( + interceptor: OAuth2RequestInterceptor? = 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 startUrl + 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("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordStart() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 challengeUrl + 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"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordChallenge() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformResetPasswordContinue() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performResetPasswordContinue(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordContinue() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformResetPasswordSubmit() { + val mockRequestParams = mockk(relaxed = true) + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns submitUrl + 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"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordSubmit() { + val mockRequestParams = mockk(relaxed = true) + val mockRequest = mockk(relaxed = true) + every { mockRequest.requestUrl } returns submitUrl + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformResetPasswordPollCompletion() { + val mockRequest = mockk(relaxed = true) + 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) + + 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"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformResetPasswordPollCompletion() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 new file mode 100644 index 0000000000..29ff1e6bf3 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignInInteractorRequestInterceptorTest.kt @@ -0,0 +1,352 @@ +// 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.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.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 +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 +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 [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 { + + 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() + 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 : OAuth2RequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + private fun createInteractor( + interceptor: OAuth2RequestInterceptor? = testInterceptor + ): SignInInteractor { + return SignInInteractor( + 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 performSignInInitiate + @Test + fun testInterceptorHeadersAreMergedInPerformSignInInitiate() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignInInitiate(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + assertEquals("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeaders() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor(interceptor = null) + + interactor.performSignInInitiate(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals(2, capturedHeaders.captured.size) + assertFalse(capturedHeaders.captured.containsKey("x-akamai-sensor")) + } + // endregion + + // region performIntrospect + @Test + fun testInterceptorHeadersAreMergedInPerformIntrospect() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performIntrospect(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformIntrospect() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformSignInDefaultChallenge() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignInDefaultChallenge(continuationToken = "token", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignInDefaultChallenge() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformSignInSelectedChallenge() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignInSelectedChallenge(continuationToken = "token", challengeId = "challenge-1", correlationId = "corr-id") + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignInSelectedChallenge() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformOOBTokenRequest() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performOOBTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformOOBTokenRequest() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformContinuationTokenTokenRequest() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performContinuationTokenTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformContinuationTokenTokenRequest() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformPasswordTokenRequest() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performPasswordTokenRequest(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformPasswordTokenRequest() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 new file mode 100644 index 0000000000..050e06f5cd --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/nativeauth/providers/interactors/SignUpInteractorRequestInterceptorTest.kt @@ -0,0 +1,276 @@ +// 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.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 +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 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 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 { + + 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() + 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 : OAuth2RequestInterceptor { + override fun additionalHeaders(requestUrl: URL): Map? { + return mapOf("x-akamai-sensor" to "sensor-data-123") + } + } + + private fun createInteractor( + interceptor: OAuth2RequestInterceptor? = 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 startUrl + 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("MSAL.Android", capturedHeaders.captured["x-client-SKU"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpStart() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 challengeUrl + 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"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpChallenge() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformSignUpSubmitCode() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignUpSubmitCode(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitCode() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformSignUpSubmitPassword() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignUpSubmitPassword(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitPassword() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 + @Test + fun testInterceptorHeadersAreMergedInPerformSignUpSubmitUserAttributes() { + val mockRequest = mockk(relaxed = true) + 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) + + val capturedHeaders = setupHttpClientCapture() + val interactor = createInteractor() + + interactor.performSignUpSubmitUserAttributes(mockk(relaxed = true)) + + assertTrue(capturedHeaders.isCaptured) + assertEquals("sensor-data-123", capturedHeaders.captured["x-akamai-sensor"]) + } + + @Test + fun testNullInterceptorDoesNotModifyHeadersInPerformSignUpSubmitUserAttributes() { + val mockRequest = mockk(relaxed = true) + 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) + + 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 +} 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() {