Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3d29a2e
Add OAuth WebAuthenticator support to Windows
Jun 18, 2025
c387415
Fix test run crash
Jun 18, 2025
e6f9f98
Simplify condition
Jun 18, 2025
271e3ab
report authentication error
Jun 18, 2025
a9f9e01
Ensure code and state parameters are preserved
Jun 18, 2025
56847bd
Merge remote-tracking branch 'dotnet/maui/main' into dotmorten/window…
dotMorten Mar 25, 2026
aa50699
Fix typo
dotMorten Mar 25, 2026
34217e7
Merge remote-tracking branch 'origin/main' into working
mattleibow Apr 8, 2026
9dcdd3c
[Windows] WebAuthenticator: use app activation for OAuth callbacks
mattleibow Apr 8, 2026
f41f0f3
Merge branch 'windows-app-activation-lifecycle' into windows-oauth-ac…
mattleibow Apr 8, 2026
d94e1c7
Merge windows-app-activation-lifecycle into windows-oauth-activation-…
mattleibow Apr 8, 2026
eb9a840
Merge windows-app-activation-lifecycle into windows-oauth-activation-…
mattleibow Apr 9, 2026
06d61d2
Merge windows-app-activation-lifecycle into windows-oauth-activation-…
mattleibow Apr 9, 2026
1b66e12
Rename OnAppActivation to OnAppInstanceActivated in Essentials
mattleibow Apr 9, 2026
0cb668a
Modernize WebAuthenticator sample server to .NET 10 minimal APIs
mattleibow Apr 9, 2026
00275e4
Enable nullable and implicit usings for sample server
mattleibow Apr 9, 2026
a6966a1
Centralize AspNet.Security.OAuth.Apple version in Versions.props
mattleibow Apr 9, 2026
86c21b1
Add explanatory comments to WebAuthenticator sample server
mattleibow Apr 9, 2026
13372a7
Add missing AddAuthorization() for .NET 10 minimal APIs
mattleibow Apr 9, 2026
dc62109
Windows WebAuthenticator: OAuth2Manager + TCS dual-strategy
mattleibow Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,4 +57,4 @@ public async Task Get([FromRoute] string scheme)
}
}
}
}
}
9 changes: 9 additions & 0 deletions src/Essentials/src/Platform/Platform.shared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Called when the Windows application receives activation arguments that may be handled by platform features.
/// </summary>
/// <param name="application">The application instance that received the activation.</param>
/// <param name="args">The activation arguments.</param>
/// <returns><see langword="true"/> if a platform feature handled the activation; otherwise, <see langword="false"/>.</returns>
public static bool OnAppActivation(UI.Xaml.Application application, Microsoft.Windows.AppLifecycle.AppActivationArguments args) =>
WebAuthenticatorImplementation.OnAppActivation(application, args);

