diff --git a/src/Uno.Extensions.Authentication.Oidc/HostBuilderExtensions.cs b/src/Uno.Extensions.Authentication.Oidc/HostBuilderExtensions.cs index 2e4a213ccd..f86d198512 100644 --- a/src/Uno.Extensions.Authentication.Oidc/HostBuilderExtensions.cs +++ b/src/Uno.Extensions.Authentication.Oidc/HostBuilderExtensions.cs @@ -1,4 +1,7 @@ -namespace Uno.Extensions; +using IdentityModel.OidcClient.Browser; +using Microsoft.Extensions.DependencyInjection; + +namespace Uno.Extensions; /// /// Provides extension methods for OIDC authentication to use with . @@ -26,7 +29,7 @@ public static IAuthenticationBuilder AddOidc( string name = OidcAuthenticationProvider.DefaultName) { #if WINDOWS - WinUIEx.WebAuthenticator.Init(); + WinUIEx.WebAuthenticator.CheckOAuthRedirectionActivation(); #endif var hostBuilder = (builder as IBuilder)?.HostBuilder; @@ -35,6 +38,19 @@ public static IAuthenticationBuilder AddOidc( return builder; } + hostBuilder = hostBuilder + .ConfigureServices((ctx, services) => + { + if (ctx.IsRegistered(nameof(AddOidc))) + { + return; + } + + services + .AddTransient(); + }); + + hostBuilder .UseConfiguration(configure: configBuilder => configBuilder diff --git a/src/Uno.Extensions.Authentication.Oidc/OidcAuthenticationProvider.cs b/src/Uno.Extensions.Authentication.Oidc/OidcAuthenticationProvider.cs index 28c0721c55..b7516d7710 100644 --- a/src/Uno.Extensions.Authentication.Oidc/OidcAuthenticationProvider.cs +++ b/src/Uno.Extensions.Authentication.Oidc/OidcAuthenticationProvider.cs @@ -1,11 +1,10 @@ - -using IdentityModel.OidcClient.Browser; -using System.Diagnostics; +using IdentityModel.OidcClient.Browser; namespace Uno.Extensions.Authentication.Oidc; internal record OidcAuthenticationProvider( ILogger ProviderLogger, + IBrowser Browser, IOptionsSnapshot Configuration, ITokenCache Tokens, OidcAuthenticationSettings? Settings = null) : BaseAuthenticationProvider(ProviderLogger, DefaultName, Tokens) @@ -23,7 +22,7 @@ public void Build() config.RedirectUri = WebAuthenticationBroker.GetCurrentApplicationCallbackUri().OriginalString; config.PostLogoutRedirectUri = WebAuthenticationBroker.GetCurrentApplicationCallbackUri().OriginalString; } - config.Browser = new WebAuthenticatorBrowser(); + config.Browser = Browser; _client = new OidcClient(config); } @@ -87,47 +86,4 @@ protected async override ValueTask InternalLogoutAsync(IDispatcher? dispat } return default; } - - -} - - - - -public class WebAuthenticatorBrowser : IBrowser -{ - public async Task InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) - { - try - { -#if WINDOWS - var userResult = await WinUIEx.WebAuthenticator.AuthenticateAsync(new Uri(options.StartUrl), new Uri(options.EndUrl)); - var callbackurl = $"{options.EndUrl}/?{string.Join("&", userResult.Properties.Select(x => $"{x.Key}={x.Value}"))}"; - return new BrowserResult - { - Response = callbackurl - }; -#else - var userResult = await WebAuthenticationBroker.AuthenticateAsync(WebAuthenticationOptions.None, new Uri(options.StartUrl), new Uri(options.EndUrl)); - - return new BrowserResult - { - Response = userResult.ResponseData - }; -#endif - } - catch (Exception ex) - { - Debug.WriteLine(ex); - return new BrowserResult() - { - ResultType = BrowserResultType.UnknownError, - Error = ex.ToString() - }; - } - } - - } - - diff --git a/src/Uno.Extensions.Authentication.Oidc/WebAuthenticatorBrowser.cs b/src/Uno.Extensions.Authentication.Oidc/WebAuthenticatorBrowser.cs new file mode 100644 index 0000000000..6fe245af4d --- /dev/null +++ b/src/Uno.Extensions.Authentication.Oidc/WebAuthenticatorBrowser.cs @@ -0,0 +1,42 @@ + +using IdentityModel.OidcClient.Browser; +using System.Diagnostics; + +namespace Uno.Extensions.Authentication.Oidc; + +public class WebAuthenticatorBrowser : IBrowser +{ + public async Task InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) + { + try + { +#if WINDOWS + var userResult = await WinUIEx.WebAuthenticator.AuthenticateAsync(new Uri(options.StartUrl), new Uri(options.EndUrl)); + var callbackurl = $"{options.EndUrl}/?{string.Join("&", userResult.Properties.Select(x => $"{x.Key}={x.Value}"))}"; + return new BrowserResult + { + Response = callbackurl + }; +#else + var userResult = await WebAuthenticationBroker.AuthenticateAsync(WebAuthenticationOptions.None, new Uri(options.StartUrl), new Uri(options.EndUrl)); + + return new BrowserResult + { + Response = userResult.ResponseData + }; +#endif + } + catch (Exception ex) + { + Debug.WriteLine(ex); + return new BrowserResult() + { + ResultType = BrowserResultType.UnknownError, + Error = ex.ToString() + }; + } + } +} + + + diff --git a/src/Uno.Extensions.Authentication.UI/HostBuilderExtensions.cs b/src/Uno.Extensions.Authentication.UI/HostBuilderExtensions.cs index bb4081fa1a..737a649331 100644 --- a/src/Uno.Extensions.Authentication.UI/HostBuilderExtensions.cs +++ b/src/Uno.Extensions.Authentication.UI/HostBuilderExtensions.cs @@ -26,7 +26,7 @@ public static IAuthenticationBuilder AddWeb( string name = WebAuthenticationProvider.DefaultName) { #if WINDOWS - WinUIEx.WebAuthenticator.Init(); + WinUIEx.WebAuthenticator.CheckOAuthRedirectionActivation(); #endif var hostBuilder = (builder as IBuilder)?.HostBuilder; if (hostBuilder is null) @@ -78,7 +78,7 @@ public static IAuthenticationBuilder AddWeb( { #if WINDOWS - WinUIEx.WebAuthenticator.Init(); + WinUIEx.WebAuthenticator.CheckOAuthRedirectionActivation(); #endif var hostBuilder = (builder as IBuilder)?.HostBuilder; if (hostBuilder is null) diff --git a/src/Uno.Extensions.Authentication.UI/WinUIEx_WebAuthenticator.cs b/src/Uno.Extensions.Authentication.UI/WinUIEx_WebAuthenticator.cs index aeb65ea477..4acbae0033 100644 --- a/src/Uno.Extensions.Authentication.UI/WinUIEx_WebAuthenticator.cs +++ b/src/Uno.Extensions.Authentication.UI/WinUIEx_WebAuthenticator.cs @@ -2,7 +2,11 @@ #if WINDOWS using System.Diagnostics; +using System.Text.Json.Nodes; +using Microsoft.Windows.AppLifecycle; +using Uno.Extensions.Authentication; using Windows.ApplicationModel.Activation; +using Windows.Security.Authentication.Web; namespace WinUIEx; @@ -31,7 +35,20 @@ public sealed class WebAuthenticator /// 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 authorizeUri, Uri callbackUri) => Instance.Authenticate(authorizeUri, callbackUri); + /// Prior to calling this, a call to must be made during application startup. + /// + public static Task AuthenticateAsync(Uri authorizeUri, Uri callbackUri) => Instance.Authenticate(authorizeUri, callbackUri, CancellationToken.None); + + /// + /// 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. + /// Cancellation token. + /// Returns a result parsed out from the callback url. + /// Prior to calling this, a call to must be made during application startup. + /// + public static Task AuthenticateAsync(Uri authorizeUri, Uri callbackUri, CancellationToken cancellationToken) => Instance.Authenticate(authorizeUri, callbackUri, cancellationToken); private static readonly WebAuthenticator Instance = new WebAuthenticator(); @@ -42,28 +59,6 @@ private WebAuthenticator() Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().Activated += CurrentAppInstance_Activated; } - private static bool _init; - -#pragma warning disable CA2255 // The ModuleInitializer attribute should not be used in libraries - [System.Runtime.CompilerServices.ModuleInitializer] -#pragma warning restore - public static void Init() - { - if (_init) - { - return; - } - _init = true; - try - { - OnAppCreation(); - } - catch (Exception ex) - { - System.Diagnostics.Trace.WriteLine("WinUIEx: Failed to initialize the WebAuthenticator: " + ex.Message, "WinUIEx"); - } - } - private static bool IsUriProtocolDeclared(string scheme) { if (global::Windows.ApplicationModel.Package.Current is null) @@ -93,27 +88,82 @@ private static bool IsUriProtocolDeclared(string scheme) private static NameValueCollection? GetState(IProtocolActivatedEventArgs protocolArgs) { - Debug.WriteLine($"args: {protocolArgs.Uri.Query}"); - var vals = System.Web.HttpUtility.ParseQueryString(protocolArgs.Uri.Query); - if (vals["state"] is string state) + NameValueCollection? vals = null; + try + { + vals = System.Web.HttpUtility.ParseQueryString(protocolArgs.Uri.Query); + } + catch { } + try + { + if (vals is null || !(vals["state"] is string)) + { + var fragment = protocolArgs.Uri.Fragment; + if (fragment.StartsWith("#")) + { + fragment = fragment.Substring(1); + } + vals = System.Web.HttpUtility.ParseQueryString(fragment); + } + } + catch { } + if (vals != null && vals["state"] is string state) { - var vals2 = System.Web.HttpUtility.ParseQueryString(state); - // Some services doesn't like & encoded state parameters, and breaks them out separately. - // In that case copy over the important values - if (vals.AllKeys.Contains("appInstanceId") && !vals2.AllKeys.Contains("appInstanceId")) - vals2.Add("appInstanceId", vals["appInstanceId"]); - if (vals.AllKeys.Contains("signinId") && !vals2.AllKeys.Contains("signinId")) - vals2.Add("signinId", vals["signinId"]); - return vals2; + try + { + var jsonObject = System.Text.Json.Nodes.JsonObject.Parse(state) as JsonObject; + if (jsonObject is not null) + { + NameValueCollection vals2 = new NameValueCollection(jsonObject.Count); + if (jsonObject.ContainsKey("appInstanceId") && jsonObject["appInstanceId"] is JsonValue jvalue && jvalue.TryGetValue(out string? value)) + vals2.Add("appInstanceId", value); + if (jsonObject.ContainsKey("signinId") && jsonObject["signinId"] is JsonValue jvalue2 && jvalue2.TryGetValue(out string? value2)) + vals2.Add("signinId", value2); + return vals2; + } + } + catch { } } return null; } + private static bool _oauthCheckWasPerformed; - private static void OnAppCreation() + /// + /// Performs an OAuth protocol activation check and redirects activation to the correct application instance. + /// + /// If true, this application instance will not automatically be shut down. If set to + /// true ensure you handle instance exit, or you'll end up with multiple instances running. + /// true if the activation was redirected and this instance should be shut down, otherwise false. + /// + /// The call to this method should be done preferably in the Program.Main method, or the application constructor. It must be called + /// prior to using + /// + /// + public static bool CheckOAuthRedirectionActivation(bool skipShutDownOnActivation = false) { var activatedEventArgs = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent()?.GetActivatedEventArgs(); + return CheckOAuthRedirectionActivation(activatedEventArgs, skipShutDownOnActivation); + } + + /// + /// Performs an OAuth protocol activation check and redirects activation to the correct application instance. + /// + /// The activation arguments + /// If true, this application instance will not automatically be shut down. If set to + /// true ensure you handle instance exit, or you'll end up with multiple instances running. + /// true if the activation was redirected and this instance should be shut down, otherwise false. + /// + /// The call to this method should be done preferably in the Program.Main method, or the application constructor. It must be called + /// prior to using + /// + /// + public static bool CheckOAuthRedirectionActivation(AppActivationArguments? activatedEventArgs, bool skipShutDownOnActivation = false) + { + _oauthCheckWasPerformed = true; if (activatedEventArgs is null) - return; + return false; + if (activatedEventArgs.Kind != Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol) + return false; var state = GetState(activatedEventArgs); if (state is not null && state["appInstanceId"] is string id && state["signinId"] is string signinId && !string.IsNullOrEmpty(signinId)) { @@ -123,7 +173,9 @@ private static void OnAppCreation() { // Redirect to correct instance and close this one instance.RedirectActivationToAsync(activatedEventArgs).AsTask().Wait(); - System.Diagnostics.Process.GetCurrentProcess().Kill(); + if (!skipShutDownOnActivation) + System.Diagnostics.Process.GetCurrentProcess().Kill(); + return true; } } else @@ -134,6 +186,7 @@ private static void OnAppCreation() Microsoft.Windows.AppLifecycle.AppInstance.FindOrRegisterForKey(Guid.NewGuid().ToString()); } } + return false; } private void CurrentAppInstance_Activated(object? sender, Microsoft.Windows.AppLifecycle.AppActivationArguments e) @@ -161,9 +214,13 @@ private void ResumeSignin(Uri callbackUri, string signinId) } } - private async Task Authenticate(Uri authorizeUri, Uri callbackUri) + private async Task Authenticate(Uri authorizeUri, Uri callbackUri, CancellationToken cancellationToken) { - if (global::Windows.ApplicationModel.Package.Current is null) + if (!_oauthCheckWasPerformed) + { + throw new InvalidOperationException("OAuth redirection check on app activation was not detected. Please make sure a call to WebAuthenticator.CheckOAuthRedirectionActivation was made during App creation."); + } + if (!PlatformHelper.IsAppPackaged) // Original code uses: !Helpers.IsAppPackaged { throw new InvalidOperationException("The WebAuthenticator requires a packaged app with an AppxManifest"); } @@ -172,76 +229,82 @@ private async Task Authenticate(Uri authorizeUri, Uri ca throw new InvalidOperationException($"The URI Scheme {callbackUri.Scheme} is not declared in AppxManifest.xml"); } var g = Guid.NewGuid(); + var taskId = g.ToString(); UriBuilder b = new UriBuilder(authorizeUri); var query = System.Web.HttpUtility.ParseQueryString(authorizeUri.Query); - var state = $"appInstanceId={Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().Key}&signinId={g}"; + var stateJson = new JsonObject + { + { "appInstanceId", Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().Key }, + { "signinId", taskId } + }; if (query["state"] is string oldstate && !string.IsNullOrEmpty(oldstate)) { - // Encode the state parameter - state += "&state=" + System.Web.HttpUtility.UrlEncode(oldstate); + stateJson["state"] = oldstate; } - query["state"] = state; + + query["state"] = stateJson.ToJsonString(); b.Query = query.ToString(); authorizeUri = b.Uri; var tcs = new TaskCompletionSource(); + if (cancellationToken.CanBeCanceled) + { + cancellationToken.Register(() => + { + tcs.TrySetCanceled(); + if (tasks.ContainsKey(taskId)) + tasks.Remove(taskId); + }); + if (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + } + var process = new System.Diagnostics.Process(); process.StartInfo.FileName = "rundll32.exe"; process.StartInfo.Arguments = "url.dll,FileProtocolHandler " + authorizeUri.ToString(); process.StartInfo.UseShellExecute = true; process.Start(); - tasks.Add(g.ToString(), tcs); + tasks.Add(taskId, tcs); var uri = await tcs.Task.ConfigureAwait(false); return new WebAuthenticatorResult(uri); } } - /// /// Web Authenticator result parsed from the callback Url. /// /// public class WebAuthenticatorResult { - public Uri? RawCallbackUrl { get; } /// /// Initializes a new instance of the class. /// /// Callback url public WebAuthenticatorResult(Uri callbackUrl) { - RawCallbackUrl = callbackUrl; - - var query = new NameValueCollection(); - - // Retrieve from fragment - if (!string.IsNullOrEmpty(callbackUrl.Fragment) && callbackUrl.Fragment.Length>1) - { - var frag = callbackUrl.Fragment.Substring(1); - query = System.Web.HttpUtility.ParseQueryString(frag); - } - - // Retrieve from query - if (!string.IsNullOrEmpty(callbackUrl.Query)) - { - var str = callbackUrl.Query; - var q = System.Web.HttpUtility.ParseQueryString(str); - foreach (string key in q.Keys) - { - query[key] = q[key]; - } - } - + var str = string.Empty; + if (!string.IsNullOrEmpty(callbackUrl.Fragment)) + str = callbackUrl.Fragment.Substring(1); + else if (!string.IsNullOrEmpty(callbackUrl.Query)) + str = callbackUrl.Query; + var query = System.Web.HttpUtility.ParseQueryString(str); foreach (string key in query.Keys) { if (key == "state") { - var values = System.Web.HttpUtility.ParseQueryString(query[key] ?? string.Empty); - if (values["state"] is string state) + try { - Properties[key] = state; + var jsonObject = System.Text.Json.Nodes.JsonObject.Parse(query[key] ?? "{}") as JsonObject; + + if (jsonObject is not null && jsonObject.ContainsKey("state") && jsonObject["state"] is JsonValue jvalue && jvalue.TryGetValue(out string? value)) + { + Properties[key] = value; + continue; + } } - continue; + catch { } } Properties[key] = query[key] ?? String.Empty; } diff --git a/src/Uno.Extensions.Core/PlatformHelper.cs b/src/Uno.Extensions.Core/PlatformHelper.cs index a3de8deb52..0b31ad186a 100644 --- a/src/Uno.Extensions.Core/PlatformHelper.cs +++ b/src/Uno.Extensions.Core/PlatformHelper.cs @@ -2,6 +2,12 @@ public static class PlatformHelper { + private const long AppModelErrorNoPackage = 15700L; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int GetCurrentPackageFullName(ref int packageFullNameLength, System.Text.StringBuilder packageFullName); + + private static bool _isAppPackaged; private static bool _isNetCore; private static bool _initialized; private static bool _isWebAssembly; @@ -38,6 +44,17 @@ public static bool IsThreadingEnabled private static bool IsWebAssemblyThreadingSupported { get; } = Environment.GetEnvironmentVariable("UNO_BOOTSTRAP_MONO_RUNTIME_CONFIGURATION")?.StartsWith("threads", StringComparison.OrdinalIgnoreCase) ?? false; + /// + /// Gets a value indicating whether the app is packaged. + /// + public static bool IsAppPackaged + { + get + { + EnsureInitialized(); + return _isAppPackaged; + } + } /// /// Initialization is performed explicitly to avoid a mono/mono issue regarding .cctor and FullAOT @@ -55,6 +72,23 @@ private static void EnsureInitialized() _isWebAssembly = RuntimeInformation.IsOSPlatform(OSPlatform.Create("WEBASSEMBLY")) // Legacy Value (Bootstrapper 1.2.0-dev.29 or earlier). || RuntimeInformation.IsOSPlatform(OSPlatform.Create("BROWSER")); + + // If wasm, then can assume app isn't packaged, so skip this check + if (!IsWebAssembly) + { + try + { + // Application is MSIX packaged if it has an identity: https://learn.microsoft.com/en-us/windows/msix/detect-package-identity + int length = 0; + var sb = new System.Text.StringBuilder(0); + int result = GetCurrentPackageFullName(ref length, sb); + _isAppPackaged = result != AppModelErrorNoPackage; + } + catch + { + _isAppPackaged = false; + } + } } } } diff --git a/testing/TestHarness/TestHarness.Shared/App.xaml.cs b/testing/TestHarness/TestHarness.Shared/App.xaml.cs index e4abc2da72..ab9b93c231 100644 --- a/testing/TestHarness/TestHarness.Shared/App.xaml.cs +++ b/testing/TestHarness/TestHarness.Shared/App.xaml.cs @@ -20,7 +20,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // test scenario is selected. This line is included to ensure web auth test cases // work, without having to navigate to a test scenario in the new app instance // that is launched to handle the web auth redirect - WinUIEx.WebAuthenticator.Init(); + WinUIEx.WebAuthenticator.CheckOAuthRedirectionActivation(); #endif