Skip to content

Scope error-class throttling per-user for public-client flows - #1055

Open
Avery-Dunn wants to merge 1 commit into
devfrom
avdunn/throttling-fix
Open

Scope error-class throttling per-user for public-client flows#1055
Avery-Dunn wants to merge 1 commit into
devfrom
avdunn/throttling-fix

Conversation

@Avery-Dunn

@Avery-Dunn Avery-Dunn commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #1019, using the core fix made by Maximilian Pfeffer (@gterminator) in #1050 but avoiding an issue in 429 throttling scenarios, adds a couple extra tests, and allowing it to be run in our CI pipelines.

See:
AzureAD/microsoft-authentication-library-for-js#8756
AzureAD/microsoft-authentication-library-for-dotnet#6159

Problem

When a public-client app acquires tokens for multiple users under the same clientId / authority / scope, a failed request for one user could throttle every other user. Reported symptoms in #1019:

  • One user repeatedly submitting a bad password (ADFS/STS returns HTTP 500 for that user) caused all users of the app to be blocked with MsalThrottlingException.
  • Intermittent HTTP 500 responses from the token endpoint for one user blocked unrelated users.

Root cause

HttpHelper throttles public-client requests by a "request thumbprint". Before this change the thumbprint was derived from clientId + authority + scope only — it did not include any user component for flows like Username/Password (ROPC), where there is no Account yet. As a result two different users produced a byte-identical throttle key, so a 500 cached for user A immediately throttled user B.

Fix

Make the throttle key response-type-aware:

  • HTTP 5xx (error-class, and can be user-specific — e.g. a single user's bad password) → key per-user, folding the request's user component (UPN, else OID) into the thumbprint. One user's failure no longer blocks others.
  • HTTP 429 / explicit Retry-After (service-directed rate limiting for the whole client) → key remains app-wide (no user component), preserving correct global back-off.

checkForThrottling now checks the app-wide key first, then the user-aware key when it differs; processThrottlingInstructions writes under the app-wide key for 429/Retry-After and under the user-aware key for 5xx.

Files changed

  • msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java
    • getRequestThumbprint(RequestContext, boolean includeUser) overload.
    • checkForThrottling checks both app-wide and user-aware keys.
    • processThrottlingInstructions routes 5xx → user-aware, 429/Retry-After → app-wide.

Behavior change

Trigger Before After
HTTP 5xx (e.g. bad password via ADFS) Throttles the whole client (all users) Throttles only that user
HTTP 429 App-wide App-wide (unchanged)
Explicit Retry-After App-wide App-wide (unchanged)
Auth-code / other flows without a user component App-wide App-wide (unchanged)

Tests

msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RequestThrottlingTest.java:

  • UserNamePassword_DifferentUsersThrottledIndependently — user A's 500 throttles A but not user B (regression test for [Bug] Throttling cache affects whole clientId when user provides wrong password in ADFS federation #1019).
  • UserNamePassword_429ThrottlesDifferentUsersAppWide — a 429 still throttles a different user, proving 429 stays app-wide (guards against over-narrowing).
  • STSResponseContains_StatusCode500_DifferentUsersNotThrottledForEachOther and SilentFlow_DifferentAccountsThrottledIndependently — end-to-end and silent-flow isolation.
  • All existing throttling tests (RETRY_AFTER_HEADER, 429, 500, combinations) continue to pass unchanged.

@gterminator

Copy link
Copy Markdown
Contributor

Avery-Dunn I'm not sure about this change. What happens when there is some gateway timeout? Or other 5xx messages?

I mean we have to check for the user case that we have a 5xx AND a retry in the header, otherwise we could get some delays in that we dont want e.g. on 502 Bad Gateway for example.

Btw. What happens when we retry? Do we consume the exception and automatically retry?

if (includeUser) {
UserIdentifier userIdentifier = requestContext.userIdentifier();
if (userIdentifier != null) {
if (!StringHelper.isBlank(userIdentifier.upn())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we should generally not use upn as discriminator. Despite the name, it's not unique. Consider using oid and then falling back to upn.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts MSAL4J’s public-client request throttling so that HTTP 5xx “error-class” throttling is keyed per user, while HTTP 429 / explicit Retry-After throttling remains app-wide, preventing one user’s failures (e.g., bad password causing ADFS 500) from throttling other users sharing the same clientId/authority/scope.

Changes:

  • Introduces app-wide vs user-aware throttle thumbprints and updates throttling read/write logic accordingly.
  • Updates throttling checks to consult the app-wide key first, then the user-aware key when different.
  • Adds regression and isolation tests for per-user 5xx throttling and app-wide 429 throttling.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java Adds user-aware thumbprint support and routes throttling behavior based on response type (5xx vs 429/Retry-After).
msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RequestThrottlingTest.java Adds regression tests ensuring 5xx throttling is per-user while 429 remains app-wide.

Comment on lines +190 to +200
// Check the app-wide key first (429 / Retry-After entries), then the user-aware key
// (5xx entries) when it differs from the app-wide key.
String appWideThumbprint = getRequestThumbprint(requestContext, false);
long retryInMs = ThrottlingCache.retryInMs(appWideThumbprint);

if (retryInMs <= 0) {
String userAwareThumbprint = getRequestThumbprint(requestContext, true);
if (!userAwareThumbprint.equals(appWideThumbprint)) {
retryInMs = ThrottlingCache.retryInMs(userAwareThumbprint);
}
}
Comment on lines +215 to +219
@Test
void STSResponseContains_StatusCode500_DifferentUsersNotThrottledForEachOther() throws Exception {
skipInvocationCountCheck = true;
ThrottlingCache.clear();

@gterminator

Copy link
Copy Markdown
Contributor

Avery-Dunn can we maybe pin this special behavior to a special status code?

I want to avoid that you get DDOS-ed when some Auth Server is on bad gateway or has a error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Throttling cache affects whole clientId when user provides wrong password in ADFS federation

4 participants