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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -60,6 +75,7 @@
+
diff --git a/src/Essentials/samples/Samples/ViewModel/PasskeysViewModel.cs b/src/Essentials/samples/Samples/ViewModel/PasskeysViewModel.cs
index aa7f3fa2736e..6bc22846fe9e 100644
--- a/src/Essentials/samples/Samples/ViewModel/PasskeysViewModel.cs
+++ b/src/Essentials/samples/Samples/ViewModel/PasskeysViewModel.cs
@@ -33,6 +33,8 @@ public class PasskeysViewModel : BaseViewModel
public ObservableCollection Passkeys { get; } = new();
+ public ObservableCollection ExternalProviders { get; } = new();
+
public PasskeysViewModel()
{
SignUpCommand = new Command(async () => await SignUpAsync());
@@ -40,7 +42,10 @@ public PasskeysViewModel()
SignOutCommand = new Command(async () => await SignOutAsync());
RegisterCommand = new Command(async () => await RegisterAsync());
LoginCommand = new Command(async () => await LoginAsync());
+ GetExternalProfileCommand = new Command(async () => await GetExternalProfileAsync());
EditServerUrlCommand = new Command(async () => await EditServerUrlAsync());
+
+ _ = LoadProvidersAsync();
}
public bool IsSupported => PasskeysApi.IsSupported;
@@ -122,8 +127,14 @@ public int PasskeyCount
public ICommand LoginCommand { get; }
+ public ICommand GetExternalProfileCommand { get; }
+
public ICommand EditServerUrlCommand { get; }
+ public bool HasExternalProviders => ExternalProviders.Count > 0;
+
+ public bool NoExternalProviders => ExternalProviders.Count == 0;
+
async Task SignUpAsync()
{
try
@@ -378,6 +389,136 @@ void SetSignedOutState()
Passkeys.Clear();
}
+ // Loads the external providers the server has configured, so the UI shows a button per provider —
+ // no provider is hard-coded here. Called at startup and whenever the server URL changes.
+ async Task LoadProvidersAsync()
+ {
+ try
+ {
+ var client = GetClient();
+ using var httpResponse = await client.GetAsync("/native-auth/external/providers");
+ if (!httpResponse.IsSuccessStatusCode)
+ return;
+
+ var body = await httpResponse.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(body);
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ ExternalProviders.Clear();
+ foreach (var element in doc.RootElement.EnumerateArray())
+ {
+ var name = element.TryGetProperty("name", out var n) ? n.GetString() : null;
+ if (string.IsNullOrEmpty(name))
+ continue;
+ var displayName = element.TryGetProperty("displayName", out var d) ? d.GetString() : name;
+ var provider = name;
+ ExternalProviders.Add(new ExternalProviderItem
+ {
+ Name = name,
+ DisplayName = displayName,
+ SignInCommand = new Command(async () => await ExternalSignInAsync(provider)),
+ });
+ }
+ OnPropertyChanged(nameof(HasExternalProviders));
+ OnPropertyChanged(nameof(NoExternalProviders));
+ });
+ }
+ catch
+ {
+ // Best-effort: if the server is unreachable, just show no external providers.
+ }
+ }
+
+ async Task ExternalSignInAsync(string provider)
+ {
+ try
+ {
+ IsBusy = true;
+ Log($"Opening {provider} sign-in in the browser…");
+
+ // The server runs the OAuth exchange, creates/links a local account, and redirects to our
+ // custom scheme with a one-time code. The app never handles the provider's token.
+ var callback = new Uri("xamarinessentials://");
+ var startUrl = new Uri($"{NormalizeBaseUrl()}native-auth/external/start?provider={Uri.EscapeDataString(provider)}&returnUri={Uri.EscapeDataString(callback.ToString())}");
+
+ var result = await WebAuthenticator.AuthenticateAsync(startUrl, callback);
+
+ if (result.Properties.TryGetValue("error", out var error) && !string.IsNullOrEmpty(error))
+ {
+ Log($"❌ External sign-in failed: {error}");
+ return;
+ }
+
+ if (!result.Properties.TryGetValue("code", out var code) || string.IsNullOrEmpty(code))
+ {
+ Log("❌ The server did not return a sign-in code.");
+ return;
+ }
+
+ // Exchange the code over our HttpClient so the session cookie lands in our CookieContainer
+ // (the browser's cookies are a separate jar). Now signed in like password/passkey.
+ Log("Exchanging the code for a session…");
+ await PostJsonAsync("/native-auth/external/exchange", new { code });
+
+ await RefreshAndOfferPasskeyAsync();
+ }
+ catch (Exception ex)
+ {
+ HandleError(ex);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ async Task GetExternalProfileAsync()
+ {
+ try
+ {
+ IsBusy = true;
+ Log("Asking the server for your external profile…");
+
+ // We never hold the provider token — we ask our own API, which uses its stored token to fetch
+ // the profile and relay it back.
+ var client = GetClient();
+ using var httpResponse = await client.GetAsync("/me/external");
+ var body = await httpResponse.Content.ReadAsStringAsync();
+ if (!httpResponse.IsSuccessStatusCode)
+ throw new InvalidOperationException($"Server returned {(int)httpResponse.StatusCode}: {ExtractServerMessage(body)}");
+
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+
+ if (root.TryGetProperty("message", out var message))
+ {
+ Log(message.GetString());
+ return;
+ }
+
+ var provider = root.TryGetProperty("provider", out var p) ? p.GetString() : "?";
+ var details = body;
+ if (root.TryGetProperty("profile", out var profile))
+ {
+ var name = profile.TryGetProperty("name", out var n) ? n.GetString() : string.Empty;
+ var email = profile.TryGetProperty("email", out var e) ? e.GetString() : string.Empty;
+ details = $"Provider: {provider}{Environment.NewLine}Name: {name}{Environment.NewLine}Email: {email}";
+ }
+
+ await DisplayAlertAsync($"Fetched by the server on your behalf:{Environment.NewLine}{Environment.NewLine}{details}");
+ Log($"✅ Server relayed your {provider} profile (you never saw the provider token).");
+ }
+ catch (Exception ex)
+ {
+ HandleError(ex);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
async Task EditServerUrlAsync()
{
var url = await DisplayPromptAsync(
@@ -392,6 +533,7 @@ async Task EditServerUrlAsync()
// A new server means a fresh HttpClient with an empty cookie jar, i.e. a new session.
SetSignedOutState();
Log($"Server set to {ServerBaseUrl}");
+ await LoadProvidersAsync();
}
bool EnsureSupported()
@@ -519,4 +661,16 @@ public class PasskeyItem
public ICommand DeleteCommand { get; set; }
}
+
+ // One external OAuth provider the server has configured (e.g. Google, Microsoft, Apple, Facebook).
+ public class ExternalProviderItem
+ {
+ public string Name { get; set; }
+
+ public string DisplayName { get; set; }
+
+ public string ButtonText => $"Sign in with {DisplayName}";
+
+ public ICommand SignInCommand { get; set; }
+ }
}