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) {