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 @@