Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7f5c420
Remember the UPN across a broker-install interruption to pre-fill log…
wzhipan Jul 27, 2026
7a70c0d
Add changelog entry for the broker-install UPN hint (#3195)
wzhipan Jul 27, 2026
a3e64c0
Capture and pre-fill the broker-install UPN where OneAuth also travels
wzhipan Jul 27, 2026
4f933c7
Align the MAM-CA UPN hint with the Phase 1 design
wzhipan Jul 28, 2026
8d285bd
Raise the MAM-CA UPN hint TTL default to 15 minutes
wzhipan Jul 28, 2026
a5638dd
Gate the MAM-CA UPN hint strictly on the intuneAppProtection marker
wzhipan Jul 28, 2026
ea754a3
Document that the UPN hint must be read when the account screen is shown
wzhipan Jul 28, 2026
1ad01b6
Keep the MAM-CA UPN hint alive until it is actually used
wzhipan Jul 28, 2026
6c33201
Correct the stale single-use wording in the UPN hint store
wzhipan Jul 28, 2026
d723ed0
Default the MAM-CA UPN hint TTL to three minutes
wzhipan Jul 29, 2026
12dc7af
Harden the UPN hint read and tighten the changelog
wzhipan Jul 29, 2026
7dadd0c
Address third-party review of the MAM-CA UPN hint store
wzhipan Jul 29, 2026
e2e0d13
Drop the "unknown" client-id placeholder from the UPN hint store
wzhipan Jul 29, 2026
f6cd95d
Convert the new MAM-CA UPN hint classes to Kotlin
wzhipan Jul 29, 2026
910bcd7
Merge dev into copilot/mam-upn-hint-store
wzhipan Jul 29, 2026
2af179a
Bind the MAM-CA UPN hint to the authority it was captured against
wzhipan Jul 29, 2026
7e4d693
Drop the redundant storage-level getValidUpnHint seam
wzhipan Jul 29, 2026
da54f6d
Add Robolectric coverage for the Android glue in this PR
wzhipan Jul 29, 2026
01955a8
Correct stale documentation about when the UPN hint is retired
wzhipan Jul 29, 2026
0893d56
Address review feedback on the MAM-CA UPN hint store
wzhipan Jul 30, 2026
c63dd95
Address review nits on the MAM-CA UPN hint store
wzhipan Jul 31, 2026
f853d1a
Merge remote-tracking branch 'origin/dev' into copilot/mam-upn-hint-s…
wzhipan Jul 31, 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
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ vNext
- [MINOR] Promote finalizeBlob() onto the IOnboardingTelemetryRecorder interface (as a binary-compatible default method) so callers finalize the onboarding telemetry blob through the interface instead of downcasting to the concrete OnboardingTelemetryRecorder (#3180)
- [PATCH] Security: validate the target of WebView broker-install intent-scheme redirects against an allow-list (Google Play Store only), clearing any explicit component/selector and stripping URI-permission grant flags so resolution is driven solely by the validated package; gated by ENABLE_BROKER_INSTALL_INTENT_VALIDATION CommonFlight (default off) and recording the launched / blocked outcome via the is_broker_install_intent_blocked span attribute (#3170)
- [PATCH] Rename WebAppError 'error' field to 'code' in WebApps API error response (#3192)
- [PATCH] Pre-fill login_hint with the UPN remembered from a MAM Conditional Access broker-install redirect, so users are not asked to retype their address after installing Company Portal; the hint is bound to the authority host it was captured against and is never sent to another, and the pre-fill outcome is recorded via the mam_ca_upn_hint_outcome span attribute; gated by ENABLE_MAM_CA_UPN_HINT CommonFlight (default off) (#3195)

Version 24.5.0
----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
import com.microsoft.identity.common.java.exception.ServiceException;
import com.microsoft.identity.common.java.exception.UnsupportedBrokerException;
import com.microsoft.identity.common.java.interfaces.IPlatformComponents;
import com.microsoft.identity.common.java.providers.MamUpnHintStore;
import com.microsoft.identity.common.java.providers.microsoft.MicrosoftRefreshToken;
import com.microsoft.identity.common.java.providers.microsoft.azureactivedirectory.ClientInfo;
import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAccount;
Expand Down Expand Up @@ -349,8 +350,16 @@ private String tryGetNegotiatedProtocolVersionFromHelloCache(
* @return an {@link AcquireTokenResult}.
*/
@Override
public AcquireTokenResult acquireToken(final @NonNull InteractiveTokenCommandParameters parameters)
public AcquireTokenResult acquireToken(final @NonNull InteractiveTokenCommandParameters requestParameters)
throws BaseException, InterruptedException, ExecutionException {
// MAM Conditional Access onboarding: pre-fill the UPN the user gave us before a Conditional
// Access "install Company Portal" block interrupted them. This runs on every interactive
// request, not only the one that follows an install - it is a no-op unless the flight is on,
// the caller left login_hint blank, the caller did not ask the user to pick or create an
// account, and a hint stored for this client and authority is still within its TTL.
final InteractiveTokenCommandParameters parameters =
MamUpnHintStore.applyStoredUpnHintIfAbsent(requestParameters);

final AcquireTokenResult result;
try {
//Get the broker interactive parameters intent
Expand Down Expand Up @@ -382,6 +391,13 @@ public AcquireTokenResult acquireToken(final @NonNull InteractiveTokenCommandPar
.putApiId(TelemetryEventStrings.Api.BROKER_ACQUIRE_TOKEN_INTERACTIVE)
);

if (result != null && result.getSucceeded()) {
// Signed in, so any remembered UPN has served its purpose and should not linger. This is
// where a hint is retired: applying one does not delete it, because the request it was
// attached to can still fail.
MamUpnHintStore.clearUpnHint(parameters.getPlatformComponents(), parameters.getClientId());
}

return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
import com.microsoft.identity.common.java.util.ThreadUtils;
import com.microsoft.identity.common.java.flighting.CommonFlight;
import com.microsoft.identity.common.java.flighting.CommonFlightsManager;
import com.microsoft.identity.common.java.providers.MamUpnHintStore;
import com.microsoft.identity.common.java.providers.RawAuthorizationResult;
import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationRequest;
import com.microsoft.identity.common.java.providers.microsoft.microsoftsts.MicrosoftStsAuthorizationResponse;
Expand Down Expand Up @@ -105,10 +106,18 @@ public class LocalMSALController extends BaseController {

@Override
public AcquireTokenResult acquireToken(
@NonNull final InteractiveTokenCommandParameters parameters)
@NonNull final InteractiveTokenCommandParameters requestParameters)
throws ExecutionException, InterruptedException, ClientException, IOException, ArgumentException {
final String methodTag = TAG + ":acquireToken";

// MAM broker-install onboarding: pre-fill the UPN the user gave us before a Conditional
// Access "install Company Portal" block interrupted them. This runs on every interactive
// request, not only the one that follows an install - it is a no-op unless the flight is on,

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.

Minor doc accuracy nit: the "no-op unless (a) flight on, (b) login_hint blank, (c) valid hint stored for this client and authority" list reads as sufficient, but applyStoredUpnHintIfAbsent also declines when prompt is SELECT_ACCOUNT or CREATE (MamUpnHintStore.java:571-575), even with all three conditions holding. A reader following the comment could be surprised when a request with prompt=SELECT_ACCOUNT isn''t pre-filled despite the setup looking right.

Suggested phrasing:

"... unless the flight is on, the caller left login_hint blank, the caller did not ask the user to pick or create an account, and a hint stored for this client and authority is still within its TTL."

Same fix in BrokerMsalController at line 357.

Related (optional): the prompt-decline rule used to live in the class-level Javadoc of MamUpnHintStore and was removed in the "trim to intro + five bullets" cleanup. It''s now only visible by reading applyStoredUpnHintIfAbsent''s body. Consider adding a short bullet or a line under the Authority-binding bullet noting the prompt exclusion, so applyStoredUpnHintIfAbsent''s contract is discoverable from the class Javadoc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on all three. Taking your phrasing for both controllers, and restoring the prompt rule to the class Javadoc.

One addition: the line that was trimmed only mentioned "choose", so the restored bullet will cover CREATE as well.

// the caller left login_hint blank, the caller did not ask the user to pick or create an
// account, and a hint stored for this client and authority is still within its TTL.
final InteractiveTokenCommandParameters parameters =
MamUpnHintStore.applyStoredUpnHintIfAbsent(requestParameters);

Logger.verbose(
methodTag,
"Acquiring token..."
Expand Down Expand Up @@ -174,6 +183,19 @@ public AcquireTokenResult acquireToken(

ResultUtil.logResult(TAG, result);

// MAM Conditional Access onboarding: if Conditional Access blocked this request until
// Company Portal is installed, remember the UPN the server sent back. Installing Company
// Portal usually kills this process, so the hint is persisted and pre-filled on the
// interactive request the user makes once they return. No-op unless the flight is on and
// this failure is specifically the MAM-CA install - an ordinary device-registration broker
// install stores nothing.
MamUpnHintStore.saveUpnHintForMamCaInstall(
parametersWithScopes.getPlatformComponents(),
parametersWithScopes.getClientId(),
parametersWithScopes.getAuthority(),
result.getAuthorizationErrorResponse()
);

if (result.getAuthorizationStatus().equals(AuthorizationStatus.SUCCESS)) {
//3) Exchange authorization code for token
final TokenResult tokenResult = performTokenRequest(
Expand Down Expand Up @@ -235,6 +257,13 @@ public AcquireTokenResult acquireToken(
.putApiId(TelemetryEventStrings.Api.LOCAL_ACQUIRE_TOKEN_INTERACTIVE)
);

if (acquireTokenResult.getSucceeded()) {
// Signed in, so any remembered UPN has served its purpose and should not linger. This is
// where a hint is retired: applying one does not delete it, because the request it was
// attached to can still fail.
MamUpnHintStore.clearUpnHint(parameters.getPlatformComponents(), parameters.getClientId());
}

return acquireTokenResult;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

import static com.microsoft.identity.common.java.AuthenticationConstants.OAuth2.CLIENT_ID;
import static com.microsoft.identity.common.java.AuthenticationConstants.OAuth2.UTID;

import com.microsoft.identity.common.java.opentelemetry.OTelUtility;
Expand Down Expand Up @@ -371,14 +372,62 @@ public void updateLatestUrlStatus(final String loadingError, final String authEr
public Map<Integer, UrlStatus> getUrlStatusMap() {
return WebViewAuthorizationFragment.this.getUrlLoadTracker();
}
}
},
getClientIdFromRequestUrl(mAuthorizationRequestUrl),
getAuthorityHostFromRequestUrl(mAuthorizationRequestUrl)
);
setUpWebView(view, mAADWebViewClient);
mAADWebViewClient.initializeAuthUxJavaScriptApi(mWebView, mAuthorizationRequestUrl);
launchWebView(mAuthorizationRequestUrl, mRequestHeaders);
return view;
}

/**
* Reads {@code client_id} off an authorization request URL, so the WebView client can scope
* per-app state to the app being authorized.
*
* @param requestUrl the authorization request URL.
* @return the client id, or null when it cannot be determined.
*/
@Nullable
@VisibleForTesting
static String getClientIdFromRequestUrl(@Nullable final String requestUrl) {
final String methodTag = TAG + ":getClientIdFromRequestUrl";
if (StringUtil.isNullOrEmpty(requestUrl)) {
return null;
}

try {
return Uri.parse(requestUrl).getQueryParameter(CLIENT_ID);
} catch (final UnsupportedOperationException e) {
Logger.warn(methodTag, "Could not read the client id off the request url.");
return null;
}
}

/**
* Reads the host off an authorization request URL, so state captured mid-flow can be bound to
* the authority it came from.
*
* @param requestUrl the authorization request URL.
* @return the authority host, or null when it cannot be determined.
*/
@Nullable
@VisibleForTesting
static String getAuthorityHostFromRequestUrl(@Nullable final String requestUrl) {
final String methodTag = TAG + ":getAuthorityHostFromRequestUrl";
if (StringUtil.isNullOrEmpty(requestUrl)) {
return null;
}

try {
return Uri.parse(requestUrl).getHost();
} catch (final Exception e) {
Logger.warn(methodTag, "Could not read the authority host off the request url.");
return null;
}
}

@Override
public void handleBackButtonPressed() {
final String methodTag = TAG + ":handleBackButtonPressed";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import androidx.lifecycle.ViewTreeLifecycleOwner;

import com.microsoft.identity.common.adal.internal.AuthenticationConstants;
import com.microsoft.identity.common.components.AndroidPlatformComponentsFactory;
import com.microsoft.identity.common.adal.internal.util.StringExtensions;
import com.microsoft.identity.common.internal.broker.BrokerData;
import com.microsoft.identity.common.internal.broker.BrokerValidator;
Expand Down Expand Up @@ -86,6 +87,7 @@
import com.microsoft.identity.common.java.WarningType;
import com.microsoft.identity.common.java.exception.ClientException;
import com.microsoft.identity.common.java.exception.ErrorStrings;
import com.microsoft.identity.common.java.providers.MamUpnHintStore;
import com.microsoft.identity.common.java.providers.RawAuthorizationResult;
import static com.microsoft.identity.common.java.telemetry.OnboardingTelemetryConstants.STEP_AUTHENTICATOR_MFA_LINKING_STARTED;
import static com.microsoft.identity.common.java.telemetry.OnboardingTelemetryConstants.STEP_BROKER_INSTALL_PROMPTED;
Expand Down Expand Up @@ -170,6 +172,20 @@ public class AzureActiveDirectoryWebViewClient extends OAuth2WebViewClient {
private final SpanContext mSpanContext;
private final String mUtid;

/**
* Client id of the request being authorized, when known. Used to scope the MAM Conditional
* Access UPN hint to the app that was interrupted.
*/
@Nullable
private final String mClientId;

/**
* Host of the authority this WebView is talking to, so a UPN captured mid-flow can be bound to
* it. Null when it could not be determined from the authorization request url.
*/
@Nullable
private final String mAuthorityHost;

private String mPasskeyRegistrationScript;

/**
Expand All @@ -195,6 +211,35 @@ public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity,
@Nullable final String utid,
final boolean isWebViewWebCpEnabledInBrokerlessCase,
@Nullable final IUrlLoadTracker urlLoadTracker) {
this(activity, completionCallback, pageLoadedCallback, redirectUrl,
switchBrowserProtocolCoordinator, utid, isWebViewWebCpEnabledInBrokerlessCase,
urlLoadTracker, null);
}

public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity,
@NonNull final IAuthorizationCompletionCallback completionCallback,
@NonNull final OnPageLoadedCallback pageLoadedCallback,
@NonNull final String redirectUrl,
@NonNull final SwitchBrowserProtocolCoordinator switchBrowserProtocolCoordinator,
@Nullable final String utid,
final boolean isWebViewWebCpEnabledInBrokerlessCase,
@Nullable final IUrlLoadTracker urlLoadTracker,
@Nullable final String clientId) {
this(activity, completionCallback, pageLoadedCallback, redirectUrl,
switchBrowserProtocolCoordinator, utid, isWebViewWebCpEnabledInBrokerlessCase,
urlLoadTracker, clientId, null);
}

public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity,
@NonNull final IAuthorizationCompletionCallback completionCallback,
@NonNull final OnPageLoadedCallback pageLoadedCallback,
@NonNull final String redirectUrl,
@NonNull final SwitchBrowserProtocolCoordinator switchBrowserProtocolCoordinator,
@Nullable final String utid,
final boolean isWebViewWebCpEnabledInBrokerlessCase,
@Nullable final IUrlLoadTracker urlLoadTracker,
@Nullable final String clientId,
@Nullable final String authorityHost) {
super(activity, completionCallback, pageLoadedCallback);
mRedirectUrl = redirectUrl;
mCertBasedAuthFactory = new CertBasedAuthFactory(activity);
Expand All @@ -203,6 +248,8 @@ public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity,
mSpanContext = activity instanceof AuthorizationActivity ? ((AuthorizationActivity) getActivity()).getSpanContext() : null;
mIsWebViewWebCpEnabledInBrokerlessCase = isWebViewWebCpEnabledInBrokerlessCase;
mUrlLoadTracker = urlLoadTracker;
mClientId = clientId;
mAuthorityHost = authorityHost;
}

public AzureActiveDirectoryWebViewClient(@NonNull final Activity activity,
Expand Down Expand Up @@ -1350,6 +1397,29 @@ private void processInstallRequest(@NonNull final WebView view, @NonNull final S
final Map<String, String> parameters = StringExtensions.getUrlParameters(url);
final String appLink = parameters.get(APP_LINK_KEY);

// MAM Conditional Access onboarding: the service tells us which account is being onboarded
// on this redirect. Installing Company Portal usually kills this process, so remember the UPN
// now and pre-fill it on the request the user makes when they come back, instead of asking
// them to type their address again. No-op unless the flight is on and the redirect is marked
// as the MAM-CA path.
//
// Written inline on the UI thread, deliberately. This is the last moment the process is
// guaranteed to be alive: the completion callback below finishes the authorization activity
// and the Play Store install that follows very often kills us. Handing the write to a
// background thread would risk losing the hint in exactly the case it exists for, and would
// trade a bounded, best-effort write - a single encrypted key/value put, with every failure
// caught and swallowed inside the store - for a race it cannot win.
final Activity installRequestActivity = getActivity();
if (installRequestActivity != null) {
MamUpnHintStore.saveUpnHintForMamCaInstall(

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.

This is on the UI thread right? shouldOverrideUrlLoading -> handleUrl -> processInstallRequest. Opening the encrypted store and the put both hit disk here, inline as we handle the redirect. Probably fine since the activity's about to be torn down for the Play Store hop, but did we think about doing the store write off the main thread?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, UI thread - and kept that way deliberately. Added a comment in 0893d56ad explaining why: this is the last moment the process is guaranteed alive (the Company Portal install usually kills us), and it is a single encrypted put with every failure caught inside the store. Handing it to a background thread would risk losing the hint in exactly the case it exists for.

AndroidPlatformComponentsFactory.createFromContext(
installRequestActivity.getApplicationContext()),
mClientId,
mAuthorityHost,
parameters
);
}

Logger.info(methodTag,"Launching the link to app:" + appLink);
getCompletionCallback().onChallengeResponseReceived(result);

Expand Down
Loading
Loading