diff --git a/common b/common index f505fc5fb..fb98ec58b 160000 --- a/common +++ b/common @@ -1 +1 @@ -Subproject commit f505fc5fbdc8fe33dcbcc023bda256be86f44f39 +Subproject commit fb98ec58b36af556fed6de41480b783c8ee3df53 diff --git a/msalautomationapp/src/androidTest/java/com/microsoft/identity/client/msal/automationapp/testpass/broker/concurrent/ConcurrentAcquireTokenSilentHelper.kt b/msalautomationapp/src/androidTest/java/com/microsoft/identity/client/msal/automationapp/testpass/broker/concurrent/ConcurrentAcquireTokenSilentHelper.kt new file mode 100644 index 000000000..a3935a629 --- /dev/null +++ b/msalautomationapp/src/androidTest/java/com/microsoft/identity/client/msal/automationapp/testpass/broker/concurrent/ConcurrentAcquireTokenSilentHelper.kt @@ -0,0 +1,145 @@ +// 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.msal.automationapp.testpass.broker.concurrent + +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Drives a barrier-synchronized concurrent `AcquireTokenSilent` stress run. + * + * Each of [iterations] waves releases all [threadCount] threads from a + * [CyclicBarrier] at once, so the broker sees [threadCount] truly simultaneous + * requests. A wave only advances once every request has called back. + * + * Callers must make each wave's requests distinct (e.g. [scopesForThread]) or + * the dispatcher de-duplicates the identical in-flight commands. + */ +object ConcurrentAcquireTokenSilentHelper { + + /** + * One distinct delegated scope per thread, so concurrent commands aren't + * de-duplicated. All are silently satisfiable once the device is WPJ'd. + */ + val SCOPE_POOL = arrayOf( + "User.Read", + "AccessReview.Read.All", + "PeopleSettings.Read.All", + "AdministrativeUnit.Read.All", + "UserAuthenticationMethod.Read", + "Sites.Search.All", + "User-Phone.ReadWrite.All", + "Organization.Read.All", + "AgentCollection.Read.All", + "Place.Read.All", + "Application.Read.All", + "Agreement.Read.All", + "TermStore.Read.All", + "User-Mail.ReadWrite.All", + "User-LifeCycleInfo.Read.All" + ) + + fun scopesForThread(threadIndex: Int): List = + listOf(SCOPE_POOL[threadIndex % SCOPE_POOL.size]) + + /** `allCompleted` is true only if every wave finished within the timeout. */ + data class StressResult(val allCompleted: Boolean, val errors: List) + + /** Issues one request; must call `done.countDown()` exactly once. */ + fun interface SilentTokenRequester { + fun request(threadIndex: Int, iteration: Int, done: CountDownLatch, errors: MutableList) + } + + fun run( + threadCount: Int, + iterations: Int, + perWaveTimeoutSec: Long, + requester: SilentTokenRequester, + ): StressResult { + require(threadCount > 0) { "threadCount must be > 0" } + require(iterations > 0) { "iterations must be > 0" } + require(perWaveTimeoutSec > 0) { "perWaveTimeoutSec must be > 0" } + + val errors = Collections.synchronizedList(ArrayList()) + val stopped = AtomicBoolean(false) + val allThreadsDone = CountDownLatch(threadCount) + + // The last thread to reach the barrier installs the wave's shared latch + // (counting down from threadCount) before any thread is released. + val waveLatch = AtomicReference() + val barrier = CyclicBarrier(threadCount) { waveLatch.set(CountDownLatch(threadCount)) } + + for (t in 0 until threadCount) { + Thread({ + try { + for (iter in 0 until iterations) { + if (stopped.get()) break + + try { + barrier.await(perWaveTimeoutSec, TimeUnit.SECONDS) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + break + } catch (barrierBroken: Exception) { + if (!stopped.get()) errors.add("Thread $t barrier broke at wave $iter: $barrierBroken") + break + } + if (stopped.get()) break + + val done = waveLatch.get() + try { + requester.request(t, iter, done, errors) + } catch (dispatchError: Throwable) { + errors.add("Thread $t iter $iter dispatch failed: $dispatchError") + done.countDown() + } + + try { + if (!done.await(perWaveTimeoutSec, TimeUnit.SECONDS)) { + // First to time out aborts the run and frees any + // threads already parked at the next barrier. + if (stopped.compareAndSet(false, true)) { + errors.add("Wave $iter timed out after ${perWaveTimeoutSec}s") + barrier.reset() + } + break + } + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + break + } + } + } finally { + allThreadsDone.countDown() + } + }, "ConcurrentATS-$t").apply { isDaemon = true }.start() + } + + val allCompleted = allThreadsDone.await(iterations.toLong() * perWaveTimeoutSec, TimeUnit.SECONDS) + return StressResult(allCompleted && !stopped.get(), ArrayList(errors)) + } +} diff --git a/msalautomationapp/src/androidTest/java/com/microsoft/identity/client/msal/automationapp/testpass/broker/concurrent/TestCaseConcurrentAcquireTokenSilent.kt b/msalautomationapp/src/androidTest/java/com/microsoft/identity/client/msal/automationapp/testpass/broker/concurrent/TestCaseConcurrentAcquireTokenSilent.kt new file mode 100644 index 000000000..ac40c6dea --- /dev/null +++ b/msalautomationapp/src/androidTest/java/com/microsoft/identity/client/msal/automationapp/testpass/broker/concurrent/TestCaseConcurrentAcquireTokenSilent.kt @@ -0,0 +1,194 @@ +// 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.msal.automationapp.testpass.broker.concurrent + +import com.microsoft.identity.client.AcquireTokenSilentParameters +import com.microsoft.identity.client.IAuthenticationResult +import com.microsoft.identity.client.Prompt +import com.microsoft.identity.client.SilentAuthenticationCallback +import com.microsoft.identity.client.claims.ClaimsRequest +import com.microsoft.identity.client.claims.RequestedClaimAdditionalInformation +import com.microsoft.identity.client.exception.MsalException +import com.microsoft.identity.client.msal.automationapp.BuildConfig +import com.microsoft.identity.client.msal.automationapp.R +import com.microsoft.identity.client.msal.automationapp.sdk.MsalAuthTestParams +import com.microsoft.identity.client.msal.automationapp.sdk.MsalSdk +import com.microsoft.identity.client.msal.automationapp.testpass.broker.AbstractMsalBrokerTest +import com.microsoft.identity.client.ui.automation.TokenRequestTimeout +import com.microsoft.identity.client.ui.automation.annotations.LongUIAutomationTest +import com.microsoft.identity.client.ui.automation.annotations.RetryOnFailure +import com.microsoft.identity.client.ui.automation.annotations.StressTest +import com.microsoft.identity.client.ui.automation.annotations.SupportedBrokers +import com.microsoft.identity.client.ui.automation.broker.BrokerMicrosoftAuthenticator +import com.microsoft.identity.client.ui.automation.interaction.OnInteractionRequired +import com.microsoft.identity.client.ui.automation.interaction.PromptHandlerParameters +import com.microsoft.identity.client.ui.automation.interaction.PromptParameter +import com.microsoft.identity.client.ui.automation.interaction.microsoftsts.AadPromptHandler +import com.microsoft.identity.client.ui.automation.logging.Logger +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.providers.oauth2.IDToken +import com.microsoft.identity.labapi.utilities.constants.TempUserType +import com.microsoft.identity.labapi.utilities.constants.UserType +import org.junit.Assert +import org.junit.Test + +/** + * Concurrent stress test for `AcquireTokenSilent` through the broker on a + * Workplace-Joined (WPJ) device: register the device inline via a `deviceid` + * claim on a single interactive sign-in, then fire [ITERATIONS] barrier- + * synchronized waves of [CONCURRENT_THREADS] simultaneous `forceRefresh` silent + * calls and assert none hangs or errors. + * + * Each thread uses a distinct scope so the `CommandDispatcher` can't de-duplicate + * the simultaneous in-flight commands; the WPJ PRT satisfies every scope silently. + * [CONCURRENT_THREADS] equals the pool size so every concurrent request is unique. + */ +@SupportedBrokers(brokers = [BrokerMicrosoftAuthenticator::class]) +@StressTest +@LongUIAutomationTest +class TestCaseConcurrentAcquireTokenSilent : AbstractMsalBrokerTest() { + + @Test + fun test_concurrentAcquireTokenSilent_withBroker() { + val username = mLabAccount.username + val password = mLabAccount.password + + val msalSdk = MsalSdk() + + // Inline WPJ: a deviceid claim on the interactive sign-in registers the + // device (broker gets a PRT) and establishes the account in one flow. + val deviceIdClaims = ClaimsRequest().apply { + requestClaimInIdToken( + "deviceid", + RequestedClaimAdditionalInformation().apply { setEssential(true) }, + ) + } + + val interactiveParams = MsalAuthTestParams.builder() + .activity(mActivity) + .loginHint(username) + .scopes(listOf(*mScopes)) + .promptParameter(Prompt.LOGIN) + .claims(deviceIdClaims) + .msalConfigResourceId(configFileResourceId) + .build() + + val interactiveResult = msalSdk.acquireTokenInteractive( + interactiveParams, + OnInteractionRequired { + val promptHandlerParameters = PromptHandlerParameters.builder() + .prompt(PromptParameter.LOGIN) + .loginHint(username) + .broker(mBroker) + .sessionExpected(false) + .registerPageExpected(true) + .consentPageExpected(false) + .speedBumpExpected(false) + .expectingLoginPageAccountPicker(false) + .build() + + AadPromptHandler(promptHandlerParameters).handlePrompt(username, password) + }, + TokenRequestTimeout.MEDIUM, + ) + + interactiveResult.assertSuccess() + + // Confirm the device actually registered (deviceid present in the token). + val claims = IDToken.parseJWT(interactiveResult.accessToken) + Assert.assertNotNull("deviceid claim must be present after inline WPJ", claims["deviceid"]) + + val account = msalSdk.getAccount(mActivity, configFileResourceId, username) + Assert.assertNotNull("Account must not be null after a successful interactive sign-in", account) + + // Distinct scope per thread → no command de-duplication. + val result = ConcurrentAcquireTokenSilentHelper.run( + CONCURRENT_THREADS, + ITERATIONS, + PER_WAVE_TIMEOUT_SECONDS, + ) { threadIndex, iteration, done, errors -> + val silentParameters = AcquireTokenSilentParameters.Builder() + .forAccount(account) + .fromAuthority(account.authority) + .withScopes(ConcurrentAcquireTokenSilentHelper.scopesForThread(threadIndex)) + .forceRefresh(true) + .withCallback(object : SilentAuthenticationCallback { + override fun onSuccess(authenticationResult: IAuthenticationResult) { + done.countDown() + } + + override fun onError(exception: MsalException) { + errors.add("Thread $threadIndex iter $iteration [${exception.errorCode}]: $exception") + done.countDown() + } + }) + .build() + + mApplication.acquireTokenSilentAsync(silentParameters) + } + + Assert.assertTrue( + "Concurrent AcquireTokenSilent got stuck: not all $CONCURRENT_THREADS threads" + + " completed $ITERATIONS waves (per-wave timeout ${PER_WAVE_TIMEOUT_SECONDS}s)", + result.allCompleted, + ) + + // null_object under concurrency is a known broker issue whose fix (network-token fallback) + // is gated to MSAL_CPP (OneAuth). See BrokerFlight.USE_NETWORK_TOKEN_FALLBACK_FOR_NULL_OBJECT + val unexpectedErrors = result.errors.filterNot { it.contains("[${ClientException.NULL_OBJECT}]") } + + val toleratedNullObjects = result.errors.size - unexpectedErrors.size + if (toleratedNullObjects > 0) { + Logger.w( + TAG, + "Tolerated $toleratedNullObjects known null_object error(s);" + + " UseNetworkTokenFallbackForNullObjectMsalAndroid flight is off", + ) + } + + Assert.assertTrue( + "Some concurrent AcquireTokenSilent calls failed: $unexpectedErrors", + unexpectedErrors.isEmpty(), + ) + } + + override fun getJsonUserType(): UserType = UserType.BASIC + + override fun getTempUserType(): TempUserType? = null + + override fun getScopes(): Array = arrayOf("User.read") + + override fun getAuthority(): String = + mApplication.configuration.defaultAuthority.toString() + + override fun getConfigFileResourceId(): Int = R.raw.msal_config_default + + companion object { + private val TAG = TestCaseConcurrentAcquireTokenSilent::class.java.simpleName + + /** One thread per pooled scope, so every concurrent request is unique. */ + private val CONCURRENT_THREADS = ConcurrentAcquireTokenSilentHelper.SCOPE_POOL.size + private const val ITERATIONS = 100 + private const val PER_WAVE_TIMEOUT_SECONDS = 30L + } +} diff --git a/testapps/testapp/src/main/java/com/microsoft/identity/client/testapp/ConcurrentAcquireTokenExecutor.kt b/testapps/testapp/src/main/java/com/microsoft/identity/client/testapp/ConcurrentAcquireTokenExecutor.kt index 05336b048..c84dc6e63 100644 --- a/testapps/testapp/src/main/java/com/microsoft/identity/client/testapp/ConcurrentAcquireTokenExecutor.kt +++ b/testapps/testapp/src/main/java/com/microsoft/identity/client/testapp/ConcurrentAcquireTokenExecutor.kt @@ -124,19 +124,21 @@ class ConcurrentAcquireTokenExecutor( * (CommandDispatcher collapses commands with identical parameters). */ private val SCOPE_POOL = listOf( - "user.read", - "user.readbasic.all", - "mail.read", - "calendars.read", - "contacts.read", - "files.read", - "files.read.all", - "people.read", - "notes.read", - "tasks.read", - "sites.read.all", - "directory.read.all", - "group.read.all" + "User.Read", + "AccessReview.Read.All", + "PeopleSettings.Read.All", + "AdministrativeUnit.Read.All", + "UserAuthenticationMethod.Read", + "Sites.Search.All", + "User-Phone.ReadWrite.All", + "Organization.Read.All", + "AgentCollection.Read.All", + "Place.Read.All", + "Application.Read.All", + "Agreement.Read.All", + "TermStore.Read.All", + "User-Mail.ReadWrite.All", + "User-LifeCycleInfo.Read.All" ) /** diff --git a/testapps/testapp/src/main/res/layout/fragment_acquire.xml b/testapps/testapp/src/main/res/layout/fragment_acquire.xml index 7a14cc34b..24416c8d0 100644 --- a/testapps/testapp/src/main/res/layout/fragment_acquire.xml +++ b/testapps/testapp/src/main/res/layout/fragment_acquire.xml @@ -755,7 +755,7 @@ android:layout_height="wrap_content" android:layout_weight="7" android:inputType="number" - android:text="13" + android:text="15" android:textSize="12sp" />