From 3d29a2ea24ce5371d1897367d1fded67d34b9ffd Mon Sep 17 00:00:00 2001 From: Morten Nielsen Date: Wed, 18 Jun 2025 10:35:41 -0700 Subject: [PATCH 01/14] Add OAuth WebAuthenticator support to Windows --- .../Platform/Windows/MauiWinUIApplication.cs | 12 ++++ src/Essentials/src/Essentials.csproj | 1 + .../WebAuthenticator.shared.cs | 6 -- .../WebAuthenticator/WebAuthenticator.uwp.cs | 57 ++++++++++++++++++- .../Tests/WebAuthenticator_Tests.cs | 12 ---- 5 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs index ce39fd4bd2d5..12a4897054ae 100644 --- a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs +++ b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Hosting; using Microsoft.Maui.LifecycleEvents; +using global::Windows.ApplicationModel.Activation; namespace Microsoft.Maui { @@ -20,6 +21,17 @@ public abstract class MauiWinUIApplication : UI.Xaml.Application, IPlatformAppli protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) { + var activatedEventArgs = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent()?.GetActivatedEventArgs(); + if (activatedEventArgs?.Kind == Windows.AppLifecycle.ExtendedActivationKind.Protocol) + { + IProtocolActivatedEventArgs? protocolArgs = activatedEventArgs?.Data as IProtocolActivatedEventArgs; + if (protocolArgs is not null && Security.Authentication.OAuth.OAuth2Manager.CompleteAuthRequest(protocolArgs.Uri)) + { + System.Diagnostics.Process.GetCurrentProcess().Kill(); + return; // We relaunched the application after compliting a OAuth sign-in, so close this instance + } + } + // Windows running on a different thread will "launch" the app again if (_application != null && _services != null) { diff --git a/src/Essentials/src/Essentials.csproj b/src/Essentials/src/Essentials.csproj index 3dc2cd3778a4..10df099e275a 100644 --- a/src/Essentials/src/Essentials.csproj +++ b/src/Essentials/src/Essentials.csproj @@ -39,6 +39,7 @@ + diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs index d6616ffcdc58..3afc23eb78ef 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs @@ -75,18 +75,12 @@ public static 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. -#if !NETSTANDARD - [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] -#endif 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. -#if !NETSTANDARD - [System.Runtime.Versioning.UnsupportedOSPlatform("windows")] -#endif public static Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) => Current.AuthenticateAsync(webAuthenticatorOptions); diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs index c097d2d2ba51..2ddf643c3406 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs @@ -1,13 +1,66 @@ using System; +using System.IO; +using System.Linq; +using System.Net.Http; using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; +using Microsoft.Security.Authentication.OAuth; +using Microsoft.Maui.ApplicationModel; +using Microsoft.Maui.Storage; +using Windows.Security.Authentication.Web; namespace Microsoft.Maui.Authentication { partial class WebAuthenticatorImplementation : IWebAuthenticator { - public Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) + public async Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) { - throw new PlatformNotSupportedException("This implementation of WebAuthenticator does not support Windows. See https://github.com/microsoft/WindowsAppSDK/issues/441 for more details."); + var url = webAuthenticatorOptions?.Url; + var callbackUrl = webAuthenticatorOptions?.CallbackUrl; + bool isPackaged = global::Windows.ApplicationModel.Package.Current is not null; + if(isPackaged) + { + if (!IsUriProtocolDeclared(callbackUrl.Scheme)) + throw new InvalidOperationException($"You need to declare the windows.protocol usage of the protocol/scheme `{callbackUrl.Scheme}` in your AppxManifest.xml file"); + } + else + { + if (callbackUrl.Scheme == "http" || callbackUrl.Scheme == "https") + throw new InvalidOperationException($"{callbackUrl.Scheme}:// schemes are not allowed for callbackUri. Use a custom scheme like 'myapp' instead."); + var value = Microsoft.Win32.Registry.ClassesRoot.OpenSubKey(callbackUrl.Scheme); + if (value is null || value.GetValue("URL Protocol") is null) + { + throw new InvalidOperationException($"The URI Scheme '{callbackUrl.Scheme}' is not registered. Call ActivationRegistrationManager.RegisterForProtocolActivation to register protocol activation."); + } + } + AuthRequestParams authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl); + +#pragma warning disable RS0030 // The proposed workaround for this issue is to use the `GetActiveWindow` method, which is not available here. Null is safely handled in this code. + var window = Microsoft.Maui.ApplicationModel.WindowStateManager.Default.GetActiveWindow()?.AppWindow?.Id; +#pragma warning restore RS0030 + if (!window.HasValue) + throw new InvalidOperationException("No active window found for authentication."); + + AuthRequestResult authRequestResult = await OAuth2Manager.RequestAuthWithParamsAsync(window.Value, url, authRequestParams); + + return new WebAuthenticatorResult(authRequestResult.ResponseUri); + } + + static bool IsUriProtocolDeclared(string scheme) + { + var docPath = FileSystemUtils.PlatformGetFullAppPackageFilePath(PlatformUtils.AppManifestFilename); + var doc = XDocument.Load(docPath, LoadOptions.None); + var reader = doc.CreateReader(); + var namespaceManager = new XmlNamespaceManager(reader.NameTable); + namespaceManager.AddNamespace("x", PlatformUtils.AppManifestXmlns); + namespaceManager.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10"); + + // Check if the protocol was declared + var decl = doc.Root.XPathSelectElements($"//uap:Extension[@Category='windows.protocol']/uap:Protocol[@Name='{scheme}']", namespaceManager); + + return decl != null && decl.Any(); } } } diff --git a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs index f8fae1805eb1..a28746752462 100644 --- a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs @@ -21,21 +21,15 @@ public class WebAuthenticator_Tests [Trait(Traits.InteractionType, Traits.InteractionTypes.Human)] public async Task Redirect(string urlBase, string callbackScheme, string accessToken, string refreshToken, int expires) { -#pragma warning disable CA1416 // Validate platform compatibility: Not supported on Windows var authenticationTask = WebAuthenticator.AuthenticateAsync( new Uri($"{urlBase}?access_token={accessToken}&refresh_token={refreshToken}&expires={expires}"), new Uri($"{callbackScheme}://")); -#pragma warning restore CA1416 // Validate platform compatibility -#if WINDOWS - var exception = await Assert.ThrowsAsync(async () => await authenticationTask); -#else var r = await authenticationTask.ConfigureAwait(false); Assert.Equal(accessToken, r?.AccessToken); Assert.Equal(refreshToken, r?.RefreshToken); Assert.NotNull(r?.ExpiresIn); Assert.True(r?.ExpiresIn > DateTime.UtcNow); -#endif } [Theory] @@ -49,24 +43,18 @@ public async Task Redirect(string urlBase, string callbackScheme, string accessT public async Task RedirectWithResponseDecoder(string urlBase, string callbackScheme, string accessToken, string refreshToken, int expires) { var responseDecoder = new TestResponseDecoder(); -#pragma warning disable CA1416 // Validate platform compatibility: Not supported on Windows var authenticationTask = WebAuthenticator.AuthenticateAsync(new WebAuthenticatorOptions { Url = new Uri($"{urlBase}?access_token={accessToken}&refresh_token={refreshToken}&expires={expires}"), CallbackUrl = new Uri($"{callbackScheme}://"), ResponseDecoder = responseDecoder }); -#pragma warning restore CA1416 // Validate platform compatibility -#if WINDOWS - var exception = await Assert.ThrowsAsync(async () => await authenticationTask); -#else var r = await authenticationTask.ConfigureAwait(false); Assert.Equal(accessToken, r?.AccessToken); Assert.Equal(refreshToken, r?.RefreshToken); Assert.NotNull(r?.ExpiresIn); Assert.True(r?.ExpiresIn > DateTime.UtcNow); Assert.Equal(1, responseDecoder.CallCount); -#endif } [Theory] From c38741507aae81af3c18d6c3b487e73fd43dc204 Mon Sep 17 00:00:00 2001 From: Morten Nielsen Date: Wed, 18 Jun 2025 11:22:19 -0700 Subject: [PATCH 02/14] Fix test run crash --- .../WebAuthenticator/WebAuthenticator.uwp.cs | 18 ++++++++++++------ .../Platforms/Windows/Package.appxmanifest | 5 +++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs index 2ddf643c3406..939b804e9673 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs @@ -19,6 +19,7 @@ public async Task AuthenticateAsync(WebAuthenticatorOpti { var url = webAuthenticatorOptions?.Url; var callbackUrl = webAuthenticatorOptions?.CallbackUrl; + bool isPackaged = global::Windows.ApplicationModel.Package.Current is not null; if(isPackaged) { @@ -35,15 +36,20 @@ public async Task AuthenticateAsync(WebAuthenticatorOpti throw new InvalidOperationException($"The URI Scheme '{callbackUrl.Scheme}' is not registered. Call ActivationRegistrationManager.RegisterForProtocolActivation to register protocol activation."); } } - AuthRequestParams authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl); + var window = Microsoft.Maui.ApplicationModel.WindowStateManager.Default.GetActiveWindow(); -#pragma warning disable RS0030 // The proposed workaround for this issue is to use the `GetActiveWindow` method, which is not available here. Null is safely handled in this code. - var window = Microsoft.Maui.ApplicationModel.WindowStateManager.Default.GetActiveWindow()?.AppWindow?.Id; -#pragma warning restore RS0030 - if (!window.HasValue) + if (window is null) throw new InvalidOperationException("No active window found for authentication."); + + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window); - AuthRequestResult authRequestResult = await OAuth2Manager.RequestAuthWithParamsAsync(window.Value, url, authRequestParams); + if (hwnd == IntPtr.Zero) + throw new InvalidOperationException("No active window found for authentication."); + + var windowId = UI.Win32Interop.GetWindowIdFromWindow(hwnd); + + AuthRequestParams authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl); + AuthRequestResult authRequestResult = await OAuth2Manager.RequestAuthWithParamsAsync(windowId, url, authRequestParams); return new WebAuthenticatorResult(authRequestResult.ResponseUri); } diff --git a/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest b/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest index afa2dcd56da0..edba696d8ce2 100644 --- a/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest +++ b/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest @@ -33,6 +33,11 @@ + + + + + From e6f9f98a6dbe53ed9552740d95d59b1b66fc84f3 Mon Sep 17 00:00:00 2001 From: Morten Nielsen Date: Wed, 18 Jun 2025 12:41:24 -0700 Subject: [PATCH 03/14] Simplify condition --- .../src/WebAuthenticator/WebAuthenticator.uwp.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs index 939b804e9673..8259249ce31e 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs @@ -36,13 +36,9 @@ public async Task AuthenticateAsync(WebAuthenticatorOpti throw new InvalidOperationException($"The URI Scheme '{callbackUrl.Scheme}' is not registered. Call ActivationRegistrationManager.RegisterForProtocolActivation to register protocol activation."); } } - var window = Microsoft.Maui.ApplicationModel.WindowStateManager.Default.GetActiveWindow(); - if (window is null) - throw new InvalidOperationException("No active window found for authentication."); - - var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window); - + var window = WindowStateManager.Default.GetActiveWindow(); + var hwnd = window is null ? IntPtr.Zero : WinRT.Interop.WindowNative.GetWindowHandle(window); if (hwnd == IntPtr.Zero) throw new InvalidOperationException("No active window found for authentication."); From 271e3abd4f7f5f23e0c488fa52c0c3b8b687c335 Mon Sep 17 00:00:00 2001 From: Morten Nielsen Date: Wed, 18 Jun 2025 13:57:41 -0700 Subject: [PATCH 04/14] report authentication error --- .../src/WebAuthenticator/WebAuthenticator.uwp.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs index 8259249ce31e..554cc5470f63 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.uwp.cs @@ -46,8 +46,11 @@ public async Task AuthenticateAsync(WebAuthenticatorOpti AuthRequestParams authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl); AuthRequestResult authRequestResult = await OAuth2Manager.RequestAuthWithParamsAsync(windowId, url, authRequestParams); - - return new WebAuthenticatorResult(authRequestResult.ResponseUri); + if(authRequestResult.Failure is not null) + { + throw new UnauthorizedAccessException(authRequestResult.Failure.Error); + } + return new WebAuthenticatorResult(authRequestResult.ResponseUri, webAuthenticatorOptions.ResponseDecoder); } static bool IsUriProtocolDeclared(string scheme) From a9f9e01a3cd7cb203b92de6ffb41de5c287db718 Mon Sep 17 00:00:00 2001 From: Morten Nielsen Date: Wed, 18 Jun 2025 13:58:34 -0700 Subject: [PATCH 05/14] Ensure code and state parameters are preserved --- .../Controllers/MobileAuthController.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs index 4451a230614b..0e40b229d345 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs @@ -41,7 +41,9 @@ public async Task Get([FromRoute] string scheme) { "access_token", auth.Properties.GetTokenValue("access_token") }, { "refresh_token", auth.Properties.GetTokenValue("refresh_token") ?? string.Empty }, { "expires_in", (auth.Properties.ExpiresUtc?.ToUnixTimeSeconds() ?? -1).ToString() }, - { "email", email } + { "email", email }, + { "code", auth.Properties.GetTokenValue("code") ?? Guid.NewGuid().ToString() }, + { "state", auth.Properties.GetTokenValue("state") ?? string.Empty }, }; // Build the result url @@ -55,4 +57,4 @@ public async Task Get([FromRoute] string scheme) } } } -} +} \ No newline at end of file From aa50699b948eb1917d6861107b0cfaf7ebc06ae2 Mon Sep 17 00:00:00 2001 From: dotMorten Date: Wed, 25 Mar 2026 13:07:53 -0700 Subject: [PATCH 06/14] Fix typo --- src/Core/src/Platform/Windows/MauiWinUIApplication.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs index 12a4897054ae..952e8456d1a8 100644 --- a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs +++ b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs @@ -28,7 +28,7 @@ protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) if (protocolArgs is not null && Security.Authentication.OAuth.OAuth2Manager.CompleteAuthRequest(protocolArgs.Uri)) { System.Diagnostics.Process.GetCurrentProcess().Kill(); - return; // We relaunched the application after compliting a OAuth sign-in, so close this instance + return; // We relaunched the application after completing an OAuth sign-in, so close this instance } } From 9dcdd3cd6be8798457c07fdda8008ce19a988f67 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 8 Apr 2026 22:19:14 +0200 Subject: [PATCH 07/14] [Windows] WebAuthenticator: use app activation for OAuth callbacks Keep the community OAuth commits intact, and wire the Windows callback flow through the new app activation lifecycle event instead of special-casing startup. This preserves code/state handling, response decoding, and cancellation semantics while keeping MauiWinUIApplication generic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/Controls.Sample/MauiProgram.cs | 32 +++++++ .../EssentialsMauiAppBuilderExtensions.cs | 4 + .../Windows/WindowsLifecycle.cs | 5 +- .../WindowsLifecycleBuilderExtensions.cs | 1 + .../Platform/Windows/MauiWinUIApplication.cs | 62 +++++++++++--- .../net-windows/PublicAPI.Unshipped.txt | 6 +- .../LifecycleEvents/LifecycleEventsTests.cs | 43 ++++++++++ .../src/Platform/Platform.shared.cs | 9 ++ .../net-windows/PublicAPI.Unshipped.txt | 1 + .../WebAuthenticator.windows.cs | 83 ++++++++++++++----- .../Tests/WebAuthenticator_Tests.cs | 13 --- 11 files changed, 209 insertions(+), 50 deletions(-) diff --git a/src/Controls/samples/Controls.Sample/MauiProgram.cs b/src/Controls/samples/Controls.Sample/MauiProgram.cs index b7f16cf1bd54..975a2a683177 100644 --- a/src/Controls/samples/Controls.Sample/MauiProgram.cs +++ b/src/Controls/samples/Controls.Sample/MauiProgram.cs @@ -4,6 +4,7 @@ using System.Diagnostics.Metrics; using System.Linq; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using Maui.Controls.Sample.Controls; using Maui.Controls.Sample.Pages; using Maui.Controls.Sample.Services; @@ -29,6 +30,8 @@ #if ANDROID using Android.Gms.Common; using Android.Gms.Maps; +#elif WINDOWS +using Microsoft.Windows.AppLifecycle; #endif namespace Maui.Controls.Sample @@ -286,6 +289,7 @@ static string GetTags(IEnumerable> tags) => events.AddWindows(windows => windows // .OnPlatformMessage((a, b) => // LogEvent(nameof(WindowsLifecycle.OnPlatformMessage))) + .OnAppActivation((application, args) => HandleWindowsAppActivation(application, args)) .OnActivated((a, b) => LogEvent(nameof(WindowsLifecycle.OnActivated))) .OnClosed((a, b) => LogEvent(nameof(WindowsLifecycle.OnClosed))) .OnLaunched((a, b) => LogEvent(nameof(WindowsLifecycle.OnLaunched))) @@ -310,6 +314,34 @@ static bool LogEvent(string eventName, string? type = null) Debug.WriteLine($"Lifecycle event: {eventName}{(type == null ? "" : $" ({type})")}"); return true; } + +#if WINDOWS + static bool HandleWindowsAppActivation(Microsoft.UI.Xaml.Application application, AppActivationArguments args) + { + LogEvent(nameof(WindowsLifecycle.OnAppActivation), args.Kind.ToString()); + + // This sample opts into single-instancing from the MAUI lifecycle callback + // instead of a custom Program.cs entry point. + var keyInstance = AppInstance.FindOrRegisterForKey("Maui.Controls.Sample"); + + if (!keyInstance.IsCurrent) + { + // The WinAppSDK single-instance guidance redirects the activation and then + // terminates the losing instance immediately. Using Kill here avoids leaving + // a headless process around that can continue to hold build outputs open. + keyInstance.RedirectActivationToAsync(args).AsTask().GetAwaiter().GetResult(); + Process.GetCurrentProcess().Kill(); + return true; + } + + if (Application.Current?.Windows.FirstOrDefault() is Window window) + // Redirected activations can arrive off the UI thread, so hop through the + // MAUI window dispatcher before re-activating the existing window. + window.Dispatcher.Dispatch(() => Application.Current.ActivateWindow(window)); + + return false; + } +#endif }); // Adapt to dual-screen and foldable Android devices like Surface Duo, includes TwoPaneView layout control diff --git a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs index 92f8344518e4..d71f0bb75459 100644 --- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs +++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs @@ -65,6 +65,10 @@ internal static MauiAppBuilder UseEssentials(this MauiAppBuilder builder) })); #elif WINDOWS life.AddWindows(windows => windows + .OnAppActivation((application, args) => + { + return ApplicationModel.Platform.OnAppActivation(application, args); + }) .OnActivated((window, args) => { ApplicationModel.Platform.OnActivated(window, args); diff --git a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs index f1fcbe71cfc6..557c63ee783d 100644 --- a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs +++ b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycle.cs @@ -1,7 +1,10 @@ -namespace Microsoft.Maui.LifecycleEvents +using Microsoft.Windows.AppLifecycle; + +namespace Microsoft.Maui.LifecycleEvents { public static class WindowsLifecycle { + public delegate bool OnAppActivation(UI.Xaml.Application application, AppActivationArguments args); public delegate void OnActivated(UI.Xaml.Window window, UI.Xaml.WindowActivatedEventArgs args); public delegate void OnClosed(UI.Xaml.Window window, UI.Xaml.WindowEventArgs args); public delegate void OnLaunched(UI.Xaml.Application application, UI.Xaml.LaunchActivatedEventArgs args); diff --git a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs index eeb18a5758bd..6fb0b611899c 100644 --- a/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs +++ b/src/Core/src/LifecycleEvents/Windows/WindowsLifecycleBuilderExtensions.cs @@ -2,6 +2,7 @@ { public static class WindowsLifecycleBuilderExtensions { + public static IWindowsLifecycleBuilder OnAppActivation(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnAppActivation del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnActivated(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnActivated del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnClosed(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnClosed del) => lifecycle.OnEvent(del); public static IWindowsLifecycleBuilder OnLaunching(this IWindowsLifecycleBuilder lifecycle, WindowsLifecycle.OnLaunching del) => lifecycle.OnEvent(del); diff --git a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs index 952e8456d1a8..e09b650d0e71 100644 --- a/src/Core/src/Platform/Windows/MauiWinUIApplication.cs +++ b/src/Core/src/Platform/Windows/MauiWinUIApplication.cs @@ -1,8 +1,8 @@ -using System; +using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Hosting; using Microsoft.Maui.LifecycleEvents; -using global::Windows.ApplicationModel.Activation; +using Microsoft.Windows.AppLifecycle; namespace Microsoft.Maui { @@ -21,20 +21,17 @@ public abstract class MauiWinUIApplication : UI.Xaml.Application, IPlatformAppli protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) { - var activatedEventArgs = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent()?.GetActivatedEventArgs(); - if (activatedEventArgs?.Kind == Windows.AppLifecycle.ExtendedActivationKind.Protocol) - { - IProtocolActivatedEventArgs? protocolArgs = activatedEventArgs?.Data as IProtocolActivatedEventArgs; - if (protocolArgs is not null && Security.Authentication.OAuth.OAuth2Manager.CompleteAuthRequest(protocolArgs.Uri)) - { - System.Diagnostics.Process.GetCurrentProcess().Kill(); - return; // We relaunched the application after completing an OAuth sign-in, so close this instance - } - } + LaunchActivatedEventArgs = args; + + var launchActivation = AppInstance.GetCurrent().GetActivatedEventArgs(); - // Windows running on a different thread will "launch" the app again + // A running WinUI app can be activated again without rebuilding the MAUI application. + // Reuse the existing services and let activation handlers short-circuit the relaunch path. if (_application != null && _services != null) { + if (launchActivation is AppActivationArguments activatedEventArgs && OnAppActivation(activatedEventArgs)) + return; + _services.InvokeLifecycleEvents(del => del(this, args)); _services.InvokeLifecycleEvents(del => del(this, args)); return; @@ -49,6 +46,14 @@ protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) _services = applicationContext.Services; + // Future AppInstance activation callbacks need the app-level services to exist first. + RegisterForAppActivation(); + + // Run the initial activation after services are available, but before OnLaunching/window creation, + // so handlers can redirect or suppress the default startup flow. + if (launchActivation is AppActivationArguments initialActivation && OnAppActivation(initialActivation)) + return; + _services.InvokeLifecycleEvents(del => del(this, args)); _application = _services.GetRequiredService(); @@ -60,10 +65,41 @@ protected override void OnLaunched(UI.Xaml.LaunchActivatedEventArgs args) _services.InvokeLifecycleEvents(del => del(this, args)); } + protected virtual bool OnAppActivation(AppActivationArguments args) + { + var wasHandled = false; + + _services?.InvokeLifecycleEvents(del => + { + // Preserve any earlier "handled" result so multiple listeners can participate safely. + wasHandled = del(this, args) || wasHandled; + }); + + return wasHandled; + } + + void RegisterForAppActivation() + { + if (_isRegisteredForAppActivation) + return; + + _isRegisteredForAppActivation = true; + + // After startup, later file/protocol/redirected activations are delivered through AppInstance. + AppInstance.GetCurrent().Activated += OnAppInstanceActivated; + + void OnAppInstanceActivated(object? sender, AppActivationArguments args) + { + OnAppActivation(args); + } + } + public static new MauiWinUIApplication Current => (MauiWinUIApplication)UI.Xaml.Application.Current; public UI.Xaml.LaunchActivatedEventArgs LaunchActivatedEventArgs { get; protected set; } = null!; + bool _isRegisteredForAppActivation; + IServiceProvider? _services; IApplication? _application; diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index f02df3a74f88..3dbc9916393e 100644 --- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,2 +1,6 @@ -#nullable enable +#nullable enable +Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation override Microsoft.Maui.Platform.MauiPasswordTextBox.OnCreateAutomationPeer() -> Microsoft.UI.Xaml.Automation.Peers.AutomationPeer! +static Microsoft.Maui.LifecycleEvents.WindowsLifecycleBuilderExtensions.OnAppActivation(this Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! lifecycle, Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation! del) -> Microsoft.Maui.LifecycleEvents.IWindowsLifecycleBuilder! +virtual Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppActivation.Invoke(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +virtual Microsoft.Maui.MauiWinUIApplication.OnAppActivation(Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool diff --git a/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs b/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs index ae08390f4e03..e0791a48f099 100644 --- a/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs +++ b/src/Core/tests/UnitTests/LifecycleEvents/LifecycleEventsTests.cs @@ -161,6 +161,49 @@ public void CanAddMultipleEventsViaBuilder() Assert.Equal(1, event2Fired); } +#if WINDOWS + [Fact] + public void CanAddWindowsOnAppActivationLifecycleEvent() + { + var firstHandlerCalled = false; + var secondHandlerCalled = false; + var wasHandled = false; + + var mauiApp = MauiApp.CreateBuilder() + .ConfigureLifecycleEvents(builder => + { + builder.AddWindows(windows => + { + windows.OnAppActivation((application, args) => + { + firstHandlerCalled = true; + return false; + }); + + windows.OnAppActivation((application, args) => + { + secondHandlerCalled = true; + return true; + }); + }); + }) + .Build(); + + var service = mauiApp.Services.GetRequiredService(); + + Assert.True(service.ContainsEvent(nameof(WindowsLifecycle.OnAppActivation))); + + service.InvokeEvents(nameof(WindowsLifecycle.OnAppActivation), del => + { + wasHandled = del(null!, null!) || wasHandled; + }); + + Assert.True(firstHandlerCalled); + Assert.True(secondHandlerCalled); + Assert.True(wasHandled); + } +#endif + #if ANDROID [Fact] public void CanAddAndroidOnKeyDownLifecycleEvent() diff --git a/src/Essentials/src/Platform/Platform.shared.cs b/src/Essentials/src/Platform/Platform.shared.cs index 3fa0f8d0877d..35281ba613ee 100644 --- a/src/Essentials/src/Platform/Platform.shared.cs +++ b/src/Essentials/src/Platform/Platform.shared.cs @@ -154,6 +154,15 @@ public static void OnPlatformWindowInitialized(UI.Xaml.Window window) => public static void OnActivated(UI.Xaml.Window window, UI.Xaml.WindowActivatedEventArgs args) => WindowStateManager.Default.OnActivated(window, args); + /// + /// Called when the Windows application receives activation arguments that may be handled by platform features. + /// + /// The application instance that received the activation. + /// The activation arguments. + /// if a platform feature handled the activation; otherwise, . + public static bool OnAppActivation(UI.Xaml.Application application, Microsoft.Windows.AppLifecycle.AppActivationArguments args) => + WebAuthenticatorImplementation.OnAppActivation(application, args); + #elif TIZEN /// /// Gets a object with information about the current application package. diff --git a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index c85f863d2957..5c2f6ab8512a 100644 --- a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,4 +1,5 @@ #nullable enable +static Microsoft.Maui.ApplicationModel.Platform.OnAppActivation(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool *REMOVED*Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>! *REMOVED*static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs index 54ae0f929599..992af4c9607d 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Linq; using System.Threading; @@ -5,26 +6,50 @@ using System.Xml; using System.Xml.Linq; using System.Xml.XPath; -using Microsoft.Security.Authentication.OAuth; using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Storage; -using Windows.Security.Authentication.Web; +using Microsoft.Security.Authentication.OAuth; +using Microsoft.Windows.AppLifecycle; +using Windows.ApplicationModel.Activation; namespace Microsoft.Maui.Authentication { partial class WebAuthenticatorImplementation : IWebAuthenticator { - - public Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) - => AuthenticateAsync(webAuthenticatorOptions, CancellationToken.None); + internal static bool OnAppActivation(UI.Xaml.Application application, AppActivationArguments args) + { + if (args is null || args.Kind != ExtendedActivationKind.Protocol) + return false; + + if (args.Data is not IProtocolActivatedEventArgs protocolArgs) + return false; + + if (!OAuth2Manager.CompleteAuthRequest(protocolArgs.Uri)) + return false; + + // When the protocol callback launches a transient helper instance, complete the auth request + // and immediately exit before the app finishes booting into a headless background process. + if (WindowStateManager.Default.GetActiveWindow() is null) + System.Diagnostics.Process.GetCurrentProcess().Kill(); + + return true; + } + + public async Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions) + => await AuthenticateAsync(webAuthenticatorOptions, CancellationToken.None).ConfigureAwait(false); public async Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken) { - var url = webAuthenticatorOptions?.Url; - var callbackUrl = webAuthenticatorOptions?.CallbackUrl; + cancellationToken.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(webAuthenticatorOptions); + + var url = webAuthenticatorOptions.Url ?? throw new ArgumentNullException(nameof(webAuthenticatorOptions.Url)); + var callbackUrl = webAuthenticatorOptions.CallbackUrl ?? throw new ArgumentNullException(nameof(webAuthenticatorOptions.CallbackUrl)); - bool isPackaged = global::Windows.ApplicationModel.Package.Current is not null; - if(isPackaged) + bool isPackaged = AppInfoUtils.IsPackagedApp; + + if (isPackaged) { if (!IsUriProtocolDeclared(callbackUrl.Scheme)) throw new InvalidOperationException($"You need to declare the windows.protocol usage of the protocol/scheme `{callbackUrl.Scheme}` in your AppxManifest.xml file"); @@ -33,31 +58,43 @@ public async Task AuthenticateAsync(WebAuthenticatorOpti { if (callbackUrl.Scheme == "http" || callbackUrl.Scheme == "https") throw new InvalidOperationException($"{callbackUrl.Scheme}:// schemes are not allowed for callbackUri. Use a custom scheme like 'myapp' instead."); + var value = Microsoft.Win32.Registry.ClassesRoot.OpenSubKey(callbackUrl.Scheme); if (value is null || value.GetValue("URL Protocol") is null) { throw new InvalidOperationException($"The URI Scheme '{callbackUrl.Scheme}' is not registered. Call ActivationRegistrationManager.RegisterForProtocolActivation to register protocol activation."); } } + AuthRequestParams authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl); - var window = WindowStateManager.Default.GetActiveWindow(); - var hwnd = window is null ? IntPtr.Zero : WinRT.Interop.WindowNative.GetWindowHandle(window); - if (hwnd == IntPtr.Zero) + var windowId = WindowStateManager.Default.GetActiveAppWindow(false)?.Id; + if (!windowId.HasValue) throw new InvalidOperationException("No active window found for authentication."); - var windowId = UI.Win32Interop.GetWindowIdFromWindow(hwnd); - - AuthRequestParams authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl); - var authRequest = OAuth2Manager.RequestAuthWithParamsAsync(windowId, url, authRequestParams); - cancellationToken.Register(() => authRequest.Cancel()); - AuthRequestResult authRequestResult = await authRequest; - if(authRequestResult.Failure is not null) + AuthRequestResult authRequestResult = await OAuth2Manager + .RequestAuthWithParamsAsync(windowId.Value, url, authRequestParams) + .AsTask(cancellationToken) + .ConfigureAwait(false); + if (authRequestResult.Failure is not null) { - throw new UnauthorizedAccessException(authRequestResult.Failure.Error); - } + var message = string.IsNullOrEmpty(authRequestResult.Failure.ErrorDescription) + ? authRequestResult.Failure.Error + : $"{authRequestResult.Failure.Error}: {authRequestResult.Failure.ErrorDescription}"; + + if (IsUserCancellation(authRequestResult.Failure.Error, authRequestResult.Failure.ErrorDescription)) + throw new TaskCanceledException(message); + + throw new InvalidOperationException(message); + } + return new WebAuthenticatorResult(authRequestResult.ResponseUri, webAuthenticatorOptions.ResponseDecoder); } + static bool IsUserCancellation(string? error, string? errorDescription) => + string.Equals(error, "access_denied", StringComparison.OrdinalIgnoreCase) || + (error?.IndexOf("cancel", StringComparison.OrdinalIgnoreCase) >= 0) || + (errorDescription?.IndexOf("cancel", StringComparison.OrdinalIgnoreCase) >= 0); + static bool IsUriProtocolDeclared(string scheme) { var docPath = FileSystemUtils.PlatformGetFullAppPackageFilePath(PlatformUtils.AppManifestFilename); @@ -68,9 +105,11 @@ static bool IsUriProtocolDeclared(string scheme) namespaceManager.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10"); // Check if the protocol was declared - var decl = doc.Root.XPathSelectElements($"//uap:Extension[@Category='windows.protocol']/uap:Protocol[@Name='{scheme}']", namespaceManager); + var root = doc.Root ?? throw new InvalidOperationException("The app manifest could not be loaded."); + var decl = root.XPathSelectElements($"//uap:Extension[@Category='windows.protocol']/uap:Protocol[@Name='{scheme}']", namespaceManager); return decl != null && decl.Any(); } + } } diff --git a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs index 20a1ab2d31c8..890af8d02d0d 100644 --- a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs +++ b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs @@ -69,23 +69,16 @@ public async Task RedirectWithResponseDecoder(string urlBase, string callbackSch [Trait(Traits.InteractionType, Traits.InteractionTypes.Human)] public async Task Redirect_WithCancellation(string urlBase, string callbackScheme, string accessToken, string refreshToken, int expires) { -#pragma warning disable CA1416 // Validate platform compatibility: Not supported on Windows using var cts = new CancellationTokenSource(); var authenticationTask = WebAuthenticator.AuthenticateAsync( new Uri($"{urlBase}?access_token={accessToken}&refresh_token={refreshToken}&expires={expires}"), new Uri($"{callbackScheme}://"), cts.Token); -#pragma warning restore CA1416 // Validate platform compatibility - -#if WINDOWS - var exception = await Assert.ThrowsAsync(async () => await authenticationTask); -#else var r = await authenticationTask; Assert.Equal(accessToken, r?.AccessToken); Assert.Equal(refreshToken, r?.RefreshToken); Assert.NotNull(r?.ExpiresIn); Assert.True(r?.ExpiresIn > DateTime.UtcNow); -#endif } [Theory] @@ -99,7 +92,6 @@ public async Task Redirect_WithCancellation(string urlBase, string callbackSchem public async Task RedirectWithResponseDecoder_WithCancellation(string urlBase, string callbackScheme, string accessToken, string refreshToken, int expires) { var responseDecoder = new TestResponseDecoder(); -#pragma warning disable CA1416 // Validate platform compatibility: Not supported on Windows using var cts = new CancellationTokenSource(); var authenticationTask = WebAuthenticator.AuthenticateAsync(new WebAuthenticatorOptions { @@ -107,17 +99,12 @@ public async Task RedirectWithResponseDecoder_WithCancellation(string urlBase, s CallbackUrl = new Uri($"{callbackScheme}://"), ResponseDecoder = responseDecoder }, cts.Token); -#pragma warning restore CA1416 // Validate platform compatibility -#if WINDOWS - var exception = await Assert.ThrowsAsync(async () => await authenticationTask); -#else var r = await authenticationTask; Assert.Equal(accessToken, r?.AccessToken); Assert.Equal(refreshToken, r?.RefreshToken); Assert.NotNull(r?.ExpiresIn); Assert.True(r?.ExpiresIn > DateTime.UtcNow); Assert.Equal(1, responseDecoder.CallCount); -#endif } From 1b66e121c9a63e9d069101c251f459ab318901aa Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 9 Apr 2026 02:43:44 +0200 Subject: [PATCH 08/14] Rename OnAppActivation to OnAppInstanceActivated in Essentials Update WebAuthenticator, Platform, EssentialsMauiAppBuilderExtensions, and PublicAPI.Unshipped.txt to use the renamed lifecycle event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs | 4 ++-- src/Essentials/src/Platform/Platform.shared.cs | 4 ++-- .../src/PublicAPI/net-windows/PublicAPI.Unshipped.txt | 2 +- .../src/WebAuthenticator/WebAuthenticator.windows.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs index d71f0bb75459..ed35c80fa5a3 100644 --- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs +++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs @@ -65,9 +65,9 @@ internal static MauiAppBuilder UseEssentials(this MauiAppBuilder builder) })); #elif WINDOWS life.AddWindows(windows => windows - .OnAppActivation((application, args) => + .OnAppInstanceActivated((application, args) => { - return ApplicationModel.Platform.OnAppActivation(application, args); + return ApplicationModel.Platform.OnAppInstanceActivated(application, args); }) .OnActivated((window, args) => { diff --git a/src/Essentials/src/Platform/Platform.shared.cs b/src/Essentials/src/Platform/Platform.shared.cs index 35281ba613ee..135e28ecd56a 100644 --- a/src/Essentials/src/Platform/Platform.shared.cs +++ b/src/Essentials/src/Platform/Platform.shared.cs @@ -160,8 +160,8 @@ public static void OnActivated(UI.Xaml.Window window, UI.Xaml.WindowActivatedEve /// The application instance that received the activation. /// The activation arguments. /// if a platform feature handled the activation; otherwise, . - public static bool OnAppActivation(UI.Xaml.Application application, Microsoft.Windows.AppLifecycle.AppActivationArguments args) => - WebAuthenticatorImplementation.OnAppActivation(application, args); + public static bool OnAppInstanceActivated(UI.Xaml.Application application, Microsoft.Windows.AppLifecycle.AppActivationArguments args) => + WebAuthenticatorImplementation.OnAppInstanceActivated(application, args); #elif TIZEN /// diff --git a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 5c2f6ab8512a..ecd1d0ba92d4 100644 --- a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,5 +1,5 @@ #nullable enable -static Microsoft.Maui.ApplicationModel.Platform.OnAppActivation(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +static Microsoft.Maui.ApplicationModel.Platform.OnAppInstanceActivated(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool *REMOVED*Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task?>! *REMOVED*static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task!>! diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs index 992af4c9607d..957d9c9ddac7 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs @@ -16,7 +16,7 @@ namespace Microsoft.Maui.Authentication { partial class WebAuthenticatorImplementation : IWebAuthenticator { - internal static bool OnAppActivation(UI.Xaml.Application application, AppActivationArguments args) + internal static bool OnAppInstanceActivated(UI.Xaml.Application application, AppActivationArguments args) { if (args is null || args.Kind != ExtendedActivationKind.Protocol) return false; From 0cb668afd7b2eed9153217cff4d094c081377af5 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 9 Apr 2026 03:17:22 +0200 Subject: [PATCH 09/14] Modernize WebAuthenticator sample server to .NET 10 minimal APIs Replace the Startup.cs + Controller pattern with a single Program.cs using top-level statements and minimal API endpoints. Remove IIS Express launch profile and unused Logging.Debug package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Controllers/MobileAuthController.cs | 60 --------- ...ials.Sample.Server.WebAuthenticator.csproj | 1 - .../Sample.Server.WebAuthenticator/Program.cs | 118 +++++++++++++++--- .../Properties/launchSettings.json | 15 --- .../Sample.Server.WebAuthenticator/Startup.cs | 93 -------------- .../WebAuthenticator.windows.cs | 44 ++++--- 6 files changed, 131 insertions(+), 200 deletions(-) delete mode 100644 src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs delete mode 100644 src/Essentials/samples/Sample.Server.WebAuthenticator/Startup.cs diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs deleted file mode 100644 index 0e40b229d345..000000000000 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Sample.Server.WebAuthenticator -{ - [Route("mobileauth")] - [ApiController] - public class AuthController : ControllerBase - { - const string callbackScheme = "xamarinessentials"; - - [HttpGet("{scheme}")] - public async Task Get([FromRoute] string scheme) - { - var auth = await Request.HttpContext.AuthenticateAsync(scheme); - - if (!auth.Succeeded - || auth?.Principal == null - || !auth.Principal.Identities.Any(id => id.IsAuthenticated) - || string.IsNullOrEmpty(auth.Properties.GetTokenValue("access_token"))) - { - // Not authenticated, challenge - await Request.HttpContext.ChallengeAsync(scheme); - } - else - { - var claims = auth.Principal.Identities.FirstOrDefault()?.Claims; - var email = string.Empty; - email = claims?.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Email)?.Value; - - // Get parameters to send back to the callback - var qs = new Dictionary - { - { "access_token", auth.Properties.GetTokenValue("access_token") }, - { "refresh_token", auth.Properties.GetTokenValue("refresh_token") ?? string.Empty }, - { "expires_in", (auth.Properties.ExpiresUtc?.ToUnixTimeSeconds() ?? -1).ToString() }, - { "email", email }, - { "code", auth.Properties.GetTokenValue("code") ?? Guid.NewGuid().ToString() }, - { "state", auth.Properties.GetTokenValue("state") ?? string.Empty }, - }; - - // Build the result url - var url = callbackScheme + "://#" + string.Join( - "&", - qs.Where(kvp => !string.IsNullOrEmpty(kvp.Value) && kvp.Value != "-1") - .Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}")); - - // Redirect to final url - Request.HttpContext.Response.Redirect(url); - } - } - } -} \ No newline at end of file diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj b/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj index 8ab8c74cd3de..beef7f4d11d3 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj @@ -9,7 +9,6 @@ - diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs index e7caaeef9261..0cca0f407b5e 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs @@ -1,26 +1,110 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -namespace Sample.Server.WebAuthenticator +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthentication(o => + { + o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddFacebook(fb => + { + fb.AppId = builder.Configuration["FacebookAppId"]!; + fb.AppSecret = builder.Configuration["FacebookAppSecret"]!; + fb.SaveTokens = true; + }) + .AddGoogle(g => + { + g.ClientId = builder.Configuration["GoogleClientId"]!; + g.ClientSecret = builder.Configuration["GoogleClientSecret"]!; + g.SaveTokens = true; + }) + .AddMicrosoftAccount(ms => + { + ms.ClientId = builder.Configuration["MicrosoftClientId"]!; + ms.ClientSecret = builder.Configuration["MicrosoftClientSecret"]!; + ms.SaveTokens = true; + }) + .AddApple(a => + { + a.ClientId = builder.Configuration["AppleClientId"]!; + a.KeyId = builder.Configuration["AppleKeyId"]!; + a.TeamId = builder.Configuration["AppleTeamId"]!; + a.UsePrivateKey(keyId + => builder.Environment.ContentRootFileProvider.GetFileInfo($"AuthKey_{keyId}.p8")); + a.SaveTokens = true; + }); + +/* + * For Apple signin on Azure App Service, add the Configuration setting: + * WEBSITE_LOAD_USER_PROFILE = 1 + * Without this you will get a File Not Found exception when generating a certificate from AuthKey_{keyId}.p8. + */ + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +app.UseAuthentication(); +app.UseAuthorization(); + +const string callbackScheme = "xamarinessentials"; + +app.MapGet("/mobileauth/{scheme}", async (string scheme, HttpContext httpContext) => { - public class Program + var auth = await httpContext.AuthenticateAsync(scheme); + + if (!auth.Succeeded + || auth?.Principal == null + || !auth.Principal.Identities.Any(id => id.IsAuthenticated) + || string.IsNullOrEmpty(auth.Properties.GetTokenValue("access_token"))) { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); + await httpContext.ChallengeAsync(scheme); + return; } -} + + var claims = auth.Principal.Identities.FirstOrDefault()?.Claims; + var email = claims?.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ?? string.Empty; + + var qs = new Dictionary + { + { "access_token", auth.Properties.GetTokenValue("access_token")! }, + { "refresh_token", auth.Properties.GetTokenValue("refresh_token") ?? string.Empty }, + { "expires_in", (auth.Properties.ExpiresUtc?.ToUnixTimeSeconds() ?? -1).ToString() }, + { "email", email }, + { "code", auth.Properties.GetTokenValue("code") ?? Guid.NewGuid().ToString() }, + { "state", auth.Properties.GetTokenValue("state") ?? string.Empty }, + }; + + var url = callbackScheme + "://#" + string.Join( + "&", + qs.Where(kvp => !string.IsNullOrEmpty(kvp.Value) && kvp.Value != "-1") + .Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}")); + + httpContext.Response.Redirect(url); +}); + +// Simple redirect endpoint used by device tests — returns query parameters +// as a callback URI so the client can parse them without a real OAuth provider. +app.MapGet("/redirect", (HttpContext httpContext) => +{ + var qs = httpContext.Request.QueryString.Value ?? string.Empty; + var url = callbackScheme + "://" + qs; + httpContext.Response.Redirect(url); +}); + +app.Run(); diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Properties/launchSettings.json b/src/Essentials/samples/Sample.Server.WebAuthenticator/Properties/launchSettings.json index 6c0ef8184858..77dbb35757bb 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Properties/launchSettings.json +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Properties/launchSettings.json @@ -1,20 +1,5 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:58823/", - "sslPort": 44373 - } - }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "Sample.Server.WebAuthenticator": { "commandName": "Project", "launchBrowser": true, diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Startup.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Startup.cs deleted file mode 100644 index dc8ee7c5143e..000000000000 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Startup.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace Sample.Server.WebAuthenticator -{ - public class Startup - { - public Startup(IConfiguration configuration, IWebHostEnvironment webHostEnvironment) - { - Configuration = configuration; - WebHostEnvironment = webHostEnvironment; - } - - IConfiguration Configuration { get; } - - IWebHostEnvironment WebHostEnvironment { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 - public void ConfigureServices(IServiceCollection services) - { - services.AddControllers(); - - services.AddAuthentication(o => - { - o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - }) - .AddCookie() - .AddFacebook(fb => - { - fb.AppId = Configuration["FacebookAppId"]; - fb.AppSecret = Configuration["FacebookAppSecret"]; - fb.SaveTokens = true; - }) - .AddGoogle(g => - { - g.ClientId = Configuration["GoogleClientId"]; - g.ClientSecret = Configuration["GoogleClientSecret"]; - g.SaveTokens = true; - }) - .AddMicrosoftAccount(ms => - { - ms.ClientId = Configuration["MicrosoftClientId"]; - ms.ClientSecret = Configuration["MicrosoftClientSecret"]; - ms.SaveTokens = true; - }) - .AddApple(a => - { - a.ClientId = Configuration["AppleClientId"]; - a.KeyId = Configuration["AppleKeyId"]; - a.TeamId = Configuration["AppleTeamId"]; - a.UsePrivateKey(keyId - => WebHostEnvironment.ContentRootFileProvider.GetFileInfo($"AuthKey_{keyId}.p8")); - a.SaveTokens = true; - }); - - /* - * For Apple signin - * If you are running the app on Azure App Service you must add the Configuration setting - * WEBSITE_LOAD_USER_PROFILE = 1 - * Without this setting you will get a File Not Found exception when AppleAuthenticationHandler tries to generate a certificate using your AuthKey_{keyId}.p8 file. - */ - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseRouting(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } - } -} diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs index 957d9c9ddac7..8e5770a406e3 100644 --- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs +++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs @@ -47,34 +47,42 @@ public async Task AuthenticateAsync(WebAuthenticatorOpti var url = webAuthenticatorOptions.Url ?? throw new ArgumentNullException(nameof(webAuthenticatorOptions.Url)); var callbackUrl = webAuthenticatorOptions.CallbackUrl ?? throw new ArgumentNullException(nameof(webAuthenticatorOptions.CallbackUrl)); - bool isPackaged = AppInfoUtils.IsPackagedApp; + var windowId = WindowStateManager.Default.GetActiveAppWindow(false)?.Id; + if (!windowId.HasValue) + throw new InvalidOperationException("No active window found for authentication."); - if (isPackaged) + if (AppInfoUtils.IsPackagedApp) { if (!IsUriProtocolDeclared(callbackUrl.Scheme)) - throw new InvalidOperationException($"You need to declare the windows.protocol usage of the protocol/scheme `{callbackUrl.Scheme}` in your AppxManifest.xml file"); + { + throw new InvalidOperationException( + $"You need to declare the windows.protocol usage of the " + + $"protocol/scheme `{callbackUrl.Scheme}` in your AppxManifest.xml file"); + } } else { if (callbackUrl.Scheme == "http" || callbackUrl.Scheme == "https") - throw new InvalidOperationException($"{callbackUrl.Scheme}:// schemes are not allowed for callbackUri. Use a custom scheme like 'myapp' instead."); + { + throw new InvalidOperationException( + $"{callbackUrl.Scheme}:// schemes are not allowed for callbackUri. " + + $"Use a custom scheme like 'myapp' instead."); + } - var value = Microsoft.Win32.Registry.ClassesRoot.OpenSubKey(callbackUrl.Scheme); - if (value is null || value.GetValue("URL Protocol") is null) + if (!IsRegistryDeclared(callbackUrl.Scheme)) { - throw new InvalidOperationException($"The URI Scheme '{callbackUrl.Scheme}' is not registered. Call ActivationRegistrationManager.RegisterForProtocolActivation to register protocol activation."); + throw new InvalidOperationException( + $"The URI Scheme '{callbackUrl.Scheme}' is not registered. " + + $"Call ActivationRegistrationManager.RegisterForProtocolActivation to register protocol activation."); } } - AuthRequestParams authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl); - var windowId = WindowStateManager.Default.GetActiveAppWindow(false)?.Id; - if (!windowId.HasValue) - throw new InvalidOperationException("No active window found for authentication."); - - AuthRequestResult authRequestResult = await OAuth2Manager + var authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl); + var authRequestResult = await OAuth2Manager .RequestAuthWithParamsAsync(windowId.Value, url, authRequestParams) .AsTask(cancellationToken) .ConfigureAwait(false); + if (authRequestResult.Failure is not null) { var message = string.IsNullOrEmpty(authRequestResult.Failure.ErrorDescription) @@ -82,7 +90,9 @@ public async Task AuthenticateAsync(WebAuthenticatorOpti : $"{authRequestResult.Failure.Error}: {authRequestResult.Failure.ErrorDescription}"; if (IsUserCancellation(authRequestResult.Failure.Error, authRequestResult.Failure.ErrorDescription)) + { throw new TaskCanceledException(message); + } throw new InvalidOperationException(message); } @@ -108,8 +118,14 @@ static bool IsUriProtocolDeclared(string scheme) var root = doc.Root ?? throw new InvalidOperationException("The app manifest could not be loaded."); var decl = root.XPathSelectElements($"//uap:Extension[@Category='windows.protocol']/uap:Protocol[@Name='{scheme}']", namespaceManager); - return decl != null && decl.Any(); + return decl?.Any() == true; } + static bool IsRegistryDeclared(string scheme) + { + var value = Win32.Registry.ClassesRoot.OpenSubKey(scheme); + + return value?.GetValue("URL Protocol") is not null; + } } } From 00275e48f02897c67ee7745a26fc28a07d29fc4e Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 9 Apr 2026 03:18:04 +0200 Subject: [PATCH 10/14] Enable nullable and implicit usings for sample server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Essentials.Sample.Server.WebAuthenticator.csproj | 2 ++ .../samples/Sample.Server.WebAuthenticator/Program.cs | 8 -------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj b/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj index beef7f4d11d3..176cb4fb8d32 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj @@ -2,6 +2,8 @@ $(_MauiDotNetTfm) + enable + enable diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs index 0cca0f407b5e..a664db9d5a92 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs @@ -1,15 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; var builder = WebApplication.CreateBuilder(args); From a6966a12e6e0400a2d95c78b5b915977910cd5ca Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 9 Apr 2026 03:20:30 +0200 Subject: [PATCH 11/14] Centralize AspNet.Security.OAuth.Apple version in Versions.props Move the inline version from the sample csproj into eng/Versions.props and eng/NuGetVersions.targets, matching the pattern used by the other ASP.NET Core authentication packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/NuGetVersions.targets | 4 ++++ eng/Versions.props | 1 + ...Essentials.Sample.Server.WebAuthenticator.csproj | 2 +- .../Sample.Server.WebAuthenticator/Program.cs | 13 +++++-------- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/eng/NuGetVersions.targets b/eng/NuGetVersions.targets index 1c4fd496bcc5..317532915405 100644 --- a/eng/NuGetVersions.targets +++ b/eng/NuGetVersions.targets @@ -186,6 +186,10 @@ Update="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="$(MicrosoftAspNetCoreAuthenticationMicrosoftAccountPackageVersion)" /> + 10.0.0 10.0.0 10.0.0 + 9.2.0 10.0.0 10.0.0 10.0.0 diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj b/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj index 176cb4fb8d32..cfef328a2d6e 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs index a664db9d5a92..b1a4d6dc9478 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs @@ -30,20 +30,17 @@ }) .AddApple(a => { + // For Apple Sign In on Azure App Service, add the Configuration setting: + // WEBSITE_LOAD_USER_PROFILE = 1 + // Without this you will get a File Not Found exception when generating a certificate from AuthKey_{keyId}.p8. + a.ClientId = builder.Configuration["AppleClientId"]!; a.KeyId = builder.Configuration["AppleKeyId"]!; a.TeamId = builder.Configuration["AppleTeamId"]!; - a.UsePrivateKey(keyId - => builder.Environment.ContentRootFileProvider.GetFileInfo($"AuthKey_{keyId}.p8")); + a.UsePrivateKey(keyId => builder.Environment.ContentRootFileProvider.GetFileInfo($"AuthKey_{keyId}.p8")); a.SaveTokens = true; }); -/* - * For Apple signin on Azure App Service, add the Configuration setting: - * WEBSITE_LOAD_USER_PROFILE = 1 - * Without this you will get a File Not Found exception when generating a certificate from AuthKey_{keyId}.p8. - */ - var app = builder.Build(); if (app.Environment.IsDevelopment()) From 86c21b125aa3f5da339d47061b346723eed8a368 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 9 Apr 2026 03:21:57 +0200 Subject: [PATCH 12/14] Add explanatory comments to WebAuthenticator sample server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Sample.Server.WebAuthenticator/Program.cs | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs index b1a4d6dc9478..2c4b812cacf3 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs @@ -3,8 +3,22 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +// This is a companion server for testing MAUI's WebAuthenticator API. +// It acts as an OAuth broker: the mobile app opens a URL like /mobileauth/google, +// the server handles the OAuth dance with the provider, then redirects back to +// the app using the "xamarinessentials://" custom scheme with tokens in the URI. +// +// To run locally: +// dotnet run +// +// Provider credentials are read from configuration (user-secrets or appsettings): +// dotnet user-secrets set "GoogleClientId" "your-client-id" +// dotnet user-secrets set "GoogleClientSecret" "your-secret" + var builder = WebApplication.CreateBuilder(args); +// Register authentication providers. Each one needs client credentials +// configured via user-secrets, environment variables, or appsettings.json. builder.Services.AddAuthentication(o => { o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; @@ -32,8 +46,8 @@ { // For Apple Sign In on Azure App Service, add the Configuration setting: // WEBSITE_LOAD_USER_PROFILE = 1 - // Without this you will get a File Not Found exception when generating a certificate from AuthKey_{keyId}.p8. - + // Without this you will get a File Not Found exception when generating + // a certificate from AuthKey_{keyId}.p8. a.ClientId = builder.Configuration["AppleClientId"]!; a.KeyId = builder.Configuration["AppleKeyId"]!; a.TeamId = builder.Configuration["AppleTeamId"]!; @@ -51,8 +65,21 @@ app.UseAuthentication(); app.UseAuthorization(); +// This must match the protocol scheme registered in the MAUI app +// (e.g. in Package.appxmanifest on Windows or Info.plist on iOS). const string callbackScheme = "xamarinessentials"; +// Main OAuth endpoint. +// The mobile app calls: WebAuthenticator.AuthenticateAsync( +// new Uri("https://this-server/mobileauth/google"), +// new Uri("xamarinessentials://")); +// +// Flow: +// 1. First request → not authenticated → server challenges the provider (e.g. Google) +// 2. User signs in with the provider in the browser +// 3. Provider redirects back here with tokens +// 4. Server builds a xamarinessentials://# redirect with access_token, etc. +// 5. OS delivers the custom-scheme URI back to the MAUI app app.MapGet("/mobileauth/{scheme}", async (string scheme, HttpContext httpContext) => { var auth = await httpContext.AuthenticateAsync(scheme); @@ -62,10 +89,12 @@ || !auth.Principal.Identities.Any(id => id.IsAuthenticated) || string.IsNullOrEmpty(auth.Properties.GetTokenValue("access_token"))) { + // Not yet authenticated — redirect the user to the provider's login page. await httpContext.ChallengeAsync(scheme); return; } + // Authenticated — gather tokens and claims to send back to the app. var claims = auth.Principal.Identities.FirstOrDefault()?.Claims; var email = claims?.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ?? string.Empty; @@ -79,6 +108,7 @@ { "state", auth.Properties.GetTokenValue("state") ?? string.Empty }, }; + // Build the callback URI: xamarinessentials://#access_token=...&refresh_token=... var url = callbackScheme + "://#" + string.Join( "&", qs.Where(kvp => !string.IsNullOrEmpty(kvp.Value) && kvp.Value != "-1") @@ -87,8 +117,10 @@ httpContext.Response.Redirect(url); }); -// Simple redirect endpoint used by device tests — returns query parameters -// as a callback URI so the client can parse them without a real OAuth provider. +// Simple passthrough redirect used by device tests. +// Echoes query parameters back as a callback URI so the client can +// validate the round-trip without needing a real OAuth provider. +// Example: /redirect?access_token=abc → xamarinessentials://?access_token=abc app.MapGet("/redirect", (HttpContext httpContext) => { var qs = httpContext.Request.QueryString.Value ?? string.Empty; From 13372a78286706632011c48b5d7f5d5b9ebcfad1 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Thu, 9 Apr 2026 03:34:03 +0200 Subject: [PATCH 13/14] Add missing AddAuthorization() for .NET 10 minimal APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In .NET 10, UseAuthorization() requires AddAuthorization() to be called explicitly — it is no longer auto-registered by the framework. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ials.Sample.Server.WebAuthenticator.csproj | 3 +- .../Sample.Server.WebAuthenticator/Program.cs | 51 ++++++++++--------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj b/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj index cfef328a2d6e..277703c86f15 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Essentials.Sample.Server.WebAuthenticator.csproj @@ -1,9 +1,10 @@ - + $(_MauiDotNetTfm) enable enable + eda0c227-a2f1-4e09-b9f0-0ee493d760c7 diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs index 2c4b812cacf3..aa7df424bf40 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs @@ -24,36 +24,39 @@ o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie() - .AddFacebook(fb => - { - fb.AppId = builder.Configuration["FacebookAppId"]!; - fb.AppSecret = builder.Configuration["FacebookAppSecret"]!; - fb.SaveTokens = true; - }) - .AddGoogle(g => - { - g.ClientId = builder.Configuration["GoogleClientId"]!; - g.ClientSecret = builder.Configuration["GoogleClientSecret"]!; - g.SaveTokens = true; - }) + //.AddFacebook(fb => + //{ + // fb.AppId = builder.Configuration["FacebookAppId"]!; + // fb.AppSecret = builder.Configuration["FacebookAppSecret"]!; + // fb.SaveTokens = true; + //}) + //.AddGoogle(g => + //{ + // g.ClientId = builder.Configuration["GoogleClientId"]!; + // g.ClientSecret = builder.Configuration["GoogleClientSecret"]!; + // g.SaveTokens = true; + //}) .AddMicrosoftAccount(ms => { ms.ClientId = builder.Configuration["MicrosoftClientId"]!; ms.ClientSecret = builder.Configuration["MicrosoftClientSecret"]!; ms.SaveTokens = true; }) - .AddApple(a => - { - // For Apple Sign In on Azure App Service, add the Configuration setting: - // WEBSITE_LOAD_USER_PROFILE = 1 - // Without this you will get a File Not Found exception when generating - // a certificate from AuthKey_{keyId}.p8. - a.ClientId = builder.Configuration["AppleClientId"]!; - a.KeyId = builder.Configuration["AppleKeyId"]!; - a.TeamId = builder.Configuration["AppleTeamId"]!; - a.UsePrivateKey(keyId => builder.Environment.ContentRootFileProvider.GetFileInfo($"AuthKey_{keyId}.p8")); - a.SaveTokens = true; - }); + //.AddApple(a => + //{ + // // For Apple Sign In on Azure App Service, add the Configuration setting: + // // WEBSITE_LOAD_USER_PROFILE = 1 + // // Without this you will get a File Not Found exception when generating + // // a certificate from AuthKey_{keyId}.p8. + // a.ClientId = builder.Configuration["AppleClientId"]!; + // a.KeyId = builder.Configuration["AppleKeyId"]!; + // a.TeamId = builder.Configuration["AppleTeamId"]!; + // a.UsePrivateKey(keyId => builder.Environment.ContentRootFileProvider.GetFileInfo($"AuthKey_{keyId}.p8")); + // a.SaveTokens = true; + //}) + ; + +builder.Services.AddAuthorization(); var app = builder.Build(); From dc62109f0cced730c4192e948dbb89012a722731 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 10 Apr 2026 14:23:59 +0200 Subject: [PATCH 14/14] Windows WebAuthenticator: OAuth2Manager + TCS dual-strategy Use OAuth2Manager.RequestAuthWithParamsAsync as the primary auth flow for standard OAuth2 authorization code + PKCE, racing against a TCS fallback that handles server-brokered flows via protocol activation. - Add IPlatformWebAuthenticatorCallback.OnAppInstanceActivatedCallback for Windows, matching the iOS/Android callback pattern - Add WebAuthenticatorExtensions.OnAppInstanceActivated extension method - Race OAuth2Manager against TCS via Task.WhenAny so server-brokered flows (tokens in callback URI) complete without blocking - Update sample server to use query string format (?code=&state=) for OAuth2Manager compatibility while preserving iOS/Android support - Add direct OAuth2 (Entra) test button to Essentials sample - Route Platform.OnAppInstanceActivated through WebAuthenticator.Default so custom IWebAuthenticator implementations get the callback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EssentialsMauiAppBuilderExtensions.cs | 18 +- .../Sample.Server.WebAuthenticator/Program.cs | 33 +++- .../Samples/View/WebAuthenticatorPage.xaml | 1 + .../ViewModel/WebAuthenticatorViewModel.cs | 45 ++++- .../src/Platform/Platform.shared.cs | 2 +- .../net-windows/PublicAPI.Unshipped.txt | 2 + .../WebAuthenticator.shared.cs | 13 ++ .../WebAuthenticator.windows.cs | 166 +++++++++++++----- 8 files changed, 230 insertions(+), 50 deletions(-) diff --git a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs index ed35c80fa5a3..18bab0350940 100644 --- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs +++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs @@ -67,7 +67,23 @@ internal static MauiAppBuilder UseEssentials(this MauiAppBuilder builder) life.AddWindows(windows => windows .OnAppInstanceActivated((application, args) => { - return ApplicationModel.Platform.OnAppInstanceActivated(application, args); + // Let the WebAuthenticator (default or custom) handle the callback first. + if (ApplicationModel.Platform.OnAppInstanceActivated(application, args)) + return true; + + // No handler claimed it — check if another instance owns the + // activation key. This handles the case where the OS launches a + // transient instance for a protocol callback that belongs to the + // original instance's pending OAuth flow. + var keyInstance = Microsoft.Windows.AppLifecycle.AppInstance.FindOrRegisterForKey("MauiEssentials"); + if (!keyInstance.IsCurrent) + { + keyInstance.RedirectActivationToAsync(args).AsTask().GetAwaiter().GetResult(); + System.Diagnostics.Process.GetCurrentProcess().Kill(); + return true; + } + + return false; }) .OnActivated((window, args) => { diff --git a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs index aa7df424bf40..5da731c96747 100644 --- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs +++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs @@ -60,6 +60,14 @@ var app = builder.Build(); +// When running behind a reverse proxy (e.g. dev tunnels, Azure App Service), +// use the forwarded headers so OAuth redirect URIs use the public hostname +// instead of localhost. +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.All +}); + if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); @@ -81,8 +89,15 @@ // 1. First request → not authenticated → server challenges the provider (e.g. Google) // 2. User signs in with the provider in the browser // 3. Provider redirects back here with tokens -// 4. Server builds a xamarinessentials://# redirect with access_token, etc. +// 4. Server builds a callback URI with tokens and redirects back to the app // 5. OS delivers the custom-scheme URI back to the MAUI app +// +// The callback uses query string format (?key=value) so that: +// - Windows OAuth2Manager.CompleteAuthRequest can parse it (requires ? not #) +// - iOS/Android WebAuthenticatorResult.ParseQueryString handles both ? and # formats +// +// The server preserves the 'state' parameter from the original request so that +// OAuth2Manager can match the callback to the pending authorization request. app.MapGet("/mobileauth/{scheme}", async (string scheme, HttpContext httpContext) => { var auth = await httpContext.AuthenticateAsync(scheme); @@ -103,16 +118,20 @@ var qs = new Dictionary { + // Standard OAuth2 parameters + { "code", auth.Properties.GetTokenValue("code") ?? Guid.NewGuid().ToString() }, + { "state", httpContext.Request.Query["state"].FirstOrDefault() + ?? auth.Properties.GetTokenValue("state") ?? string.Empty }, + // Additional tokens for server-brokered flows (iOS/Android compatibility) { "access_token", auth.Properties.GetTokenValue("access_token")! }, { "refresh_token", auth.Properties.GetTokenValue("refresh_token") ?? string.Empty }, { "expires_in", (auth.Properties.ExpiresUtc?.ToUnixTimeSeconds() ?? -1).ToString() }, { "email", email }, - { "code", auth.Properties.GetTokenValue("code") ?? Guid.NewGuid().ToString() }, - { "state", auth.Properties.GetTokenValue("state") ?? string.Empty }, }; - // Build the callback URI: xamarinessentials://#access_token=...&refresh_token=... - var url = callbackScheme + "://#" + string.Join( + // Use query string format (?) for Windows OAuth2Manager compatibility. + // iOS/Android WebAuthenticatorResult handles both ? and # via WebUtils.ParseQueryString. + var url = callbackScheme + "://callback?" + string.Join( "&", qs.Where(kvp => !string.IsNullOrEmpty(kvp.Value) && kvp.Value != "-1") .Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}")); @@ -123,11 +142,11 @@ // Simple passthrough redirect used by device tests. // Echoes query parameters back as a callback URI so the client can // validate the round-trip without needing a real OAuth provider. -// Example: /redirect?access_token=abc → xamarinessentials://?access_token=abc +// Example: /redirect?access_token=abc → xamarinessentials://callback?access_token=abc app.MapGet("/redirect", (HttpContext httpContext) => { var qs = httpContext.Request.QueryString.Value ?? string.Empty; - var url = callbackScheme + "://" + qs; + var url = callbackScheme + "://callback" + qs; httpContext.Response.Redirect(url); }); diff --git a/src/Essentials/samples/Samples/View/WebAuthenticatorPage.xaml b/src/Essentials/samples/Samples/View/WebAuthenticatorPage.xaml index 04024298af9a..1fb2d3a44312 100644 --- a/src/Essentials/samples/Samples/View/WebAuthenticatorPage.xaml +++ b/src/Essentials/samples/Samples/View/WebAuthenticatorPage.xaml @@ -15,6 +15,7 @@