fix auth flows

This commit is contained in:
Fabian Freund
2026-08-09 04:31:22 +02:00
parent d7055a6f44
commit 599fb997f1
21 changed files with 349 additions and 58 deletions
@@ -110,6 +110,7 @@ AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) {
key: _toNativeRule(value),
},
marketplaceFallbackEnabled: settings.appLinkMarketplaceFallback,
authExceptionsEnabled: settings.appLinkAuthExceptionsEnabled,
protectGeneralContext: protection.protectGeneralContext,
protectedContextIds: protection.protectedContextIds.toList(),
strictContextIds: protection.strictContextIds.toList(),
@@ -120,7 +120,7 @@ final class AppLinkPolicySnapshotProvider
}
String _$appLinkPolicySnapshotHash() =>
r'6fe2dca118d7162561fc7f6280d1a0411d50972a';
r'4d456a3cbf091e95ac35d913e8fa20d5f25978ab';
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
/// native profile-scoped store (§2.8), the sole policy source consulted by the
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'797520166026d1f0272cf713aa1c25ecf129c236';
String _$tabRepositoryHash() => r'5824be32664bac24623cf8863f68b7afa254d025';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -1397,8 +1397,8 @@ final class VisibleTabListItemsFamily extends $Family
/// switcher lays them out — the unassigned bucket first, then containers by
/// pinned/`order_key`. Stepping off the end of one container therefore
/// continues into the next, and selecting that tab moves the selected container
/// along with it. Containers without tabs are skipped so their tree query never
/// runs.
/// along with it. Named containers holding no tabs are skipped so their tree
/// query never runs.
///
/// "Previous" is a step towards the top of that order and "next" a step
/// towards its end, so direction follows `tabListDirection` (baked into the
@@ -1443,8 +1443,8 @@ final sequentialTabNavigationOrderProvider =
/// switcher lays them out — the unassigned bucket first, then containers by
/// pinned/`order_key`. Stepping off the end of one container therefore
/// continues into the next, and selecting that tab moves the selected container
/// along with it. Containers without tabs are skipped so their tree query never
/// runs.
/// along with it. Named containers holding no tabs are skipped so their tree
/// query never runs.
///
/// "Previous" is a step towards the top of that order and "next" a step
/// towards its end, so direction follows `tabListDirection` (baked into the
@@ -1493,8 +1493,8 @@ final class SequentialTabNavigationOrderProvider
/// switcher lays them out — the unassigned bucket first, then containers by
/// pinned/`order_key`. Stepping off the end of one container therefore
/// continues into the next, and selecting that tab moves the selected container
/// along with it. Containers without tabs are skipped so their tree query never
/// runs.
/// along with it. Named containers holding no tabs are skipped so their tree
/// query never runs.
///
/// "Previous" is a step towards the top of that order and "next" a step
/// towards its end, so direction follows `tabListDirection` (baked into the
@@ -817,6 +817,11 @@ class _AppLinksModeSection extends HookConsumerWidget {
(s) => s.appLinkMarketplaceFallback,
),
);
final authExceptionsEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.appLinkAuthExceptionsEnabled,
),
);
final rules = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.appLinkRules),
);
@@ -887,6 +892,23 @@ class _AppLinksModeSection extends HookConsumerWidget {
);
},
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Allow login app callbacks'),
subtitle: const Text(
'Let apps that opened a Custom Tab receive their login callback, '
'even when links are set to never open in apps',
),
value: authExceptionsEnabled,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(current) =>
current.copyWith.appLinkAuthExceptionsEnabled(value),
);
},
),
_AppLinkRulesSubsection(rules: rules),
],
),
@@ -252,6 +252,12 @@ class GeneralSettings with FastEquatable {
/// Defaults to false — the wrong default for a de-Googled browser.
final bool appLinkMarketplaceFallback;
/// Whether app-link "never" rules allow a same-caller Custom Tab / ActionView
/// login callback to return to the app that opened the browser. Defaults to
/// true to keep OAuth-style sign-in flows working while normal app links still
/// obey [appLinksMode].
final bool appLinkAuthExceptionsEnabled;
/// Whether the local search index (`history` table populated via tab→
/// history triggers) is active. When false, the SQL trigger guard returns
/// without writing; existing rows stay until the user clears them.
@@ -359,6 +365,7 @@ class GeneralSettings with FastEquatable {
required this.appLinkRules,
required this.appLinkContextOverrides,
required this.appLinkMarketplaceFallback,
required this.appLinkAuthExceptionsEnabled,
required this.enableLocalSearchIndex,
required this.indexPrivateTabs,
required this.acceptSuggestionOnSubmit,
@@ -436,6 +443,7 @@ class GeneralSettings with FastEquatable {
Map<String, PersistedAppLinkRule>? appLinkRules,
Map<String, ContextAppLinkPolicy>? appLinkContextOverrides,
bool? appLinkMarketplaceFallback,
bool? appLinkAuthExceptionsEnabled,
bool? enableLocalSearchIndex,
bool? indexPrivateTabs,
bool? acceptSuggestionOnSubmit,
@@ -524,6 +532,7 @@ class GeneralSettings with FastEquatable {
appLinkRules = appLinkRules ?? const {},
appLinkContextOverrides = appLinkContextOverrides ?? const {},
appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false,
appLinkAuthExceptionsEnabled = appLinkAuthExceptionsEnabled ?? true,
enableLocalSearchIndex = enableLocalSearchIndex ?? true,
indexPrivateTabs = indexPrivateTabs ?? false,
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
@@ -693,6 +702,7 @@ class GeneralSettings with FastEquatable {
appLinkRules,
appLinkContextOverrides,
appLinkMarketplaceFallback,
appLinkAuthExceptionsEnabled,
enableLocalSearchIndex,
indexPrivateTabs,
acceptSuggestionOnSubmit,
@@ -161,6 +161,10 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback);
GeneralSettings appLinkAuthExceptionsEnabled(
bool appLinkAuthExceptionsEnabled,
);
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
@@ -253,6 +257,7 @@ abstract class _$GeneralSettingsCWProxy {
Map<String, PersistedAppLinkRule> appLinkRules,
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
bool appLinkMarketplaceFallback,
bool appLinkAuthExceptionsEnabled,
bool enableLocalSearchIndex,
bool indexPrivateTabs,
bool acceptSuggestionOnSubmit,
@@ -556,6 +561,11 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) =>
call(appLinkMarketplaceFallback: appLinkMarketplaceFallback);
@override
GeneralSettings appLinkAuthExceptionsEnabled(
bool appLinkAuthExceptionsEnabled,
) => call(appLinkAuthExceptionsEnabled: appLinkAuthExceptionsEnabled);
@override
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
call(enableLocalSearchIndex: enableLocalSearchIndex);
@@ -665,6 +675,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? appLinkRules = const $CopyWithPlaceholder(),
Object? appLinkContextOverrides = const $CopyWithPlaceholder(),
Object? appLinkMarketplaceFallback = const $CopyWithPlaceholder(),
Object? appLinkAuthExceptionsEnabled = const $CopyWithPlaceholder(),
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
Object? indexPrivateTabs = const $CopyWithPlaceholder(),
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
@@ -1061,6 +1072,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.appLinkMarketplaceFallback
// ignore: cast_nullable_to_non_nullable
: appLinkMarketplaceFallback as bool,
appLinkAuthExceptionsEnabled:
appLinkAuthExceptionsEnabled == const $CopyWithPlaceholder() ||
appLinkAuthExceptionsEnabled == null
? _value.appLinkAuthExceptionsEnabled
// ignore: cast_nullable_to_non_nullable
: appLinkAuthExceptionsEnabled as bool,
enableLocalSearchIndex:
enableLocalSearchIndex == const $CopyWithPlaceholder() ||
enableLocalSearchIndex == null
@@ -1257,6 +1274,7 @@ GeneralSettings _$GeneralSettingsFromJson(
json['appLinkContextOverrides'] as Map<String, dynamic>?,
),
appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?,
appLinkAuthExceptionsEnabled: json['appLinkAuthExceptionsEnabled'] as bool?,
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
indexPrivateTabs: json['indexPrivateTabs'] as bool?,
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
@@ -1356,6 +1374,7 @@ Map<String, dynamic> _$GeneralSettingsToJson(
(k, e) => MapEntry(k, e.toJson()),
),
'appLinkMarketplaceFallback': instance.appLinkMarketplaceFallback,
'appLinkAuthExceptionsEnabled': instance.appLinkAuthExceptionsEnabled,
'enableLocalSearchIndex': instance.enableLocalSearchIndex,
'indexPrivateTabs': instance.indexPrivateTabs,
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
@@ -109,6 +109,7 @@ const generalSettingColumnTypes = <String, DriftSqlType>{
'customTabsEnabled': DriftSqlType.bool,
'appLinksMode': DriftSqlType.string,
'appLinkMarketplaceFallback': DriftSqlType.bool,
'appLinkAuthExceptionsEnabled': DriftSqlType.bool,
'enableLocalSearchIndex': DriftSqlType.bool,
'indexPrivateTabs': DriftSqlType.bool,
'acceptSuggestionOnSubmit': DriftSqlType.bool,
@@ -19,6 +19,10 @@ class AuthIntentReceiverActivity : Activity() {
val sourceIntent = intent?.let { Intent(it) } ?: Intent()
// Stamp the caller before CustomTabIntentProcessor builds the session source, so the
// app-links authentication carve-out can recognise a sign-in callback for this tab.
addExternalCallerInformation(sourceIntent)
if (GlobalComponents.components == null && !GlobalComponents.ensureExternalComponents(applicationContext)) {
finish()
return
@@ -0,0 +1,83 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.activities
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import mozilla.components.support.utils.EXTRA_ACTIVITY_REFERRER_CATEGORY
import mozilla.components.support.utils.EXTRA_ACTIVITY_REFERRER_PACKAGE
import mozilla.components.support.utils.ext.packageManagerCompatHelper
/**
* Records which app sent [intent] so the session created from it carries a `caller`.
*
* AC's `CustomTabIntentProcessor` reads the caller through `SafeIntent.externalPackage()`, which
* only looks at the [EXTRA_ACTIVITY_REFERRER_PACKAGE] extra — nothing populates it for us, so a
* receiver has to stamp it before handing the intent to the processors or every custom tab ends up
* with `Source.External.CustomTab(null)`. Mirrors Fenix's `IntentReceiverActivity`
* `addReferrerInformation`.
*
* The app-links authentication carve-out
* ([eu.weblibre.flutter_mozilla_components.applinks.WebLibreAppLinksInterceptor]) is the consumer:
* it lets a sign-in callback return to the app that opened the tab.
*/
fun Activity.addExternalCallerInformation(intent: Intent) {
val caller = resolveExternalCallerPackage(intent) ?: return
intent.putExtra(EXTRA_ACTIVITY_REFERRER_PACKAGE, caller)
// ApplicationInfo.category is API 26+; this module builds against minSdk 24.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val category = packageManagerCompatHelper.getApplicationInfoCompat(caller, 0).category
intent.putExtra(EXTRA_ACTIVITY_REFERRER_CATEGORY, category)
} catch (e: PackageManager.NameNotFoundException) {
// The caller is not resolvable — the package id alone is enough for our purposes.
}
}
}
/**
* Best-effort identity of the app that sent [intent].
*
* [Activity.getCallingPackage] is supplied by the system and cannot be forged, so it wins when
* present (only set for `startActivityForResult` callers). The referrer chain below is
* caller-controlled and therefore spoofable — an app can claim to be another package. Consumers
* must not grant anything on it that the caller could not already do itself.
*/
@Suppress("TooGenericExceptionCaught")
fun Activity.resolveExternalCallerPackage(intent: Intent): String? {
callingPackage?.let { return it }
// Android can throw when the referrer carries data it cannot deserialise.
val activityReferrer = try {
referrer
} catch (e: RuntimeException) {
null
}
activityReferrer?.let { uri ->
if (uri.scheme == ANDROID_APP_SCHEME) {
uri.host?.let { return it }
}
}
@Suppress("DEPRECATION")
val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER)
if (referrerUri?.scheme == ANDROID_APP_SCHEME) {
referrerUri.host?.let { return it }
}
intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name ->
Uri.parse(name).takeIf { it.scheme == ANDROID_APP_SCHEME }?.host?.let { return it }
}
return null
}
private const val ANDROID_APP_SCHEME = "android-app"
@@ -93,7 +93,7 @@ class IntentReceiverActivity : Activity() {
}
}
val caller = resolveCallerPackage(intent) ?: return false
val caller = resolveExternalCallerPackage(intent) ?: return false
if (caller == packageName) return false
if (!IntentGatekeeperPreferences.isBlocked(applicationContext, caller)) return false
@@ -102,32 +102,17 @@ class IntentReceiverActivity : Activity() {
return true
}
private fun resolveCallerPackage(intent: Intent): String? {
referrer?.let { uri ->
if (uri.scheme == "android-app") {
uri.host?.let { return it }
}
}
@Suppress("DEPRECATION")
val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER)
if (referrerUri?.scheme == "android-app") {
referrerUri.host?.let { return it }
}
intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name ->
Uri.parse(name).takeIf { it.scheme == "android-app" }?.host?.let { return it }
}
return callingPackage
}
override fun onDestroy() {
super.onDestroy()
coroutineScope.cancel()
}
private fun processIntent(intent: Intent) {
// Must run before any intent processor: CustomTabIntentProcessor reads the caller off the
// intent when it builds the session source, and the app-links authentication carve-out
// needs that caller to recognise a sign-in callback.
addExternalCallerInformation(intent)
if (GlobalComponents.components == null) {
if (GlobalComponents.ensureExternalComponents(applicationContext)) {
routeIntent(intent)
@@ -57,6 +57,7 @@ data class AppLinkPolicy(
val protectedContextIds: Set<String>,
val strictContextIds: Set<String>,
val protectedTargetPatterns: List<ProtectedTargetPattern>,
val authExceptionsEnabled: Boolean,
/**
* Per-container overrides keyed by contextId; only isolated containers appear. A navigation whose
* source contextId is a key uses the entry's mode + rules instead of the global ones (replace).
@@ -72,6 +73,7 @@ data class AppLinkPolicy(
protectedContextIds = emptySet(),
strictContextIds = emptySet(),
protectedTargetPatterns = emptyList(),
authExceptionsEnabled = true,
contextOverrides = emptyMap(),
)
}
@@ -16,11 +16,15 @@ import mozilla.components.support.base.log.logger.Logger
* behaviour so the app opens in its own recents entry.
* - [AUTOMATIC]: global-`always` or a remembered `alwaysOpen` rule — `NEW_TASK`, subject to the
* 2 s same-package cooldown loop-breaker (§2.4).
* - [AUTHENTICATION]: same-caller Custom Tab / ActionView callback — `NEW_TASK | CLEAR_TOP` so the
* originating app can resume its existing task. Not a user gesture, so it takes the same 2 s
* cooldown as [AUTOMATIC] (AC applies its loop-breaker to authentication flows too).
* - [MARKETPLACE]: install-app fallback — `NEW_TASK | CLEAR_TASK`.
*/
enum class AppLinkLaunchMode {
MANUAL,
AUTOMATIC,
AUTHENTICATION,
MARKETPLACE,
}
@@ -34,9 +38,9 @@ enum class AppLinkLaunchResult {
/**
* Launches external apps. Every launch re-resolves immediately first (no cache) and verifies the
* expected package before `startActivity` (§2.7). Automatic launches honour a 2 s same-package
* cooldown to break app→browser→app ping-pong loops (§2.4); manual and prompt-resolved opens are
* user gestures that bypass the check but still record it.
* expected package before `startActivity` (§2.7). Automatic and authentication launches honour a 2 s
* same-package cooldown to break app→browser→app ping-pong loops (§2.4); manual and prompt-resolved
* opens are user gestures that bypass the check but still record it.
*/
class AppLinkLauncher(
private val resolver: ExternalAppResolver,
@@ -81,7 +85,7 @@ class AppLinkLauncher(
else -> resolved.packageName
}
if (mode == AppLinkLaunchMode.AUTOMATIC) {
if (mode == AppLinkLaunchMode.AUTOMATIC || mode == AppLinkLaunchMode.AUTHENTICATION) {
val (lastPackage, lastTs) = lastLaunch
if (lastPackage != null && lastPackage == targetPackage &&
clock.elapsedRealtime() < lastTs + cooldownMs
@@ -117,6 +121,8 @@ class AppLinkLauncher(
Intent.FLAG_ACTIVITY_NEW_TASK
AppLinkLaunchMode.AUTOMATIC ->
Intent.FLAG_ACTIVITY_NEW_TASK
AppLinkLaunchMode.AUTHENTICATION ->
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
AppLinkLaunchMode.MARKETPLACE ->
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
@@ -28,6 +28,7 @@ fun AppLinkPolicySnapshot.toAppLinkPolicy(): AppLinkPolicy {
port = pattern.port?.toInt(),
)
},
authExceptionsEnabled = authExceptionsEnabled,
contextOverrides = contextOverrides.mapValues { (_, override) ->
ContextAppLinkPolicy(
globalMode = override.mode.toAppLinkMode(),
@@ -93,6 +93,7 @@ class AppLinkPolicyStore internal constructor(
root.put(FIELD_MIGRATED, migrated)
root.put(FIELD_GLOBAL_MODE, policy.globalMode.name)
root.put(FIELD_MARKETPLACE, policy.marketplaceFallbackEnabled)
root.put(FIELD_AUTH_EXCEPTIONS, policy.authExceptionsEnabled)
root.put(FIELD_PROTECT_GENERAL, policy.protectGeneralContext)
root.put(FIELD_PROTECTED_CONTEXTS, JSONArray(policy.protectedContextIds.toList()))
root.put(FIELD_STRICT_CONTEXTS, JSONArray(policy.strictContextIds.toList()))
@@ -177,6 +178,7 @@ class AppLinkPolicyStore internal constructor(
globalMode = AppLinkMode.valueOf(root.getString(FIELD_GLOBAL_MODE)),
rules = rules,
marketplaceFallbackEnabled = root.optBoolean(FIELD_MARKETPLACE, false),
authExceptionsEnabled = root.optBoolean(FIELD_AUTH_EXCEPTIONS, true),
protectGeneralContext = root.optBoolean(FIELD_PROTECT_GENERAL, false),
protectedContextIds = root.optJSONArray(FIELD_PROTECTED_CONTEXTS).toStringSet(),
strictContextIds = root.optJSONArray(FIELD_STRICT_CONTEXTS).toStringSet(),
@@ -218,6 +220,7 @@ class AppLinkPolicyStore internal constructor(
private const val FIELD_MIGRATED = "migrated"
private const val FIELD_GLOBAL_MODE = "globalMode"
private const val FIELD_MARKETPLACE = "marketplaceFallbackEnabled"
private const val FIELD_AUTH_EXCEPTIONS = "authExceptionsEnabled"
private const val FIELD_PROTECT_GENERAL = "protectGeneralContext"
private const val FIELD_PROTECTED_CONTEXTS = "protectedContextIds"
private const val FIELD_STRICT_CONTEXTS = "strictContextIds"
@@ -58,9 +58,27 @@ class WebLibreAppLinksInterceptor(
val uriScheme = runCatching { uri.toUri().scheme }.getOrNull()
val engineSupportsScheme = AppLinkSchemes.isEngineSupported(uriScheme)
val session = components.core.store.state.findTabOrCustomTab(engineSession)
val policy = AppLinkPolicyStores.forProfile(components.profileApplicationContext).policy
// A tab an external app opened for us (Custom Tab / ActionView) may be hosting a sign-in
// round trip. Gated on the policy so turning the carve-out off restores the plain §2.4
// eligibility rules rather than only skipping the launch below.
val authExceptionsAllowed = policy.authExceptionsEnabled && isPossibleAuthentication(session)
// Step 2 — navigation eligibility. Any hit lets the engine proceed normally.
if (!isEligible(uri, lastUri, uriScheme, engineSupportsScheme, hasUserGesture, isRedirect, isDirectNavigation, isSubframeRequest)) {
if (!isEligible(
uri,
lastUri,
uriScheme,
engineSupportsScheme,
hasUserGesture,
isRedirect,
isDirectNavigation,
isSubframeRequest,
authExceptionsAllowed,
)
) {
return null
}
@@ -74,10 +92,6 @@ class WebLibreAppLinksInterceptor(
val resolved = runtime.resolver.resolve(uri, includeHttpAppLinks = true, useCache = true)
val policy = AppLinkPolicyStores.forProfile(components.profileApplicationContext).policy
val session = components.core.store.state.findTabOrCustomTab(engineSession)
// Container isolation (replace semantics): a container with "isolated app link settings"
// enabled contributes an entry keyed by its contextId. When the source tab's contextId has
// one, its mode + rules fully replace the global ones for this navigation.
@@ -85,12 +99,47 @@ class WebLibreAppLinksInterceptor(
val effectiveMode = override?.globalMode ?: policy.globalMode
val effectiveRules = override?.rules ?: policy.rules
val isProtectedNavigation = isProtected(policy, session, uri)
val isPrivateNavigation = session?.content?.private ?: false
val isWalletNavigation = AppLinkSchemes.isWallet(resolved.originalScheme) ||
AppLinkSchemes.isWallet(resolved.intentDataScheme)
// An ambiguous resolution is never treated as a callback: with several handlers we cannot
// show the caller *is* the target, and the launch would raise a chooser rather than return
// to the app. AC declines here too — its package comes from the bound component, which is
// only set for an unambiguous handler.
val authTargetPackage = if (resolved.isAmbiguous) null else resolved.packageName
// §2.4 authentication carve-out (AC parity): a tab opened *by* the app the navigation
// targets is a sign-in round trip rather than a general app link, so it returns to its
// caller even under `never`. The forced-prompt contexts still win — a protected container,
// a private tab or a wallet scheme must not leak out silently, so those fall through to the
// classifier, which prompts for them regardless of mode (§2.4 step 4).
if (authExceptionsAllowed &&
isAuthenticationCallback(session, authTargetPackage) &&
!isProtectedNavigation && !isPrivateNavigation && !isWalletNavigation
) {
val result = runtime.launcher.launch(
uri,
AppLinkLaunchMode.AUTHENTICATION,
expectedPackage = authTargetPackage,
)
logger.info(
"auth app-link callback uri=$uri tab=${session?.id} " +
"caller=${callerPackage(session)} package=$authTargetPackage -> $result",
)
return if (result == AppLinkLaunchResult.LAUNCHED) {
RequestInterceptor.InterceptionResponse.Deny
} else {
safeNonLaunchResponse(pendingStore, resolved)
}
}
val input = ClassifierInput(
resolved = resolved,
isProtected = isProtected(policy, session, uri),
isPrivate = session?.content?.private ?: false,
isWallet = AppLinkSchemes.isWallet(resolved.originalScheme) ||
AppLinkSchemes.isWallet(resolved.intentDataScheme),
isProtected = isProtectedNavigation,
isPrivate = isPrivateNavigation,
isWallet = isWalletNavigation,
missingSession = session == null,
suppressionHit = session != null &&
pendingStore.isSuppressed(session.id, targetFingerprint(uri, resolved)),
@@ -262,6 +311,38 @@ class WebLibreAppLinksInterceptor(
}
}
/**
* True when [targetPackage] is the very app that opened this tab — the shape of a sign-in
* callback. [targetPackage] must be an unambiguous resolution; pass `null` otherwise.
*/
private fun isAuthenticationCallback(session: SessionState?, targetPackage: String?): Boolean {
if (targetPackage.isNullOrEmpty()) return false
return callerPackage(session) == targetPackage
}
/**
* The package that launched this session, as recorded by
* [eu.weblibre.flutter_mozilla_components.activities.addExternalCallerInformation]. Note the
* underlying referrer is caller-supplied and can be spoofed, so this may only gate actions the
* caller could already perform itself (here: launching its own intent).
*/
private fun callerPackage(session: SessionState?): String? {
return when (val source = session?.source) {
is SessionState.Source.External.CustomTab -> source.caller?.packageId
is SessionState.Source.External.ActionView -> source.caller?.packageId
else -> null
}
}
private fun isPossibleAuthentication(session: SessionState?): Boolean {
return when (session?.source) {
is SessionState.Source.External.CustomTab,
is SessionState.Source.External.ActionView,
-> true
else -> false
}
}
// ---- Eligibility (§2.4 step 2) ----
private fun isEligible(
@@ -273,6 +354,7 @@ class WebLibreAppLinksInterceptor(
isRedirect: Boolean,
isDirectNavigation: Boolean,
isSubframeRequest: Boolean,
authExceptionsAllowed: Boolean,
): Boolean {
if (uriScheme == null) return false
// A subframe request not triggered by the user and outside the allowlist stays in-page.
@@ -282,8 +364,10 @@ class WebLibreAppLinksInterceptor(
val isIntentionalNavigation = hasUserGesture || isAllowedRedirect || isDirectNavigation
// Unintentional engine-supported navigation continues in the browser.
if (engineSupportsScheme && !isIntentionalNavigation) return false
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping).
if (engineSupportsScheme && isSameDomain(lastUri, uri)) return false
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping),
// unless this tab could be hosting an authentication round trip whose callback is an http
// app link on the same site.
if (engineSupportsScheme && isSameDomain(lastUri, uri) && !authExceptionsAllowed) return false
// Always-denied schemes never resolve or launch externally.
if (AppLinkSchemes.isAlwaysDenied(uriScheme)) return false
return true
@@ -5830,6 +5830,11 @@ data class AppLinkPolicySnapshot (
/** Remembered rules keyed by canonical scope. */
val rules: Map<String, NativeAppLinkRule>,
val marketplaceFallbackEnabled: Boolean,
/**
* Allows same-caller Custom Tab / ActionView authentication callbacks to
* return to their app even when the general app-link mode is `never`.
*/
val authExceptionsEnabled: Boolean,
/** Regular / no-contextId tabs are proxied via the `general` scope. */
val protectGeneralContext: Boolean,
/** contextIds that resolve to a proxy after inherit/bypass/alias. */
@@ -5850,12 +5855,13 @@ data class AppLinkPolicySnapshot (
val globalMode = pigeonVar_list[0] as AppLinksMode
val rules = pigeonVar_list[1] as Map<String, NativeAppLinkRule>
val marketplaceFallbackEnabled = pigeonVar_list[2] as Boolean
val protectGeneralContext = pigeonVar_list[3] as Boolean
val protectedContextIds = pigeonVar_list[4] as List<String>
val strictContextIds = pigeonVar_list[5] as List<String>
val protectedTargetPatterns = pigeonVar_list[6] as List<ProtectedTargetPattern>
val contextOverrides = pigeonVar_list[7] as Map<String, NativeContextAppLinkPolicy>
return AppLinkPolicySnapshot(globalMode, rules, marketplaceFallbackEnabled, protectGeneralContext, protectedContextIds, strictContextIds, protectedTargetPatterns, contextOverrides)
val authExceptionsEnabled = pigeonVar_list[3] as Boolean
val protectGeneralContext = pigeonVar_list[4] as Boolean
val protectedContextIds = pigeonVar_list[5] as List<String>
val strictContextIds = pigeonVar_list[6] as List<String>
val protectedTargetPatterns = pigeonVar_list[7] as List<ProtectedTargetPattern>
val contextOverrides = pigeonVar_list[8] as Map<String, NativeContextAppLinkPolicy>
return AppLinkPolicySnapshot(globalMode, rules, marketplaceFallbackEnabled, authExceptionsEnabled, protectGeneralContext, protectedContextIds, strictContextIds, protectedTargetPatterns, contextOverrides)
}
}
fun toList(): List<Any?> {
@@ -5863,6 +5869,7 @@ data class AppLinkPolicySnapshot (
globalMode,
rules,
marketplaceFallbackEnabled,
authExceptionsEnabled,
protectGeneralContext,
protectedContextIds,
strictContextIds,
@@ -5878,7 +5885,7 @@ data class AppLinkPolicySnapshot (
return true
}
val other = other as AppLinkPolicySnapshot
return GeckoPigeonUtils.deepEquals(this.globalMode, other.globalMode) && GeckoPigeonUtils.deepEquals(this.rules, other.rules) && GeckoPigeonUtils.deepEquals(this.marketplaceFallbackEnabled, other.marketplaceFallbackEnabled) && GeckoPigeonUtils.deepEquals(this.protectGeneralContext, other.protectGeneralContext) && GeckoPigeonUtils.deepEquals(this.protectedContextIds, other.protectedContextIds) && GeckoPigeonUtils.deepEquals(this.strictContextIds, other.strictContextIds) && GeckoPigeonUtils.deepEquals(this.protectedTargetPatterns, other.protectedTargetPatterns) && GeckoPigeonUtils.deepEquals(this.contextOverrides, other.contextOverrides)
return GeckoPigeonUtils.deepEquals(this.globalMode, other.globalMode) && GeckoPigeonUtils.deepEquals(this.rules, other.rules) && GeckoPigeonUtils.deepEquals(this.marketplaceFallbackEnabled, other.marketplaceFallbackEnabled) && GeckoPigeonUtils.deepEquals(this.authExceptionsEnabled, other.authExceptionsEnabled) && GeckoPigeonUtils.deepEquals(this.protectGeneralContext, other.protectGeneralContext) && GeckoPigeonUtils.deepEquals(this.protectedContextIds, other.protectedContextIds) && GeckoPigeonUtils.deepEquals(this.strictContextIds, other.strictContextIds) && GeckoPigeonUtils.deepEquals(this.protectedTargetPatterns, other.protectedTargetPatterns) && GeckoPigeonUtils.deepEquals(this.contextOverrides, other.contextOverrides)
}
override fun hashCode(): Int {
@@ -5886,6 +5893,7 @@ data class AppLinkPolicySnapshot (
result = 31 * result + GeckoPigeonUtils.deepHash(this.globalMode)
result = 31 * result + GeckoPigeonUtils.deepHash(this.rules)
result = 31 * result + GeckoPigeonUtils.deepHash(this.marketplaceFallbackEnabled)
result = 31 * result + GeckoPigeonUtils.deepHash(this.authExceptionsEnabled)
result = 31 * result + GeckoPigeonUtils.deepHash(this.protectGeneralContext)
result = 31 * result + GeckoPigeonUtils.deepHash(this.protectedContextIds)
result = 31 * result + GeckoPigeonUtils.deepHash(this.strictContextIds)
@@ -5894,7 +5902,7 @@ data class AppLinkPolicySnapshot (
return result
}
override fun toString(): String {
return "AppLinkPolicySnapshot(globalMode=$globalMode, rules=$rules, marketplaceFallbackEnabled=$marketplaceFallbackEnabled, protectGeneralContext=$protectGeneralContext, protectedContextIds=$protectedContextIds, strictContextIds=$strictContextIds, protectedTargetPatterns=$protectedTargetPatterns, contextOverrides=$contextOverrides)"
return "AppLinkPolicySnapshot(globalMode=$globalMode, rules=$rules, marketplaceFallbackEnabled=$marketplaceFallbackEnabled, authExceptionsEnabled=$authExceptionsEnabled, protectGeneralContext=$protectGeneralContext, protectedContextIds=$protectedContextIds, strictContextIds=$strictContextIds, protectedTargetPatterns=$protectedTargetPatterns, contextOverrides=$contextOverrides)"
}
}
@@ -58,6 +58,11 @@ class AppLinkClassifierTest {
// ---- §2.2 table: engine-supported (http) scheme, app resolves ----
@Test
fun safeDefaultAllowsAuthExceptions() {
assertEquals(true, AppLinkPolicy.SAFE_DEFAULT.authExceptionsEnabled)
}
@Test
fun engineSupportedAlwaysAutoLaunches() {
val d = AppLinkClassifier.classify(
@@ -13,6 +13,7 @@ import kotlin.test.assertEquals
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
class AppLinkLauncherTest {
@@ -69,6 +70,33 @@ class AppLinkLauncherTest {
assertEquals(1, started)
}
@Test
fun authenticationLaunchUsesClearTopFlags() {
val intent = mock(Intent::class.java)
val resolved = ResolvedAppLink(
hasExternalApp = true,
appIntent = intent,
packageName = "com.example.app",
appName = "App",
fallbackUrl = null,
marketplaceIntent = null,
isAmbiguous = false,
engineSupportsScheme = false,
scopeKey = "pkg:com.example.app",
originalScheme = "example",
intentDataScheme = "example",
)
var startedIntent: Intent? = null
val l = launcher(resolved, FakeClock()) { startedIntent = it }
assertEquals(
AppLinkLaunchResult.LAUNCHED,
l.launch("example://callback", AppLinkLaunchMode.AUTHENTICATION),
)
verify(intent).flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
assertEquals(intent, startedIntent)
}
@Test
fun automaticLaunchWithinCooldownIsRefused() {
val clock = FakeClock(1000L)
@@ -78,6 +106,22 @@ class AppLinkLauncherTest {
assertEquals(AppLinkLaunchResult.COOLDOWN, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
}
@Test
fun authenticationLaunchWithinCooldownIsRefused() {
val clock = FakeClock(1000L)
val l = launcher(resolvedFor("com.example.app"), clock)
assertEquals(
AppLinkLaunchResult.LAUNCHED,
l.launch("zoommtg://x", AppLinkLaunchMode.AUTHENTICATION),
)
// An app that re-opens its Custom Tab on receiving the callback would otherwise ping-pong.
clock.now = 1500L // < 2000 ms later
assertEquals(
AppLinkLaunchResult.COOLDOWN,
l.launch("zoommtg://x", AppLinkLaunchMode.AUTHENTICATION),
)
}
@Test
fun automaticLaunchAfterCooldownSucceeds() {
val clock = FakeClock(1000L)
@@ -6486,6 +6486,7 @@ class AppLinkPolicySnapshot {
required this.globalMode,
required this.rules,
required this.marketplaceFallbackEnabled,
required this.authExceptionsEnabled,
required this.protectGeneralContext,
required this.protectedContextIds,
required this.strictContextIds,
@@ -6500,6 +6501,10 @@ class AppLinkPolicySnapshot {
bool marketplaceFallbackEnabled;
/// Allows same-caller Custom Tab / ActionView authentication callbacks to
/// return to their app even when the general app-link mode is `never`.
bool authExceptionsEnabled;
/// Regular / no-contextId tabs are proxied via the `general` scope.
bool protectGeneralContext;
@@ -6521,6 +6526,7 @@ class AppLinkPolicySnapshot {
globalMode,
rules,
marketplaceFallbackEnabled,
authExceptionsEnabled,
protectGeneralContext,
protectedContextIds,
strictContextIds,
@@ -6540,12 +6546,13 @@ class AppLinkPolicySnapshot {
rules: (result[1]! as Map<Object?, Object?>)
.cast<String, NativeAppLinkRule>(),
marketplaceFallbackEnabled: result[2]! as bool,
protectGeneralContext: result[3]! as bool,
protectedContextIds: (result[4]! as List<Object?>).cast<String>(),
strictContextIds: (result[5]! as List<Object?>).cast<String>(),
protectedTargetPatterns: (result[6]! as List<Object?>)
authExceptionsEnabled: result[3]! as bool,
protectGeneralContext: result[4]! as bool,
protectedContextIds: (result[5]! as List<Object?>).cast<String>(),
strictContextIds: (result[6]! as List<Object?>).cast<String>(),
protectedTargetPatterns: (result[7]! as List<Object?>)
.cast<ProtectedTargetPattern>(),
contextOverrides: (result[7]! as Map<Object?, Object?>)
contextOverrides: (result[8]! as Map<Object?, Object?>)
.cast<String, NativeContextAppLinkPolicy>(),
);
}
@@ -6565,6 +6572,7 @@ class AppLinkPolicySnapshot {
marketplaceFallbackEnabled,
other.marketplaceFallbackEnabled,
) &&
_deepEquals(authExceptionsEnabled, other.authExceptionsEnabled) &&
_deepEquals(protectGeneralContext, other.protectGeneralContext) &&
_deepEquals(protectedContextIds, other.protectedContextIds) &&
_deepEquals(strictContextIds, other.strictContextIds) &&
@@ -6578,7 +6586,7 @@ class AppLinkPolicySnapshot {
@override
String toString() {
return 'AppLinkPolicySnapshot(globalMode: $globalMode, rules: $rules, marketplaceFallbackEnabled: $marketplaceFallbackEnabled, protectGeneralContext: $protectGeneralContext, protectedContextIds: $protectedContextIds, strictContextIds: $strictContextIds, protectedTargetPatterns: $protectedTargetPatterns, contextOverrides: $contextOverrides)';
return 'AppLinkPolicySnapshot(globalMode: $globalMode, rules: $rules, marketplaceFallbackEnabled: $marketplaceFallbackEnabled, authExceptionsEnabled: $authExceptionsEnabled, protectGeneralContext: $protectGeneralContext, protectedContextIds: $protectedContextIds, strictContextIds: $strictContextIds, protectedTargetPatterns: $protectedTargetPatterns, contextOverrides: $contextOverrides)';
}
}
@@ -2994,6 +2994,10 @@ class AppLinkPolicySnapshot {
final bool marketplaceFallbackEnabled;
/// Allows same-caller Custom Tab / ActionView authentication callbacks to
/// return to their app even when the general app-link mode is `never`.
final bool authExceptionsEnabled;
/// Regular / no-contextId tabs are proxied via the `general` scope.
final bool protectGeneralContext;
@@ -3014,6 +3018,7 @@ class AppLinkPolicySnapshot {
required this.globalMode,
required this.rules,
required this.marketplaceFallbackEnabled,
required this.authExceptionsEnabled,
required this.protectGeneralContext,
required this.protectedContextIds,
required this.strictContextIds,