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/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs
index 92f8344518e4..18bab0350940 100644
--- a/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs
+++ b/src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs
@@ -65,6 +65,26 @@ internal static MauiAppBuilder UseEssentials(this MauiAppBuilder builder)
}));
#elif WINDOWS
life.AddWindows(windows => windows
+ .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) =>
{
ApplicationModel.Platform.OnActivated(window, args);
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 4451a230614b..000000000000
--- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Controllers/MobileAuthController.cs
+++ /dev/null
@@ -1,58 +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 }
- };
-
- // 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);
- }
- }
- }
-}
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..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,15 +1,17 @@
-
+
$(_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 e7caaeef9261..5da731c96747 100644
--- a/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs
+++ b/src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs
@@ -1,26 +1,153 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-
-namespace Sample.Server.WebAuthenticator
+using System.Net;
+using System.Security.Claims;
+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;
+ })
+ .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 =>
+ //{
+ // // 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();
+
+// 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())
{
- public class Program
+ app.UseDeveloperExceptionPage();
+}
+
+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 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);
+
+ 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();
- });
+ // 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;
+
+ 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 },
+ };
+
+ // 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)}"));
+
+ httpContext.Response.Redirect(url);
+});
+
+// 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://callback?access_token=abc
+app.MapGet("/redirect", (HttpContext httpContext) =>
+{
+ var qs = httpContext.Request.QueryString.Value ?? string.Empty;
+ var url = callbackScheme + "://callback" + 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/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 @@
+
diff --git a/src/Essentials/samples/Samples/ViewModel/WebAuthenticatorViewModel.cs b/src/Essentials/samples/Samples/ViewModel/WebAuthenticatorViewModel.cs
index 85c1b46723ea..1f3f8a3e036a 100644
--- a/src/Essentials/samples/Samples/ViewModel/WebAuthenticatorViewModel.cs
+++ b/src/Essentials/samples/Samples/ViewModel/WebAuthenticatorViewModel.cs
@@ -9,11 +9,17 @@ namespace Samples.ViewModel
{
public class WebAuthenticatorViewModel : BaseViewModel
{
- const string authenticationUrl = "https://xamarin-essentials-auth-sample.azurewebsites.net/mobileauth/";
+ const string authenticationUrl = "https://xxhwft3b-5001.inc1.devtunnels.ms/mobileauth/";
+
+ // Direct OAuth2 — no intermediary server. OAuth2Manager handles the full PKCE flow.
+ // Set your Entra client ID here and register xamarinessentials://auth as a Mobile/Desktop redirect URI.
+ const string entraClientId = "bc1980c5-6d94-48db-8fdc-0337290a76bd";
+ const string entraAuthorizeUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
public WebAuthenticatorViewModel()
{
MicrosoftCommand = new Command(async () => await OnAuthenticate("Microsoft"));
+ MicrosoftDirectCommand = new Command(async () => await OnAuthenticateDirect());
GoogleCommand = new Command(async () => await OnAuthenticate("Google"));
FacebookCommand = new Command(async () => await OnAuthenticate("Facebook"));
AppleCommand = new Command(async () => await OnAuthenticate("Apple"));
@@ -21,6 +27,8 @@ public WebAuthenticatorViewModel()
public ICommand MicrosoftCommand { get; }
+ public ICommand MicrosoftDirectCommand { get; }
+
public ICommand GoogleCommand { get; }
public ICommand FacebookCommand { get; }
@@ -84,5 +92,40 @@ async Task OnAuthenticate(string scheme)
await DisplayAlertAsync($"Failed: {ex.Message}");
}
}
+
+ ///
+ /// Direct OAuth2 flow — talks directly to the Entra authorize endpoint.
+ /// On Windows, this uses OAuth2Manager with PKCE (no intermediary server needed).
+ ///
+ async Task OnAuthenticateDirect()
+ {
+ try
+ {
+ var callbackUrl = new Uri("xamarinessentials://auth");
+
+ // Build the authorize URL with client_id and scope.
+ // OAuth2Manager adds PKCE code_challenge, state, and redirect_uri automatically.
+ var authUrl = new Uri($"{entraAuthorizeUrl}?client_id={entraClientId}&scope=openid%20email%20profile");
+
+ var r = await WebAuthenticator.AuthenticateAsync(authUrl, callbackUrl);
+
+ AuthToken = string.Empty;
+ if (r.Properties.TryGetValue("name", out var name) && !string.IsNullOrEmpty(name))
+ AuthToken += $"Name: {name}{Environment.NewLine}";
+ if (r.Properties.TryGetValue("email", out var email) && !string.IsNullOrEmpty(email))
+ AuthToken += $"Email: {email}{Environment.NewLine}";
+ AuthToken += r?.AccessToken ?? r?.IdToken;
+ }
+ catch (OperationCanceledException)
+ {
+ AuthToken = string.Empty;
+ await DisplayAlertAsync("Login canceled.");
+ }
+ catch (Exception ex)
+ {
+ AuthToken = string.Empty;
+ await DisplayAlertAsync($"Failed: {ex.Message}");
+ }
+ }
}
}
diff --git a/src/Essentials/src/Platform/Platform.shared.cs b/src/Essentials/src/Platform/Platform.shared.cs
index 3fa0f8d0877d..dea6c36dc4de 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 OnAppInstanceActivated(UI.Xaml.Application application, Microsoft.Windows.AppLifecycle.AppActivationArguments args) =>
+ WebAuthenticator.Default.OnAppInstanceActivated(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..30a4bb38c75d 100644
--- a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -1,4 +1,7 @@
#nullable enable
+Microsoft.Maui.Authentication.IPlatformWebAuthenticatorCallback.OnAppInstanceActivatedCallback(Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool
+static Microsoft.Maui.ApplicationModel.Platform.OnAppInstanceActivated(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool
+static Microsoft.Maui.Authentication.WebAuthenticatorExtensions.OnAppInstanceActivated(this Microsoft.Maui.Authentication.IWebAuthenticator! webAuthenticator, 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.shared.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs
index 6b256deb6c6e..28b72a03130b 100644
--- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs
+++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.shared.cs
@@ -22,7 +22,6 @@ public interface IWebAuthenticator
/// A instance containing additional configuration for this authentication call.
/// A object with the results of this operation.
/// Thrown when the user canceled the authentication flow.
- /// Windows: Thrown when called on Windows.
/// iOS/macOS: Thrown when iOS version is less than 13 is used or macOS less than 13.1 is used.
///
/// Android: Thrown when the no IntentFilter has been created for the callback URL.
@@ -36,7 +35,6 @@ public interface IWebAuthenticator
/// A to monitor for cancellation requests.
/// A object with the results of this operation.
/// Thrown when the user canceled the authentication flow.
- /// Windows: Thrown when called on Windows.
/// iOS/macOS: Thrown when iOS version is less than 13 is used or macOS less than 13.1 is used.
///
/// Android: Thrown when the no IntentFilter has been created for the callback URL.
@@ -64,6 +62,15 @@ public interface IPlatformWebAuthenticatorCallback
/// An object containing additional data about this resume operation.
/// when the callback can be processed, otherwise .
bool OnResumeCallback(Intent intent);
+#elif WINDOWS
+ ///
+ /// Called when the app receives an AppInstance activation callback (e.g. protocol activation)
+ /// that may be part of an authentication flow. Custom implementations can handle the activation
+ /// before the default single-instance redirect behavior runs.
+ ///
+ /// The activation arguments delivered by the OS.
+ /// when the activation was handled, otherwise .
+ bool OnAppInstanceActivatedCallback(Microsoft.Windows.AppLifecycle.AppActivationArguments args);
#endif
}
@@ -93,9 +100,6 @@ 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);
@@ -104,18 +108,12 @@ public static Task AuthenticateAsync(Uri url, Uri callba
/// Expected callback url that the navigation flow will eventually redirect to.
/// A to monitor for cancellation requests.
/// 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, CancellationToken cancellationToken)
=> Current.AuthenticateAsync(url, callbackUrl, cancellationToken);
/// 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);
@@ -123,9 +121,6 @@ public static Task AuthenticateAsync(WebAuthenticatorOpt
/// Options to configure the authentication request.
/// A to monitor for cancellation requests.
/// Returns a result parsed out from the callback url.
-#if !NETSTANDARD
- [System.Runtime.Versioning.UnsupportedOSPlatform("windows")]
-#endif
public static Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken)
=> Current.AuthenticateAsync(webAuthenticatorOptions, cancellationToken);
@@ -210,6 +205,10 @@ public static bool ContinueUserActivity(this IWebAuthenticator webAuthenticator,
///
public static bool OnResume(this IWebAuthenticator webAuthenticator, Intent intent) =>
webAuthenticator.AsPlatformCallback().OnResumeCallback(intent);
+#elif WINDOWS
+ ///
+ public static bool OnAppInstanceActivated(this IWebAuthenticator webAuthenticator, Microsoft.Windows.AppLifecycle.AppActivationArguments args) =>
+ webAuthenticator.AsPlatformCallback().OnAppInstanceActivatedCallback(args);
#endif
}
diff --git a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs
index 05782395a151..f6b047857451 100644
--- a/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs
+++ b/src/Essentials/src/WebAuthenticator/WebAuthenticator.windows.cs
@@ -1,18 +1,217 @@
+#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
+ partial class WebAuthenticatorImplementation : IWebAuthenticator, IPlatformWebAuthenticatorCallback
{
- public Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions)
+ // TCS for receiving protocol activation callbacks.
+ // Used by both OAuth2Manager flows (where it races against RequestAuthWithParamsAsync)
+ // and server-brokered flows (where it is the sole completion mechanism).
+ TaskCompletionSource? tcsResponse;
+ Uri? currentRedirectUri;
+ WebAuthenticatorOptions? currentOptions;
+
+ ///
+ public bool OnAppInstanceActivatedCallback(AppActivationArguments args)
+ {
+ if (args is null || args.Kind != ExtendedActivationKind.Protocol)
+ {
+ System.Diagnostics.Debug.WriteLine($"[WebAuthenticator] OnAppInstanceActivatedCallback: skipped (Kind={args?.Kind})");
+ return false;
+ }
+
+ if (args.Data is not IProtocolActivatedEventArgs protocolArgs)
+ {
+ System.Diagnostics.Debug.WriteLine("[WebAuthenticator] OnAppInstanceActivatedCallback: not IProtocolActivatedEventArgs");
+ return false;
+ }
+
+ var uri = protocolArgs.Uri;
+ System.Diagnostics.Debug.WriteLine($"[WebAuthenticator] OnAppInstanceActivatedCallback: URI={uri}");
+
+ // First, try OAuth2Manager — completes standard authorization code + PKCE flows.
+ if (OAuth2Manager.CompleteAuthRequest(uri))
+ {
+ System.Diagnostics.Debug.WriteLine("[WebAuthenticator] CompleteAuthRequest succeeded (OAuth2Manager flow)");
+ // Also complete TCS so the racing Task.WhenAny in AuthenticateAsync unblocks.
+ // Use TrySetResult since it may already be completed or cancelled.
+ var callbackUri = new Uri(uri.ToString());
+ tcsResponse?.TrySetResult(new WebAuthenticatorResult(callbackUri, currentOptions?.ResponseDecoder));
+ return true;
+ }
+
+ // Fallback: complete the TCS for server-brokered flows that return tokens directly.
+ if (tcsResponse is not null && !(tcsResponse.Task?.IsCompleted ?? true))
+ {
+ try
+ {
+ var callbackUri = new Uri(uri.ToString());
+
+ if (currentRedirectUri is not null && !WebUtils.CanHandleCallback(currentRedirectUri, callbackUri))
+ {
+ System.Diagnostics.Debug.WriteLine($"[WebAuthenticator] TCS fallback: scheme mismatch (expected {currentRedirectUri.Scheme})");
+ return false;
+ }
+
+ System.Diagnostics.Debug.WriteLine("[WebAuthenticator] TCS fallback: completing auth result");
+ tcsResponse.TrySetResult(new WebAuthenticatorResult(callbackUri, currentOptions?.ResponseDecoder));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"[WebAuthenticator] TCS fallback exception: {ex}");
+ tcsResponse.TrySetException(ex);
+ return true;
+ }
+ }
+
+ System.Diagnostics.Debug.WriteLine("[WebAuthenticator] OnAppInstanceActivatedCallback: no handler matched");
+ return false;
+ }
+
+ public async Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions)
+ => await AuthenticateAsync(webAuthenticatorOptions, CancellationToken.None).ConfigureAwait(false);
+
+ public async Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken)
+ {
+ 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));
+
+ 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");
+ }
+ }
+ else
+ {
+ if (callbackUrl.Scheme is "http" or "https")
+ {
+ throw new InvalidOperationException(
+ $"{callbackUrl.Scheme}:// schemes are not allowed for callbackUri. " +
+ $"Use a custom scheme like 'myapp' instead.");
+ }
+
+ if (!IsRegistryDeclared(callbackUrl.Scheme))
+ {
+ throw new InvalidOperationException(
+ $"The URI Scheme '{callbackUrl.Scheme}' is not registered. " +
+ $"Call ActivationRegistrationManager.RegisterForProtocolActivation to register protocol activation.");
+ }
+ }
+
+ // Set up TCS before launching the browser — protocol activation callbacks will complete it.
+ if (tcsResponse?.Task != null && !tcsResponse.Task.IsCompleted)
+ tcsResponse.TrySetCanceled();
+
+ tcsResponse = new TaskCompletionSource();
+ currentRedirectUri = callbackUrl;
+ currentOptions = webAuthenticatorOptions;
+
+ using (cancellationToken.Register(() => tcsResponse.TrySetCanceled()))
+ {
+ // Use OAuth2Manager to open the browser and manage the auth flow.
+ // OAuth2Manager handles PKCE for direct OAuth2 flows; for server-brokered flows
+ // it will fail (the server returns tokens, not an auth code), but the TCS fallback
+ // catches the callback via protocol activation.
+ // Race both: whichever completes first wins.
+ var windowId = WindowStateManager.Default.GetActiveAppWindow(false)?.Id;
+ if (windowId.HasValue)
+ {
+ var authRequestParams = AuthRequestParams.CreateForAuthorizationCodeRequest("", callbackUrl);
+ var oauthTask = OAuth2Manager
+ .RequestAuthWithParamsAsync(windowId.Value, url, authRequestParams)
+ .AsTask(cancellationToken);
+
+ // Wait for either OAuth2Manager or TCS (protocol activation callback) to complete.
+ var completedTask = await Task.WhenAny(oauthTask, tcsResponse.Task).ConfigureAwait(false);
+
+ if (completedTask == tcsResponse.Task)
+ {
+ // TCS won — server-brokered flow or OAuth2Manager completed it via the callback.
+ return await tcsResponse.Task.ConfigureAwait(false);
+ }
+
+ // OAuth2Manager finished first — check its result.
+ var authRequestResult = await oauthTask.ConfigureAwait(false);
+ if (authRequestResult.Response is not null)
+ {
+ return new WebAuthenticatorResult(authRequestResult.ResponseUri, webAuthenticatorOptions.ResponseDecoder);
+ }
+
+ // OAuth2Manager failed — if TCS was completed while we checked, use it.
+ if (tcsResponse.Task.IsCompleted)
+ {
+ return await tcsResponse.Task.ConfigureAwait(false);
+ }
+
+ // Report the failure.
+ 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);
+ }
+ }
+
+ // No active window — fall back to Launcher + TCS only.
+ var launched = await global::Windows.System.Launcher.LaunchUriAsync(url);
+ if (!launched)
+ throw new InvalidOperationException("Failed to launch the browser for authentication.");
+
+ return await tcsResponse.Task.ConfigureAwait(false);
+ }
+ }
+
+ 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)
{
- throw new PlatformNotSupportedException("This implementation of WebAuthenticator does not support Windows. See https://github.com/microsoft/WindowsAppSDK/issues/441 for more details.");
+ 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");
+
+ 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?.Any() == true;
}
- public Task AuthenticateAsync(WebAuthenticatorOptions webAuthenticatorOptions, CancellationToken cancellationToken)
+
+ static bool IsRegistryDeclared(string scheme)
{
- throw new PlatformNotSupportedException("This implementation of WebAuthenticator does not support Windows. See https://github.com/microsoft/WindowsAppSDK/issues/441 for more details.");
+ var value = Win32.Registry.ClassesRoot.OpenSubKey(scheme);
+
+ return value?.GetValue("URL Protocol") is not null;
}
}
}
diff --git a/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest b/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest
index 36fd8a868108..146e97e4d089 100644
--- a/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest
+++ b/src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest
@@ -33,6 +33,11 @@
+
+
+
+
+
diff --git a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs
index 06f150b3b067..890af8d02d0d 100644
--- a/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs
+++ b/src/Essentials/test/DeviceTests/Tests/WebAuthenticator_Tests.cs
@@ -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(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]
@@ -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(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
}
@@ -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(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]
@@ -111,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
{
@@ -119,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
}