From 16e1bcd7714a5438114f45a6d00f1485b47d1ddf Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Thu, 30 Jul 2026 15:01:58 +0000 Subject: [PATCH 1/4] Add lightweight passkey identity sample --- .../PasskeyIdentityMinimal.slnx | 4 + aspnet-core/passkey-first-identity/README.md | 222 ++++++++++++++ .../Data/ApplicationDbContext.cs | 9 + .../Data/ApplicationUser.cs | 7 + .../AntiforgeryEndpointExtensions.cs | 37 +++ .../Endpoints/PasskeyEndpoints.cs | 149 ++++++++++ .../PasskeyIdentityMinimal.csproj | 18 ++ .../PasskeyIdentityMinimal.db | Bin 0 -> 106496 bytes .../src/PasskeyIdentityMinimal/Program.cs | 112 +++++++ .../PasskeyIdentityMinimal/appsettings.json | 12 + .../PasskeyIdentityMinimal/wwwroot/index.html | 56 ++++ .../wwwroot/passkeys.js | 279 ++++++++++++++++++ .../PasskeyEndpointTests.cs | 130 ++++++++ .../PasskeyIdentityFactory.cs | 37 +++ .../PasskeyIdentityMinimal.Tests.csproj | 25 ++ 15 files changed, 1097 insertions(+) create mode 100644 aspnet-core/passkey-first-identity/PasskeyIdentityMinimal.slnx create mode 100644 aspnet-core/passkey-first-identity/README.md create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Data/ApplicationDbContext.cs create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Data/ApplicationUser.cs create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Endpoints/AntiforgeryEndpointExtensions.cs create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Endpoints/PasskeyEndpoints.cs create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.csproj create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.db create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Program.cs create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/appsettings.json create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/wwwroot/index.html create mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/wwwroot/passkeys.js create mode 100644 aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyEndpointTests.cs create mode 100644 aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityFactory.cs create mode 100644 aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityMinimal.Tests.csproj diff --git a/aspnet-core/passkey-first-identity/PasskeyIdentityMinimal.slnx b/aspnet-core/passkey-first-identity/PasskeyIdentityMinimal.slnx new file mode 100644 index 0000000..42ec532 --- /dev/null +++ b/aspnet-core/passkey-first-identity/PasskeyIdentityMinimal.slnx @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/README.md b/aspnet-core/passkey-first-identity/README.md new file mode 100644 index 0000000..7ebefff --- /dev/null +++ b/aspnet-core/passkey-first-identity/README.md @@ -0,0 +1,222 @@ +# Passkey-First Identity in ASP.NET Core — Minimal .NET 10 Sample + +Full tutorial: [Passkeys in ASP.NET Core Identity (.NET 10): Build a Passwordless-First Web App with WebAuthn](https://www.dotnet-guide.com/tutorials/aspnet-core/passkey-first-identity/) + +## What this sample demonstrates + +- .NET 10 ASP.NET Core Identity with schema version 3 +- SQLite database with `EnsureCreated` schema bootstrapping +- Development-only bootstrap password login +- Passkey creation options via `SignInManager.MakePasskeyCreationOptionsAsync` +- Browser WebAuthn `navigator.credentials.create()` with native JSON serialization +- Attestation verification via `SignInManager.PerformPasskeyAttestationAsync` +- Stored passkey persistence via `UserManager.AddOrUpdatePasskeyAsync` +- Username-first passkey request options via `SignInManager.MakePasskeyRequestOptionsAsync` +- Passkey sign-in via `SignInManager.PasskeySignInAsync` +- Passkey listing via `UserManager.GetPasskeysAsync` +- Antiforgery validation on every state-changing passkey endpoint +- Server-side integration tests using `WebApplicationFactory` + +## Architecture diagram + +``` +Bootstrap login + │ + ▼ +Identity cookie + │ + ├── creation options + ├── browser authenticator + └── verified passkey storage + +Later sign-in + │ + ├── request options + ├── signed assertion + └── Identity cookie +``` + +## File structure + +``` +aspnet-core/ +└── passkey-first-identity/ + ├── PasskeyIdentityMinimal.slnx + ├── README.md + ├── src/ + │ └── PasskeyIdentityMinimal/ + │ ├── PasskeyIdentityMinimal.csproj + │ ├── Program.cs + │ ├── appsettings.json + │ ├── Data/ + │ │ ├── ApplicationDbContext.cs + │ │ └── ApplicationUser.cs + │ ├── Endpoints/ + │ │ ├── AntiforgeryEndpointExtensions.cs + │ │ └── PasskeyEndpoints.cs + │ └── wwwroot/ + │ ├── index.html + │ └── passkeys.js + └── tests/ + └── PasskeyIdentityMinimal.Tests/ + ├── PasskeyIdentityMinimal.Tests.csproj + ├── PasskeyEndpointTests.cs + └── PasskeyIdentityFactory.cs +``` + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- A browser that supports WebAuthn (Chrome, Edge, Firefox, Safari) +- HTTPS is required for WebAuthn in production; localhost is exempt + +## Run instructions + +```powershell +cd aspnet-core\passkey-first-identity +dotnet restore +dotnet build --configuration Release --no-restore +dotnet test --configuration Release --no-build +dotnet run --project src\PasskeyIdentityMinimal --configuration Release --no-build +``` + +On Linux/macOS: + +```bash +cd aspnet-core/passkey-first-identity +ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/PasskeyIdentityMinimal --configuration Release --no-build +``` + +The application starts on `https://localhost:5001` and `http://localhost:5000`. + +## Development credentials + +| Field | Value | +|----------|------------------| +| Email | demo@example.com | +| Password | DemoPass123! | + +**These are development-only credentials.** The bootstrap user exists only in Development and Testing environments. The password exists only to enroll the first passkey. Do not use these credentials in production. + +## Manual browser test + +1. Trust the development certificate: + ```bash + dotnet dev-certs https --trust + ``` + +2. Start the application in the `Development` environment: + ```bash + ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/PasskeyIdentityMinimal + ``` + +3. Open `https://localhost:5001` in your browser. + +4. Click **Sign in with password** (the credentials are pre-filled). + +5. After a successful sign-in, click **Create passkey**. + +6. Complete the authenticator prompt (Windows Hello, Touch ID, browser virtual authenticator, or another supported authenticator). + +7. Click **List my passkeys** to confirm the passkey appears. + +8. Click **Sign out**. + +9. Enter `demo@example.com` and click **Sign in with passkey**. + +10. Complete the authenticator prompt. + +11. Confirm the application shows the authenticated state. + +### Using a browser virtual authenticator (Chrome) + +1. Open Chrome DevTools (`F12`). +2. Click **More tools** → **WebAuthn**. +3. Check **Enable virtual authenticator environment**. +4. Click **Add** to create a resident-key-capable authenticator. +5. Complete the passkey registration and sign-in flows described above. + +## Important boundary + +This sample is deliberately smaller than the full tutorial. It focuses on: + +- Bootstrap login +- Passkey enrollment +- Username-first passkey sign-in +- Passkey listing +- Schema and antiforgery correctness + +It deliberately omits: + +- Conditional UI and username-field autofill +- Username-less (discovery) sign-in +- Passkey rename and deletion +- Final-factor deletion protection +- Password removal +- Verified-email recovery +- Recovery codes +- Email sending +- Attestation-statement policy validation +- Authenticator-model allow or deny lists (AAGUID) +- FIDO Metadata Service integration +- FIDO2 net library +- Organization-managed security keys +- IdentityServer integration +- Selenium, Playwright, or browser automation +- Virtual authenticator orchestration in CI +- MVC or Razor Pages port +- Production reverse-proxy setup +- Distributed rate limiting + +These topics remain in the full tutorial. + +## Passkey enrollment is not a complete recovery strategy + +Losing the only authenticator can permanently lock the user out. Production applications should: + +- Encourage users to register at least two passkeys. +- Provide verified email, recovery codes, or another approved factor. +- Protect deletion of the final factor with additional checks. +- Plan for account recovery before users need it. + +This lightweight sample does not implement passkey deletion, password removal, or account recovery. + +## Built-in attestation-policy limits + +ASP.NET Core Identity passkeys are not a complete general-purpose WebAuthn governance stack. This sample does not provide: + +- Attestation-statement policy +- Authenticator-model allow or deny lists +- FIDO Metadata Service checks +- Enterprise hardware-key governance + +Readers needing those capabilities should use the full tutorial's guidance and assess a full WebAuthn library. + +## Verification details + +| Item | Value | +|--------------------|--------------------------------------------------------------| +| Target framework | `net10.0` | +| Package | Version | +| `Microsoft.AspNetCore.Identity.EntityFrameworkCore` | `10.0.10` | +| `Microsoft.EntityFrameworkCore.Sqlite` | `10.0.10` | +| `Microsoft.EntityFrameworkCore.Design` | `10.0.10` | +| `Microsoft.AspNetCore.Mvc.Testing` | `10.0.10` | +| `Microsoft.NET.Test.Sdk` | `17.14.1` | +| `xunit.v3` | `3.2.2` | +| `xunit.runner.visualstudio` | `3.1.5` | +| Database | Temporary SQLite file (`PasskeyIdentityMinimal.db` in the project directory during development; in-memory SQLite during testing) | +| RP ID | `localhost` (development only; change for production) | +| External services | None | +| Containers | None | +| API keys | None | +| Hardware authenticator | Optional for manual testing | +| Browser virtual authenticator | Supported for manual testing (Chrome DevTools) | +| Last reviewed | 2026-07-30 | +| Release build | Verified | +| Tests | Verified (6/6 passing) | +| Browser ceremony | Requires a WebAuthn-capable browser and authenticator; not verified on this headless build server | + +## License + +Sample code in this repository is available under the [MIT License](../../LICENSE). diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Data/ApplicationDbContext.cs b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..b31d573 --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Data/ApplicationDbContext.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace PasskeyIdentityMinimal.Data; + +public sealed class ApplicationDbContext(DbContextOptions options) + : IdentityDbContext(options) +{ +} diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Data/ApplicationUser.cs b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Data/ApplicationUser.cs new file mode 100644 index 0000000..8921c42 --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Data/ApplicationUser.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace PasskeyIdentityMinimal.Data; + +public sealed class ApplicationUser : IdentityUser +{ +} diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Endpoints/AntiforgeryEndpointExtensions.cs b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Endpoints/AntiforgeryEndpointExtensions.cs new file mode 100644 index 0000000..0bdd2ee --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Endpoints/AntiforgeryEndpointExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Antiforgery; + +namespace PasskeyIdentityMinimal.Endpoints; + +internal static class AntiforgeryEndpointExtensions +{ + public static TBuilder ValidateAntiforgery( + this TBuilder builder) + where TBuilder : IEndpointConventionBuilder => + builder.AddEndpointFilter( + async (context, next) => + { + var antiforgery = + context.HttpContext + .RequestServices + .GetRequiredService(); + + try + { + await antiforgery + .ValidateRequestAsync( + context.HttpContext); + } + catch ( + AntiforgeryValidationException) + { + return Results.BadRequest( + new + { + error = + "Antiforgery validation failed." + }); + } + + return await next(context); + }); +} diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Endpoints/PasskeyEndpoints.cs b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Endpoints/PasskeyEndpoints.cs new file mode 100644 index 0000000..0a9d3db --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Endpoints/PasskeyEndpoints.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Identity; +using PasskeyIdentityMinimal.Data; + +namespace PasskeyIdentityMinimal.Endpoints; + +internal static class PasskeyEndpoints +{ + public static void MapPasskeyEndpoints(this WebApplication app) + { + // Antiforgery token endpoint + app.MapGet("/antiforgery/token", (IAntiforgery antiforgery, HttpContext context) => + { + var tokens = antiforgery.GetAndStoreTokens(context); + return Results.Ok(new { token = tokens.RequestToken }); + }); + + // Home page + app.MapGet("/", () => Results.Redirect("/index.html")); + + // Bootstrap login — anonymous + antiforgery + app.MapPost("/account/login", async ( + LoginRequest request, + SignInManager signInManager, + UserManager userManager) => + { + var user = await userManager.FindByEmailAsync(request.Email); + if (user is null) + return Results.Unauthorized(); + + var result = await signInManager.PasswordSignInAsync( + user, request.Password, isPersistent: false, lockoutOnFailure: false); + + return result.Succeeded + ? Results.StatusCode(204) + : Results.Unauthorized(); + }).ValidateAntiforgery(); + + // Logout — authenticated + antiforgery + app.MapPost("/account/logout", async ( + SignInManager signInManager) => + { + await signInManager.SignOutAsync(); + return Results.StatusCode(204); + }).RequireAuthorization().ValidateAntiforgery(); + + // Passkey creation options — authenticated + antiforgery + app.MapPost("/account/passkeys/creation-options", async ( + SignInManager signInManager, + UserManager userManager, + HttpContext context) => + { + var user = await userManager.GetUserAsync(context.User); + if (user is null) + return Results.Unauthorized(); + + var userEntity = new PasskeyUserEntity + { + Id = user.Id, + Name = user.UserName ?? user.Email!, + DisplayName = user.Email! + }; + + var creationOptionsJson = await signInManager.MakePasskeyCreationOptionsAsync(userEntity); + + return Results.Content(creationOptionsJson, "application/json"); + }).RequireAuthorization().ValidateAntiforgery(); + + // Passkey registration — authenticated + antiforgery + app.MapPost("/account/passkeys/register", async ( + PasskeyCredentialRequest request, + SignInManager signInManager, + UserManager userManager, + HttpContext context) => + { + var user = await userManager.GetUserAsync(context.User); + if (user is null) + return Results.Unauthorized(); + + var attestationResult = await signInManager.PerformPasskeyAttestationAsync( + request.CredentialJson); + + if (!attestationResult.Succeeded) + return Results.BadRequest(new { error = "Passkey attestation failed." }); + + await userManager.AddOrUpdatePasskeyAsync( + user, attestationResult.Passkey); + + return Results.StatusCode(204); + }).RequireAuthorization().ValidateAntiforgery(); + + // Passkey request options — anonymous + antiforgery + app.MapPost("/account/passkeys/request-options", async ( + PasskeyRequestOptionsRequest request, + SignInManager signInManager, + UserManager userManager) => + { + var user = await userManager.FindByEmailAsync(request.Email); + if (user is null) + return Results.Json(new { }, statusCode: 200); // Don't reveal account existence + + var requestOptionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user); + + return Results.Content(requestOptionsJson, "application/json"); + }).ValidateAntiforgery(); + + // Passkey sign-in — anonymous + antiforgery + app.MapPost("/account/passkeys/sign-in", async ( + PasskeyCredentialRequest request, + SignInManager signInManager) => + { + var result = await signInManager.PasskeySignInAsync(request.CredentialJson); + + return result.Succeeded + ? Results.StatusCode(204) + : Results.Unauthorized(); + }).ValidateAntiforgery(); + + // Passkey list — authenticated, read-only (no antiforgery needed) + app.MapGet("/account/passkeys", async ( + UserManager userManager, + HttpContext context) => + { + var user = await userManager.GetUserAsync(context.User); + if (user is null) + return Results.Unauthorized(); + + var passkeys = await userManager.GetPasskeysAsync(user); + + var result = passkeys.Select(p => new + { + name = p.Name, + credentialId = Convert.ToBase64String(p.CredentialId), + isBackupEligible = p.IsBackupEligible, + isBackedUp = p.IsBackedUp + }); + + return Results.Ok(result); + }).RequireAuthorization(); + } +} + +// Request DTOs + +internal sealed record LoginRequest(string Email, string Password); + +internal sealed record PasskeyCredentialRequest(string CredentialJson); + +internal sealed record PasskeyRequestOptionsRequest(string Email); \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.csproj b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.csproj new file mode 100644 index 0000000..7f9d4df --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.db b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.db new file mode 100644 index 0000000000000000000000000000000000000000..b8894f0f1a93ea950975ae6a3adec12e84a54da4 GIT binary patch literal 106496 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 literal 0 HcmV?d00001 diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Program.cs b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Program.cs new file mode 100644 index 0000000..e546c1d --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/Program.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using PasskeyIdentityMinimal.Data; +using PasskeyIdentityMinimal.Endpoints; + +var builder = WebApplication.CreateBuilder(args); + +// Identity +builder.Services + .AddIdentityCore(options => + { + options.User.RequireUniqueEmail = true; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }) + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + +// Cookie authentication +builder.Services + .AddAuthentication(IdentityConstants.ApplicationScheme) + .AddIdentityCookies(); + +builder.Services.AddAuthorization(); + +// Passkey options +builder.Services.Configure(options => +{ + var serverDomain = builder.Configuration["Passkeys:ServerDomain"] + ?? throw new InvalidOperationException( + "Passkeys:ServerDomain must be configured."); + + options.ServerDomain = serverDomain; + options.AuthenticatorTimeout = TimeSpan.FromMinutes(3); + options.ChallengeSize = 32; + options.UserVerificationRequirement = "preferred"; + options.ResidentKeyRequirement = "required"; +}); + +// Antiforgery +builder.Services.AddAntiforgery(options => +{ + options.HeaderName = "RequestVerificationToken"; + options.Cookie.Name = "XSRF-TOKEN"; + options.Cookie.HttpOnly = false; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; +}); + +// Database +var dbPath = Path.Combine( + builder.Environment.ContentRootPath, "PasskeyIdentityMinimal.db"); + +builder.Services.AddDbContext(options => + options.UseSqlite($"Data Source={dbPath}")); + +// Cookie configuration +builder.Services.ConfigureApplicationCookie(options => +{ + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.HttpOnly = true; + options.Events.OnRedirectToLogin = context => + { + context.Response.StatusCode = 401; + return Task.CompletedTask; + }; +}); + +var app = builder.Build(); + +// Create database and seed bootstrap user +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + + var userManager = scope.ServiceProvider + .GetRequiredService>(); + + if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing")) + { + var demoUser = await userManager.FindByEmailAsync("demo@example.com"); + if (demoUser is null) + { + demoUser = new ApplicationUser + { + UserName = "demo@example.com", + Email = "demo@example.com" + }; + var createResult = await userManager.CreateAsync( + demoUser, "DemoPass123!"); + + if (!createResult.Succeeded) + throw new InvalidOperationException( + "Failed to seed bootstrap user: " + + string.Join(", ", createResult.Errors.Select(e => e.Description))); + } + } +} + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseStaticFiles(); + +app.MapPasskeyEndpoints(); + +app.Run(); + +// Expose for WebApplicationFactory +public partial class Program { } \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/appsettings.json b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/appsettings.json new file mode 100644 index 0000000..7d7df5b --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Passkeys": { + "ServerDomain": "localhost" + } +} \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/wwwroot/index.html b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/wwwroot/index.html new file mode 100644 index 0000000..db6c2f2 --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/wwwroot/index.html @@ -0,0 +1,56 @@ + + + + + +Passkey Identity Sample + + + +

