Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
90b5786
Add ConcurrentAcquireTokenExecutor and E2E concurrent stress test
Copilot Apr 30, 2026
8827786
Fix review feedback: iteration number in error message, tighter timeout
Copilot Apr 30, 2026
a54f69d
Increase thread count to 13 and add 1000 iterations per thread to mat…
Copilot Apr 30, 2026
a31eb6b
Extract ConcurrentAcquireTokenSilentHelper to share concurrency imple…
Copilot Apr 30, 2026
588db9c
Fix spelling: synchronised -> synchronized in comments
Copilot Apr 30, 2026
5f2db2c
Cut total timeout from 14400s to 5000s
Copilot Apr 30, 2026
e5239ad
Set ITERATIONS_PER_THREAD=100 and TOTAL_TIMEOUT_SECONDS=500
Copilot Apr 30, 2026
23e9ad4
Change timeout from per-request to per-wave (10s max per wave)
Copilot Apr 30, 2026
98d009a
Remove arbitrary 200s buffer from TOTAL_TIMEOUT_SECONDS; derive it fr…
Copilot Apr 30, 2026
85f1ca2
Remove TOTAL_TIMEOUT_SECONDS from public API; derive backstop interna…
Copilot Apr 30, 2026
186f41a
Update testapps/testapp/src/main/java/com/microsoft/identity/client/t…
rpdome Apr 30, 2026
7de08fb
Delete duplicate ConcurrentAcquireTokenExecutor.java; align helper SC…
Copilot Apr 30, 2026
79ad944
Apply code-review feedback: input validation, interrupt handling, dis…
Copilot Apr 30, 2026
166c1f9
add annotation
rpdome Apr 30, 2026
2d6e1c4
Merge branch 'dev' into copilot/add-e2e-concurrent-stress-test
rpdome Jun 16, 2026
2c30553
move to kt.
rpdome Jun 16, 2026
300bb04
updatescope
rpdome Jun 16, 2026
cb50fac
workaround known issue
rpdome Jun 16, 2026
5ec338c
common
rpdome Jun 17, 2026
51587df
clarify
rpdome Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String> =
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<String>)

/** Issues one request; must call `done.countDown()` exactly once. */
fun interface SilentTokenRequester {
fun request(threadIndex: Int, iteration: Int, done: CountDownLatch, errors: MutableList<String>)
}

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<String>())
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<CountDownLatch>()
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))
}
}
Original file line number Diff line number Diff line change
@@ -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<String> = 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
Comment thread
rpdome marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
rpdome marked this conversation as resolved.
"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"
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
</LinearLayout>

Expand Down
Loading