Skip to content

Commit 2388e1d

Browse files
rohitluthraCopilotCopilot
authored
Add support for Authenticator app activation links in WebView., Fixes AB#3556494 (#3090)
<a href="https://identitydivision.visualstudio.com/Engineering/_workitems/edit/3556494 ">[[AB#3556494](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3556494)](https://identitydivision.visualstudio.com/Engineering/_workitems/edit/3556494 </a> Add support for user using "Pair your account functionality" in webview. * Previously the Android Applink was taking user to Playstore directly. * After fix --> the user is taken to authenticator app where MFA is completed and user is brought back to webview to complete the setup. [Check this fix video out - You can skip to half the video](https://microsoft.sharepoint.com/:v:/t/aad/cQpS7w5j-RjGTIwPARqjhpcjEgUC1m9f8sjVib1RAlSQ9S09Zw) --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent d8249fa commit 2388e1d

5 files changed

Lines changed: 250 additions & 1 deletion

File tree

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
vNext
22
----------
3+
- [PATCH] Add support for Authenticator app activation links in WebView, enabling account pairing/MFA flows to launch Microsoft Authenticator directly instead of redirecting to the Play Store (#3090)
34
- [PATCH] Fix: WPJ's BrokerDiscovery cache crash due to shared predefined encryption key with MSAL (#3081)
45
- [PATCH] Fix ABBA deadlock between AzureActiveDirectory and AzureActiveDirectoryAuthority class monitors by extracting polymorphic getAuthorityURL() calls outside synchronized scopes and removing unnecessary synchronized from ConcurrentHashMap read-only methods (#3082)
56
- [PATCH] Optimize AcquireTokenSilent save path: replace keySet() decrypt-all with in-memory map lookup in removeAccount()/removeCredential(), add telemetry for deleteAccessTokensWithIntersectingScopes, and remove unused elapsed_time_save_account_shared_preferences attribute (#3074)

common/src/main/java/com/microsoft/identity/common/adal/internal/AuthenticationConstants.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,13 @@ public static String computeMaxHostBrokerProtocol() {
13241324
*/
13251325
public static final String AUTHENTICATOR_MFA_LINKING_PREFIX = "microsoft-authenticator://activatemfa";
13261326

1327+
/**
1328+
* Path for the Authenticator app activation Android App Link.
1329+
* This is the HTTPS-based app link used by eSTS to launch the Authenticator app
1330+
* for MFA account pairing (e.g., https://login.microsoftonline.com/authenticatorApp/activateAccount).
1331+
*/
1332+
public static final String AUTHENTICATOR_APP_LINK_ACTIVATION_PATH = "/authenticatorapp/activateaccount";
1333+
13271334
/**
13281335
* Redirect URL from WebCP that should launch the Intune Company Portal app.
13291336
*/

common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@
112112
import static com.microsoft.identity.common.java.exception.ClientException.UNKNOWN_ERROR;
113113
import static com.microsoft.identity.common.java.flighting.CommonFlight.ENABLE_OPEN_ID_VC_REDIRECT;
114114
import static com.microsoft.identity.common.java.flighting.CommonFlight.ENABLE_PLAYSTORE_URL_LAUNCH;
115+
import static com.microsoft.identity.common.java.providers.microsoft.azureactivedirectory.AzureActiveDirectoryCloud.CHINA_CLOUD_LEGACY_HOST;
116+
import static com.microsoft.identity.common.java.providers.microsoft.azureactivedirectory.AzureActiveDirectoryCloud.PUBLIC_CLOUD_HOST;
117+
import static com.microsoft.identity.common.java.providers.microsoft.azureactivedirectory.AzureActiveDirectoryCloud.US_GOV_CLOUD_HOST;
115118

116119
import io.opentelemetry.api.baggage.Baggage;
117120
import io.opentelemetry.api.trace.Span;
@@ -336,6 +339,9 @@ else if (isRedirectUrl(formattedURL)) {
336339
} else if (isAuthAppMFAUrl(formattedURL)) {
337340
Logger.info(methodTag,"Request to link account with Authenticator.");
338341
processAuthAppMFAUrl(url);
342+
} else if (isAuthenticatorActivationAppLink(formattedURL)) {
343+
Logger.info(methodTag,"Request to open Authenticator via activation app link.");
344+
processAuthenticatorActivationAppLink(view, url);
339345
} else if (isAmazonAppRedirect(formattedURL)) {
340346
Logger.info(methodTag, "It is an Amazon app request");
341347
processAmazonAppUri(url);
@@ -399,6 +405,35 @@ private boolean isAuthAppMFAUrl(@NonNull final String url) {
399405
return url.startsWith(AuthenticationConstants.Broker.AUTHENTICATOR_MFA_LINKING_PREFIX);
400406
}
401407

408+
/**
409+
* Checks if the URL is an Authenticator app activation Android App Link.
410+
* These are HTTPS URLs from trusted AAD hosts with the activation path,
411+
* e.g., https://login.microsoftonline.com/authenticatorApp/activateAccount?...
412+
*
413+
* Unlike Chrome, WebView does not resolve Android App Links automatically.
414+
* We must intercept them and dispatch via Intent.ACTION_VIEW.
415+
*
416+
* @param url The lowercased URL to check.
417+
* @return true if the URL is an Authenticator activation app link.
418+
*/
419+
boolean isAuthenticatorActivationAppLink(@NonNull final String url) {
420+
if (!isUriSSLProtected(url)) {
421+
return false;
422+
}
423+
final Uri uri = Uri.parse(url);
424+
final String host = uri.getHost();
425+
final String path = uri.getPath();
426+
if (host == null || path == null) {
427+
return false;
428+
}
429+
final boolean isTrustedHost = host.equalsIgnoreCase(PUBLIC_CLOUD_HOST)
430+
|| host.equalsIgnoreCase(US_GOV_CLOUD_HOST)
431+
|| host.equalsIgnoreCase(CHINA_CLOUD_LEGACY_HOST);
432+
final boolean isActivationPath = path.equalsIgnoreCase(
433+
AuthenticationConstants.Broker.AUTHENTICATOR_APP_LINK_ACTIVATION_PATH);
434+
return isTrustedHost && isActivationPath;
435+
}
436+
402437
private boolean isPlayStoreUrl(@NonNull final String url) {
403438
return url.startsWith(PLAY_STORE_INSTALL_PREFIX);
404439
}
@@ -895,6 +930,37 @@ private void processAuthAppMFAUrl(String url) {
895930
}
896931
}
897932

933+
/**
934+
* Handles Authenticator activation Android App Links.
935+
* WebView does not resolve Android App Links automatically (unlike Chrome).
936+
* This method intercepts the HTTPS activation URL and dispatches it via
937+
* Intent.ACTION_VIEW so the system can route it to the Authenticator app.
938+
*
939+
* @param view The WebView that intercepted the navigation.
940+
* @param url The original (non-lowercased) activation URL.
941+
*/
942+
void processAuthenticatorActivationAppLink(@NonNull final WebView view,
943+
@NonNull final String url) {
944+
final String methodTag = TAG + ":processAuthenticatorActivationAppLink";
945+
view.stopLoading();
946+
try {
947+
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
948+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
949+
if (intent.resolveActivity(getActivity().getPackageManager()) != null) {
950+
getActivity().startActivity(intent);
951+
Logger.info(methodTag, "Launched Authenticator via activation app link.");
952+
} else {
953+
Logger.warn(methodTag, "No application found to handle activation app link. Opening in browser.");
954+
// Browser automatically redirects user to playstore.
955+
openLinkInBrowser(url);
956+
}
957+
} catch (final ActivityNotFoundException e) {
958+
Logger.error(methodTag, "Failed to launch Authenticator via activation app link.", e);
959+
// Browser automatically redirects user to playstore.
960+
openLinkInBrowser(url);
961+
}
962+
}
963+
898964
private void launchCompanyPortal() {
899965
final String methodTag = TAG + ":launchCompanyPortal";
900966

common/src/test/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClientTest.java

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
import android.app.Activity;
4040
import android.content.ActivityNotFoundException;
4141
import android.content.Context;
42+
import android.content.Intent;
43+
import android.content.pm.ActivityInfo;
44+
import android.content.pm.ResolveInfo;
45+
import android.net.Uri;
4246
import android.net.http.SslError;
4347
import android.webkit.SslErrorHandler;
4448
import android.webkit.WebResourceError;
@@ -74,8 +78,9 @@
7478
import org.mockito.Mockito;
7579
import org.robolectric.Robolectric;
7680
import org.robolectric.RobolectricTestRunner;
81+
import org.robolectric.Shadows;
7782
import org.robolectric.annotation.Config;
78-
83+
import org.robolectric.shadows.ShadowPackageManager;
7984
import java.util.HashMap;
8085

8186
import io.opentelemetry.api.trace.Span;
@@ -128,6 +133,18 @@ public class AzureActiveDirectoryWebViewClientTest {
128133
private static final String TEST_PLAYSTORE_REDIRECT_WITH_BROWSER_PROTOCOL = "browser://play.app.goo.gl/?link=https://play.google.com/store/apps/details?id=com.microsoft.windowsintune.companyportal";
129134
private static final String TEST_OPENID_VC_URL = "openid-vc://credential-offer?credential_issuer=https%3A%2F%2Fexample.com&credential_configuration_ids=VerifiedEmployee";
130135

136+
// Authenticator activation app link test URLs
137+
private static final String TEST_AUTHENTICATOR_ACTIVATION_GLOBAL =
138+
"https://login.microsoftonline.com/authenticatorApp/activateAccount?accountType=mfa&source=qrCode&accountType=msa&code=demo&uaid=0022d4c4141444b484dd38026d312794&expires=3971458484";
139+
private static final String TEST_AUTHENTICATOR_ACTIVATION_CHINA =
140+
"https://login.chinacloudapi.cn/authenticatorApp/activateAccount?accountType=mfa&source=qrCode&accountType=msa&code=demo&uaid=0022d4c4141444b484dd38026d312794&expires=3971458484";
141+
private static final String TEST_AUTHENTICATOR_ACTIVATION_US_GOV =
142+
"https://login.microsoftonline.us/authenticatorApp/activateAccount?accountType=mfa&source=qrCode&accountType=msa&code=demo&uaid=0022d4c4141444b484dd38026d312794&expires=3971458484";
143+
private static final String TEST_AUTHENTICATOR_ACTIVATION_INVALID_HOST =
144+
"https://login.evil.com/authenticatorApp/activateAccount?accountType=mfa&code=123";
145+
private static final String TEST_AUTHENTICATOR_ACTIVATION_INVALID_PATH =
146+
"https://login.microsoftonline.com/some/other/path?accountType=mfa&code=123";
147+
131148
@Before
132149
public void setup() throws ClientException {
133150
mContext = ApplicationProvider.getApplicationContext();
@@ -922,4 +939,160 @@ public void testUrlTracker_onReceivedHttpError_subResource_doesNotCallUpdateLate
922939

923940
Mockito.verify(mockTracker, never()).updateLatestUrlStatus(any(), any());
924941
}
942+
943+
// ===== Authenticator activation app link tests =====
944+
945+
@Test
946+
public void testIsAuthenticatorActivationAppLink_globalHost_shouldReturnTrue() {
947+
assertTrue(mWebViewClient.isAuthenticatorActivationAppLink(
948+
TEST_AUTHENTICATOR_ACTIVATION_GLOBAL.toLowerCase()));
949+
}
950+
951+
@Test
952+
public void testIsAuthenticatorActivationAppLink_chinaHost_shouldReturnTrue() {
953+
assertTrue(mWebViewClient.isAuthenticatorActivationAppLink(
954+
TEST_AUTHENTICATOR_ACTIVATION_CHINA.toLowerCase()));
955+
}
956+
957+
@Test
958+
public void testIsAuthenticatorActivationAppLink_usGovHost_shouldReturnTrue() {
959+
assertTrue(mWebViewClient.isAuthenticatorActivationAppLink(
960+
TEST_AUTHENTICATOR_ACTIVATION_US_GOV.toLowerCase()));
961+
}
962+
963+
@Test
964+
public void testIsAuthenticatorActivationAppLink_invalidHost_shouldReturnFalse() {
965+
assertFalse(mWebViewClient.isAuthenticatorActivationAppLink(
966+
TEST_AUTHENTICATOR_ACTIVATION_INVALID_HOST.toLowerCase()));
967+
}
968+
969+
@Test
970+
public void testIsAuthenticatorActivationAppLink_invalidPath_shouldReturnFalse() {
971+
assertFalse(mWebViewClient.isAuthenticatorActivationAppLink(
972+
TEST_AUTHENTICATOR_ACTIVATION_INVALID_PATH.toLowerCase()));
973+
}
974+
975+
@Test
976+
public void testIsAuthenticatorActivationAppLink_nonHttpsScheme_shouldReturnFalse() {
977+
assertFalse(mWebViewClient.isAuthenticatorActivationAppLink(
978+
"http://login.microsoftonline.com/authenticatorapp/activateaccount"));
979+
}
980+
981+
@Test
982+
public void testIsAuthenticatorActivationAppLink_legacyMfaScheme_shouldReturnFalse() {
983+
assertFalse(mWebViewClient.isAuthenticatorActivationAppLink(
984+
AUTHENTICATOR_MFA_LINKING_PREFIX.toLowerCase()));
985+
}
986+
987+
/**
988+
* Registers a fake handler on the given activity's PackageManager so that
989+
* {@code intent.resolveActivity(...)} returns non-null for ACTION_VIEW + the given Uri.
990+
* This lets us exercise the "handler present" branch of
991+
* processAuthenticatorActivationAppLink in a Robolectric test.
992+
*/
993+
private void registerActivationHandler(@NonNull final Activity activity,
994+
@NonNull final Uri uri,
995+
@NonNull final String packageName,
996+
@NonNull final String activityClass) {
997+
final ShadowPackageManager shadowPm = Shadows.shadowOf(activity.getPackageManager());
998+
final Intent intent = new Intent(Intent.ACTION_VIEW, uri);
999+
final ResolveInfo info = new ResolveInfo();
1000+
info.activityInfo = new ActivityInfo();
1001+
info.activityInfo.packageName = packageName;
1002+
info.activityInfo.name = activityClass;
1003+
shadowPm.addResolveInfoForIntent(intent, info);
1004+
}
1005+
1006+
@Test
1007+
public void testAuthenticatorActivationAppLink_launchesIntent_whenHandlerPresent() {
1008+
// Arrange: register a handler so resolveActivity(...) returns non-null.
1009+
registerActivationHandler(
1010+
mActivity,
1011+
Uri.parse(TEST_AUTHENTICATOR_ACTIVATION_GLOBAL),
1012+
"com.azure.authenticator",
1013+
"com.azure.authenticator.ui.MainActivity");
1014+
1015+
final WebView mockWebView = Mockito.mock(WebView.class);
1016+
1017+
// Act
1018+
final boolean handled = mWebViewClient.shouldOverrideUrlLoading(
1019+
mockWebView, TEST_AUTHENTICATOR_ACTIVATION_GLOBAL);
1020+
1021+
// Assert: URL was intercepted, WebView stopped, and an ACTION_VIEW intent was launched.
1022+
assertTrue(handled);
1023+
Mockito.verify(mockWebView).stopLoading();
1024+
1025+
final Intent launched = Shadows.shadowOf(mActivity).getNextStartedActivity();
1026+
Assert.assertNotNull("Expected an intent to be launched for the activation link", launched);
1027+
assertEquals(Intent.ACTION_VIEW, launched.getAction());
1028+
assertEquals(TEST_AUTHENTICATOR_ACTIVATION_GLOBAL, launched.getData().toString());
1029+
assertTrue("Expected FLAG_ACTIVITY_NEW_TASK on activation intent",
1030+
(launched.getFlags() & Intent.FLAG_ACTIVITY_NEW_TASK) != 0);
1031+
}
1032+
1033+
@Test
1034+
public void testAuthenticatorActivationAppLink_noHandler_stopsLoading_andDoesNotCrash() {
1035+
// Arrange: no handler registered -> intent.resolveActivity() returns null in both
1036+
// the Authenticator launch path and the openLinkInBrowser fallback path.
1037+
final WebView mockWebView = Mockito.mock(WebView.class);
1038+
1039+
// Act
1040+
final boolean handled = mWebViewClient.shouldOverrideUrlLoading(
1041+
mockWebView, TEST_AUTHENTICATOR_ACTIVATION_GLOBAL);
1042+
1043+
// Assert: handled=true, WebView stopped, no activity started (no crash, no error callback).
1044+
assertTrue(handled);
1045+
Mockito.verify(mockWebView).stopLoading();
1046+
Assert.assertNull("Expected NO intent to be launched when no handler is installed",
1047+
Shadows.shadowOf(mActivity).getNextStartedActivity());
1048+
}
1049+
1050+
@Test
1051+
public void testAuthenticatorActivationAppLink_preservesOriginalCasing() {
1052+
// The activation link carries case-sensitive query values (e.g. base64 codes).
1053+
// shouldOverrideUrlLoading lowercases the URL for matching but must dispatch the
1054+
// *original* URL to the Authenticator.
1055+
final String mixedCaseUrl =
1056+
"https://login.microsoftonline.com/authenticatorApp/activateAccount"
1057+
+ "?accountType=mfa&source=QrCode&code=AbCdEf123XYZ&url=https://Service";
1058+
registerActivationHandler(
1059+
mActivity,
1060+
Uri.parse(mixedCaseUrl),
1061+
"com.azure.authenticator",
1062+
"com.azure.authenticator.ui.MainActivity");
1063+
1064+
final WebView mockWebView = Mockito.mock(WebView.class);
1065+
1066+
final boolean handled = mWebViewClient.shouldOverrideUrlLoading(mockWebView, mixedCaseUrl);
1067+
1068+
assertTrue(handled);
1069+
final Intent launched = Shadows.shadowOf(mActivity).getNextStartedActivity();
1070+
Assert.assertNotNull(launched);
1071+
assertEquals("Authenticator activation intent must carry the original-cased URL",
1072+
mixedCaseUrl, launched.getData().toString());
1073+
}
1074+
1075+
@Test
1076+
public void testProcessAuthAppMFAUrl_startsViewIntentWithNewTaskFlag() {
1077+
// microsoft-authenticator://activatemfa/... is handed to processAuthAppMFAUrl,
1078+
// which dispatches an ACTION_VIEW intent with FLAG_ACTIVITY_NEW_TASK.
1079+
final String mfaUrl = AUTHENTICATOR_MFA_LINKING_PREFIX + "/?x=1";
1080+
// Make the intent resolvable so the OS would accept the launch. (Robolectric
1081+
// records the started activity regardless, but this documents the expectation.)
1082+
registerActivationHandler(
1083+
mActivity,
1084+
Uri.parse(mfaUrl),
1085+
"com.azure.authenticator",
1086+
"com.azure.authenticator.ui.MainActivity");
1087+
1088+
final boolean handled = mWebViewClient.shouldOverrideUrlLoading(mMockWebView, mfaUrl);
1089+
1090+
assertTrue(handled);
1091+
final Intent launched = Shadows.shadowOf(mActivity).getNextStartedActivity();
1092+
Assert.assertNotNull(launched);
1093+
assertEquals(Intent.ACTION_VIEW, launched.getAction());
1094+
assertEquals(mfaUrl, launched.getDataString());
1095+
assertTrue("MFA activation intent must carry FLAG_ACTIVITY_NEW_TASK",
1096+
(launched.getFlags() & Intent.FLAG_ACTIVITY_NEW_TASK) != 0);
1097+
}
9251098
}

common4j/src/main/com/microsoft/identity/common/java/providers/microsoft/azureactivedirectory/AzureActiveDirectoryCloud.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ void setIsValidated(final boolean isValidated) {
121121
public static final String PUBLIC_CLOUD_HOST = "login.microsoftonline.com";
122122
public static final String PPE_CLOUD_HOST = "login.windows-ppe.net";
123123
public static final String CHINA_CLOUD_HOST = "login.partner.microsoftonline.cn";
124+
// Legacy host for the 21Vianet cloud identity environment.
125+
public static final String CHINA_CLOUD_LEGACY_HOST = "login.chinacloudapi.cn";
124126
public static final String US_GOV_CLOUD_HOST = "login.microsoftonline.us";
125127
public static final String BLEU_CLOUD_HOST = "login.sovcloud-identity.fr";
126128
public static final String DELOS_CLOUD_HOST = "login.sovcloud-identity.de";

0 commit comments

Comments
 (0)