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.298 0.5.0 2.3.1 10.0.28000.2526 @@ -98,6 +99,8 @@ 11.0.0-rc.1.26379.102 11.0.0-rc.1.26379.102 11.0.0-rc.1.26379.102 + 11.0.0-rc.1.26379.102 + 11.0.0-rc.1.26379.102 10.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 @@ + + + + + + + + + + +