Skip to content

Commit 3efa7f5

Browse files
mohitc1Mohit
andauthored
Add SwitchBrowserRedirectActivity for non-broker Switch Browser flows, Fixes AB#3590615 (#3133)
Introduces an open SwitchBrowserRedirectActivity in Common that handles browser redirects for the Switch Browser protocol. This activity catches the /switch_browser_resume redirect and forwards it to SwitchBrowserActivity. BrokerBrowserRedirectActivity now extends this class (updated in broker). Apps opting into Switch Browser override exported=true and add intent-filters. Fixes [AB#3590615](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3590615) --------- Co-authored-by: Mohit <mchand@microsoft.com>
1 parent a96c144 commit 3efa7f5

7 files changed

Lines changed: 298 additions & 14 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 SwitchBrowserRedirectActivity for non-broker Switch Browser protocol flows. BrokerBrowserRedirectActivity now extends this shared base class (#3133)
34
- [PATCH] Fix Token Endpoint Server Telemetry Parsing (#3128)
45
- [MINOR] Enable passkey registration by default in CommonFlight (ENABLE_PASSKEY_REGISTRATION) (#3132)
56
- [PATCH] Emit ipc_strategy telemetry attribute for successful device registration IPC strategy and refactor execute flow to pack protocol request once before strategy retries (#3124)

common/src/main/AndroidManifest.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@
3131
android:theme="@style/Theme.AppCompat.Light"
3232
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|keyboard" />
3333

34+
<!-- Activity that handles browser-switch operations.
35+
Launches the external browser and receives the result from SwitchBrowserRedirectActivity. -->
36+
<activity
37+
android:name="com.microsoft.identity.common.internal.providers.oauth2.SwitchBrowserActivity"
38+
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|keyboard"
39+
android:launchMode="singleTask"
40+
android:taskAffinity="" />
41+
42+
<!-- Activity to handle browser redirects for Switch Browser protocol in non-broker flows.
43+
Apps that opt into Switch Browser must override this in their manifest with
44+
android:exported="true" and the appropriate intent-filter.
45+
taskAffinity="" ensures this activity always creates its own task when launched
46+
from the browser (via FLAG_ACTIVITY_NEW_TASK), preventing it from landing in
47+
AuthorizationActivity's task where finishAffinity() would kill the WebView. -->
48+
<activity
49+
android:name="com.microsoft.identity.common.internal.providers.oauth2.SwitchBrowserRedirectActivity"
50+
android:launchMode="singleTask"
51+
android:taskAffinity=""
52+
android:exported="false" />
53+
3454
</application>
3555

3656
<!-- Required for API Level 30 to make sure we can detect browsers and other apps we want to

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,16 @@ import io.opentelemetry.api.trace.StatusCode
5151
* 2. This activity is launched with browser configuration parameters
5252
* 3. Activity launches the specified browser (Custom Tabs or standard browser)
5353
* 4. User completes authentication in the external browser
54-
* 5. BrokerBrowserRedirectActivity is launched when the redirect URI is triggered.
55-
* 5. BrokerBrowserRedirectActivity redirects back to this activity via onNewIntent()
54+
* 5. SwitchBrowserRedirectActivity (or subclass) is launched when the redirect URI is triggered.
55+
* 5. SwitchBrowserRedirectActivity redirects back to this activity via onNewIntent()
5656
* 6. Activity passes the result back to WebViewAuthorizationFragment
5757
* 7. Activity finishes and removes itself from the task stack
5858
*
5959
* Activity back stack behavior:
60-
* 1 BrokerAuthorizationActivity hosting WebViewAuthorizationFragment --launches--> SwitchBrowserActivity in a new task.
60+
* 1 AuthorizationActivity hosting WebViewAuthorizationFragment --launches--> SwitchBrowserActivity in a new task.
6161
* 2 SwitchBrowserActivity --launches--> 3rd Party Browser (Custom Tabs or standard browser) in current task.
62-
* 3 3rd Party Browser --redirects to--> BrokerBrowserRedirectActivity in a new task.
63-
* 4 BrokerBrowserRedirectActivity -- launches--> SwitchBrowserActivity in the existing task, and finishes current task.
62+
* 3 3rd Party Browser --redirects to--> SwitchBrowserRedirectActivity (or subclass) in a new task.
63+
* 4 SwitchBrowserRedirectActivity -- launches--> SwitchBrowserActivity in the existing task, and finishes current task.
6464
* 5 SwitchBrowserActivity --passes result to--> WebViewAuthorizationFragment, and finishes current activity stack.
6565
*
6666
* **Security Note:** This activity is not exported and can only be launched within the app
@@ -81,8 +81,8 @@ class SwitchBrowserActivity : FragmentActivity() {
8181
/** Intent extra key for the target browser package name */
8282
const val BROWSER_PACKAGE_NAME = "browser_package_name"
8383

84-
/** Intent extra key for the broker redirect URI to use */
85-
const val BROKER_REDIRECT_URI = "broker_redirect_uri"
84+
/** Intent extra key for the redirect URI to use for the switch-browser callback */
85+
const val REDIRECT_URI = "redirect_uri"
8686

8787
/** Intent extra key indicating if the browser supports Custom Tabs */
8888
const val BROWSER_SUPPORTS_CUSTOM_TABS = "browser_supports_custom_tabs"
@@ -139,7 +139,7 @@ class SwitchBrowserActivity : FragmentActivity() {
139139
* boundary.
140140
*
141141
* @param context Application or activity context used to build the intent.
142-
* @param brokerRedirectUri The broker redirect URI used for the switch-browser callback.
142+
* @param redirectUri The redirect URI used for the switch-browser callback.
143143
* @param browserPackageName The package name of the browser to launch.
144144
* @param browserSupportsCustomTabs Whether the target browser supports Custom Tabs.
145145
* @param processUri The URI to open in the browser for authentication.
@@ -149,14 +149,14 @@ class SwitchBrowserActivity : FragmentActivity() {
149149
@JvmStatic
150150
fun buildSwitchBrowserLaunchIntent(
151151
context: Context,
152-
brokerRedirectUri: String,
152+
redirectUri: String,
153153
browserPackageName: String,
154154
browserSupportsCustomTabs: Boolean,
155155
processUri: String,
156156
spanContext: SerializableSpanContext?
157157
): Intent {
158158
return Intent(context, SwitchBrowserActivity::class.java).apply {
159-
putExtra(BROKER_REDIRECT_URI, brokerRedirectUri)
159+
putExtra(REDIRECT_URI, redirectUri)
160160
putExtra(BROWSER_PACKAGE_NAME, browserPackageName)
161161
putExtra(BROWSER_SUPPORTS_CUSTOM_TABS, browserSupportsCustomTabs)
162162
putExtra(PROCESS_URI, processUri)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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.providers.oauth2
24+
25+
import android.app.Activity
26+
import android.content.Intent
27+
import android.os.Bundle
28+
import com.microsoft.identity.common.logging.Logger
29+
30+
/**
31+
* Activity that handles the browser redirect for Switch Browser protocol in non-broker flows.
32+
*
33+
* When the browser completes the Switch Browser challenge, it redirects to
34+
* `<redirect_uri>/switch_browser_resume?code=...&action_uri=...&state=...`.
35+
* This activity catches that redirect via its intent-filter and forwards
36+
* the data to [SwitchBrowserActivity], which resumes the WebView flow.
37+
*
38+
* This activity is declared as `exported="false"` in Common's manifest.
39+
* Apps that opt into Switch Browser must override this in their own manifest
40+
* with `exported="true"` and the appropriate intent-filter for their redirect URI scheme/host.
41+
*
42+
* Subclasses (e.g. BrokerBrowserRedirectActivity) can override [getAuthIntent] to handle
43+
* additional redirect scenarios beyond Switch Browser resume.
44+
*/
45+
open class SwitchBrowserRedirectActivity : Activity() {
46+
47+
companion object {
48+
private val TAG: String = SwitchBrowserRedirectActivity::class.java.simpleName
49+
}
50+
51+
override fun onCreate(savedInstanceState: Bundle?) {
52+
super.onCreate(savedInstanceState)
53+
if (savedInstanceState == null) {
54+
processRedirectIntent(intent)
55+
}
56+
}
57+
58+
override fun onNewIntent(intent: Intent) {
59+
super.onNewIntent(intent)
60+
setIntent(intent)
61+
processRedirectIntent(intent)
62+
}
63+
64+
private fun processRedirectIntent(intent: Intent?) {
65+
val methodTag = "$TAG:processRedirectIntent"
66+
Logger.info(methodTag, "Received redirect from browser.")
67+
intent?.dataString?.let { intentDataString ->
68+
getAuthIntent(intentDataString)?.let { authIntent ->
69+
startActivity(authIntent)
70+
}
71+
} ?: run {
72+
Logger.warn(methodTag, "No data in redirect intent.")
73+
}
74+
finishAffinity()
75+
}
76+
77+
/**
78+
* Build the intent to handle the redirect data.
79+
* Default implementation handles Switch Browser resume.
80+
* Subclasses can override to handle additional redirect types.
81+
*
82+
* @param intentDataString The URI data from the browser redirect.
83+
* @return The intent to start, or null if the data cannot be handled.
84+
*/
85+
protected open fun getAuthIntent(intentDataString: String): Intent? {
86+
val methodTag = "$TAG:getAuthIntent"
87+
Logger.info(methodTag, "Switching to WebView via SwitchBrowserActivity.")
88+
return SwitchBrowserActivity.buildSwitchBrowserResumeIntent(
89+
applicationContext, intentDataString
90+
)
91+
}
92+
}

common/src/main/java/com/microsoft/identity/common/internal/ui/webview/challengehandlers/SwitchBrowserRequestHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class SwitchBrowserRequestHandler(
112112
)
113113
val switchBrowserIntent = SwitchBrowserActivity.buildSwitchBrowserLaunchIntent(
114114
context = activity,
115-
brokerRedirectUri = switchBrowserChallenge.redirectUri,
115+
redirectUri = switchBrowserChallenge.redirectUri,
116116
browserPackageName = browser.packageName,
117117
browserSupportsCustomTabs = browser.isCustomTabsServiceSupported,
118118
processUri = switchBrowserChallenge.processUri.toString(),

common/src/main/java/com/microsoft/identity/common/internal/ui/webview/switchbrowser/SwitchBrowserUriHelper.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,13 @@ object SwitchBrowserUriHelper {
104104
* @param uri The uri containing the switch browser code and action URL.
105105
* e.g. msauth://com.microsoft.identity.client/switch_browser?code=code&action_uri=action-uri
106106
*
107-
* @return The process uri constructed from the broker redirect uri.
107+
* @return The process uri constructed from the redirect uri.
108108
* e.g. action_uri?code=code
109109
*/
110110
@Throws(ClientException::class, IllegalArgumentException::class, NullPointerException::class, UnsupportedOperationException::class)
111111
fun buildProcessUri(uri: Uri): Uri {
112112
val methodTag = "$TAG:buildProcessUri"
113-
// Get the SwitchBrowser purpose token from the broker redirect uri.
113+
// Get the SwitchBrowser purpose token from the redirect uri.
114114
val code = uri.getQueryParameter(
115115
SWITCH_BROWSER.CODE
116116
)
@@ -121,7 +121,7 @@ object SwitchBrowserUriHelper {
121121
Logger.error(methodTag, errorMessage, exception)
122122
throw exception
123123
}
124-
// Get the process uri from the broker redirect uri.
124+
// Get the process uri from the redirect uri.
125125
val actionUri = uri.getQueryParameter(
126126
SWITCH_BROWSER.ACTION_URI
127127
)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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.providers.oauth2
24+
25+
import android.content.Intent
26+
import android.net.Uri
27+
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER
28+
import org.junit.Assert.assertEquals
29+
import org.junit.Assert.assertNotNull
30+
import org.junit.Assert.assertTrue
31+
import org.junit.Test
32+
import org.junit.runner.RunWith
33+
import org.robolectric.Robolectric
34+
import org.robolectric.RobolectricTestRunner
35+
import org.robolectric.Shadows.shadowOf
36+
37+
/**
38+
* Tests for [SwitchBrowserRedirectActivity].
39+
*/
40+
@RunWith(RobolectricTestRunner::class)
41+
class SwitchBrowserRedirectActivityTest {
42+
43+
companion object {
44+
private const val TEST_ACTION_URI = "https://login.microsoftonline.com/process"
45+
private const val TEST_CODE = "test-code-123"
46+
private const val TEST_STATE = "test-state-456"
47+
private const val REDIRECT_URI = "msauth://com.test.app/signature"
48+
}
49+
50+
@Test
51+
fun onCreate_startsActivityWithCorrectExtras_whenValidSwitchBrowserResumeUri() {
52+
val intent = buildRedirectIntent(TEST_CODE, TEST_ACTION_URI, TEST_STATE)
53+
54+
val controller = Robolectric.buildActivity(
55+
SwitchBrowserRedirectActivity::class.java, intent
56+
).create()
57+
val activity = controller.get()
58+
59+
val shadow = shadowOf(activity)
60+
val startedIntent = shadow.nextStartedActivity
61+
62+
assertNotNull("Should start SwitchBrowserActivity", startedIntent)
63+
assertEquals(TEST_ACTION_URI, startedIntent.getStringExtra(SWITCH_BROWSER.ACTION_URI))
64+
assertEquals(TEST_CODE, startedIntent.getStringExtra(SWITCH_BROWSER.CODE))
65+
assertEquals(TEST_STATE, startedIntent.getStringExtra(SWITCH_BROWSER.STATE))
66+
assertTrue(activity.isFinishing)
67+
}
68+
69+
@Test
70+
fun onCreate_finishes_whenIntentHasNoData() {
71+
val intent = Intent()
72+
73+
val controller = Robolectric.buildActivity(
74+
SwitchBrowserRedirectActivity::class.java, intent
75+
).create()
76+
val activity = controller.get()
77+
78+
val shadow = shadowOf(activity)
79+
val startedIntent = shadow.nextStartedActivity
80+
81+
// No activity should be started when there's no data
82+
assertEquals(null, startedIntent)
83+
assertTrue(activity.isFinishing)
84+
}
85+
86+
@Test
87+
fun onNewIntent_startsActivityWithNewData() {
88+
val initialIntent = buildRedirectIntent(TEST_CODE, TEST_ACTION_URI, TEST_STATE)
89+
90+
val controller = Robolectric.buildActivity(
91+
SwitchBrowserRedirectActivity::class.java, initialIntent
92+
).create()
93+
val activity = controller.get()
94+
95+
// Clear the first started activity
96+
val shadow = shadowOf(activity)
97+
shadow.nextStartedActivity
98+
99+
// Deliver a new intent with different params
100+
val newCode = "new-code-789"
101+
val newState = "new-state-012"
102+
val newIntent = buildRedirectIntent(newCode, TEST_ACTION_URI, newState)
103+
104+
controller.newIntent(newIntent)
105+
106+
val startedIntent = shadow.nextStartedActivity
107+
108+
assertNotNull("Should start SwitchBrowserActivity with new data", startedIntent)
109+
assertEquals(TEST_ACTION_URI, startedIntent.getStringExtra(SWITCH_BROWSER.ACTION_URI))
110+
assertEquals(newCode, startedIntent.getStringExtra(SWITCH_BROWSER.CODE))
111+
assertEquals(newState, startedIntent.getStringExtra(SWITCH_BROWSER.STATE))
112+
}
113+
114+
@Test
115+
fun onCreate_doesNotStartActivity_onConfigurationChange() {
116+
val intent = buildRedirectIntent(TEST_CODE, TEST_ACTION_URI, TEST_STATE)
117+
118+
val controller = Robolectric.buildActivity(
119+
SwitchBrowserRedirectActivity::class.java, intent
120+
).create()
121+
val activity = controller.get()
122+
123+
// Clear first started activity
124+
val shadow = shadowOf(activity)
125+
shadow.nextStartedActivity
126+
127+
// Simulate recreation with savedInstanceState (e.g. configuration change)
128+
controller.recreate()
129+
130+
// After recreation with savedInstanceState, it should NOT re-start the activity
131+
val startedIntent = shadow.nextStartedActivity
132+
assertEquals(null, startedIntent)
133+
}
134+
135+
@Test
136+
fun onCreate_parsesEncodedUriParameters() {
137+
val encodedActionUri = "https%3A%2F%2Flogin.microsoftonline.com%2Fprocess%3Fparam%3Dvalue"
138+
val decodedActionUri = "https://login.microsoftonline.com/process?param=value"
139+
val intent = Intent().apply {
140+
data = Uri.parse(
141+
"$REDIRECT_URI/${SWITCH_BROWSER.RESUME_PATH}?" +
142+
"${SWITCH_BROWSER.ACTION_URI}=$encodedActionUri&" +
143+
"${SWITCH_BROWSER.CODE}=$TEST_CODE&" +
144+
"${SWITCH_BROWSER.STATE}=$TEST_STATE"
145+
)
146+
}
147+
148+
val controller = Robolectric.buildActivity(
149+
SwitchBrowserRedirectActivity::class.java, intent
150+
).create()
151+
val activity = controller.get()
152+
153+
val shadow = shadowOf(activity)
154+
val startedIntent = shadow.nextStartedActivity
155+
156+
assertNotNull("Should handle encoded URI parameters", startedIntent)
157+
assertEquals(decodedActionUri, startedIntent.getStringExtra(SWITCH_BROWSER.ACTION_URI))
158+
assertEquals(TEST_CODE, startedIntent.getStringExtra(SWITCH_BROWSER.CODE))
159+
assertEquals(TEST_STATE, startedIntent.getStringExtra(SWITCH_BROWSER.STATE))
160+
}
161+
162+
private fun buildRedirectIntent(code: String, actionUri: String, state: String): Intent {
163+
val uriString = "$REDIRECT_URI/${SWITCH_BROWSER.RESUME_PATH}?" +
164+
"${SWITCH_BROWSER.ACTION_URI}=$actionUri&" +
165+
"${SWITCH_BROWSER.CODE}=$code&" +
166+
"${SWITCH_BROWSER.STATE}=$state"
167+
return Intent().apply {
168+
data = Uri.parse(uriString)
169+
}
170+
}
171+
}

0 commit comments

Comments
 (0)