Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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 eng/NuGetVersions.targets
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@
Update="Microsoft.AspNetCore.Authentication.MicrosoftAccount"
Version="$(MicrosoftAspNetCoreAuthenticationMicrosoftAccountPackageVersion)"
/>
<PackageReference
Update="AspNet.Security.OAuth.Apple"
Version="$(AspNetSecurityOAuthApplePackageVersion)"
/>
<PackageReference
Update="Microsoft.AspNetCore.Components.WebView"
Version="$(MicrosoftAspNetCoreComponentsWebViewPackageVersion)"
Expand Down
1 change: 1 addition & 0 deletions eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
<MicrosoftAspNetCoreAuthenticationFacebookPackageVersion>10.0.0</MicrosoftAspNetCoreAuthenticationFacebookPackageVersion>
<MicrosoftAspNetCoreAuthenticationGooglePackageVersion>10.0.0</MicrosoftAspNetCoreAuthenticationGooglePackageVersion>
<MicrosoftAspNetCoreAuthenticationMicrosoftAccountPackageVersion>10.0.0</MicrosoftAspNetCoreAuthenticationMicrosoftAccountPackageVersion>
<AspNetSecurityOAuthApplePackageVersion>9.2.0</AspNetSecurityOAuthApplePackageVersion>
<MicrosoftAspNetCoreComponentsAnalyzersPackageVersion>10.0.0</MicrosoftAspNetCoreComponentsAnalyzersPackageVersion>
<MicrosoftAspNetCoreComponentsFormsPackageVersion>10.0.0</MicrosoftAspNetCoreComponentsFormsPackageVersion>
<MicrosoftAspNetCoreComponentsPackageVersion>10.0.0</MicrosoftAspNetCoreComponentsPackageVersion>
Expand Down
20 changes: 20 additions & 0 deletions src/Core/src/Hosting/EssentialsMauiAppBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Windows lifecycle behavior — This fallback registers every Essentials app under the shared "MauiEssentials" key and redirects/kills any unhandled activation, not just WebAuthenticator protocol callbacks. Because initial launch also flows through OnAppInstanceActivated, apps using Essentials can unexpectedly become single-instance and terminate secondary launches for unrelated activation kinds. Scope this to pending WebAuthenticator protocol activations and avoid a global hard-coded key.

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);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>$(_MauiDotNetTfm)</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>eda0c227-a2f1-4e09-b9f0-0ee493d760c7</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.Apple" Version="9.2.0" />
<PackageReference Include="AspNet.Security.OAuth.Apple" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" />
</ItemGroup>

</Project>
173 changes: 150 additions & 23 deletions src/Essentials/samples/Sample.Server.WebAuthenticator/Program.cs
Original file line number Diff line number Diff line change
@@ -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<Startup>();
});
// 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<string, string>
{
// 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();
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading
Loading