Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <c>/me/external</c>). Works with any scheme registered on the AuthenticationBuilder.
/// </summary>
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<ApplicationUser> 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<ApplicationUser> 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<ApplicationUser> signInManager,
UserManager<ApplicationUser> 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<ApplicationUser> signInManager,
UserManager<ApplicationUser> 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<ApplicationUser> 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<ApplicationUser?> ResolveOrCreateUserAsync(ExternalLoginInfo info, UserManager<ApplicationUser> 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<ApplicationUser> 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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;

namespace Essentials.Samples.WebServer.Components.Account;

/// <summary>
/// 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. <c>SaveTokens = true</c> keeps the provider's access token on the server. Add more providers
/// with any other <c>AuthenticationBuilder.AddX</c> handler.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,14 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" />
</ItemGroup>

<!-- External OAuth sign-in providers. Each is only activated when its credentials are supplied via
configuration (see appsettings.json / user-secrets), so the sample runs with none, some, or all
of them. This mirrors the set demonstrated by the Web Authenticator sample. -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" />
<PackageReference Include="AspNet.Security.OAuth.Apple" Version="9.2.0" />
</ItemGroup>

</Project>
12 changes: 11 additions & 1 deletion src/Essentials/samples/Samples.WebServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,6 +68,9 @@

builder.Services.AddSingleton<IEmailSender<ApplicationUser>, 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");
Expand Down Expand Up @@ -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);

Expand Down
8 changes: 8 additions & 0 deletions src/Essentials/samples/Samples.WebServer/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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://<ServerDomain>/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.",
Expand Down
16 changes: 16 additions & 0 deletions src/Essentials/samples/Samples/View/PasskeysPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@
<Button Text="Sign in with a passkey" Command="{Binding LoginCommand}" IsEnabled="{Binding IsNotBusy}" />
<Label Text="Username-less: no email needed. Your device lists the passkeys you've saved and you just pick one — the passkey carries the identity."
FontSize="12" TextColor="Gray" />

<Label Text="— or —" HorizontalOptions="Center" TextColor="Gray" Margin="0,4" />

<!-- One button per external provider the server has configured (Google, Microsoft, …). -->
<VerticalStackLayout Spacing="8" BindableLayout.ItemsSource="{Binding ExternalProviders}">
<BindableLayout.ItemTemplate>
<DataTemplate>
<Button Text="{Binding ButtonText}" Command="{Binding SignInCommand}" />
</DataTemplate>
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
<Label Text="The server runs the OAuth exchange and creates a local account — your app only ever gets the server's session, never the provider's token."
FontSize="12" TextColor="Gray" IsVisible="{Binding HasExternalProviders}" />
<Label Text="No external sign-in providers are configured on the server. Add Google/Microsoft/Apple/Facebook credentials in the server's user-secrets to enable them."
FontSize="12" TextColor="Gray" IsVisible="{Binding NoExternalProviders}" />
</VerticalStackLayout>

<!-- Signed IN: account summary + passkey management. -->
Expand All @@ -60,6 +75,7 @@
</VerticalStackLayout>

<Button Text="{Binding CreatePasskeyButtonText}" Command="{Binding RegisterCommand}" IsEnabled="{Binding IsNotBusy}" />
<Button Text="Show my external profile (server relay)" Command="{Binding GetExternalProfileCommand}" IsEnabled="{Binding IsNotBusy}" />
<Button Text="Sign out" Command="{Binding SignOutCommand}" IsEnabled="{Binding IsNotBusy}" />
</VerticalStackLayout>

Expand Down
Loading
Loading