Skip to content

Commit e404a43

Browse files
mohitc1Mohit
andauthored
Add enableSwitchBrowser opt-in to AuthorizationActivityFactory, Fixes AB#3627515 (#3137)
## Summary Add enableSwitchBrowser param to AuthorizationActivityParameters and AuthorizationActivityFactory so consumers (OneAuth) can opt-in to the Switch Browser protocol. When enabled, the factory checks browser availability (Chrome/Edge/AEA), if app has correctly configured SwitchBrowserActivity its manifest and appends switch_browser=1 to the authorization request URL. ## Changes - AuthorizationActivityParameters.kt: Added enableSwitchBrowser: Boolean = false - AuthorizationActivityFactory.kt: Added static TAG, appendSwitchBrowserParam() method - AuthorizationActivityFactoryTest.java: Added 2 unit tests - changelog.txt: Added entry - SwitchBrowserUtils which contains methods to check for browser and manifest check ## Fixes [AB#3627515](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3627515) --------- Co-authored-by: Mohit <mchand@microsoft.com>
1 parent eb36ada commit e404a43

6 files changed

Lines changed: 237 additions & 2 deletions

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+
- [MINOR] Add enableSwitchBrowser opt-in param to AuthorizationActivityFactory/Parameters so consumers (OneAuth) can signal switch_browser=1 when a compatible browser is available (#3137)
34
- [PATCH] Remove stale code, eliminate flickers due to BrokerAuthorizationActivity (#3139)
45

56
Version 24.3.0

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ public static final class SWITCH_BROWSER {
548548
/**
549549
* String Query parameter key to indicate support for SWITCH_BROWSER protocol.
550550
*/
551-
public static final String CLIENT_SUPPORTS_FLOW = "switch_browser";
551+
public static final String SWITCH_BROWSER_EXTRA_QUERY_PARAM = "switch_browser";
552552

553553
/**
554554
* String Query parameter key for the purpose token.

common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityFactory.kt

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ import android.os.Bundle
2727
import androidx.fragment.app.Fragment
2828
import com.microsoft.identity.common.adal.internal.AuthenticationConstants
2929
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.AuthorizationIntentKey.OTEL_CONTEXT_CARRIER
30+
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER
3031
import com.microsoft.identity.common.internal.msafederation.getIdProviderExtraQueryParamForAuthorization
3132
import com.microsoft.identity.common.internal.msafederation.getIdProviderHeadersForAuthorization
3233
import com.microsoft.identity.common.internal.msafederation.google.SignInWithGoogleApi.Companion.getInstance
3334
import com.microsoft.identity.common.internal.msafederation.google.SignInWithGoogleCredential
3435
import com.microsoft.identity.common.internal.msafederation.google.SignInWithGoogleParameters
36+
import com.microsoft.identity.common.internal.ui.webview.switchbrowser.SwitchBrowserUtil
3537
import com.microsoft.identity.common.internal.util.CommonMoshiJsonAdapter
3638
import com.microsoft.identity.common.internal.util.ProcessUtil
3739
import com.microsoft.identity.common.java.AuthenticationConstants.OAuth2.UTID
@@ -46,6 +48,7 @@ import com.microsoft.identity.common.java.opentelemetry.SpanExtension
4648
import com.microsoft.identity.common.java.opentelemetry.TextMapPropagatorExtension
4749
import com.microsoft.identity.common.java.ui.AuthorizationAgent
4850
import com.microsoft.identity.common.java.util.CommonURIBuilder
51+
import com.microsoft.identity.common.logging.Logger
4952
import java.net.URISyntaxException
5053

5154

@@ -54,6 +57,8 @@ import java.net.URISyntaxException
5457
*/
5558
object AuthorizationActivityFactory {
5659

60+
private val TAG: String = AuthorizationActivityFactory::class.java.simpleName
61+
5762
/**
5863
* Return the correct authorization activity based on library configuration.
5964
*
@@ -82,14 +87,21 @@ object AuthorizationActivityFactory {
8287
intent = Intent(parameters.context, AuthorizationActivity::class.java)
8388
}
8489

90+
// If switch browser is enabled, check browser availability and append switch_browser=1
91+
val effectiveRequestUrl = if (parameters.enableSwitchBrowser) {
92+
appendSwitchBrowserParam(parameters.context, parameters.requestUrl, parameters.redirectUri)
93+
} else {
94+
parameters.requestUrl
95+
}
96+
8597
intent.apply {
8698
putExtra(
8799
AuthenticationConstants.AuthorizationIntentKey.AUTH_INTENT,
88100
parameters.authIntent
89101
)
90102
putExtra(
91103
AuthenticationConstants.AuthorizationIntentKey.REQUEST_URL,
92-
parameters.requestUrl
104+
effectiveRequestUrl
93105
)
94106
putExtra(
95107
AuthenticationConstants.AuthorizationIntentKey.REDIRECT_URI,
@@ -276,4 +288,32 @@ object AuthorizationActivityFactory {
276288
}
277289
return fragment
278290
}
291+
292+
/**
293+
* If Switch Browser is supported (compatible browser + manifest entry), appends
294+
* `switch_browser=1` to the request URL. Otherwise returns the URL unchanged.
295+
*
296+
* @param context Android context.
297+
* @param requestUrl The original authorization request URL.
298+
* @param redirectUri The app's redirect URI.
299+
* @return The (possibly modified) request URL.
300+
*/
301+
private fun appendSwitchBrowserParam(
302+
context: android.content.Context,
303+
requestUrl: String,
304+
redirectUri: String
305+
): String {
306+
val methodTag = "$TAG:appendSwitchBrowserParam"
307+
if (!SwitchBrowserUtil.isSwitchBrowserSupported(context, redirectUri)) {
308+
return requestUrl
309+
}
310+
return try {
311+
val uriBuilder = CommonURIBuilder(requestUrl)
312+
uriBuilder.addParameterIfAbsent(SWITCH_BROWSER.SWITCH_BROWSER_EXTRA_QUERY_PARAM, "1")
313+
uriBuilder.build().toString()
314+
} catch (e: Exception) {
315+
Logger.warn(methodTag, "Failed to append switch_browser param: ${e.message}")
316+
requestUrl
317+
}
318+
}
279319
}

common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityParameters.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import com.microsoft.identity.common.java.ui.AuthorizationAgent
4242
* @param utid The tenant unique id, if applicable
4343
* @param webViewEnableSilentAuthorizationFlowTimeOutMs If set to a non-null value, this indicates that the flow is silent and specifies the timeout for the silent authorization flow in milliseconds.
4444
* @param isWebViewWebCpEnabled This parameter controls whether webcp URLs should be handled within the WebView or redirected to external browser
45+
* @param enableSwitchBrowser When true, the factory will check for browser availability and append switch_browser=1 to the request URL to opt-in to the Switch Browser protocol
4546
*/
4647
data class AuthorizationActivityParameters @JvmOverloads constructor(
4748
val context: Context,
@@ -60,4 +61,5 @@ data class AuthorizationActivityParameters @JvmOverloads constructor(
6061
val utid: String? = null,
6162
val webViewEnableSilentAuthorizationFlowTimeOutMs: Long? = null,
6263
val isWebViewWebCpEnabled: Boolean = false,
64+
val enableSwitchBrowser: Boolean = false,
6365
)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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.common.internal.ui.webview.switchbrowser
24+
25+
import android.content.Context
26+
import android.content.Intent
27+
import android.content.pm.PackageManager
28+
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER
29+
import com.microsoft.identity.common.internal.ui.browser.AndroidBrowserSelector
30+
import com.microsoft.identity.common.java.ui.BrowserDescriptor
31+
import com.microsoft.identity.common.logging.Logger
32+
import androidx.core.net.toUri
33+
34+
/**
35+
* Utility class for Switch Browser protocol
36+
*/
37+
object SwitchBrowserUtil {
38+
39+
private const val TAG = "SwitchBrowserUtils"
40+
41+
/**
42+
* Determines whether the Switch Browser protocol can be used for the current request.
43+
* Both conditions must be met:
44+
* 1. A compatible browser is installed on the device.
45+
* 2. The app manifest declares a handler for the switch_browser_resume redirect.
46+
*
47+
* @param context Android context for browser and manifest resolution.
48+
* @param redirectUri The app's redirect URI.
49+
* @return true if switch browser is supported, false otherwise.
50+
*/
51+
@JvmStatic
52+
fun isSwitchBrowserSupported(context: Context, redirectUri: String): Boolean {
53+
val methodTag = "$TAG:isSwitchBrowserSupported"
54+
try {
55+
if (!isCompatibleBrowserInstalled(context)) {
56+
Logger.info(methodTag, "No compatible browser found for Switch Browser protocol.")
57+
return false
58+
}
59+
60+
if (!isSwitchBrowserResumeHandlerRegistered(context, redirectUri)) {
61+
Logger.warn(
62+
methodTag,
63+
"SwitchBrowserRedirectActivity not registered in manifest for redirect URI. " +
64+
"Switch Browser will not be enabled."
65+
)
66+
return false
67+
}
68+
return true
69+
} catch (e: Exception) {
70+
Logger.warn(methodTag, "Failed to check Switch Browser prerequisites: ${e.message}")
71+
}
72+
return false
73+
}
74+
75+
/**
76+
* Checks whether a compatible browser (Chrome, Edge, or AEA) is installed on the device.
77+
*
78+
* @param context Android context for browser resolution.
79+
* @return true if a compatible browser is installed, false otherwise.
80+
*/
81+
@JvmStatic
82+
fun isCompatibleBrowserInstalled(context: Context): Boolean {
83+
val browserSelector = AndroidBrowserSelector(context)
84+
val browser = browserSelector.selectBrowser(
85+
BrowserDescriptor.getBrowserSafeListForSwitchBrowser(),
86+
null
87+
)
88+
return browser != null
89+
}
90+
91+
/**
92+
* Checks whether the app's manifest declares [SwitchBrowserRedirectActivity] with an
93+
* intent-filter that can handle the switch_browser_resume redirect URI. This
94+
* validates both that the activity is declared and that it can handle the resume URI.
95+
* Note: This check is for non-broker flows only. Broker uses its own subclass via a
96+
* different code path.
97+
*
98+
* @param context Android context for PackageManager access.
99+
* @param redirectUri The app's redirect URI.
100+
* @return true if a valid handler is registered, false otherwise.
101+
*/
102+
@JvmStatic
103+
fun isSwitchBrowserResumeHandlerRegistered(
104+
context: Context,
105+
redirectUri: String
106+
): Boolean {
107+
val resumeUri = (redirectUri.trimEnd('/') + "/" + SWITCH_BROWSER.RESUME_PATH).toUri()
108+
val intent = Intent(Intent.ACTION_VIEW, resumeUri).apply {
109+
addCategory(Intent.CATEGORY_DEFAULT)
110+
addCategory(Intent.CATEGORY_BROWSABLE)
111+
setPackage(context.packageName)
112+
}
113+
val resolvedActivities = context.packageManager.queryIntentActivities(
114+
intent, PackageManager.MATCH_DEFAULT_ONLY
115+
)
116+
return resolvedActivities.isNotEmpty()
117+
}
118+
}

common/src/test/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityFactoryTest.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,27 +35,37 @@
3535
import static org.junit.Assert.assertEquals;
3636
import static org.junit.Assert.assertFalse;
3737
import static org.junit.Assert.assertNotNull;
38+
import static org.junit.Assert.assertTrue;
3839
import static org.mockito.ArgumentMatchers.any;
3940
import static org.mockito.Mockito.mock;
41+
import static org.mockito.Mockito.mockConstruction;
4042
import static org.mockito.Mockito.when;
4143

4244
import android.app.Activity;
4345
import android.content.Context;
4446
import android.content.Intent;
47+
import android.content.pm.ActivityInfo;
48+
import android.content.pm.ResolveInfo;
4549

4650
import androidx.fragment.app.Fragment;
4751

4852
import com.microsoft.identity.common.internal.msafederation.google.SignInWithGoogleApi;
4953
import com.microsoft.identity.common.internal.msafederation.google.SignInWithGoogleCredential;
5054
import com.microsoft.identity.common.internal.msafederation.google.SignInWithGoogleParameters;
55+
import com.microsoft.identity.common.internal.ui.browser.AndroidBrowserSelector;
56+
import com.microsoft.identity.common.java.browser.Browser;
5157
import com.microsoft.identity.common.java.ui.AuthorizationAgent;
5258

5359
import org.junit.Test;
5460
import org.junit.runner.RunWith;
61+
import org.mockito.MockedConstruction;
5562
import org.robolectric.Robolectric;
5663
import org.robolectric.RobolectricTestRunner;
5764
import org.robolectric.RuntimeEnvironment;
65+
import org.robolectric.Shadows;
66+
import org.robolectric.shadows.ShadowPackageManager;
5867

68+
import java.util.Collections;
5969
import java.util.HashMap;
6070

6171
import lombok.SneakyThrows;
@@ -256,4 +266,68 @@ public void testGetAuthorizationFragmentFromStartIntentWithSilentFlowNonWebView(
256266
// Verify it creates BrowserAuthorizationFragment even with silent flow when not WebView
257267
assertEquals(BrowserAuthorizationFragment.class, fragment.getClass());
258268
}
269+
270+
@Test
271+
public void testSwitchBrowserEnabled_noBrowser_urlUnchanged() {
272+
// No browsers installed in Robolectric → selectBrowser returns null → URL unchanged
273+
final AuthorizationActivityParameters params = new AuthorizationActivityParameters(
274+
context, authIntent, requestUrl, redirectUri, requestHeaders,
275+
authorizationAgent, webViewZoomEnabled, webViewZoomControlsEnabled,
276+
sourceLibraryName, sourceLibraryVersion, null, null, false, true
277+
);
278+
279+
final Intent resultIntent = AuthorizationActivityFactory.getAuthorizationActivityIntent(params);
280+
assertEquals(requestUrl, resultIntent.getStringExtra(REQUEST_URL));
281+
assertFalse(resultIntent.getStringExtra(REQUEST_URL).contains("switch_browser"));
282+
}
283+
284+
@Test
285+
public void testSwitchBrowserEnabled_browserAvailable_appendsParam() {
286+
// Register a handler for the switch_browser_resume redirect in the shadow PackageManager
287+
final ShadowPackageManager shadowPm = Shadows.shadowOf(context.getPackageManager());
288+
final Intent resumeIntent = new Intent(Intent.ACTION_VIEW,
289+
android.net.Uri.parse("msauth://example.com/redirect/switch_browser_resume"));
290+
resumeIntent.addCategory(Intent.CATEGORY_DEFAULT);
291+
resumeIntent.addCategory(Intent.CATEGORY_BROWSABLE);
292+
resumeIntent.setPackage(context.getPackageName());
293+
final ResolveInfo resolveInfo = new ResolveInfo();
294+
resolveInfo.activityInfo = new ActivityInfo();
295+
resolveInfo.activityInfo.packageName = context.getPackageName();
296+
resolveInfo.activityInfo.name = "com.microsoft.identity.common.internal.providers.oauth2.SwitchBrowserRedirectActivity";
297+
shadowPm.addResolveInfoForIntent(resumeIntent, resolveInfo);
298+
299+
final Browser mockBrowser = new Browser("com.android.chrome", Collections.singleton("hash"), "100", true);
300+
try (MockedConstruction<AndroidBrowserSelector> ignored = mockConstruction(
301+
AndroidBrowserSelector.class,
302+
(mock, ctx) -> when(mock.selectBrowser(any(), any())).thenReturn(mockBrowser)
303+
)) {
304+
final AuthorizationActivityParameters params = new AuthorizationActivityParameters(
305+
context, authIntent, requestUrl, redirectUri, requestHeaders,
306+
authorizationAgent, webViewZoomEnabled, webViewZoomControlsEnabled,
307+
sourceLibraryName, sourceLibraryVersion, null, null, false, true
308+
);
309+
310+
final Intent resultIntent = AuthorizationActivityFactory.getAuthorizationActivityIntent(params);
311+
assertTrue(resultIntent.getStringExtra(REQUEST_URL).contains("switch_browser=1"));
312+
}
313+
}
314+
315+
@Test
316+
public void testSwitchBrowserEnabled_browserAvailable_noManifestEntry_urlUnchanged() {
317+
// Browser available but NO manifest handler registered → URL unchanged
318+
final Browser mockBrowser = new Browser("com.android.chrome", Collections.singleton("hash"), "100", true);
319+
try (MockedConstruction<AndroidBrowserSelector> ignored = mockConstruction(
320+
AndroidBrowserSelector.class,
321+
(mock, ctx) -> when(mock.selectBrowser(any(), any())).thenReturn(mockBrowser)
322+
)) {
323+
final AuthorizationActivityParameters params = new AuthorizationActivityParameters(
324+
context, authIntent, requestUrl, redirectUri, requestHeaders,
325+
authorizationAgent, webViewZoomEnabled, webViewZoomControlsEnabled,
326+
sourceLibraryName, sourceLibraryVersion, null, null, false, true
327+
);
328+
329+
final Intent resultIntent = AuthorizationActivityFactory.getAuthorizationActivityIntent(params);
330+
assertFalse(resultIntent.getStringExtra(REQUEST_URL).contains("switch_browser"));
331+
}
332+
}
259333
}

0 commit comments

Comments
 (0)