-
Notifications
You must be signed in to change notification settings - Fork 145
Add e2e concurrent/stress test for AcquireTokenSilent via broker, Fixes AB#3582859 #2504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 8827786
Fix review feedback: iteration number in error message, tighter timeout
Copilot a54f69d
Increase thread count to 13 and add 1000 iterations per thread to mat…
Copilot a31eb6b
Extract ConcurrentAcquireTokenSilentHelper to share concurrency imple…
Copilot 588db9c
Fix spelling: synchronised -> synchronized in comments
Copilot 5f2db2c
Cut total timeout from 14400s to 5000s
Copilot e5239ad
Set ITERATIONS_PER_THREAD=100 and TOTAL_TIMEOUT_SECONDS=500
Copilot 23e9ad4
Change timeout from per-request to per-wave (10s max per wave)
Copilot 98d009a
Remove arbitrary 200s buffer from TOTAL_TIMEOUT_SECONDS; derive it fr…
Copilot 85f1ca2
Remove TOTAL_TIMEOUT_SECONDS from public API; derive backstop interna…
Copilot 186f41a
Update testapps/testapp/src/main/java/com/microsoft/identity/client/t…
rpdome 7de08fb
Delete duplicate ConcurrentAcquireTokenExecutor.java; align helper SC…
Copilot 79ad944
Apply code-review feedback: input validation, interrupt handling, dis…
Copilot 166c1f9
add annotation
rpdome 2d6e1c4
Merge branch 'dev' into copilot/add-e2e-concurrent-stress-test
rpdome 2c30553
move to kt.
rpdome 300bb04
updatescope
rpdome cb50fac
workaround known issue
rpdome 5ec338c
common
rpdome 51587df
clarify
rpdome File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Submodule common
updated
6 files
145 changes: 145 additions & 0 deletions
145
...lient/msal/automationapp/testpass/broker/concurrent/ConcurrentAcquireTokenSilentHelper.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| } | ||
| } |
194 changes: 194 additions & 0 deletions
194
...ent/msal/automationapp/testpass/broker/concurrent/TestCaseConcurrentAcquireTokenSilent.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.