diff --git a/Microsoft.Maui-dev.sln b/Microsoft.Maui-dev.sln
index f4df92fbc508..1e38ed308b61 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.Server.Passkeys", "src\Essentials\samples\Samples.Server.Passkeys\Essentials.Samples.Server.Passkeys.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}"
@@ -407,6 +409,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
@@ -702,6 +708,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 3f33579a7d83..e7ca3410c059 100644
--- a/Microsoft.Maui-mac.slnf
+++ b/Microsoft.Maui-mac.slnf
@@ -40,6 +40,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.Server.Passkeys\\Essentials.Samples.Server.Passkeys.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 7217d8c92cfd..8f25cce62097 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.Server.Passkeys", "src\Essentials\samples\Samples.Server.Passkeys\Essentials.Samples.Server.Passkeys.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}"
@@ -370,6 +372,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
@@ -614,6 +620,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 f7608a537b90..c12745d53e14 100644
--- a/Microsoft.Maui-windows.slnf
+++ b/Microsoft.Maui-windows.slnf
@@ -47,6 +47,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.Server.Passkeys\\Essentials.Samples.Server.Passkeys.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 69a818bb7586..1a20641a8d48 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.Server.Passkeys", "src\Essentials\samples\Samples.Server.Passkeys\Essentials.Samples.Server.Passkeys.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}"
@@ -396,6 +398,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
@@ -683,6 +689,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..bbeec497153f
--- /dev/null
+++ b/docs/specs/Passkeys.md
@@ -0,0 +1,1020 @@
+# 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;
+}
+```
+
+Failures surface as **BCL exceptions** (no custom exception type): malformed/missing options are an
+`ArgumentException`; a genuine ceremony/platform failure (no matching credential, misconfigured domain
+association, native error) is an `InvalidOperationException`; user cancellation is a
+`TaskCanceledException`; and an unsupported OS is a `FeatureNotSupportedException`. See §8 for the full
+mapping.
+
+**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.
+using var beginResponse = await httpClient.PostAsync("/passkeys/register/begin", content: null);
+string creationOptionsJson = await beginResponse.Content.ReadAsStringAsync();
+
+// 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("/passkeys/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.
+using var beginResponse = await httpClient.PostAsync("/passkeys/login/begin", content: null);
+string requestOptionsJson = await beginResponse.Content.ReadAsStringAsync();
+
+// 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("/passkeys/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`,
+ → `InvalidOperationException`, 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 runtime NuGet dependency** — `Microsoft.Windows.CsWin32` generates strongly typed bindings at
+ build time (`PrivateAssets="all"`) for 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, with generated ABI layouts and
+ a small local unmanaged-buffer owner for pointer lifetimes.
+- 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(...)`) on the caller's UI thread, then invoke the
+ synchronous native call on a worker. Keeping the WinUI dispatcher unblocked is required for activation
+ and z-order processing so the Windows Security modal remains in front of its owner.
+- **`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 generated interop 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()` detects support and selects the newest supported
+ option fields. The native API is available in **Windows 10 version 1903+**. Newer features degrade by
+ API version; for example, `residentKey: "preferred"` uses
+ `bPreferResidentKey` on WebAuthn API 3+ and degrades to no preference on API 1–2.
+- **Full JSON on API 9+**: the original creation/request JSON is supplied through the version 9 option
+ fields, and Windows' UTF-8 registration/authentication response JSON is returned directly. API 1–8
+ use the structured compatibility path. There is no API 7 JSON-extension path because its unsigned
+ extension output buffer is not documented as UTF-8 JSON; extension inputs are therefore ignored on
+ Windows API 1–8 rather than returning an incorrectly decoded result.
+- **Highest implementation cost** of the three (memory ownership/free and version branching), even with
+ generated bindings.
+
+### 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` | — / `AllowedCredentials` (native app registration API has no exclude-list property) | exclude / allow list |
+| `attestation` | Attestation conveyance | via `requestJson` | `AttestationPreference` | `dwAttestationConveyancePreference` |
+| `extensions` (e.g. `credProps`, `prf`, `largeBlob`) | WebAuthn extensions | via `requestJson` | — (not mapped in v1) | — (not mapped in v1) |
+| `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 an `InvalidOperationException` (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 generated interop 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) | `InvalidOperationException` — no passkey available (distinct from user cancellation) |
+| Malformed options JSON | `ArgumentException` |
+| Domain association not configured | Platform error surfaced as `InvalidOperationException` with the native message |
+| Any other native failure | `InvalidOperationException` 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**: no new NuGet package (in-box framework).
+- **Windows**: adds `Microsoft.Windows.CsWin32` as a private build-time source-generator dependency; it
+ contributes no runtime package dependency. The generated bindings call the in-box `webauthn.dll`.
+- **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 a small headless ASP.NET Core Identity server at
+ [`src/Essentials/samples/Samples.Server.Passkeys`](../../src/Essentials/samples/Samples.Server.Passkeys).
+ Its `PasskeyEndpoints.cs` exposes the native-app-facing JSON ceremony API and platform association
+ documents. ASP.NET Core Identity generates and validates the WebAuthn options/responses, so successful
+ registration and sign-in provide an interop conformance check across Apple, Android, and Windows.
+
+ ```
+ POST /passkeys/register/begin -> PublicKeyCredentialCreationOptions JSON
+ POST /passkeys/register/finish (body: attestation JSON) -> { registered, username }
+ POST /passkeys/login/begin -> PublicKeyCredentialRequestOptions JSON
+ POST /passkeys/login/finish (body: assertion JSON) -> { authenticated }
+ ```
+
+ 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 with username/password registration and an in-memory SQLite store. 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.Server.Passkeys --launch-profile http`.
+ 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 | Any Windows installation exposing `webauthn.dll` / API version 1+ (officially Windows 10 version 1903+) via CsWin32-generated bindings; newer fields are runtime-gated (§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 39ff9fe21684..61268687faa0 100644
--- a/eng/NuGetVersions.targets
+++ b/eng/NuGetVersions.targets
@@ -144,6 +144,10 @@
Update="Microsoft.IO.RecyclableMemoryStream"
Version="$(MicrosoftIoRecyclableMemoryStreamVersion)"
/>
+
+
+ 8.0.148
+ 0.3.2980.5.02.3.110.0.28000.2526
@@ -98,6 +99,8 @@
11.0.0-rc.1.26379.10211.0.0-rc.1.26379.10211.0.0-rc.1.26379.102
+ 11.0.0-rc.1.26379.102
+ 11.0.0-rc.1.26379.10210.0.2$(MicrosoftAspNetCorePackageVersion)
diff --git a/src/Essentials/samples/Configure-Passkeys.ps1 b/src/Essentials/samples/Configure-Passkeys.ps1
new file mode 100644
index 000000000000..95856597036f
--- /dev/null
+++ b/src/Essentials/samples/Configure-Passkeys.ps1
@@ -0,0 +1,544 @@
+#!/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.Server.Passkeys 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
+ - on macOS (unless -NoApple), 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
+ - on macOS (unless -NoApple), the associated-domains entitlement plus the auto-detected Mac Catalyst
+ signing identity + provisioning profile. See README-Passkeys.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-passkeys'. 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, when
+ Apple is configured, the Apple app-id `.`. Apple setup is skipped
+ automatically outside macOS unless AppleTeamId is passed explicitly.
+
+.PARAMETER AndroidKeystore
+ Path to the Android keystore whose signing-certificate SHA-256 goes into the Digital Asset Links
+ (assetlinks.json). Defaults to the debug keystore .NET for Android 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 NoApple
+ Skip Apple (iOS / iPadOS / Mac Catalyst) setup. On macOS the script configures Apple by default,
+ auto-detecting your Team ID (from the "Apple Development" signing certificate), signing identity,
+ and provisioning profile. Apple setup is skipped automatically outside macOS because Apple targets
+ require a Mac to build and sign. Pass AppleTeamId explicitly to prepare server trust and entitlements
+ outside macOS for a subsequent build on a Mac.
+
+.PARAMETER NoAndroid
+ Skip Android setup. By default the script writes the Android debug-key SHA-256 fingerprint +
+ apk-key-hash origin. If Android is not skipped but the debug key can't be read, the script FAILS —
+ pass -NoAndroid to opt out.
+
+.PARAMETER AppleTeamId
+ Your 10-character Apple Developer Team ID (developer.apple.com -> Membership). Optional — it is
+ auto-detected from your "Apple Development" signing certificate; pass this only to override the
+ detected value. The Apple app-id `.` is written into the server's App Site
+ Association config, and the git-ignored Apple entitlements/signing are generated.
+
+.PARAMETER NoStartHost
+ Skip hosting the tunnel. By default the script hosts the tunnel (blocking) at the end; pass this to
+ just (re)configure and print the host command instead.
+
+.EXAMPLE
+ ./Configure-Passkeys.ps1
+ # Configures Android and, on macOS, Apple; writes user-secrets and hosts the tunnel.
+
+.EXAMPLE
+ ./Configure-Passkeys.ps1 -NoApple
+ # Android-only: skip Apple setup (e.g. on a machine with no Apple signing certificate).
+
+.EXAMPLE
+ ./Configure-Passkeys.ps1 -AppleTeamId 42GDTGK33W
+ # Override the auto-detected Apple Team ID with an explicit one.
+
+.EXAMPLE
+ ./Configure-Passkeys.ps1 -NoStartHost
+ # Configures without starting the blocking tunnel host (prints the host command instead).
+#>
+[CmdletBinding()]
+param(
+ [string]$TunnelId = 'maui-essentials-passkeys',
+ [int]$Port = 5177,
+ [string]$ApplicationId,
+ [string]$AndroidKeystore,
+ [string]$AppleTeamId,
+ [string]$AppleSigningIdentity,
+ [string]$AppleProvisioningProfile,
+ [switch]$NoApple,
+ [switch]$NoAndroid,
+ [switch]$NoStartHost
+)
+
+$ErrorActionPreference = 'Stop'
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$project = Join-Path $here 'Samples.Server.Passkeys' 'Essentials.Samples.Server.Passkeys.csproj'
+$appleSkippedForPlatform = $false
+
+if (-not $IsMacOS -and -not $NoApple -and -not $AppleTeamId) {
+ $NoApple = $true
+ $appleSkippedForPlatform = $true
+}
+
+# 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) {
+ throw "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 $AndroidKeystore) {
+ $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
+ $AndroidKeystore = Join-Path $localAppData 'Xamarin' 'Mono for Android' 'debug.keystore'
+}
+
+# Computes the Android 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) — can't compute the Android signing fingerprint."
+ return $null
+ }
+ if (-not (Test-Path $keystore)) {
+ Write-Warning "Android keystore not found at '$keystore' (build the Android app once to create it)."
+ 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 keystore '$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)) {
+ throw "'$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]
+}
+
+# Derives the 10-char Apple Developer Team ID so it needn't be passed by hand: it's the Organizational
+# Unit (OU) of the "Apple Development" signing certificate, and also a provisioning profile's
+# TeamIdentifier. Returns the Team ID, or $null.
+function Get-AppleTeamId {
+ if (-not (Get-Command 'security' -ErrorAction SilentlyContinue)) { return $null }
+
+ # Preferred: the OU of the Apple Development signing certificate.
+ if (Get-Command 'openssl' -ErrorAction SilentlyContinue) {
+ $subject = (& security find-certificate -a -c 'Apple Develop' -p 2>$null | & openssl x509 -noout -subject 2>$null) -join "`n"
+ $m = [regex]::Match($subject, 'OU\s*=\s*([A-Z0-9]{10})')
+ if ($m.Success) { return $m.Groups[1].Value }
+ }
+
+ # Fallback: a provisioning profile's TeamIdentifier.
+ $dir = Join-Path $HOME 'Library' 'MobileDevice' 'Provisioning Profiles'
+ if (Test-Path $dir) {
+ $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"
+ $m = [regex]::Match($xml, 'TeamIdentifier\s*\s*([A-Z0-9]{10})')
+ if ($m.Success) { return $m.Groups[1].Value }
+ }
+ }
+
+ return $null
+}
+
+# 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)) {
+ throw "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-Passkeys.ps1 from Passkeys.Local.in.props. DO NOT COMMIT (git-ignored).`n Re-run Configure-Passkeys.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) {
+ throw @"
+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
+"@
+}
+
+$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. Configured
+# by default; fails if the debug key can't be read (pass -NoAndroid to skip Android instead).
+if ($NoAndroid) {
+ Write-Host " Android: skipped (-NoAndroid)." -ForegroundColor DarkGray
+}
+else {
+ $android = Get-AndroidKeyInfo $AndroidKeystore
+ if (-not $android) {
+ throw "Could not read the Android debug-signing key (needed for the Digital Asset Links fingerprint). Build the Android app once to generate the debug keystore, pass -AndroidKeystore , or pass -NoAndroid to skip 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 (configured by default on macOS; skipped with -NoApple or automatically
+# on other operating systems). The committed files are never edited.
+$appDir = Join-Path $here 'Samples'
+$entitlementsRel = $null
+$resolvedIdentity = $null
+$resolvedProfile = $null
+
+if (-not $NoApple -and -not $AppleTeamId) {
+ $AppleTeamId = Get-AppleTeamId
+ if ($AppleTeamId) {
+ Write-Host " Apple Team ID auto-detected from your signing cert: $AppleTeamId" -ForegroundColor DarkGray
+ }
+}
+
+if ($NoApple) {
+ if ($appleSkippedForPlatform) {
+ Write-Host " Apple: skipped automatically (Apple targets require macOS to build and sign)." -ForegroundColor DarkGray
+ }
+ else {
+ Write-Host " Apple: skipped (-NoApple)." -ForegroundColor DarkGray
+ }
+}
+elseif (-not $AppleTeamId) {
+ throw "Could not determine your Apple Team ID: no 'Apple Development' signing certificate found. Install one (Xcode -> Settings -> Accounts -> Manage Certificates), pass -AppleTeamId , or pass -NoApple to skip Apple."
+}
+else {
+ $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 ($IsMacOS) {
+ 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-Passkeys.md (Apple section)." -ForegroundColor DarkGray
+ }
+ else {
+ Write-Host " Server trust and entitlements generated. Build and sign the Apple app on a Mac." -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
+if ($NoStartHost) {
+ 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"
+}
+else {
+ Write-Host " In ANOTHER terminal, run the server: dotnet run --project `"$project`" --launch-profile http"
+ Write-Host " (this terminal is about to host the tunnel — pass -NoStartHost to skip that)"
+}
+Write-Host " Then build/run the sample — its Passkeys page defaults to $uri."
+
+if (-not $NoStartHost) {
+ 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-Passkeys.md b/src/Essentials/samples/README-Passkeys.md
new file mode 100644
index 000000000000..27f5c473831c
--- /dev/null
+++ b/src/Essentials/samples/README-Passkeys.md
@@ -0,0 +1,216 @@
+# .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.Server.Passkeys/`](Samples.Server.Passkeys)** — the reference relying-party (RP) server
+ (ASP.NET Core Identity + WebAuthn) that does the server half. It's small and commented — read the code
+ for the endpoint and auth details.
+- **[`Configure-Passkeys.ps1`](Configure-Passkeys.ps1)** — provisions a dev tunnel and writes the RP trust
+ config into the server's user-secrets (Android by default, plus Apple on macOS; `-NoApple` /
+ `-NoAndroid` to skip).
+
+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/*` (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.Server.Passkeys` 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** is baked
+in by `Configure-Passkeys.ps1`), sign up (or sign in) with a username + password, tap **Create a passkey**
+(approve the device prompt), then **Sign in with a passkey**.
+
+## 1. Server (shared by all platforms)
+
+From `src/Essentials/samples`, configure and host the tunnel — one command that writes the server
+config into user-secrets and then holds the tunnel open (blocking):
+
+```bash
+pwsh ./Configure-Passkeys.ps1
+```
+
+It provisions a persistent dev tunnel, writes the RP domain + web origin into the SERVER's user-secrets,
+and sets up Android trust plus, **when running on macOS**, Apple trust (auto-detecting the Android
+debug-key fingerprint and, on a Mac, your Apple Team ID + signing identity + provisioning profile).
+Outside macOS, Apple setup is skipped automatically because Apple apps require a Mac to build and sign.
+It prints the public `https://…devtunnels.ms` URL, bakes it into the app, then **hosts the tunnel**.
+
+> It **fails fast** if a platform it's meant to configure can't be — e.g. the Android debug keystore
+> doesn't exist yet (build the Android app once), or on macOS there's no Apple signing certificate.
+> Pass **`-NoAndroid`** and/or **`-NoApple`** to skip a platform you aren't testing, and `-NoStartHost`
+> to just (re)configure without hosting. Outside macOS, pass `-AppleTeamId ` only if you want to
+> prepare the server trust and entitlements for a subsequent build on a Mac.
+
+Then, in another terminal, run the server:
+
+```bash
+dotnet run --project Samples.Server.Passkeys --launch-profile http
+```
+
+Verify the printed public URL reaches the server:
+
+```bash
+curl https:///health
+```
+
+The response reports the relying-party ID and whether Android and Apple trust are configured.
+
+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) with your **Apple Development signing certificate** in the keychain (the script reads your
+10-character **Team ID** straight from it — no need to look it up), **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-Passkeys.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`). The **same command from
+ [section 1](#1-server-shared-by-all-platforms)** sets up Apple by default — once you've done steps 1–2
+ above (App ID + provisioning profile), just re-run it (your Team ID is auto-detected from the signing
+ certificate):
+ ```bash
+ pwsh ./Configure-Passkeys.ps1
+ # add -AppleTeamId only to override the auto-detected Team ID.
+ ```
+ 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 run --project Samples/Essentials.Sample.csproj -f net11.0-ios`
+ - **Mac Catalyst** (signed via `Signing.local.props`): `dotnet run --project Samples/Essentials.Sample.csproj -f net11.0-maccatalyst`
+ - **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. |
+| 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-Passkeys.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 run --project Samples/Essentials.Sample.csproj -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 10 version 1903+)
+
+Nothing extra: the Windows platform trusts the `https` origin directly (no domain-association file).
+Use a Windows installation with the WebAuthn API (officially **Windows 10 version 1903+**) and Windows
+Hello or a compatible FIDO2 authenticator. With the server running (section 1),
+deploy the Windows head from your IDE (or `dotnet run --project Samples/Essentials.Sample.csproj -f
+net11.0-windows10.0.` matching the project's Windows TFM), then follow
+[**Using the app**](#using-the-app).
+
+For local capability testing, edit this constant and rebuild:
+
+- `WindowsWebAuthn.TestApiVersionOverride`: set `2` to emulate the Windows 10-era WebAuthn API;
+ `0` uses the actual OS version. The override can only lower capabilities.
+
+## 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-Passkeys.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/README.md b/src/Essentials/samples/README.md
new file mode 100644
index 000000000000..7c82cc522e9c
--- /dev/null
+++ b/src/Essentials/samples/README.md
@@ -0,0 +1,33 @@
+# .NET MAUI Essentials — samples
+
+This folder holds the **Essentials.Sample** app and the small reference **servers** that some of its
+pages talk to. Each scenario has its own setup + testing guide — start there.
+
+## Scenarios
+
+### Passkeys (WebAuthn / FIDO2)
+
+Creating and using passkeys with the platform authenticator (Face ID / Touch ID / Windows Hello /
+Android), verified by a minimal ASP.NET Core Identity relying-party server.
+
+- **Guide:** [README-Passkeys.md](README-Passkeys.md)
+- **Reference server:** [`Samples.Server.Passkeys/`](Samples.Server.Passkeys)
+- **Setup:** [`Configure-Passkeys.ps1`](Configure-Passkeys.ps1)
+
+### WebAuthenticator
+
+Browser-based OAuth sign-in via `WebAuthenticator`, brokered by a small reference server.
+
+- **Guide:** _coming — being reworked_
+- **Reference server:** [`Sample.Server.WebAuthenticator/`](Sample.Server.WebAuthenticator)
+
+## Projects
+
+- **[`Samples/`](Samples)** — the **Essentials.Sample** MAUI app (all the Essentials demo pages,
+ including the **Passkeys** page).
+- **[`Samples.Server.Passkeys/`](Samples.Server.Passkeys)** — the passkeys relying-party (RP) server
+ (small and commented — read the code for endpoint/auth details).
+- **[`Sample.Server.WebAuthenticator/`](Sample.Server.WebAuthenticator)** — the WebAuthenticator
+ reference server.
+
+Pick the scenario you want to test from the table above and follow its guide.
diff --git a/src/Essentials/samples/Samples.Server.Passkeys/Essentials.Samples.Server.Passkeys.csproj b/src/Essentials/samples/Samples.Server.Passkeys/Essentials.Samples.Server.Passkeys.csproj
new file mode 100644
index 000000000000..c0a74ef45464
--- /dev/null
+++ b/src/Essentials/samples/Samples.Server.Passkeys/Essentials.Samples.Server.Passkeys.csproj
@@ -0,0 +1,16 @@
+
+
+
+ $(_MauiDotNetTfm)
+ enable
+ enable
+ Samples.Server.Passkeys
+ essentials-sample-webserver
+
+
+
+
+
+
+
+
diff --git a/src/Essentials/samples/Samples.Server.Passkeys/IdentityNoOpEmailSender.cs b/src/Essentials/samples/Samples.Server.Passkeys/IdentityNoOpEmailSender.cs
new file mode 100644
index 000000000000..48e6fb8afa09
--- /dev/null
+++ b/src/Essentials/samples/Samples.Server.Passkeys/IdentityNoOpEmailSender.cs
@@ -0,0 +1,18 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace Samples.Server.Passkeys;
+
+// Dev-only test server: Identity's registration flow requires an IEmailSender to be
+// registered, but this headless sample never sends email (email confirmation is disabled in
+// Program.cs). Every method is a no-op. Do not copy this into production.
+internal sealed class IdentityNoOpEmailSender : IEmailSender
+{
+ public Task SendConfirmationLinkAsync(IdentityUser user, string email, string confirmationLink) =>
+ Task.CompletedTask;
+
+ public Task SendPasswordResetLinkAsync(IdentityUser user, string email, string resetLink) =>
+ Task.CompletedTask;
+
+ public Task SendPasswordResetCodeAsync(IdentityUser user, string email, string resetCode) =>
+ Task.CompletedTask;
+}
diff --git a/src/Essentials/samples/Samples.Server.Passkeys/PasskeyEndpoints.cs b/src/Essentials/samples/Samples.Server.Passkeys/PasskeyEndpoints.cs
new file mode 100644
index 000000000000..9e0e8a318b94
--- /dev/null
+++ b/src/Essentials/samples/Samples.Server.Passkeys/PasskeyEndpoints.cs
@@ -0,0 +1,212 @@
+using System.Buffers.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Identity;
+
+namespace Samples.Server.Passkeys;
+
+///
+/// The passkey ceremony endpoints for the native app, plus the platform domain-association documents
+/// they depend on - they belong together, because an on-device passkey ceremony only works when the
+/// same relying-party domain both runs the ceremony and serves the well-known association files.
+///
+///
+/// The ceremony endpoints are driven by a native HttpClient (not browser forms): the WebAuthn
+/// challenge is round-tripped through the auth cookie, so the client must use a CookieContainer
+/// and send the cookie from /begin back on the matching /finish. Registration enrolls a
+/// passkey for the signed-in user, so the caller must be authenticated first.
+///
+internal static class PasskeyEndpoints
+{
+ public static IEndpointRouteBuilder MapPasskeys(this IEndpointRouteBuilder endpoints, IConfiguration config)
+ {
+ MapCeremony(endpoints);
+ MapDomainAssociation(endpoints, config);
+ return endpoints;
+ }
+
+ // The /passkeys/* ceremony API called by the native app.
+ static void MapCeremony(IEndpointRouteBuilder endpoints)
+ {
+ // Native JSON APIs, not browser form posts, so antiforgery doesn't apply - disable it on the whole
+ // group. (WebAuthn payloads are signed over challenge+origin+rpId and can't be forged or replayed.)
+ var group = endpoints.MapGroup("/passkeys").DisableAntiforgery();
+
+ // Reports the signed-in user's passkeys so the app can list them and offer to enroll one.
+ group.MapGet("/list", async (
+ HttpContext context,
+ UserManager userManager) =>
+ {
+ var user = await userManager.GetUserAsync(context.User);
+ if (user is null)
+ return Results.Json(new { error = "Not signed in." }, statusCode: StatusCodes.Status401Unauthorized);
+
+ var passkeys = await userManager.GetPasskeysAsync(user);
+ return Results.Ok(new
+ {
+ username = user.UserName,
+ passkeyCount = passkeys.Count,
+ passkeys = passkeys.Select(pk => new
+ {
+ // Base64Url-encode the credential id (raw bytes) into a stable string identifier.
+ id = Base64Url.EncodeToString(pk.CredentialId),
+ name = pk.Name,
+ createdAt = pk.CreatedAt,
+ }),
+ });
+ }).RequireAuthorization();
+
+ // Registration begin: returns the WebAuthn creation options for the signed-in user.
+ group.MapPost("/register/begin", async (
+ HttpContext context,
+ UserManager userManager,
+ SignInManager signInManager) =>
+ {
+ var user = await userManager.GetUserAsync(context.User);
+ if (user is null)
+ return Results.Json(new { error = "Sign in first (POST /account/login?useCookies=true) - a passkey is enrolled for the signed-in user." }, statusCode: StatusCodes.Status401Unauthorized);
+
+ var userId = await userManager.GetUserIdAsync(user);
+ var userName = await userManager.GetUserNameAsync(user) ?? user.UserName!;
+ var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new PasskeyUserEntity
+ {
+ Id = userId,
+ Name = userName,
+ DisplayName = userName,
+ });
+
+ return Results.Content(optionsJson, "application/json");
+ }).RequireAuthorization();
+
+ // Registration finish: validates the attestation and stores the passkey against the signed-in user.
+ // An optional ?name= (the app passes an auto-generated device label) is stored so passkeys created
+ // on different devices are distinguishable in the list.
+ group.MapPost("/register/finish", async (
+ HttpContext context,
+ JsonElement credential,
+ string? name,
+ UserManager userManager,
+ SignInManager signInManager) =>
+ {
+ PasskeyAttestationResult attestation;
+ try
+ {
+ attestation = await signInManager.PerformPasskeyAttestationAsync(credential.GetRawText());
+ }
+ catch (InvalidOperationException ex)
+ {
+ // No attestation ceremony in progress (no preceding /begin, or the challenge cookie was lost).
+ return Results.BadRequest($"No passkey registration is in progress. Call /passkeys/register/begin first (and send its cookie). {ex.Message}");
+ }
+
+ if (!attestation.Succeeded)
+ return Results.BadRequest($"Attestation failed: {attestation.Failure?.Message}");
+
+ var user = await userManager.GetUserAsync(context.User);
+ if (user is null)
+ return Results.Unauthorized();
+
+ var userId = await userManager.GetUserIdAsync(user);
+ if (!string.Equals(userId, attestation.UserEntity.Id, StringComparison.Ordinal))
+ return Results.BadRequest("The passkey registration user does not match the signed-in user.");
+
+ if (!string.IsNullOrWhiteSpace(name))
+ attestation.Passkey.Name = name.Trim();
+
+ var stored = await userManager.AddOrUpdatePasskeyAsync(user, attestation.Passkey);
+ if (!stored.Succeeded)
+ return Results.BadRequest("Failed to store passkey.");
+
+ return Results.Ok(new { registered = true, username = user.UserName, name = attestation.Passkey.Name });
+ }).RequireAuthorization();
+
+ // Sign-in begin: returns the WebAuthn request options for username-less (discoverable) sign-in.
+ // No username is needed - the passkey itself carries the identity, and the server only learns who
+ // the user is at /login/finish, from the credential the assertion is signed with.
+ group.MapPost("/login/begin", async (
+ SignInManager signInManager) =>
+ {
+ var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user: null);
+ return Results.Content(optionsJson, "application/json");
+ });
+
+ // Sign-in finish: validates the assertion and signs the user in.
+ group.MapPost("/login/finish", async (
+ JsonElement credential,
+ SignInManager signInManager) =>
+ {
+ SignInResult result;
+ try
+ {
+ result = await signInManager.PasskeySignInAsync(credential.GetRawText());
+ }
+ catch (InvalidOperationException ex)
+ {
+ // No assertion ceremony in progress (no preceding /begin, or the challenge cookie was lost).
+ return Results.BadRequest($"No passkey sign-in is in progress. Call /passkeys/login/begin first (and send its cookie). {ex.Message}");
+ }
+
+ if (!result.Succeeded)
+ {
+ var reason = result.IsLockedOut
+ ? "the account is locked out."
+ : result.IsNotAllowed
+ ? "the account is not allowed to sign in."
+ : "the passkey could not be verified.";
+
+ return Results.Json(
+ new { error = $"Sign-in failed: {reason}" },
+ statusCode: StatusCodes.Status401Unauthorized);
+ }
+
+ return Results.Ok(new { authenticated = true });
+ });
+ }
+
+ // The platform domain-association documents that let real devices trust this relying party:
+ // Android Digital Asset Links (/.well-known/assetlinks.json) and Apple App Site Association
+ // (/.well-known/apple-app-site-association). Populated from the Passkeys:Android / Passkeys:Apple
+ // config; must be served over HTTPS from the same domain configured as the passkey ServerDomain.
+ static void MapDomainAssociation(IEndpointRouteBuilder endpoints, IConfiguration config)
+ {
+ // Android - Digital Asset Links. The sha256_cert_fingerprints are the colon-delimited SHA-256
+ // hashes of the app's signing certificate(s) (keytool / apksigner output).
+ endpoints.MapGet("/.well-known/assetlinks.json", () =>
+ {
+ var packageName = config["Passkeys:Android:PackageName"];
+ var fingerprints = config.GetSection("Passkeys:Android:Sha256CertFingerprints").Get()
+ ?? Array.Empty();
+
+ var doc = new[]
+ {
+ new
+ {
+ relation = new[]
+ {
+ "delegate_permission/common.get_login_creds",
+ "delegate_permission/common.handle_all_urls",
+ },
+ target = new
+ {
+ @namespace = "android_app",
+ package_name = packageName,
+ sha256_cert_fingerprints = fingerprints,
+ },
+ },
+ };
+
+ return Results.Json(doc, contentType: "application/json");
+ });
+
+ // Apple - App Site Association (webcredentials). Each entry is ".".
+ // Must be served at the domain root, over HTTPS, with no file extension.
+ endpoints.MapGet("/.well-known/apple-app-site-association", () =>
+ {
+ var appIds = config.GetSection("Passkeys:Apple:AppIds").Get()
+ ?? Array.Empty();
+
+ var doc = new { webcredentials = new { apps = appIds } };
+
+ return Results.Json(doc, contentType: "application/json");
+ });
+ }
+}
diff --git a/src/Essentials/samples/Samples.Server.Passkeys/Program.cs b/src/Essentials/samples/Samples.Server.Passkeys/Program.cs
new file mode 100644
index 000000000000..9bee4ac81173
--- /dev/null
+++ b/src/Essentials/samples/Samples.Server.Passkeys/Program.cs
@@ -0,0 +1,140 @@
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Samples.Server.Passkeys;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddAuthentication(options =>
+ {
+ options.DefaultScheme = IdentityConstants.ApplicationScheme;
+ options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
+ })
+ // AddBearerToken must come before AddIdentityCookies in the chain: it returns the AuthenticationBuilder
+ // so the chain can continue, whereas AddIdentityCookies returns a different (terminal) builder type that
+ // has no AddBearerToken. The bearer scheme is required by MapIdentityApi even though the app authenticates
+ // with the cookie variant.
+ .AddBearerToken(IdentityConstants.BearerScheme)
+ .AddIdentityCookies();
+
+// Authorization services for the RequireAuthorization() passkey endpoints.
+builder.Services.AddAuthorization();
+
+// The application cookie answers an unauthenticated [Authorize] request with a 302 to a login page,
+// which is useless to the native client. Return a clean 401 for the native API paths instead.
+builder.Services.ConfigureApplicationCookie(options =>
+{
+ options.Events.OnRedirectToLogin = context =>
+ {
+ if (context.Request.Path.StartsWithSegments("/passkeys", StringComparison.Ordinal))
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return Task.CompletedTask;
+ }
+
+ context.Response.Redirect(context.RedirectUri);
+ return Task.CompletedTask;
+ };
+});
+
+// In-memory SQLite: the real relational engine, but nothing on disk. A named shared-cache
+// in-memory database exists only while a connection to it is open, so keep one open for the
+// app's lifetime. Everything is wiped when the server stops - fine for this throwaway dev tool.
+const string inMemoryConnectionString = "Data Source=PasskeysSample;Mode=Memory;Cache=Shared";
+var keepAliveConnection = new SqliteConnection(inMemoryConnectionString);
+keepAliveConnection.Open();
+builder.Services.AddSingleton(keepAliveConnection);
+builder.Services.AddDbContext(options => options.UseSqlite(inMemoryConnectionString));
+
+builder.Services.AddIdentityCore(options =>
+ {
+ // Dev-only test server: skip email confirmation so you can register and immediately sign in
+ // (there is no real email sender). Do not copy this into production.
+ options.SignIn.RequireConfirmedAccount = false;
+ options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
+ })
+ .AddEntityFrameworkStores()
+ .AddSignInManager()
+ .AddApiEndpoints()
+ .AddDefaultTokenProviders();
+
+builder.Services.AddSingleton, IdentityNoOpEmailSender>();
+
+// Passkey relying-party config. ServerDomain is the RP ID (the public host the apps use).
+// ValidateOrigin must also accept each platform's native origin (Android's apk-key-hash, Apple's web origin).
+var passkeysConfig = builder.Configuration.GetSection("Passkeys");
+var serverDomain = passkeysConfig["ServerDomain"];
+var allowedOrigins = passkeysConfig.GetSection("AllowedOrigins").Get() ?? Array.Empty();
+if (!string.IsNullOrEmpty(serverDomain))
+{
+ var webOrigin = $"https://{serverDomain}";
+ var origins = new HashSet(allowedOrigins, StringComparer.Ordinal) { webOrigin };
+
+ builder.Services.Configure(options =>
+ {
+ options.ServerDomain = serverDomain;
+ options.ValidateOrigin = context => ValueTask.FromResult(origins.Contains(context.Origin));
+ });
+}
+
+// Behind a dev tunnel the app is reached over HTTPS on a public host but listens on plain HTTP
+// locally. Honor the forwarded scheme/host so the effective origin matches the RP ID.
+builder.Services.Configure(options =>
+{
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
+});
+
+var app = builder.Build();
+
+// Create the schema in the in-memory database at startup (no migrations needed).
+using (var scope = app.Services.CreateScope())
+{
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.EnsureCreated();
+}
+
+app.UseForwardedHeaders();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+// Username/password auth: /account/register, /account/login (?useCookies=true sets the auth cookie), etc.
+// There's no logout endpoint: the auth cookie is a self-contained ticket the client holds, so the native
+// app "signs out" by simply dropping its cookie jar - there's no server-side session to invalidate.
+app.MapGroup("/account").MapIdentityApi();
+
+// Passkey ceremony endpoints + the platform domain-association documents they depend on.
+app.MapPasskeys(app.Configuration);
+
+// Public tunnel/server readiness probe. If this responds through the dev-tunnel URL, tunnel access
+// (including any required approval or X-Tunnel-Authorization token) has already succeeded.
+app.MapGet("/health", (IConfiguration config) =>
+{
+ var domain = config["Passkeys:ServerDomain"];
+ var androidPackage = config["Passkeys:Android:PackageName"];
+ var androidFingerprints = config.GetSection("Passkeys:Android:Sha256CertFingerprints").Get()
+ ?? Array.Empty();
+ var appleAppIds = config.GetSection("Passkeys:Apple:AppIds").Get()
+ ?? Array.Empty();
+
+ return Results.Ok(new
+ {
+ status = "healthy",
+ relyingPartyId = domain,
+ android = new
+ {
+ configured = !string.IsNullOrWhiteSpace(androidPackage) && androidFingerprints.Length > 0,
+ packageName = androidPackage,
+ fingerprintCount = androidFingerprints.Length,
+ },
+ apple = new
+ {
+ configured = appleAppIds.Length > 0,
+ appIdCount = appleAppIds.Length,
+ },
+ });
+});
+
+app.Run();
diff --git a/src/Essentials/samples/Samples.Server.Passkeys/Properties/launchSettings.json b/src/Essentials/samples/Samples.Server.Passkeys/Properties/launchSettings.json
new file mode 100644
index 000000000000..783b5ff798db
--- /dev/null
+++ b/src/Essentials/samples/Samples.Server.Passkeys/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5177",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7235;http://localhost:5177",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+ }
diff --git a/src/Essentials/samples/Samples.Server.Passkeys/appsettings.json b/src/Essentials/samples/Samples.Server.Passkeys/appsettings.json
new file mode 100644
index 000000000000..10f68b8c8b4f
--- /dev/null
+++ b/src/Essentials/samples/Samples.Server.Passkeys/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/Essentials/samples/Samples/.gitignore b/src/Essentials/samples/Samples/.gitignore
new file mode 100644
index 000000000000..d2da6eca1117
--- /dev/null
+++ b/src/Essentials/samples/Samples/.gitignore
@@ -0,0 +1,4 @@
+# Local passkey test config generated by ../Configure-Passkeys.ps1 (server URL, Apple signing + entitlements).
+# Never committed — the committed Passkeys.Local.in.props template is the reference. See ../README.md.
+*.Local.props
+Entitlements.Local.plist
diff --git a/src/Essentials/samples/Samples/Essentials.Sample.csproj b/src/Essentials/samples/Samples/Essentials.Sample.csproj
index ca4145d34abf..c4cc005f9b62 100644
--- a/src/Essentials/samples/Samples/Essentials.Sample.csproj
+++ b/src/Essentials/samples/Samples/Essentials.Sample.csproj
@@ -33,6 +33,10 @@
+
+ Platforms\Windows\Package.appxmanifest
+
+
@@ -57,4 +61,10 @@
+
+
+
diff --git a/src/Essentials/samples/Samples/Passkeys.Local.in.props b/src/Essentials/samples/Samples/Passkeys.Local.in.props
new file mode 100644
index 000000000000..731829d60311
--- /dev/null
+++ b/src/Essentials/samples/Samples/Passkeys.Local.in.props
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Platforms/iOS/Entitlements.Local.plist
+
+
+
+
+ Apple Development: Your Name (XXXXXXXXXX)
+ Your Provisioning Profile Name
+ None
+
+
+
diff --git a/src/Essentials/samples/Samples/View/PasskeysPage.xaml b/src/Essentials/samples/Samples/View/PasskeysPage.xaml
new file mode 100644
index 000000000000..702ce3a0cdbc
--- /dev/null
+++ b/src/Essentials/samples/Samples/View/PasskeysPage.xaml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Essentials/samples/Samples/View/PasskeysPage.xaml.cs b/src/Essentials/samples/Samples/View/PasskeysPage.xaml.cs
new file mode 100644
index 000000000000..4fe945701803
--- /dev/null
+++ b/src/Essentials/samples/Samples/View/PasskeysPage.xaml.cs
@@ -0,0 +1,12 @@
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Controls.Xaml;
+
+namespace Samples.View;
+
+public partial class PasskeysPage : BasePage
+{
+ public PasskeysPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/Essentials/samples/Samples/ViewModel/HomeViewModel.cs b/src/Essentials/samples/Samples/ViewModel/HomeViewModel.cs
index 204aaf75ab6f..e144ae438ce0 100644
--- a/src/Essentials/samples/Samples/ViewModel/HomeViewModel.cs
+++ b/src/Essentials/samples/Samples/ViewModel/HomeViewModel.cs
@@ -247,6 +247,12 @@ public HomeViewModel()
typeof(WebAuthenticatorPage),
"Quickly and easily authenticate and wait for a callback.",
new[] { "auth", "authenticate", "authenticator", "web", "webauth" }),
+ new SampleItem(
+ "🔑",
+ "Passkeys",
+ typeof(PasskeysPage),
+ "Register and sign in with a passkey (WebAuthn/FIDO2) against a reference relying-party server.",
+ new[] { "passkey", "passkeys", "webauthn", "fido2", "auth", "authenticate", "credential" }),
};
filteredItems = samples;
filterText = string.Empty;
diff --git a/src/Essentials/samples/Samples/ViewModel/PasskeysViewModel.cs b/src/Essentials/samples/Samples/ViewModel/PasskeysViewModel.cs
new file mode 100644
index 000000000000..734dce6ca92e
--- /dev/null
+++ b/src/Essentials/samples/Samples/ViewModel/PasskeysViewModel.cs
@@ -0,0 +1,458 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using Microsoft.Maui.ApplicationModel;
+using Microsoft.Maui.Authentication;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Devices;
+using PasskeysApi = Microsoft.Maui.Authentication.Passkeys;
+
+namespace Samples.ViewModel;
+
+public class PasskeysViewModel : BaseViewModel
+{
+ // The relying-party server (Samples.Server.Passkeys). Passkeys are bound to a domain, so it must be a
+ // public HTTPS host reachable from the device. Run `pwsh ./Configure-Passkeys.ps1` in src/Essentials/samples
+ // to provision a dev tunnel; it bakes the URL in via AssemblyMetadata (see Essentials.Sample.csproj).
+ readonly string serverBaseUrl = GetConfiguredServerUrl();
+
+ static string GetConfiguredServerUrl()
+ {
+ foreach (var attribute in typeof(PasskeysViewModel).Assembly.GetCustomAttributes())
+ {
+ if (attribute.Key == "PasskeysServerUrl" && !string.IsNullOrWhiteSpace(attribute.Value))
+ return attribute.Value;
+ }
+
+ return "https://your-tunnel-5177.devtunnels.ms";
+ }
+
+ string username = string.Empty;
+ string password = string.Empty;
+ string status = string.Empty;
+ bool isSignedIn;
+ string currentUsername;
+ int passkeyCount;
+
+ HttpClient httpClient;
+
+ public ObservableCollection Passkeys { get; } = new();
+
+ public PasskeysViewModel()
+ {
+ SignUpCommand = new Command(async () => await SignUpAsync());
+ SignInPasswordCommand = new Command(async () => await SignInPasswordAsync());
+ SignOutCommand = new Command(SignOut);
+ RegisterCommand = new Command(async () => await RegisterAsync());
+ LoginCommand = new Command(async () => await LoginAsync());
+ }
+
+ public bool IsSupported => PasskeysApi.IsSupported;
+
+ public string SupportedText => IsSupported
+ ? "Passkeys are supported on this device."
+ : "Passkeys are NOT supported on this device/OS version.";
+
+ public string ServerBaseUrl => serverBaseUrl;
+
+ public string Username
+ {
+ get => username;
+ set => SetProperty(ref username, value);
+ }
+
+ public string Password
+ {
+ get => password;
+ set => SetProperty(ref password, value);
+ }
+
+ public string Status
+ {
+ get => status;
+ set => SetProperty(ref status, value);
+ }
+
+ public bool IsSignedIn
+ {
+ get => isSignedIn;
+ set => SetProperty(ref isSignedIn, value, onChanged: () =>
+ {
+ OnPropertyChanged(nameof(IsLoggedOut));
+ OnPropertyChanged(nameof(AccountStatusText));
+ });
+ }
+
+ public bool IsLoggedOut => !IsSignedIn;
+
+ public string CurrentUsername
+ {
+ get => currentUsername;
+ set => SetProperty(ref currentUsername, value, onChanged: () => OnPropertyChanged(nameof(AccountStatusText)));
+ }
+
+ public int PasskeyCount
+ {
+ get => passkeyCount;
+ set => SetProperty(ref passkeyCount, value, onChanged: () =>
+ {
+ OnPropertyChanged(nameof(HasPasskey));
+ OnPropertyChanged(nameof(PasskeyStatusText));
+ OnPropertyChanged(nameof(CreatePasskeyButtonText));
+ });
+ }
+
+ public bool HasPasskey => PasskeyCount > 0;
+
+ public string AccountStatusText => $"Signed in as {CurrentUsername}";
+
+ public string PasskeyStatusText => HasPasskey
+ ? (PasskeyCount == 1 ? "✓ 1 passkey on this account." : $"✓ {PasskeyCount} passkeys on this account.")
+ : "No passkey yet - add one for faster sign-in.";
+
+ public string CreatePasskeyButtonText => HasPasskey ? "Add another passkey" : "Create a passkey";
+
+ public ICommand SignUpCommand { get; }
+
+ public ICommand SignInPasswordCommand { get; }
+
+ public ICommand SignOutCommand { get; }
+
+ public ICommand RegisterCommand { get; }
+
+ public ICommand LoginCommand { get; }
+
+ async Task SignUpAsync()
+ {
+ try
+ {
+ IsBusy = true;
+ Log($"Creating account '{Username}'…");
+
+ // POST /account/register { email, password }.
+ await PostJsonAsync("/account/register", new { email = Username, password = Password });
+
+ // /register doesn't sign you in, so log in immediately.
+ Log("Account created. Signing in…");
+ await PostJsonAsync("/account/login?useCookies=true", new { email = Username, password = Password });
+
+ await RefreshAfterSignInAsync();
+ }
+ catch (Exception ex)
+ {
+ HandleError(ex);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ async Task SignInPasswordAsync()
+ {
+ try
+ {
+ IsBusy = true;
+ Log($"Signing in as '{Username}'…");
+
+ // POST /account/login?useCookies=true sets the auth cookie on our CookieContainer.
+ await PostJsonAsync("/account/login?useCookies=true", new { email = Username, password = Password });
+
+ await RefreshAfterSignInAsync();
+ }
+ catch (Exception ex)
+ {
+ HandleError(ex);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ void SignOut()
+ {
+ // The auth cookie is a self-contained ticket held in our CookieContainer, and the dev server keeps
+ // no server-side session - so "signing out" is just discarding the cookie jar. Drop the HttpClient;
+ // the next request builds a fresh one with an empty CookieContainer.
+ httpClient?.Dispose();
+ httpClient = null;
+ SetSignedOutState();
+ Log("Signed out.");
+ }
+
+ async Task RegisterAsync()
+ {
+ if (!EnsureSupported())
+ return;
+
+ try
+ {
+ IsBusy = true;
+ Log("Requesting creation options…");
+
+ // Get the WebAuthn creation options for the signed-in user.
+ var creationOptionsJson = await PostAsync("/passkeys/register/begin");
+
+ // Create the passkey with the platform authenticator (biometric / PIN).
+ Log("Creating passkey with the platform authenticator…");
+ var response = await PasskeysApi.CreateAsync(creationOptionsJson, CancellationToken.None);
+
+ // Send the attestation back to be verified and stored, labelled with this device so passkeys
+ // from different test devices are easy to tell apart in the list.
+ Log("Verifying attestation with the server…");
+ var nameQuery = $"?name={Uri.EscapeDataString(BuildDeviceName())}";
+ await PostAsync($"/passkeys/register/finish{nameQuery}", response.ToString());
+
+ await RefreshAccountStateAsync();
+ Log("✅ Passkey created. You can now sign in with it.");
+ }
+ catch (Exception ex)
+ {
+ HandleError(ex);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ // A descriptive, auto-generated label from the Essentials DeviceInfo API so passkeys created on
+ // different devices are easy to tell apart — no user prompt. e.g. "iPhone 16 Pro (iOS 18.0)".
+ static string BuildDeviceName()
+ {
+ var name = string.IsNullOrWhiteSpace(DeviceInfo.Name)
+ ? $"{DeviceInfo.Manufacturer} {DeviceInfo.Model}".Trim()
+ : DeviceInfo.Name;
+ if (string.IsNullOrWhiteSpace(name))
+ name = "Unknown device";
+ return $"{name} ({DeviceInfo.Platform} {DeviceInfo.VersionString})";
+ }
+
+ async Task LoginAsync()
+ {
+ if (!EnsureSupported())
+ return;
+
+ try
+ {
+ IsBusy = true;
+ Log("Requesting request options…");
+
+ // Get the WebAuthn request options. Username-less: the passkey carries the identity and the
+ // OS account picker chooses the account, so no username is sent.
+ var requestOptionsJson = await PostAsync("/passkeys/login/begin");
+
+ // Assert with the platform authenticator (biometric / PIN).
+ Log("Asserting passkey with the platform authenticator…");
+ var response = await PasskeysApi.AssertAsync(requestOptionsJson, CancellationToken.None);
+
+ // Send the assertion back to be verified and signed in.
+ Log("Verifying assertion with the server…");
+ await PostAsync("/passkeys/login/finish", response.ToString());
+
+ await RefreshAccountStateAsync();
+ Log($"✅ Signed in with a passkey as {CurrentUsername}.");
+ }
+ catch (Exception ex)
+ {
+ HandleError(ex);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ // After sign-in, offer to set up a passkey if the account doesn't have one yet.
+ async Task RefreshAfterSignInAsync()
+ {
+ await RefreshAccountStateAsync();
+ if (IsSignedIn)
+ Log($"✅ Signed in as {CurrentUsername}.");
+ }
+
+ // Refreshes signed-in state and the passkey list from GET /passkeys/list (401 when signed out).
+ async Task RefreshAccountStateAsync()
+ {
+ var client = GetClient();
+ using var httpResponse = await client.GetAsync("/passkeys/list");
+
+ if (httpResponse.StatusCode == HttpStatusCode.Unauthorized)
+ {
+ SetSignedOutState();
+ return;
+ }
+
+ var body = await httpResponse.Content.ReadAsStringAsync();
+ if (!httpResponse.IsSuccessStatusCode)
+ throw new InvalidOperationException($"Server returned {(int)httpResponse.StatusCode}: {ExtractServerMessage(body)}");
+
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+ CurrentUsername = root.TryGetProperty("username", out var u) ? u.GetString() : Username;
+ PasskeyCount = root.TryGetProperty("passkeyCount", out var c) ? c.GetInt32() : 0;
+
+ Passkeys.Clear();
+ if (root.TryGetProperty("passkeys", out var list) && list.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var pk in list.EnumerateArray())
+ {
+ Passkeys.Add(new PasskeyItem
+ {
+ Id = pk.TryGetProperty("id", out var id) ? id.GetString() : null,
+ Name = pk.TryGetProperty("name", out var n) ? n.GetString() : null,
+ CreatedAt = pk.TryGetProperty("createdAt", out var ca) && ca.TryGetDateTimeOffset(out var dto)
+ ? dto.ToLocalTime().ToString("MMM d, yyyy")
+ : null,
+ });
+ }
+ }
+
+ IsSignedIn = true;
+ }
+
+ void SetSignedOutState()
+ {
+ IsSignedIn = false;
+ CurrentUsername = null;
+ PasskeyCount = 0;
+ Passkeys.Clear();
+ }
+
+ bool EnsureSupported()
+ {
+ OnPropertyChanged(nameof(IsSupported));
+ OnPropertyChanged(nameof(SupportedText));
+ if (!PasskeysApi.IsSupported)
+ {
+ Log("Passkeys are not supported on this device/OS version.");
+ return false;
+ }
+
+ return true;
+ }
+
+ async Task PostAsync(string relativeUrl, string jsonBody = null)
+ {
+ var client = GetClient();
+ using var content = new StringContent(jsonBody ?? string.Empty, Encoding.UTF8, "application/json");
+ using var httpResponse = await client.PostAsync(relativeUrl, content);
+
+ var body = await httpResponse.Content.ReadAsStringAsync();
+ if (!httpResponse.IsSuccessStatusCode)
+ throw new InvalidOperationException($"Server returned {(int)httpResponse.StatusCode}: {ExtractServerMessage(body)}");
+
+ return body;
+ }
+
+ // Posts a JSON object over the shared cookie-preserving HttpClient.
+ Task PostJsonAsync(string relativeUrl, object payload)
+ => PostAsync(relativeUrl, JsonSerializer.Serialize(payload));
+
+ // Pulls a readable message out of an error body ({ "error": … } / { "title": … } or plain text).
+ static string ExtractServerMessage(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ return "(no details)";
+
+ var trimmed = body.Trim();
+ if (trimmed[0] == '{')
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(trimmed);
+ if (doc.RootElement.TryGetProperty("error", out var error))
+ return error.GetString() ?? trimmed;
+ if (doc.RootElement.TryGetProperty("title", out var title))
+ return title.GetString() ?? trimmed;
+ }
+ catch (JsonException)
+ {
+ // not JSON after all - fall through
+ }
+ }
+
+ return trimmed;
+ }
+
+ HttpClient GetClient()
+ {
+ // A single client with a CookieContainer so the /begin cookie is sent back on /finish.
+ if (httpClient is null || httpClient.BaseAddress?.ToString() != NormalizeBaseUrl())
+ {
+ httpClient?.Dispose();
+ var handler = new HttpClientHandler
+ {
+ CookieContainer = new CookieContainer(),
+ UseCookies = true,
+ };
+ httpClient = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(NormalizeBaseUrl()),
+ Timeout = TimeSpan.FromMinutes(3),
+ };
+ }
+
+ return httpClient;
+ }
+
+ string NormalizeBaseUrl()
+ {
+ var url = (ServerBaseUrl ?? string.Empty).Trim();
+ if (url.Length == 0 || url[url.Length - 1] != '/')
+ url += "/";
+ return url;
+ }
+
+ void HandleError(Exception ex)
+ {
+ switch (ex)
+ {
+ case OperationCanceledException:
+ Log("⚠️ Canceled by the user.");
+ break;
+ case HttpRequestException http:
+ Log($"❌ Network error: {http.Message}. Is the server URL correct and reachable?");
+ break;
+ default:
+ Log($"❌ {ex.GetType().Name}: {ex.Message}");
+ break;
+ }
+ }
+
+ void Log(string message)
+ {
+ // Only the latest message is shown (status strip at the bottom of the page).
+ MainThread.BeginInvokeOnMainThread(() =>
+ Status = $"{DateTime.Now:HH:mm:ss} {message}");
+ }
+}
+
+// One row in the signed-in passkey list. Name is the auto-generated device label; Id is the Base64Url
+// credential id (raw bytes encoded), shown as a secondary, technical identifier.
+public class PasskeyItem
+{
+ public string Id { get; set; }
+
+ public string Name { get; set; }
+
+ public string CreatedAt { get; set; }
+
+ // The device label, falling back to the short credential id when the server has no name for it.
+ public string DisplayName => string.IsNullOrWhiteSpace(Name) ? ShortId : Name;
+
+ // A short, readable form of the (long) Base64Url credential id.
+ public string ShortId => string.IsNullOrEmpty(Id)
+ ? "(unknown id)"
+ : Id.Length <= 16 ? Id : Id.Substring(0, 16) + "…";
+
+ public string CreatedAtText => string.IsNullOrEmpty(CreatedAt) ? string.Empty : $"Added {CreatedAt}";
+}
diff --git a/src/Essentials/src/Essentials.csproj b/src/Essentials/src/Essentials.csproj
index 48d4f24f233c..8f8af4448651 100644
--- a/src/Essentials/src/Essentials.csproj
+++ b/src/Essentials/src/Essentials.csproj
@@ -39,6 +39,7 @@
+
@@ -47,6 +48,7 @@
+
diff --git a/src/Essentials/src/NativeMethods.json b/src/Essentials/src/NativeMethods.json
new file mode 100644
index 000000000000..12053274290a
--- /dev/null
+++ b/src/Essentials/src/NativeMethods.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://aka.ms/CsWin32.schema.json",
+ "allowMarshaling": false,
+ "public": false,
+ "wideCharOnly": true
+}
diff --git a/src/Essentials/src/NativeMethods.txt b/src/Essentials/src/NativeMethods.txt
new file mode 100644
index 000000000000..1477fbefe927
--- /dev/null
+++ b/src/Essentials/src/NativeMethods.txt
@@ -0,0 +1,8 @@
+WebAuthNGetApiVersionNumber
+WebAuthNAuthenticatorMakeCredential
+WebAuthNAuthenticatorGetAssertion
+WebAuthNFreeCredentialAttestation
+WebAuthNFreeAssertion
+WebAuthNGetCancellationId
+WebAuthNCancelCurrentOperation
+WebAuthNGetErrorName
diff --git a/src/Essentials/src/Passkeys/PasskeyJson.shared.cs b/src/Essentials/src/Passkeys/PasskeyJson.shared.cs
new file mode 100644
index 000000000000..1ddfe7bc5759
--- /dev/null
+++ b/src/Essentials/src/Passkeys/PasskeyJson.shared.cs
@@ -0,0 +1,229 @@
+#nullable enable
+#if !NETSTANDARD
+using System;
+using System.Buffers.Text;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace Microsoft.Maui.Authentication;
+
+// Strongly-typed WebAuthn (FIDO2) JSON models shared by the iOS and Windows implementations to read
+// the relying party's options and build the response, serialized with the System.Text.Json source
+// generator (no reflection, AOT-friendly). Android does not use these: its Credential Manager consumes
+// and produces the WebAuthn JSON directly. This file is excluded from the netstandard build, which has
+// no passkey implementation and does not reference System.Text.Json.
+static partial class WebAuthn
+{
+ // PublicKeyCredentialCreationOptions (server -> CreateAsync).
+ internal sealed class CreationOptions
+ {
+ public RelyingParty? Rp { get; set; }
+
+ public UserEntity? User { get; set; }
+
+ public string? Challenge { get; set; }
+
+ public List? PubKeyCredParams { get; set; }
+
+ public AuthenticatorSelection? AuthenticatorSelection { get; set; }
+
+ public string? Attestation { get; set; }
+
+ public List? ExcludeCredentials { get; set; }
+
+ public int? Timeout { get; set; }
+
+ // Not part of the spec here (userVerification lives under authenticatorSelection for creation),
+ // but tolerated as a top-level fallback for servers that place it here.
+ public string? UserVerification { get; set; }
+ }
+
+ // PublicKeyCredentialRequestOptions (server -> AssertAsync).
+ internal sealed class RequestOptions
+ {
+ public string? Challenge { get; set; }
+
+ public string? RpId { get; set; }
+
+ public string? UserVerification { get; set; }
+
+ public List? AllowCredentials { get; set; }
+
+ public int? Timeout { get; set; }
+
+ }
+
+ internal sealed class RelyingParty
+ {
+ public string? Id { get; set; }
+
+ public string? Name { get; set; }
+ }
+
+ internal sealed class UserEntity
+ {
+ public string? Id { get; set; }
+
+ public string? Name { get; set; }
+
+ public string? DisplayName { get; set; }
+ }
+
+ internal sealed class AuthenticatorSelection
+ {
+ public string? UserVerification { get; set; }
+
+ public string? AuthenticatorAttachment { get; set; }
+
+ public string? ResidentKey { get; set; }
+
+ public bool? RequireResidentKey { get; set; }
+ }
+
+ internal sealed class CredentialParameter
+ {
+ public int? Alg { get; set; }
+ }
+
+ internal sealed class CredentialDescriptor
+ {
+ public string? Id { get; set; }
+ }
+
+ // The client data hashed and signed by the authenticator (Windows builds this itself; on Apple the
+ // OS produces it).
+ internal sealed class ClientData
+ {
+ public string? Type { get; set; }
+
+ public string? Challenge { get; set; }
+
+ public string? Origin { get; set; }
+
+ public bool CrossOrigin { get; set; }
+ }
+
+ // The WebAuthn registration response (CreateAsync result) posted back to the RP server.
+ internal sealed class RegistrationResponse
+ {
+ public string? Id { get; set; }
+
+ public string? RawId { get; set; }
+
+ public string Type { get; set; } = "public-key";
+
+ public RegistrationResponseData? Response { get; set; }
+
+ public ClientExtensionOutputs ClientExtensionResults { get; set; } = new();
+ }
+
+ internal sealed class RegistrationResponseData
+ {
+ [JsonPropertyName("clientDataJSON")]
+ public string? ClientDataJson { get; set; }
+
+ public string? AttestationObject { get; set; }
+
+ public List? Transports { get; set; }
+ }
+
+ // The WebAuthn authentication response (AssertAsync result) posted back to the RP server.
+ internal sealed class AssertionResponse
+ {
+ public string? Id { get; set; }
+
+ public string? RawId { get; set; }
+
+ public string Type { get; set; } = "public-key";
+
+ public AssertionResponseData? Response { get; set; }
+
+ public ClientExtensionOutputs ClientExtensionResults { get; set; } = new();
+ }
+
+ internal sealed class AssertionResponseData
+ {
+ [JsonPropertyName("clientDataJSON")]
+ public string? ClientDataJson { get; set; }
+
+ public string? AuthenticatorData { get; set; }
+
+ public string? Signature { get; set; }
+
+ public string? UserHandle { get; set; }
+ }
+
+ // Serialized as an empty object ("clientExtensionResults": {}) on platforms that do not map extensions.
+ internal sealed class ClientExtensionOutputs
+ {
+ }
+
+ internal static byte[] DecodeRequired(string? value, string name)
+ {
+ if (string.IsNullOrEmpty(value))
+ throw new ArgumentException($"The options are missing the '{name}'.", "options");
+
+ return Decode(value, name);
+ }
+
+ internal static byte[] Decode(string value, string name)
+ {
+ try
+ {
+ return Base64Url.DecodeFromChars(value);
+ }
+ catch (FormatException ex)
+ {
+ throw new ArgumentException($"The options contain an invalid '{name}' Base64Url value.", "options", ex);
+ }
+ }
+
+ internal static uint GetTimeout(int? timeout)
+ {
+ var value = timeout ?? 60000;
+ if (value < 0)
+ throw new ArgumentOutOfRangeException("options", "The WebAuthn timeout cannot be negative.");
+
+ return (uint)value;
+ }
+
+ [JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonSerializable(typeof(CreationOptions))]
+ [JsonSerializable(typeof(RequestOptions))]
+ [JsonSerializable(typeof(ClientData))]
+ [JsonSerializable(typeof(RegistrationResponse))]
+ [JsonSerializable(typeof(AssertionResponse))]
+ internal partial class JsonContext : JsonSerializerContext
+ {
+ }
+
+ // Parses the WebAuthn response JSON so the shared response types can surface the credential id and
+ // user handle. Used only on platforms that build a response; the netstandard build has no passkeys.
+ internal static class ResponseReader
+ {
+ public static RegistrationResponse ReadRegistration(string json)
+ => Deserialize(json, JsonContext.Default.RegistrationResponse);
+
+ public static AssertionResponse ReadAssertion(string json)
+ => Deserialize(json, JsonContext.Default.AssertionResponse);
+
+ static T Deserialize(string json, JsonTypeInfo typeInfo)
+ where T : class
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(json, typeInfo)
+ ?? throw new InvalidOperationException("The response JSON was empty.");
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException("The response JSON could not be parsed.", ex);
+ }
+ }
+ }
+}
+#endif
diff --git a/src/Essentials/src/Passkeys/Passkeys.android.cs b/src/Essentials/src/Passkeys/Passkeys.android.cs
new file mode 100644
index 000000000000..5de02edc4d97
--- /dev/null
+++ b/src/Essentials/src/Passkeys/Passkeys.android.cs
@@ -0,0 +1,148 @@
+#nullable enable
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Android.OS;
+using Android.Runtime;
+using AndroidX.Core.Content;
+using AndroidX.Credentials;
+using Java.Util.Concurrent;
+using Microsoft.Maui.ApplicationModel;
+
+namespace Microsoft.Maui.Authentication;
+
+partial class PasskeysImplementation : IPasskeys
+{
+ // Passkey credentials are handled natively by the platform Credential Manager on Android 14+ (API 34).
+ public bool IsSupported => OperatingSystem.IsAndroidVersionAtLeast(34);
+
+ public async Task CreateAsync(PasskeyCreationOptions options, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ EnsureSupported();
+
+ var activity = Platform.CurrentActivity
+ ?? throw new InvalidOperationException("Passkeys require a current Activity.");
+
+ var manager = CredentialManager.Create(activity);
+ var executor = ContextCompat.GetMainExecutor(activity)
+ ?? throw new InvalidOperationException("Unable to acquire the Android main-thread executor.");
+ var request = new CreatePublicKeyCredentialRequest(
+ options.ToString(),
+ clientDataHash: null,
+ preferImmediatelyAvailableCredentials: options.PreferImmediatelyAvailable);
+
+ var result = await InvokeAsync(
+ (signal, executor, callback) => manager.CreateCredentialAsync(activity, request, signal, executor, callback),
+ executor,
+ cancellationToken);
+
+ var response = result.JavaCast()
+ ?? throw new InvalidOperationException("The credential provider did not return a passkey registration response.");
+
+ return new PasskeyCreationResponse(response.RegistrationResponseJson!);
+ }
+
+ public async Task AssertAsync(PasskeyRequestOptions options, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ EnsureSupported();
+
+ var activity = Platform.CurrentActivity
+ ?? throw new InvalidOperationException("Passkeys require a current Activity.");
+
+ var manager = CredentialManager.Create(activity);
+ var executor = ContextCompat.GetMainExecutor(activity)
+ ?? throw new InvalidOperationException("Unable to acquire the Android main-thread executor.");
+ var option = new GetPublicKeyCredentialOption(options.ToString());
+ var request = new GetCredentialRequest.Builder()
+ .AddCredentialOption(option)
+ .SetPreferImmediatelyAvailableCredentials(options.PreferImmediatelyAvailable)
+ .Build();
+
+ var result = await InvokeAsync(
+ (signal, executor, callback) => manager.GetCredentialAsync(activity, request, signal, executor, callback),
+ executor,
+ cancellationToken);
+
+ var response = result.JavaCast()
+ ?? throw new InvalidOperationException("The credential provider did not return a sign-in response.");
+
+ var credential = response.Credential.JavaCast()
+ ?? throw new InvalidOperationException("The returned credential was not a passkey.");
+
+ return new PasskeyAssertionResponse(credential.AuthenticationResponseJson!);
+ }
+
+ void EnsureSupported()
+ {
+ if (!IsSupported)
+ throw new FeatureNotSupportedException("Passkeys require Android 14 (API 34) or later.");
+ }
+
+ static async Task InvokeAsync(
+ Action start,
+ IExecutor executor,
+ CancellationToken cancellationToken)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var signal = new CancellationSignal();
+ var callback = new CredentialManagerCallback(tcs);
+
+ using var registration = cancellationToken.Register(() =>
+ {
+ signal.Cancel();
+ tcs.TrySetCanceled(cancellationToken);
+ });
+
+ try
+ {
+ start(signal, executor, callback);
+ }
+ catch (Exception ex) when (IsCancellation(ex))
+ {
+ tcs.TrySetCanceled();
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetException(new InvalidOperationException(ex.Message, ex));
+ }
+
+ return await tcs.Task;
+ }
+
+ // A synchronous failure from the Credential Manager call is a .NET exception; the Java
+ // *CancellationException surfaces here as an OperationCanceledException or a type whose name
+ // contains "Cancellation". Fully qualify to avoid the Android.OS.OperationCanceledException clash.
+ static bool IsCancellation(Exception ex)
+ => ex is System.OperationCanceledException || IsCancellation(ex.GetType().Name);
+
+ static bool IsCancellation(string typeName)
+ => typeName.Contains("Cancellation", StringComparison.Ordinal);
+
+ sealed class CredentialManagerCallback : Java.Lang.Object, ICredentialManagerCallback
+ {
+ readonly TaskCompletionSource _tcs;
+
+ public CredentialManagerCallback(TaskCompletionSource tcs) => _tcs = tcs;
+
+ public void OnResult(Java.Lang.Object? result)
+ {
+ if (result is null)
+ _tcs.TrySetException(new InvalidOperationException("The credential provider returned no result."));
+ else
+ _tcs.TrySetResult(result);
+ }
+
+ public void OnError(Java.Lang.Object? e)
+ {
+ // e is a bound AndroidX.Credentials.Exceptions.* object (a Java throwable), not a .NET Exception.
+ var typeName = e?.GetType().Name ?? string.Empty;
+
+ if (IsCancellation(typeName))
+ _tcs.TrySetCanceled();
+ else
+ _tcs.TrySetException(new InvalidOperationException(e?.ToString() ?? "The passkey operation failed."));
+ }
+ }
+}
diff --git a/src/Essentials/src/Passkeys/Passkeys.ios.cs b/src/Essentials/src/Passkeys/Passkeys.ios.cs
new file mode 100644
index 000000000000..71b65b80576b
--- /dev/null
+++ b/src/Essentials/src/Passkeys/Passkeys.ios.cs
@@ -0,0 +1,243 @@
+#nullable enable
+using System;
+using System.Buffers.Text;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using AuthenticationServices;
+using Foundation;
+using Microsoft.Maui.ApplicationModel;
+using UIKit;
+
+namespace Microsoft.Maui.Authentication;
+
+partial class PasskeysImplementation : IPasskeys
+{
+ public bool IsSupported =>
+ OperatingSystem.IsIOSVersionAtLeast(16) ||
+ OperatingSystem.IsMacCatalystVersionAtLeast(16);
+
+ public async Task CreateAsync(PasskeyCreationOptions options, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ EnsureSupported();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var creation = Deserialize(options.ToString(), WebAuthn.JsonContext.Default.CreationOptions, "creation options");
+
+ var rpId = creation.Rp?.Id
+ ?? throw new ArgumentException("The creation options are missing the 'rp.id'.", nameof(options));
+ var challenge = WebAuthn.DecodeRequired(creation.Challenge, "challenge");
+ var user = creation.User
+ ?? throw new ArgumentException("The creation options are missing the 'user'.", nameof(options));
+ var userId = WebAuthn.DecodeRequired(user.Id, "user.id");
+ var userName = user.Name ?? string.Empty;
+
+ var provider = new ASAuthorizationPlatformPublicKeyCredentialProvider(rpId);
+ var request = provider.CreateCredentialRegistrationRequest(
+ NSData.FromArray(challenge),
+ userName,
+ NSData.FromArray(userId));
+ // Apple's native-app registration request has no excludeCredentials property; Apple only
+ // exposes that option on the separate web-browser registration request protocol.
+
+ var userVerification = MapUserVerification(creation.AuthenticatorSelection?.UserVerification ?? creation.UserVerification);
+ if (userVerification is not null)
+ request.UserVerificationPreference = userVerification;
+
+ var attestation = MapAttestation(creation.Attestation);
+ if (attestation is not null)
+ request.AttestationPreference = attestation;
+
+ var authorization = await PerformAsync(request, options.PreferImmediatelyAvailable, cancellationToken);
+
+ var registration = authorization.GetCredential();
+ if (registration is null)
+ throw new InvalidOperationException("The authenticator did not return a registration credential.");
+
+ var credentialId = registration.CredentialId?.ToArray() ?? Array.Empty();
+ var response = new WebAuthn.RegistrationResponse
+ {
+ Id = Base64Url.EncodeToString(credentialId),
+ RawId = Base64Url.EncodeToString(credentialId),
+ Response = new WebAuthn.RegistrationResponseData
+ {
+ ClientDataJson = Base64Url.EncodeToString(registration.RawClientDataJson?.ToArray() ?? Array.Empty()),
+ AttestationObject = Base64Url.EncodeToString(registration.RawAttestationObject?.ToArray() ?? Array.Empty()),
+ Transports = new List { "internal" },
+ },
+ };
+
+ return new PasskeyCreationResponse(JsonSerializer.Serialize(response, WebAuthn.JsonContext.Default.RegistrationResponse));
+ }
+
+ public async Task AssertAsync(PasskeyRequestOptions options, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ EnsureSupported();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var request = Deserialize(options.ToString(), WebAuthn.JsonContext.Default.RequestOptions, "request options");
+
+ var rpId = request.RpId
+ ?? throw new ArgumentException("The request options are missing the 'rpId'.", nameof(options));
+ var challenge = WebAuthn.DecodeRequired(request.Challenge, "challenge");
+
+ var provider = new ASAuthorizationPlatformPublicKeyCredentialProvider(rpId);
+ var assertionRequest = provider.CreateCredentialAssertionRequest(NSData.FromArray(challenge));
+
+ var userVerification = MapUserVerification(request.UserVerification);
+ if (userVerification is not null)
+ assertionRequest.UserVerificationPreference = userVerification;
+
+ var allowed = MapAllowedCredentials(request.AllowCredentials);
+ if (allowed is not null)
+ assertionRequest.AllowedCredentials = allowed;
+
+ var authorization = await PerformAsync(assertionRequest, options.PreferImmediatelyAvailable, cancellationToken);
+
+ var assertion = authorization.GetCredential();
+ if (assertion is null)
+ throw new InvalidOperationException("The authenticator did not return an assertion credential.");
+
+ var credentialId = assertion.CredentialId?.ToArray() ?? Array.Empty();
+ var userHandle = assertion.UserId?.ToArray();
+ var response = new WebAuthn.AssertionResponse
+ {
+ Id = Base64Url.EncodeToString(credentialId),
+ RawId = Base64Url.EncodeToString(credentialId),
+ Response = new WebAuthn.AssertionResponseData
+ {
+ ClientDataJson = Base64Url.EncodeToString(assertion.RawClientDataJson?.ToArray() ?? Array.Empty()),
+ AuthenticatorData = Base64Url.EncodeToString(assertion.RawAuthenticatorData?.ToArray() ?? Array.Empty()),
+ Signature = Base64Url.EncodeToString(assertion.Signature?.ToArray() ?? Array.Empty()),
+ UserHandle = userHandle is { Length: > 0 } ? Base64Url.EncodeToString(userHandle) : null,
+ },
+ };
+
+ return new PasskeyAssertionResponse(JsonSerializer.Serialize(response, WebAuthn.JsonContext.Default.AssertionResponse));
+ }
+
+ void EnsureSupported()
+ {
+ if (!IsSupported)
+ throw new FeatureNotSupportedException("Passkeys require iOS 16.0 or Mac Catalyst 16.0 or later.");
+ }
+
+ static T Deserialize(string json, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, string what)
+ where T : class
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(json, typeInfo)
+ ?? throw new ArgumentException($"The {what} JSON was empty.", "options");
+ }
+ catch (JsonException ex)
+ {
+ throw new ArgumentException($"The {what} JSON could not be parsed.", "options", ex);
+ }
+ }
+
+ static NSString? MapUserVerification(string? value) => value switch
+ {
+ null => null,
+ "required" => ASAuthorizationPublicKeyCredentialUserVerificationPreference.Required,
+ "discouraged" => ASAuthorizationPublicKeyCredentialUserVerificationPreference.Discouraged,
+ _ => ASAuthorizationPublicKeyCredentialUserVerificationPreference.Preferred,
+ };
+
+ static NSString? MapAttestation(string? value) => value switch
+ {
+ null => null,
+ "direct" => ASAuthorizationPublicKeyCredentialAttestationKind.Direct,
+ "indirect" => ASAuthorizationPublicKeyCredentialAttestationKind.Indirect,
+ "enterprise" => ASAuthorizationPublicKeyCredentialAttestationKind.Enterprise,
+ _ => ASAuthorizationPublicKeyCredentialAttestationKind.None,
+ };
+
+ static ASAuthorizationPlatformPublicKeyCredentialDescriptor[]? MapAllowedCredentials(List? credentials)
+ {
+ if (credentials is null || credentials.Count == 0)
+ return null;
+
+ var list = new List();
+ foreach (var credential in credentials)
+ {
+ if (!string.IsNullOrEmpty(credential.Id))
+ list.Add(new ASAuthorizationPlatformPublicKeyCredentialDescriptor(NSData.FromArray(WebAuthn.Decode(credential.Id, "credential.id"))));
+ }
+
+ return list.Count == 0 ? null : list.ToArray();
+ }
+
+ static async Task PerformAsync(ASAuthorizationRequest request, bool preferImmediatelyAvailable, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var manager = new PasskeyAuthorizationManager(WindowStateManager.Default.GetCurrentUIWindow(true)!);
+ var controller = new ASAuthorizationController(new[] { request })
+ {
+ Delegate = manager,
+ PresentationContextProvider = manager,
+ };
+ var gate = new object();
+ var started = false;
+
+ using (cancellationToken.Register(() =>
+ {
+ lock (gate)
+ {
+ if (started)
+ controller.Cancel();
+
+ manager.TrySetCanceled();
+ }
+ }))
+ {
+ lock (gate)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (preferImmediatelyAvailable)
+ controller.PerformRequests(ASAuthorizationControllerRequestOptions.ImmediatelyAvailableCredentials);
+ else
+ controller.PerformRequests();
+
+ started = true;
+ }
+
+ return await manager.Task;
+ }
+ }
+
+ [System.Runtime.Versioning.SupportedOSPlatform("ios16.0")]
+ [System.Runtime.Versioning.SupportedOSPlatform("maccatalyst16.0")]
+ sealed class PasskeyAuthorizationManager : NSObject, IASAuthorizationControllerDelegate, IASAuthorizationControllerPresentationContextProviding
+ {
+ readonly TaskCompletionSource _tcs = new();
+ readonly UIWindow _anchor;
+
+ public PasskeyAuthorizationManager(UIWindow anchor) => _anchor = anchor;
+
+ public Task Task => _tcs.Task;
+
+ public void TrySetCanceled() => _tcs.TrySetCanceled();
+
+ public UIWindow GetPresentationAnchor(ASAuthorizationController controller) => _anchor;
+
+ [Export("authorizationController:didCompleteWithAuthorization:")]
+ public void DidComplete(ASAuthorizationController controller, ASAuthorization authorization)
+ => _tcs.TrySetResult(authorization);
+
+ [Export("authorizationController:didCompleteWithError:")]
+ public void DidComplete(ASAuthorizationController controller, NSError error)
+ {
+ // ASAuthorizationError.Canceled == 1001 (user dismissed the sheet or the request was canceled).
+ if (error.Code == 1001)
+ _tcs.TrySetCanceled();
+ else
+ _tcs.TrySetException(new InvalidOperationException(error.LocalizedDescription));
+ }
+ }
+}
diff --git a/src/Essentials/src/Passkeys/Passkeys.netstandard.tvos.tizen.cs b/src/Essentials/src/Passkeys/Passkeys.netstandard.tvos.tizen.cs
new file mode 100644
index 000000000000..3348d8805248
--- /dev/null
+++ b/src/Essentials/src/Passkeys/Passkeys.netstandard.tvos.tizen.cs
@@ -0,0 +1,17 @@
+#nullable enable
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Maui.ApplicationModel;
+
+namespace Microsoft.Maui.Authentication;
+
+partial class PasskeysImplementation : IPasskeys
+{
+ public bool IsSupported => false;
+
+ public Task CreateAsync(PasskeyCreationOptions options, CancellationToken cancellationToken = default)
+ => throw ExceptionUtils.NotSupportedOrImplementedException;
+
+ public Task AssertAsync(PasskeyRequestOptions options, CancellationToken cancellationToken = default)
+ => throw ExceptionUtils.NotSupportedOrImplementedException;
+}
diff --git a/src/Essentials/src/Passkeys/Passkeys.shared.cs b/src/Essentials/src/Passkeys/Passkeys.shared.cs
new file mode 100644
index 000000000000..11edd539703f
--- /dev/null
+++ b/src/Essentials/src/Passkeys/Passkeys.shared.cs
@@ -0,0 +1,220 @@
+#nullable enable
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Maui.ApplicationModel;
+
+namespace Microsoft.Maui.Authentication;
+
+///
+/// Create and use passkeys (WebAuthn / FIDO2 public-key credentials) with the native platform
+/// authenticator (Face ID / Touch ID / Windows Hello / Android biometric).
+///
+///
+/// This API brokers the standard WebAuthn JSON between a relying-party (RP) server and the OS
+/// authenticator. The server produces the options JSON and verifies the response JSON; this API only
+/// drives the native UI. It does not perform any server-side verification, attestation validation, or
+/// challenge generation.
+///
+public interface IPasskeys
+{
+ ///
+ /// Gets a value indicating whether this platform (and OS version) can create and use passkeys.
+ ///
+ bool IsSupported { get; }
+
+ ///
+ /// Registers a new passkey by driving the native "create credential" UI.
+ ///
+ /// The relying party's PublicKeyCredentialCreationOptions (server-provided).
+ /// A to cancel the operation.
+ /// The WebAuthn registration response to send back to the RP server.
+ /// Thrown when passkeys are not supported on this platform or OS version.
+ /// Thrown when the creation options JSON is malformed or missing required fields.
+ /// Thrown when the user cancels the flow or the operation is canceled.
+ /// Thrown when the ceremony fails (e.g. misconfigured domain association or a platform error).
+ Task CreateAsync(PasskeyCreationOptions options, CancellationToken cancellationToken = default);
+
+ ///
+ /// Authenticates with an existing passkey by driving the native "get credential" UI.
+ ///
+ /// The relying party's PublicKeyCredentialRequestOptions (server-provided).
+ /// A to cancel the operation.
+ /// The WebAuthn assertion response to send back to the RP server.
+ /// Thrown when passkeys are not supported on this platform or OS version.
+ /// Thrown when the request options JSON is malformed or missing required fields.
+ /// Thrown when the user cancels the flow or the operation is canceled.
+ /// Thrown when the ceremony fails (e.g. no matching credential or a platform error).
+ Task AssertAsync(PasskeyRequestOptions options, CancellationToken cancellationToken = default);
+}
+
+///
+/// Represents the relying party's PublicKeyCredentialCreationOptions for .
+///
+public sealed class PasskeyCreationOptions
+{
+ readonly string _json;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The server's PublicKeyCredentialCreationOptions JSON.
+ public PasskeyCreationOptions(string creationOptionsJson) =>
+ _json = creationOptionsJson ?? throw new ArgumentNullException(nameof(creationOptionsJson));
+
+ ///
+ /// Gets or sets a value indicating whether the ceremony should stay 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 the
+ /// Android and Apple immediately-available options; ignored on Windows. This is an app-side behavior
+ /// knob and is not part of the server JSON.
+ ///
+ public bool PreferImmediatelyAvailable { get; set; }
+
+ /// Returns the underlying PublicKeyCredentialCreationOptions JSON.
+ public override string ToString() => _json;
+}
+
+///
+/// Represents the relying party's PublicKeyCredentialRequestOptions for .
+///
+public sealed class PasskeyRequestOptions
+{
+ readonly string _json;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The server's PublicKeyCredentialRequestOptions JSON.
+ public PasskeyRequestOptions(string requestOptionsJson) =>
+ _json = requestOptionsJson ?? throw new ArgumentNullException(nameof(requestOptionsJson));
+
+ ///
+ public bool PreferImmediatelyAvailable { get; set; }
+
+ /// Returns the underlying PublicKeyCredentialRequestOptions JSON.
+ public override string ToString() => _json;
+}
+
+///
+/// Represents the result of a passkey registration. Call to get the full WebAuthn
+/// registration response JSON to POST back to the relying-party server.
+///
+public sealed class PasskeyCreationResponse
+{
+ readonly string _json;
+ readonly string _id;
+
+ internal PasskeyCreationResponse(string registrationResponseJson)
+ {
+ _json = registrationResponseJson ?? throw new ArgumentNullException(nameof(registrationResponseJson));
+#if NETSTANDARD
+ // Passkeys are not supported on this target; a response is never constructed here at runtime.
+ _id = string.Empty;
+#else
+ _id = WebAuthn.ResponseReader.ReadRegistration(_json).Id
+ ?? throw new InvalidOperationException("The registration response JSON did not contain a credential 'id'.");
+#endif
+ }
+
+ ///
+ /// Gets the credential id (base64url) of the created passkey, i.e. the WebAuthn
+ /// PublicKeyCredential.id. This is the single, primary identifier of the passkey; store it to
+ /// look the credential up later.
+ ///
+ public string Id => _id;
+
+ /// Returns the full WebAuthn registration response JSON.
+ public override string ToString() => _json;
+}
+
+///
+/// Represents the result of a passkey authentication. Call to get the full WebAuthn
+/// authentication response JSON to POST back to the relying-party server.
+///
+public sealed class PasskeyAssertionResponse
+{
+ readonly string _json;
+ readonly string _id;
+ readonly string? _userHandle;
+
+ internal PasskeyAssertionResponse(string authenticationResponseJson)
+ {
+ _json = authenticationResponseJson ?? throw new ArgumentNullException(nameof(authenticationResponseJson));
+#if NETSTANDARD
+ // Passkeys are not supported on this target; a response is never constructed here at runtime.
+ _id = string.Empty;
+ _userHandle = null;
+#else
+ var parsed = WebAuthn.ResponseReader.ReadAssertion(_json);
+ _id = parsed.Id
+ ?? throw new InvalidOperationException("The authentication response JSON did not contain a credential 'id'.");
+ _userHandle = parsed.Response?.UserHandle;
+#endif
+ }
+
+ ///
+ /// Gets the credential id (base64url) of the passkey used, i.e. the WebAuthn
+ /// PublicKeyCredential.id.
+ ///
+ public string Id => _id;
+
+ ///
+ /// Gets the user handle (base64url) the relying party 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 => _userHandle;
+
+ /// Returns the full WebAuthn authentication response JSON.
+ public override string ToString() => _json;
+}
+
+///
+/// Create and use passkeys (WebAuthn / FIDO2 public-key credentials) with the native platform authenticator.
+///
+public static class Passkeys
+{
+ /// Gets a value indicating whether this platform (and OS version) can create and use passkeys.
+ public static bool IsSupported => Default.IsSupported;
+
+ ///
+ public static Task CreateAsync(PasskeyCreationOptions options, CancellationToken cancellationToken = default)
+ => Default.CreateAsync(options, cancellationToken);
+
+ ///
+ /// Registers a new passkey from the server's PublicKeyCredentialCreationOptions JSON.
+ ///
+ /// The server's PublicKeyCredentialCreationOptions JSON.
+ /// A to cancel the operation.
+ /// The WebAuthn registration response to send back to the RP server.
+ public static Task CreateAsync(string creationOptionsJson, CancellationToken cancellationToken = default)
+ => Default.CreateAsync(new PasskeyCreationOptions(creationOptionsJson), cancellationToken);
+
+ ///
+ public static Task AssertAsync(PasskeyRequestOptions options, CancellationToken cancellationToken = default)
+ => Default.AssertAsync(options, cancellationToken);
+
+ ///
+ /// Authenticates with an existing passkey from the server's PublicKeyCredentialRequestOptions JSON.
+ ///
+ /// The server's PublicKeyCredentialRequestOptions JSON.
+ /// A to cancel the operation.
+ /// The WebAuthn assertion response to send back to the RP server.
+ public static Task AssertAsync(string requestOptionsJson, CancellationToken cancellationToken = default)
+ => Default.AssertAsync(new PasskeyRequestOptions(requestOptionsJson), cancellationToken);
+
+ static IPasskeys? defaultImplementation;
+
+ ///
+ /// Provides the default implementation for static usage of this API.
+ ///
+ public static IPasskeys Default =>
+ defaultImplementation ??= new PasskeysImplementation();
+
+ internal static void SetDefault(IPasskeys? implementation) =>
+ defaultImplementation = implementation;
+}
diff --git a/src/Essentials/src/Passkeys/Passkeys.windows.cs b/src/Essentials/src/Passkeys/Passkeys.windows.cs
new file mode 100644
index 000000000000..9ed24151a8cb
--- /dev/null
+++ b/src/Essentials/src/Passkeys/Passkeys.windows.cs
@@ -0,0 +1,262 @@
+#nullable enable
+using System;
+using System.Buffers.Text;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Maui.ApplicationModel;
+
+namespace Microsoft.Maui.Authentication;
+
+partial class PasskeysImplementation : IPasskeys
+{
+ // webauthn.dll ships in-box on Windows 10 1903+. Detect the native capability directly and
+ // gate newer WebAuthn fields by the API version reported by Windows.
+ public bool IsSupported => WindowsWebAuthn.IsAvailable;
+
+ public async Task CreateAsync(PasskeyCreationOptions options, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ EnsureSupported();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Resolve the owner HWND on the caller's UI thread, then move only the blocking native
+ // ceremony off the UI thread so WinUI can process activation and z-order changes.
+ var hwnd = GetHwnd();
+ return await Task.Run(() => MakeCredential(hwnd, options, cancellationToken), cancellationToken);
+ }
+
+ public async Task AssertAsync(PasskeyRequestOptions options, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ EnsureSupported();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var hwnd = GetHwnd();
+ return await Task.Run(() => GetAssertion(hwnd, options, cancellationToken), cancellationToken);
+ }
+
+ void EnsureSupported()
+ {
+ if (!IsSupported)
+ throw new FeatureNotSupportedException("Passkeys require the Windows WebAuthn API (available in Windows 10 version 1903 and later).");
+ }
+
+ static IntPtr GetHwnd()
+ {
+ return WindowStateManager.Default.GetActiveWindowHandle(true);
+ }
+
+ static PasskeyCreationResponse MakeCredential(IntPtr hwnd, PasskeyCreationOptions options, CancellationToken cancellationToken)
+ {
+ var creation = Deserialize(options.ToString(), WebAuthn.JsonContext.Default.CreationOptions, "creation options");
+
+ var rpId = creation.Rp?.Id
+ ?? throw new ArgumentException("The creation options are missing the 'rp.id'.", nameof(options));
+ var rpName = creation.Rp?.Name ?? rpId;
+
+ var user = creation.User
+ ?? throw new ArgumentException("The creation options are missing the 'user'.", nameof(options));
+ var userId = WebAuthn.DecodeRequired(user.Id, "user.id");
+ var userName = user.Name ?? string.Empty;
+ var userDisplayName = user.DisplayName ?? userName;
+
+ var challenge = WebAuthn.DecodeRequired(creation.Challenge, "challenge");
+ var clientDataJson = BuildClientDataJson("webauthn.create", challenge, rpId);
+ var residentKey = MapResidentKey(creation.AuthenticatorSelection);
+
+ var result = WindowsWebAuthn.MakeCredential(
+ new WindowsWebAuthn.MakeCredentialRequest
+ {
+ WindowHandle = hwnd,
+ RelyingPartyId = rpId,
+ RelyingPartyName = rpName,
+ UserId = userId,
+ UserName = userName,
+ UserDisplayName = userDisplayName,
+ Algorithms = MapCoseParameters(creation.PubKeyCredParams),
+ ClientDataJson = clientDataJson,
+ Timeout = WebAuthn.GetTimeout(creation.Timeout),
+ UserVerification = MapUserVerification(creation.AuthenticatorSelection?.UserVerification ?? creation.UserVerification),
+ AuthenticatorAttachment = MapAuthenticatorAttachment(creation.AuthenticatorSelection?.AuthenticatorAttachment),
+ Attestation = MapAttestation(creation.Attestation),
+ ResidentKey = residentKey,
+ ExcludeCredentials = MapCredentialIds(creation.ExcludeCredentials),
+ OptionsJson = Encoding.UTF8.GetBytes(options.ToString()),
+ },
+ cancellationToken);
+
+ if (result.ResponseJson.Length > 0)
+ return new PasskeyCreationResponse(Encoding.UTF8.GetString(result.ResponseJson));
+
+ var json = BuildRegistrationResponseJson(
+ result.CredentialId,
+ result.AttestationObject,
+ clientDataJson);
+ return new PasskeyCreationResponse(json);
+ }
+
+ static PasskeyAssertionResponse GetAssertion(IntPtr hwnd, PasskeyRequestOptions options, CancellationToken cancellationToken)
+ {
+ var request = Deserialize(options.ToString(), WebAuthn.JsonContext.Default.RequestOptions, "request options");
+
+ var rpId = request.RpId
+ ?? throw new ArgumentException("The request options are missing the 'rpId'.", nameof(options));
+ var challenge = WebAuthn.DecodeRequired(request.Challenge, "challenge");
+ var clientDataJson = BuildClientDataJson("webauthn.get", challenge, rpId);
+
+ var result = WindowsWebAuthn.GetAssertion(
+ new WindowsWebAuthn.GetAssertionRequest
+ {
+ WindowHandle = hwnd,
+ RelyingPartyId = rpId,
+ ClientDataJson = clientDataJson,
+ Timeout = WebAuthn.GetTimeout(request.Timeout),
+ UserVerification = MapUserVerification(request.UserVerification),
+ AllowCredentials = MapCredentialIds(request.AllowCredentials),
+ OptionsJson = Encoding.UTF8.GetBytes(options.ToString()),
+ },
+ cancellationToken);
+
+ if (result.ResponseJson.Length > 0)
+ return new PasskeyAssertionResponse(Encoding.UTF8.GetString(result.ResponseJson));
+
+ var json = BuildAssertionResponseJson(
+ result.CredentialId,
+ result.AuthenticatorData,
+ result.Signature,
+ clientDataJson,
+ result.UserHandle);
+ return new PasskeyAssertionResponse(json);
+ }
+
+ static T Deserialize(string json, JsonTypeInfo typeInfo, string what)
+ where T : class
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(json, typeInfo)
+ ?? throw new ArgumentException($"The {what} JSON was empty.", "options");
+ }
+ catch (JsonException ex)
+ {
+ throw new ArgumentException($"The {what} JSON could not be parsed.", "options", ex);
+ }
+ }
+
+ static byte[][] MapCredentialIds(List? credentials)
+ {
+ if (credentials is null || credentials.Count == 0)
+ return Array.Empty();
+
+ var list = new List();
+ foreach (var credential in credentials)
+ {
+ if (!string.IsNullOrEmpty(credential.Id))
+ list.Add(WebAuthn.Decode(credential.Id, "credential.id"));
+ }
+
+ return list.ToArray();
+ }
+
+ static byte[] BuildClientDataJson(string type, byte[] challenge, string rpId)
+ {
+ var clientData = new WebAuthn.ClientData
+ {
+ Type = type,
+ Challenge = Base64Url.EncodeToString(challenge),
+ Origin = $"https://{rpId}",
+ CrossOrigin = false,
+ };
+
+ return JsonSerializer.SerializeToUtf8Bytes(clientData, WebAuthn.JsonContext.Default.ClientData);
+ }
+
+ static string BuildRegistrationResponseJson(byte[] credentialId, byte[] attestationObject, byte[] clientDataJson)
+ {
+ var response = new WebAuthn.RegistrationResponse
+ {
+ Id = Base64Url.EncodeToString(credentialId),
+ RawId = Base64Url.EncodeToString(credentialId),
+ Response = new WebAuthn.RegistrationResponseData
+ {
+ ClientDataJson = Base64Url.EncodeToString(clientDataJson),
+ AttestationObject = Base64Url.EncodeToString(attestationObject),
+ },
+ };
+
+ return JsonSerializer.Serialize(response, WebAuthn.JsonContext.Default.RegistrationResponse);
+ }
+
+ static string BuildAssertionResponseJson(byte[] credentialId, byte[] authenticatorData, byte[] signature, byte[] clientDataJson, byte[] userHandle)
+ {
+ var response = new WebAuthn.AssertionResponse
+ {
+ Id = Base64Url.EncodeToString(credentialId),
+ RawId = Base64Url.EncodeToString(credentialId),
+ Response = new WebAuthn.AssertionResponseData
+ {
+ ClientDataJson = Base64Url.EncodeToString(clientDataJson),
+ AuthenticatorData = Base64Url.EncodeToString(authenticatorData),
+ Signature = Base64Url.EncodeToString(signature),
+ UserHandle = userHandle.Length > 0 ? Base64Url.EncodeToString(userHandle) : null,
+ },
+ };
+
+ return JsonSerializer.Serialize(response, WebAuthn.JsonContext.Default.AssertionResponse);
+ }
+
+ static int[] MapCoseParameters(List? pubKeyCredParams)
+ {
+ if (pubKeyCredParams is { Count: > 0 })
+ {
+ var list = new List();
+ foreach (var param in pubKeyCredParams)
+ {
+ if (param.Alg is int alg)
+ list.Add(alg);
+ }
+
+ if (list.Count > 0)
+ return list.ToArray();
+ }
+
+ // Default to ES256 + RS256.
+ return new[] { -7, -257 };
+ }
+
+ static WindowsWebAuthn.UserVerificationRequirement MapUserVerification(string? value) => value switch
+ {
+ "required" => WindowsWebAuthn.UserVerificationRequirement.Required,
+ "discouraged" => WindowsWebAuthn.UserVerificationRequirement.Discouraged,
+ "preferred" => WindowsWebAuthn.UserVerificationRequirement.Preferred,
+ _ => WindowsWebAuthn.UserVerificationRequirement.Any,
+ };
+
+ static WindowsWebAuthn.AuthenticatorAttachment MapAuthenticatorAttachment(string? value) => value switch
+ {
+ "platform" => WindowsWebAuthn.AuthenticatorAttachment.Platform,
+ "cross-platform" => WindowsWebAuthn.AuthenticatorAttachment.CrossPlatform,
+ _ => WindowsWebAuthn.AuthenticatorAttachment.Any,
+ };
+
+ static WindowsWebAuthn.AttestationConveyancePreference MapAttestation(string? value) => value switch
+ {
+ "direct" => WindowsWebAuthn.AttestationConveyancePreference.Direct,
+ "indirect" => WindowsWebAuthn.AttestationConveyancePreference.Indirect,
+ "none" => WindowsWebAuthn.AttestationConveyancePreference.None,
+ _ => WindowsWebAuthn.AttestationConveyancePreference.Any,
+ };
+
+ internal static WindowsWebAuthn.ResidentKeyOptions MapResidentKey(WebAuthn.AuthenticatorSelection? selection) =>
+ selection?.ResidentKey switch
+ {
+ "required" => new(Require: true, Prefer: false),
+ "preferred" => new(Require: false, Prefer: true),
+ "discouraged" => new(Require: false, Prefer: false),
+ _ => new(Require: selection?.RequireResidentKey == true, Prefer: false),
+ };
+}
diff --git a/src/Essentials/src/Passkeys/WindowsWebAuthn.windows.cs b/src/Essentials/src/Passkeys/WindowsWebAuthn.windows.cs
new file mode 100644
index 000000000000..b7db4d5e98b6
--- /dev/null
+++ b/src/Essentials/src/Passkeys/WindowsWebAuthn.windows.cs
@@ -0,0 +1,461 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Maui.ApplicationModel;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.Security.Authentication.WebAuthn;
+
+namespace Microsoft.Maui.Authentication;
+
+internal static unsafe class WindowsWebAuthn
+{
+ // Local testing only: set to an older API version to exercise downlevel behavior, then rebuild.
+ // Zero uses the version reported by Windows. The override can never raise the native version.
+ const uint TestApiVersionOverride = 0;
+
+ internal enum AuthenticatorAttachment : uint
+ {
+ Any = 0,
+ Platform = 1,
+ CrossPlatform = 2,
+ }
+
+ internal enum UserVerificationRequirement : uint
+ {
+ Any = 0,
+ Required = 1,
+ Preferred = 2,
+ Discouraged = 3,
+ }
+
+ internal enum AttestationConveyancePreference : uint
+ {
+ Any = 0,
+ None = 1,
+ Indirect = 2,
+ Direct = 3,
+ }
+
+ internal readonly record struct ResidentKeyOptions(bool Require, bool Prefer);
+
+ internal sealed class MakeCredentialRequest
+ {
+ public required IntPtr WindowHandle { get; init; }
+ public required string RelyingPartyId { get; init; }
+ public required string RelyingPartyName { get; init; }
+ public required byte[] UserId { get; init; }
+ public required string UserName { get; init; }
+ public required string UserDisplayName { get; init; }
+ public required int[] Algorithms { get; init; }
+ public required byte[] ClientDataJson { get; init; }
+ public required uint Timeout { get; init; }
+ public required AuthenticatorAttachment AuthenticatorAttachment { get; init; }
+ public required ResidentKeyOptions ResidentKey { get; init; }
+ public required UserVerificationRequirement UserVerification { get; init; }
+ public required AttestationConveyancePreference Attestation { get; init; }
+ public required byte[][] ExcludeCredentials { get; init; }
+ public required byte[] OptionsJson { get; init; }
+ }
+
+ internal sealed class GetAssertionRequest
+ {
+ public required IntPtr WindowHandle { get; init; }
+ public required string RelyingPartyId { get; init; }
+ public required byte[] ClientDataJson { get; init; }
+ public required uint Timeout { get; init; }
+ public required UserVerificationRequirement UserVerification { get; init; }
+ public required byte[][] AllowCredentials { get; init; }
+ public required byte[] OptionsJson { get; init; }
+ }
+
+ internal readonly record struct CredentialAttestation(
+ byte[] CredentialId,
+ byte[] AttestationObject,
+ byte[] ResponseJson);
+
+ internal readonly record struct Assertion(
+ byte[] CredentialId,
+ byte[] AuthenticatorData,
+ byte[] Signature,
+ byte[] UserHandle,
+ byte[] ResponseJson);
+
+ internal static uint ApiVersion
+ {
+ get
+ {
+ try
+ {
+ return ApplyApiVersionOverride(
+ PInvoke.WebAuthNGetApiVersionNumber(),
+ TestApiVersionOverride);
+ }
+ catch (DllNotFoundException)
+ {
+ return 0;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return 0;
+ }
+ }
+ }
+
+ internal static bool IsAvailable => ApiVersion > 0;
+
+ internal static CredentialAttestation MakeCredential(MakeCredentialRequest request, CancellationToken cancellationToken)
+ {
+ var apiVersion = ApiVersion;
+
+ using var native = new NativeBuffers();
+ var cancellationId = GetCancellationId();
+
+ var rpInfo = new WEBAUTHN_RP_ENTITY_INFORMATION
+ {
+ dwVersion = Constants.RP_ENTITY_INFORMATION_VERSION,
+ pwszId = native.Pin(request.RelyingPartyId),
+ pwszName = native.Pin(request.RelyingPartyName),
+ pwszIcon = default,
+ };
+
+ var userInfo = new WEBAUTHN_USER_ENTITY_INFORMATION
+ {
+ dwVersion = Constants.USER_ENTITY_INFORMATION_VERSION,
+ cbId = (uint)request.UserId.Length,
+ pbId = native.Pin(request.UserId),
+ pwszName = native.Pin(request.UserName),
+ pwszIcon = default,
+ pwszDisplayName = native.Pin(request.UserDisplayName),
+ };
+
+ var clientData = new WEBAUTHN_CLIENT_DATA
+ {
+ dwVersion = Constants.CLIENT_DATA_VERSION,
+ cbClientDataJSON = (uint)request.ClientDataJson.Length,
+ pbClientDataJSON = native.Pin(request.ClientDataJson),
+ pwszHashAlgId = native.Pin("SHA-256"),
+ };
+
+ var coseParameters = native.PinCoseParameters(request.Algorithms);
+ var options = new WEBAUTHN_AUTHENTICATOR_MAKE_CREDENTIAL_OPTIONS
+ {
+ dwVersion = GetMakeCredentialOptionsVersion(apiVersion, request.ResidentKey.Prefer),
+ dwTimeoutMilliseconds = request.Timeout,
+ CredentialList = default,
+ Extensions = default,
+ dwAuthenticatorAttachment = (uint)request.AuthenticatorAttachment,
+ bRequireResidentKey = request.ResidentKey.Require,
+ dwUserVerificationRequirement = (uint)request.UserVerification,
+ dwAttestationConveyancePreference = (uint)request.Attestation,
+ dwFlags = 0,
+ pCancellationId = native.PinCancellationId(cancellationId),
+ pExcludeCredentialList = native.PinCredentialList(request.ExcludeCredentials),
+ bPreferResidentKey = apiVersion >= Constants.RESIDENT_KEY_PREFERENCE_API_VERSION && request.ResidentKey.Prefer,
+ cbPublicKeyCredentialCreationOptionsJSON = apiVersion >= Constants.FULL_JSON_API_VERSION
+ ? (uint)request.OptionsJson.Length
+ : 0,
+ pbPublicKeyCredentialCreationOptionsJSON = apiVersion >= Constants.FULL_JSON_API_VERSION
+ ? native.Pin(request.OptionsJson)
+ : null,
+ };
+
+ cancellationToken.ThrowIfCancellationRequested();
+ using var cancellationRegistration = RegisterCancellation(cancellationToken, cancellationId);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var hr = PInvoke.WebAuthNAuthenticatorMakeCredential(
+ (HWND)request.WindowHandle,
+ in rpInfo,
+ in userInfo,
+ in coseParameters,
+ in clientData,
+ options,
+ out var attestation);
+
+ ThrowIfFailed(hr, cancellationToken);
+ if (attestation is null)
+ throw new InvalidOperationException("Windows WebAuthn returned no credential attestation.");
+
+ try
+ {
+ var responseJson = attestation->dwVersion >= Constants.ATTESTATION_FULL_JSON_OUTPUT_VERSION
+ ? ReadBytes(attestation->pbRegistrationResponseJSON, attestation->cbRegistrationResponseJSON)
+ : Array.Empty();
+
+ return new(
+ ReadBytes(attestation->pbCredentialId, attestation->cbCredentialId),
+ ReadBytes(attestation->pbAttestationObject, attestation->cbAttestationObject),
+ responseJson);
+ }
+ finally
+ {
+ PInvoke.WebAuthNFreeCredentialAttestation(attestation);
+ }
+ }
+
+ internal static Assertion GetAssertion(GetAssertionRequest request, CancellationToken cancellationToken)
+ {
+ var apiVersion = ApiVersion;
+ using var native = new NativeBuffers();
+ var cancellationId = GetCancellationId();
+
+ var clientData = new WEBAUTHN_CLIENT_DATA
+ {
+ dwVersion = Constants.CLIENT_DATA_VERSION,
+ cbClientDataJSON = (uint)request.ClientDataJson.Length,
+ pbClientDataJSON = native.Pin(request.ClientDataJson),
+ pwszHashAlgId = native.Pin("SHA-256"),
+ };
+
+ var options = new WEBAUTHN_AUTHENTICATOR_GET_ASSERTION_OPTIONS
+ {
+ dwVersion = apiVersion >= Constants.FULL_JSON_API_VERSION
+ ? Constants.GET_ASSERTION_FULL_JSON_OPTIONS_VERSION
+ : Constants.GET_ASSERTION_OPTIONS_VERSION,
+ dwTimeoutMilliseconds = request.Timeout,
+ CredentialList = default,
+ Extensions = default,
+ dwAuthenticatorAttachment = (uint)AuthenticatorAttachment.Any,
+ dwUserVerificationRequirement = (uint)request.UserVerification,
+ dwFlags = 0,
+ pwszU2fAppId = default,
+ pbU2fAppId = null,
+ pCancellationId = native.PinCancellationId(cancellationId),
+ pAllowCredentialList = native.PinCredentialList(request.AllowCredentials),
+ cbPublicKeyCredentialRequestOptionsJSON = apiVersion >= Constants.FULL_JSON_API_VERSION
+ ? (uint)request.OptionsJson.Length
+ : 0,
+ pbPublicKeyCredentialRequestOptionsJSON = apiVersion >= Constants.FULL_JSON_API_VERSION
+ ? native.Pin(request.OptionsJson)
+ : null,
+ };
+
+ cancellationToken.ThrowIfCancellationRequested();
+ using var cancellationRegistration = RegisterCancellation(cancellationToken, cancellationId);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var hr = PInvoke.WebAuthNAuthenticatorGetAssertion(
+ (HWND)request.WindowHandle,
+ request.RelyingPartyId,
+ in clientData,
+ options,
+ out var assertion);
+
+ ThrowIfFailed(hr, cancellationToken);
+ if (assertion is null)
+ throw new InvalidOperationException("Windows WebAuthn returned no assertion.");
+
+ try
+ {
+ var responseJson = assertion->dwVersion >= Constants.ASSERTION_FULL_JSON_OUTPUT_VERSION
+ ? ReadBytes(assertion->pbAuthenticationResponseJSON, assertion->cbAuthenticationResponseJSON)
+ : Array.Empty();
+
+ return new(
+ ReadBytes(assertion->Credential.pbId, assertion->Credential.cbId),
+ ReadBytes(assertion->pbAuthenticatorData, assertion->cbAuthenticatorData),
+ ReadBytes(assertion->pbSignature, assertion->cbSignature),
+ ReadBytes(assertion->pbUserId, assertion->cbUserId),
+ responseJson);
+ }
+ finally
+ {
+ PInvoke.WebAuthNFreeAssertion(assertion);
+ }
+ }
+
+ internal static uint ApplyApiVersionOverride(uint nativeVersion, uint overrideVersion) =>
+ overrideVersion == 0 ? nativeVersion : Math.Min(nativeVersion, overrideVersion);
+
+ internal static uint GetMakeCredentialOptionsVersion(uint apiVersion, bool preferResidentKey)
+ {
+ if (apiVersion >= Constants.FULL_JSON_API_VERSION)
+ return Constants.MAKE_CREDENTIAL_FULL_JSON_OPTIONS_VERSION;
+
+ if (preferResidentKey && apiVersion >= Constants.RESIDENT_KEY_PREFERENCE_API_VERSION)
+ return Constants.MAKE_CREDENTIAL_RESIDENT_KEY_OPTIONS_VERSION;
+
+ return Constants.MAKE_CREDENTIAL_OPTIONS_VERSION;
+ }
+
+ static Guid GetCancellationId() =>
+ // If Windows cannot allocate an ID, the ceremony can still run, but mid-flight cancellation
+ // cannot be forwarded to the native modal operation.
+ PInvoke.WebAuthNGetCancellationId(out var id) == 0 ? id : Guid.Empty;
+
+ static CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, Guid id)
+ {
+ if (!cancellationToken.CanBeCanceled || id == Guid.Empty)
+ return default;
+
+ return cancellationToken.Register(() =>
+ {
+ try
+ {
+ PInvoke.WebAuthNCancelCurrentOperation(in id);
+ }
+ catch (DllNotFoundException)
+ {
+ }
+ catch (EntryPointNotFoundException)
+ {
+ }
+ });
+ }
+
+ static void ThrowIfFailed(HRESULT hr, CancellationToken cancellationToken)
+ {
+ if (hr.Succeeded)
+ return;
+
+ if (cancellationToken.IsCancellationRequested ||
+ (uint)hr is 0x80090036 or 0x800704C7)
+ {
+ throw new TaskCanceledException();
+ }
+
+ var message = PInvoke.WebAuthNGetErrorName(hr).ToString() ?? "Unknown error";
+ throw new InvalidOperationException($"WebAuthn operation failed ({hr}): {message}");
+ }
+
+ static byte[] ReadBytes(byte* ptr, uint length) =>
+ ptr is null || length == 0
+ ? Array.Empty()
+ : new ReadOnlySpan(ptr, checked((int)length)).ToArray();
+
+ static class Constants
+ {
+ public const uint RP_ENTITY_INFORMATION_VERSION = 1;
+ public const uint USER_ENTITY_INFORMATION_VERSION = 1;
+ public const uint CLIENT_DATA_VERSION = 1;
+ public const uint COSE_CREDENTIAL_PARAMETER_VERSION = 1;
+ public const uint CREDENTIAL_EX_VERSION = 1;
+ public const uint MAKE_CREDENTIAL_OPTIONS_VERSION = 3;
+ public const uint MAKE_CREDENTIAL_RESIDENT_KEY_OPTIONS_VERSION = 4;
+ public const uint RESIDENT_KEY_PREFERENCE_API_VERSION = 3;
+ public const uint GET_ASSERTION_OPTIONS_VERSION = 4;
+ public const uint FULL_JSON_API_VERSION = 9;
+ public const uint MAKE_CREDENTIAL_FULL_JSON_OPTIONS_VERSION = 9;
+ public const uint GET_ASSERTION_FULL_JSON_OPTIONS_VERSION = 9;
+ public const uint ATTESTATION_FULL_JSON_OUTPUT_VERSION = 8;
+ public const uint ASSERTION_FULL_JSON_OUTPUT_VERSION = 6;
+ }
+
+ sealed class NativeBuffers : IDisposable
+ {
+ readonly List _allocations = new();
+
+ public byte* Pin(byte[] data)
+ {
+ if (data.Length == 0)
+ return null;
+
+ var ptr = Marshal.AllocHGlobal(data.Length);
+ Marshal.Copy(data, 0, ptr, data.Length);
+ _allocations.Add(ptr);
+ return (byte*)ptr;
+ }
+
+ public PCWSTR Pin(string? value)
+ {
+ if (value is null)
+ return default;
+
+ var ptr = Marshal.StringToHGlobalUni(value);
+ _allocations.Add(ptr);
+ return (char*)ptr;
+ }
+
+ public Guid* PinCancellationId(Guid id)
+ {
+ if (id == Guid.Empty)
+ return null;
+
+ var ptr = Allocate();
+ *ptr = id;
+ return ptr;
+ }
+
+ public WEBAUTHN_COSE_CREDENTIAL_PARAMETERS PinCoseParameters(int[] algorithms)
+ {
+ var parameters = AllocateArray(algorithms.Length);
+ var credentialType = Pin("public-key");
+
+ for (var i = 0; i < algorithms.Length; i++)
+ {
+ parameters[i] = new WEBAUTHN_COSE_CREDENTIAL_PARAMETER
+ {
+ dwVersion = Constants.COSE_CREDENTIAL_PARAMETER_VERSION,
+ pwszCredentialType = credentialType,
+ lAlg = algorithms[i],
+ };
+ }
+
+ return new()
+ {
+ cCredentialParameters = (uint)algorithms.Length,
+ pCredentialParameters = parameters,
+ };
+ }
+
+ public WEBAUTHN_CREDENTIAL_LIST* PinCredentialList(byte[][] credentialIds)
+ {
+ if (credentialIds.Length == 0)
+ return null;
+
+ var pointerArray = Marshal.AllocHGlobal(checked(IntPtr.Size * credentialIds.Length));
+ _allocations.Add(pointerArray);
+ var credentialPointers = (WEBAUTHN_CREDENTIAL_EX**)pointerArray;
+ var credentialType = Pin("public-key");
+
+ for (var i = 0; i < credentialIds.Length; i++)
+ {
+ var id = credentialIds[i];
+ var credential = Allocate();
+ *credential = new()
+ {
+ dwVersion = Constants.CREDENTIAL_EX_VERSION,
+ cbId = (uint)id.Length,
+ pbId = Pin(id),
+ pwszCredentialType = credentialType,
+ dwTransports = 0,
+ };
+ credentialPointers[i] = credential;
+ }
+
+ var list = Allocate();
+ *list = new()
+ {
+ cCredentials = (uint)credentialIds.Length,
+ ppCredentials = credentialPointers,
+ };
+ return list;
+ }
+
+ T* Allocate() where T : unmanaged
+ {
+ var ptr = Marshal.AllocHGlobal(sizeof(T));
+ _allocations.Add(ptr);
+ return (T*)ptr;
+ }
+
+ T* AllocateArray(int length) where T : unmanaged
+ {
+ var ptr = Marshal.AllocHGlobal(checked(sizeof(T) * length));
+ _allocations.Add(ptr);
+ return (T*)ptr;
+ }
+
+ public void Dispose()
+ {
+ for (var i = _allocations.Count - 1; i >= 0; i--)
+ Marshal.FreeHGlobal(_allocations[i]);
+
+ _allocations.Clear();
+ }
+ }
+}
diff --git a/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
index e3803621c946..be6c687b6efb 100644
--- a/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -35,3 +35,31 @@ Microsoft.Maui.Media.IViewScreenshot
Microsoft.Maui.Media.IViewScreenshot.CaptureViewAsync(object! platformView) -> System.Threading.Tasks.Task!
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
+Microsoft.Maui.Authentication.IPasskeys
+Microsoft.Maui.Authentication.IPasskeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.IsSupported.get -> bool
+Microsoft.Maui.Authentication.PasskeyAssertionResponse
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.UserHandle.get -> string?
+Microsoft.Maui.Authentication.PasskeyCreationOptions
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PasskeyCreationOptions(string! creationOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.PasskeyCreationResponse
+Microsoft.Maui.Authentication.PasskeyCreationResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyRequestOptions
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PasskeyRequestOptions(string! requestOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.Passkeys
+override Microsoft.Maui.Authentication.PasskeyAssertionResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationOptions.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyRequestOptions.ToString() -> string!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(string! requestOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
+static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
diff --git a/src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index deb297e3ad87..0c602d41c996 100644
--- a/src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -22,3 +22,31 @@ Microsoft.Maui.Media.IViewScreenshot
Microsoft.Maui.Media.IViewScreenshot.CaptureViewAsync(object! platformView) -> System.Threading.Tasks.Task!
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
+Microsoft.Maui.Authentication.IPasskeys
+Microsoft.Maui.Authentication.IPasskeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.IsSupported.get -> bool
+Microsoft.Maui.Authentication.PasskeyAssertionResponse
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.UserHandle.get -> string?
+Microsoft.Maui.Authentication.PasskeyCreationOptions
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PasskeyCreationOptions(string! creationOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.PasskeyCreationResponse
+Microsoft.Maui.Authentication.PasskeyCreationResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyRequestOptions
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PasskeyRequestOptions(string! requestOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.Passkeys
+override Microsoft.Maui.Authentication.PasskeyAssertionResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationOptions.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyRequestOptions.ToString() -> string!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(string! requestOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
+static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
diff --git a/src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
index deb297e3ad87..0c602d41c996 100644
--- a/src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -22,3 +22,31 @@ Microsoft.Maui.Media.IViewScreenshot
Microsoft.Maui.Media.IViewScreenshot.CaptureViewAsync(object! platformView) -> System.Threading.Tasks.Task!
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
+Microsoft.Maui.Authentication.IPasskeys
+Microsoft.Maui.Authentication.IPasskeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.IsSupported.get -> bool
+Microsoft.Maui.Authentication.PasskeyAssertionResponse
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.UserHandle.get -> string?
+Microsoft.Maui.Authentication.PasskeyCreationOptions
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PasskeyCreationOptions(string! creationOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.PasskeyCreationResponse
+Microsoft.Maui.Authentication.PasskeyCreationResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyRequestOptions
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PasskeyRequestOptions(string! requestOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.Passkeys
+override Microsoft.Maui.Authentication.PasskeyAssertionResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationOptions.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyRequestOptions.ToString() -> string!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(string! requestOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
+static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
diff --git a/src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
index f8d2061b2a7a..91a8a512e6f3 100644
--- a/src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
@@ -20,3 +20,31 @@ Microsoft.Maui.Media.IViewScreenshot
Microsoft.Maui.Media.IViewScreenshot.CaptureViewAsync(object! platformView) -> System.Threading.Tasks.Task!
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
+Microsoft.Maui.Authentication.IPasskeys
+Microsoft.Maui.Authentication.IPasskeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.IsSupported.get -> bool
+Microsoft.Maui.Authentication.PasskeyAssertionResponse
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.UserHandle.get -> string?
+Microsoft.Maui.Authentication.PasskeyCreationOptions
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PasskeyCreationOptions(string! creationOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.PasskeyCreationResponse
+Microsoft.Maui.Authentication.PasskeyCreationResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyRequestOptions
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PasskeyRequestOptions(string! requestOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.Passkeys
+override Microsoft.Maui.Authentication.PasskeyAssertionResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationOptions.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyRequestOptions.ToString() -> string!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(string! requestOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
+static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
diff --git a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
index 76dbfd28deb5..dfc952858323 100644
--- a/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -23,3 +23,31 @@ Microsoft.Maui.Media.IViewScreenshot
Microsoft.Maui.Media.IViewScreenshot.CaptureViewAsync(object! platformView) -> System.Threading.Tasks.Task!
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
+Microsoft.Maui.Authentication.IPasskeys
+Microsoft.Maui.Authentication.IPasskeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.IsSupported.get -> bool
+Microsoft.Maui.Authentication.PasskeyAssertionResponse
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.UserHandle.get -> string?
+Microsoft.Maui.Authentication.PasskeyCreationOptions
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PasskeyCreationOptions(string! creationOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.PasskeyCreationResponse
+Microsoft.Maui.Authentication.PasskeyCreationResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyRequestOptions
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PasskeyRequestOptions(string! requestOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.Passkeys
+override Microsoft.Maui.Authentication.PasskeyAssertionResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationOptions.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyRequestOptions.ToString() -> string!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(string! requestOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
+static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
diff --git a/src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt
index 5bc86ac733fe..00f4f11c7a00 100644
--- a/src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt
@@ -20,3 +20,31 @@ Microsoft.Maui.Media.IViewScreenshot
Microsoft.Maui.Media.IViewScreenshot.CaptureViewAsync(object! platformView) -> System.Threading.Tasks.Task!
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
+Microsoft.Maui.Authentication.IPasskeys
+Microsoft.Maui.Authentication.IPasskeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.IsSupported.get -> bool
+Microsoft.Maui.Authentication.PasskeyAssertionResponse
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.UserHandle.get -> string?
+Microsoft.Maui.Authentication.PasskeyCreationOptions
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PasskeyCreationOptions(string! creationOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.PasskeyCreationResponse
+Microsoft.Maui.Authentication.PasskeyCreationResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyRequestOptions
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PasskeyRequestOptions(string! requestOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.Passkeys
+override Microsoft.Maui.Authentication.PasskeyAssertionResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationOptions.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyRequestOptions.ToString() -> string!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(string! requestOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
+static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
diff --git a/src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
index 5bc86ac733fe..00f4f11c7a00 100644
--- a/src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
+++ b/src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt
@@ -20,3 +20,31 @@ Microsoft.Maui.Media.IViewScreenshot
Microsoft.Maui.Media.IViewScreenshot.CaptureViewAsync(object! platformView) -> System.Threading.Tasks.Task!
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.get -> bool
Microsoft.Maui.Media.MediaPickerOptions.SaveToGallery.set -> void
+Microsoft.Maui.Authentication.IPasskeys
+Microsoft.Maui.Authentication.IPasskeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Maui.Authentication.IPasskeys.IsSupported.get -> bool
+Microsoft.Maui.Authentication.PasskeyAssertionResponse
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyAssertionResponse.UserHandle.get -> string?
+Microsoft.Maui.Authentication.PasskeyCreationOptions
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PasskeyCreationOptions(string! creationOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyCreationOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.PasskeyCreationResponse
+Microsoft.Maui.Authentication.PasskeyCreationResponse.Id.get -> string!
+Microsoft.Maui.Authentication.PasskeyRequestOptions
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PasskeyRequestOptions(string! requestOptionsJson) -> void
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.get -> bool
+Microsoft.Maui.Authentication.PasskeyRequestOptions.PreferImmediatelyAvailable.set -> void
+Microsoft.Maui.Authentication.Passkeys
+override Microsoft.Maui.Authentication.PasskeyAssertionResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationOptions.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyCreationResponse.ToString() -> string!
+override Microsoft.Maui.Authentication.PasskeyRequestOptions.ToString() -> string!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(Microsoft.Maui.Authentication.PasskeyRequestOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.AssertAsync(string! requestOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authentication.PasskeyCreationOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
+static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
diff --git a/src/Essentials/test/DeviceTests/Tests/Windows/Passkeys_Windows_Tests.cs b/src/Essentials/test/DeviceTests/Tests/Windows/Passkeys_Windows_Tests.cs
new file mode 100644
index 000000000000..3b966c5b651a
--- /dev/null
+++ b/src/Essentials/test/DeviceTests/Tests/Windows/Passkeys_Windows_Tests.cs
@@ -0,0 +1,61 @@
+using Microsoft.Maui.Authentication;
+using Xunit;
+
+namespace Microsoft.Maui.Essentials.DeviceTests;
+
+[Category("Passkeys")]
+public class Passkeys_Windows_Tests
+{
+ [Theory]
+ [InlineData("required", null, true, false)]
+ [InlineData("preferred", true, false, true)]
+ [InlineData("discouraged", true, false, false)]
+ [InlineData(null, true, true, false)]
+ [InlineData(null, false, false, false)]
+ public void MapResidentKeyPreservesModernModes(
+ string residentKey,
+ bool? legacyRequireResidentKey,
+ bool expectedRequire,
+ bool expectedPrefer)
+ {
+ var selection = new WebAuthn.AuthenticatorSelection
+ {
+ ResidentKey = residentKey,
+ RequireResidentKey = legacyRequireResidentKey,
+ };
+
+ var actual = PasskeysImplementation.MapResidentKey(selection);
+
+ Assert.Equal(expectedRequire, actual.Require);
+ Assert.Equal(expectedPrefer, actual.Prefer);
+ }
+
+ [Theory]
+ [InlineData(2u, true, 3u)]
+ [InlineData(3u, true, 4u)]
+ [InlineData(3u, false, 3u)]
+ [InlineData(9u, false, 9u)]
+ [InlineData(9u, true, 9u)]
+ public void MakeCredentialOptionsVersionMatchesCapabilities(
+ uint apiVersion,
+ bool preferResidentKey,
+ uint expected)
+ {
+ var actual = WindowsWebAuthn.GetMakeCredentialOptionsVersion(
+ apiVersion,
+ preferResidentKey);
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Theory]
+ [InlineData(9u, 2u, 2u)]
+ [InlineData(2u, 9u, 2u)]
+ [InlineData(9u, 0u, 9u)]
+ public void ApiVersionOverrideCanOnlyLowerVersion(uint nativeVersion, uint value, uint expected)
+ {
+ var actual = WindowsWebAuthn.ApplyApiVersionOverride(nativeVersion, value);
+
+ Assert.Equal(expected, actual);
+ }
+}
diff --git a/src/Essentials/test/UnitTests/Passkeys_Tests.cs b/src/Essentials/test/UnitTests/Passkeys_Tests.cs
new file mode 100644
index 000000000000..6d43176639e2
--- /dev/null
+++ b/src/Essentials/test/UnitTests/Passkeys_Tests.cs
@@ -0,0 +1,71 @@
+using System;
+using Microsoft.Maui.Authentication;
+using Xunit;
+
+namespace Tests;
+
+public class Passkeys_Tests
+{
+ [Fact]
+ public void CreationResponse_Exposes_Id()
+ {
+ const string json = "{\"id\":\"cred-1\",\"rawId\":\"cred-1\",\"type\":\"public-key\"}";
+ var response = new PasskeyCreationResponse(json);
+ Assert.Equal("cred-1", response.Id);
+ Assert.Equal(json, response.ToString());
+ }
+
+ [Fact]
+ public void CreationResponse_Throws_When_Id_Missing()
+ {
+ const string json = "{\"rawId\":\"cred-1\",\"type\":\"public-key\"}";
+ Assert.Throws(() => new PasskeyCreationResponse(json));
+ }
+
+ [Fact]
+ public void AssertionResponse_Exposes_Id_And_UserHandle()
+ {
+ const string json = "{\"id\":\"cred-1\",\"response\":{\"userHandle\":\"dXNlcg\"}}";
+ var response = new PasskeyAssertionResponse(json);
+ Assert.Equal("cred-1", response.Id);
+ Assert.Equal("dXNlcg", response.UserHandle);
+ }
+
+ [Fact]
+ public void AssertionResponse_UserHandle_Null_When_Absent()
+ {
+ const string json = "{\"id\":\"cred-1\",\"response\":{\"signature\":\"MEU\"}}";
+ var response = new PasskeyAssertionResponse(json);
+ Assert.Null(response.UserHandle);
+ }
+
+ [Fact]
+ public void AssertionResponse_Throws_When_Id_Missing()
+ {
+ const string json = "{\"response\":{\"signature\":\"MEU\"}}";
+ Assert.Throws(() => new PasskeyAssertionResponse(json));
+ }
+
+ [Fact]
+ public void Options_ToString_Returns_Raw_Json()
+ {
+ const string json = "{\"challenge\":\"abc\"}";
+ Assert.Equal(json, new PasskeyCreationOptions(json).ToString());
+ Assert.Equal(json, new PasskeyRequestOptions(json).ToString());
+ }
+
+ [Fact]
+ public void GetTimeout_Rejects_Negative_Values()
+ {
+ Assert.Throws(() => WebAuthn.GetTimeout(-1));
+ Assert.Equal(60000u, WebAuthn.GetTimeout(null));
+ }
+
+ [Fact]
+ public void DecodeRequired_Rejects_Invalid_Base64Url_As_ArgumentException()
+ {
+ var exception = Assert.Throws(() => WebAuthn.DecodeRequired("***", "challenge"));
+ Assert.IsType(exception.InnerException);
+ }
+
+}