#elif TIZEN
/// <summary>
/// Gets a <see cref="Tizen.Applications.Package"/> object with information about the current application package.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<System.Collections.Generic.IEnumerable<Microsoft.Maui.Storage.FileResult?>!>!
Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<Microsoft.Maui.Storage.FileResult!>?>!
*REMOVED*static Microsoft.Maui.Storage.FilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<Microsoft.Maui.Storage.FileResult?>!>!
Expand Down
14 changes: 0 additions & 14 deletions src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ public interface IWebAuthenticator
/// <param name="webAuthenticatorOptions">A <see cref="WebAuthenticatorOptions"/> instance containing additional configuration for this authentication call.</param>
/// <returns>A <see cref="WebAuthenticatorResult"/> object with the results of this operation.</returns>
/// <exception cref="TaskCanceledException">Thrown when the user canceled the authentication flow.</exception>
/// <exception cref="PlatformNotSupportedException">Windows: Thrown when called on Windows.</exception>
/// <exception cref="FeatureNotSupportedException">iOS/macOS: Thrown when iOS version is less than 13 is used or macOS less than 13.1 is used.</exception>
/// <exception cref="InvalidOperationException">
/// <para>Android: Thrown when the no IntentFilter has been created for the callback URL.</para>
Expand All @@ -36,7 +35,6 @@ public interface IWebAuthenticator
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="WebAuthenticatorResult"/> object with the results of this operation.</returns>
/// <exception cref="TaskCanceledException">Thrown when the user canceled the authentication flow.</exception>
/// <exception cref="PlatformNotSupportedException">Windows: Thrown when called on Windows.</exception>
/// <exception cref="FeatureNotSupportedException">iOS/macOS: Thrown when iOS version is less than 13 is used or macOS less than 13.1 is used.</exception>
/// <exception cref="InvalidOperationException">
/// <para>Android: Thrown when the no IntentFilter has been created for the callback URL.</para>
Expand Down Expand Up @@ -93,9 +91,6 @@ public static class WebAuthenticator
/// <param name="url"> Url to navigate to, beginning the authentication flow.</param>
/// <param name="callbackUrl"> Expected callback url that the navigation flow will eventually redirect to.</param>
/// <returns>Returns a result parsed out from the callback url.</returns>
#if !NETSTANDARD
[System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
#endif
public static Task<WebAuthenticatorResult> AuthenticateAsync(Uri url, Uri callbackUrl)
=> Current.AuthenticateAsync(url, callbackUrl);

Expand All @@ -104,28 +99,19 @@ public static Task<WebAuthenticatorResult> AuthenticateAsync(Uri url, Uri callba
/// <param name="callbackUrl"> Expected callback url that the navigation flow will eventually redirect to.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>Returns a result parsed out from the callback url.</returns>
#if !NETSTANDARD
[System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
#endif
public static Task<WebAuthenticatorResult> AuthenticateAsync(Uri url, Uri callbackUrl, CancellationToken cancellationToken)
=> Current.AuthenticateAsync(url, callbackUrl, cancellationToken);

/// <summary>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.</summary>
/// <param name="webAuthenticatorOptions">Options to configure the authentication request.</param>
/// <returns>Returns a result parsed out from the callback url.</returns>
#if !NETSTANDARD
[System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
#endif
public static Task<WebAuthenticatorResult> AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions)
=> Current.AuthenticateAsync(webAuthenticatorOptions);

/// <summary>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.</summary>
/// <param name="webAuthenticatorOptions">Options to configure the authentication request.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>Returns a result parsed out from the callback url.</returns>
#if !NETSTANDARD
[System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
#endif
public static Task<WebAuthenticatorResult> AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken)
=> Current.AuthenticateAsync(webAuthenticatorOptions, cancellationToken);

Expand Down
105 changes: 101 additions & 4 deletions src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,115 @@
#nullable enable
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Storage;
using Microsoft.Security.Authentication.OAuth;
using Microsoft.Windows.AppLifecycle;
using Windows.ApplicationModel.Activation;

namespace Microsoft.Maui.Authentication
{
partial class WebAuthenticatorImplementation : IWebAuthenticator
{
public Task<WebAuthenticatorResult> AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions)
internal static bool OnAppActivation(UI.Xaml.Application application, AppActivationArguments args)
{
throw new PlatformNotSupportedException("This implementation of WebAuthenticator does not support Windows. See https://github.com/microsoft/WindowsAppSDK/issues/441 for more details.");
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 Task<WebAuthenticatorResult> AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken)

public async Task<WebAuthenticatorResult> AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions)
=> await AuthenticateAsync(webAuthenticatorOptions, CancellationToken.None).ConfigureAwait(false);

public async Task<WebAuthenticatorResult> AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken)
{
throw new PlatformNotSupportedException("This implementation of WebAuthenticator does not support Windows. See https://github.com/microsoft/WindowsAppSDK/issues/441 for more details.");
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 = 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");
}
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.");
}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registry.ClassesRoot.OpenSubKey(...) returns a RegistryKey that should be disposed/closed. Consider using using var value = ... (or a try/finally) before reading URL Protocol, to avoid leaking registry handles in unpackaged scenarios.

Copilot uses AI. Check for mistakes.
}
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
.RequestAuthWithParamsAsync(windowId.Value, url, authRequestParams)
.AsTask(cancellationToken)
.ConfigureAwait(false);
if (authRequestResult.Failure is not null)
{
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);
var doc = XDocument.Load(docPath, LoadOptions.None);
var reader = doc.CreateReader();

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc.CreateReader() returns an XmlReader that implements IDisposable. Consider disposing it (e.g., using var reader = doc.CreateReader();) to avoid holding onto unmanaged resources longer than necessary when parsing the manifest.

Suggested change
var reader = doc.CreateReader();
using var reader = doc.CreateReader();

Copilot uses AI. Check for mistakes.
var namespaceManager = new XmlNamespaceManager(reader.NameTable);
namespaceManager.AddNamespace("x", PlatformUtils.AppManifestXmlns);
namespaceManager.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10");

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsUriProtocolDeclared hard-codes the UAP XML namespace string even though PlatformUtils.AppManifestUapXmlns already defines it (and is used elsewhere, e.g., Permissions). Using the shared constant would reduce duplication and avoid drift if the namespace ever changes.

Suggested change
namespaceManager.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10");
namespaceManager.AddNamespace("uap", PlatformUtils.AppManifestUapXmlns);

Copilot uses AI. Check for mistakes.

// Check if the protocol was declared
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();
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
<Extensions>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="xamarinessentials"/>
</uap:Extension>
</Extensions>
</Application>
</Applications>

Expand Down
25 changes: 0 additions & 25 deletions src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,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<PlatformNotSupportedException>(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]
Expand All @@ -50,24 +44,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<PlatformNotSupportedException>(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
}


Expand All @@ -81,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<PlatformNotSupportedException>(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]
Expand All @@ -111,25 +92,19 @@ 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
{
Url = new Uri($"{urlBase}?access_token={accessToken}&refresh_token={refreshToken}&expires={expires}"),
CallbackUrl = new Uri($"{callbackScheme}://"),
ResponseDecoder = responseDecoder
}, cts.Token);
#pragma warning restore CA1416 // Validate platform compatibility
#if WINDOWS
var exception = await Assert.ThrowsAsync<PlatformNotSupportedException>(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
}


Expand Down
Loading