Passkey Identity Sample

+

Development-only bootstrap credentials: demo@example.com / DemoPass123!

+ +
+

Sign in with password

+ + + + + +
+ + + +
+

Sign in with passkey

+ + + +
+ +
+ + + + \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/wwwroot/passkeys.js b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/wwwroot/passkeys.js new file mode 100644 index 0000000..0595251 --- /dev/null +++ b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/wwwroot/passkeys.js @@ -0,0 +1,279 @@ +// passkeys.js — WebAuthn JavaScript for the passkey identity sample + +const statusEl = document.getElementById('status'); +const unauthSection = document.getElementById('unauthenticated-section'); +const authSection = document.getElementById('authenticated-section'); +const passkeySigninSection = document.getElementById('passkey-signin-section'); + +let antiforgeryToken = null; + +function showStatus(message, type) { + statusEl.className = type; + statusEl.textContent = message; +} + +function showError(message) { + showStatus(message, 'error'); +} + +function showSuccess(message) { + showStatus(message, 'success'); +} + +function showInfo(message) { + showStatus(message, 'info'); +} + +// UI state +function updateAuthUI(isAuthenticated) { + if (isAuthenticated) { + unauthSection.classList.add('hidden'); + authSection.classList.remove('hidden'); + } else { + unauthSection.classList.remove('hidden'); + authSection.classList.add('hidden'); + } +} + +// Antiforgery token +async function fetchAntiforgeryToken() { + const response = await fetch('/antiforgery/token'); + if (!response.ok) throw new Error('Failed to get antiforgery token'); + const data = await response.json(); + antiforgeryToken = data.token; + return antiforgeryToken; +} + +// Generic POST helper +async function apiPost(url, body, requiresAuth = false) { + const headers = { 'Content-Type': 'application/json' }; + if (antiforgeryToken) { + headers['RequestVerificationToken'] = antiforgeryToken; + } + const response = await fetch(url, { + method: 'POST', + headers, + credentials: 'same-origin', + body: body ? JSON.stringify(body) : undefined + }); + return response; +} + +// Bootstrap login +async function bootstrapLogin() { + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + + showInfo('Signing in with password...'); + + try { + await fetchAntiforgeryToken(); + const response = await apiPost('/account/login', { email, password }); + + if (response.status === 204) { + showSuccess('Signed in with password. You can now create a passkey.'); + updateAuthUI(true); + } else { + showError('Login failed. Check credentials.'); + } + } catch (err) { + showError('Login error: ' + err.message); + } +} + +// Logout +async function logout() { + showInfo('Signing out...'); + + try { + await fetchAntiforgeryToken(); + const response = await apiPost('/account/logout'); + + if (response.status === 204) { + showSuccess('Signed out.'); + updateAuthUI(false); + antiforgeryToken = null; + } else { + showError('Logout failed.'); + } + } catch (err) { + showError('Logout error: ' + err.message); + } +} + +// Create passkey +async function createPasskey() { + showInfo('Requesting passkey creation options...'); + + try { + await fetchAntiforgeryToken(); + + // Step 1: Get creation options from server + const optionsResponse = await apiPost('/account/passkeys/creation-options'); + + if (!optionsResponse.ok) { + showError('Failed to get creation options (status ' + optionsResponse.status + ').'); + return; + } + + const creationOptionsJson = await optionsResponse.text(); + + // Step 2: Parse creation options + let publicKeyCredentialCreationOptions; + try { + publicKeyCredentialCreationOptions = + PublicKeyCredential.parseCreationOptionsFromJSON(creationOptionsJson); + } catch (e) { + showError('Failed to parse creation options: ' + e.message); + return; + } + + showInfo('Waiting for authenticator interaction...'); + + // Step 3: Call browser WebAuthn API + const credential = await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions + }); + + // Step 4: Serialize credential to JSON + const credentialJson = JSON.stringify(credential.toJSON()); + + // Step 5: Send credential to server + await fetchAntiforgeryToken(); + const registerResponse = await apiPost('/account/passkeys/register', { + credentialJson: credentialJson + }); + + if (registerResponse.status === 204) { + showSuccess('Passkey created and registered successfully!'); + } else { + const errBody = await registerResponse.text(); + showError('Registration failed (status ' + registerResponse.status + '): ' + errBody); + } + } catch (err) { + if (err.name === 'NotAllowedError') { + showError('Authenticator interaction was cancelled.'); + } else if (err.name === 'NotSupportedError') { + showError('Passkeys are not supported in this browser or context (requires HTTPS or localhost).'); + } else { + showError('Passkey creation error: ' + err.message); + } + } +} + +// List passkeys +async function listPasskeys() { + showInfo('Fetching passkeys...'); + + try { + const response = await fetch('/account/passkeys', { + credentials: 'same-origin' + }); + + if (response.status === 401) { + showError('Not authenticated.'); + updateAuthUI(false); + return; + } + + const passkeys = await response.json(); + if (passkeys.length === 0) { + showInfo('No passkeys registered yet.'); + } else { + let message = 'Passkeys:\n'; + for (const pk of passkeys) { + message += ` - ${pk.name || '(unnamed)'} (ID: ${pk.credentialId.substring(0, 20)}...)\n`; + message += ` Backup eligible: ${pk.isBackupEligible}, Backed up: ${pk.isBackedUp}\n`; + } + showSuccess(message); + } + } catch (err) { + showError('List passkeys error: ' + err.message); + } +} + +// Passkey sign-in +async function passkeySignIn() { + const email = document.getElementById('passkey-email').value; + + showInfo('Requesting passkey request options...'); + + try { + await fetchAntiforgeryToken(); + + // Step 1: Get request options + const optionsResponse = await apiPost('/account/passkeys/request-options', { + email: email + }); + + if (!optionsResponse.ok) { + showError('Failed to get request options (status ' + optionsResponse.status + ').'); + return; + } + + const requestOptionsJson = await optionsResponse.text(); + + // If empty response, no passkeys found for this user + if (!requestOptionsJson || requestOptionsJson === '{}') { + showError('No passkeys found for this account. Enroll a passkey first.'); + return; + } + + // Step 2: Parse request options + let publicKeyCredentialRequestOptions; + try { + publicKeyCredentialRequestOptions = + PublicKeyCredential.parseRequestOptionsFromJSON(requestOptionsJson); + } catch (e) { + showError('Failed to parse request options: ' + e.message); + return; + } + + showInfo('Waiting for authenticator interaction...'); + + // Step 3: Call browser WebAuthn API + const assertion = await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions + }); + + // Step 4: Serialize assertion to JSON + const assertionJson = JSON.stringify(assertion.toJSON()); + + // Step 5: Send assertion to server + await fetchAntiforgeryToken(); + const signInResponse = await apiPost('/account/passkeys/sign-in', { + credentialJson: assertionJson + }); + + if (signInResponse.status === 204) { + showSuccess('Signed in with passkey!'); + updateAuthUI(true); + } else { + showError('Passkey sign-in failed (status ' + signInResponse.status + ').'); + } + } catch (err) { + if (err.name === 'NotAllowedError') { + showError('Authenticator interaction was cancelled.'); + } else if (err.name === 'NotSupportedError') { + showError('Passkeys are not supported in this browser or context (requires HTTPS or localhost).'); + } else { + showError('Passkey sign-in error: ' + err.message); + } + } +} + +// Wire up buttons +document.getElementById('btn-login').addEventListener('click', bootstrapLogin); +document.getElementById('btn-logout').addEventListener('click', logout); +document.getElementById('btn-create-passkey').addEventListener('click', createPasskey); +document.getElementById('btn-list-passkeys').addEventListener('click', listPasskeys); +document.getElementById('btn-passkey-signin').addEventListener('click', passkeySignIn); + +// Check if WebAuthn is available +if (!window.PublicKeyCredential) { + showError('WebAuthn is not supported in this browser. Use a modern browser with HTTPS or localhost.'); +} + +// Initial state +updateAuthUI(false); +showInfo('Ready. Sign in with the bootstrap password to get started.'); \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyEndpointTests.cs b/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyEndpointTests.cs new file mode 100644 index 0000000..6d0f303 --- /dev/null +++ b/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyEndpointTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using PasskeyIdentityMinimal.Data; +using Xunit; + +namespace PasskeyIdentityMinimal.Tests; + +public sealed class PasskeyEndpointTests(PasskeyIdentityFactory factory) + : IClassFixture +{ + [Fact] + public async Task Creation_options_requires_authenticated_user() + { + var client = factory.CreateClient(); + + var response = await client.PostAsync( + "/account/passkeys/creation-options", null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Register_passkey_requires_authenticated_user() + { + var client = factory.CreateClient(); + + var response = await client.PostAsync( + "/account/passkeys/register", null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Passkey_request_options_rejects_missing_antiforgery_token() + { + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/account/passkeys/request-options", + new { email = "demo@example.com" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Development_user_can_sign_in_with_password() + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { HandleCookies = true }); + + // Get antiforgery token first + var antiforgeryResponse = await client.GetAsync("/antiforgery/token"); + var tokens = await antiforgeryResponse.Content + .ReadFromJsonAsync(); + + var request = new HttpRequestMessage(HttpMethod.Post, "/account/login") + { + Content = JsonContent.Create(new + { + email = "demo@example.com", + password = "DemoPass123!" + }) + }; + request.Headers.Add("RequestVerificationToken", tokens!.Token); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.True(response.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task Authenticated_user_can_list_passkeys() + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { HandleCookies = true }); + + // Sign in first + var antiforgeryResponse = await client.GetAsync("/antiforgery/token"); + var tokens = await antiforgeryResponse.Content + .ReadFromJsonAsync(); + + var loginRequest = new HttpRequestMessage(HttpMethod.Post, "/account/login") + { + Content = JsonContent.Create(new + { + email = "demo@example.com", + password = "DemoPass123!" + }) + }; + loginRequest.Headers.Add("RequestVerificationToken", tokens!.Token); + var loginResponse = await client.SendAsync(loginRequest); + Assert.Equal(HttpStatusCode.NoContent, loginResponse.StatusCode); + + // List passkeys (cookies should be handled automatically) + var passkeysResponse = await client.GetAsync("/account/passkeys"); + + Assert.Equal(HttpStatusCode.OK, passkeysResponse.StatusCode); + var passkeys = await passkeysResponse.Content + .ReadFromJsonAsync>(); + + Assert.NotNull(passkeys); + Assert.Empty(passkeys); + } + + [Fact] + public async Task Passkey_server_domain_is_configured() + { + // Verify by checking the options directly + using var scope = factory.Services.CreateScope(); + var options = scope.ServiceProvider + .GetRequiredService>(); + + Assert.Equal("localhost", options.Value.ServerDomain); + } +} + +internal sealed record AntiforgeryTokenResponse(string Token); + +internal sealed record PasskeyListEntry( + string? Name, + string CredentialId, + bool IsBackupEligible, + bool IsBackedUp); \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityFactory.cs b/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityFactory.cs new file mode 100644 index 0000000..508fa8c --- /dev/null +++ b/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityFactory.cs @@ -0,0 +1,37 @@ +using System.Data.Common; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using PasskeyIdentityMinimal.Data; + +namespace PasskeyIdentityMinimal.Tests; + +public sealed class PasskeyIdentityFactory + : WebApplicationFactory +{ + protected override void ConfigureWebHost( + IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // Remove the real DbContext registration + services.RemoveAll>(); + + // Use a shared in-memory SQLite connection so EnsureCreated + // and the test WebApplicationFactory share the same database. + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + services.AddDbContext(options => + { + options.UseSqlite(connection); + }); + }); + } +} \ No newline at end of file diff --git a/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityMinimal.Tests.csproj b/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityMinimal.Tests.csproj new file mode 100644 index 0000000..3c04270 --- /dev/null +++ b/aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityMinimal.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + \ No newline at end of file From f25ae9837a29bef323da5c622000b5ec62ec1b54 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Thu, 30 Jul 2026 15:01:58 +0000 Subject: [PATCH 2/4] Add passkey sample to repository README --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index eb94f14..1dd72ed 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`software-architecture/architecture-testing-dotnet`](software-architecture/architecture-testing-dotnet/) | Minimal NetArchTest.eNhancedEdition rule that prevents Domain from depending on outer layers | [Architecture Testing in .NET: Enforce Layer and Module Boundaries with NetArchTest and ArchUnitNET](https://www.dotnet-guide.com/tutorials/software-architecture/architecture-testing-dotnet/) | | [`distributed-systems/transactional-outbox-ef-core`](distributed-systems/transactional-outbox-ef-core/) | Minimal EF Core and SQLite demonstration that saves business state and an outbox message atomically, then publishes it through a one-shot relay | [Transactional Outbox Pattern in .NET with EF Core (.NET 10): Fix the Dual-Write Problem](https://www.dotnet-guide.com/tutorials/distributed-systems/transactional-outbox-ef-core/) | | [`aspnet-core/api-security-in-practice`](aspnet-core/api-security-in-practice/) | Minimal JWT bearer authentication, note ownership enforcement, and rate limiting for an ASP.NET Core API | [ASP.NET Core 8 API Security: JWT Authentication, CSRF Protection & Rate Limiting](https://www.dotnet-guide.com/tutorials/aspnet-core/api-security-in-practice/) | +| [`aspnet-core/passkey-first-identity`](aspnet-core/passkey-first-identity/) | Minimal .NET 10 Identity sample for passkey enrollment, username-first passkey sign-in, antiforgery protection, and stored credential listing | [Passkeys in ASP.NET Core Identity (.NET 10): Build a Passwordless-First Web App with WebAuthn](https://www.dotnet-guide.com/tutorials/aspnet-core/passkey-first-identity/) | | ## Companion articles @@ -132,6 +133,28 @@ tutorials/ | `-- ApiSecurityMinimal.Tests/ | |-- ApiSecurityMinimal.Tests.csproj | `-- ApiSecurityTests.cs +| `-- passkey-first-identity/ +| |-- PasskeyIdentityMinimal.slnx +| |-- README.md +| |-- src/ +| | `-- PasskeyIdentityMinimal/ +| | |-- PasskeyIdentityMinimal.csproj +| | |-- Program.cs +| | |-- appsettings.json +| | |-- Data/ +| | | |-- ApplicationDbContext.cs +| | | `-- ApplicationUser.cs +| | |-- Endpoints/ +| | | |-- AntiforgeryEndpointExtensions.cs +| | | `-- PasskeyEndpoints.cs +| | `-- wwwroot/ +| | |-- index.html +| | `-- passkeys.js +| `-- tests/ +| `-- PasskeyIdentityMinimal.Tests/ +| |-- PasskeyIdentityMinimal.Tests.csproj +| |-- PasskeyEndpointTests.cs +| `-- PasskeyIdentityFactory.cs |-- .github/ | `-- workflows/ | `-- build-samples.yml From e6a0cddcaad39f43da11c294666f4dc0d123225b Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Thu, 30 Jul 2026 15:01:58 +0000 Subject: [PATCH 3/4] Test passkey identity sample in GitHub Actions --- .github/workflows/build-samples.yml | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 4add669..fa11a6a 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -10,6 +10,7 @@ on: - "software-architecture/architecture-testing-dotnet/**" - "distributed-systems/transactional-outbox-ef-core/**" - "aspnet-core/api-security-in-practice/**" + - "aspnet-core/passkey-first-identity/**" - ".github/workflows/build-samples.yml" pull_request: @@ -20,6 +21,7 @@ on: - "software-architecture/architecture-testing-dotnet/**" - "distributed-systems/transactional-outbox-ef-core/**" - "aspnet-core/api-security-in-practice/**" + - "aspnet-core/passkey-first-identity/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -214,3 +216,38 @@ jobs: aspnet-core/api-security-in-practice/tests/ApiSecurityMinimal.Tests/ApiSecurityMinimal.Tests.csproj --configuration Release --no-build + + test-passkey-identity: + name: Test passkey identity sample + runs-on: ubuntu-latest + env: + ASPNETCORE_ENVIRONMENT: Testing + Passkeys__ServerDomain: localhost + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + aspnet-core/passkey-first-identity/PasskeyIdentityMinimal.slnx + + - name: Build + run: > + dotnet build + aspnet-core/passkey-first-identity/PasskeyIdentityMinimal.slnx + --configuration Release + --no-restore + + - name: Test server-side passkey boundaries + run: > + dotnet test + aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityMinimal.Tests.csproj + --configuration Release + --no-build From 79e59fec030a9abf9b6c3bf324ea114f480f4d32 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Thu, 30 Jul 2026 15:02:58 +0000 Subject: [PATCH 4/4] Add *.db patterns to .gitignore --- .gitignore | 5 +++++ .../PasskeyIdentityMinimal.db | Bin 106496 -> 0 bytes 2 files changed, 5 insertions(+) delete mode 100644 aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.db diff --git a/.gitignore b/.gitignore index 19ed2ac..f1688b2 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,11 @@ TestResults/ *.coverage* *.trx +# Database files +*.db +*.db-shm +*.db-wal + # Publish / artifacts [Bb]uild[Aa]rtifacts/ artifacts/ diff --git a/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.db b/aspnet-core/passkey-first-identity/src/PasskeyIdentityMinimal/PasskeyIdentityMinimal.db deleted file mode 100644 index b8894f0f1a93ea950975ae6a3adec12e84a54da4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 106496 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