Skip to content

Commit 4d2f245

Browse files
CopilotrpdomeCopilot
authored
Add e2e concurrent/stress test for AcquireTokenSilent via broker, Fixes AB#3582859 (#2504)
[AB#3582859](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3582859) Add stress test for concurrent request scenario. (one caveat: there's currently a known "null_object" issue which is only addressed in OneAuth. so the test will purposedly suppress that. but at least this should be good enough to capture deadlocks or other errors) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rpdome <19558668+rpdome@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Dome Pongmongkol <rapong@microsoft.com>
1 parent 69b5475 commit 4d2f245

5 files changed

Lines changed: 356 additions & 15 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// All rights reserved.
3+
//
4+
// This code is licensed under the MIT License.
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files(the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions :
12+
//
13+
// The above copyright notice and this permission notice shall be included in
14+
// all copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
// THE SOFTWARE.
23+
package com.microsoft.identity.client.msal.automationapp.testpass.broker.concurrent
24+
25+
import java.util.Collections
26+
import java.util.concurrent.CountDownLatch
27+
import java.util.concurrent.CyclicBarrier
28+
import java.util.concurrent.TimeUnit
29+
import java.util.concurrent.atomic.AtomicBoolean
30+
import java.util.concurrent.atomic.AtomicReference
31+
32+
/**
33+
* Drives a barrier-synchronized concurrent `AcquireTokenSilent` stress run.
34+
*
35+
* Each of [iterations] waves releases all [threadCount] threads from a
36+
* [CyclicBarrier] at once, so the broker sees [threadCount] truly simultaneous
37+
* requests. A wave only advances once every request has called back.
38+
*
39+
* Callers must make each wave's requests distinct (e.g. [scopesForThread]) or
40+
* the dispatcher de-duplicates the identical in-flight commands.
41+
*/
42+
object ConcurrentAcquireTokenSilentHelper {
43+
44+
/**
45+
* One distinct delegated scope per thread, so concurrent commands aren't
46+
* de-duplicated. All are silently satisfiable once the device is WPJ'd.
47+
*/
48+
val SCOPE_POOL = arrayOf(
49+
"User.Read",
50+
"AccessReview.Read.All",
51+
"PeopleSettings.Read.All",
52+
"AdministrativeUnit.Read.All",
53+
"UserAuthenticationMethod.Read",
54+
"Sites.Search.All",
55+
"User-Phone.ReadWrite.All",
56+
"Organization.Read.All",
57+
"AgentCollection.Read.All",
58+
"Place.Read.All",
59+
"Application.Read.All",
60+
"Agreement.Read.All",
61+
"TermStore.Read.All",
62+
"User-Mail.ReadWrite.All",
63+
"User-LifeCycleInfo.Read.All"
64+
)
65+
66+
fun scopesForThread(threadIndex: Int): List<String> =
67+
listOf(SCOPE_POOL[threadIndex % SCOPE_POOL.size])
68+
69+
/** `allCompleted` is true only if every wave finished within the timeout. */
70+
data class StressResult(val allCompleted: Boolean, val errors: List<String>)
71+
72+
/** Issues one request; must call `done.countDown()` exactly once. */
73+
fun interface SilentTokenRequester {
74+
fun request(threadIndex: Int, iteration: Int, done: CountDownLatch, errors: MutableList<String>)
75+
}
76+
77+
fun run(
78+
threadCount: Int,
79+
iterations: Int,
80+
perWaveTimeoutSec: Long,
81+
requester: SilentTokenRequester,
82+
): StressResult {
83+
require(threadCount > 0) { "threadCount must be > 0" }
84+
require(iterations > 0) { "iterations must be > 0" }
85+
require(perWaveTimeoutSec > 0) { "perWaveTimeoutSec must be > 0" }
86+
87+
val errors = Collections.synchronizedList(ArrayList<String>())
88+
val stopped = AtomicBoolean(false)
89+
val allThreadsDone = CountDownLatch(threadCount)
90+
91+
// The last thread to reach the barrier installs the wave's shared latch
92+
// (counting down from threadCount) before any thread is released.
93+
val waveLatch = AtomicReference<CountDownLatch>()
94+
val barrier = CyclicBarrier(threadCount) { waveLatch.set(CountDownLatch(threadCount)) }
95+
96+
for (t in 0 until threadCount) {
97+
Thread({
98+
try {
99+
for (iter in 0 until iterations) {
100+
if (stopped.get()) break
101+
102+
try {
103+
barrier.await(perWaveTimeoutSec, TimeUnit.SECONDS)
104+
} catch (interrupted: InterruptedException) {
105+
Thread.currentThread().interrupt()
106+
break
107+
} catch (barrierBroken: Exception) {
108+
if (!stopped.get()) errors.add("Thread $t barrier broke at wave $iter: $barrierBroken")
109+
break
110+
}
111+
if (stopped.get()) break
112+
113+
val done = waveLatch.get()
114+
try {
115+
requester.request(t, iter, done, errors)
116+
} catch (dispatchError: Throwable) {
117+
errors.add("Thread $t iter $iter dispatch failed: $dispatchError")
118+
done.countDown()
119+
}
120+
121+
try {
122+
if (!done.await(perWaveTimeoutSec, TimeUnit.SECONDS)) {
123+
// First to time out aborts the run and frees any
124+
// threads already parked at the next barrier.
125+
if (stopped.compareAndSet(false, true)) {
126+
errors.add("Wave $iter timed out after ${perWaveTimeoutSec}s")
127+
barrier.reset()
128+
}
129+
break
130+
}
131+
} catch (interrupted: InterruptedException) {
132+
Thread.currentThread().interrupt()
133+
break
134+
}
135+
}
136+
} finally {
137+
allThreadsDone.countDown()
138+
}
139+
}, "ConcurrentATS-$t").apply { isDaemon = true }.start()
140+
}
141+
142+
val allCompleted = allThreadsDone.await(iterations.toLong() * perWaveTimeoutSec, TimeUnit.SECONDS)
143+
return StressResult(allCompleted && !stopped.get(), ArrayList(errors))
144+
}
145+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// All rights reserved.
3+
//
4+
// This code is licensed under the MIT License.
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files(the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions :
12+
//
13+
// The above copyright notice and this permission notice shall be included in
14+
// all copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
// THE SOFTWARE.
23+
package com.microsoft.identity.client.msal.automationapp.testpass.broker.concurrent
24+
25+
import com.microsoft.identity.client.AcquireTokenSilentParameters
26+
import com.microsoft.identity.client.IAuthenticationResult
27+
import com.microsoft.identity.client.Prompt
28+
import com.microsoft.identity.client.SilentAuthenticationCallback
29+
import com.microsoft.identity.client.claims.ClaimsRequest
30+
import com.microsoft.identity.client.claims.RequestedClaimAdditionalInformation
31+
import com.microsoft.identity.client.exception.MsalException
32+
import com.microsoft.identity.client.msal.automationapp.BuildConfig
33+
import com.microsoft.identity.client.msal.automationapp.R
34+
import com.microsoft.identity.client.msal.automationapp.sdk.MsalAuthTestParams
35+
import com.microsoft.identity.client.msal.automationapp.sdk.MsalSdk
36+
import com.microsoft.identity.client.msal.automationapp.testpass.broker.AbstractMsalBrokerTest
37+
import com.microsoft.identity.client.ui.automation.TokenRequestTimeout
38+
import com.microsoft.identity.client.ui.automation.annotations.LongUIAutomationTest
39+
import com.microsoft.identity.client.ui.automation.annotations.RetryOnFailure
40+
import com.microsoft.identity.client.ui.automation.annotations.StressTest
41+
import com.microsoft.identity.client.ui.automation.annotations.SupportedBrokers
42+
import com.microsoft.identity.client.ui.automation.broker.BrokerMicrosoftAuthenticator
43+
import com.microsoft.identity.client.ui.automation.interaction.OnInteractionRequired
44+
import com.microsoft.identity.client.ui.automation.interaction.PromptHandlerParameters
45+
import com.microsoft.identity.client.ui.automation.interaction.PromptParameter
46+
import com.microsoft.identity.client.ui.automation.interaction.microsoftsts.AadPromptHandler
47+
import com.microsoft.identity.client.ui.automation.logging.Logger
48+
import com.microsoft.identity.common.java.exception.ClientException
49+
import com.microsoft.identity.common.java.providers.oauth2.IDToken
50+
import com.microsoft.identity.labapi.utilities.constants.TempUserType
51+
import com.microsoft.identity.labapi.utilities.constants.UserType
52+
import org.junit.Assert
53+
import org.junit.Test
54+
55+
/**
56+
* Concurrent stress test for `AcquireTokenSilent` through the broker on a
57+
* Workplace-Joined (WPJ) device: register the device inline via a `deviceid`
58+
* claim on a single interactive sign-in, then fire [ITERATIONS] barrier-
59+
* synchronized waves of [CONCURRENT_THREADS] simultaneous `forceRefresh` silent
60+
* calls and assert none hangs or errors.
61+
*
62+
* Each thread uses a distinct scope so the `CommandDispatcher` can't de-duplicate
63+
* the simultaneous in-flight commands; the WPJ PRT satisfies every scope silently.
64+
* [CONCURRENT_THREADS] equals the pool size so every concurrent request is unique.
65+
*/
66+
@SupportedBrokers(brokers = [BrokerMicrosoftAuthenticator::class])
67+
@StressTest
68+
@LongUIAutomationTest
69+
class TestCaseConcurrentAcquireTokenSilent : AbstractMsalBrokerTest() {
70+
71+
@Test
72+
fun test_concurrentAcquireTokenSilent_withBroker() {
73+
val username = mLabAccount.username
74+
val password = mLabAccount.password
75+
76+
val msalSdk = MsalSdk()
77+
78+
// Inline WPJ: a deviceid claim on the interactive sign-in registers the
79+
// device (broker gets a PRT) and establishes the account in one flow.
80+
val deviceIdClaims = ClaimsRequest().apply {
81+
requestClaimInIdToken(
82+
"deviceid",
83+
RequestedClaimAdditionalInformation().apply { setEssential(true) },
84+
)
85+
}
86+
87+
val interactiveParams = MsalAuthTestParams.builder()
88+
.activity(mActivity)
89+
.loginHint(username)
90+
.scopes(listOf(*mScopes))
91+
.promptParameter(Prompt.LOGIN)
92+
.claims(deviceIdClaims)
93+
.msalConfigResourceId(configFileResourceId)
94+
.build()
95+
96+
val interactiveResult = msalSdk.acquireTokenInteractive(
97+
interactiveParams,
98+
OnInteractionRequired {
99+
val promptHandlerParameters = PromptHandlerParameters.builder()
100+
.prompt(PromptParameter.LOGIN)
101+
.loginHint(username)
102+
.broker(mBroker)
103+
.sessionExpected(false)
104+
.registerPageExpected(true)
105+
.consentPageExpected(false)
106+
.speedBumpExpected(false)
107+
.expectingLoginPageAccountPicker(false)
108+
.build()
109+
110+
AadPromptHandler(promptHandlerParameters).handlePrompt(username, password)
111+
},
112+
TokenRequestTimeout.MEDIUM,
113+
)
114+
115+
interactiveResult.assertSuccess()
116+
117+
// Confirm the device actually registered (deviceid present in the token).
118+
val claims = IDToken.parseJWT(interactiveResult.accessToken)
119+
Assert.assertNotNull("deviceid claim must be present after inline WPJ", claims["deviceid"])
120+
121+
val account = msalSdk.getAccount(mActivity, configFileResourceId, username)
122+
Assert.assertNotNull("Account must not be null after a successful interactive sign-in", account)
123+
124+
// Distinct scope per thread → no command de-duplication.
125+
val result = ConcurrentAcquireTokenSilentHelper.run(
126+
CONCURRENT_THREADS,
127+
ITERATIONS,
128+
PER_WAVE_TIMEOUT_SECONDS,
129+
) { threadIndex, iteration, done, errors ->
130+
val silentParameters = AcquireTokenSilentParameters.Builder()
131+
.forAccount(account)
132+
.fromAuthority(account.authority)
133+
.withScopes(ConcurrentAcquireTokenSilentHelper.scopesForThread(threadIndex))
134+
.forceRefresh(true)
135+
.withCallback(object : SilentAuthenticationCallback {
136+
override fun onSuccess(authenticationResult: IAuthenticationResult) {
137+
done.countDown()
138+
}
139+
140+
override fun onError(exception: MsalException) {
141+
errors.add("Thread $threadIndex iter $iteration [${exception.errorCode}]: $exception")
142+
done.countDown()
143+
}
144+
})
145+
.build()
146+
147+
mApplication.acquireTokenSilentAsync(silentParameters)
148+
}
149+
150+
Assert.assertTrue(
151+
"Concurrent AcquireTokenSilent got stuck: not all $CONCURRENT_THREADS threads" +
152+
" completed $ITERATIONS waves (per-wave timeout ${PER_WAVE_TIMEOUT_SECONDS}s)",
153+
result.allCompleted,
154+
)
155+
156+
// null_object under concurrency is a known broker issue whose fix (network-token fallback)
157+
// is gated to MSAL_CPP (OneAuth). See BrokerFlight.USE_NETWORK_TOKEN_FALLBACK_FOR_NULL_OBJECT
158+
val unexpectedErrors = result.errors.filterNot { it.contains("[${ClientException.NULL_OBJECT}]") }
159+
160+
val toleratedNullObjects = result.errors.size - unexpectedErrors.size
161+
if (toleratedNullObjects > 0) {
162+
Logger.w(
163+
TAG,
164+
"Tolerated $toleratedNullObjects known null_object error(s);" +
165+
" UseNetworkTokenFallbackForNullObjectMsalAndroid flight is off",
166+
)
167+
}
168+
169+
Assert.assertTrue(
170+
"Some concurrent AcquireTokenSilent calls failed: $unexpectedErrors",
171+
unexpectedErrors.isEmpty(),
172+
)
173+
}
174+
175+
override fun getJsonUserType(): UserType = UserType.BASIC
176+
177+
override fun getTempUserType(): TempUserType? = null
178+
179+
override fun getScopes(): Array<String> = arrayOf("User.read")
180+
181+
override fun getAuthority(): String =
182+
mApplication.configuration.defaultAuthority.toString()
183+
184+
override fun getConfigFileResourceId(): Int = R.raw.msal_config_default
185+
186+
companion object {
187+
private val TAG = TestCaseConcurrentAcquireTokenSilent::class.java.simpleName
188+
189+
/** One thread per pooled scope, so every concurrent request is unique. */
190+
private val CONCURRENT_THREADS = ConcurrentAcquireTokenSilentHelper.SCOPE_POOL.size
191+
private const val ITERATIONS = 100
192+
private const val PER_WAVE_TIMEOUT_SECONDS = 30L
193+
}
194+
}

testapps/testapp/src/main/java/com/microsoft/identity/client/testapp/ConcurrentAcquireTokenExecutor.kt

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -124,19 +124,21 @@ class ConcurrentAcquireTokenExecutor(
124124
* (CommandDispatcher collapses commands with identical parameters).
125125
*/
126126
private val SCOPE_POOL = listOf(
127-
"user.read",
128-
"user.readbasic.all",
129-
"mail.read",
130-
"calendars.read",
131-
"contacts.read",
132-
"files.read",
133-
"files.read.all",
134-
"people.read",
135-
"notes.read",
136-
"tasks.read",
137-
"sites.read.all",
138-
"directory.read.all",
139-
"group.read.all"
127+
"User.Read",
128+
"AccessReview.Read.All",
129+
"PeopleSettings.Read.All",
130+
"AdministrativeUnit.Read.All",
131+
"UserAuthenticationMethod.Read",
132+
"Sites.Search.All",
133+
"User-Phone.ReadWrite.All",
134+
"Organization.Read.All",
135+
"AgentCollection.Read.All",
136+
"Place.Read.All",
137+
"Application.Read.All",
138+
"Agreement.Read.All",
139+
"TermStore.Read.All",
140+
"User-Mail.ReadWrite.All",
141+
"User-LifeCycleInfo.Read.All"
140142
)
141143

142144
/**

testapps/testapp/src/main/res/layout/fragment_acquire.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@
755755
android:layout_height="wrap_content"
756756
android:layout_weight="7"
757757
android:inputType="number"
758-
android:text="13"
758+
android:text="15"
759759
android:textSize="12sp" />
760760
</LinearLayout>
761761

0 commit comments

Comments
 (0)