From 19f238d9bb77dbccfcdbbd5b6138bebd8f7d9de1 Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Tue, 5 Mar 2024 18:09:20 -0300 Subject: [PATCH] [PM-6539] Fix Autofill Extension TDE without MP flow (#3049) * PM-6539 Fix Autofill Extension TDE without MP updating PromptSSO to work in MAUI and also Generator view. WebAuthenticator copied with UIWindow gotten as it was in Xamarin forms to work. Also fix one NRE on state migration. * PM-6539 Remove unnecessary using --- src/Core/Core.csproj | 2 + src/Core/Pages/Accounts/LoginSsoPage.xaml.cs | 1 + .../Pages/Accounts/LoginSsoPageViewModel.cs | 15 ++ src/Core/Services/Logging/DebugLogger.cs | 6 +- src/Core/Services/StateMigrationService.cs | 18 +- .../WebAuthenticator.ios.cs | 244 ++++++++++++++++++ .../WebAuthenticator.shared.cs | 188 ++++++++++++++ .../WebAuthenticatorResult.cs | 152 +++++++++++ .../WebAuthenticatorMAUI/WebUtils.cs | 126 +++++++++ .../CredentialProviderViewController.cs | 7 +- .../LockPasswordViewController.cs | 37 +-- .../BaseLockPasswordViewController.cs | 33 ++- .../Controllers/LoginAddViewController.cs | 3 +- src/iOS.Extension/LoadingViewController.cs | 5 +- .../LoadingViewController.cs | 2 +- 15 files changed, 793 insertions(+), 46 deletions(-) create mode 100644 src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticator.ios.cs create mode 100644 src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticator.shared.cs create mode 100644 src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticatorResult.cs create mode 100644 src/Core/Utilities/WebAuthenticatorMAUI/WebUtils.cs diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index b4abf4463..aa4fba0f9 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -77,6 +77,7 @@ + @@ -107,5 +108,6 @@ + \ No newline at end of file diff --git a/src/Core/Pages/Accounts/LoginSsoPage.xaml.cs b/src/Core/Pages/Accounts/LoginSsoPage.xaml.cs index 1c1402a8b..13c553667 100644 --- a/src/Core/Pages/Accounts/LoginSsoPage.xaml.cs +++ b/src/Core/Pages/Accounts/LoginSsoPage.xaml.cs @@ -21,6 +21,7 @@ namespace Bit.App.Pages InitializeComponent(); _vm = BindingContext as LoginSsoPageViewModel; _vm.Page = this; + _vm.FromIosExtension = _appOptions?.IosExtension ?? false; _vm.StartTwoFactorAction = () => MainThread.BeginInvokeOnMainThread(async () => await StartTwoFactorAsync()); _vm.StartSetPasswordAction = () => MainThread.BeginInvokeOnMainThread(async () => await StartSetPasswordAsync()); diff --git a/src/Core/Pages/Accounts/LoginSsoPageViewModel.cs b/src/Core/Pages/Accounts/LoginSsoPageViewModel.cs index 03945f2fd..d5b858063 100644 --- a/src/Core/Pages/Accounts/LoginSsoPageViewModel.cs +++ b/src/Core/Pages/Accounts/LoginSsoPageViewModel.cs @@ -15,6 +15,16 @@ using Bit.Core.Utilities; using Microsoft.Maui.Authentication; using Microsoft.Maui.Networking; using NetworkAccess = Microsoft.Maui.Networking.NetworkAccess; +using Org.BouncyCastle.Asn1.Ocsp; + +#if IOS +using AuthenticationServices; +using Foundation; +using UIKit; +using WebAuthenticator = Bit.Core.Utilities.MAUI.WebAuthenticator; +using WebAuthenticatorResult = Bit.Core.Utilities.MAUI.WebAuthenticatorResult; +using WebAuthenticatorOptions = Bit.Core.Utilities.MAUI.WebAuthenticatorOptions; +#endif namespace Bit.App.Pages { @@ -64,6 +74,8 @@ namespace Bit.App.Pages set => SetProperty(ref _orgIdentifier, value); } + public bool FromIosExtension { get; set; } + public ICommand LogInCommand { get; } public Action StartTwoFactorAction { get; set; } public Action StartSetPasswordAction { get; set; } @@ -153,6 +165,9 @@ namespace Bit.App.Pages CallbackUrl = new Uri(REDIRECT_URI), Url = new Uri(url), PrefersEphemeralWebBrowserSession = _useEphemeralWebBrowserSession, +#if IOS + ShouldUseSharedApplicationKeyWindow = FromIosExtension +#endif }); var code = GetResultCode(authResult, state); diff --git a/src/Core/Services/Logging/DebugLogger.cs b/src/Core/Services/Logging/DebugLogger.cs index a52c1de5d..21ac179b9 100644 --- a/src/Core/Services/Logging/DebugLogger.cs +++ b/src/Core/Services/Logging/DebugLogger.cs @@ -1,10 +1,6 @@ #if !FDROID -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Runtime.CompilerServices; -using System.Threading.Tasks; using Bit.Core.Abstractions; namespace Bit.Core.Services @@ -55,3 +51,5 @@ namespace Bit.Core.Services } } #endif + + diff --git a/src/Core/Services/StateMigrationService.cs b/src/Core/Services/StateMigrationService.cs index 56883ada2..ed566a4b9 100644 --- a/src/Core/Services/StateMigrationService.cs +++ b/src/Core/Services/StateMigrationService.cs @@ -849,16 +849,18 @@ namespace Bit.Core.Services { // account data var state = await GetValueAsync(Storage.Prefs, V7Keys.StateKey); - - // Migrate environment data to use Regions - foreach (var account in state.Accounts.Where(a => a.Value?.Profile?.UserId != null && a.Value?.Settings != null)) + if (state != null) { - var urls = account.Value.Settings.EnvironmentUrls ?? Region.US.GetUrls(); - account.Value.Settings.Region = urls.Region; - account.Value.Settings.EnvironmentUrls = urls.Region.GetUrls() ?? urls; - } + // Migrate environment data to use Regions + foreach (var account in state.Accounts.Where(a => a.Value?.Profile?.UserId != null && a.Value?.Settings != null)) + { + var urls = account.Value.Settings.EnvironmentUrls ?? Region.US.GetUrls(); + account.Value.Settings.Region = urls.Region; + account.Value.Settings.EnvironmentUrls = urls.Region.GetUrls() ?? urls; + } - await SetValueAsync(Storage.Prefs, Constants.StateKey, state); + await SetValueAsync(Storage.Prefs, Constants.StateKey, state); + } // Update pre auth urls and region var preAuthUrls = await GetValueAsync(Storage.Prefs, V7Keys.PreAuthEnvironmentUrlsKey) ?? Region.US.GetUrls(); diff --git a/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticator.ios.cs b/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticator.ios.cs new file mode 100644 index 000000000..3014c6de1 --- /dev/null +++ b/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticator.ios.cs @@ -0,0 +1,244 @@ +// This is a copy from MAUI Essentials WebAuthenticator with a fix for getting UIWindow without Scenes. + +#if IOS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AuthenticationServices; +using Foundation; +using SafariServices; +using ObjCRuntime; +using UIKit; +using WebKit; +using Microsoft.Maui.Authentication; +using Microsoft.Maui.ApplicationModel; +using Bit.Core.Services; + +namespace Bit.Core.Utilities.MAUI +{ + partial class WebAuthenticatorImplementation : IWebAuthenticator, IPlatformWebAuthenticatorCallback + { +#if IOS + const int asWebAuthenticationSessionErrorCodeCanceledLogin = 1; + const string asWebAuthenticationSessionErrorDomain = "com.apple.AuthenticationServices.WebAuthenticationSession"; + + const int sfAuthenticationErrorCanceledLogin = 1; + const string sfAuthenticationErrorDomain = "com.apple.SafariServices.Authentication"; +#endif + + TaskCompletionSource tcsResponse; + UIViewController currentViewController; + Uri redirectUri; + WebAuthenticatorOptions currentOptions; + +#if IOS + ASWebAuthenticationSession was; + SFAuthenticationSession sf; +#endif + + public async Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) + { + currentOptions = webAuthenticatorOptions; + var url = webAuthenticatorOptions?.Url; + var callbackUrl = webAuthenticatorOptions?.CallbackUrl; + var prefersEphemeralWebBrowserSession = webAuthenticatorOptions?.PrefersEphemeralWebBrowserSession ?? false; + + if (!VerifyHasUrlSchemeOrDoesntRequire(callbackUrl.Scheme)) + throw new InvalidOperationException("You must register your URL Scheme handler in your app's Info.plist."); + + // Cancel any previous task that's still pending + if (tcsResponse?.Task != null && !tcsResponse.Task.IsCompleted) + tcsResponse.TrySetCanceled(); + + tcsResponse = new TaskCompletionSource(); + redirectUri = callbackUrl; + var scheme = redirectUri.Scheme; + +#if IOS + void AuthSessionCallback(NSUrl cbUrl, NSError error) + { + if (error == null) + OpenUrlCallback(cbUrl); + else if (error.Domain == asWebAuthenticationSessionErrorDomain && error.Code == asWebAuthenticationSessionErrorCodeCanceledLogin) + tcsResponse.TrySetCanceled(); + else if (error.Domain == sfAuthenticationErrorDomain && error.Code == sfAuthenticationErrorCanceledLogin) + tcsResponse.TrySetCanceled(); + else + tcsResponse.TrySetException(new NSErrorException(error)); + + was = null; + sf = null; + } + + if (OperatingSystem.IsIOSVersionAtLeast(12)) + { + was = new ASWebAuthenticationSession(MAUI.WebUtils.GetNativeUrl(url), scheme, AuthSessionCallback); + + if (OperatingSystem.IsIOSVersionAtLeast(13)) + { + var ctx = new ContextProvider(webAuthenticatorOptions.ShouldUseSharedApplicationKeyWindow + ? GetWorkaroundedUIWindow() + : WindowStateManager.Default.GetCurrentUIWindow()); + was.PresentationContextProvider = ctx; + was.PrefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession; + } + else if (prefersEphemeralWebBrowserSession) + { + ClearCookies(); + } + + using (was) + { +#pragma warning disable CA1416 // Analyzer bug https://github.com/dotnet/roslyn-analyzers/issues/5938 + was.Start(); +#pragma warning restore CA1416 + return await tcsResponse.Task; + } + } + + if (prefersEphemeralWebBrowserSession) + ClearCookies(); + +#pragma warning disable CA1422 // 'SFAuthenticationSession' is obsoleted on: 'ios' 12.0 and later + if (OperatingSystem.IsIOSVersionAtLeast(11)) + { + sf = new SFAuthenticationSession(MAUI.WebUtils.GetNativeUrl(url), scheme, AuthSessionCallback); + using (sf) + { + sf.Start(); + return await tcsResponse.Task; + } + } +#pragma warning restore CA1422 + + // This is only on iOS9+ but we only support 10+ in Essentials anyway + var controller = new SFSafariViewController(MAUI.WebUtils.GetNativeUrl(url), false) + { + Delegate = new NativeSFSafariViewControllerDelegate + { + DidFinishHandler = (svc) => + { + // Cancel our task if it wasn't already marked as completed + if (!(tcsResponse?.Task?.IsCompleted ?? true)) + tcsResponse.TrySetCanceled(); + } + }, + }; + + currentViewController = controller; + await WindowStateManager.Default.GetCurrentUIViewController().PresentViewControllerAsync(controller, true); +#else + var opened = UIApplication.SharedApplication.OpenUrl(url); + if (!opened) + tcsResponse.TrySetException(new Exception("Error opening Safari")); +#endif + + return await tcsResponse.Task; + } + + private UIWindow GetWorkaroundedUIWindow(bool throwIfNull = false) + { + var window = UIApplication.SharedApplication.KeyWindow; + + if (window != null && window.WindowLevel == UIWindowLevel.Normal) + return window; + + if (window == null) + { + window = UIApplication.SharedApplication + .Windows + .OrderByDescending(w => w.WindowLevel) + .FirstOrDefault(w => w.RootViewController != null && w.WindowLevel == UIWindowLevel.Normal); + } + + if (throwIfNull && window == null) + throw new InvalidOperationException("Could not find current window."); + + return window; + + } + + void ClearCookies() + { + NSUrlCache.SharedCache.RemoveAllCachedResponses(); + +#if IOS + if (OperatingSystem.IsIOSVersionAtLeast(11)) + { + WKWebsiteDataStore.DefaultDataStore.HttpCookieStore.GetAllCookies((cookies) => + { + foreach (var cookie in cookies) + { +#pragma warning disable CA1416 // Known false positive with lambda, here we can also assert the version + WKWebsiteDataStore.DefaultDataStore.HttpCookieStore.DeleteCookie(cookie, null); +#pragma warning restore CA1416 + } + }); + } +#endif + } + + public bool OpenUrlCallback(Uri uri) + { + // If we aren't waiting on a task, don't handle the url + if (tcsResponse?.Task?.IsCompleted ?? true) + return false; + + try + { + // If we can't handle the url, don't + if (!MAUI.WebUtils.CanHandleCallback(redirectUri, uri)) + return false; + + currentViewController?.DismissViewControllerAsync(true); + currentViewController = null; + + tcsResponse.TrySetResult(new WebAuthenticatorResult(uri, currentOptions?.ResponseDecoder)); + return true; + } + catch (Exception ex) + { + // TODO change this to ILogger? + Console.WriteLine(ex); + } + return false; + } + + static bool VerifyHasUrlSchemeOrDoesntRequire(string scheme) + { + // app is currently supporting iOS11+ so no need for these checks. + return true; + //// iOS11+ uses sfAuthenticationSession which handles its own url routing + //if (OperatingSystem.IsIOSVersionAtLeast(11, 0) || OperatingSystem.IsTvOSVersionAtLeast(11, 0)) + // return true; + + //return AppInfoImplementation.VerifyHasUrlScheme(scheme); + } + +#if IOS + class NativeSFSafariViewControllerDelegate : SFSafariViewControllerDelegate + { + public Action DidFinishHandler { get; set; } + + public override void DidFinish(SFSafariViewController controller) => + DidFinishHandler?.Invoke(controller); + } + + class ContextProvider : NSObject, IASWebAuthenticationPresentationContextProviding + { + public ContextProvider(UIWindow window) => + Window = window; + + public readonly UIWindow Window; + + [Export("presentationAnchorForWebAuthenticationSession:")] + public UIWindow GetPresentationAnchor(ASWebAuthenticationSession session) + => Window; + } +#endif + } +} + +#endif \ No newline at end of file diff --git a/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticator.shared.cs b/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticator.shared.cs new file mode 100644 index 000000000..c4bb1646e --- /dev/null +++ b/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticator.shared.cs @@ -0,0 +1,188 @@ +// This is a copy from MAUI Essentials WebAuthenticator + +#if IOS + +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Bit.Core.Utilities; + +namespace Bit.Core.Utilities.MAUI +{ + /// + /// A web navigation API intended to be used for authentication with external web services such as OAuth. + /// + public interface IWebAuthenticator + { + /// + /// Begin an authentication flow by navigating to the specified URL and waiting for a callback/redirect to the callback URL scheme. + /// + /// A instance containing additional configuration for this authentication call. + /// A object with the results of this operation. + Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions); + } + + /// + /// Provides abstractions for the platform web authenticator callbacks triggered when using . + /// + public interface IPlatformWebAuthenticatorCallback + { +#if IOS || MACCATALYST || MACOS + /// + /// Opens the specified URI to start the authentication flow. + /// + /// The URI to open that will start the authentication flow. + /// when the URI has been opened, otherwise . + bool OpenUrlCallback(Uri uri); +#elif ANDROID + /// + /// The event that is triggered when an authentication flow calls back into the Android application. + /// + /// An object containing additional data about this resume operation. + /// when the callback can be processed, otherwise . + bool OnResumeCallback(Android.Content.Intent intent); +#endif + } + + /// + /// Provides abstractions used for decoding a URI returned from a authentication request, for use with . + /// + public interface IWebAuthenticatorResponseDecoder + { + /// + /// Decodes the given URIs query string into a dictionary. + /// + /// The object to decode the query parameters from. + /// A object where each of the query parameters values of are accessible through their respective keys. + IDictionary? DecodeResponse(Uri uri); + } + + /// + /// A web navigation API intended to be used for Authentication with external web services such as OAuth. + /// + /// + /// This API helps with navigating to a start URL and waiting for a callback URL to the app. Your app must + /// be registered to handle the callback scheme you provide in the call to authenticate. + /// + public static class WebAuthenticator + { + /// Begin an authentication flow by navigating to the specified url and waiting for a callback/redirect to the callbackUrl scheme. + /// Url to navigate to, beginning the authentication flow. + /// Expected callback url that the navigation flow will eventually redirect to. + /// Returns a result parsed out from the callback url. + public static Task AuthenticateAsync(Uri url, Uri callbackUrl) + => Current.AuthenticateAsync(url, callbackUrl); + + /// Begin an authentication flow by navigating to the specified url and waiting for a callback/redirect to the callbackUrl scheme.The start url and callbackUrl are specified in the webAuthenticatorOptions. + /// Options to configure the authentication request. + /// Returns a result parsed out from the callback url. + public static Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) + => Current.AuthenticateAsync(webAuthenticatorOptions); + + static IWebAuthenticator Current => Utilities.MAUI.WebAuthenticator.Default; + + static IWebAuthenticator? defaultImplementation; + + /// + /// Provides the default implementation for static usage of this API. + /// + public static IWebAuthenticator Default => + defaultImplementation ??= new MAUI.WebAuthenticatorImplementation(); + + internal static void SetDefault(IWebAuthenticator? implementation) => + defaultImplementation = implementation; + } + + /// + /// This class contains static extension methods for use with . + /// + public static class WebAuthenticatorExtensions + { + static IPlatformWebAuthenticatorCallback AsPlatformCallback(this IWebAuthenticator webAuthenticator) + { + if (webAuthenticator is not IPlatformWebAuthenticatorCallback platform) + throw new PlatformNotSupportedException("This implementation of IWebAuthenticator does not implement IPlatformWebAuthenticatorCallback."); + return platform; + } + +#if ANDROID + internal static bool IsAuthenticatingWithCustomTabs(this IWebAuthenticator webAuthenticator) + => (webAuthenticator as MAUI.WebAuthenticatorImplementation)?.AuthenticatingWithCustomTabs ?? false; +#endif + + /// + /// Begin an authentication flow by navigating to the specified url and waiting for a callback/redirect to the callbackUrl scheme. + /// + /// The to use for the authentication flow. + /// Url to navigate to, beginning the authentication flow. + /// Expected callback url that the navigation flow will eventually redirect to. + /// Returns a result parsed out from the callback url. + public static Task AuthenticateAsync(this IWebAuthenticator webAuthenticator, Uri url, Uri callbackUrl) => + webAuthenticator.AuthenticateAsync(new WebAuthenticatorOptions { Url = url, CallbackUrl = callbackUrl }); + +#if IOS || MACCATALYST || MACOS + /// + public static bool OpenUrl(this IWebAuthenticator webAuthenticator, Uri uri) => + webAuthenticator.AsPlatformCallback().OpenUrlCallback(uri); + + /// + public static bool OpenUrl(this IWebAuthenticator webAuthenticator, UIKit.UIApplication app, Foundation.NSUrl url, Foundation.NSDictionary options) + { + if(url?.AbsoluteString != null) + { + return webAuthenticator.OpenUrl(new Uri(url.AbsoluteString)); + } + return false; + } + + /// + public static bool ContinueUserActivity(this IWebAuthenticator webAuthenticator, UIKit.UIApplication application, Foundation.NSUserActivity userActivity, UIKit.UIApplicationRestorationHandler completionHandler) + { + var uri = userActivity?.WebPageUrl?.AbsoluteString; + if (string.IsNullOrEmpty(uri)) + return false; + + return webAuthenticator.OpenUrl(new Uri(uri)); + } + +#elif ANDROID + /// + public static bool OnResume(this IWebAuthenticator webAuthenticator, Android.Content.Intent intent) => + webAuthenticator.AsPlatformCallback().OnResumeCallback(intent); +#endif + } + + /// + /// Represents additional options for . + /// + public class WebAuthenticatorOptions + { + /// + /// Gets or sets the URL that will start the authentication flow. + /// + public Uri? Url { get; set; } + + /// + /// Gets or sets the callback URL that should be called when authentication completes. + /// + public Uri? CallbackUrl { get; set; } + + /// + /// Gets or sets whether the browser used for the authentication flow is short-lived. + /// This means it will not share session nor cookies with the regular browser on this device if set the . + /// + /// This setting only has effect on iOS. + public bool PrefersEphemeralWebBrowserSession { get; set; } + + /// + /// Gets or sets the decoder implementation used to decode the incoming authentication result URI. + /// + public IWebAuthenticatorResponseDecoder? ResponseDecoder { get; set; } + + public bool ShouldUseSharedApplicationKeyWindow { get; set; } + } +} + +#endif \ No newline at end of file diff --git a/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticatorResult.cs b/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticatorResult.cs new file mode 100644 index 000000000..818d5fee9 --- /dev/null +++ b/src/Core/Utilities/WebAuthenticatorMAUI/WebAuthenticatorResult.cs @@ -0,0 +1,152 @@ +// This is a copy from MAUI Essentials WebAuthenticator + +#if IOS + +using System; +using System.Collections.Generic; +using Bit.Core.Utilities.MAUI; +using Microsoft.Maui.ApplicationModel; + +namespace Bit.Core.Utilities.MAUI +{ + /// + /// Represents a Web Authenticator Result object parsed from the callback Url. + /// + /// + /// All of the query string or url fragment properties are parsed into a dictionary and can be accessed by their key. + /// + public class WebAuthenticatorResult + { + /// + /// Initializes a new instance of the class. + /// + public WebAuthenticatorResult() + { + } + + /// + /// Initializes a new instance of the class by parsing a URI's query string parameters. + /// + /// The callback uri that was used to end the authentication sequence. + public WebAuthenticatorResult(Uri uri) : this(uri, null) + { + } + + /// + /// Initializes a new instance of the class by parsing a URI's query string parameters. + /// + /// + /// If the responseDecoder is non-null, then it is used to decode the fragment or query string + /// returned by the authorization service. Otherwise, a default response decoder is used. + /// + /// The callback uri that was used to end the authentication sequence. + /// The decoder that can be used to decode the callback uri. + public WebAuthenticatorResult(Uri uri, IWebAuthenticatorResponseDecoder responseDecoder) + { + CallbackUri = uri; + var properties = responseDecoder?.DecodeResponse(uri) ?? WebUtils.ParseQueryString(uri); + foreach (var kvp in properties) + { + Properties[kvp.Key] = kvp.Value; + } + } + + /// + /// Create a new instance from an existing dictionary. + /// + /// The dictionary of properties to incorporate. + public WebAuthenticatorResult(IDictionary properties) + { + foreach (var kvp in properties) + Properties[kvp.Key] = kvp.Value; + } + + /// + /// The uri that was used to call back with the access token. + /// + /// + /// The value of the callback URI, including the fragment or query string bearing + /// the access token and associated information. + /// + public Uri CallbackUri { get; } + + /// + /// The timestamp when the class was instantiated, which usually corresponds with the parsed result of a request. + /// + public DateTimeOffset Timestamp { get; set; } = new DateTimeOffset(DateTime.UtcNow); + + /// + /// The dictionary of key/value pairs parsed form the callback URI's query string. + /// + public Dictionary Properties { get; set; } = new(StringComparer.Ordinal); + + /// Puts a key/value pair into the dictionary. + public void Put(string key, string value) + => Properties[key] = value; + + /// Gets a value for a given key from the dictionary. + /// Key from the callback URI's query string. + public string Get(string key) + { + if (Properties.TryGetValue(key, out var v)) + return v; + + return default; + } + + /// The value for the `access_token` key. + /// Access Token parsed from the callback URI access_token parameter. + public string AccessToken + => Get("access_token"); + + /// The value for the `refresh_token` key. + /// Refresh Token parsed from the callback URI refresh_token parameter. + public string RefreshToken + => Get("refresh_token"); + + /// The value for the `id_token` key. + /// The value for the `id_token` key. + /// Apple doesn't return an access token on iOS native sign in, but it does return id_token as a JWT. + public string IdToken + => Get("id_token"); + + /// + /// The refresh token expiry date as calculated by the timestamp of when the result was created plus + /// the value in seconds for the refresh_token_expires_in key. + /// + /// Timestamp of the creation of the object instance plus the expires_in seconds parsed from the callback URI. + public DateTimeOffset? RefreshTokenExpiresIn + { + get + { + if (Properties.TryGetValue("refresh_token_expires_in", out var v)) + { + if (int.TryParse(v, out var i)) + return Timestamp.AddSeconds(i); + } + + return null; + } + } + + /// + /// The expiry date as calculated by the timestamp of when the result was created plus + /// the value in seconds for the `expires_in` key. + /// + /// Timestamp of the creation of the object instance plus the expires_in seconds parsed from the callback URI. + public DateTimeOffset? ExpiresIn + { + get + { + if (Properties.TryGetValue("expires_in", out var v)) + { + if (int.TryParse(v, out var i)) + return Timestamp.AddSeconds(i); + } + + return null; + } + } + } +} +#endif \ No newline at end of file diff --git a/src/Core/Utilities/WebAuthenticatorMAUI/WebUtils.cs b/src/Core/Utilities/WebAuthenticatorMAUI/WebUtils.cs new file mode 100644 index 000000000..b9a6a4285 --- /dev/null +++ b/src/Core/Utilities/WebAuthenticatorMAUI/WebUtils.cs @@ -0,0 +1,126 @@ +// This is copied from MAUI repo to be used from WebAuthenticator +// https://github.com/dotnet/maui/blob/main/src/Essentials/src/Types/Shared/WebUtils.shared.cs + +#if IOS + +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Bit.Core.Utilities.MAUI +{ + static class WebUtils + { + internal static IDictionary ParseQueryString(Uri uri) + { + var parameters = new Dictionary(StringComparer.Ordinal); + + if (uri == null) + return parameters; + + // Note: Uri.Query starts with a '?' + if (!string.IsNullOrEmpty(uri.Query)) + UnpackParameters(uri.Query.AsSpan(1), parameters); + + // Note: Uri.Fragment starts with a '#' + if (!string.IsNullOrEmpty(uri.Fragment)) + UnpackParameters(uri.Fragment.AsSpan(1), parameters); + + return parameters; + } + + // The following method is a port of the logic found in https://source.dot.net/#Microsoft.AspNetCore.WebUtilities/src/Shared/QueryStringEnumerable.cs + // but refactored such that it: + // + // 1. avoids the IEnumerable overhead that isn't needed (the ASP.NET logic was clearly designed that way to offer a public API whereas we don't need that) + // 2. avoids the use of unsafe code + static void UnpackParameters(ReadOnlySpan query, Dictionary parameters) + { + while (!query.IsEmpty) + { + int delimeterIndex = query.IndexOf('&'); + ReadOnlySpan segment; + + if (delimeterIndex >= 0) + { + segment = query.Slice(0, delimeterIndex); + query = query.Slice(delimeterIndex + 1); + } + else + { + segment = query; + query = default; + } + + // If it's nonempty, emit it + if (!segment.IsEmpty) + { + var equalIndex = segment.IndexOf('='); + string name, value; + + if (equalIndex >= 0) + { + name = segment.Slice(0, equalIndex).ToString(); + + var span = segment.Slice(equalIndex + 1); + var chars = new char[span.Length]; + + for (int i = 0; i < span.Length; i++) + chars[i] = span[i] == '+' ? ' ' : span[i]; + + value = new string(chars); + } + else + { + name = segment.ToString(); + value = string.Empty; + } + + name = Uri.UnescapeDataString(name); + + parameters[name] = Uri.UnescapeDataString(value); + } + } + } + + internal static Uri EscapeUri(Uri uri) + { + if (uri == null) + throw new ArgumentNullException(nameof(uri)); + + var idn = new global::System.Globalization.IdnMapping(); + return new Uri(uri.Scheme + "://" + idn.GetAscii(uri.Authority) + uri.PathAndQuery + uri.Fragment); + } + + internal static bool CanHandleCallback(Uri expectedUrl, Uri callbackUrl) + { + if (!callbackUrl.Scheme.Equals(expectedUrl.Scheme, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.IsNullOrEmpty(expectedUrl.Host)) + { + if (!callbackUrl.Host.Equals(expectedUrl.Host, StringComparison.OrdinalIgnoreCase)) + return false; + } + + return true; + } + +#if __IOS__ || __TVOS__ || __MACOS__ + internal static Foundation.NSUrl GetNativeUrl(Uri uri) + { + try + { + return new Foundation.NSUrl(uri.OriginalString); + } + catch (Exception ex) + { + Debug.WriteLine($"Unable to create NSUrl from Original string, trying Absolute URI: {ex.Message}"); + return new Foundation.NSUrl(uri.AbsoluteUri); + } + } +#endif + } +} + +#endif \ No newline at end of file diff --git a/src/iOS.Autofill/CredentialProviderViewController.cs b/src/iOS.Autofill/CredentialProviderViewController.cs index 2ad523d22..57593fec1 100644 --- a/src/iOS.Autofill/CredentialProviderViewController.cs +++ b/src/iOS.Autofill/CredentialProviderViewController.cs @@ -55,7 +55,6 @@ namespace Bit.iOS.Autofill { ExtContext = ExtensionContext }; - } catch (Exception ex) { @@ -156,6 +155,7 @@ namespace Bit.iOS.Autofill { InitAppIfNeeded(); _context.Configuring = true; + if (!await IsAuthed()) { await _accountsManager.NavigateOnAccountChangeAsync(false); @@ -522,8 +522,9 @@ namespace Bit.iOS.Autofill private void LaunchLoginSsoFlow() { - var loginPage = new LoginSsoPage(); - var app = new App.App(new AppOptions { IosExtension = true }); + var appOptions = new AppOptions { IosExtension = true }; + var loginPage = new LoginSsoPage(appOptions); + var app = new App.App(appOptions); ThemeManager.SetTheme(app.Resources); ThemeManager.ApplyResourcesTo(loginPage); if (loginPage.BindingContext is LoginSsoPageViewModel vm) diff --git a/src/iOS.Autofill/LockPasswordViewController.cs b/src/iOS.Autofill/LockPasswordViewController.cs index 0d73e1fff..9455754a1 100644 --- a/src/iOS.Autofill/LockPasswordViewController.cs +++ b/src/iOS.Autofill/LockPasswordViewController.cs @@ -1,8 +1,8 @@ using System; using Bit.App.Controls; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.iOS.Core.Utilities; -using MapKit; using UIKit; namespace Bit.iOS.Autofill @@ -33,22 +33,29 @@ namespace Bit.iOS.Autofill public override async void ViewDidLoad() { - _cancelButton = new UIBarButtonItem(UIBarButtonSystemItem.Cancel, CancelButton_TouchUpInside); - - base.ViewDidLoad(); - - _accountSwitchingOverlayHelper = new AccountSwitchingOverlayHelper(); - - _accountSwitchButton = await _accountSwitchingOverlayHelper.CreateAccountSwitchToolbarButtonItemCustomViewAsync(); - _accountSwitchButton.TouchUpInside += AccountSwitchedButton_TouchUpInside; - - NavItem.SetLeftBarButtonItems(new UIBarButtonItem[] + try { - _cancelButton, - new UIBarButtonItem(_accountSwitchButton) - }, false); + _cancelButton = new UIBarButtonItem(UIBarButtonSystemItem.Cancel, CancelButton_TouchUpInside); - _accountSwitchingOverlayView = _accountSwitchingOverlayHelper.CreateAccountSwitchingOverlayView(OverlayView); + base.ViewDidLoad(); + + _accountSwitchingOverlayHelper = new AccountSwitchingOverlayHelper(); + + _accountSwitchButton = await _accountSwitchingOverlayHelper.CreateAccountSwitchToolbarButtonItemCustomViewAsync(); + _accountSwitchButton.TouchUpInside += AccountSwitchedButton_TouchUpInside; + + NavItem.SetLeftBarButtonItems(new UIBarButtonItem[] + { + _cancelButton, + new UIBarButtonItem(_accountSwitchButton) + }, false); + + _accountSwitchingOverlayView = _accountSwitchingOverlayHelper.CreateAccountSwitchingOverlayView(OverlayView); + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + } } private void CancelButton_TouchUpInside(object sender, EventArgs e) diff --git a/src/iOS.Core/Controllers/BaseLockPasswordViewController.cs b/src/iOS.Core/Controllers/BaseLockPasswordViewController.cs index e01502558..31477f403 100644 --- a/src/iOS.Core/Controllers/BaseLockPasswordViewController.cs +++ b/src/iOS.Core/Controllers/BaseLockPasswordViewController.cs @@ -16,6 +16,7 @@ using Bit.iOS.Core.Views; using Foundation; using UIKit; using Microsoft.Maui.Controls.Compatibility; +using Microsoft.Maui.Platform; namespace Bit.iOS.Core.Controllers { @@ -222,20 +223,27 @@ namespace Bit.iOS.Core.Controllers public override void ViewDidAppear(bool animated) { - base.ViewDidAppear(animated); - - // Users with key connector and without biometric or pin has no MP to unlock with - if (!_hasMasterPassword) + try { - if (!(_pinEnabled || _biometricEnabled) || - (_biometricEnabled && !_biometricIntegrityValid)) + base.ViewDidAppear(animated); + + // Users with key connector and without biometric or pin has no MP to unlock with + if (!_hasMasterPassword) { - PromptSSO(); + if (!(_pinEnabled || _biometricEnabled) || + (_biometricEnabled && !_biometricIntegrityValid)) + { + PromptSSO(); + } + } + else if (!_biometricEnabled || !_biometricIntegrityValid) + { + MasterPasswordCell.TextField.BecomeFirstResponder(); } } - else if (!_biometricEnabled || !_biometricIntegrityValid) + catch (Exception ex) { - MasterPasswordCell.TextField.BecomeFirstResponder(); + LoggerHelper.LogEvenIfCantBeResolved(ex); } } @@ -433,8 +441,9 @@ namespace Bit.iOS.Core.Controllers public void PromptSSO() { - var loginPage = new LoginSsoPage(); - var app = new App.App(new AppOptions { IosExtension = true }); + var appOptions = new AppOptions { IosExtension = true }; + var loginPage = new LoginSsoPage(appOptions); + var app = new App.App(appOptions); ThemeManager.SetTheme(app.Resources); ThemeManager.ApplyResourcesTo(loginPage); if (loginPage.BindingContext is LoginSsoPageViewModel vm) @@ -444,7 +453,7 @@ namespace Bit.iOS.Core.Controllers } var navigationPage = new NavigationPage(loginPage); - var loginController = navigationPage.CreateViewController(); + var loginController = navigationPage.ToUIViewController(MauiContextSingleton.Instance.MauiContext); loginController.ModalPresentationStyle = UIModalPresentationStyle.FullScreen; PresentViewController(loginController, true, null); } diff --git a/src/iOS.Core/Controllers/LoginAddViewController.cs b/src/iOS.Core/Controllers/LoginAddViewController.cs index 385655fc1..2081b07d2 100644 --- a/src/iOS.Core/Controllers/LoginAddViewController.cs +++ b/src/iOS.Core/Controllers/LoginAddViewController.cs @@ -18,6 +18,7 @@ using Bit.iOS.Core.Views; using Foundation; using UIKit; using Microsoft.Maui.Controls.Compatibility; +using Microsoft.Maui.Platform; namespace Bit.iOS.Core.Controllers { @@ -239,7 +240,7 @@ namespace Bit.iOS.Core.Controllers ThemeManager.ApplyResourcesTo(generatorPage); var navigationPage = new NavigationPage(generatorPage); - var generatorController = navigationPage.CreateViewController(); + var generatorController = navigationPage.ToUIViewController(MauiContextSingleton.Instance.MauiContext); generatorController.ModalPresentationStyle = UIModalPresentationStyle.FullScreen; PresentViewController(generatorController, true, null); } diff --git a/src/iOS.Extension/LoadingViewController.cs b/src/iOS.Extension/LoadingViewController.cs index 75d8cc8a6..570bec9dd 100644 --- a/src/iOS.Extension/LoadingViewController.cs +++ b/src/iOS.Extension/LoadingViewController.cs @@ -599,8 +599,9 @@ namespace Bit.iOS.Extension private void LaunchLoginSsoFlow() { - var loginPage = new LoginSsoPage(); - var app = new App.App(new AppOptions { IosExtension = true }); + var appOptions = new AppOptions { IosExtension = true }; + var loginPage = new LoginSsoPage(appOptions); + var app = new App.App(appOptions); ThemeManager.SetTheme(app.Resources); ThemeManager.ApplyResourcesTo(loginPage); if (loginPage.BindingContext is LoginSsoPageViewModel vm) diff --git a/src/iOS.ShareExtension/LoadingViewController.cs b/src/iOS.ShareExtension/LoadingViewController.cs index acf295d56..d97150487 100644 --- a/src/iOS.ShareExtension/LoadingViewController.cs +++ b/src/iOS.ShareExtension/LoadingViewController.cs @@ -381,7 +381,7 @@ namespace Bit.iOS.ShareExtension private void LaunchLoginSsoFlow() { - var loginPage = new LoginSsoPage(); + var loginPage = new LoginSsoPage(_appOptions.Value); SetupAppAndApplyResources(loginPage); if (loginPage.BindingContext is LoginSsoPageViewModel vm) {