diff --git a/src/Kp2aBusinessLogic/Io/OneDrive2FileStorage.cs b/src/Kp2aBusinessLogic/Io/OneDrive2FileStorage.cs index c5edf6096..0b6677479 100644 --- a/src/Kp2aBusinessLogic/Io/OneDrive2FileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/OneDrive2FileStorage.cs @@ -17,6 +17,7 @@ using System.Reflection; using System.Text; using Android.Content; +using Android.Preferences; using Android.Util; using KeePass.Util; using keepass2android.Io.ItemLocation; @@ -198,6 +199,43 @@ public OneDrive2ItemLocation BuildShare(string id, public static IPublicClientApplication _publicClientApp = null; private string ClientID = "8374f801-0f55-407d-80cc-9a04fe86d9b2"; + // MSAL broker (WAM/Authenticator/Company Portal) is required to pass the device ID for + // Intune/Entra-managed devices, but broken broker setups (missing package visibility, + // missing broker redirect URI registration, ...) must not block ordinary OneDrive sign-in. + // The user-facing "OneDrive device sign-in" preference (pref_app_file_handling.xml) picks + // between: Auto (try broker, permanently fall back to non-broker for this device on a + // broker-specific failure), Always (force broker, surface real errors, no fallback - + // for admins who need to confirm/require the Intune device-ID path), and Never (skip + // broker entirely - the escape hatch for devices where broker itself is broken). + private enum BrokerMode + { + Auto, + Always, + Never + } + + // Must match Resource.String.OneDriveBrokerMode_key's value (config.xml) - this business-logic + // assembly has no access to the app's generated Resource class, so the key is duplicated here. + private const string PrefKeyBrokerMode = "OneDriveBrokerMode"; + private const string BrokerModeValueAlways = "ALWAYS"; + private const string BrokerModeValueNever = "NEVER"; + + // Sticky per-device flag learned in Auto mode once broker is seen to fail; independent of + // PrefKeyBrokerMode so switching back to Auto later remembers the outcome. + private const string PrefKeyBrokerAvailable = "OneDrive2FileStorageBrokerAvailable"; + + private static readonly HashSet BrokerFailureErrorCodes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "cannot_invoke_broker", + "no_broker_installed_on_device", + "no_broker_account_found", + "null_intent_returned_from_broker", + "broker_signature_verification_failed", + "android_broker_operation_failed", + }; + + private static BrokerMode _brokerMode; + private static bool _brokerEnabledForCurrentClient; public abstract IEnumerable Scopes { @@ -206,10 +244,83 @@ public abstract IEnumerable Scopes public OneDrive2FileStorage() { - _publicClientApp = PublicClientApplicationBuilder.Create(ClientID) - .WithRedirectUri($"msal{ClientID}://auth") - .WithBroker(true) // Enable broker authentication to pass device ID for Intune/Entra managed devices - .Build(); + _brokerMode = ReadBrokerModePreference(); + _brokerEnabledForCurrentClient = _brokerMode switch + { + BrokerMode.Always => true, + BrokerMode.Never => false, + _ => IsBrokerAllowedByCache(), + }; + _publicClientApp = BuildPublicClientApp(_brokerEnabledForCurrentClient); + } + + private IPublicClientApplication BuildPublicClientApp(bool useBroker) + { + var builder = PublicClientApplicationBuilder.Create(ClientID) + .WithRedirectUri($"msal{ClientID}://auth"); + if (useBroker) + builder = builder.WithBroker(true); + return builder.Build(); + } + + private BrokerMode ReadBrokerModePreference() + { + try + { + string value = PreferenceManager.GetDefaultSharedPreferences(Android.App.Application.Context) + .GetString(PrefKeyBrokerMode, null); + if (value == BrokerModeValueAlways) + return BrokerMode.Always; + if (value == BrokerModeValueNever) + return BrokerMode.Never; + return BrokerMode.Auto; + } + catch (Exception e) + { + logDebug("failed to read broker-mode preference: " + e); + return BrokerMode.Auto; + } + } + + private bool IsBrokerAllowedByCache() + { + try + { + return PreferenceManager.GetDefaultSharedPreferences(Android.App.Application.Context) + .GetBoolean(PrefKeyBrokerAvailable, true); + } + catch (Exception e) + { + logDebug("failed to read broker-available preference: " + e); + return true; + } + } + + private static bool IsBrokerFailure(MsalException ex) + { + return ex.ErrorCode != null && BrokerFailureErrorCodes.Contains(ex.ErrorCode); + } + + // Only Auto mode auto-recovers from a broker failure by falling back to non-broker; Always + // mode intentionally surfaces the real error instead of silently masking a broken broker. + private static bool ShouldFallBackFromBroker(MsalException ex) + { + return _brokerMode == BrokerMode.Auto && _brokerEnabledForCurrentClient && IsBrokerFailure(ex); + } + + private void DisableBrokerAndRebuildClient() + { + _brokerEnabledForCurrentClient = false; + _publicClientApp = BuildPublicClientApp(false); + try + { + PreferenceManager.GetDefaultSharedPreferences(Android.App.Application.Context) + .Edit().PutBoolean(PrefKeyBrokerAvailable, false).Commit(); + } + catch (Exception e) + { + logDebug("failed to persist broker-unavailable preference: " + e); + } } class PathItemBuilder @@ -993,36 +1104,48 @@ protected void FinishActivityWithSuccess( public async void OnStart(IFileStorageSetupActivity activity) { - logDebug("OneDrive2.OnStart"); - if (activity.ProcessName.Equals(FileStorageSetupDefs.ProcessNameFileUsageSetup)) - activity.State.PutString(FileStorageSetupDefs.ExtraPath, activity.Ioc.Path); - string rootPathForUser = await TryLoginSilent(activity.Ioc.Path); - if (rootPathForUser != null) - { - logDebug("rootPathForUser not null"); - FinishActivityWithSuccess(activity, rootPathForUser); - return; - } - logDebug("rootPathForUser null"); - try { + logDebug("OneDrive2.OnStart"); + if (activity.ProcessName.Equals(FileStorageSetupDefs.ProcessNameFileUsageSetup)) + activity.State.PutString(FileStorageSetupDefs.ExtraPath, activity.Ioc.Path); + string rootPathForUser = await TryLoginSilent(activity.Ioc.Path); + if (rootPathForUser != null) + { + logDebug("rootPathForUser not null"); + FinishActivityWithSuccess(activity, rootPathForUser); + return; + } + logDebug("rootPathForUser null"); logDebug("try interactive"); - AuthenticationResult res = await _publicClientApp.AcquireTokenInteractive(Scopes) - .WithParentActivityOrWindow((Activity)activity) - .ExecuteAsync(); + AuthenticationResult res; + try + { + res = await _publicClientApp.AcquireTokenInteractive(Scopes) + .WithParentActivityOrWindow((Activity)activity) + .ExecuteAsync(); + } + catch (MsalException msalEx) when (ShouldFallBackFromBroker(msalEx)) + { + logDebug("interactive auth failed with broker error " + msalEx.ErrorCode + ", retrying without broker"); + DisableBrokerAndRebuildClient(); + res = await _publicClientApp.AcquireTokenInteractive(Scopes) + .WithParentActivityOrWindow((Activity)activity) + .ExecuteAsync(); + } logDebug("ok interactive"); BuildClient(res); FinishActivityWithSuccess(activity, BuildRootPathForUser(res)); - - } catch (Exception e) { - logDebug("authenticating not successful: " + e); + string errorMessage = "authenticating not successful"; + if (e is MsalException msalEx) + errorMessage += " (" + msalEx.ErrorCode + ")"; + logDebug(errorMessage + ": " + e); Intent data = new Intent(); - data.PutExtra(FileStorageSetupDefs.ExtraErrorMessage, "authenticating not successful"); + data.PutExtra(FileStorageSetupDefs.ExtraErrorMessage, errorMessage); ((Activity)activity).SetResult(Result.Canceled, data); ((Activity)activity).Finish(); } @@ -1084,9 +1207,18 @@ private async Task TryLoginSilent(string iocPath) logDebug("ui required"); return null; } + catch (MsalException msalEx) when (ShouldFallBackFromBroker(msalEx)) + { + // The broker-backed account/token cache is not usable once broker itself is broken. + // Disable broker for future logins and fall back to a fresh interactive sign-in. + logDebug("silent auth failed with broker error " + msalEx.ErrorCode + ", disabling broker for future logins"); + DisableBrokerAndRebuildClient(); + return null; + } catch (Exception ex) { - logDebug("silent login failed: " + ex.ToString()); + string? errorCode = (ex as MsalException)?.ErrorCode; + logDebug("silent login failed" + (errorCode != null ? " (" + errorCode + ")" : "") + ": " + ex); return null; } } diff --git a/src/keepass2android-app/Manifests/AndroidManifest_debug.xml b/src/keepass2android-app/Manifests/AndroidManifest_debug.xml index 2045646d2..8e085de54 100644 --- a/src/keepass2android-app/Manifests/AndroidManifest_debug.xml +++ b/src/keepass2android-app/Manifests/AndroidManifest_debug.xml @@ -31,6 +31,11 @@ + + + + + diff --git a/src/keepass2android-app/Manifests/AndroidManifest_net.xml b/src/keepass2android-app/Manifests/AndroidManifest_net.xml index 81159fa58..b77a5f24d 100644 --- a/src/keepass2android-app/Manifests/AndroidManifest_net.xml +++ b/src/keepass2android-app/Manifests/AndroidManifest_net.xml @@ -32,6 +32,11 @@ + + + + + diff --git a/src/keepass2android-app/Resources/values/config.xml b/src/keepass2android-app/Resources/values/config.xml index b84fee880..9e41e5456 100644 --- a/src/keepass2android-app/Resources/values/config.xml +++ b/src/keepass2android-app/Resources/values/config.xml @@ -213,6 +213,14 @@ ERROR + OneDriveBrokerMode + AUTO + + AUTO + ALWAYS + NEVER + + ShowUnlockedNotification true 65536 diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index f583fd3f4..5b8f20f38 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -415,6 +415,8 @@ Show local backups SSL certificates Define the behavior when certificate validation fails. Note: you can install certificates on your device if validation fails! + OneDrive device sign-in (broker) + Controls whether OneDrive sign-in uses your device\'s Microsoft account manager (broker), which is required on some Intune/Entra-managed work devices. Automatic tries broker sign-in first and falls back to normal sign-in if it fails. Choose "Never" if OneDrive sign-in fails repeatedly on this device. Clear cache? This will delete all cached database files. Any changes you made while having no access to the source database and which have not yet been synchronized will be lost! Continue? Check for modifications @@ -1282,6 +1284,11 @@ Warn when validation fails Do not accept invalid certificates + + Automatic (recommended) + Always use device sign-in + Never use device sign-in + Make sure this works on your system and consider using the built-in keyboard if not. Description provided by the plugin: Keepass2Android supports Android\'s Autofill feature but it looks like you haven\'t enabled it yet. diff --git a/src/keepass2android-app/Resources/xml/pref_app_file_handling.xml b/src/keepass2android-app/Resources/xml/pref_app_file_handling.xml index e232ef512..3cd14594f 100644 --- a/src/keepass2android-app/Resources/xml/pref_app_file_handling.xml +++ b/src/keepass2android-app/Resources/xml/pref_app_file_handling.xml @@ -70,6 +70,15 @@ android:dialogTitle="@string/AcceptAllServerCertificates_title" android:defaultValue="@string/AcceptAllServerCertificates_default" /> + +