diff --git a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityParameters.kt b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityParameters.kt index f65a7603ce..84d4fcc5d9 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityParameters.kt +++ b/common/src/main/java/com/microsoft/identity/common/internal/providers/oauth2/AuthorizationActivityParameters.kt @@ -42,6 +42,7 @@ import com.microsoft.identity.common.java.ui.AuthorizationAgent * @param utid The tenant unique id, if applicable * @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. * @param isWebViewWebCpEnabled This parameter controls whether webcp URLs should be handled within the WebView or redirected to external browser + * @param useAuthTab Whether to use AuthTab (Chrome 137+ API) for browser-based authentication flows */ data class AuthorizationActivityParameters @JvmOverloads constructor( val context: Context, @@ -60,4 +61,9 @@ data class AuthorizationActivityParameters @JvmOverloads constructor( val utid: String? = null, val webViewEnableSilentAuthorizationFlowTimeOutMs: Long? = null, val isWebViewWebCpEnabled: Boolean = false, -) + val useAuthTab: Boolean = false, +) { + companion object { + const val USE_AUTH_TAB = "com.microsoft.identity.USE_AUTH_TAB" + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/ui/browser/AuthTabManager.kt b/common/src/main/java/com/microsoft/identity/common/internal/ui/browser/AuthTabManager.kt new file mode 100644 index 0000000000..5bea23e68f --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/internal/ui/browser/AuthTabManager.kt @@ -0,0 +1,179 @@ +// 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.ui.browser + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.customtabs.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.providers.RawAuthorizationResult +import com.microsoft.identity.common.logging.Logger + +/** + * Encapsulates all AuthTab API interactions. + * + * AuthTab is a Chrome 137+ API (requires androidx.browser:browser:1.9.0+) that delivers + * authentication results via an in-process [ActivityResultLauncher] callback instead of + * intent-redirect chains. + * + * Each fragment instance should create its own [AuthTabManager]. Do NOT share instances + * across fragments. + * + * [registerLauncher] MUST be called on the main thread before the fragment reaches the + * CREATED state (i.e., in [androidx.fragment.app.Fragment.onCreate] or earlier). + */ +class AuthTabManager { + + companion object { + private val TAG = AuthTabManager::class.java.simpleName + + /** + * Returns true if AuthTab is supported on this device/browser combination. + * + * Thread-safe; may be called from any thread. + * + * @param context Android context used to query browser capabilities. + * @return true if [AuthTabIntent] can be used, false otherwise. + */ + @JvmStatic + fun isAuthTabSupported(context: Context): Boolean { + return try { + CustomTabsClient.isAuthTabSupported(context) + } catch (e: Exception) { + Logger.warn(TAG, "isAuthTabSupported check failed.") + false + } + } + } + + /** Holds the registered [ActivityResultLauncher]. Set by [registerLauncher]. */ + private var mLauncher: ActivityResultLauncher? = null + + /** + * Registers the [ActivityResultLauncher] that AuthTab will use to deliver results. + * + * Must be called on the main thread before the owning fragment reaches the CREATED state. + * + * @param caller The [ActivityResultCaller] (fragment or activity) to register with. + * @param onResult Callback invoked with the mapped [RawAuthorizationResult] when the + * AuthTab session concludes. + * @return this, for chaining. + */ + fun registerLauncher( + caller: ActivityResultCaller, + onResult: (RawAuthorizationResult) -> Unit + ): AuthTabManager { + val methodTag = "$TAG:registerLauncher" + Logger.verbose(methodTag, "Registering AuthTab activity result launcher.") + mLauncher = AuthTabIntent.registerActivityResultLauncher(caller) { authResult -> + onResult(mapAuthResultToRawResult(authResult)) + } + return this + } + + /** + * Launches an AuthTab session using a custom-scheme redirect URI. + * + * [registerLauncher] must be called before invoking this method. + * + * @param authUrl The authorization endpoint URL to load. + * @param redirectScheme The custom URL scheme that the server will redirect to upon completion. + * @throws IllegalStateException if [registerLauncher] has not been called. + */ + fun launch(authUrl: Uri, redirectScheme: String) { + val launcher = mLauncher + ?: throw IllegalStateException( + "AuthTab launcher is not registered. Call registerLauncher() before launch()." + ) + AuthTabIntent.Builder().build().launch(launcher, authUrl, redirectScheme) + } + + /** + * Launches an AuthTab session using an HTTPS redirect URI. + * + * [registerLauncher] must be called before invoking this method. + * + * @param authUrl The authorization endpoint URL to load. + * @param redirectHost The HTTPS host that the server will redirect to upon completion. + * @param redirectPath The HTTPS path that the server will redirect to upon completion. + * @throws IllegalStateException if [registerLauncher] has not been called. + */ + fun launchWithHttpsRedirect(authUrl: Uri, redirectHost: String, redirectPath: String) { + val launcher = mLauncher + ?: throw IllegalStateException( + "AuthTab launcher is not registered. Call registerLauncher() before launchWithHttpsRedirect()." + ) + AuthTabIntent.Builder().build().launchWithHttpsRedirect(launcher, authUrl, redirectHost, redirectPath) + } + + /** + * Maps an [AuthTabIntent.AuthResult] to a [RawAuthorizationResult]. + * + * @param authResult The raw result returned by the AuthTab activity. + * @return A [RawAuthorizationResult] representing the outcome. + */ + fun mapAuthResultToRawResult(authResult: AuthTabIntent.AuthResult): RawAuthorizationResult { + return when (authResult.resultCode) { + AuthTabIntent.RESULT_OK -> { + val uri = authResult.resultUri + if (uri != null) { + RawAuthorizationResult.fromRedirectUri(uri.toString()) + } else { + RawAuthorizationResult.fromException( + ClientException( + "authorization_result_not_found", + "AuthTab returned RESULT_OK but no redirect URI was received." + ) + ) + } + } + AuthTabIntent.RESULT_CANCELED -> + RawAuthorizationResult.fromResultCode(RawAuthorizationResult.ResultCode.CANCELLED) + AuthTabIntent.RESULT_VERIFICATION_FAILED -> + RawAuthorizationResult.fromException( + ClientException( + "auth_tab_verification_failed", + "AuthTab verification failed." + ) + ) + AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> + RawAuthorizationResult.fromException( + ClientException( + "auth_tab_verification_timed_out", + "AuthTab verification timed out." + ) + ) + else -> + RawAuthorizationResult.fromException( + ClientException( + "auth_tab_unknown_result", + "AuthTab returned an unknown result code: ${authResult.resultCode}" + ) + ) + } + } +} diff --git a/common/src/test/java/com/microsoft/identity/common/internal/ui/browser/AuthTabManagerTest.kt b/common/src/test/java/com/microsoft/identity/common/internal/ui/browser/AuthTabManagerTest.kt new file mode 100644 index 0000000000..fe85a83bd5 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/ui/browser/AuthTabManagerTest.kt @@ -0,0 +1,234 @@ +// 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.ui.browser + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.customtabs.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient +import com.microsoft.identity.common.java.providers.RawAuthorizationResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * Unit tests for [AuthTabManager]. + */ +@RunWith(RobolectricTestRunner::class) +class AuthTabManagerTest { + + private lateinit var context: Context + private lateinit var authTabManager: AuthTabManager + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + authTabManager = AuthTabManager() + } + + @After + fun tearDown() { + unmockkAll() + } + + // region isAuthTabSupported + + @Test + fun test_isAuthTabSupported_returnsTrue_whenClientReturnsTrue() { + mockkStatic(CustomTabsClient::class) + every { CustomTabsClient.isAuthTabSupported(any()) } returns true + + assertTrue(AuthTabManager.isAuthTabSupported(context)) + } + + @Test + fun test_isAuthTabSupported_returnsFalse_whenClientReturnsFalse() { + mockkStatic(CustomTabsClient::class) + every { CustomTabsClient.isAuthTabSupported(any()) } returns false + + assertFalse(AuthTabManager.isAuthTabSupported(context)) + } + + @Test + fun test_isAuthTabSupported_returnsFalse_onException() { + mockkStatic(CustomTabsClient::class) + every { CustomTabsClient.isAuthTabSupported(any()) } throws RuntimeException("Browser not available") + + assertFalse(AuthTabManager.isAuthTabSupported(context)) + } + + // endregion + + // region mapAuthResultToRawResult + + @Test + fun test_mapAuthResultToRawResult_RESULT_OK_withUri() { + val redirectUri = "msauth://com.example.app/callback?code=auth_code" + val mockAuthResult = mockk() + every { mockAuthResult.resultCode } returns AuthTabIntent.RESULT_OK + every { mockAuthResult.resultUri } returns Uri.parse(redirectUri) + + val result = authTabManager.mapAuthResultToRawResult(mockAuthResult) + + assertNotNull(result) + assertEquals(RawAuthorizationResult.ResultCode.COMPLETED, result.resultCode) + } + + @Test + fun test_mapAuthResultToRawResult_RESULT_OK_withNullUri() { + val mockAuthResult = mockk() + every { mockAuthResult.resultCode } returns AuthTabIntent.RESULT_OK + every { mockAuthResult.resultUri } returns null + + val result = authTabManager.mapAuthResultToRawResult(mockAuthResult) + + assertNotNull(result) + assertEquals(RawAuthorizationResult.ResultCode.NON_OAUTH_ERROR, result.resultCode) + assertNotNull(result.exception) + assertEquals("authorization_result_not_found", result.exception!!.errorCode) + } + + @Test + fun test_mapAuthResultToRawResult_RESULT_CANCELED() { + val mockAuthResult = mockk() + every { mockAuthResult.resultCode } returns AuthTabIntent.RESULT_CANCELED + + val result = authTabManager.mapAuthResultToRawResult(mockAuthResult) + + assertNotNull(result) + assertEquals(RawAuthorizationResult.ResultCode.CANCELLED, result.resultCode) + } + + @Test + fun test_mapAuthResultToRawResult_RESULT_VERIFICATION_FAILED() { + val mockAuthResult = mockk() + every { mockAuthResult.resultCode } returns AuthTabIntent.RESULT_VERIFICATION_FAILED + + val result = authTabManager.mapAuthResultToRawResult(mockAuthResult) + + assertNotNull(result) + assertEquals(RawAuthorizationResult.ResultCode.NON_OAUTH_ERROR, result.resultCode) + assertNotNull(result.exception) + assertEquals("auth_tab_verification_failed", result.exception!!.errorCode) + } + + @Test + fun test_mapAuthResultToRawResult_RESULT_VERIFICATION_TIMED_OUT() { + val mockAuthResult = mockk() + every { mockAuthResult.resultCode } returns AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT + + val result = authTabManager.mapAuthResultToRawResult(mockAuthResult) + + assertNotNull(result) + assertEquals(RawAuthorizationResult.ResultCode.NON_OAUTH_ERROR, result.resultCode) + assertNotNull(result.exception) + assertEquals("auth_tab_verification_timed_out", result.exception!!.errorCode) + } + + @Test + fun test_mapAuthResultToRawResult_unknownResultCode() { + val mockAuthResult = mockk() + every { mockAuthResult.resultCode } returns Int.MAX_VALUE + + val result = authTabManager.mapAuthResultToRawResult(mockAuthResult) + + assertNotNull(result) + assertEquals(RawAuthorizationResult.ResultCode.NON_OAUTH_ERROR, result.resultCode) + assertNotNull(result.exception) + assertEquals("auth_tab_unknown_result", result.exception!!.errorCode) + } + + // endregion + + // region launch / registerLauncher + + @Test(expected = IllegalStateException::class) + fun test_launch_throwsIfLauncherNotRegistered() { + authTabManager.launch(Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"), "msauth") + } + + @Test + fun test_registerLauncher_setsLauncherField() { + mockkStatic(AuthTabIntent::class) + val mockLauncher = mockk>() + val mockCaller = mockk() + every { AuthTabIntent.registerActivityResultLauncher(any(), any()) } returns mockLauncher + + val returned = authTabManager.registerLauncher(mockCaller) { /* no-op */ } + + // Verify method chaining returns the same instance + assertEquals(authTabManager, returned) + // Verify mLauncher is set: calling launch should not throw the "not registered" error. + // It may throw something else because the mocked launcher has no behaviour set up, + // but the guard condition (mLauncher == null) must be satisfied. + try { + authTabManager.launch(Uri.parse("https://login.microsoftonline.com"), "msauth") + } catch (e: IllegalStateException) { + assertFalse( + "Expected launcher to be set, but launch() still reported it as unregistered: ${e.message}", + e.message?.contains("not registered") == true + ) + } catch (_: Exception) { + // Any other exception is acceptable: the launcher is set, just the mock doesn't process calls. + } + } + + @Test + fun test_registerLauncher_callbackInvoked_withMappedResult() { + mockkStatic(AuthTabIntent::class) + val mockCaller = mockk() + val callbackSlot = slot>() + every { AuthTabIntent.registerActivityResultLauncher(any(), capture(callbackSlot)) } returns mockk() + + var receivedResult: RawAuthorizationResult? = null + authTabManager.registerLauncher(mockCaller) { result -> receivedResult = result } + + assertTrue("Callback should have been captured during registerLauncher", callbackSlot.isCaptured) + + // Simulate a RESULT_CANCELED AuthTab result being delivered via the launcher callback + val mockAuthResult = mockk() + every { mockAuthResult.resultCode } returns AuthTabIntent.RESULT_CANCELED + callbackSlot.captured.onActivityResult(mockAuthResult) + + assertNotNull(receivedResult) + assertEquals(RawAuthorizationResult.ResultCode.CANCELLED, receivedResult!!.resultCode) + } + + // endregion +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index 37deeff5ff..f438d3a850 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -211,7 +211,14 @@ public enum CommonFlight implements IFlightConfig { * Flight to enable increased thread pool size for silent requests. * When true, uses 12 threads. When false, uses legacy 5 threads. */ - USE_INCREASED_DEFAULT_SILENT_REQUEST_THREAD_POOL_SIZE("UseIncreasedSilentRequestThreadPoolSize", false); + USE_INCREASED_DEFAULT_SILENT_REQUEST_THREAD_POOL_SIZE("UseIncreasedSilentRequestThreadPoolSize", false), + + /** + * Flight to enable AuthTab for browser-based authentication flows. + * AuthTab is a Chrome 137+ API that delivers auth results via callback + * instead of intent redirects. Default: false (feature gated). + */ + ENABLE_AUTH_TAB("EnableAuthTab", false); private String key; private Object defaultValue; diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 740a606b89..121a432078 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -25,7 +25,7 @@ ext { androidxCoreVersion = "1.5.0" annotationVersion = "1.0.0" appCompatVersion = "1.1.0" - browserVersion = "1.7.0" + browserVersion = "1.9.0" constraintLayoutVersion = "1.1.3" dexmakerMockitoVersion = "2.19.0" espressoCoreVersion = "3.1.0"