diff --git a/changelog.txt b/changelog.txt index 6afc424369..214f090d7d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ vNext ---------- +- [PATCH] Fix Switch browser back stack (#2750) - [MINOR] Move ests telemetry behind feature flag (#2742) - [MINOR] Update Broker ATS flow for Resource Account (#2704) - [PATCH] Handle BadTokenException gracefully in CBA dialogs (#2731) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityFactory.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityFactory.kt index da7cc5140a..f17fed48d9 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityFactory.kt @@ -65,11 +65,6 @@ object AuthorizationActivityFactory { val libraryConfig = LibraryConfiguration.getInstance() if (ProcessUtil.isBrokerProcess(parameters.context)) { intent = Intent(parameters.context, BrokerAuthorizationActivity::class.java) - if (parameters.requestUrl.contains(AuthenticationConstants.SWITCH_BROWSER.CLIENT_SUPPORTS_FLOW)) { - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) - // In the case of a SwitchBrowser protocol, we need to transition from the browser to the WebView. - // These flags ensure that we have a new task stack that allows for this transition. - } } else if (libraryConfig.isAuthorizationInCurrentTask && parameters.authorizationAgent != AuthorizationAgent.WEBVIEW) { // We exclude the case when the authorization agent is already selected as WEBVIEW because of confusion // that results from attempting to use the CurrentTaskAuthorizationActivity in that case, because as webview diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/BrokerAuthorizationActivity.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/BrokerAuthorizationActivity.java index f3df344522..bac2575359 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/BrokerAuthorizationActivity.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/BrokerAuthorizationActivity.java @@ -22,26 +22,9 @@ // THE SOFTWARE. package com.microsoft.identity.common.internal.providers.oauth2; -import android.content.Intent; - /** * Declares as a separate class so that we can specify attributes exclusively to :auth process * in AndroidManifest without overriding MSAL's (In case where MSAL and broker is shipped together). */ public class BrokerAuthorizationActivity extends AuthorizationActivity { - - /** - * Refreshes the WebView with new intent data after the user completes authentication in the browser. - * - *
In the Switch browser flow, once the user finishes authentication in the browser, ETS will send a request
- * to the broker containing a code and an action URI. The broker will then send this request data back to the
- * WebView authorization activity via an intent. This method is used to refresh the WebView with the new intent
- * data that includes the code and action URI.
- * see {@link WebViewAuthorizationFragment#onResume()}
- */
- @Override
- protected void onNewIntent(final Intent intent) {
- super.onNewIntent(intent);
- setIntent(intent);
- }
}
diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/SwitchBrowserActivity.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/SwitchBrowserActivity.kt
new file mode 100644
index 0000000000..d1bd9ed564
--- /dev/null
+++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/SwitchBrowserActivity.kt
@@ -0,0 +1,246 @@
+// Copyright (c) Microsoft Corporation.
+// All rights reserved.
+//
+// This code is licensed under the MIT License.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files(the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions :
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+package com.microsoft.identity.common.internal.providers.oauth2
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.fragment.app.FragmentActivity
+import com.microsoft.identity.common.logging.Logger
+import androidx.core.net.toUri
+import com.microsoft.identity.common.internal.ui.browser.CustomTabsManager
+
+
+/**
+ * Activity responsible for handling browser switching flows.
+ *
+ * This activity serves as an intermediary between the WebView-based authentication and external browser
+ * authentication. When a Switch Browser challenge is received in [WebViewAuthorizationFragment], this activity
+ * is launched to handle the browser switch operation.
+ *
+ * **Flow Overview:**
+ * 1. WebViewAuthorizationFragment receives a SwitchBrowser challenge
+ * 2. This activity is launched with browser configuration parameters
+ * 3. Activity launches the specified browser (Custom Tabs or standard browser)
+ * 4. User completes authentication in the external browser
+ * 5. BrokerBrowserRedirectActivity is launched when the redirect URI is triggered.
+ * 5. BrokerBrowserRedirectActivity redirects back to this activity via onNewIntent()
+ * 6. Activity passes the result back to WebViewAuthorizationFragment
+ * 7. Activity finishes and removes itself from the task stack
+ *
+ * Activity back stack behavior:
+ * 1 BrokerAuthorizationActivity hosting WebViewAuthorizationFragment --launches--> SwitchBrowserActivity in a new task.
+ * 2 SwitchBrowserActivity --launches--> 3rd Party Browser (Custom Tabs or standard browser) in current task.
+ * 3 3rd Party Browser --redirects to--> BrokerBrowserRedirectActivity in a new task.
+ * 4 BrokerBrowserRedirectActivity -- launches--> SwitchBrowserActivity in the existing task, and finishes current task.
+ * 5 SwitchBrowserActivity --passes result to--> WebViewAuthorizationFragment, and finishes current activity stack.
+ *
+ * **Security Note:** This activity is not exported and can only be launched within the app
+ * to prevent external apps from triggering unwanted browser switches.
+ *
+ * @see WebViewAuthorizationFragment
+ */
+class SwitchBrowserActivity : FragmentActivity() {
+
+ // Flag to track if a Custom Chrome Tab (CCT) has been launched
+ private var cctLaunched = false
+ private var customTabsManager = CustomTabsManager(this)
+
+ companion object {
+ private val TAG: String = SwitchBrowserActivity::class.java.simpleName
+
+ /** Intent extra key for the target browser package name */
+ const val BROWSER_PACKAGE_NAME = "browser_package_name"
+
+ /** Intent extra key indicating if the browser supports Custom Tabs */
+ const val BROWSER_SUPPORTS_CUSTOM_TABS = "browser_supports_custom_tabs"
+
+ /** Intent extra key for the URI to process in the browser */
+ const val PROCESS_URI = "process_uri"
+
+ /** Intent extra key indicating a resume request from the browser redirect */
+ const val RESUME_REQUEST = "resume_request"
+ }
+
+ /**
+ * Initializes the activity and launches the appropriate browser for DUNA authentication.
+ *
+ * This method extracts the browser configuration from intent extras and launches either
+ * a Custom Tabs intent or a standard browser intent based on browser capabilities.
+ *
+ * @param savedInstanceState Saved instance state bundle (unused in this implementation)
+ */
+ override fun onCreate(savedInstanceState: Bundle?) {
+ val methodTag = "$TAG:onCreate"
+ super.onCreate(savedInstanceState)
+ Logger.info(methodTag, "SwitchBrowserActivity created - Launching browser")
+ launchBrowser()
+ }
+
+
+ /**
+ * Launches the specified browser for DUNA authentication based on intent extras.
+ *
+ * This method reads the target browser package name, Custom Tabs support flag,
+ * and the process URI from the intent extras. It then constructs and launches
+ * either a Custom Tabs intent or a standard browser intent accordingly.
+ *
+ * If required parameters are missing, it logs an error and finishes the activity.
+ */
+ private fun launchBrowser() {
+ val methodTag = "$TAG:launchBrowser"
+ cctLaunched = false
+ // Extract configuration parameters from intent extras
+ val extras = this.intent.extras ?: Bundle()
+ val browserPackageName = extras.getString(BROWSER_PACKAGE_NAME)
+ val browserSupportsCustomTabs = extras.getBoolean(BROWSER_SUPPORTS_CUSTOM_TABS, false)
+ val processUri = extras.getString(PROCESS_URI)
+
+ // Validate required parameters
+ if (browserPackageName.isNullOrBlank()) {
+ Logger.error(methodTag, "No browser package name found in extras - Cannot proceed with browser switch", null)
+ finish()
+ return
+ }
+ if (processUri.isNullOrBlank()) {
+ Logger.error(methodTag, "No process URI found in extras - Cannot proceed with browser switch", null)
+ finish()
+ return
+ }
+
+ Logger.info(
+ methodTag,
+ "Launching switch browser request on browser: $browserPackageName, Custom Tabs supported: $browserSupportsCustomTabs"
+ )
+
+ // Create an intent to launch the browser
+ val browserIntent: Intent
+ if (browserSupportsCustomTabs) {
+ Logger.info(methodTag, "CustomTabsService is supported.")
+ //create customTabsIntent
+ if (!customTabsManager.bind(this, browserPackageName)) {
+ Logger.warn(methodTag, "Failed to bind CustomTabsService.")
+ browserIntent = Intent(Intent.ACTION_VIEW)
+ } else {
+ browserIntent = customTabsManager.customTabsIntent.intent
+ }
+ } else {
+ Logger.warn(methodTag, "CustomTabsService is NOT supported")
+ browserIntent = Intent(Intent.ACTION_VIEW)
+ }
+ browserIntent.setPackage(browserPackageName)
+ browserIntent.setData(processUri.toUri())
+ startActivity(browserIntent)
+ }
+
+ /**
+ * Handles the redirect back from the browser after DUNA authentication completion.
+ *
+ * This method is called when the browser redirects back to the app with the authentication
+ * result. The intent contains the authentication response which is passed back to the
+ * WebViewAuthorizationFragment for processing.
+ *
+ * **Important:** This method also finishes the activity and removes it from the task stack
+ * to prevent it from remaining in the back stack after the authentication flow completes.
+ *
+ * @param intent The intent containing the authentication result from the browser redirect
+ */
+ override fun onNewIntent(intent: Intent?) {
+ val methodTag = "$TAG:onNewIntent"
+ super.onNewIntent(intent)
+ // Update the activity's intent with the new intent containing the auth result
+ Logger.info(methodTag, "On new intent received.")
+ setIntent(intent)
+
+ if (intent != null) {
+ if (intent.hasExtra(PROCESS_URI)) {
+ // Handle scenario where a new browser switch request is received while one is already in progress
+ // This can occur when the user initiates another auth request before completing the first one.
+ Logger.warn(
+ methodTag,
+ "Received new switch browser request while one is already in progress" +
+ " - Restarting browser switch flow"
+ )
+ // Launch the new browser request, which will reset cctLaunched and start fresh
+ launchBrowser()
+ return
+ }
+ if (intent.hasExtra(RESUME_REQUEST)) {
+ WebViewAuthorizationFragment.setSwitchBrowserBundle(intent.extras)
+ // Clean up: finish this activity and remove it from task stack
+ Logger.info(methodTag, "Finishing activity and removing from task stack")
+ finishAndRemoveTask()
+ return
+ }
+ }
+ // Clean up: finish this activity and remove it from task stack
+ Logger.info(methodTag, "Unexpected intent - Finishing activity and removing from task stack")
+ finishAndRemoveTask()
+ }
+
+ /**
+ * Handles the activity resume lifecycle event and manages Custom Chrome Tab (CCT) launch state.
+ *
+ * This method implements a critical part of the browser switch flow by tracking whether a Custom Chrome Tab
+ * has been launched and handling the case where the user returns to this activity without completing
+ * the authentication flow in the browser.
+ *
+ * **Behavior Logic:**
+ * - On first resume (after onCreate): Sets cctLaunched flag to true and continues normally
+ * - On subsequent resumes: If CCT was already launched, assumes user backed out of browser and finishes activity
+ *
+ * **Why This Logic is Needed:**
+ * When a Custom Chrome Tab is launched, this activity goes into the background. If the user presses the back
+ * button in the CCT or otherwise returns to this activity without completing authentication, we need to
+ * clean up and finish this activity to prevent it from remaining in the back stack.
+ *
+ * **Flow Scenarios:**
+ * 1. **Normal Flow**: onCreate → onResume (1st time) → CCT launched → user completes auth → onNewIntent → finish
+ * 2. **User Cancellation**: onCreate → onResume (1st time) → CCT launched → user backs out → onResume (2nd time) → finish
+ *
+ * **Important Notes:**
+ * - This prevents the activity from staying alive indefinitely if authentication is cancelled
+ * - Uses finishAndRemoveTask() to clean up the entire task stack, not just this activity
+ * - The cctLaunched flag is essential for distinguishing between the initial resume and subsequent resumes
+ */
+ override fun onResume() {
+ super.onResume()
+ val methodTag = "$TAG:onResume"
+ Logger.info(methodTag, "onResume called - Managing CCT launch state")
+
+ if (cctLaunched) {
+ // User has returned to this activity after CCT was launched, likely due to backing out
+ Logger.info(methodTag, "CCT was launched previously and user returned - Assuming cancellation, finishing activity")
+ finishAndRemoveTask()
+ } else {
+ // First resume after onCreate - mark CCT as launched for future reference
+ Logger.info(methodTag, "First resume after onCreate - Marking CCT as launched")
+ }
+
+ cctLaunched = true
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ customTabsManager.unbind()
+ }
+}
diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java
index 91cbacc1cd..1b047f2187 100644
--- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java
+++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/WebViewAuthorizationFragment.java
@@ -33,7 +33,6 @@
import static com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFields.VERSION;
import android.annotation.SuppressLint;
-import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
@@ -129,10 +128,13 @@ public class WebViewAuthorizationFragment extends AuthorizationFragment {
private boolean isBrokerRequest = false;
+ private static Bundle switchBrowserBundle;
+
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final String methodTag = TAG + ":onCreate";
+ Logger.verbose(methodTag, "WebViewAuthorizationFragment onCreate");
final FragmentActivity activity = getActivity();
if (activity != null) {
WebViewUtil.setDataDirectorySuffix(activity.getApplicationContext());
@@ -151,47 +153,36 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
@Override
public void onResume() {
super.onResume();
+ Logger.verbose(TAG + ":onResume", "WebViewAuthorizationFragment onResume");
if (getSwitchBrowserCoordinator().isExpectingSwitchBrowserResume()) {
- resumeSwitchBrowser(getExtras());
- }
- }
-
- /**
- * Get the extras from the activity intent.
- *
- * @return Bundle with the extras
- */
- @NonNull
- private Bundle getExtras() {
- final Activity activity = getActivity();
- if (activity == null) {
- return Bundle.EMPTY;
- }
- final Intent intent = activity.getIntent();
- if (intent == null) {
- return Bundle.EMPTY;
+ resumeSwitchBrowser();
+ } else {
+ setSwitchBrowserBundle(null);
}
- final Bundle extras = intent.getExtras();
- return extras == null ? Bundle.EMPTY : extras;
}
/**
* Resume the switch browser protocol flow.
- *
- * @param extras Bundle with the data to resume the switch browser protocol flow.
*/
- private void resumeSwitchBrowser(@NonNull final Bundle extras) {
+ private void resumeSwitchBrowser() {
final String methodTag = TAG + ":resumeSwitchBrowser";
try {
+ if (switchBrowserBundle == null) {
+ throw new ClientException(
+ ClientException.NULL_OBJECT,
+ "No switch browser bundle found to resume the flow."
+ );
+ }
Logger.info(methodTag, "Resuming switch browser flow");
getSwitchBrowserCoordinator().processSwitchBrowserResume(
mAuthorizationRequestUrl,
- extras,
+ switchBrowserBundle,
(switchBrowserResumeUri, switchBrowserResumeHeaders) -> {
launchWebView(switchBrowserResumeUri.toString(), switchBrowserResumeHeaders);
return null;
}
);
+ setSwitchBrowserBundle(null);
} catch (final ClientException e) {
Logger.error(methodTag, "Error processing switch browser resume", e);
sendResult(RawAuthorizationResult.fromException(e));
@@ -476,4 +467,12 @@ private SwitchBrowserProtocolCoordinator getSwitchBrowserCoordinator() {
}
return mSwitchBrowserProtocolCoordinator;
}
+
+ /**
+ * Set the switch browser bundle to be used when resuming the flow.
+ * @param bundle The bundle containing the data needed to resume the flow.
+ */
+ public static synchronized void setSwitchBrowserBundle(@Nullable final Bundle bundle) {
+ switchBrowserBundle = bundle;
+ }
}
diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/challengehandlers/SwitchBrowserRequestHandler.kt b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/challengehandlers/SwitchBrowserRequestHandler.kt
index df6f78268a..401fbb13bc 100644
--- a/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/challengehandlers/SwitchBrowserRequestHandler.kt
+++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/webview/challengehandlers/SwitchBrowserRequestHandler.kt
@@ -23,11 +23,10 @@
package com.microsoft.identity.common.internal.ui.webview.challengehandlers
import android.app.Activity
-import android.content.Context
import android.content.Intent
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER
+import com.microsoft.identity.common.internal.providers.oauth2.SwitchBrowserActivity
import com.microsoft.identity.common.internal.ui.browser.AndroidBrowserSelector
-import com.microsoft.identity.common.internal.ui.browser.CustomTabsManager
import com.microsoft.identity.common.internal.ui.webview.switchbrowser.SwitchBrowserUriHelper
import com.microsoft.identity.common.internal.ui.webview.switchbrowser.SwitchBrowserUriHelper.isSwitchBrowserRedirectUrl
import com.microsoft.identity.common.java.browser.IBrowserSelector
@@ -48,8 +47,6 @@ import io.opentelemetry.api.trace.StatusCode
*/
class SwitchBrowserRequestHandler(
private val activity: Activity,
- private val context: Context,
- private val customTabsManager: CustomTabsManager,
private val browserSelector: IBrowserSelector,
private val spanContext: SpanContext?
) : IChallengeHandler