Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;

import com.microsoft.identity.common.adal.internal.AuthenticationConstants;
import com.microsoft.identity.common.java.WarningType;
import com.microsoft.identity.common.java.configuration.LibraryConfiguration;
import com.microsoft.identity.common.java.exception.ClientException;
import com.microsoft.identity.common.java.logging.Logger;
import com.microsoft.identity.common.java.providers.oauth2.AuthorizationRequest;
import com.microsoft.identity.common.java.providers.oauth2.IAuthorizationStrategy;
import com.microsoft.identity.common.java.providers.oauth2.OAuth2Strategy;
import com.microsoft.identity.common.java.ui.AuthorizationAgent;

import java.lang.ref.WeakReference;

Expand All @@ -51,6 +55,8 @@ public abstract class AndroidAuthorizationStrategy<
GenericAuthorizationRequest extends AuthorizationRequest>
implements IAuthorizationStrategy<GenericOAuth2Strategy, GenericAuthorizationRequest> {

private static final String TAG = AndroidAuthorizationStrategy.class.getSimpleName();

private final WeakReference<Context> mReferencedApplicationContext;
private final WeakReference<Activity> mReferencedActivity;
private final WeakReference<Fragment> mReferencedFragment;
Expand All @@ -73,8 +79,44 @@ protected Context getApplicationContext() {
/**
* If fragment is provided, add AuthorizationFragment on top of that fragment.
* Otherwise, launch AuthorizationActivity.
* <p>
* For browser-based flows (non-WebView), validates that no other application is registered for
* the same custom URL scheme before starting the authorization UI. If another app is found, a
* {@link ClientException} with error code
* {@link com.microsoft.identity.common.java.exception.ErrorStrings#MULTIPLE_APPS_LISTENING_CUSTOM_URL_SCHEME}
* is thrown so that it propagates correctly through the command pipeline to MSAL's adapter layer.
* WebView flows are intentionally excluded from this check.
*/
protected void launchIntent(@NonNull Intent intent) throws ClientException {
// Perform the multiple-app URL scheme validation for non-WebView flows.
// This is done here (rather than in the factory) so that the ClientException always
// propagates through this method's declared throws clause, regardless of whether we're
// using the fragment-embedded or full-Activity path.
// A null authorizationAgent is treated the same as BROWSER (default browser flow).
final AuthorizationAgent authorizationAgent =
(AuthorizationAgent) intent.getSerializableExtra(
AuthenticationConstants.AuthorizationIntentKey.AUTHORIZATION_AGENT);
if (authorizationAgent != AuthorizationAgent.WEBVIEW) {
final Context appContext = getApplicationContext();
if (appContext == null) {
Logger.warn(TAG + ":launchIntent",
"Application context is null; skipping multiple-app URL scheme validation.");
} else {
final String redirectUri = intent.getStringExtra(
AuthenticationConstants.AuthorizationIntentKey.REDIRECT_URI);
if (redirectUri == null) {
Logger.warn(TAG + ":launchIntent",
"Redirect URI is null in the intent; skipping multiple-app URL scheme validation.");
} else {
BrowserRedirectValidator.validateNoMultipleAppsListening(
appContext,
redirectUri,
LibraryConfiguration.getInstance().isAuthorizationInCurrentTask()
);
}
}
}

final Fragment fragment = mReferencedFragment.get();

if (fragment != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import java.net.URISyntaxException
* Constructs intents and/or fragments for interactive requests based on library configuration and current request.
*/
object AuthorizationActivityFactory {

/**
* Return the correct authorization activity based on library configuration.
*
Expand Down Expand Up @@ -151,6 +152,11 @@ object AuthorizationActivityFactory {
* [BrowserAuthorizationFragment]
* [CurrentTaskBrowserAuthorizationFragment]
*
* Note: multiple-app URL scheme validation is NOT performed here. It is performed upstream
* in [AndroidAuthorizationStrategy.launchIntent], where a checked [ClientException] can
* propagate correctly through the command pipeline. Callers should ensure validation has
* already been performed before invoking this factory method for browser flows.
*
Comment thread
fadidurah marked this conversation as resolved.
* @param intent The intent used to start the authorization flow.
* @return returns an Fragment that's used as to authorize a token request.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// 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.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import com.microsoft.identity.common.java.exception.ClientException
import com.microsoft.identity.common.java.exception.ErrorStrings

/**
* Validates that no other application is registered to handle the same custom URL scheme
* used for the BrowserTabActivity redirect URI.
*/
object BrowserRedirectValidator {

private const val BROWSER_TAB_ACTIVITY_CLASS =
"com.microsoft.identity.client.BrowserTabActivity"
private const val CURRENT_TASK_BROWSER_TAB_ACTIVITY_CLASS =
"com.microsoft.identity.client.CurrentTaskBrowserTabActivity"

/**
* Verifies that no other application is listening on the custom URL scheme defined by
* [redirectUri]. If another application's activity is found that handles the same scheme,
* a [ClientException] with error code
* [ErrorStrings.MULTIPLE_APPS_LISTENING_CUSTOM_URL_SCHEME] is thrown.
*
* @param context The Android context used to query the PackageManager.
* @param redirectUri The redirect URI whose URL scheme will be checked.
* @param useCurrentTask Whether the flow uses [CurrentTaskBrowserTabActivity] (true) or
* [BrowserTabActivity] (false) as the expected activity class.
* @throws ClientException if another application is found listening on the same URL scheme.
*/
@JvmStatic
@Throws(ClientException::class)
fun validateNoMultipleAppsListening(
context: Context,
redirectUri: String,
useCurrentTask: Boolean
) {
val packageManager = context.packageManager ?: return

val intent = Intent(Intent.ACTION_VIEW).apply {
addCategory(Intent.CATEGORY_DEFAULT)
addCategory(Intent.CATEGORY_BROWSABLE)
data = Uri.parse(redirectUri)
}

val resolvedActivities = packageManager.queryIntentActivities(
intent,
PackageManager.GET_RESOLVED_FILTER
)

val expectedActivityClassName = if (useCurrentTask) {
CURRENT_TASK_BROWSER_TAB_ACTIVITY_CLASS
} else {
BROWSER_TAB_ACTIVITY_CLASS
}

for (resolveInfo in resolvedActivities) {
val activityInfo = resolveInfo.activityInfo ?: continue
// If this is our own registered BrowserTabActivity, it is expected — skip it.
if (activityInfo.name == expectedActivityClassName &&
activityInfo.packageName == context.packageName
) {
continue
}
// Another application's activity is also listening on this URL scheme.
val otherPackage = activityInfo.packageName
throw ClientException(
Comment thread
fadidurah marked this conversation as resolved.
ErrorStrings.MULTIPLE_APPS_LISTENING_CUSTOM_URL_SCHEME,
"More than one app is listening for the URL scheme defined for BrowserTabActivity " +
"in the AndroidManifest. The package name of this other app is: $otherPackage"
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// 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.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.AuthorizationIntentKey.AUTHORIZATION_AGENT
import com.microsoft.identity.common.adal.internal.AuthenticationConstants.AuthorizationIntentKey.REDIRECT_URI
import com.microsoft.identity.common.java.exception.ClientException
import com.microsoft.identity.common.java.exception.ErrorStrings
import com.microsoft.identity.common.java.providers.oauth2.AuthorizationRequest
import com.microsoft.identity.common.java.providers.oauth2.OAuth2Strategy
import com.microsoft.identity.common.java.ui.AuthorizationAgent
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf

/**
* Tests that [AndroidAuthorizationStrategy.launchIntent] performs URL scheme conflict validation
* for non-WebView flows and skips it for WebView flows.
*/
@RunWith(RobolectricTestRunner::class)
class AndroidAuthorizationStrategyValidationTest {

companion object {
private const val COMPETING_PACKAGE = "com.example.otherapp"
private const val COMPETING_ACTIVITY = "com.example.otherapp.SomeActivity"
private const val REDIRECT_URI_VALUE = "msauth://org.robolectric.default/redirect"
}

private lateinit var activity: Activity
private lateinit var context: Context

@Before
fun setUp() {
activity = Robolectric.buildActivity(Activity::class.java).create().get()
context = RuntimeEnvironment.getApplication()
}

/**
* Minimal concrete subclass of [AndroidAuthorizationStrategy] that exposes [launchIntent]
* publicly for testing (and overrides [requestAuthorization] as required by the interface).
*/
@Suppress("UNCHECKED_CAST")
private inner class TestAndroidAuthorizationStrategy(
appContext: Context,
act: Activity
) : AndroidAuthorizationStrategy<
OAuth2Strategy<*, *, *, *, *, *, *, *, *, *, *, *, *>,
AuthorizationRequest<*>>(appContext, act, null) {

override fun requestAuthorization(
authorizationRequest: AuthorizationRequest<*>,
oAuth2Strategy: OAuth2Strategy<*, *, *, *, *, *, *, *, *, *, *, *, *>
) = throw UnsupportedOperationException("not used in these tests")

override fun completeAuthorization(requestCode: Int, data: com.microsoft.identity.common.java.providers.RawAuthorizationResult) =
Unit

// Expose the protected method publicly for tests.
@Throws(ClientException::class)
fun testLaunchIntent(intent: Intent) = launchIntent(intent)
}

// ──────────────────────────────────────────────────────────────────────────────────
// Helper
// ──────────────────────────────────────────────────────────────────────────────────

private fun registerCompetingApp() {
val resolveInfo = ResolveInfo().apply {
activityInfo = ActivityInfo().apply {
packageName = COMPETING_PACKAGE
name = COMPETING_ACTIVITY
}
}
val competingRedirectIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(REDIRECT_URI_VALUE)
addCategory(Intent.CATEGORY_DEFAULT)
addCategory(Intent.CATEGORY_BROWSABLE)
}
shadowOf(context.packageManager).addResolveInfoForIntent(
competingRedirectIntent, resolveInfo
)
}

private fun buildIntent(agent: AuthorizationAgent?, redirectUri: String? = REDIRECT_URI_VALUE): Intent {
val intent = Intent()
if (agent != null) intent.putExtra(AUTHORIZATION_AGENT, agent)
if (redirectUri != null) intent.putExtra(REDIRECT_URI, redirectUri)
return intent
}

// ──────────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────────

/**
* Browser flow with a competing app registered → [ClientException] must be thrown from
* [launchIntent] so it propagates through the command pipeline.
*/
@Test
fun `launchIntent browser flow throws ClientException when competing app is registered`() {
registerCompetingApp()
val strategy = TestAndroidAuthorizationStrategy(context, activity)

try {
strategy.testLaunchIntent(buildIntent(AuthorizationAgent.BROWSER))
fail("Expected ClientException")
} catch (e: ClientException) {
assertEquals(ErrorStrings.MULTIPLE_APPS_LISTENING_CUSTOM_URL_SCHEME, e.errorCode)
}
}

/**
* WebView flow with a competing app registered → no exception (WebView is excluded from
* URL scheme conflict validation).
*/
@Test
fun `launchIntent webview flow does not throw even when competing app is registered`() {
registerCompetingApp()
val strategy = TestAndroidAuthorizationStrategy(context, activity)

// Should not throw — activity.startActivity() will simply be called.
strategy.testLaunchIntent(buildIntent(AuthorizationAgent.WEBVIEW))
}

/**
* Null authorizationAgent is treated as a browser flow → validation runs, and a conflict
* causes [ClientException].
*/
@Test
fun `launchIntent null authorizationAgent treated as browser flow and throws on conflict`() {
registerCompetingApp()
val strategy = TestAndroidAuthorizationStrategy(context, activity)

try {
strategy.testLaunchIntent(buildIntent(agent = null))
fail("Expected ClientException")
} catch (e: ClientException) {
assertEquals(ErrorStrings.MULTIPLE_APPS_LISTENING_CUSTOM_URL_SCHEME, e.errorCode)
}
}

/**
* Browser flow with no competing apps → no exception.
*/
@Test
fun `launchIntent browser flow passes when no competing app is registered`() {
val strategy = TestAndroidAuthorizationStrategy(context, activity)

// No competing app registered — should not throw (startActivity completes normally).
strategy.testLaunchIntent(buildIntent(AuthorizationAgent.BROWSER))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,5 @@ public void testGetAuthorizationFragmentFromStartIntentWithSilentFlowNonWebView(
// Verify it creates BrowserAuthorizationFragment even with silent flow when not WebView
assertEquals(BrowserAuthorizationFragment.class, fragment.getClass());
}

}
Loading
Loading