Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ vNext

- [MINOR] Remove LruCache from SharedPreferencesFileManager (#2910)
- [MINOR] Edge TB: Claims (#2925)
- [MINOR] Add AuthTab (Chrome 137+) support for browser-based authentication flows via ENABLE_AUTH_TAB feature flag (#3533538)

Version 24.0.0
----------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// 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.net.Uri
import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import androidx.browser.auth.AuthTabIntent
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.AuthorizationIntentKey.REDIRECT_URI
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.AuthorizationIntentKey.REQUEST_URL
import com.microsoft.identity.common.java.exception.ClientException
import com.microsoft.identity.common.java.exception.ErrorStrings
import com.microsoft.identity.common.java.opentelemetry.AttributeName
import com.microsoft.identity.common.java.opentelemetry.OTelUtility
import com.microsoft.identity.common.java.opentelemetry.SpanExtension
import com.microsoft.identity.common.java.opentelemetry.SpanName
import com.microsoft.identity.common.java.providers.RawAuthorizationResult
import com.microsoft.identity.common.logging.Logger
import io.opentelemetry.api.trace.StatusCode

/**
* Authorization fragment that uses AuthTab (Chrome 137+) for browser-based authentication flows.
*
* AuthTab returns results via [ActivityResultLauncher] instead of intent-based redirects,
* improving security and simplifying the flow. Falls back to [BrowserAuthorizationFragment]
* behavior is not handled here; the routing decision happens in [AuthorizationActivityFactory].
*
* The [ActivityResultLauncher] must be registered in [onCreate] before the STARTED lifecycle state.
*/
class AuthTabAuthorizationFragment : AuthorizationFragment() {

companion object {
private val TAG: String = AuthTabAuthorizationFragment::class.java.simpleName
private const val AUTH_FLOW_STARTED = "authFlowStarted"
}

private var requestUrl: String? = null
private var redirectUri: String? = null
private var authFlowStarted = false

private lateinit var authTabLauncher: ActivityResultLauncher<AuthTabIntent>

override fun onCreate(savedInstanceState: Bundle?) {
// Register the ActivityResultLauncher BEFORE calling super.onCreate(),
// which transitions to STARTED state. This is required by the Activity Result API.
authTabLauncher = AuthTabIntent.registerActivityResultLauncher(this, ::handleAuthResult)
super.onCreate(savedInstanceState)
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(REQUEST_URL, requestUrl)
outState.putString(REDIRECT_URI, redirectUri)
outState.putBoolean(AUTH_FLOW_STARTED, authFlowStarted)
}

override fun extractState(state: Bundle) {
super.extractState(state)
requestUrl = state.getString(REQUEST_URL)
redirectUri = state.getString(REDIRECT_URI)
authFlowStarted = state.getBoolean(AUTH_FLOW_STARTED, false)
}

override fun onResume() {
super.onResume()
val methodTag = "$TAG:onResume"

if (authFlowStarted) {
// The fragment resumed after returning from AuthTab.
// If a result was already sent (via handleAuthResult), do nothing further.
// If no result was sent yet, it means the user backed out without completing auth.
if (!mAuthResultSent) {
Logger.info(methodTag, "AuthTab flow already started and resumed without result - treating as cancellation.")
cancelAuthorization(true)
}
return
}

val url = requestUrl
val redirect = redirectUri
if (url.isNullOrBlank() || redirect.isNullOrBlank()) {
Logger.error(methodTag, "Missing requestUrl or redirectUri - Cannot launch AuthTab.", null)
sendResult(RawAuthorizationResult.fromException(
ClientException(ErrorStrings.UNKNOWN_ERROR, "Missing requestUrl or redirectUri for AuthTab flow.")
))
finish()
return
}

val scheme = Uri.parse(redirect).scheme
if (scheme.isNullOrBlank()) {
Logger.error(methodTag, "Could not extract redirect scheme from redirectUri: $redirect", null)
sendResult(RawAuthorizationResult.fromException(
ClientException(ErrorStrings.UNKNOWN_ERROR, "Could not extract redirect scheme from redirectUri.")
))
finish()
return
}

authFlowStarted = true
Logger.info(methodTag, "Launching AuthTab for URL, redirect scheme: $scheme")
val authTabIntent = AuthTabIntent.Builder().build()
authTabIntent.launch(authTabLauncher, Uri.parse(url), scheme)
}

private fun handleAuthResult(result: AuthTabIntent.AuthResult) {
val methodTag = "$TAG:handleAuthResult"
val span = OTelUtility.createSpan(SpanName.AuthTabAuthorization.name)
SpanExtension.makeCurrentSpan(span).use {
try {
span.setAttribute(AttributeName.is_auth_tab_used.name, true)
span.setAttribute(AttributeName.auth_tab_result_code.name, result.resultCode)

when (result.resultCode) {
AuthTabIntent.RESULT_OK -> {
val resultUri = result.resultUri
Logger.info(methodTag, "AuthTab returned RESULT_OK.")
span.setStatus(StatusCode.OK)
if (resultUri != null) {
sendResult(RawAuthorizationResult.fromRedirectUri(resultUri.toString()))
} else {
Logger.warn(methodTag, "AuthTab RESULT_OK but resultUri is null - treating as cancellation.")
sendResult(RawAuthorizationResult.ResultCode.CANCELLED)
}
finish()
}
AuthTabIntent.RESULT_CANCELED -> {
Logger.info(methodTag, "AuthTab returned RESULT_CANCELED - user cancelled authorization.")
span.setStatus(StatusCode.OK)
cancelAuthorization(true)
}
AuthTabIntent.RESULT_VERIFICATION_FAILED -> {
Logger.error(methodTag, "AuthTab returned RESULT_VERIFICATION_FAILED.", null)
span.setStatus(StatusCode.ERROR)
sendResult(RawAuthorizationResult.fromException(
ClientException(ErrorStrings.UNKNOWN_ERROR, "AuthTab verification failed.")
))
finish()
}
AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> {
Logger.error(methodTag, "AuthTab returned RESULT_VERIFICATION_TIMED_OUT.", null)
span.setStatus(StatusCode.ERROR)
sendResult(RawAuthorizationResult.fromException(
ClientException(ErrorStrings.UNKNOWN_ERROR, "AuthTab verification timed out.")
))
finish()
}
else -> {
Logger.warn(methodTag, "AuthTab returned unknown result code: ${result.resultCode}")
span.setStatus(StatusCode.OK)
cancelAuthorization(true)
}
}
} catch (t: Throwable) {
span.setStatus(StatusCode.ERROR)
span.recordException(t)
throw t
} finally {
span.end()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFie
import com.microsoft.identity.common.java.AuthenticationConstants.SdkPlatformFields.VERSION
import com.microsoft.identity.common.java.configuration.LibraryConfiguration
import com.microsoft.identity.common.java.exception.ClientException
import com.microsoft.identity.common.java.flighting.CommonFlight
import com.microsoft.identity.common.java.flighting.CommonFlightsManager
import com.microsoft.identity.common.java.logging.DiagnosticContext
import com.microsoft.identity.common.java.opentelemetry.OtelContextExtension
import com.microsoft.identity.common.java.opentelemetry.SerializableSpanContext
Expand Down Expand Up @@ -172,6 +174,9 @@ object AuthorizationActivityFactory {
} else {
if (libraryConfig.isAuthorizationInCurrentTask) {
CurrentTaskBrowserAuthorizationFragment()
} else if (CommonFlightsManager.INSTANCE.getFlightsProvider()
.isFlightEnabled(CommonFlight.ENABLE_AUTH_TAB)) {
AuthTabAuthorizationFragment()
} else {
BrowserAuthorizationFragment()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ package com.microsoft.identity.common.internal.providers.oauth2

import android.content.Intent
import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import androidx.browser.auth.AuthTabIntent
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
import com.microsoft.identity.common.java.flighting.CommonFlight
import com.microsoft.identity.common.java.flighting.CommonFlightsManager


/**
Expand Down Expand Up @@ -65,6 +69,9 @@ class SwitchBrowserActivity : FragmentActivity() {
private var cctLaunched = false
private var customTabsManager = CustomTabsManager(this)

// AuthTab launcher for DUNA flows when AuthTab is supported
private lateinit var authTabLauncher: ActivityResultLauncher<AuthTabIntent>

companion object {
private val TAG: String = SwitchBrowserActivity::class.java.simpleName

Expand All @@ -91,6 +98,11 @@ class SwitchBrowserActivity : FragmentActivity() {
*/
override fun onCreate(savedInstanceState: Bundle?) {
val methodTag = "$TAG:onCreate"
// Register the AuthTab launcher before super.onCreate() to ensure it is registered
// before the activity reaches the STARTED state, as required by the Activity Result API.
authTabLauncher = AuthTabIntent.registerActivityResultLauncher(this) { result ->
handleAuthTabResult(result)
}
super.onCreate(savedInstanceState)
Logger.info(methodTag, "SwitchBrowserActivity created - Launching browser")
launchBrowser()
Expand Down Expand Up @@ -134,6 +146,20 @@ class SwitchBrowserActivity : FragmentActivity() {

// Create an intent to launch the browser
val browserIntent: Intent
if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_AUTH_TAB)
&& CustomTabsManager.isAuthTabSupported(this, browserPackageName)) {
Logger.info(methodTag, "AuthTab is supported - launching via AuthTabIntent.")
val authTabIntent = AuthTabIntent.Builder().build()
val scheme = processUri.toUri().scheme
if (scheme.isNullOrBlank()) {
Logger.error(methodTag, "Could not extract scheme from processUri: $processUri - Cannot launch AuthTab", null)
finish()
return
}
authTabIntent.launch(authTabLauncher, processUri.toUri(), scheme)
return
}

if (browserSupportsCustomTabs) {
Logger.info(methodTag, "CustomTabsService is supported.")
//create customTabsIntent
Expand All @@ -152,6 +178,48 @@ class SwitchBrowserActivity : FragmentActivity() {
startActivity(browserIntent)
}

/**
* Handles the result from AuthTab for DUNA authentication flows.
*
* @param result The [AuthTabIntent.AuthResult] returned by AuthTab.
*/
private fun handleAuthTabResult(result: AuthTabIntent.AuthResult) {
val methodTag = "$TAG:handleAuthTabResult"
Logger.info(methodTag, "AuthTab result received, resultCode: ${result.resultCode}")
when (result.resultCode) {
AuthTabIntent.RESULT_OK -> {
val resultUri = result.resultUri
if (resultUri != null) {
val bundle = Bundle().apply {
putString(com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER.ACTION_URI,
resultUri.getQueryParameter(com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER.ACTION_URI))
putString(com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER.CODE,
resultUri.getQueryParameter(com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER.CODE))
putString(com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER.STATE,
resultUri.getQueryParameter(com.microsoft.identity.common.adal.internal.AuthenticationConstants.SWITCH_BROWSER.STATE))
putBoolean(RESUME_REQUEST, true)
}
WebViewAuthorizationFragment.setSwitchBrowserBundle(bundle)
} else {
Logger.warn(methodTag, "AuthTab RESULT_OK but resultUri is null.")
}
}
AuthTabIntent.RESULT_CANCELED -> {
Logger.info(methodTag, "AuthTab result: RESULT_CANCELED - user cancelled.")
}
AuthTabIntent.RESULT_VERIFICATION_FAILED -> {
Logger.error(methodTag, "AuthTab result: RESULT_VERIFICATION_FAILED.", null)
}
AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> {
Logger.error(methodTag, "AuthTab result: RESULT_VERIFICATION_TIMED_OUT.", null)
}
else -> {
Logger.warn(methodTag, "AuthTab result: unknown resultCode ${result.resultCode}.")
}
}
finishAndRemoveTask()
}

/**
* Handles the redirect back from the browser after DUNA authentication completion.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,34 @@ private CustomTabsClient getClient() {
return mCustomTabsClient.get();
}

/**
* Checks whether the given browser package supports AuthTab (Chrome 137+).
*
* <p>AuthTab is a specialized Custom Tab variant for authentication that returns results
* via {@link androidx.activity.result.ActivityResultLauncher} instead of intent-based redirects.
* It requires Chrome 137 or higher (or an equivalent browser that supports the AuthTab protocol).
*
* <p>This method is safe to call from any thread. It does not perform any blocking I/O.
*
* @param context The application context; must not be null.
* @param browserPackage The package name of the browser to query; must not be null.
* @return {@code true} if the given browser package supports AuthTab; {@code false} otherwise,
* including if the browser is not installed, the package name is invalid, or any
* exception occurs during the check.
*/
public static boolean isAuthTabSupported(@NonNull final Context context,
@NonNull final String browserPackage) {
final String methodTag = TAG + ":isAuthTabSupported";
try {
final boolean supported = CustomTabsClient.isAuthTabSupported(context, browserPackage);
Logger.info(methodTag, "AuthTab supported by " + browserPackage + ": " + supported);
return supported;
} catch (final Exception e) {
Logger.warn(methodTag, "Exception checking AuthTab support for " + browserPackage + ": " + e.getMessage());
return false;
}
}

/**
* Method to unbind custom tabs service {@link androidx.browser.customtabs.CustomTabsService}.
*/
Expand Down
Loading
Loading