diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/ExternalAuthEndpoints.cs b/src/Essentials/samples/Samples.WebServer/Components/Account/ExternalAuthEndpoints.cs new file mode 100644 index 000000000000..9856913dc7ad --- /dev/null +++ b/src/Essentials/samples/Samples.WebServer/Components/Account/ExternalAuthEndpoints.cs @@ -0,0 +1,204 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Essentials.Samples.WebServer.Data; + +namespace Microsoft.AspNetCore.Routing; + +/// +/// Server-brokered external OAuth sign-in for the native app. The server runs the OAuth exchange with the +/// configured provider (Google, Microsoft, Apple, Facebook, …), creates or links a local ASP.NET Core +/// Identity account, and returns a session to the app; the provider's own token stays on the server (used +/// by /me/external). Works with any scheme registered on the AuthenticationBuilder. +/// +internal static class ExternalAuthEndpoints +{ + const string CodePurpose = "Essentials.Samples.WebServer.NativeExternalLogin.v1"; + static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(2); + + public static IEndpointRouteBuilder MapExternalAuthApi(this IEndpointRouteBuilder endpoints) + { + // Lists the configured external providers so the app can render a button per provider. + endpoints.MapGet("/native-auth/external/providers", async (SignInManager signInManager) => + { + var schemes = await signInManager.GetExternalAuthenticationSchemesAsync(); + var providers = schemes + .Select(scheme => new { name = scheme.Name, displayName = scheme.DisplayName ?? scheme.Name }) + .ToArray(); + return Results.Ok(providers); + }); + + // Challenges the provider, returning to /complete afterwards. ConfigureExternalAuthenticationProperties + // stamps the markers GetExternalLoginInfoAsync needs at /complete (a raw Challenge would omit them). + endpoints.MapGet("/native-auth/external/start", ( + string provider, + string returnUri, + SignInManager signInManager) => + { + var redirectUri = $"/native-auth/external/complete?returnUri={Uri.EscapeDataString(returnUri)}"; + var props = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUri); + return Results.Challenge(props, new[] { provider }); + }); + + // Creates/links the local account, stores the provider token, and redirects to the app's custom + // scheme with a short-lived one-time code. + endpoints.MapGet("/native-auth/external/complete", async ( + string returnUri, + HttpContext context, + SignInManager signInManager, + UserManager userManager, + IDataProtectionProvider dataProtectionProvider) => + { + var info = await signInManager.GetExternalLoginInfoAsync(); + if (info is null) + return RedirectToApp(returnUri, "error", "external_login_failed"); + + var user = await ResolveOrCreateUserAsync(info, userManager); + if (user is null) + return RedirectToApp(returnUri, "error", "account_create_failed"); + + await PersistProviderTokensAsync(user, info, userManager); + await context.SignOutAsync(IdentityConstants.ExternalScheme); + + var protector = dataProtectionProvider.CreateProtector(CodePurpose).ToTimeLimitedDataProtector(); + var code = protector.Protect(await userManager.GetUserIdAsync(user), CodeLifetime); + return RedirectToApp(returnUri, "code", code); + }); + + // Exchanges the one-time code for a session. The app makes this call itself, so the auth cookie lands + // in its own HttpClient and it becomes authenticated for /me/external, /passkeys/list, etc. + endpoints.MapPost("/native-auth/external/exchange", async ( + ExchangeRequest body, + SignInManager signInManager, + UserManager userManager, + IDataProtectionProvider dataProtectionProvider) => + { + if (string.IsNullOrEmpty(body?.Code)) + return Results.Json(new { error = "A code is required." }, statusCode: StatusCodes.Status400BadRequest); + + var protector = dataProtectionProvider.CreateProtector(CodePurpose).ToTimeLimitedDataProtector(); + string userId; + try + { + userId = protector.Unprotect(body.Code); + } + catch (Exception) + { + // Tampered, wrong-purpose, or expired code. + return Results.Json(new { error = "Invalid or expired code." }, statusCode: StatusCodes.Status400BadRequest); + } + + var user = await userManager.FindByIdAsync(userId); + if (user is null) + return Results.Json(new { error = "User not found." }, statusCode: StatusCodes.Status400BadRequest); + + await signInManager.SignInAsync(user, isPersistent: true); + return Results.Ok(new { signedIn = true, username = user.UserName }); + }).DisableAntiforgery(); + + // Fetches the signed-in user's profile from the provider using the server-stored token and returns it, + // so the app can show provider data without ever handling the provider token itself. + endpoints.MapGet("/me/external", async ( + HttpContext context, + UserManager userManager, + IHttpClientFactory httpFactory) => + { + var user = await userManager.GetUserAsync(context.User); + if (user is null) + return Results.Json(new { error = "Not signed in." }, statusCode: StatusCodes.Status401Unauthorized); + + foreach (var login in await userManager.GetLoginsAsync(user)) + { + var token = await userManager.GetAuthenticationTokenAsync(user, login.LoginProvider, "access_token"); + if (string.IsNullOrEmpty(token)) + continue; + + var userInfoUrl = UserInfoUrlFor(login.LoginProvider); + if (userInfoUrl is null) + { + // Providers without a userinfo endpoint (e.g. Apple) supply identity in the id_token at + // sign-in; fall back to what the local account captured. + return Results.Ok(new + { + provider = login.LoginProvider, + note = "This provider has no userinfo endpoint; showing the linked local account.", + profile = new { name = user.UserName, email = user.Email }, + }); + } + + var http = httpFactory.CreateClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + using var response = await http.GetAsync(userInfoUrl); + var content = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + return Results.Json(new { provider = login.LoginProvider, error = $"Provider returned {(int)response.StatusCode}." }); + + using var document = System.Text.Json.JsonDocument.Parse(content); + return Results.Ok(new { provider = login.LoginProvider, profile = document.RootElement.Clone() }); + } + + return Results.Ok(new { message = "No linked external provider with a stored token. Sign in with an external account first." }); + }).RequireAuthorization(); + + return endpoints; + } + + static IResult RedirectToApp(string returnUri, string key, string value) + { + var separator = returnUri.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + return Results.Redirect($"{returnUri}{separator}{key}={Uri.EscapeDataString(value)}"); + } + + // Userinfo endpoints for providers that expose one. Add entries to relay data from other providers. + static string? UserInfoUrlFor(string provider) => provider switch + { + "Google" => "https://www.googleapis.com/oauth2/v3/userinfo", + "Microsoft" => "https://graph.microsoft.com/v1.0/me", + "Facebook" => "https://graph.facebook.com/me?fields=id,name,email", + _ => null, + }; + + static async Task ResolveOrCreateUserAsync(ExternalLoginInfo info, UserManager userManager) + { + var user = await userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); + if (user is not null) + return user; + + var email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? info.Principal.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrEmpty(email)) + return null; + + user = await userManager.FindByNameAsync(email); + if (user is null) + { + user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true }; + if (!(await userManager.CreateAsync(user)).Succeeded) + return null; + } + + var linked = await userManager.AddLoginAsync(user, info); + if (!linked.Succeeded) + { + var already = (await userManager.GetLoginsAsync(user)) + .Any(l => l.LoginProvider == info.LoginProvider && l.ProviderKey == info.ProviderKey); + if (!already) + return null; + } + + return user; + } + + static async Task PersistProviderTokensAsync(ApplicationUser user, ExternalLoginInfo info, UserManager userManager) + { + // ExternalLoginSignInAsync doesn't persist provider tokens, so store them so /me/external can call + // the provider on the user's behalf later. + if (info.AuthenticationTokens is null) + return; + + foreach (var token in info.AuthenticationTokens) + await userManager.SetAuthenticationTokenAsync(user, info.LoginProvider, token.Name, token.Value); + } + + record ExchangeRequest(string Code); +} diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/ExternalProviders.cs b/src/Essentials/samples/Samples.WebServer/Components/Account/ExternalProviders.cs new file mode 100644 index 000000000000..f8204d902bc1 --- /dev/null +++ b/src/Essentials/samples/Samples.WebServer/Components/Account/ExternalProviders.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace Essentials.Samples.WebServer.Components.Account; + +/// +/// Registers external OAuth sign-in providers from configuration. Each provider is added only when its +/// credentials are present (in appsettings.json or user-secrets), so the sample runs with none, some, or all +/// configured. SaveTokens = true keeps the provider's access token on the server. Add more providers +/// with any other AuthenticationBuilder.AddX handler. +/// +internal static class ExternalProviders +{ + public static AuthenticationBuilder AddConfiguredExternalProviders( + this AuthenticationBuilder builder, + IConfiguration configuration, + IWebHostEnvironment environment) + { + var auth = configuration.GetSection("Authentication"); + + var google = auth.GetSection("Google"); + if (!string.IsNullOrEmpty(google["ClientId"])) + { + builder.AddGoogle(options => + { + options.ClientId = google["ClientId"]!; + options.ClientSecret = google["ClientSecret"]!; + options.SaveTokens = true; + }); + } + + var microsoft = auth.GetSection("Microsoft"); + if (!string.IsNullOrEmpty(microsoft["ClientId"])) + { + builder.AddMicrosoftAccount(options => + { + options.ClientId = microsoft["ClientId"]!; + options.ClientSecret = microsoft["ClientSecret"]!; + options.SaveTokens = true; + }); + } + + var facebook = auth.GetSection("Facebook"); + if (!string.IsNullOrEmpty(facebook["AppId"])) + { + builder.AddFacebook(options => + { + options.AppId = facebook["AppId"]!; + options.AppSecret = facebook["AppSecret"]!; + options.SaveTokens = true; + }); + } + + var apple = auth.GetSection("Apple"); + // Apple derives its client secret from the private key, so require the key — a partial Apple config + // would otherwise fail options validation on every request and break the other providers too. + if (!string.IsNullOrEmpty(apple["ClientId"]) && !string.IsNullOrEmpty(apple["PrivateKeyPath"])) + { + builder.AddApple(options => + { + options.ClientId = apple["ClientId"]!; + options.KeyId = apple["KeyId"]!; + options.TeamId = apple["TeamId"]!; + options.SaveTokens = true; + options.UsePrivateKey(_ => environment.ContentRootFileProvider.GetFileInfo(apple["PrivateKeyPath"]!)); + }); + } + + return builder; + } +} diff --git a/src/Essentials/samples/Samples.WebServer/Essentials.Samples.WebServer.csproj b/src/Essentials/samples/Samples.WebServer/Essentials.Samples.WebServer.csproj index 9e54064ef43e..1bc7011df27b 100644 --- a/src/Essentials/samples/Samples.WebServer/Essentials.Samples.WebServer.csproj +++ b/src/Essentials/samples/Samples.WebServer/Essentials.Samples.WebServer.csproj @@ -20,4 +20,14 @@ + + + + + + + + diff --git a/src/Essentials/samples/Samples.WebServer/Program.cs b/src/Essentials/samples/Samples.WebServer/Program.cs index bd2d8b0525cb..eda24dc62a36 100644 --- a/src/Essentials/samples/Samples.WebServer/Program.cs +++ b/src/Essentials/samples/Samples.WebServer/Program.cs @@ -28,13 +28,17 @@ // Bearer token services are required by MapIdentityApi even though the app uses the cookie variant. authBuilder.AddBearerToken(IdentityConstants.BearerScheme); +// Registers the external OAuth providers that have credentials configured. +authBuilder.AddConfiguredExternalProviders(builder.Configuration, builder.Environment); + // The application cookie answers an unauthenticated [Authorize] request with a 302 to the HTML login page, // which is useless to the native client. Return a clean 401 for the native API paths instead. builder.Services.ConfigureApplicationCookie(options => { options.Events.OnRedirectToLogin = context => { - if (context.Request.Path.StartsWithSegments("/passkeys", StringComparison.Ordinal)) + if (context.Request.Path.StartsWithSegments("/passkeys", StringComparison.Ordinal) || + context.Request.Path.StartsWithSegments("/me", StringComparison.Ordinal)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; @@ -64,6 +68,9 @@ builder.Services.AddSingleton, IdentityNoOpEmailSender>(); +// Used by /me/external to call the provider's userinfo endpoint. +builder.Services.AddHttpClient(); + // Passkey relying-party config. ServerDomain is the RP ID (the public host the apps use). // ValidateOrigin must also accept each platform's native origin (Android's apk-key-hash, Apple's web origin). var passkeysConfig = builder.Configuration.GetSection("Passkeys"); @@ -137,6 +144,9 @@ // Passkey ceremony endpoints. app.MapNativePasskeyApi(); +// External OAuth sign-in endpoints. +app.MapExternalAuthApi(); + // Platform domain-association documents (Android assetlinks.json / Apple AASA). app.MapDomainAssociation(app.Configuration); diff --git a/src/Essentials/samples/Samples.WebServer/appsettings.json b/src/Essentials/samples/Samples.WebServer/appsettings.json index 336da3dd5de0..f3342625c4f4 100644 --- a/src/Essentials/samples/Samples.WebServer/appsettings.json +++ b/src/Essentials/samples/Samples.WebServer/appsettings.json @@ -10,6 +10,14 @@ }, "AllowedHosts": "*", + "//Authentication": "External OAuth providers. Fill in the credentials for whichever you want (in user-secrets, not source). Each provider only activates when its ClientId/AppId is set. Register the matching redirect URIs with each provider: https:///signin-google, /signin-microsoft, /signin-facebook, /signin-apple.", + "Authentication": { + "Google": { "ClientId": "", "ClientSecret": "" }, + "Microsoft": { "ClientId": "", "ClientSecret": "" }, + "Facebook": { "AppId": "", "AppSecret": "" }, + "Apple": { "ClientId": "", "KeyId": "", "TeamId": "", "PrivateKeyPath": "" } + }, + "Passkeys": { "//": "Fill these in after provisioning the dev tunnel and learning the app signing identities. See README.md.", "//ServerDomain": "The RP ID = public host (no scheme/port), e.g. 'abcd1234-5177.euw.devtunnels.ms'. Empty = Identity defaults for localhost.", diff --git a/src/Essentials/samples/Samples/View/PasskeysPage.xaml b/src/Essentials/samples/Samples/View/PasskeysPage.xaml index ef7aa8c43809..a293acef133d 100644 --- a/src/Essentials/samples/Samples/View/PasskeysPage.xaml +++ b/src/Essentials/samples/Samples/View/PasskeysPage.xaml @@ -34,6 +34,21 @@