diff --git a/Microsoft.Maui-dev.sln b/Microsoft.Maui-dev.sln
index 581401a01795..331272cf7143 100644
--- a/Microsoft.Maui-dev.sln
+++ b/Microsoft.Maui-dev.sln
@@ -113,6 +113,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Sample", "src\Es
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Sample.Server.WebAuthenticator", "src\Essentials\samples\Sample.Server.WebAuthenticator\Essentials.Sample.Server.WebAuthenticator.csproj", "{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Samples.WebServer", "src\Essentials\samples\Samples.WebServer\Essentials.Samples.WebServer.csproj", "{049EE355-4999-4ECB-8C66-F3425B024336}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorWinFormsApp", "src\BlazorWebView\samples\BlazorWinFormsApp\BlazorWinFormsApp.csproj", "{7A10CA08-6394-43D3-AFAA-4D696EA111C9}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorWpfApp", "src\BlazorWebView\samples\BlazorWpfApp\BlazorWpfApp.csproj", "{3C0ACFD6-9FBE-46C2-B4A5-3C1839476A1D}"
@@ -427,6 +429,10 @@ Global
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Release|Any CPU.Build.0 = Release|Any CPU
{7A10CA08-6394-43D3-AFAA-4D696EA111C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7A10CA08-6394-43D3-AFAA-4D696EA111C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A10CA08-6394-43D3-AFAA-4D696EA111C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -742,6 +748,7 @@ Global
{95BF8553-3D9D-4831-ACDD-A3E697BC08A6} = {94F3C036-A5F4-4ACC-A028-8506802ADB88}
{C677BF3D-B234-491D-BA48-D9742DB564F8} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
+ {049EE355-4999-4ECB-8C66-F3425B024336} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
{7A10CA08-6394-43D3-AFAA-4D696EA111C9} = {A8E9400E-70DD-421F-8609-1C2FA4AE8E71}
{3C0ACFD6-9FBE-46C2-B4A5-3C1839476A1D} = {A8E9400E-70DD-421F-8609-1C2FA4AE8E71}
{ED7F28E0-D0AF-417D-983D-3D874EEE8554} = {1614D1A4-5C3D-4D5B-8C89-426E37A564EF}
diff --git a/Microsoft.Maui-mac.slnf b/Microsoft.Maui-mac.slnf
index 1cdb0ae49579..47c93fcbe600 100644
--- a/Microsoft.Maui-mac.slnf
+++ b/Microsoft.Maui-mac.slnf
@@ -45,6 +45,7 @@
"src\\Core\\tests\\DeviceTests\\Core.DeviceTests.csproj",
"src\\Core\\tests\\UnitTests\\Core.UnitTests.csproj",
"src\\Essentials\\samples\\Sample.Server.WebAuthenticator\\Essentials.Sample.Server.WebAuthenticator.csproj",
+ "src\\Essentials\\samples\\Samples.WebServer\\Essentials.Samples.WebServer.csproj",
"src\\Essentials\\samples\\Samples\\Essentials.Sample.csproj",
"src\\Essentials\\src\\Essentials.csproj",
"src\\Essentials\\test\\DeviceTests\\Essentials.DeviceTests.csproj",
diff --git a/Microsoft.Maui-vscode.sln b/Microsoft.Maui-vscode.sln
index 3e88b3db593e..8a2ad6406c27 100644
--- a/Microsoft.Maui-vscode.sln
+++ b/Microsoft.Maui-vscode.sln
@@ -111,6 +111,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Sample", "src\Es
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Sample.Server.WebAuthenticator", "src\Essentials\samples\Sample.Server.WebAuthenticator\Essentials.Sample.Server.WebAuthenticator.csproj", "{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Samples.WebServer", "src\Essentials\samples\Samples.WebServer\Essentials.Samples.WebServer.csproj", "{049EE355-4999-4ECB-8C66-F3425B024336}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{ED7F28E0-D0AF-417D-983D-3D874EEE8554}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestUtils.DeviceTests", "src\TestUtils\src\DeviceTests\TestUtils.DeviceTests.csproj", "{F28E8899-98D2-4915-8D48-D101D4837AB9}"
@@ -388,6 +390,10 @@ Global
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Release|Any CPU.Build.0 = Release|Any CPU
{F28E8899-98D2-4915-8D48-D101D4837AB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F28E8899-98D2-4915-8D48-D101D4837AB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F28E8899-98D2-4915-8D48-D101D4837AB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -652,6 +658,7 @@ Global
{95BF8553-3D9D-4831-ACDD-A3E697BC08A6} = {94F3C036-A5F4-4ACC-A028-8506802ADB88}
{C677BF3D-B234-491D-BA48-D9742DB564F8} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
+ {049EE355-4999-4ECB-8C66-F3425B024336} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
{ED7F28E0-D0AF-417D-983D-3D874EEE8554} = {1614D1A4-5C3D-4D5B-8C89-426E37A564EF}
{F28E8899-98D2-4915-8D48-D101D4837AB9} = {7AC28763-9C68-4BF9-A1BA-25CBFFD2D15C}
{C8B3C3B3-1CDA-41A2-BF20-A7FE33D6BB36} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
diff --git a/Microsoft.Maui-windows.slnf b/Microsoft.Maui-windows.slnf
index 5c975ef5789a..2e4b7a9c8e1a 100644
--- a/Microsoft.Maui-windows.slnf
+++ b/Microsoft.Maui-windows.slnf
@@ -52,6 +52,7 @@
"src\\Core\\tests\\DeviceTests\\Core.DeviceTests.csproj",
"src\\Core\\tests\\UnitTests\\Core.UnitTests.csproj",
"src\\Essentials\\samples\\Sample.Server.WebAuthenticator\\Essentials.Sample.Server.WebAuthenticator.csproj",
+ "src\\Essentials\\samples\\Samples.WebServer\\Essentials.Samples.WebServer.csproj",
"src\\Essentials\\samples\\Samples\\Essentials.Sample.csproj",
"src\\Essentials\\src\\Essentials.csproj",
"src\\Essentials\\test\\DeviceTests\\Essentials.DeviceTests.csproj",
diff --git a/Microsoft.Maui.sln b/Microsoft.Maui.sln
index a422dffb3af3..ddadbb791683 100644
--- a/Microsoft.Maui.sln
+++ b/Microsoft.Maui.sln
@@ -113,6 +113,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Sample", "src\Es
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Sample.Server.WebAuthenticator", "src\Essentials\samples\Sample.Server.WebAuthenticator\Essentials.Sample.Server.WebAuthenticator.csproj", "{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.Samples.WebServer", "src\Essentials\samples\Samples.WebServer\Essentials.Samples.WebServer.csproj", "{049EE355-4999-4ECB-8C66-F3425B024336}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorWinFormsApp", "src\BlazorWebView\samples\BlazorWinFormsApp\BlazorWinFormsApp.csproj", "{7A10CA08-6394-43D3-AFAA-4D696EA111C9}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorWpfApp", "src\BlazorWebView\samples\BlazorWpfApp\BlazorWpfApp.csproj", "{3C0ACFD6-9FBE-46C2-B4A5-3C1839476A1D}"
@@ -414,6 +416,10 @@ Global
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {049EE355-4999-4ECB-8C66-F3425B024336}.Release|Any CPU.Build.0 = Release|Any CPU
{7A10CA08-6394-43D3-AFAA-4D696EA111C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7A10CA08-6394-43D3-AFAA-4D696EA111C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A10CA08-6394-43D3-AFAA-4D696EA111C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -721,6 +727,7 @@ Global
{95BF8553-3D9D-4831-ACDD-A3E697BC08A6} = {94F3C036-A5F4-4ACC-A028-8506802ADB88}
{C677BF3D-B234-491D-BA48-D9742DB564F8} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
{F7DB0CB3-D244-403A-8C3B-B1ED5E5838EC} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
+ {049EE355-4999-4ECB-8C66-F3425B024336} = {95BF8553-3D9D-4831-ACDD-A3E697BC08A6}
{7A10CA08-6394-43D3-AFAA-4D696EA111C9} = {A8E9400E-70DD-421F-8609-1C2FA4AE8E71}
{3C0ACFD6-9FBE-46C2-B4A5-3C1839476A1D} = {A8E9400E-70DD-421F-8609-1C2FA4AE8E71}
{ED7F28E0-D0AF-417D-983D-3D874EEE8554} = {1614D1A4-5C3D-4D5B-8C89-426E37A564EF}
diff --git a/docs/specs/Passkeys.md b/docs/specs/Passkeys.md
new file mode 100644
index 000000000000..a3a39364d8d4
--- /dev/null
+++ b/docs/specs/Passkeys.md
@@ -0,0 +1,1019 @@
+# Passkeys (WebAuthn / FIDO2) — Cross-platform Essentials API
+
+| Property | Value |
+|---|---|
+| **Status** | Proposed (spec-first) — design complete, under review |
+| **Area** | `area-essentials` |
+| **Namespace** | `Microsoft.Maui.Authentication` |
+| **Target** | `net11.0` feature branch (adds public API) |
+| **Related** | Discussion [#21498](https://github.com/dotnet/maui/discussions/21498) "FIDO2 Passkeys support?", Issue [#32020](https://github.com/dotnet/maui/issues/32020) "Cannot use passkey/fido/webauthn in BlazorWebView" |
+
+> This document presents the **complete proposed design** of the Passkeys Essentials API for the `net11.0`
+> branch — written as a definitive design rather than a running decision log. It is a spec-first proposal
+> and **under review**; feedback on the PR is welcome. Items intentionally left for later are listed under
+> [§13 Planned follow-ups](#13-planned-follow-ups).
+
+## 1. Summary
+
+Add a cross-platform Essentials API that lets a .NET MAUI app create and use **passkeys** (WebAuthn /
+FIDO2 public-key credentials) using the native platform authenticator UI (Face ID / Touch ID / Windows
+Hello / Android biometric + Google Password Manager / iCloud Keychain).
+
+The API is intentionally **thin**: it brokers between the app's relying-party (RP) server and the OS
+authenticator. The server produces standard WebAuthn options JSON; the API drives the native UI and
+returns the standard WebAuthn response JSON to send back to the server for verification. It does **not**
+implement any server-side WebAuthn verification, attestation validation, or challenge generation.
+
+## 2. Motivation
+
+- Passwordless / phishing-resistant sign-in via passkeys is now a first-class capability on the primary
+ MAUI app platforms — Android, iOS/iPadOS, Mac Catalyst, and Windows — yet MAUI exposes **none** of it
+ natively. (Support is per-platform; see §7 for exactly which targets are covered and which fall back to
+ `IsSupported == false`.)
+- The existing `WebAuthenticator` Essentials API is **OAuth web-redirect** auth — despite the similar
+ name it is unrelated to WebAuthn/passkeys.
+- BlazorWebView cannot use the browser WebAuthn JS API ([#32020](https://github.com/dotnet/maui/issues/32020)),
+ so even hybrid apps need a native bridge.
+- Each platform's native passkey API is non-trivial (delegate/callback bridges on Apple, coroutine
+ interop on Android, raw Win32 struct marshaling on Windows). Centralizing this in Essentials removes a
+ large amount of per-app boilerplate and platform expertise.
+
+## 3. Goals / Non-goals
+
+### Goals
+- One cross-platform API to **create** (register) and **get** (authenticate / assert) a passkey.
+- Use the **standard WebAuthn JSON** contract so it interoperates 1:1 with existing server libraries
+ (e.g. [Fido2NetLib](https://github.com/passwordless-lib/fido2-net-lib), SimpleWebAuthn, and
+ **ASP.NET Core Identity's built-in passkeys**, .NET 10+).
+- Follow existing Essentials conventions (`interface` + static facade + per-platform partial
+ implementation + `Default`/`SetDefault` testability), mirroring `WebAuthenticator`.
+- **Use only the OS-official credential provider on each platform** — AndroidX Credential Manager, Apple
+ AuthenticationServices, Windows WebAuthn — with **no Google Play Services dependency** (see §7.1/§9).
+- Graceful capability detection (`IsSupported`) and clear exceptions on unsupported OS/versions.
+
+### Non-goals (for v1)
+- Server-side WebAuthn (challenge issuance, attestation/assertion verification). That stays on the RP
+ server, as the spec intends.
+- Acting as a **credential provider** / password manager (Android `CredentialProviderService`, iOS
+ AutoFill credential provider extension). This is "use passkeys in my app", not "be a passkey vault".
+- Bundling **Google Play Services** to back-fill passkeys on Android 9–13. We ship the OS-native path
+ only (Android 14+); apps that need the older range can add the Play adapter themselves (§7.1).
+- Conditional UI / autofill-driven passkey sign-in — a separate view-oriented feature, deferred (§7.5,
+ Appendix A).
+- A **BlazorWebView passkey bridge** ([#32020](https://github.com/dotnet/maui/issues/32020)), deferred —
+ see [§13 Planned follow-ups](#13-planned-follow-ups). A detailed follow-up issue is filed once the native
+ API is implemented and working.
+- Cross-device / security-key–only flows as a distinct API. On platforms where the OS offers this
+ automatically (Apple, Windows) it is available through the same call; a dedicated security-key API is
+ out of scope for v1.
+- A strongly-typed C# model of the entire WebAuthn options/response schema (see §6.2 for rationale).
+
+## 4. Background: passkeys & the cross-platform insight
+
+A passkey ceremony has two operations, both defined by the [W3C WebAuthn spec](https://www.w3.org/TR/webauthn-3/):
+
+1. **Registration** (`navigator.credentials.create`): server sends `PublicKeyCredentialCreationOptions`
+ → authenticator creates a key pair → returns an attestation response → server stores the public key.
+2. **Authentication** (`navigator.credentials.get`): server sends `PublicKeyCredentialRequestOptions`
+ → authenticator signs the challenge → returns an assertion → server verifies the signature.
+
+```mermaid
+sequenceDiagram
+ participant App as MAUI App
+ participant RP as RP Server
+ participant API as Passkeys (Essentials)
+ participant OS as Platform Authenticator
+
+ Note over App,OS: Registration
+ App->>RP: begin register (userId)
+ RP-->>App: PublicKeyCredentialCreationOptions (JSON)
+ App->>API: CreateAsync(creationOptionsJson)
+ API->>OS: native make-credential + biometric UI
+ OS-->>API: attestation
+ API-->>App: PasskeyCreationResponse
+ App->>RP: finish register (response JSON)
+
+ Note over App,OS: Authentication
+ App->>RP: begin login
+ RP-->>App: PublicKeyCredentialRequestOptions (JSON)
+ App->>API: AssertAsync(requestOptionsJson)
+ API->>OS: native get-assertion + biometric UI
+ OS-->>API: assertion (signature)
+ API-->>App: PasskeyAssertionResponse
+ App->>RP: finish login (response JSON)
+```
+
+**Key design driver — the interop format:**
+
+| Platform | Native contract |
+|---|---|
+| **Android** (Credential Manager) | **WebAuthn JSON in / JSON out** — native |
+| **Apple** (AuthenticationServices) | Structured `NSData` objects |
+| **Windows** (Win32 `webauthn.dll`) | Structured C structs |
+
+Because Android already speaks the exact browser WebAuthn JSON, and because that JSON is what every
+server library emits/consumes, the cross-platform contract is **JSON-in / JSON-out**. Android is a
+pass-through; Apple and Windows translate JSON ⇄ native structures internally. This keeps the public API
+tiny and forward-compatible with new WebAuthn fields.
+
+## 5. Public API
+
+> The C# below is **illustrative shape, not compilable code** — get-only properties, elided bodies, and
+> `internal` constructors show the intended public surface, not the implementation. Types are sketched to
+> convey names, signatures, and relationships for review.
+
+```csharp
+namespace Microsoft.Maui.Authentication;
+
+///
+/// Create and use passkeys (WebAuthn / FIDO2 public-key credentials) with the native
+/// platform authenticator. Brokers standard WebAuthn JSON between a relying-party server
+/// and the OS; does not perform server-side verification.
+///
+public interface IPasskeys
+{
+ ///
+ /// Whether this platform (and OS version) can create and use passkeys.
+ ///
+ bool IsSupported { get; }
+
+ ///
+ /// Registers a new passkey. Drives the native "create credential" UI.
+ ///
+ ///
+ /// The relying party's PublicKeyCredentialCreationOptions (server-provided).
+ ///
+ /// The WebAuthn registration response to send back to the RP server.
+ Task CreateAsync(
+ PasskeyCreationOptions options,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Authenticates with an existing passkey. Drives the native "get credential" UI.
+ ///
+ ///
+ /// The relying party's PublicKeyCredentialRequestOptions (server-provided).
+ ///
+ /// The WebAuthn assertion response to send back to the RP server.
+ Task AssertAsync(
+ PasskeyRequestOptions options,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// The relying party's PublicKeyCredentialCreationOptions, for .
+///
+public sealed class PasskeyCreationOptions
+{
+ /// The server's PublicKeyCredentialCreationOptions JSON.
+ public PasskeyCreationOptions(string creationOptionsJson) =>
+ _json = creationOptionsJson ?? throw new ArgumentNullException(nameof(creationOptionsJson));
+
+ readonly string _json;
+
+ ///
+ /// When , keep the ceremony **on this device** and skip any cross-device /
+ /// hybrid step (QR code, "use another device", phone-as-authenticator). For registration this means
+ /// only create a passkey if the local authenticator can do so directly; for authentication it means
+ /// only offer a passkey already present on this device. Maps to Android
+ /// preferImmediatelyAvailableCredentials and Apple's preferImmediatelyAvailableCredentials;
+ /// ignored on Windows. (App-side behavior knob — not part of the server JSON.)
+ ///
+ public bool PreferImmediatelyAvailable { get; set; }
+
+ /// Returns the underlying PublicKeyCredentialCreationOptions JSON.
+ public override string ToString() => _json;
+}
+
+///
+/// The relying party's PublicKeyCredentialRequestOptions, for .
+///
+public sealed class PasskeyRequestOptions
+{
+ /// The server's PublicKeyCredentialRequestOptions JSON.
+ public PasskeyRequestOptions(string requestOptionsJson) =>
+ _json = requestOptionsJson ?? throw new ArgumentNullException(nameof(requestOptionsJson));
+
+ readonly string _json;
+
+ ///
+ public bool PreferImmediatelyAvailable { get; set; }
+
+ /// Returns the underlying PublicKeyCredentialRequestOptions JSON.
+ public override string ToString() => _json;
+}
+
+///
+/// Result of a passkey registration. returns the full WebAuthn registration
+/// response (shape of PublicKeyCredential with an AuthenticatorAttestationResponse) —
+/// POST it to the RP server to finish registration. A couple of commonly-needed fields are decoded
+/// and cached as properties; everything else stays in the JSON for the server to verify.
+///
+public sealed class PasskeyCreationResponse
+{
+ internal PasskeyCreationResponse(string registrationResponseJson) { /* parses lazily; caches */ }
+
+ ///
+ /// The credential id (base64url), i.e. the WebAuthn PublicKeyCredential.id. This is the
+ /// single, primary identifier of the created passkey; store it to look the credential up later.
+ ///
+ public string Id { get; }
+
+ /// Returns the full WebAuthn registration response JSON.
+ public override string ToString();
+}
+
+///
+/// Result of a passkey authentication. returns the full WebAuthn authentication
+/// response (shape of PublicKeyCredential with an AuthenticatorAssertionResponse) —
+/// POST it to the RP server to finish sign-in. A couple of commonly-needed fields are decoded and
+/// cached as properties; everything else stays in the JSON for the server to verify.
+///
+public sealed class PasskeyAssertionResponse
+{
+ internal PasskeyAssertionResponse(string authenticationResponseJson) { /* parses lazily; caches */ }
+
+ ///
+ /// The credential id (base64url), i.e. the WebAuthn PublicKeyCredential.id — identifies which
+ /// passkey was used.
+ ///
+ public string Id { get; }
+
+ ///
+ /// The user handle (base64url) the RP set as user.id at registration, i.e. the WebAuthn
+ /// response.userHandle. Present for discoverable-credential ("username-less") sign-in; may be
+ /// when the authenticator does not return one.
+ ///
+ public string? UserHandle { get; }
+
+ /// Returns the full WebAuthn authentication response JSON.
+ public override string ToString();
+}
+
+/// Static facade, mirroring .
+public static class Passkeys
+{
+ public static bool IsSupported => Default.IsSupported;
+
+ public static Task CreateAsync(PasskeyCreationOptions options, CancellationToken cancellationToken = default)
+ => Default.CreateAsync(options, cancellationToken);
+
+ public static Task AssertAsync(PasskeyRequestOptions options, CancellationToken cancellationToken = default)
+ => Default.AssertAsync(options, cancellationToken);
+
+ // Convenience string overloads on the facade (construct the options object from raw server JSON).
+ public static Task CreateAsync(string creationOptionsJson, CancellationToken cancellationToken = default)
+ => Default.CreateAsync(new PasskeyCreationOptions(creationOptionsJson), cancellationToken);
+
+ public static Task AssertAsync(string requestOptionsJson, CancellationToken cancellationToken = default)
+ => Default.AssertAsync(new PasskeyRequestOptions(requestOptionsJson), cancellationToken);
+
+ static IPasskeys? defaultImplementation;
+ public static IPasskeys Default => defaultImplementation ??= new PasskeysImplementation();
+ internal static void SetDefault(IPasskeys? implementation) => defaultImplementation = implementation;
+}
+
+///
+/// Thrown when a passkey ceremony fails for a reason other than user cancellation
+/// () — e.g. no matching credential, a misconfigured domain
+/// association, or an underlying platform error. See §8 for the full mapping.
+///
+public class PasskeyException : Exception
+{
+ public PasskeyException(string message, Exception? innerException = null)
+ : base(message, innerException) { }
+}
+```
+
+**On the response properties (the 80/20).** Rather than extension methods, the two or three fields most
+apps actually read on-device are exposed as **real, cached properties** directly on the response types.
+Everything else (attestation object, authenticator data, signature, client-data JSON) stays inside the
+JSON returned by `ToString()` — those are consumed by the RP server, not the client. The responses parse
+their JSON lazily on first property access and cache the results.
+
+- **`Id` (both responses)** — the credential id, base64url. See §6.3 for why it's `Id` (matches the W3C
+ JSON member `id`) and not `CredentialId`, and why raw bytes are deferred.
+- **`UserHandle` (assertion only)** — base64url, nullable; the RP's `user.id`, useful for username-less
+ sign-in.
+
+### 5.1 Usage examples
+
+#### Registration (creating a passkey)
+
+The app asks its server to begin registration, hands the returned `PublicKeyCredentialCreationOptions`
+JSON to `CreateAsync`, which drives the native "create credential" UI (Face ID / Windows Hello / Android
+biometric). The resulting registration response JSON is posted back to the server, which verifies it and
+stores the new public key.
+
+```csharp
+using System.Text; // for StringContent / Encoding
+using Microsoft.Maui.Authentication;
+
+if (!Passkeys.IsSupported)
+ return; // fall back to password UI
+
+// 1. Ask your server to begin registration; it returns PublicKeyCredentialCreationOptions JSON.
+string creationOptionsJson = await httpClient.GetStringAsync("/passkey/register/begin");
+
+// 2. Drive the native create-credential UI (Face ID / Windows Hello / Android biometric).
+PasskeyCreationResponse created = await Passkeys.CreateAsync(creationOptionsJson);
+
+// 3. Send the raw response JSON back to the server to verify + store the public key.
+// `created.ToString()` is *already* WebAuthn JSON, so post it as a raw application/json
+// body — do NOT use PostAsJsonAsync, which would re-encode the string as a quoted JSON literal.
+using var body = new StringContent(created.ToString(), Encoding.UTF8, "application/json");
+await httpClient.PostAsync("/passkey/register/finish", body);
+
+// Optional: store the credential id so you can reference this passkey later.
+string credentialId = created.Id; // base64url
+```
+
+#### Login (authenticating with a passkey)
+
+The app asks its server to begin sign-in, hands the returned `PublicKeyCredentialRequestOptions` JSON to
+`AssertAsync`, which drives the native "get credential" UI so the user picks a passkey and authenticates.
+The resulting assertion response JSON is posted back to the server, which verifies the signature to
+complete sign-in.
+
+```csharp
+using System.Text; // for StringContent / Encoding
+using Microsoft.Maui.Authentication;
+
+if (!Passkeys.IsSupported)
+ return; // fall back to password UI
+
+// 1. Ask your server to begin sign-in; it returns PublicKeyCredentialRequestOptions JSON.
+string requestOptionsJson = await httpClient.GetStringAsync("/passkey/login/begin");
+
+// 2. Drive the native get-credential UI so the user selects a passkey and authenticates.
+PasskeyAssertionResponse asserted = await Passkeys.AssertAsync(requestOptionsJson);
+
+// 3. Send the raw response JSON back to the server to verify the signature and finish sign-in.
+// Post the already-serialized WebAuthn JSON as a raw application/json body (not PostAsJsonAsync).
+using var body = new StringContent(asserted.ToString(), Encoding.UTF8, "application/json");
+await httpClient.PostAsync("/passkey/login/finish", body);
+
+// Optional: a couple of commonly-needed fields are available directly as (cached) properties.
+string credentialId = asserted.Id; // base64url — which passkey was used
+string? userHandle = asserted.UserHandle; // base64url RP user id, if returned
+```
+
+## 6. Design
+
+### 6.1 JSON-in / JSON-out contract
+The API passes the server's WebAuthn options JSON through to the OS and returns the OS's WebAuthn response
+JSON back. This contract:
+- **Requires zero translation on Android** — Credential Manager consumes/produces exactly this JSON.
+- **Interoperates 1:1 with server libraries** — Fido2NetLib, SimpleWebAuthn, and ASP.NET Core Identity
+ already emit `CreationOptions`/`RequestOptions` JSON and consume the response JSON.
+- **Keeps the public surface small** — two options types + two response types.
+- **Is forward-compatible** — new WebAuthn fields (e.g. `hints`, the PRF extension) flow through the JSON
+ with no API change. On Apple/Windows the implementation maps the subset the OS supports and passes the
+ rest through.
+
+### 6.2 Thin wrapper types
+Each payload is a small dedicated type whose **`ToString()` returns the underlying WebAuthn JSON**. There is
+no shared base class and no `Json` property — the JSON is simply what the object stringifies to. The options
+types add the one app-side behavior knob (`PreferImmediatelyAvailable`); the response types add the couple
+of decoded properties apps read on-device (`Id`, and `UserHandle` on the assertion).
+
+- Wrapper types (rather than bare `string`s) give **compile-time safety** — an options object can't be
+ passed where a response is expected — and a natural home for the behavior knob and decoded properties,
+ while still surfacing the raw JSON verbatim via `ToString()`.
+- The API deliberately does **not** model the full WebAuthn schema (`Rp`, `User`, `PubKeyCredParams`,
+ `AllowCredentials`, `AuthenticatorSelection`, extensions…). That would be a large public surface tracking
+ ongoing WebAuthn spec churn, still require JSON serialization for Android, and duplicate types already in
+ server libraries. All of it stays in the JSON.
+- Decoded fields are **real, cached properties** on the response types. Responses parse their JSON lazily on
+ first access and cache the result. Additional properties/methods can be added later without breaking the
+ API.
+
+### 6.3 Naming decisions
+
+Naming is anchored to the terms the W3C WebAuthn spec and the platform SDKs already use, so the API is
+familiar to anyone who has touched passkeys and searchable against existing docs.
+
+**Industry background.** The [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) spec defines
+dedicated *JSON serialization* types whose names all carry a **`JSON` suffix**:
+[`PublicKeyCredentialCreationOptionsJSON`](https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptionsjson),
+[`PublicKeyCredentialRequestOptionsJSON`](https://w3c.github.io/webauthn/#dictdef-publickeycredentialrequestoptionsjson),
+[`RegistrationResponseJSON`](https://w3c.github.io/webauthn/#dictdef-registrationresponsejson), and
+[`AuthenticationResponseJSON`](https://w3c.github.io/webauthn/#dictdef-authenticationresponsejson),
+produced/consumed via [`PublicKeyCredential.toJSON()`](https://w3c.github.io/webauthn/#dom-publickeycredential-tojson)
+and `parseCreationOptionsFromJSON()` / `parseRequestOptionsFromJSON()`. Android's Credential Manager
+mirrors this with string members named
+[`requestJson`](https://developer.android.com/reference/androidx/credentials/CreatePublicKeyCredentialRequest),
+[`registrationResponseJson`](https://developer.android.com/reference/androidx/credentials/CreatePublicKeyCredentialResponse),
+and [`authenticationResponseJson`](https://developer.android.com/reference/androidx/credentials/PublicKeyCredential).
+
+So the industry vocabulary is: **inputs are "options", outputs are "responses", and the serialized form
+is called "JSON"** — not "payload", not "request"/"response body". That directly informs the names below.
+
+| MAUI type / member | Wraps (industry type) | Reasoning & source |
+|---|---|---|
+| `PasskeyCreationOptions` | [`PublicKeyCredentialCreationOptionsJSON`](https://w3c.github.io/webauthn/#dictdef-publickeycredentialcreationoptionsjson) | Registration **input** → "creation options". Matches W3C "creation options" and Android's `CreatePublicKeyCredentialRequest(requestJson)`. |
+| `PasskeyRequestOptions` | [`PublicKeyCredentialRequestOptionsJSON`](https://w3c.github.io/webauthn/#dictdef-publickeycredentialrequestoptionsjson) | Authentication **input** → "request options". Matches W3C "request options" and Android's `GetPublicKeyCredentialOption(requestJson)`. (WebAuthn overloads "request" to mean the *get* options — hence `RequestOptions`, not `AssertionOptions`.) |
+| `PasskeyCreationResponse` | [`RegistrationResponseJSON`](https://w3c.github.io/webauthn/#dictdef-registrationresponsejson) | Registration **output**. W3C/Android both call this the "registration response". |
+| `PasskeyAssertionResponse` | [`AuthenticationResponseJSON`](https://w3c.github.io/webauthn/#dictdef-authenticationresponsejson) | Authentication **output**. The W3C JSON type is "authentication response"; the underlying object is `AuthenticatorAssertionResponse` and Apple calls it an *assertion* — `Assertion` names the response after that ceremony output. |
+| `ToString()` (each type) | `...JSON` suffix / Android `...Json` members | Returns the raw serialized value. There is no separate `Json` property or shared base — the object simply stringifies to its WebAuthn JSON. |
+| `Id` (both responses) | [`PublicKeyCredential.id`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-id) | The credential id, base64url. See "Id" below. |
+| `UserHandle` (assertion) | [`AuthenticatorAssertionResponse.userHandle`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorassertionresponse-userhandle) → JSON [`userHandle`](https://w3c.github.io/webauthn/#dom-authenticationresponsejson) | "User handle" is the W3C term of art (the RP's `user.id`). `string?` base64url, nullable. Apple exposes it as `UserId`; the API uses the W3C name. |
+| `Passkeys` / `IPasskeys` | — | User-facing term everyone uses ([FIDO Alliance "passkeys"](https://fidoalliance.org/passkeys/)), rather than the spec-internal `WebAuthn`/`PublicKeyCredential` or the older `FIDO2`. |
+| `CreateAsync` | `navigator.credentials.create()` | W3C registration verb is *create*; Android is `createCredential`. |
+| `AssertAsync` | `navigator.credentials.get()` | The W3C authentication verb is *get*, but a bare `GetAsync` is meaningless here and collides with the many `Get*` APIs; the ceremony's output is an [*assertion*](https://www.w3.org/TR/webauthn-3/#authentication-assertion), so `Assert` is precise. |
+
+**No shared base type.** The four wrapper types have no public base class. Holding and stringifying JSON is
+fully served by a `ToString()` override on each concrete type, and an options type and a response type share
+nothing else. ("Payload" is not used as a name — WebAuthn/Android never use it, and it would blur the
+options-vs-response distinction.)
+
+**`Id`.** In WebAuthn the value is the [**Credential ID**](https://www.w3.org/TR/webauthn-3/#credential-id):
+a probabilistically-unique byte sequence identifying the public key credential. It surfaces on
+`PublicKeyCredential` in two forms of the *same* value —
+[`id`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-id) (base64url string) and
+[`rawId`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-rawid) (the bytes). Within a single
+passkey response there is exactly one identifier and it is the primary one, so it is exposed as **`Id`**
+(`string`, base64url):
+
+- Matches the W3C/Android JSON member name `id` verbatim — the exact token stored in the RP's database, so
+ comparisons are direct.
+- Unambiguous in context (a `PasskeyAssertionResponse.Id` can only be the credential id).
+- Shortest correct name.
+
+**Raw id bytes are not surfaced.** `rawId` is the same Credential ID as bytes. Credential IDs are
+[spec-capped at 1023 bytes](https://www.w3.org/TR/webauthn-3/#credential-id) but for passkeys are typically
+small (~16–64 bytes). The base64url `Id` is what apps forward and compare, and the full `rawId` remains in
+the `ToString()` JSON. If bytes are ever needed, the .NET guideline against array-typed properties means
+they would be added as a **method** (`byte[] GetRawId()`) — additive and non-breaking.
+
+### 6.4 Placement
+- Lives in Essentials alongside `WebAuthenticator`, namespace `Microsoft.Maui.Authentication`.
+
+### 6.5 Decoded properties (surfaced vs. left in JSON)
+
+Everything in the response is reachable via `ToString()` (the raw WebAuthn JSON). The question is only
+*which* fields are common enough to also decode into first-class properties. The test is: **does a typical
+client app read this on-device, or does it only forward it to the server?** Fields that only the RP server
+consumes stay in the JSON.
+
+| Field (WebAuthn) | On | What it is / used for | Typical app needs it client-side? | Surfacing |
+|---|---|---|---|---|
+| `id` | both | Credential ID (base64url) — which passkey; store/reference it | **Yes** — store per user, dedupe, display | **`Id`** ✅ surfaced |
+| `response.userHandle` | assert | RP `user.id` — identifies the account in username-less sign-in *before* server round-trip | **Yes** for discoverable-credential UX | **`UserHandle`** ✅ surfaced |
+| `authenticatorAttachment` | both | `"platform"` (this device) vs `"cross-platform"` (security key / phone) | **Sometimes** — UX copy ("passkey saved on this device" vs "on your security key") | In JSON. Natural future addition as `AuthenticatorAttachment` (nullable enum) — non-breaking |
+| `rawId` | both | Same Credential ID as bytes | Rarely — apps forward/compare the base64url `id` | In JSON; if ever added, a `byte[] GetRawId()` **method** |
+| `response.transports` | reg | Authenticator transports (`usb`/`nfc`/`ble`/`internal`/`hybrid`); server stores to optimize future `allowCredentials` | **No** — server-side optimization | In JSON |
+| `response.publicKey` / `publicKeyAlgorithm` | reg | The credential public key + COSE alg | **No** — server verifies/stores | In JSON |
+| `response.attestationObject` | reg | Attestation + public key | **No** — server verifies | In JSON |
+| `response.authenticatorData` | assert | Signed authenticator data (RP ID hash, counter, flags) | **No** — server verifies | In JSON |
+| `response.signature` | assert | Assertion signature | **No** — server verifies | In JSON |
+| `response.clientDataJSON` | both | Challenge/origin/type the client signed | **No** — server verifies | In JSON |
+| `clientExtensionResults` (e.g. `credProps.rk`, `prf`) | both | Extension outputs; `credProps.rk` = whether the passkey is discoverable | **Rarely** — advanced UX only, and unreliable across authenticators | In JSON |
+| `type` | both | Always `"public-key"` | No | In JSON |
+
+**Surfaced in v1:** `Id` (both responses) and `UserHandle` (the assertion). Everything else is
+server-verification material and stays in the JSON, keeping the surface small. New properties (the most
+likely being `AuthenticatorAttachment` for UX messaging) can be added later without breaking the API.
+
+## 7. Platform implementation design
+
+Each platform gets a `PasskeysImplementation` partial, following the existing `WebAuthenticator` file
+convention (see `src/Essentials/src/WebAuthenticator/`): `Passkeys.android.cs`, `Passkeys.ios.cs`
+(compiles for **both** iOS and Mac Catalyst), `Passkeys.windows.cs`, and a not-supported stub
+`Passkeys.netstandard.tvos.tizen.cs`. A `Passkeys.maccatalyst.cs` would be added only if Mac Catalyst
+needs behavior that differs from iOS. Note Essentials does **not** currently build a standalone `net-macos`
+target (the `macos` compile group in `Essentials.csproj` is commented out), so there is no
+`Passkeys.macos.cs` in v1 — see §7.2.
+
+### 7.1 Android — Jetpack Credential Manager
+
+- Docs: [Credential Manager](https://developer.android.com/identity/credential-manager) ·
+ [Sign in with passkeys](https://developer.android.com/identity/sign-in/credential-manager) ·
+ [`androidx.credentials` reference](https://developer.android.com/reference/androidx/credentials/package-summary)
+- **New NuGet dependency**: `Xamarin.AndroidX.Credentials` **only**. We deliberately do **not** add
+ `Xamarin.AndroidX.Credentials.PlayServicesAuth`.
+ - **Why no Play Services?** `androidx.credentials:credentials` is the OS API surface; the separate
+ `credentials-play-services-auth` artifact is just an *adapter* that routes to Google Play Services
+ (Google Password Manager) to back-fill passkeys on **Android 9–13 (API 28–33)**. On **Android 14+
+ (API 34)** the platform's own `CredentialManager` handles passkeys **natively, with no Play Services**.
+ - This matches the spec's OS-official principle (same posture as Apple/Windows: use only what the OS
+ provides) and — importantly — **Essentials has zero Google Play Services dependencies today** (its
+ Android deps are AndroidX Activity/Browser/Security.SecurityCrypto + Tink). Bundling
+ `credentials-play-services-auth` would introduce the *first* GMS dependency into `Microsoft.Maui.Essentials`,
+ which we want to avoid.
+ - **Consequence:** the built-in passkey path is **Android 14+ (API 34)**. Apps that must also support
+ API 28–33 can opt in by adding the `credentials-play-services-auth` provider to *their own* app; the
+ same `CredentialManager` calls then light up on older devices. MAUI does not force that cost on everyone.
+- Native model (Kotlin, from the official guide):
+
+ ```kotlin
+ // Registration
+ val credentialManager = CredentialManager.create(context)
+ val request = CreatePublicKeyCredentialRequest(requestJson = creationOptionsJson)
+ val result = credentialManager.createCredential(context, request)
+ as CreatePublicKeyCredentialResponse
+ val registrationResponseJson = result.registrationResponseJson
+
+ // Authentication
+ val option = GetPublicKeyCredentialOption(requestJson = requestOptionsJson)
+ val getRequest = GetCredentialRequest(listOf(option))
+ val getResult = credentialManager.getCredential(context, getRequest)
+ val publicKeyCredential = getResult.credential as PublicKeyCredential
+ val authenticationResponseJson = publicKeyCredential.authenticationResponseJson
+ ```
+
+- Projected .NET usage (`AndroidX.Credentials`, exact async-interop shape to be confirmed during
+ implementation — the underlying API is Kotlin-suspend/callback and will be wrapped in a
+ `TaskCompletionSource`):
+
+ ```csharp
+ var manager = CredentialManager.Create(Platform.CurrentActivity!);
+ var request = new CreatePublicKeyCredentialRequest(options.ToString());
+ var response = (CreatePublicKeyCredentialResponse)await manager.CreateCredentialAsync(
+ Platform.CurrentActivity!, request /*, cancellationSignal, executor */);
+ var registrationResponseJson = response.RegistrationResponseJson;
+ ```
+
+- **Context**: requires the current `Activity` (via `Platform.CurrentActivity`). Passkey UI is a bottom
+ sheet on that activity.
+- **App setup (documented, not code)**: host a [Digital Asset Links](https://developer.android.com/identity/sign-in/credential-manager#add-support-dal)
+ file at `https:///.well-known/assetlinks.json` binding the app's signing certificate.
+- **Min API**: with the no-Play (OS-native) path, passkeys require **API 34 (Android 14)**. `IsSupported`
+ returns `false` below that (unless a Play-backed provider has been added by the app). Note the Jetpack
+ `androidx.credentials` API itself is callable from API 23+, but passkey *credentials* are only
+ OS-native from 34.
+- Exceptions map from `CreateCredentialException` / `GetCredentialException` subclasses (e.g.
+ `*CancellationException` → `TaskCanceledException`; all other failures, including `NoCredentialException`,
+ → `PasskeyException`, matching §8).
+
+### 7.2 Apple — AuthenticationServices (iOS / iPadOS / Mac Catalyst)
+
+- **Scope note (macOS).** The `AuthenticationServices` passkey API exists on standalone macOS 13+ too,
+ but **Essentials does not currently build a `net-macos` target** (the `macos` compile group in
+ `Essentials.csproj` is commented out). So v1 covers **iOS, iPadOS, and Mac Catalyst**. Standalone
+ macOS support is a near-free follow-up once/if Essentials enables the macOS TFM — the implementation
+ code would be effectively identical.
+- Docs: [`ASAuthorizationPlatformPublicKeyCredentialProvider`](https://developer.apple.com/documentation/authenticationservices/asauthorizationplatformpublickeycredentialprovider) ·
+ [Supporting passkeys](https://developer.apple.com/documentation/authenticationservices/public-private_key_authentication/supporting_passkeys) ·
+ [.NET binding](https://learn.microsoft.com/dotnet/api/authenticationservices.asauthorizationplatformpublickeycredentialprovider)
+- **No new dependency** — `AuthenticationServices` is already bound in `Microsoft.iOS` /
+ `Microsoft.MacCatalyst` (and `Microsoft.macOS`, if a macOS target is later enabled).
+- **Structured, not JSON.** We parse the incoming options JSON, extract `challenge`, `user.id`,
+ `user.name`, `rp.id`, `pubKeyCredParams`, `allowCredentials`, `userVerification`, then build the
+ native request; on completion we read the raw `NSData` and **assemble the WebAuthn response JSON**
+ ourselves (base64url-encoding the binary fields).
+- **Binding note (Obj-C, not Swift).** `Microsoft.iOS` / `Microsoft.MacCatalyst` bind the **Objective-C**
+ `AuthenticationServices` framework and project it to C#. There is no Swift interop involved — the
+ "native" API called from the MAUI implementation is the bound Obj-C surface. The Swift snippet below is
+ the canonical Apple-docs reference; the C# snippet is the equivalent bound API the implementation uses.
+
+- Reference — Apple's native model (Swift, from the Apple docs):
+
+ ```swift
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
+
+ // Registration
+ let reg = provider.createCredentialRegistrationRequest(
+ challenge: challenge, name: userName, userID: userId)
+ // Authentication
+ let asr = provider.createCredentialAssertionRequest(challenge: challenge)
+
+ let controller = ASAuthorizationController(authorizationRequests: [reg]) // or [asr]
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ controller.performRequests()
+ ```
+
+- Bound API — the equivalent in C# (Objective-C projection via `Microsoft.iOS` etc.), which is what the
+ MAUI implementation actually writes:
+
+ ```csharp
+ using AuthenticationServices;
+ using Foundation;
+
+ var provider = new ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId);
+
+ // Registration (challenge/userId are NSData parsed from the options JSON)
+ ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest reg =
+ provider.CreateCredentialRegistrationRequest(challenge, userName, userId);
+ // Authentication
+ ASAuthorizationPlatformPublicKeyCredentialAssertionRequest asr =
+ provider.CreateCredentialAssertionRequest(challenge);
+
+ var controller = new ASAuthorizationController(new ASAuthorizationRequest[] { reg }) // or { asr }
+ {
+ Delegate = this, // ASAuthorizationControllerDelegate
+ PresentationContextProvider = this, // IASAuthorizationControllerPresentationContextProviding
+ };
+ controller.PerformRequests();
+
+ // Delegate callbacks (bridged to a TaskCompletionSource):
+ // DidComplete(ASAuthorizationController, ASAuthorization) -> success
+ // DidComplete(ASAuthorizationController, NSError) -> failure/cancel
+ ```
+
+- Verified .NET binding members we build on (`net-ios` `AuthenticationServices`, `Microsoft.iOS.dll`):
+ - `ASAuthorizationPlatformPublicKeyCredentialProvider(string relyingPartyIdentifier)`,
+ `.CreateCredentialRegistrationRequest(NSData challenge, string name, NSData userId)`,
+ `.CreateCredentialAssertionRequest(NSData challenge)`.
+ - Request props: `Challenge`, `Name`, `UserId`, `DisplayName`, `UserVerificationPreference`,
+ `AttestationPreference`.
+ - Registration result `ASAuthorizationPlatformPublicKeyCredentialRegistration`:
+ `RawAttestationObject`, `RawClientDataJson`, `CredentialId`.
+ - Assertion result `ASAuthorizationPlatformPublicKeyCredentialAssertion`:
+ `RawAuthenticatorData`, `Signature`, `UserId`, `RawClientDataJson`, `CredentialId`.
+- Async bridge: wrap the `ASAuthorizationControllerDelegate` callbacks
+ (`DidComplete(...ASAuthorization)` / `DidComplete(...NSError)`) in a `TaskCompletionSource`. Reuse the
+ window/presentation-anchor plumbing already used by other Essentials APIs.
+- **App setup (documented)**: [Associated Domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains)
+ entitlement with `webcredentials:` and a hosted `apple-app-site-association` file.
+- **Min OS**: iOS 16 / iPadOS 16 / Mac Catalyst 16 (and macOS 13 Ventura if a macOS target is later
+ enabled). Gate `IsSupported` via `OperatingSystem.IsIOSVersionAtLeast(16)` etc.
+
+### 7.3 Windows — Win32 WebAuthn API (`webauthn.dll`)
+
+- Docs: [`WebAuthNAuthenticatorMakeCredential`](https://learn.microsoft.com/windows/win32/api/webauthn/nf-webauthn-webauthnauthenticatormakecredential) ·
+ [`WebAuthNAuthenticatorGetAssertion`](https://learn.microsoft.com/windows/win32/api/webauthn/nf-webauthn-webauthnauthenticatorgetassertion) ·
+ [webauthn.h header](https://learn.microsoft.com/windows/win32/api/webauthn/) ·
+ [Microsoft `webauthn` reference implementation](https://github.com/microsoft/webauthn)
+- **No NuGet dependency** — direct P/Invoke into the in-box `webauthn.dll`. `AllowUnsafeBlocks` is
+ already enabled for the Windows TFM in `Essentials.csproj`.
+- **Structured, not JSON.** Same JSON ⇄ struct translation as Apple, plus manual marshaling.
+- Native signatures:
+
+ ```cpp
+ HRESULT WebAuthNAuthenticatorMakeCredential(
+ HWND hWnd,
+ PCWEBAUTHN_RP_ENTITY_INFORMATION pRpInformation,
+ PCWEBAUTHN_USER_ENTITY_INFORMATION pUserInformation,
+ PCWEBAUTHN_COSE_CREDENTIAL_PARAMETERS pPubKeyCredParams,
+ PCWEBAUTHN_CLIENT_DATA pWebAuthNClientData,
+ PCWEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS pWebAuthNMakeCredentialOptions,
+ PWEBAUTHN_CREDENTIAL_ATTESTATION *ppWebAuthNCredentialAttestation);
+
+ HRESULT WebAuthNAuthenticatorGetAssertion(
+ HWND hWnd,
+ LPCWSTR pwszRpId,
+ PCWEBAUTHN_CLIENT_DATA pWebAuthNClientData,
+ PCWEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS pWebAuthNGetAssertionOptions,
+ PWEBAUTHN_ASSERTION *ppWebAuthNAssertion);
+
+ DWORD WebAuthNGetApiVersionNumber();
+ HRESULT WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable(BOOL *pbIsUserVerifyingPlatformAuthenticatorAvailable);
+ void WebAuthNFreeCredentialAttestation(PWEBAUTHN_CREDENTIAL_ATTESTATION);
+ void WebAuthNFreeAssertion(PWEBAUTHN_ASSERTION);
+ ```
+
+- **HWND**: the API is modal on a top-level window. Acquire the current window handle from the MAUI
+ window (`WinRT.Interop.WindowNative.GetWindowHandle(...)`).
+- **`ClientDataJson` & origin**: the WebAuthn options JSON does **not** contain an `origin` (in a browser
+ the user agent supplies it from the current page). For a native app there is no page, so the platform
+ determines the origin from the app's verified identity, and the RP server must be configured to accept
+ that native origin (see "Origin derivation" below). On Windows specifically, our P/Invoke layer
+ constructs the `WEBAUTHN_CLIENT_DATA` (challenge + type + origin) — we build client data JSON using the
+ challenge from the options and an origin of `https://`. The OS returns `pbAttestationObject` /
+ `pbCredentialId` (make) and `pbAuthenticatorData` / `pbSignature` / `pbUserId` (get), which we
+ base64url-encode into the response JSON.
+- **Version gating**: `WebAuthNGetApiVersionNumber()` for capability, and
+ `WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable` for Hello availability. Passkeys need
+ **Windows 11**; older `webauthn.dll` (Win10 1903+) supports FIDO2 security keys but not full passkeys.
+- **Highest implementation cost** of the three (struct marshaling, memory ownership/free, version
+ branching). Recommend implementing this platform **last**.
+
+### 7.4 Unsupported platforms
+- **Built by Essentials but no passkey support in v1** — `netstandard`, tvOS, Tizen: `IsSupported == false`;
+ `CreateAsync`/`AssertAsync` throw `FeatureNotSupportedException` (consistent with other Essentials APIs).
+ Covered by the `Passkeys.netstandard.tvos.tizen.cs` stub. (tvOS *does* have an
+ `AuthenticationServices` passkey API and could be added later; it is out of scope for v1.)
+- **Not built by Essentials today** — standalone macOS and watchOS compile groups are commented out in
+ `Essentials.csproj`, so they need no stub until those targets are enabled.
+
+### 7.5 Platform behavior & runtime knobs
+
+Because of the **JSON-in / JSON-out** contract, most per-platform passkey configuration is **already carried
+inside the WebAuthn options JSON** and needs no cross-platform API knob. A small set of things are true
+*runtime behaviors* — not describable in the options JSON — and those are what the API surfaces as knobs.
+
+**Carried by the WebAuthn options JSON (no API surface — set these server-side):**
+
+| WebAuthn field | Controls | Android | Apple | Windows |
+|---|---|---|---|---|
+| `authenticatorSelection.userVerification` | Require/prefer biometric/PIN | via `requestJson` | `UserVerificationPreference` | `dwUserVerificationRequirement` |
+| `authenticatorSelection.authenticatorAttachment` | platform (device passkey) vs cross-platform (security key/phone) | via `requestJson` | request subclass | `dwAuthenticatorAttachment` |
+| `authenticatorSelection.residentKey` / `requireResidentKey` | Discoverable ("username-less") credential | via `requestJson` | implicit (passkeys are discoverable) | `bRequireResidentKey` |
+| `timeout` | Ceremony timeout | via `requestJson` | — (OS-managed) | `dwTimeoutMilliseconds` |
+| `excludeCredentials` / `allowCredentials` | Prevent re-reg / scope sign-in | via `requestJson` | `ExcludedCredentials` / `AllowedCredentials` | exclude / allow list |
+| `attestation` | Attestation conveyance | via `requestJson` | `AttestationPreference` | `dwAttestationConveyancePreference` |
+| `extensions` (e.g. `credProps`, `prf`, `largeBlob`) | WebAuthn extensions | via `requestJson` | per-extension API | `Extensions` |
+| `hints` | UI hint (security-key/hybrid/client-device) | via `requestJson` | — | — |
+
+Because all of the above flow through the JSON, we do **not** add typed knobs for them — that's the whole
+point of the JSON contract, and it stays forward-compatible as new fields land.
+
+**Runtime behaviors (NOT in the JSON) — the API's knobs:**
+
+| Behavior | Why it's not in the JSON | Platform mapping | Status |
+|---|---|---|---|
+| **1a — Immediately-available UI** | It's a *presentation mode*, not credential data | Android `setPreferImmediatelyAvailableCredentials(true)`; Apple `.preferImmediatelyAvailableCredentials` via `PerformRequests`; **Windows: no equivalent (no-op)** | **v1** — `PreferImmediatelyAvailable` on the options (§5). A `bool` on the same request; local-only, fail-fast, no hybrid/QR. |
+| **1b — Conditional UI / autofill** | It's a *separate UI-priming call*, not a ceremony | Apple `PerformAutoFillAssistedRequests()`; Android view/autofill association; Windows: none | **Follow-up** — a distinct view-oriented, event-based, assertion-only API. See **Appendix A**. |
+| **Presentation anchor / parent window** | A live UI object, can't be serialized | iOS/macOS `presentationContextProvider`; Windows top-level `HWND`; Android current `Activity` | **v1, internal** — resolved via MAUI's active window; no public surface. An optional override is a non-breaking future addition. |
+| **Request origin override** | Only for privileged/browser apps acting for a web origin | Android privileged `setOrigin`; Apple/Windows not exposed | **Not exposed** — privileged/niche; addable later as an options property without breaking. |
+| **Cancellation** | Runtime signal | `CancellationToken` → Android `CancellationSignal`, Apple `Cancel()`, Windows `WebAuthNGetCancellationId` + `WebAuthNCancelCurrentOperation` | **v1** — `CancellationToken` on both methods (§5). |
+
+The v1 public API therefore exposes exactly two behavioral knobs — **`PreferImmediatelyAvailable`** (1a) and
+**`CancellationToken`** — plus internal presentation-anchor resolution. Conditional UI (1b, Appendix A), an
+explicit presentation-anchor override, and the origin override are all additive, non-breaking future work.
+
+The subsections below give the native API on each OS and a behavior matrix for each knob.
+
+#### 7.5.1 Immediately-available UI — `PreferImmediatelyAvailable` (v1)
+
+*What it is:* a presentation-mode choice — "only offer a passkey already on this device; don't launch the
+cross-device/hybrid flow (QR, 'use another device', phone-as-authenticator)." Still a modal, but local-only
+and fail-fast. (The separate, deferred "no modal at all / inline autofill" mode is **1b** — see Appendix A.)
+
+| Platform | Native API | What it does |
+|---|---|---|
+| **Android** | `GetCredentialRequest.Builder.setPreferImmediatelyAvailableCredentials(true)` | If nothing is instantly available, fail fast with `NoCredentialException` instead of launching the hybrid/QR flow. |
+| **Apple** | `ASAuthorizationController.PerformRequests(ASAuthorizationController.RequestOptions.PreferImmediatelyAvailableCredentials)` (iOS 16+) | Presents only if a local platform passkey exists; otherwise errors — no QR/nearby-device sheet. |
+| **Windows** | *(no equivalent)* | The `webauthn.dll` native path always shows the Windows Security modal. |
+
+| `PreferImmediatelyAvailable` | Android | Apple (iOS / iPadOS / Mac Catalyst) | Windows |
+|---|---|---|---|
+| **`false`** (default) | Full UI incl. hybrid/QR "use another device" | Full sheet incl. nearby-device / QR | Full Windows Security modal |
+| **`true`** | Silent/local if present; else fails fast (`NoCredentialException`) — no hybrid | Presents only if a local passkey exists; else errors — no QR | **Ignored (no-op)** — modal still shown |
+
+*Notes:* best-effort (Windows no-op must be documented). "No credential available" (`NoCredentialException`)
+is a distinct outcome, **not** a user-cancel, so it surfaces as a `PasskeyException` (per §8) rather than
+`TaskCanceledException`. Mostly relevant for *authentication*.
+
+#### 7.5.2 Presentation anchor / parent window — internal (v1)
+
+*What it is:* the live window/view/activity the OS attaches the passkey sheet to. A process-local object with
+a lifetime, so it can never be serialized into the JSON. Purely presentation — no effect on the credential.
+
+**Already solved by existing Essentials plumbing** (the same helpers `WebAuthenticator` /
+`AppleSignInAuthenticator` use), so v1 resolves it internally with **no public API**:
+
+| Platform | Native API | Existing Essentials helper |
+|---|---|---|
+| **Android** | The `Activity` passed to `CredentialManager.CreateCredentialAsync(activity, …)` / `GetCredentialAsync(activity, …)` hosts the bottom sheet | `Platform.CurrentActivity` |
+| **Apple** | `ASAuthorizationController.PresentationContextProvider` → `GetPresentationAnchor(controller)` returns the `UIWindow` (iOS/Catalyst) | `WindowStateManager.Default.GetCurrentUIWindow(true)` (the same call `AppleSignInAuthenticator.ios.cs` uses) |
+| **Windows** | The `HWND` parameter of `WebAuthNAuthenticatorMakeCredential` / `GetAssertion` (modal on that window) | `WindowStateManager.Default.GetActiveWindowHandle(true)` |
+
+| Scenario | Android | Apple | Windows |
+|---|---|---|---|
+| **Default (active window)** | `Platform.CurrentActivity` | key `UIWindow` | active `HWND` |
+| **No active window / background** | throws (no Activity) → `InvalidOperationException` | no anchor → controller errors | null `HWND` → detached/fails |
+| **Multi-window (iPad / desktop)** | foreground Activity | returned anchor (wrong one → wrong window) | modal parented to resolved `HWND` |
+| **Explicit override (future)** | pass a specific `Activity` | return a specific `UIWindow` | pass a specific `HWND` |
+
+*Design:* resolved internally; if no foreground window/activity exists, the call throws a clear
+`InvalidOperationException`. An optional per-call window/anchor override is a non-breaking future addition.
+
+#### 7.5.3 Request origin — derivation & override
+
+*Origin derivation (native apps).* The WebAuthn options JSON carries no `origin`; in a browser the user
+agent fills it in from the current page. A native app has no page, so the platform derives the origin from
+the app's **verified identity**, and it is written into the `clientDataJSON` the server ultimately validates:
+
+| Platform | Origin the OS uses | How it's verified |
+|---|---|---|
+| **Android** | `android:apk-key-hash:` | Digital Asset Links (`assetlinks.json`) binds the package + cert to the RP domain |
+| **Apple** | `https://` | Associated Domains entitlement (`webcredentials:`) + `apple-app-site-association` |
+| **Windows** | `https://` (our P/Invoke layer constructs it) | RP ID; no separate app-identity origin |
+
+**RP-server implication:** because native origins differ from a plain web origin (Android's is an
+`android:apk-key-hash:` string), the relying-party server must be configured to **accept the app's native
+origin(s)** in addition to any web origin. This is a common cause of "origin mismatch" verification failures
+and must be documented for the test RP (§11).
+
+*Origin override (not exposed in v1).* Separately, some callers want to override the origin to run WebAuthn
+*on behalf of* a different web origin — the classic consumer being a **browser**. This is **privileged and
+security-critical** (arbitrary override enables phishing), so every platform gates it hard:
+
+| Platform | Native API | Gating |
+|---|---|---|
+| **Android** | `GetCredentialRequest.Builder.setOrigin(String)` | Requires privileged permission **`CREDENTIAL_MANAGER_SET_ORIGIN`** (system-signed / OEM-allowlisted apps only). Unprivileged callers get a **`SecurityException`**. |
+| **Apple** | *(not exposed)* | Origin comes from the Associated Domains entitlement; no "act as another origin" knob on the platform provider. |
+| **Windows** | *(not in scope)* | We construct the origin as `https://`; no general impersonation parameter on the native path we use. |
+
+| `Origin` value | Android | Apple | Windows |
+|---|---|---|---|
+| **unset** (default, normal app) | `android:apk-key-hash:…` (via DAL) | associated-domain https origin | `https://` |
+| **set, app NOT privileged** | `SecurityException` | no-op / unsupported | no-op / unsupported |
+| **set, app IS privileged (browser)** | honored | n/a via this API | n/a via this API |
+
+*Design:* the override is **not exposed.** MAUI Essentials targets normal apps authenticating for their own
+RP, for which the default automatic-origin behavior is correct; an override would work only for privileged
+system apps on Android and no-op elsewhere. It can be added later as an optional `Origin` property with
+platform caveats — non-breaking.
+
+#### 7.5.4 Cancellation — `CancellationToken` (v1)
+
+*What it is:* a transient signal to abort an in-flight ceremony (user navigated away, your timeout fired,
+screen dismissed). Tied to the call's lifetime — nothing to serialize.
+
+| Platform | Native API | What it does |
+|---|---|---|
+| **Android** | `android.os.CancellationSignal` passed to `getCredentialAsync` / `createCredentialAsync`; `signal.cancel()` | Aborts the request → `*CancellationException` (an `OperationCanceledException`). |
+| **Apple** | `ASAuthorizationController.Cancel()` (iOS 16+) | Dismisses the sheet; delegate error is `ASAuthorizationError.Canceled`. |
+| **Windows** | `WebAuthNGetCancellationId(out Guid)` → set the options struct's `pCancellationId` → from another thread `WebAuthNCancelCurrentOperation(in Guid)` | Terminates the in-progress operation. **GUID-based, not `HWND`-based** — distinct from the presentation `HWND`. |
+
+| Scenario | Android | Apple | Windows |
+|---|---|---|---|
+| **`CancellationToken.None`** | runs to completion / OS timeout | runs to completion | runs to completion |
+| **Already-cancelled token** | short-circuit → `TaskCanceledException` | short-circuit | short-circuit |
+| **Cancelled mid-ceremony** | `CancellationSignal.cancel()` → `TaskCanceledException` | `Cancel()` → `TaskCanceledException` | `WebAuthNCancelCurrentOperation(id)` → `TaskCanceledException` |
+| **User taps ✕ / dismisses** | `*CancellationException` → `TaskCanceledException` | `.Canceled` → `TaskCanceledException` | cancel `HRESULT` → `TaskCanceledException` |
+| **Cancel after completion** | no-op | no-op | no-op (no current operation for that ID) |
+
+*Notes:* both programmatic and user cancellation normalize to **`TaskCanceledException`** (consistent with
+`WebAuthenticator`). Implementation registers `token.Register(...)` to fire the native cancel; on Windows the
+cancel must come from a different thread than the blocking call, using the pre-allocated GUID.
+
+## 8. Error handling
+
+| Situation | Behavior |
+|---|---|
+| OS/version without passkey support | `IsSupported == false`; calls throw `FeatureNotSupportedException` |
+| User cancels the native UI | `TaskCanceledException` (matches `WebAuthenticator`) |
+| No matching credential (authenticate) | `PasskeyException` — no passkey available (distinct from user cancellation) |
+| Malformed options JSON | `ArgumentException` |
+| Domain association not configured | Platform error surfaced as `PasskeyException` with the native message |
+| Any other native failure | `PasskeyException` wrapping the platform exception/HRESULT |
+
+## 9. Dependencies & packaging impact
+- **Android**: adds **`Xamarin.AndroidX.Credentials` only** (version pinned via `eng/Versions.props`). We
+ deliberately **do not** add `Xamarin.AndroidX.Credentials.PlayServicesAuth`, so **no Google Play
+ Services** enters the `Microsoft.Maui.Essentials` dependency closure (it has none today). Trade-off: the
+ OS-native passkey path is Android 14+; API 28–33 back-fill is the app's opt-in (§7.1). This new AndroidX
+ dependency is recorded in `NuGets.md` (size/servicing tracked there).
+- **Apple / Windows**: no new NuGet packages (in-box frameworks / P/Invoke).
+- **Public API**: new types in `Microsoft.Maui.Authentication` → `PublicAPI.Unshipped.txt` entries per
+ TFM. Because this adds public API, implementation targets the **`net11.0`** feature branch.
+
+## 10. Security considerations
+- The API never sees or stores private keys — those remain in the platform authenticator / secure
+ hardware. It only relays the public attestation/assertion material.
+- Challenges must be generated and verified **server-side**; the API does not validate them. Doc must
+ make this explicit to avoid misuse.
+- RP ID / origin binding is enforced by the OS via domain association (asset links / associated
+ domains); the origin is derived from the app's verified identity, not caller-supplied (§7.5.3).
+ Misconfiguration fails closed at the OS layer.
+- No secrets are logged; binary fields are surfaced only as part of the response the caller already
+ must send to their server.
+
+## 11. Testing strategy
+- **Unit tests** (`Essentials.UnitTests`): options/response JSON (de)serialization, base64url handling,
+ `IsSupported` gating, `SetDefault` substitution, exception mapping. Platform calls mocked via
+ `IPasskeys`.
+- **Device tests**: passkey ceremonies require real authenticators/biometrics and hosted domain
+ association, so full end-to-end is hard to automate in CI. On-device tests verify `IsSupported`, request
+ construction, and JSON translation; the interactive ceremony runs behind a manual/sample test with a
+ reference RP server.
+- **Reference RP server (test backend)**: the repo ships one at
+ [`src/Essentials/samples/Samples.WebServer`](../../src/Essentials/samples/Samples.WebServer).
+ It is the **default .NET Blazor Web App template with ASP.NET Core Identity "Individual Accounts"**
+ — which includes built-in passkey support since .NET 10 — so the browser passkey UI works out of the
+ box and provides a spec-conformant `PublicKeyCredentialCreationOptions` / `RequestOptions` producer
+ and response verifier for free. Because it's the *official* ASP.NET Core Identity implementation, it
+ doubles as an interop conformance check across Apple, Android, and Windows. The same server also
+ hosts the OAuth pass-through backend for the `WebAuthenticator` sample, so one web app backs both
+ Essentials auth samples.
+
+ On top of the template it adds a small **native-app-facing JSON API** (`PasskeyApiEndpoints.cs`) so
+ the MAUI native `CreateAsync` / `AssertAsync` calls can drive the ceremony directly — no browser,
+ no antiforgery:
+
+ ```
+ POST /passkeys/register/begin?username=… -> PublicKeyCredentialCreationOptions JSON
+ POST /passkeys/register/finish (body: attestation JSON) -> { registered, username }
+ POST /passkeys/login/begin?username=… -> PublicKeyCredentialRequestOptions JSON
+ POST /passkeys/login/finish (body: assertion JSON) -> { authenticated, username }
+ ```
+
+ The WebAuthn challenge state is correlated through the Identity auth cookie between `begin` and
+ `finish`, so the native client uses a cookie container. RP configuration is minimal:
+
+ ```csharp
+ builder.Services.Configure(options =>
+ {
+ options.ServerDomain = ""; // must match the app's domain association
+ options.ValidateOrigin = ctx => ValueTask.FromResult(allowedOrigins.Contains(ctx.Origin));
+ });
+ ```
+
+ It is a local dev tool (auto-creates users, no password). It is part of the solution and builds in
+ CI, but you run it locally to test on devices:
+ `dotnet run --project src/Essentials/samples/Samples.WebServer`.
+ Docs: [Passkeys in ASP.NET Core](https://learn.microsoft.com/aspnet/core/security/authentication/passkeys/) ·
+ [Blazor Web App passkeys](https://learn.microsoft.com/aspnet/core/security/authentication/passkeys/blazor).
+- **Stable public domain (dev tunnels)**: passkeys are bound to a domain (the RP ID) and `localhost`
+ won't validate on a device. The server README documents exposing it via a **dev tunnel with a
+ persistent tunnel ID**, giving a stable `https://…devtunnels.ms` domain reused across all platform
+ apps. The MAUI sample's Passkeys page takes the server base URL at runtime.
+- **Native origins on the server**: the RP must accept the app's **native origin** — Android's
+ `android:apk-key-hash:` and Apple's associated-domain `https://` origin — not just a web origin
+ (see §7.5.3), configured via `IdentityPasskeyOptions.ValidateOrigin`. The server also serves the
+ matching `/.well-known/assetlinks.json` (Android) and `/.well-known/apple-app-site-association`
+ (Apple) documents from config, or ceremonies fail with an origin-mismatch error.
+- **Sample**: the `Essentials.Sample` app includes a **Passkeys** page wired to the reference RP above
+ ([`View/PasskeysPage.xaml`](../../src/Essentials/samples/Samples/View/PasskeysPage.xaml)).
+
+## 12. Key decisions
+
+| Topic | Decision |
+|---|---|
+| Interop contract | **JSON-in / JSON-out** (§6.1) — the server's WebAuthn options JSON in, the OS's response JSON out. |
+| Type shape | **Thin wrapper types** with `ToString()` returning the JSON; no shared base, no `Json` property (§6.2, §6.3). |
+| Decoded properties | Surface **`Id`** (both responses) and **`UserHandle`** (assertion); everything else stays in the JSON (§6.5). |
+| Naming | `Passkeys`/`IPasskeys`, `CreateAsync`/`AssertAsync`, `PasskeyCreationOptions`/`PasskeyRequestOptions`, `PasskeyCreationResponse`/`PasskeyAssertionResponse`, credential id as **`Id`** — all anchored to W3C/Android terms (§6.3). |
+| Packaging | Ships **in `Microsoft.Maui.Essentials`**, namespace `Microsoft.Maui.Authentication` (§6.4, §9). |
+| Android provider | **`Xamarin.AndroidX.Credentials` only — no Google Play Services** (§7.1, §9). |
+| Android minimum | **API 34 (Android 14)** for the OS-native path; API 28–33 is the app's own opt-in (§7.1). |
+| Apple scope | **iOS / iPadOS / Mac Catalyst** (iOS 16+); standalone macOS deferred until Essentials enables a `net-macos` target (§7.2). |
+| Windows minimum | **Windows 11** for passkeys, via `webauthn.dll` P/Invoke (§7.3). |
+| Runtime knobs | v1 exposes **`PreferImmediatelyAvailable`** and **`CancellationToken`**; presentation anchor is internal; origin override and conditional UI are deferred (§7.5). |
+
+## 13. Planned follow-ups
+
+These are intentionally **out of scope for this spec/PR** and tracked to be filed as their own issues
+**after the native API is implemented and shown working**:
+
+- **BlazorWebView passkey bridge** ([#32020](https://github.com/dotnet/maui/issues/32020)) — a JS-interop
+ shim so `navigator.credentials.create()/get()` inside a `BlazorWebView` routes to the native `Passkeys`
+ API (WebViews can't invoke platform WebAuthn directly). We will file a detailed follow-up issue with the
+ bridging design once `Passkeys` is implemented and validated end-to-end.
+- **Android API 28–33 support** via an opt-in `credentials-play-services-auth` recipe (without bundling
+ GMS in Essentials) — if there's demand beyond the OS-native Android 14+ path.
+- **Standalone macOS** support once Essentials enables a `net-macos` target (§7.2).
+- **Conditional UI / autofill** passkey sign-in — see the detailed design notes in **Appendix A**.
+- A possible **presentation-anchor override** (§7.5).
+
+## 14. References
+- W3C WebAuthn Level 3 — https://www.w3.org/TR/webauthn-3/
+- W3C WebAuthn L3 §JSON serialization (`...JSON` types, `toJSON()`) — https://w3c.github.io/webauthn/#sctn-parseCreationOptionsFromJSON
+- FIDO Alliance passkeys — https://fidoalliance.org/passkeys/
+- Android Credential Manager — https://developer.android.com/identity/credential-manager
+- Android passkeys guide — https://developer.android.com/identity/sign-in/credential-manager
+- `androidx.credentials` API — https://developer.android.com/reference/androidx/credentials/package-summary
+- Android `CreatePublicKeyCredentialRequest` (`requestJson`) — https://developer.android.com/reference/androidx/credentials/CreatePublicKeyCredentialRequest
+- Apple `ASAuthorizationPlatformPublicKeyCredentialProvider` — https://developer.apple.com/documentation/authenticationservices/asauthorizationplatformpublickeycredentialprovider
+- Apple "Supporting passkeys" — https://developer.apple.com/documentation/authenticationservices/public-private_key_authentication/supporting_passkeys
+- .NET binding: `ASAuthorizationPlatformPublicKeyCredentialProvider` — https://learn.microsoft.com/dotnet/api/authenticationservices.asauthorizationplatformpublickeycredentialprovider
+- Windows `WebAuthNAuthenticatorMakeCredential` — https://learn.microsoft.com/windows/win32/api/webauthn/nf-webauthn-webauthnauthenticatormakecredential
+- Windows `WebAuthNAuthenticatorGetAssertion` — https://learn.microsoft.com/windows/win32/api/webauthn/nf-webauthn-webauthnauthenticatorgetassertion
+- Microsoft `webauthn` reference — https://github.com/microsoft/webauthn
+- Fido2NetLib (server-side .NET) — https://github.com/passwordless-lib/fido2-net-lib
+- Passkeys in ASP.NET Core (test RP) — https://learn.microsoft.com/aspnet/core/security/authentication/passkeys/
+- Passkeys in ASP.NET Core Blazor Web Apps — https://learn.microsoft.com/aspnet/core/security/authentication/passkeys/blazor
+- Android passkey integration (platform vs Play adapter) — https://developer.android.com/identity/sign-in/credential-manager
+
+## Appendix A — Conditional UI / autofill (deferred design notes)
+
+> Captured for a future follow-up. **Not part of v1.** The v1 API ships only the imperative
+> `CreateAsync`/`AssertAsync` ceremonies plus the `PreferImmediatelyAvailable` option (see below); conditional
+> UI is a separate, view-oriented feature that can be added later **without breaking** the v1 surface.
+
+### Two different features, often conflated
+| | 1a — Immediately-available | 1b — Conditional UI / autofill |
+|---|---|---|
+| **Shape** | A **flag on the normal request** | A **separate API entry point + UI coupling** |
+| **UI** | Still a modal, but local-only (fail fast, no hybrid/QR) | **No modal** — inline suggestions in the autofill/QuickType bar |
+| **In v1?** | ✅ Yes — `PreferImmediatelyAvailable` on the options | ❌ Deferred (this appendix) |
+
+### Why 1b is not a request flag — it *primes the UI*
+`CreateAsync`/`AssertAsync` are **imperative**: call → modal now → `await` one result. Conditional UI is
+**declarative arming**: you tell the OS "this field *can* accept a passkey," then walk away; the OS drives
+it when the user focuses the field. Key differences:
+
+- **Input:** a UI field/view **+** the server's `PasskeyRequestOptions` (the challenge). It still needs the
+ challenge, because when the user taps a suggestion the OS signs *that* challenge — so it's "prime the UI
+ **with** a pending request," not pure UI.
+- **Output:** delivered later as an **event/callback** — whenever the user taps a passkey suggestion, or
+ **never** (they type a password instead). Not a value you `await` once.
+- **Assertion-only** — you cannot autofill a *registration*.
+- **Lifecycle-bound** — armed when the login screen appears, disarmed when it disappears.
+
+### Native APIs (assertion only)
+| Platform | API | Notes |
+|---|---|---|
+| **Apple** | `ASAuthorizationController.PerformAutoFillAssistedRequests()` (iOS 16+) | Separate method from `PerformRequests`. Arms the QuickType bar; fires when the user focuses a `UITextField` marked with the username content type. Result via the same delegate. |
+| **Android** | Associate a `GetCredentialRequest` with a view/field (androidx.credentials autofill integration / pending-get-credential, 1.3+, API 34+); optionally pre-warm via `CredentialManager.prepareGetCredential(...)` | Suggestions appear in the keyboard/autofill bar on field focus. |
+| **Windows** | *(none)* | Native-app conditional UI is not offered by `webauthn.dll`; browser-only. |
+
+### Sketch of a possible future MAUI API (illustrative, not proposed for v1)
+```csharp
+// View-oriented, event-based, disposable to disarm — NOT shaped like AssertAsync.
+IDisposable Passkeys.EnableConditionalUI(
+ View loginField, // a MAUI Entry/control
+ PasskeyRequestOptions options, // the server challenge
+ Action onCredentialSelected);
+```
+Under the hood it would resolve the MAUI control to its native field (`Entry.Handler.PlatformView` →
+`UITextField` / Android `View`), set the platform autofill/content-type hints, call
+`PerformAutoFillAssistedRequests()` (Apple) or associate the pending request with the view (Android), and
+route the callback back — disposing to disarm on page disappearance. Because it takes a `View`, is
+event-based, and is assertion-only, it belongs as its own feature rather than a knob on the ceremony
+methods.
diff --git a/eng/AndroidX.targets b/eng/AndroidX.targets
index 49a09f0cfbd1..2b5818b7949b 100644
--- a/eng/AndroidX.targets
+++ b/eng/AndroidX.targets
@@ -6,6 +6,7 @@
+
diff --git a/eng/NuGetVersions.targets b/eng/NuGetVersions.targets
index 9d6e79c6d587..e0ddc94a8275 100644
--- a/eng/NuGetVersions.targets
+++ b/eng/NuGetVersions.targets
@@ -206,6 +206,26 @@
Update="Microsoft.JSInterop"
Version="$(MicrosoftJSInteropPackageVersion)"
/>
+
+
+
+
+ 11.0.0-preview.7.26365.101
11.0.0-preview.7.26365.10111.0.0-preview.7.26365.101
+ 11.0.0-preview.7.26365.101
+ 11.0.0-preview.7.26365.101
+ 11.0.0-preview.7.26365.101
+ 11.0.0-preview.7.26365.101
+ 11.0.0-preview.7.26365.10110.0.2$(MicrosoftAspNetCorePackageVersion)
diff --git a/src/Essentials/samples/Configure.ps1 b/src/Essentials/samples/Configure.ps1
new file mode 100644
index 000000000000..c0ad16f37333
--- /dev/null
+++ b/src/Essentials/samples/Configure.ps1
@@ -0,0 +1,455 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Configures (and optionally hosts a dev tunnel for) the .NET MAUI Essentials Passkeys sample,
+ which is served by the Samples.WebServer relying-party web app.
+
+.DESCRIPTION
+ Passkeys are bound to a domain (the RP ID), so `localhost` will not work from a
+ real device. This script provisions a dev tunnel with a *persistent* tunnel id — so the public
+ domain stays the same every time — and writes all developer-specific values into files that are
+ NEVER committed: the SERVER's user-secrets, and the MAUI app's git-ignored Passkeys.Local.props
+ (imported by the app csproj). No committed file is edited.
+
+ Into the server user-secrets:
+ - the passkeys relying-party domain + web origin,
+ - the Android package name (read from the sample app's project) plus the debug-signing-key
+ SHA-256 fingerprint and `android:apk-key-hash:` origin (so Digital Asset Links validate), and
+ - (with -AppleTeamId) the Apple app-id `.` for the App Site Association.
+
+ Into the git-ignored Samples/Passkeys.Local.props (and, for Apple, Samples/Platforms/iOS/
+ Entitlements.Local.plist):
+ - the default relying-party server URL (baked into the app via AssemblyMetadata), and
+ - (with -AppleTeamId) the associated-domains entitlement plus the auto-detected Mac Catalyst
+ signing identity + provisioning profile. See README.md (Apple section) for the App ID
+ registration + profile steps that only you can do in your Apple Developer account.
+
+ It does NOT run the web server — that is a separate `dotnet run` (see the printed next steps).
+
+ You run this once. After that, the same domain is reused on every run.
+
+ Cross-platform: run with PowerShell 7+ (`pwsh`) on macOS, Windows, or Linux.
+
+.PARAMETER TunnelId
+ The dev tunnel id/name to create or reuse. Defaults to 'maui-essentials'. Keep it constant to
+ keep the same public domain.
+
+.PARAMETER Port
+ The local HTTP port the server listens on. Defaults to 5177 (matches the project's
+ launchSettings.json "http" profile).
+
+.PARAMETER ApplicationId
+ The app's application id (bundle id) shared by all platforms. Defaults to the sample app's
+ read from its project. It's used for the Android package (assetlinks) and, with
+ -AppleTeamId, the Apple app-id `.`.
+
+.PARAMETER DebugKeystore
+ Path to the Android debug keystore. Defaults to the keystore .NET for Android actually signs debug
+ builds with: /Xamarin/Mono for Android/debug.keystore (e.g. on macOS
+ ~/Library/Application Support/Xamarin/Mono for Android/debug.keystore). This is NOT
+ ~/.android/debug.keystore.
+
+.PARAMETER AppleTeamId
+ Your 10-character Apple Developer Team ID (developer.apple.com -> Membership). When supplied, the
+ script writes the Apple app-id `.` into the server's App Site Association
+ config and generates the git-ignored Apple entitlements/signing for the sample app. Omit to skip
+ Apple setup.
+
+.PARAMETER StartHost
+ If set, starts hosting the tunnel (blocking) at the end. Otherwise prints the host command.
+
+.EXAMPLE
+ ./Configure.ps1
+ # Provisions the tunnel, writes the server config into user-secrets, prints next steps.
+
+.EXAMPLE
+ ./Configure.ps1 -AppleTeamId 42GDTGK33W
+ # Also writes the Apple App Site Association app-id and the associated-domains entitlement.
+
+.EXAMPLE
+ ./Configure.ps1 -StartHost
+ # Provisions the tunnel and starts hosting it.
+#>
+[CmdletBinding()]
+param(
+ [string]$TunnelId = 'maui-essentials',
+ [int]$Port = 5177,
+ [string]$ApplicationId,
+ [string]$DebugKeystore,
+ [string]$AppleTeamId,
+ [string]$AppleSigningIdentity,
+ [string]$AppleProvisioningProfile,
+ [switch]$StartHost
+)
+
+$ErrorActionPreference = 'Stop'
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$project = Join-Path $here 'Samples.WebServer' 'Essentials.Samples.WebServer.csproj'
+
+# Default the application id to the sample app's so the two never drift.
+if (-not $ApplicationId) {
+ $appCsproj = Join-Path $here 'Samples' 'Essentials.Sample.csproj'
+ if (Test-Path $appCsproj) {
+ $m = [regex]::Match((Get-Content -Raw $appCsproj), '\s*([^<]+?)\s*')
+ if ($m.Success) { $ApplicationId = $m.Groups[1].Value.Trim() }
+ }
+ if (-not $ApplicationId) {
+ Write-Error "Could not read from '$appCsproj'. Pass -ApplicationId explicitly, or ensure the sample project defines ."
+ }
+}
+
+# Default to the .NET for Android debug keystore — the key the build actually signs the APK with.
+# .NET Android resolves this as /Xamarin/Mono for Android/debug.keystore, which
+# maps per-OS to:
+# macOS : ~/Library/Application Support/Xamarin/Mono for Android/debug.keystore
+# Windows : %LOCALAPPDATA%\Xamarin\Mono for Android\debug.keystore
+# Linux : ~/.local/share/Xamarin/Mono for Android/debug.keystore
+# This is deliberately NOT ~/.android/debug.keystore — that is Android Studio's key and does NOT sign
+# the .NET MAUI app. Reading the wrong keystore makes assetlinks.json advertise a fingerprint the APK
+# isn't signed with, and passkey creation then fails on-device with
+# "the incoming request could not be validated".
+if (-not $DebugKeystore) {
+ $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
+ $DebugKeystore = Join-Path $localAppData 'Xamarin' 'Mono for Android' 'debug.keystore'
+}
+
+# Computes the Android debug-signing-key fingerprints needed for passkeys: the colon-hex SHA-256
+# (for assetlinks.json) and the "android:apk-key-hash:" origin (for ValidateOrigin).
+# Returns $null if keytool or the keystore is unavailable (e.g. before the first Android build).
+function Get-AndroidKeyInfo($keystore) {
+ if (-not (Get-Command 'keytool' -ErrorAction SilentlyContinue)) {
+ Write-Warning "keytool not found (install a JDK). Skipping Android fingerprint setup."
+ return $null
+ }
+ if (-not (Test-Path $keystore)) {
+ Write-Warning "Debug keystore not found at '$keystore' (build the Android app once to create it). Skipping Android fingerprint setup."
+ return $null
+ }
+ $out = & keytool -list -v -keystore $keystore -alias androiddebugkey -storepass android -keypass android 2>$null
+ $line = $out | Where-Object { $_ -match 'SHA256:' } | Select-Object -First 1
+ if (-not $line) { Write-Warning "Could not read SHA-256 from the debug keystore."; return $null }
+ $hex = ($line -replace '.*SHA256:\s*', '').Trim()
+ $bytes = [byte[]]($hex.Split(':') | ForEach-Object { [Convert]::ToByte($_, 16) })
+ $b64url = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
+ return [pscustomobject]@{ Hex = $hex; Origin = "android:apk-key-hash:$b64url" }
+}
+
+# Generates a git-ignored Entitlements.Local.plist next to the committed Entitlements.plist: a copy of
+# the base entitlements plus the webcredentials associated-domains entry for the relying-party domain.
+# The committed Entitlements.plist is never modified. Pure XmlDocument (cross-platform, no external
+# tools): XmlResolver is nulled so the plist DTD is never fetched, and the output is written with a
+# fixed Apple-style header so it stays byte-clean (no empty-DOCTYPE-subset artifact).
+function New-LocalEntitlements($basePlist, $outPlist, $domain) {
+ if (-not (Test-Path $basePlist)) {
+ Write-Warning "Base entitlements not found at '$basePlist'. Skipping Apple entitlements."
+ return $false
+ }
+ $entry = "webcredentials:$domain"
+ try {
+ $xml = New-Object System.Xml.XmlDocument
+ $xml.XmlResolver = $null
+ $xml.Load($basePlist)
+
+ $dict = $xml.plist.dict
+ $existing = $dict.SelectNodes('key') | Where-Object { $_.InnerText -eq 'com.apple.developer.associated-domains' } | Select-Object -First 1
+ if ($existing) {
+ $arr = $existing.NextSibling
+ [void]$arr.RemoveAll()
+ }
+ else {
+ $k = $xml.CreateElement('key'); $k.InnerText = 'com.apple.developer.associated-domains'; [void]$dict.AppendChild($k)
+ $arr = $xml.CreateElement('array'); [void]$dict.AppendChild($arr)
+ }
+ $s = $xml.CreateElement('string'); $s.InnerText = $entry; [void]$arr.AppendChild($s)
+
+ # Serialize just the element (skipping the DOCTYPE node) with tab indentation, then
+ # prepend the canonical Apple header, so the file matches the committed plist's format exactly.
+ $settings = New-Object System.Xml.XmlWriterSettings
+ $settings.Indent = $true
+ $settings.IndentChars = "`t"
+ $settings.OmitXmlDeclaration = $true
+ $settings.NewLineChars = "`n"
+ $sb = New-Object System.Text.StringBuilder
+ $sw = New-Object System.IO.StringWriter($sb)
+ $writer = [System.Xml.XmlWriter]::Create($sw, $settings)
+ try { $xml.DocumentElement.WriteTo($writer) } finally { $writer.Dispose() }
+
+ $header = "`n`n"
+ [System.IO.File]::WriteAllText($outPlist, $header + $sb.ToString() + "`n", (New-Object System.Text.UTF8Encoding($false)))
+ return $true
+ }
+ catch {
+ Write-Warning "Could not write local entitlements '$outPlist': $($_.Exception.Message)"
+ return $false
+ }
+}
+
+function Require-Command($name, $hint) {
+ if (-not (Get-Command $name -ErrorAction SilentlyContinue)) {
+ Write-Error "'$name' is not installed. $hint"
+ }
+}
+
+# --- Apple signing auto-detection (macOS only) ------------------------------------------------
+# These make Configure a one-stop shop: they find the Apple Development identity in the keychain and
+# the provisioning profile that already matches this app-id + associated-domains, so you don't have to
+# copy names by hand. Everything is written to a LOCAL, git-ignored Passkeys.Local.props (never committed).
+
+# Returns the first "Apple Development" codesigning identity name, or $null.
+function Get-AppleSigningIdentity {
+ if (-not (Get-Command 'security' -ErrorAction SilentlyContinue)) { return $null }
+ $out = & security find-identity -v -p codesigning 2>$null
+ $matches = @($out | Where-Object { $_ -match '"(Apple Development:[^"]+)"' } | ForEach-Object {
+ [regex]::Match($_, '"(Apple Development:[^"]+)"').Groups[1].Value
+ } | Select-Object -Unique)
+ if ($matches.Count -eq 0) { return $null }
+ return $matches[0]
+}
+
+# Scans installed provisioning profiles for one whose application-identifier equals (explicit)
+# and that carries the associated-domains entitlement. Returns the profile Name, or $null.
+function Find-AppleProvisioningProfile($appId) {
+ if (-not (Get-Command 'security' -ErrorAction SilentlyContinue)) { return $null }
+ $dir = Join-Path $HOME 'Library' 'MobileDevice' 'Provisioning Profiles'
+ if (-not (Test-Path $dir)) { return $null }
+ $files = Get-ChildItem -Path (Join-Path $dir '*') -Include '*.provisionprofile', '*.mobileprovision' -File -ErrorAction SilentlyContinue
+ foreach ($f in $files) {
+ $xml = (& security cms -D -i $f.FullName 2>$null) -join "`n"
+ if (-not $xml) { continue }
+ if ($xml -notmatch 'com\.apple\.developer\.associated-domains') { continue }
+ # application-identifier looks like "."; match the explicit app id.
+ $m = [regex]::Match($xml, 'application-identifier\s*([^<]+)')
+ if (-not $m.Success) {
+ $m = [regex]::Match($xml, 'com\.apple\.application-identifier\s*([^<]+)')
+ }
+ if ($m.Success -and $m.Groups[1].Value -eq $appId) {
+ $n = [regex]::Match($xml, 'Name\s*([^<]+)')
+ if ($n.Success) { return $n.Groups[1].Value }
+ }
+ }
+ return $null
+}
+
+# Writes the git-ignored Samples/Passkeys.Local.props by loading the committed Passkeys.Local.in.props
+# template and filling in the values via XML — so the template is the single source of truth for the
+# file's shape and comments (tweak the .in file, not this script). Always sets the default server URL
+# (baked into the app via AssemblyMetadata). When Apple signing was resolved it also fills the entitlements
+# path and Mac Catalyst signing; otherwise it strips the Apple-only PropertyGroups.
+function Write-PasskeysLocalProps($appDir, $serverUrl, $entitlementsRel, $identity, $profileName) {
+ $template = Join-Path $appDir 'Passkeys.Local.in.props'
+ $path = Join-Path $appDir 'Passkeys.Local.props'
+ if (-not (Test-Path $template)) {
+ Write-Error "Template not found at '$template'. It should be committed alongside the app project."
+ }
+
+ $xml = New-Object System.Xml.XmlDocument
+ $xml.PreserveWhitespace = $false
+ $xml.Load($template)
+ $project = $xml.DocumentElement
+
+ # Replace the template's top-of-file comment(s) with a generated-file banner.
+ foreach ($node in @($xml.ChildNodes)) {
+ if ($node.NodeType -eq [System.Xml.XmlNodeType]::Comment) { [void]$xml.RemoveChild($node) }
+ }
+ $banner = $xml.CreateComment(" AUTO-GENERATED by Configure.ps1 from Passkeys.Local.in.props. DO NOT COMMIT (git-ignored).`n Re-run Configure.ps1 to refresh; edit Passkeys.Local.in.props to change the file's shape. ")
+ [void]$xml.InsertBefore($banner, $project)
+
+ # Server URL (all platforms) always.
+ foreach ($n in @($project.GetElementsByTagName('PasskeysServerUrl'))) { $n.InnerText = $serverUrl }
+
+ $apple = $entitlementsRel -and $identity -and $profileName
+ if ($apple) {
+ foreach ($n in @($project.GetElementsByTagName('CodesignEntitlements'))) { $n.InnerText = $entitlementsRel }
+ foreach ($n in @($project.GetElementsByTagName('CodesignKey'))) { $n.InnerText = $identity }
+ foreach ($n in @($project.GetElementsByTagName('CodesignProvision'))) { $n.InnerText = $profileName }
+ }
+ else {
+ # No Apple signing: drop the Apple-only property groups (each with its preceding comment).
+ $appleProps = @('CodesignEntitlements', 'CodesignKey', 'CodesignProvision', 'MtouchLink')
+ foreach ($pg in @($project.GetElementsByTagName('PropertyGroup'))) {
+ $isApple = $false
+ foreach ($child in $pg.ChildNodes) {
+ if ($child.NodeType -eq [System.Xml.XmlNodeType]::Element -and $appleProps -contains $child.Name) { $isApple = $true; break }
+ }
+ if ($isApple) {
+ $prev = $pg.PreviousSibling
+ [void]$project.RemoveChild($pg)
+ if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Comment) { [void]$project.RemoveChild($prev) }
+ }
+ }
+ }
+
+ $settings = New-Object System.Xml.XmlWriterSettings
+ $settings.Indent = $true
+ $settings.IndentChars = ' '
+ $settings.OmitXmlDeclaration = $true
+ $settings.NewLineChars = "`n"
+ $sw = New-Object System.IO.StringWriter
+ $writer = [System.Xml.XmlWriter]::Create($sw, $settings)
+ try { $xml.Save($writer) } finally { $writer.Dispose() }
+ [System.IO.File]::WriteAllText($path, $sw.ToString() + "`n", (New-Object System.Text.UTF8Encoding($false)))
+ return $path
+}
+# ---------------------------------------------------------------------------------------------
+
+Require-Command 'devtunnel' @'
+Install the dev tunnels CLI:
+ macOS: brew install --cask devtunnel
+ Windows: winget install Microsoft.devtunnel
+ Linux: https://aka.ms/devtunnels/download
+'@
+Require-Command 'dotnet' 'Install the .NET SDK from https://dotnet.microsoft.com/download.'
+
+$loggedInUser = $null
+try {
+ $u = devtunnel user show --json 2>$null | ConvertFrom-Json
+ if ($u.status -eq 'Logged in') { $loggedInUser = $u.username }
+}
+catch { }
+
+if ($loggedInUser) {
+ Write-Host "==> Already signed in to dev tunnels as $loggedInUser." -ForegroundColor Cyan
+}
+else {
+ Write-Host "==> Signing in to dev tunnels (a browser window may open)…" -ForegroundColor Cyan
+ devtunnel user login | Out-Host
+}
+
+Write-Host "==> Ensuring tunnel '$TunnelId' exists…" -ForegroundColor Cyan
+# IMPORTANT: `devtunnel create` always makes a NEW tunnel — running it when the tunnel already
+# exists creates a duplicate (in another cluster). So only create when `show` can't find it.
+$tunnelJson = devtunnel show $TunnelId --json 2>$null | ConvertFrom-Json
+if (-not $tunnelJson.tunnel) {
+ devtunnel create $TunnelId --allow-anonymous | Out-Host
+ $tunnelJson = devtunnel show $TunnelId --json 2>$null | ConvertFrom-Json
+}
+if (-not ($tunnelJson.tunnel.ports | Where-Object { $_.portNumber -eq $Port })) {
+ devtunnel port create $TunnelId -p $Port --protocol http | Out-Host
+}
+
+Write-Host "==> Resolving the public tunnel URL…" -ForegroundColor Cyan
+
+# Use the tunnel's own per-tunnel public URL (`portUri`), which is unique to this tunnel. We do NOT
+# use the tunnel-id-derived URL (https://-..devtunnels.ms): although it's nicer,
+# the tunnel name is a shared/global resource, so hardcoding it would collide across developers and
+# machines. The random-looking portUri is stable for the life of the tunnel and safe for everyone.
+#
+# `portUri` is only assigned after the tunnel has been hosted once (then it persists), so on a brand
+# new tunnel we briefly host it in the background to materialize the URL, then re-read it.
+function Get-PortUri($tunnelId, $port) {
+ try {
+ $json = devtunnel show $tunnelId --json 2>$null | ConvertFrom-Json
+ $p = $json.tunnel.ports | Where-Object { $_.portNumber -eq $port } | Select-Object -First 1
+ if ($p -and $p.portUri) { return ([string]$p.portUri).TrimEnd('/') }
+ }
+ catch { }
+ return $null
+}
+
+$uri = Get-PortUri $TunnelId $Port
+if (-not $uri) {
+ Write-Host " New tunnel — starting a brief host session to obtain the URL…" -ForegroundColor DarkGray
+ $job = Start-Job -ScriptBlock { param($t) devtunnel host $t } -ArgumentList $TunnelId
+ try {
+ for ($i = 0; $i -lt 15 -and -not $uri; $i++) {
+ Start-Sleep -Seconds 2
+ $uri = Get-PortUri $TunnelId $Port
+ }
+ }
+ finally {
+ Stop-Job $job -ErrorAction SilentlyContinue
+ Remove-Job $job -Force -ErrorAction SilentlyContinue
+ }
+}
+
+if (-not $uri) {
+ Write-Error @"
+Could not resolve the public dev tunnel URL for '$TunnelId'.
+
+A public HTTPS domain is REQUIRED — there is no localhost fallback. Passkeys are bound to a domain: the
+native authenticators need the relying party's well-known files (Android assetlinks.json, Apple AASA)
+served over public HTTPS, so 'localhost' cannot work on Android, iOS, or Mac Catalyst.
+
+Host the tunnel once to materialize its URL, then re-run this script:
+ devtunnel host $TunnelId
+"@
+}
+else {
+ $domain = ([Uri]$uri).Host
+ Write-Host " Public URL : $uri" -ForegroundColor Green
+ Write-Host " RP ID/host : $domain" -ForegroundColor Green
+
+ Write-Host "==> Writing server user-secrets (passkeys ServerDomain + web origin)…" -ForegroundColor Cyan
+ dotnet user-secrets --project $project set 'Passkeys:ServerDomain' $domain | Out-Null
+ dotnet user-secrets --project $project set 'Passkeys:AllowedOrigins:0' $uri | Out-Null
+ Write-Host " Done. The passkeys RP ID is '$domain'." -ForegroundColor Green
+
+ # Android: compute + write the debug-key fingerprint (assetlinks) and apk-key-hash origin.
+ $android = Get-AndroidKeyInfo $DebugKeystore
+ if ($android) {
+ dotnet user-secrets --project $project set 'Passkeys:Android:PackageName' $ApplicationId | Out-Null
+ dotnet user-secrets --project $project set 'Passkeys:Android:Sha256CertFingerprints:0' $android.Hex | Out-Null
+ dotnet user-secrets --project $project set 'Passkeys:AllowedOrigins:1' $android.Origin | Out-Null
+ Write-Host " Android configured: package '$ApplicationId'" -ForegroundColor Green
+ Write-Host " SHA-256 : $($android.Hex)" -ForegroundColor DarkGray
+ Write-Host " origin : $($android.Origin)" -ForegroundColor DarkGray
+ }
+ # Compose the git-ignored Passkeys.Local.props for the MAUI app: the default server URL always, plus
+ # the Apple entitlements/signing when -AppleTeamId is supplied. The committed files are never edited.
+ $appDir = Join-Path $here 'Samples'
+ $entitlementsRel = $null
+ $resolvedIdentity = $null
+ $resolvedProfile = $null
+
+ if ($AppleTeamId) {
+ $appleAppId = "$AppleTeamId.$ApplicationId"
+ dotnet user-secrets --project $project set 'Passkeys:Apple:AppIds:0' $appleAppId | Out-Null
+ Write-Host " Apple configured: app-id '$appleAppId'" -ForegroundColor Green
+
+ # Generate the git-ignored local entitlements (the committed Entitlements.plist is never touched).
+ $baseEnt = Join-Path $appDir 'Platforms' 'iOS' 'Entitlements.plist'
+ $localEnt = Join-Path $appDir 'Platforms' 'iOS' 'Entitlements.Local.plist'
+ if (New-LocalEntitlements $baseEnt $localEnt $domain) {
+ $entitlementsRel = 'Platforms/iOS/Entitlements.Local.plist'
+ Write-Host " entitlement: webcredentials:$domain (Entitlements.Local.plist — git-ignored)" -ForegroundColor DarkGray
+ }
+
+ # Resolve the signing identity and provisioning profile (params win; otherwise auto-detect).
+ if (-not $AppleSigningIdentity) { $AppleSigningIdentity = Get-AppleSigningIdentity }
+ if (-not $AppleProvisioningProfile) { $AppleProvisioningProfile = Find-AppleProvisioningProfile $appleAppId }
+ if ($AppleSigningIdentity -and $AppleProvisioningProfile) {
+ $resolvedIdentity = $AppleSigningIdentity
+ $resolvedProfile = $AppleProvisioningProfile
+ Write-Host " signing identity : $AppleSigningIdentity" -ForegroundColor DarkGray
+ Write-Host " provisioning : $AppleProvisioningProfile" -ForegroundColor DarkGray
+ }
+ else {
+ if (-not $AppleSigningIdentity) {
+ Write-Host " No 'Apple Development' signing identity found in the keychain." -ForegroundColor Yellow
+ }
+ if (-not $AppleProvisioningProfile) {
+ Write-Host " No installed provisioning profile matches '$appleAppId' with Associated Domains." -ForegroundColor Yellow
+ }
+ Write-Host " iOS Simulator still works. For Mac Catalyst / iOS device, create the profile and re-run" -ForegroundColor DarkGray
+ Write-Host " (or pass -AppleSigningIdentity / -AppleProvisioningProfile) — see README.md (Apple section)." -ForegroundColor DarkGray
+ }
+ }
+ else {
+ Write-Host " (Apple: pass -AppleTeamId to set up iOS/Mac Catalyst — see README.md (Apple section).)" -ForegroundColor DarkGray
+ }
+
+ $propsPath = Write-PasskeysLocalProps $appDir $uri $entitlementsRel $resolvedIdentity $resolvedProfile
+ Write-Host " Wrote $([IO.Path]::GetFileName($propsPath)) (git-ignored): the app defaults to $uri." -ForegroundColor Green
+
+ Write-Host ""
+ Write-Host "Next steps:" -ForegroundColor Yellow
+ Write-Host " 1) In THIS terminal, host the tunnel: devtunnel host $TunnelId"
+ Write-Host " 2) In ANOTHER terminal, run the server: dotnet run --project `"$project`" --launch-profile http"
+ Write-Host " 3) Build/run the sample — its Passkeys page now defaults to $uri (editable via the Server button)."
+}
+
+if ($StartHost) {
+ Write-Host ""
+ Write-Host "==> Hosting tunnel '$TunnelId' (Ctrl+C to stop). Run the server in another terminal." -ForegroundColor Cyan
+ devtunnel host $TunnelId
+}
diff --git a/src/Essentials/samples/README.md b/src/Essentials/samples/README.md
new file mode 100644
index 000000000000..545b82099f73
--- /dev/null
+++ b/src/Essentials/samples/README.md
@@ -0,0 +1,195 @@
+# .NET MAUI Essentials — Passkeys sample
+
+This folder contains the **Passkeys** Essentials sample and everything needed to run it end to end:
+
+- **[`Samples/`](Samples)** — the .NET MAUI **Essentials.Sample** app; the **Passkeys** page is the
+ passkeys demo.
+- **[`Samples.WebServer/`](Samples.WebServer)** — the reference relying-party (RP) server (ASP.NET
+ Core Identity + WebAuthn) that does the server half. See its
+ [`README.md`](Samples.WebServer/README.md) for detail.
+- **[`Configure.ps1`](Configure.ps1)** — provisions a dev tunnel and writes the RP trust config into
+ the server's user-secrets (and, with `-AppleTeamId`, the Apple bits).
+
+The rest of this page is the **testing guide**: run the RP server and exercise it from the app's
+**Passkeys** page on each platform.
+
+| Sample page | Endpoints | Local http port |
+| --- | --- | --- |
+| **Passkeys** | `/passkeys/*` (Blazor + ASP.NET Core Identity / WebAuthn) | 5177 |
+
+**Flow:** set up the [**server**](#1-server-shared-by-all-platforms) once (it's shared by every
+platform), then do the platform setup for what you're testing —
+[**Apple**](#2-apple-ios--ipados--mac-catalyst), [**Android**](#3-android-emulator), or
+[**Windows**](#4-windows-windows-11) — then exercise the app.
+
+## Prerequisites
+
+- **.NET SDK** (per `global.json`) and the ability to build the solution. `Samples.WebServer` is part
+ of the solution and also runs locally with `dotnet run`.
+- **Dev tunnels CLI** — passkeys are bound to a domain (the RP ID), so `localhost` won't work from a
+ real device; the server is exposed on a public `https://…devtunnels.ms` host:
+ - macOS: `brew install --cask devtunnel`
+ - Windows: `winget install Microsoft.devtunnel`
+ - Linux:
+- Platform-specific prerequisites are listed under each platform below.
+
+## Using the app
+
+On every platform the in-app steps are the same: open the **Passkeys** page (its **server URL** already
+defaults to your tunnel — `Configure.ps1` bakes it in; edit it via the Server toolbar button if needed),
+sign up (or sign in) with a username + password, tap **Create a passkey** (approve the device prompt),
+then **Sign in with a passkey**.
+
+> **Testing the web UI in a browser?** Load it via the **tunnel URL** (`https://`, clicking
+> through the dev-tunnel warning), not `http://localhost:5177`. Passkeys are bound to the RP ID domain,
+> so a `localhost` origin fails with *"relying party ID is not a registrable domain suffix…"*.
+
+## 1. Server (shared by all platforms)
+
+From `src/Essentials/samples`, provision the tunnel and write the server config into user-secrets:
+
+```bash
+pwsh ./Configure.ps1
+```
+
+`Configure.ps1` provisions a persistent dev tunnel and writes the RP domain + web origin (and the
+**Android** origins — see section 3) into the SERVER's user-secrets. It prints the public
+`https://…devtunnels.ms` URL — keep it, you type it into the app. Re-run it with the platform flags
+below to add Apple/Android trust.
+
+Then, in two terminals, host the tunnel and run the server:
+
+```bash
+devtunnel host maui-essentials # 1) hold the tunnel open
+dotnet run --project Samples.WebServer --launch-profile http # 2) run the server
+```
+
+Now pick your platform.
+
+## 2. Apple (iOS / iPadOS / Mac Catalyst)
+
+Apple only does passkeys when the app is set up like a **real, shipping app** — there is no localhost
+shortcut. Three things must line up:
+
+1. **Associated Domains entitlement** — the app declares `webcredentials:`.
+2. **App Site Association (AASA)** — `https:///.well-known/apple-app-site-association`
+ lists your `.`; Apple fetches and caches it over the public internet.
+3. **Signing** — signed by your Apple Developer **Team** with a profile that includes the Associated
+ Domains capability.
+
+At runtime the OS matches the app's entitlement against the AASA it fetched for that domain; only then
+will Face ID / Touch ID create or use a passkey.
+
+**Prerequisites:** a **paid Apple Developer account** (free/personal teams can't provision Associated
+Domains), your 10-character **Team ID**, **macOS + Xcode** (Apple apps build only on a Mac), and a
+target on **iOS 16+** or **Mac Catalyst 16+**.
+
+**Steps:**
+
+1. On developer.apple.com → **Identifiers**, register your app's bundle id as an **explicit App ID** and
+ enable the **Associated Domains** capability on it.
+
+ > **Heads up — an explicit App ID is globally unique to one team.** The sample's default
+ > `com.microsoft.maui.essentials` is owned by the MAUI team, so **you can't register it under your
+ > own team**. Use your own reverse-DNS id (e.g. `com.yourname.mauiessentials`): set it once in
+ > `` in `Samples/Essentials.Sample.csproj` (a local edit, don't commit) — it's the
+ > single app id shared by every platform, and `Configure.ps1` reads it from there. Wildcard App IDs
+ > can't carry Associated Domains, so it must be explicit.
+
+2. **Register this Mac as a device** and create a **macOS App Development** provisioning profile for that
+ App ID (your Development certificate + this Mac). Install it (double-click, or drop it in
+ `~/Library/MobileDevice/Provisioning Profiles`). An IDE with automatic provisioning (VS Code C# Dev
+ Kit, Rider) or Xcode can generate + install it for you; find this Mac's provisioning UDID with
+ `system_profiler SPHardwareDataType | grep "Provisioning UDID"`.
+
+3. Configure the server + app (from `src/Essentials/samples`):
+ ```bash
+ pwsh ./Configure.ps1 -AppleTeamId # reads the app id from
+ ```
+ This is the one-stop setup, and it writes **only git-ignored files** (no committed file is edited):
+ - `Passkeys:Apple:AppIds:0 = .` into the server user-secrets (served in the AASA);
+ - a git-ignored `Samples/Platforms/iOS/Entitlements.Local.plist` (a copy of the committed base plus
+ `com.apple.developer.associated-domains` → `webcredentials:`); and
+ - a git-ignored `Samples/Passkeys.Local.props` (imported by the app csproj) carrying the default
+ server URL and — **auto-detected** from step 2 — your Apple Development signing identity and the
+ matching provisioning profile. So **Mac Catalyst is then ready to build and run** with no extra
+ flags. (If auto-detect can't find them, pass `-AppleSigningIdentity` / `-AppleProvisioningProfile`;
+ or copy `Passkeys.Local.in.props` → `Passkeys.Local.props` and fill it in by hand.)
+
+ The iOS **Simulator** doesn't need the signing bits — the entitlement alone is applied and it runs.
+4. With the server running (section 1), verify the AASA is reachable **from the public internet** and
+ is JSON (not an HTML page):
+ ```bash
+ curl -sS https:///.well-known/apple-app-site-association
+ # {"webcredentials":{"apps":["ABCDE12345.com.microsoft.maui.essentials"]}}
+ ```
+5. Build + run, then follow [**Using the app**](#using-the-app):
+ - **iOS Simulator** (unsigned): `dotnet build Samples/Essentials.Sample.csproj -f net11.0-ios -p:RuntimeIdentifier=iossimulator-arm64`, then `xcrun simctl install booted ` + `xcrun simctl launch booted `.
+ - **Mac Catalyst** (signed via `Signing.local.props`): `dotnet build Samples/Essentials.Sample.csproj -f net11.0-maccatalyst -p:RuntimeIdentifier=maccatalyst-arm64`, then `open` the built `.app`.
+ - **Real iOS device**: signed the same way, deploy from your IDE.
+
+**Apple troubleshooting:**
+
+| Symptom | Cause / fix |
+| --- | --- |
+| `… is not associated with domain …` | AASA not reachable as JSON, its app-id ≠ your signed `.`, or the entitlement domain ≠ the server's `Passkeys:ServerDomain`. Verify with the step-4 `curl`. |
+| AASA `curl` returns HTML | The dev tunnel's anti-phishing interstitial is answering; create a tunnel access token / disable anti-phishing for the port so raw JSON is served. |
+| `Could not resolve host …devtunnels.ms` on the device | Local-network DNS won't resolve `*.devtunnels.ms`. Point the device at a public resolver (iOS Wi-Fi → Configure DNS → Manual → `8.8.8.8`) or restart the router. Apple's CDN resolves it fine over the public internet. |
+| `no profiles for '' were found` | The App ID belongs to another team — set your own reverse-DNS `` in `Essentials.Sample.csproj` and register it under your team. |
+| Browser: *"relying party ID is not a registrable domain suffix of … the current domain"* | You opened the web UI on `localhost` (e.g. `http://localhost:5177`). Passkeys are domain-bound — browse to the **tunnel URL** `https://` instead (click through the dev-tunnel warning), so the page origin matches the RP ID. |
+| Build error `MT7139: … requests the entitlement 'com.apple.developer.associated-domains', but no provisioning profile has been specified` | A **device** or **Mac Catalyst** build needs an explicit provisioning profile with Associated Domains. Generate one for your Team (IDE automatic provisioning or Xcode) and set `CodesignProvision` to its name. The iOS **Simulator** doesn't need this. |
+
+## 3. Android (emulator)
+
+No paid account and **no app manifest changes** are needed (Digital Asset Links live on the server;
+intent-filters are only for App Links, a different feature). You do need the right emulator.
+
+**Prerequisites** (one-time):
+- An **API 34+** AVD on a **Google Play** system image (not AOSP) so Google Password Manager is present.
+- The emulator signed into a **Google account** (Settings → Passwords, passkeys & accounts).
+- A **secure screen lock** (PIN/pattern) — passkeys require device authentication.
+
+**Steps:**
+
+1. Configure the server (from `src/Essentials/samples`):
+ ```bash
+ pwsh ./Configure.ps1
+ ```
+ Beyond the RP domain, this writes the Android package (`com.microsoft.maui.essentials`), your debug
+ keystore SHA-256, and the `android:apk-key-hash:` origin. It reads the keystore .NET for Android
+ signs debug builds with — `/Xamarin/Mono for Android/debug.keystore`, **not**
+ `~/.android/debug.keystore`; build the Android app once first if it doesn't exist yet.
+2. Host the tunnel + run the server (section 1). Verify Google can see the asset links: open
+ `https:///.well-known/assetlinks.json` — it should list your package + fingerprint.
+3. Run the sample on the emulator:
+ ```bash
+ dotnet build Samples/Essentials.Sample.csproj -t:Run -f net11.0-android
+ ```
+4. Follow [**Using the app**](#using-the-app).
+
+If registration fails with a "no create options" / provider error, re-check the three emulator
+prerequisites above — that's the usual cause.
+
+## 4. Windows (Windows 11)
+
+Nothing extra: the Windows platform trusts the `https` origin directly (no domain-association file).
+Passkeys require **Windows 11 with Windows Hello** configured. With the server running (section 1),
+deploy the Windows head from your IDE (or `dotnet build Samples/Essentials.Sample.csproj -t:Run -f
+net11.0-windows10.0.` matching the project's Windows TFM), then follow
+[**Using the app**](#using-the-app).
+
+## Local-only smoke test
+
+A dev tunnel is the supported path. To only check the app ↔ server round-trip (this will **not**
+complete a real passkey ceremony — the platform won't trust a non-public host), point the page at
+localhost temporarily:
+
+- iOS simulator / Mac / Windows → `https://localhost:7235`
+- Android emulator → `http://10.0.2.2:5177`
+
+## Don't commit
+
+`Configure.ps1` writes only git-ignored files (`Samples/Passkeys.Local.props`,
+`Samples/Platforms/iOS/Entitlements.Local.plist`) and the server user-secrets — nothing committed. The
+one manual exception is if you override the bundle id: your `` change in
+`Essentials.Sample.csproj` is a local edit — keep it out of commits.
diff --git a/src/Essentials/samples/Samples.WebServer/.gitignore b/src/Essentials/samples/Samples.WebServer/.gitignore
new file mode 100644
index 000000000000..cf2dd0da7cda
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/.gitignore
@@ -0,0 +1,6 @@
+# Local SQLite database — regenerated by EF Core migrations on first run.
+# The Blazor Identity template ships a seed app.db, but it is per-checkout
+# runtime data (and can accumulate real credentials), so it is not committed.
+Data/*.db
+Data/*.db-shm
+Data/*.db-wal
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs
new file mode 100644
index 000000000000..51cd9b2fd32e
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs
@@ -0,0 +1,152 @@
+using System.Security.Claims;
+using System.Text.Json;
+using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Primitives;
+using Essentials.Samples.WebServer.Components.Account.Pages;
+using Essentials.Samples.WebServer.Components.Account.Pages.Manage;
+using Essentials.Samples.WebServer.Data;
+
+namespace Microsoft.AspNetCore.Routing;
+
+internal static class IdentityComponentsEndpointRouteBuilderExtensions
+{
+ // These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
+ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+
+ var accountGroup = endpoints.MapGroup("/Account");
+
+ accountGroup.MapPost("/PerformExternalLogin", (
+ HttpContext context,
+ [FromServices] SignInManager signInManager,
+ [FromForm] string provider,
+ [FromForm] string returnUrl) =>
+ {
+ IEnumerable> query = [
+ new("ReturnUrl", returnUrl),
+ new("Action", ExternalLogin.LoginCallbackAction)];
+
+ var redirectUrl = UriHelper.BuildRelative(
+ context.Request.PathBase,
+ "/Account/ExternalLogin",
+ QueryString.Create(query));
+
+ var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
+ return TypedResults.Challenge(properties, [provider]);
+ });
+
+ accountGroup.MapPost("/Logout", async (
+ ClaimsPrincipal user,
+ [FromServices] SignInManager signInManager,
+ [FromForm] string returnUrl) =>
+ {
+ await signInManager.SignOutAsync();
+ return TypedResults.LocalRedirect($"~/{returnUrl}");
+ });
+
+ accountGroup.MapPost("/PasskeyCreationOptions", async (
+ HttpContext context,
+ [FromServices] UserManager userManager,
+ [FromServices] SignInManager signInManager,
+ [FromServices] IAntiforgery antiforgery) =>
+ {
+ await antiforgery.ValidateRequestAsync(context);
+
+ var user = await userManager.GetUserAsync(context.User);
+ if (user is null)
+ {
+ return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
+ }
+
+ var userId = await userManager.GetUserIdAsync(user);
+ var userName = await userManager.GetUserNameAsync(user) ?? "User";
+ var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
+ {
+ Id = userId,
+ Name = userName,
+ DisplayName = userName
+ });
+ return TypedResults.Content(optionsJson, contentType: "application/json");
+ });
+
+ accountGroup.MapPost("/PasskeyRequestOptions", async (
+ HttpContext context,
+ [FromServices] UserManager userManager,
+ [FromServices] SignInManager signInManager,
+ [FromServices] IAntiforgery antiforgery,
+ [FromQuery] string? username) =>
+ {
+ await antiforgery.ValidateRequestAsync(context);
+
+ var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
+ var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
+ return TypedResults.Content(optionsJson, contentType: "application/json");
+ });
+
+ var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();
+
+ manageGroup.MapPost("/LinkExternalLogin", async (
+ HttpContext context,
+ [FromServices] SignInManager signInManager,
+ [FromForm] string provider) =>
+ {
+ // Clear the existing external cookie to ensure a clean login process
+ await context.SignOutAsync(IdentityConstants.ExternalScheme);
+
+ var redirectUrl = UriHelper.BuildRelative(
+ context.Request.PathBase,
+ "/Account/Manage/ExternalLogins",
+ QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));
+
+ var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User));
+ return TypedResults.Challenge(properties, [provider]);
+ });
+
+ var loggerFactory = endpoints.ServiceProvider.GetRequiredService();
+ var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");
+
+ manageGroup.MapPost("/DownloadPersonalData", async (
+ HttpContext context,
+ [FromServices] UserManager userManager,
+ [FromServices] AuthenticationStateProvider authenticationStateProvider) =>
+ {
+ var user = await userManager.GetUserAsync(context.User);
+ if (user is null)
+ {
+ return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
+ }
+
+ var userId = await userManager.GetUserIdAsync(user);
+ downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);
+
+ // Only include personal data for download
+ var personalData = new Dictionary();
+ var personalDataProps = typeof(ApplicationUser).GetProperties().Where(
+ prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
+ foreach (var p in personalDataProps)
+ {
+ personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
+ }
+
+ var logins = await userManager.GetLoginsAsync(user);
+ foreach (var l in logins)
+ {
+ personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
+ }
+
+ personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
+ var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);
+
+ context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
+ return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
+ });
+
+ return accountGroup;
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityNoOpEmailSender.cs b/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityNoOpEmailSender.cs
new file mode 100644
index 000000000000..f82181b24be3
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityNoOpEmailSender.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Essentials.Samples.WebServer.Data;
+
+namespace Essentials.Samples.WebServer.Components.Account;
+
+// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
+internal sealed class IdentityNoOpEmailSender : IEmailSender
+{
+ private readonly IEmailSender emailSender = new NoOpEmailSender();
+
+ public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
+ emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email.");
+
+ public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
+ emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here. If you didn't request a password reset, you can ignore this email.");
+
+ public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
+ emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}. If you didn't request a password reset, you can ignore this email.");
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityRedirectManager.cs b/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityRedirectManager.cs
new file mode 100644
index 000000000000..1cdf945ee158
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityRedirectManager.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Essentials.Samples.WebServer.Components.Account;
+
+internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
+{
+ public const string StatusMessageKey = "Identity.StatusMessage";
+
+ public void RedirectTo(string? uri)
+ {
+ uri ??= "";
+
+ // Prevent open redirects.
+ if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
+ {
+ uri = navigationManager.ToBaseRelativePath(uri);
+ }
+
+ navigationManager.NavigateTo(uri);
+ }
+
+ public void RedirectTo(string uri, Dictionary queryParameters)
+ {
+ var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
+ var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
+ RedirectTo(newUri);
+ }
+
+ private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
+
+ public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs b/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs
new file mode 100644
index 000000000000..a61a34823367
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs
@@ -0,0 +1,47 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Components.Server;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Options;
+using Essentials.Samples.WebServer.Data;
+
+namespace Essentials.Samples.WebServer.Components.Account;
+
+// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
+// every 30 minutes an interactive circuit is connected.
+internal sealed class IdentityRevalidatingAuthenticationStateProvider(
+ ILoggerFactory loggerFactory,
+ IServiceScopeFactory scopeFactory,
+ IOptions options)
+ : RevalidatingServerAuthenticationStateProvider(loggerFactory)
+{
+ protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
+
+ protected override async Task ValidateAuthenticationStateAsync(
+ AuthenticationState authenticationState, CancellationToken cancellationToken)
+ {
+ // Get the user manager from a new scope to ensure it fetches fresh data
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var userManager = scope.ServiceProvider.GetRequiredService>();
+ return await ValidateSecurityStampAsync(userManager, authenticationState.User);
+ }
+
+ private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal)
+ {
+ var user = await userManager.GetUserAsync(principal);
+ if (user is null)
+ {
+ return false;
+ }
+ else if (!userManager.SupportsUserSecurityStamp)
+ {
+ return true;
+ }
+ else
+ {
+ var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
+ var userStamp = await userManager.GetSecurityStampAsync(user);
+ return principalStamp == userStamp;
+ }
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/AccessDenied.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/AccessDenied.razor
new file mode 100644
index 000000000000..905dec34875a
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/AccessDenied.razor
@@ -0,0 +1,8 @@
+@page "/Account/AccessDenied"
+
+Access denied
+
+
+
+
+
+@code {
+ private string? statusMessage;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? UserId { get; set; }
+
+ [SupplyParameterFromQuery]
+ private string? Code { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (UserId is null || Code is null)
+ {
+ RedirectManager.RedirectTo("");
+ return;
+ }
+
+ var user = await UserManager.FindByIdAsync(UserId);
+ if (user is null)
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
+ statusMessage = $"Error loading user with ID {UserId}";
+ }
+ else
+ {
+ var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
+ var result = await UserManager.ConfirmEmailAsync(user, code);
+ statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
+ }
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ConfirmEmailChange.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ConfirmEmailChange.razor
new file mode 100644
index 000000000000..45a112d77cf4
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ConfirmEmailChange.razor
@@ -0,0 +1,75 @@
+@page "/Account/ConfirmEmailChange"
+
+@using System.Text
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+
+Confirm email change
+
+
Confirm email change
+
+
+
+@code {
+ private string? message;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ [SupplyParameterFromQuery]
+ private string? UserId { get; set; }
+
+ [SupplyParameterFromQuery]
+ private string? Email { get; set; }
+
+ [SupplyParameterFromQuery]
+ private string? Code { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ message = IdentityStatusMessage;
+ IdentityStatusMessage = null;
+
+ if (UserId is null || Email is null || Code is null)
+ {
+ IdentityStatusMessage = "Error: Invalid email change confirmation link.";
+ RedirectManager.RedirectTo("Account/Login");
+ return;
+ }
+
+ var user = await UserManager.FindByIdAsync(UserId);
+ if (user is null)
+ {
+ message = $"Error: Unable to find user with Id '{UserId}'";
+ return;
+ }
+
+ var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
+ var result = await UserManager.ChangeEmailAsync(user, Email, code);
+ if (!result.Succeeded)
+ {
+ message = "Error changing email.";
+ return;
+ }
+
+ // In our UI email and user name are one and the same, so when we update the email
+ // we need to update the user name.
+ var setUserNameResult = await UserManager.SetUserNameAsync(user, Email);
+ if (!setUserNameResult.Succeeded)
+ {
+ message = "Error changing user name.";
+ return;
+ }
+
+ await SignInManager.RefreshSignInAsync(user);
+ message = "Thank you for confirming your email change.";
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ExternalLogin.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ExternalLogin.razor
new file mode 100644
index 000000000000..c255b1f465c8
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ExternalLogin.razor
@@ -0,0 +1,226 @@
+@page "/Account/ExternalLogin"
+
+@using System.ComponentModel.DataAnnotations
+@using System.Security.Claims
+@using System.Text
+@using System.Text.Encodings.Web
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using Essentials.Samples.WebServer.Data
+
+@inject SignInManager SignInManager
+@inject UserManager UserManager
+@inject IUserStore UserStore
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Register
+
+
+
Register
+
Associate your @ProviderDisplayName account.
+
+
+
+ You've successfully authenticated with @ProviderDisplayName.
+ Please enter an email address for this site below and click the Register button to finish
+ logging in.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ public const string LoginCallbackAction = "LoginCallback";
+
+ private string? message;
+ private ExternalLoginInfo? externalLoginInfo;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? RemoteError { get; set; }
+
+ [SupplyParameterFromQuery]
+ private string? ReturnUrl { get; set; }
+
+ [SupplyParameterFromQuery]
+ private string? Action { get; set; }
+
+ private string? ProviderDisplayName => externalLoginInfo?.ProviderDisplayName;
+
+ protected override async Task OnInitializedAsync()
+ {
+ message = IdentityStatusMessage;
+ IdentityStatusMessage = null;
+ Input ??= new();
+
+ if (RemoteError is not null)
+ {
+ IdentityStatusMessage = $"Error from external provider: {RemoteError}";
+ RedirectManager.RedirectTo("Account/Login");
+ return;
+ }
+
+ var info = await SignInManager.GetExternalLoginInfoAsync();
+ if (info is null)
+ {
+ IdentityStatusMessage = "Error loading external login information.";
+ RedirectManager.RedirectTo("Account/Login");
+ return;
+ }
+
+ externalLoginInfo = info;
+
+ if (HttpMethods.IsGet(HttpContext.Request.Method))
+ {
+ if (Action == LoginCallbackAction)
+ {
+ await OnLoginCallbackAsync();
+ return;
+ }
+
+ // We should only reach this page via the login callback, so redirect back to
+ // the login page if we get here some other way.
+ RedirectManager.RedirectTo("Account/Login");
+ }
+ }
+
+ private async Task OnLoginCallbackAsync()
+ {
+ if (externalLoginInfo is null)
+ {
+ IdentityStatusMessage = "Error loading external login information.";
+ RedirectManager.RedirectTo("Account/Login");
+ return;
+ }
+
+ // Sign in the user with this external login provider if the user already has a login.
+ var result = await SignInManager.ExternalLoginSignInAsync(
+ externalLoginInfo.LoginProvider,
+ externalLoginInfo.ProviderKey,
+ isPersistent: false,
+ bypassTwoFactor: true);
+
+ if (result.Succeeded)
+ {
+ Logger.LogInformation(
+ "{Name} logged in with {LoginProvider} provider.",
+ externalLoginInfo.Principal.Identity?.Name,
+ externalLoginInfo.LoginProvider);
+ RedirectManager.RedirectTo(ReturnUrl);
+ return;
+ }
+ else if (result.IsLockedOut)
+ {
+ RedirectManager.RedirectTo("Account/Lockout");
+ return;
+ }
+
+ // If the user does not have an account, then ask the user to create an account.
+ if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
+ {
+ Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
+ }
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ if (externalLoginInfo is null)
+ {
+ IdentityStatusMessage = "Error loading external login information during confirmation.";
+ RedirectManager.RedirectTo("Account/Login");
+ return;
+ }
+
+ var emailStore = GetEmailStore();
+ var user = CreateUser();
+
+ await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
+ await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
+
+ var result = await UserManager.CreateAsync(user);
+ if (result.Succeeded)
+ {
+ result = await UserManager.AddLoginAsync(user, externalLoginInfo);
+ if (result.Succeeded)
+ {
+ Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider);
+
+ var userId = await UserManager.GetUserIdAsync(user);
+ var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+
+ var callbackUrl = NavigationManager.GetUriWithQueryParameters(
+ NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
+ new Dictionary { ["userId"] = userId, ["code"] = code });
+ await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
+
+ // If account confirmation is required, we need to show the link if we don't have a real email sender
+ if (UserManager.Options.SignIn.RequireConfirmedAccount)
+ {
+ RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email });
+ }
+ else
+ {
+ await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
+ RedirectManager.RedirectTo(ReturnUrl);
+ }
+ }
+ }
+ else
+ {
+ message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}";
+ }
+ }
+
+ private ApplicationUser CreateUser()
+ {
+ try
+ {
+ return Activator.CreateInstance();
+ }
+ catch
+ {
+ throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
+ $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
+ }
+ }
+
+ private IUserEmailStore GetEmailStore()
+ {
+ if (!UserManager.SupportsUserEmail)
+ {
+ throw new NotSupportedException("The default UI requires a user store with email support.");
+ }
+ return (IUserEmailStore)UserStore;
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [EmailAddress]
+ public string Email { get; set; } = "";
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ForgotPassword.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ForgotPassword.razor
new file mode 100644
index 000000000000..c18936f6abdc
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ForgotPassword.razor
@@ -0,0 +1,77 @@
+@page "/Account/ForgotPassword"
+
+@using System.ComponentModel.DataAnnotations
+@using System.Text
+@using System.Text.Encodings.Web
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Forgot your password?
+
+
Forgot your password?
+
Enter your email.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ protected override void OnInitialized()
+ {
+ Input ??= new();
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ var user = await UserManager.FindByEmailAsync(Input.Email);
+ if (user is null || !(await UserManager.IsEmailConfirmedAsync(user)))
+ {
+ // Don't reveal that the user does not exist or is not confirmed
+ RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
+ return;
+ }
+
+ // For more information on how to enable account confirmation and password reset please
+ // visit https://go.microsoft.com/fwlink/?LinkID=532713
+ var code = await UserManager.GeneratePasswordResetTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ var callbackUrl = NavigationManager.GetUriWithQueryParameters(
+ NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri,
+ new Dictionary { ["code"] = code });
+
+ await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
+
+ RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [EmailAddress]
+ [Display(Name = "Email")]
+ public string Email { get; set; } = "";
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ForgotPasswordConfirmation.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ForgotPasswordConfirmation.razor
new file mode 100644
index 000000000000..a771a3a1d45a
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ForgotPasswordConfirmation.razor
@@ -0,0 +1,8 @@
+@page "/Account/ForgotPasswordConfirmation"
+
+Forgot password confirmation
+
+
Forgot password confirmation
+
+ Please check your email to reset your password.
+
+
+@code {
+ private string? message;
+ private ApplicationUser user = default!;
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? ReturnUrl { get; set; }
+
+ [SupplyParameterFromQuery]
+ private bool RememberMe { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ Input ??= new();
+
+ // Ensure the user has gone through the username & password screen first
+ user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
+ throw new InvalidOperationException("Unable to load two-factor authentication user.");
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty);
+ var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine);
+ var userId = await UserManager.GetUserIdAsync(user);
+
+ if (result.Succeeded)
+ {
+ Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
+ RedirectManager.RedirectTo(ReturnUrl);
+ }
+ else if (result.IsLockedOut)
+ {
+ Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
+ RedirectManager.RedirectTo("Account/Lockout");
+ }
+ else
+ {
+ Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
+ message = "Error: Invalid authenticator code.";
+ }
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+ [DataType(DataType.Text)]
+ [Display(Name = "Authenticator code")]
+ public string? TwoFactorCode { get; set; }
+
+ [Display(Name = "Remember this machine")]
+ public bool RememberMachine { get; set; }
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/LoginWithRecoveryCode.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/LoginWithRecoveryCode.razor
new file mode 100644
index 000000000000..10d23f19c8ca
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/LoginWithRecoveryCode.razor
@@ -0,0 +1,87 @@
+@page "/Account/LoginWithRecoveryCode"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using Essentials.Samples.WebServer.Data
+
+@inject SignInManager SignInManager
+@inject UserManager UserManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Recovery code verification
+
+
Recovery code verification
+
+
+
+ You have requested to log in with a recovery code. This login will not be remembered until you provide
+ an authenticator app code at log in or disable 2FA and log in again.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ private string? message;
+ private ApplicationUser user = default!;
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? ReturnUrl { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ Input ??= new();
+
+ // Ensure the user has gone through the username & password screen first
+ user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
+ throw new InvalidOperationException("Unable to load two-factor authentication user.");
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
+
+ var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
+
+ var userId = await UserManager.GetUserIdAsync(user);
+
+ if (result.Succeeded)
+ {
+ Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId);
+ RedirectManager.RedirectTo(ReturnUrl);
+ }
+ else if (result.IsLockedOut)
+ {
+ Logger.LogWarning("User account locked out.");
+ RedirectManager.RedirectTo("Account/Lockout");
+ }
+ else
+ {
+ Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId);
+ message = "Error: Invalid recovery code entered.";
+ }
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [DataType(DataType.Text)]
+ [Display(Name = "Recovery Code")]
+ public string RecoveryCode { get; set; } = "";
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/ChangePassword.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/ChangePassword.razor
new file mode 100644
index 000000000000..71060d6a5c29
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/ChangePassword.razor
@@ -0,0 +1,121 @@
+@page "/Account/Manage/ChangePassword"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Change password
+
+
Change password
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ private string? message;
+ private ApplicationUser? user;
+ private bool hasPassword;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ message = IdentityStatusMessage;
+ IdentityStatusMessage = null;
+ Input ??= new();
+
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ hasPassword = await UserManager.HasPasswordAsync(user);
+ if (!hasPassword)
+ {
+ RedirectManager.RedirectTo("Account/Manage/SetPassword");
+ }
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
+ if (!changePasswordResult.Succeeded)
+ {
+ message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}";
+ return;
+ }
+
+ await SignInManager.RefreshSignInAsync(user);
+ Logger.LogInformation("User changed their password successfully.");
+
+ IdentityStatusMessage = "Your password has been changed";
+ RedirectManager.RedirectToCurrentPage();
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [DataType(DataType.Password)]
+ [Display(Name = "Current password")]
+ public string OldPassword { get; set; } = "";
+
+ [Required]
+ [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+ [DataType(DataType.Password)]
+ [Display(Name = "New password")]
+ public string NewPassword { get; set; } = "";
+
+ [DataType(DataType.Password)]
+ [Display(Name = "Confirm new password")]
+ [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
+ public string ConfirmPassword { get; set; } = "";
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/DeletePersonalData.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/DeletePersonalData.razor
new file mode 100644
index 000000000000..bf32739372a6
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/DeletePersonalData.razor
@@ -0,0 +1,102 @@
+@page "/Account/Manage/DeletePersonalData"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Delete Personal Data
+
+
+
+
Delete Personal Data
+
+
+
+ Deleting this data will permanently remove your account, and this cannot be recovered.
+
+ Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
+ used in an authenticator app you should reset your authenticator keys.
+
+
+
+
+
+
+
+@code {
+ private string? message;
+ private ApplicationUser? user;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ message = IdentityStatusMessage;
+ IdentityStatusMessage = null;
+
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user))
+ {
+ throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled.");
+ }
+ }
+
+ private async Task OnSubmitAsync()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false);
+ if (!disable2faResult.Succeeded)
+ {
+ throw new InvalidOperationException("Unexpected error occurred disabling 2FA.");
+ }
+
+ var userId = await UserManager.GetUserIdAsync(user);
+ Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId);
+ IdentityStatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app";
+ RedirectManager.RedirectTo("Account/Manage/TwoFactorAuthentication");
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/Email.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/Email.razor
new file mode 100644
index 000000000000..66c5b4ea205c
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/Email.razor
@@ -0,0 +1,150 @@
+@page "/Account/Manage/Email"
+
+@using System.ComponentModel.DataAnnotations
+@using System.Text
+@using System.Text.Encodings.Web
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Manage email
+
+
+ Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
+ with a unique code. Enter the code in the confirmation box below.
+
+ If you lose your device and don't have the recovery codes you will lose access to your account.
+
+
+ Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
+ used in an authenticator app you should reset your authenticator keys.
+
+
+
+
+
+}
+
+@code {
+ private string? message;
+ private ApplicationUser? user;
+ private IEnumerable? recoveryCodes;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ message = IdentityStatusMessage;
+ IdentityStatusMessage = null;
+
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
+ if (!isTwoFactorEnabled)
+ {
+ throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled.");
+ }
+ }
+
+ private async Task OnSubmitAsync()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ var userId = await UserManager.GetUserIdAsync(user);
+ recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
+ message = "You have generated new recovery codes.";
+
+ Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/Index.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/Index.razor
new file mode 100644
index 000000000000..8d0668ba94d7
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/Index.razor
@@ -0,0 +1,99 @@
+@page "/Account/Manage"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+
+Profile
+
+
+
+
+@code {
+ private ApplicationUser? user;
+ private UserPasskeyInfo? passkey;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ [Parameter]
+ public string? Id { get; set; }
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ IdentityStatusMessage = null;
+ Input ??= new();
+
+ user = (await UserManager.GetUserAsync(HttpContext.User))!;
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ byte[] credentialId;
+ try
+ {
+ credentialId = Base64Url.DecodeFromChars(Id);
+ }
+ catch (FormatException)
+ {
+ IdentityStatusMessage = "Error: The specified passkey ID had an invalid format.";
+ RedirectManager.RedirectTo("Account/Manage/Passkeys");
+ return;
+ }
+
+ passkey = await UserManager.GetPasskeyAsync(user, credentialId);
+ if (passkey is null)
+ {
+ IdentityStatusMessage = "Error: The specified passkey could not be found.";
+ RedirectManager.RedirectTo("Account/Manage/Passkeys");
+ return;
+ }
+ }
+
+ private async Task Rename()
+ {
+ passkey!.Name = Input.Name;
+ var result = await UserManager.AddOrUpdatePasskeyAsync(user!, passkey);
+ if (!result.Succeeded)
+ {
+ IdentityStatusMessage = "Error: The passkey could not be updated.";
+ RedirectManager.RedirectTo("Account/Manage/Passkeys");
+ return;
+ }
+
+ IdentityStatusMessage = "Passkey updated successfully.";
+ RedirectManager.RedirectTo("Account/Manage/Passkeys");
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [StringLength(200, ErrorMessage = "Passkey names must be no longer than {1} characters.")]
+ public string Name { get; set; } = "";
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/ResetAuthenticator.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/ResetAuthenticator.razor
new file mode 100644
index 000000000000..1ed247828f84
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/ResetAuthenticator.razor
@@ -0,0 +1,66 @@
+@page "/Account/Manage/ResetAuthenticator"
+
+@using Microsoft.AspNetCore.Identity
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+@inject ILogger Logger
+
+Reset authenticator key
+
+
+
Reset authenticator key
+
+
+
+ If you reset your authenticator key your authenticator app will not work until you reconfigure it.
+
+
+ This process disables 2FA until you verify your authenticator app.
+ If you do not complete your authenticator app configuration you may lose access to your account.
+
+
+
+
+
+
+@code {
+ private string? message;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ protected override void OnInitialized()
+ {
+ message = IdentityStatusMessage;
+ IdentityStatusMessage = null;
+ }
+
+ private async Task OnSubmitAsync()
+ {
+ var user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ await UserManager.SetTwoFactorEnabledAsync(user, false);
+ await UserManager.ResetAuthenticatorKeyAsync(user);
+ var userId = await UserManager.GetUserIdAsync(user);
+ Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId);
+
+ await SignInManager.RefreshSignInAsync(user);
+
+ IdentityStatusMessage = "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.";
+ RedirectManager.RedirectTo("Account/Manage/EnableAuthenticator");
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/SetPassword.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/SetPassword.razor
new file mode 100644
index 000000000000..874595fe363a
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/SetPassword.razor
@@ -0,0 +1,105 @@
+@page "/Account/Manage/SetPassword"
+
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Identity
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+
+Set password
+
+
Set your password
+
+
+ You do not have a local username/password for this site. Add a local
+ account so you can log in without an external login.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ private string? message;
+ private ApplicationUser? user;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ message = IdentityStatusMessage;
+ IdentityStatusMessage = null;
+ Input ??= new();
+
+ user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ var hasPassword = await UserManager.HasPasswordAsync(user);
+ if (hasPassword)
+ {
+ RedirectManager.RedirectTo("Account/Manage/ChangePassword");
+ }
+ }
+
+ private async Task OnValidSubmitAsync()
+ {
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ var addPasswordResult = await UserManager.AddPasswordAsync(user, Input.NewPassword!);
+ if (!addPasswordResult.Succeeded)
+ {
+ message = $"Error: {string.Join(",", addPasswordResult.Errors.Select(error => error.Description))}";
+ return;
+ }
+
+ await SignInManager.RefreshSignInAsync(user);
+ IdentityStatusMessage = "Your password has been set.";
+ RedirectManager.RedirectToCurrentPage();
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+ [DataType(DataType.Password)]
+ [Display(Name = "New password")]
+ public string? NewPassword { get; set; }
+
+ [DataType(DataType.Password)]
+ [Display(Name = "Confirm new password")]
+ [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
+ public string? ConfirmPassword { get; set; }
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/TwoFactorAuthentication.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/TwoFactorAuthentication.razor
new file mode 100644
index 000000000000..f3ab2d5d1c04
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/TwoFactorAuthentication.razor
@@ -0,0 +1,112 @@
+@page "/Account/Manage/TwoFactorAuthentication"
+
+@using Microsoft.AspNetCore.Http.Features
+@using Microsoft.AspNetCore.Identity
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject SignInManager SignInManager
+@inject IdentityRedirectManager RedirectManager
+
+Two-factor authentication (2FA)
+
+
+
Two-factor authentication (2FA)
+@if (canTrack)
+{
+ if (is2faEnabled)
+ {
+ if (recoveryCodesLeft == 0)
+ {
+
+ Privacy and cookie policy have not been accepted.
+
You must accept the policy before you can enable two factor authentication.
+
+}
+
+@code {
+ private string? message;
+ private bool canTrack;
+ private bool hasAuthenticator;
+ private int recoveryCodesLeft;
+ private bool is2faEnabled;
+ private bool isMachineRemembered;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromTempData(Name = IdentityRedirectManager.StatusMessageKey)]
+ private string? IdentityStatusMessage { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ message = IdentityStatusMessage;
+ IdentityStatusMessage = null;
+
+ var user = await UserManager.GetUserAsync(HttpContext.User);
+ if (user is null)
+ {
+ RedirectManager.RedirectTo("Account/InvalidUser");
+ return;
+ }
+
+ canTrack = HttpContext.Features.Get()?.CanTrack ?? true;
+ hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null;
+ is2faEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
+ isMachineRemembered = await SignInManager.IsTwoFactorClientRememberedAsync(user);
+ recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user);
+ }
+
+ private async Task OnSubmitForgetBrowserAsync()
+ {
+ await SignInManager.ForgetTwoFactorClientAsync();
+
+ IdentityStatusMessage = "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.";
+ RedirectManager.RedirectToCurrentPage();
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/_Imports.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/_Imports.razor
new file mode 100644
index 000000000000..ada5bb010a89
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Manage/_Imports.razor
@@ -0,0 +1,2 @@
+@layout ManageLayout
+@attribute [Microsoft.AspNetCore.Authorization.Authorize]
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Register.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Register.razor
new file mode 100644
index 000000000000..d63049d172a7
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/Register.razor
@@ -0,0 +1,158 @@
+@page "/Account/Register"
+
+@using System.ComponentModel.DataAnnotations
+@using System.Text
+@using System.Text.Encodings.Web
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject IUserStore UserStore
+@inject SignInManager SignInManager
+@inject IEmailSender EmailSender
+@inject ILogger Logger
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Register
+
+
Register
+
+
+
+
+
+
+
Create a new account.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Use another service to register.
+
+
+
+
+
+
+@code {
+ private IEnumerable? identityErrors;
+
+ [SupplyParameterFromForm]
+ private InputModel Input { get; set; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? ReturnUrl { get; set; }
+
+ private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}";
+
+ protected override void OnInitialized()
+ {
+ Input ??= new();
+ }
+
+ public async Task RegisterUser(EditContext editContext)
+ {
+ var user = CreateUser();
+
+ await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
+ var emailStore = GetEmailStore();
+ await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
+ var result = await UserManager.CreateAsync(user, Input.Password);
+
+ if (!result.Succeeded)
+ {
+ identityErrors = result.Errors;
+ return;
+ }
+
+ Logger.LogInformation("User created a new account with password.");
+
+ var userId = await UserManager.GetUserIdAsync(user);
+ var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ var callbackUrl = NavigationManager.GetUriWithQueryParameters(
+ NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
+ new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl });
+
+ await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
+
+ if (UserManager.Options.SignIn.RequireConfirmedAccount)
+ {
+ RedirectManager.RedirectTo(
+ "Account/RegisterConfirmation",
+ new() { ["email"] = Input.Email, ["returnUrl"] = ReturnUrl });
+ }
+ else
+ {
+ await SignInManager.SignInAsync(user, isPersistent: false);
+ RedirectManager.RedirectTo(ReturnUrl);
+ }
+ }
+
+ private ApplicationUser CreateUser()
+ {
+ try
+ {
+ return Activator.CreateInstance();
+ }
+ catch
+ {
+ throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
+ $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor.");
+ }
+ }
+
+ private IUserEmailStore GetEmailStore()
+ {
+ if (!UserManager.SupportsUserEmail)
+ {
+ throw new NotSupportedException("The default UI requires a user store with email support.");
+ }
+ return (IUserEmailStore)UserStore;
+ }
+
+ private sealed class InputModel
+ {
+ [Required]
+ [EmailAddress]
+ [Display(Name = "Email")]
+ public string Email { get; set; } = "";
+
+ [Required]
+ [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
+ [DataType(DataType.Password)]
+ [Display(Name = "Password")]
+ public string Password { get; set; } = "";
+
+ [DataType(DataType.Password)]
+ [Display(Name = "Confirm password")]
+ [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
+ public string ConfirmPassword { get; set; } = "";
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/RegisterConfirmation.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/RegisterConfirmation.razor
new file mode 100644
index 000000000000..fabd6d11c40a
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/RegisterConfirmation.razor
@@ -0,0 +1,69 @@
+@page "/Account/RegisterConfirmation"
+
+@using System.Text
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Register confirmation
+
+
Register confirmation
+
+
+
+@if (emailConfirmationLink is not null)
+{
+
+ This app does not currently have a real email sender registered, see these docs for how to configure a real email sender.
+ Normally this would be emailed: Click here to confirm your account
+
+}
+else
+{
+
Please check your email to confirm your account.
+}
+
+@code {
+ private string? emailConfirmationLink;
+ private string? statusMessage;
+
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [SupplyParameterFromQuery]
+ private string? Email { get; set; }
+
+ [SupplyParameterFromQuery]
+ private string? ReturnUrl { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (Email is null)
+ {
+ RedirectManager.RedirectTo("");
+ return;
+ }
+
+ var user = await UserManager.FindByEmailAsync(Email);
+ if (user is null)
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
+ statusMessage = "Error finding user for unspecified email";
+ }
+ else if (EmailSender is IdentityNoOpEmailSender)
+ {
+ // Once you add a real email sender, you should remove this code that lets you confirm the account
+ var userId = await UserManager.GetUserIdAsync(user);
+ var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ emailConfirmationLink = NavigationManager.GetUriWithQueryParameters(
+ NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
+ new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl });
+ }
+ }
+}
diff --git a/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ResendEmailConfirmation.razor b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ResendEmailConfirmation.razor
new file mode 100644
index 000000000000..df135c4679f3
--- /dev/null
+++ b/src/Essentials/samples/Samples.WebServer/Components/Account/Pages/ResendEmailConfirmation.razor
@@ -0,0 +1,73 @@
+@page "/Account/ResendEmailConfirmation"
+
+@using System.ComponentModel.DataAnnotations
+@using System.Text
+@using System.Text.Encodings.Web
+@using Microsoft.AspNetCore.Identity
+@using Microsoft.AspNetCore.WebUtilities
+@using Essentials.Samples.WebServer.Data
+
+@inject UserManager UserManager
+@inject IEmailSender EmailSender
+@inject NavigationManager NavigationManager
+@inject IdentityRedirectManager RedirectManager
+
+Resend email confirmation
+
+
+ Swapping to Development environment will display more detailed information about the error that occurred.
+
+
+ The Development environment shouldn't be enabled for deployed applications.
+ It can result in displaying sensitive information from exceptions to end users.
+ For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
+ and restarting the app.
+