Skip to content

Commit f3aac73

Browse files
p3dr0rvCopilot
andauthored
Fix Switch browser back stack error (#2750)
## Problem The current **DUNA** implementation launches `BrokerAuthorizationActivity` with `FLAG_ACTIVITY_NEW_TASK` and `FLAG_ACTIVITY_CLEAR_TASK`. This causes: - Device registration failures - Other authentication flows failing The issue arises because the activity stack is cleared, preventing proper continuation of dependent flows. --- ## Solution Introduce **`SwitchBrowserActivity`** to handle the browser switch during the DUNA authentication flow. This approach: - Isolates the browser-based authentication - Preserves the existing activity stack for device registration and other auth processes - Leaves non-DUNA flows unchanged --- ## Activity Stack Behavior 1. **Task 1:** `BrokerAuthorizationActivity` launches `SwitchBrowserActivity` in **Task 2** 2. **Task 1:** `BrokerAuthorizationActivity` | **Task 2:** `SwitchBrowserActivity` → Chrome Custom Tabs 3. **Task 1:** `BrokerAuthorizationActivity` | **Task 2:** `SwitchBrowserActivity` → Chrome Custom Tabs | **Task 3:** `BrokerBrowserRedirectActivity` 4. **Task 1:** `BrokerAuthorizationActivity` | **Task 2:** `SwitchBrowserActivity` → Chrome Custom Tabs | **Task 3:** `BrokerBrowserRedirectActivity` (finishes) --- ## Flow Overview of `SwitchBrowserActivity` 1. `WebViewAuthorizationFragment` receives a **SwitchBrowser challenge** 2. `SwitchBrowserActivity` is launched with browser configuration parameters 3. Launches external browser (Custom Tabs or standard browser) 4. User completes authentication in the external browser 5. Browser redirects to `BrokerBrowserRedirectActivity` in a new task 6. `BrokerBrowserRedirectActivity` forwards redirect result back to `SwitchBrowserActivity` via `onNewIntent()` 7. `SwitchBrowserActivity` passes the result to `WebViewAuthorizationFragment` 8. `SwitchBrowserActivity` finishes and removes itself from the task stack --- ## Security Note - `SwitchBrowserActivity` is **not exported** - Can only be launched from inside the app - Prevents external apps from triggering unwanted browser switches --------- Co-authored-by: Copilot <[email protected]>
1 parent 17b8680 commit f3aac73

File tree

9 files changed

+321
-139
lines changed

9 files changed

+321
-139
lines changed

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] Fix Switch browser back stack (#2750)
34
- [MINOR] Move ests telemetry behind feature flag (#2742)
45
- [MINOR] Update Broker ATS flow for Resource Account (#2704)
56
- [PATCH] Handle BadTokenException gracefully in CBA dialogs (#2731)

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,6 @@ object AuthorizationActivityFactory {
6565
val libraryConfig = LibraryConfiguration.getInstance()
6666
if (ProcessUtil.isBrokerProcess(parameters.context)) {
6767
intent = Intent(parameters.context, BrokerAuthorizationActivity::class.java)
68-
if (parameters.requestUrl.contains(AuthenticationConstants.SWITCH_BROWSER.CLIENT_SUPPORTS_FLOW)) {
69-
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
70-
// In the case of a SwitchBrowser protocol, we need to transition from the browser to the WebView.
71-
// These flags ensure that we have a new task stack that allows for this transition.
72-
}
7368
} else if (libraryConfig.isAuthorizationInCurrentTask && parameters.authorizationAgent != AuthorizationAgent.WEBVIEW) {
7469
// We exclude the case when the authorization agent is already selected as WEBVIEW because of confusion
7570
// that results from attempting to use the CurrentTaskAuthorizationActivity in that case, because as webview

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

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,9 @@
2222
// THE SOFTWARE.
2323
package com.microsoft.identity.common.internal.providers.oauth2;
2424

25-
import android.content.Intent;
26-
2725
/**
2826
* Declares as a separate class so that we can specify attributes exclusively to :auth process
2927
* in AndroidManifest without overriding MSAL's (In case where MSAL and broker is shipped together).
3028
*/
3129
public class BrokerAuthorizationActivity extends AuthorizationActivity {
32-
33-
/**
34-
* Refreshes the WebView with new intent data after the user completes authentication in the browser.
35-
*
36-
* <p>In the Switch browser flow, once the user finishes authentication in the browser, ETS will send a request
37-
* to the broker containing a code and an action URI. The broker will then send this request data back to the
38-
* WebView authorization activity via an intent. This method is used to refresh the WebView with the new intent
39-
* data that includes the code and action URI.
40-
* see {@link WebViewAuthorizationFragment#onResume()}
41-
*/
42-
@Override
43-
protected void onNewIntent(final Intent intent) {
44-
super.onNewIntent(intent);
45-
setIntent(intent);
46-
}
4730
}
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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.os.Bundle
27+
import androidx.fragment.app.FragmentActivity
28+
import com.microsoft.identity.common.logging.Logger
29+
import androidx.core.net.toUri
30+
import com.microsoft.identity.common.internal.ui.browser.CustomTabsManager
31+
32+
33+
/**
34+
* Activity responsible for handling browser switching flows.
35+
*
36+
* This activity serves as an intermediary between the WebView-based authentication and external browser
37+
* authentication. When a Switch Browser challenge is received in [WebViewAuthorizationFragment], this activity
38+
* is launched to handle the browser switch operation.
39+
*
40+
* **Flow Overview:**
41+
* 1. WebViewAuthorizationFragment receives a SwitchBrowser challenge
42+
* 2. This activity is launched with browser configuration parameters
43+
* 3. Activity launches the specified browser (Custom Tabs or standard browser)
44+
* 4. User completes authentication in the external browser
45+
* 5. BrokerBrowserRedirectActivity is launched when the redirect URI is triggered.
46+
* 5. BrokerBrowserRedirectActivity redirects back to this activity via onNewIntent()
47+
* 6. Activity passes the result back to WebViewAuthorizationFragment
48+
* 7. Activity finishes and removes itself from the task stack
49+
*
50+
* Activity back stack behavior:
51+
* 1 BrokerAuthorizationActivity hosting WebViewAuthorizationFragment --launches--> SwitchBrowserActivity in a new task.
52+
* 2 SwitchBrowserActivity --launches--> 3rd Party Browser (Custom Tabs or standard browser) in current task.
53+
* 3 3rd Party Browser --redirects to--> BrokerBrowserRedirectActivity in a new task.
54+
* 4 BrokerBrowserRedirectActivity -- launches--> SwitchBrowserActivity in the existing task, and finishes current task.
55+
* 5 SwitchBrowserActivity --passes result to--> WebViewAuthorizationFragment, and finishes current activity stack.
56+
*
57+
* **Security Note:** This activity is not exported and can only be launched within the app
58+
* to prevent external apps from triggering unwanted browser switches.
59+
*
60+
* @see WebViewAuthorizationFragment
61+
*/
62+
class SwitchBrowserActivity : FragmentActivity() {
63+
64+
// Flag to track if a Custom Chrome Tab (CCT) has been launched
65+
private var cctLaunched = false
66+
private var customTabsManager = CustomTabsManager(this)
67+
68+
companion object {
69+
private val TAG: String = SwitchBrowserActivity::class.java.simpleName
70+
71+
/** Intent extra key for the target browser package name */
72+
const val BROWSER_PACKAGE_NAME = "browser_package_name"
73+
74+
/** Intent extra key indicating if the browser supports Custom Tabs */
75+
const val BROWSER_SUPPORTS_CUSTOM_TABS = "browser_supports_custom_tabs"
76+
77+
/** Intent extra key for the URI to process in the browser */
78+
const val PROCESS_URI = "process_uri"
79+
80+
/** Intent extra key indicating a resume request from the browser redirect */
81+
const val RESUME_REQUEST = "resume_request"
82+
}
83+
84+
/**
85+
* Initializes the activity and launches the appropriate browser for DUNA authentication.
86+
*
87+
* This method extracts the browser configuration from intent extras and launches either
88+
* a Custom Tabs intent or a standard browser intent based on browser capabilities.
89+
*
90+
* @param savedInstanceState Saved instance state bundle (unused in this implementation)
91+
*/
92+
override fun onCreate(savedInstanceState: Bundle?) {
93+
val methodTag = "$TAG:onCreate"
94+
super.onCreate(savedInstanceState)
95+
Logger.info(methodTag, "SwitchBrowserActivity created - Launching browser")
96+
launchBrowser()
97+
}
98+
99+
100+
/**
101+
* Launches the specified browser for DUNA authentication based on intent extras.
102+
*
103+
* This method reads the target browser package name, Custom Tabs support flag,
104+
* and the process URI from the intent extras. It then constructs and launches
105+
* either a Custom Tabs intent or a standard browser intent accordingly.
106+
*
107+
* If required parameters are missing, it logs an error and finishes the activity.
108+
*/
109+
private fun launchBrowser() {
110+
val methodTag = "$TAG:launchBrowser"
111+
cctLaunched = false
112+
// Extract configuration parameters from intent extras
113+
val extras = this.intent.extras ?: Bundle()
114+
val browserPackageName = extras.getString(BROWSER_PACKAGE_NAME)
115+
val browserSupportsCustomTabs = extras.getBoolean(BROWSER_SUPPORTS_CUSTOM_TABS, false)
116+
val processUri = extras.getString(PROCESS_URI)
117+
118+
// Validate required parameters
119+
if (browserPackageName.isNullOrBlank()) {
120+
Logger.error(methodTag, "No browser package name found in extras - Cannot proceed with browser switch", null)
121+
finish()
122+
return
123+
}
124+
if (processUri.isNullOrBlank()) {
125+
Logger.error(methodTag, "No process URI found in extras - Cannot proceed with browser switch", null)
126+
finish()
127+
return
128+
}
129+
130+
Logger.info(
131+
methodTag,
132+
"Launching switch browser request on browser: $browserPackageName, Custom Tabs supported: $browserSupportsCustomTabs"
133+
)
134+
135+
// Create an intent to launch the browser
136+
val browserIntent: Intent
137+
if (browserSupportsCustomTabs) {
138+
Logger.info(methodTag, "CustomTabsService is supported.")
139+
//create customTabsIntent
140+
if (!customTabsManager.bind(this, browserPackageName)) {
141+
Logger.warn(methodTag, "Failed to bind CustomTabsService.")
142+
browserIntent = Intent(Intent.ACTION_VIEW)
143+
} else {
144+
browserIntent = customTabsManager.customTabsIntent.intent
145+
}
146+
} else {
147+
Logger.warn(methodTag, "CustomTabsService is NOT supported")
148+
browserIntent = Intent(Intent.ACTION_VIEW)
149+
}
150+
browserIntent.setPackage(browserPackageName)
151+
browserIntent.setData(processUri.toUri())
152+
startActivity(browserIntent)
153+
}
154+
155+
/**
156+
* Handles the redirect back from the browser after DUNA authentication completion.
157+
*
158+
* This method is called when the browser redirects back to the app with the authentication
159+
* result. The intent contains the authentication response which is passed back to the
160+
* WebViewAuthorizationFragment for processing.
161+
*
162+
* **Important:** This method also finishes the activity and removes it from the task stack
163+
* to prevent it from remaining in the back stack after the authentication flow completes.
164+
*
165+
* @param intent The intent containing the authentication result from the browser redirect
166+
*/
167+
override fun onNewIntent(intent: Intent?) {
168+
val methodTag = "$TAG:onNewIntent"
169+
super.onNewIntent(intent)
170+
// Update the activity's intent with the new intent containing the auth result
171+
Logger.info(methodTag, "On new intent received.")
172+
setIntent(intent)
173+
174+
if (intent != null) {
175+
if (intent.hasExtra(PROCESS_URI)) {
176+
// Handle scenario where a new browser switch request is received while one is already in progress
177+
// This can occur when the user initiates another auth request before completing the first one.
178+
Logger.warn(
179+
methodTag,
180+
"Received new switch browser request while one is already in progress" +
181+
" - Restarting browser switch flow"
182+
)
183+
// Launch the new browser request, which will reset cctLaunched and start fresh
184+
launchBrowser()
185+
return
186+
}
187+
if (intent.hasExtra(RESUME_REQUEST)) {
188+
WebViewAuthorizationFragment.setSwitchBrowserBundle(intent.extras)
189+
// Clean up: finish this activity and remove it from task stack
190+
Logger.info(methodTag, "Finishing activity and removing from task stack")
191+
finishAndRemoveTask()
192+
return
193+
}
194+
}
195+
// Clean up: finish this activity and remove it from task stack
196+
Logger.info(methodTag, "Unexpected intent - Finishing activity and removing from task stack")
197+
finishAndRemoveTask()
198+
}
199+
200+
/**
201+
* Handles the activity resume lifecycle event and manages Custom Chrome Tab (CCT) launch state.
202+
*
203+
* This method implements a critical part of the browser switch flow by tracking whether a Custom Chrome Tab
204+
* has been launched and handling the case where the user returns to this activity without completing
205+
* the authentication flow in the browser.
206+
*
207+
* **Behavior Logic:**
208+
* - On first resume (after onCreate): Sets cctLaunched flag to true and continues normally
209+
* - On subsequent resumes: If CCT was already launched, assumes user backed out of browser and finishes activity
210+
*
211+
* **Why This Logic is Needed:**
212+
* When a Custom Chrome Tab is launched, this activity goes into the background. If the user presses the back
213+
* button in the CCT or otherwise returns to this activity without completing authentication, we need to
214+
* clean up and finish this activity to prevent it from remaining in the back stack.
215+
*
216+
* **Flow Scenarios:**
217+
* 1. **Normal Flow**: onCreate → onResume (1st time) → CCT launched → user completes auth → onNewIntent → finish
218+
* 2. **User Cancellation**: onCreate → onResume (1st time) → CCT launched → user backs out → onResume (2nd time) → finish
219+
*
220+
* **Important Notes:**
221+
* - This prevents the activity from staying alive indefinitely if authentication is cancelled
222+
* - Uses finishAndRemoveTask() to clean up the entire task stack, not just this activity
223+
* - The cctLaunched flag is essential for distinguishing between the initial resume and subsequent resumes
224+
*/
225+
override fun onResume() {
226+
super.onResume()
227+
val methodTag = "$TAG:onResume"
228+
Logger.info(methodTag, "onResume called - Managing CCT launch state")
229+
230+
if (cctLaunched) {
231+
// User has returned to this activity after CCT was launched, likely due to backing out
232+
Logger.info(methodTag, "CCT was launched previously and user returned - Assuming cancellation, finishing activity")
233+
finishAndRemoveTask()
234+
} else {
235+
// First resume after onCreate - mark CCT as launched for future reference
236+
Logger.info(methodTag, "First resume after onCreate - Marking CCT as launched")
237+
}
238+
239+
cctLaunched = true
240+
}
241+
242+
override fun onDestroy() {
243+
super.onDestroy()
244+
customTabsManager.unbind()
245+
}
246+
}

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

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
import static com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFields.VERSION;
3434

3535
import android.annotation.SuppressLint;
36-
import android.app.Activity;
3736
import android.content.Context;
3837
import android.content.Intent;
3938
import android.graphics.Bitmap;
@@ -129,10 +128,13 @@ public class WebViewAuthorizationFragment extends AuthorizationFragment {
129128

130129
private boolean isBrokerRequest = false;
131130

131+
private static Bundle switchBrowserBundle;
132+
132133
@Override
133134
public void onCreate(@Nullable Bundle savedInstanceState) {
134135
super.onCreate(savedInstanceState);
135136
final String methodTag = TAG + ":onCreate";
137+
Logger.verbose(methodTag, "WebViewAuthorizationFragment onCreate");
136138
final FragmentActivity activity = getActivity();
137139
if (activity != null) {
138140
WebViewUtil.setDataDirectorySuffix(activity.getApplicationContext());
@@ -151,47 +153,36 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
151153
@Override
152154
public void onResume() {
153155
super.onResume();
156+
Logger.verbose(TAG + ":onResume", "WebViewAuthorizationFragment onResume");
154157
if (getSwitchBrowserCoordinator().isExpectingSwitchBrowserResume()) {
155-
resumeSwitchBrowser(getExtras());
156-
}
157-
}
158-
159-
/**
160-
* Get the extras from the activity intent.
161-
*
162-
* @return Bundle with the extras
163-
*/
164-
@NonNull
165-
private Bundle getExtras() {
166-
final Activity activity = getActivity();
167-
if (activity == null) {
168-
return Bundle.EMPTY;
169-
}
170-
final Intent intent = activity.getIntent();
171-
if (intent == null) {
172-
return Bundle.EMPTY;
158+
resumeSwitchBrowser();
159+
} else {
160+
setSwitchBrowserBundle(null);
173161
}
174-
final Bundle extras = intent.getExtras();
175-
return extras == null ? Bundle.EMPTY : extras;
176162
}
177163

178164
/**
179165
* Resume the switch browser protocol flow.
180-
*
181-
* @param extras Bundle with the data to resume the switch browser protocol flow.
182166
*/
183-
private void resumeSwitchBrowser(@NonNull final Bundle extras) {
167+
private void resumeSwitchBrowser() {
184168
final String methodTag = TAG + ":resumeSwitchBrowser";
185169
try {
170+
if (switchBrowserBundle == null) {
171+
throw new ClientException(
172+
ClientException.NULL_OBJECT,
173+
"No switch browser bundle found to resume the flow."
174+
);
175+
}
186176
Logger.info(methodTag, "Resuming switch browser flow");
187177
getSwitchBrowserCoordinator().processSwitchBrowserResume(
188178
mAuthorizationRequestUrl,
189-
extras,
179+
switchBrowserBundle,
190180
(switchBrowserResumeUri, switchBrowserResumeHeaders) -> {
191181
launchWebView(switchBrowserResumeUri.toString(), switchBrowserResumeHeaders);
192182
return null;
193183
}
194184
);
185+
setSwitchBrowserBundle(null);
195186
} catch (final ClientException e) {
196187
Logger.error(methodTag, "Error processing switch browser resume", e);
197188
sendResult(RawAuthorizationResult.fromException(e));
@@ -476,4 +467,12 @@ private SwitchBrowserProtocolCoordinator getSwitchBrowserCoordinator() {
476467
}
477468
return mSwitchBrowserProtocolCoordinator;
478469
}
470+
471+
/**
472+
* Set the switch browser bundle to be used when resuming the flow.
473+
* @param bundle The bundle containing the data needed to resume the flow.
474+
*/
475+
public static synchronized void setSwitchBrowserBundle(@Nullable final Bundle bundle) {
476+
switchBrowserBundle = bundle;
477+
}
479478
}

0 commit comments

Comments
 (0)