Passkeys (WebAuthn/FIDO2) Essentials API — spec + implementation - #36617
Passkeys (WebAuthn/FIDO2) Essentials API — spec + implementation#36617mattleibow wants to merge 50 commits into
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36617Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36617" |
|
Azure Pipelines: 1 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This pull request introduces a spec-first (docs-only) proposal for adding a new cross-platform Passkeys (WebAuthn/FIDO2) API to MAUI Essentials under Microsoft.Maui.Authentication, focusing on a small JSON-in/JSON-out contract that brokers WebAuthn options/responses between an app’s RP server and native platform authenticators.
Changes:
- Adds a new specification document describing the proposed public API surface (
Passkeys,IPasskeys, options/response types) and conventions (Default/SetDefault, IsSupported). - Documents per-platform implementation strategy for Android Credential Manager, Apple AuthenticationServices, and Windows
webauthn.dll, including capability/version gating. - Captures error handling, packaging/dependency impact, security considerations, and testing strategy, plus a list of open questions.
e0372ba to
54b1172
Compare
Rework the Essentials Passkeys sample into a single, state-driven page that
mirrors a real app's account flow instead of three always-visible step lists.
Server (Samples.WebServer):
- Add GET /passkeys/list returning { username, passkeyCount } for the
cookie-authenticated user so a native client can, after signing in, detect
whether it already has a passkey (WebAuthn offers no pre-login probe by
design — that would be account enumeration).
- Guard /passkeys/list and /passkeys/register/begin with declarative
.RequireAuthorization() instead of hand-rolled GetUserAsync checks.
- Translate the Identity application-cookie challenge into a clean 401 for
/passkeys/* via ConfigureApplicationCookie, so the native JSON client gets a
status code instead of a 302 redirect to an HTML login page.
Client (Essentials.Sample):
- Add real view-model state (IsSignedIn / CurrentUsername / HasPasskey /
PasskeyCount) and drive the whole page off it.
- Sign up now auto-signs-in (mirrors "create account -> logged in"); password
sign-in and sign-up detect passkeys via /passkeys/list and pop a one-time
"Set up a passkey?" prompt when the account has none.
- Single page with logged-out (email/password + username-less passkey sign-in)
and logged-in (account summary + create/sign-out) containers.
- Move the server URL to a "Server" toolbar button (input prompt) and reduce
the log to a single last-message strip at the bottom.
- Extend BaseViewModel/BasePage with confirm (Yes/No) and text-prompt plumbing
reusing the existing event pattern.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
Make the signed-in passkey experience match a real account-management screen: name a passkey when you create it, see all your passkeys, and remove one you no longer want. Server (Samples.WebServer, PasskeyApiEndpoints.cs): - GET /passkeys/list now also returns a passkeys[] array — each entry has the Base64Url credential id, a display name (explicit name, else the AAGUID-inferred authenticator, else "Unnamed passkey", via the template's PasskeyAuthenticators.GetDisplayName), and createdAt. - POST /passkeys/register/finish accepts an optional ?name= and sets passkey.Name, falling back to the AAGUID-inferred authenticator name when omitted (mirrors the browser template's AddPasskey flow). - Add DELETE /passkeys/delete?credentialId= (RequireAuthorization) backed by UserManager.RemovePasskeyAsync so a user can remove their own passkeys. Client (Essentials.Sample): - Prompt for a passkey name (defaulting to the device name) when creating, and pass it to register/finish. - Show the user's passkeys in the signed-in view as cards (name + date added) each with a Remove button that confirms, calls the delete endpoint, and refreshes the list. - Bump the status strip from 2 to 5 word-wrapped lines so longer server messages are readable. - Alias Microsoft.Maui.Authentication.Passkeys as PasskeysApi to avoid a name clash with the new Passkeys collection property. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
Implement Flow 1 — the officially recommended Backend-for-Frontend pattern for native external login — alongside the existing password and passkey sign-in, so the MAUI app and the reference server demonstrate "sign in with an external account -> local ASP.NET Core Identity account", with the provider token kept server-side. Server (Samples.WebServer): - Register a self-contained "Development Test Login" OAuth provider (generic AddOAuth pointed at built-in /dev-oauth/* mock authorize/token/userinfo endpoints) so the whole flow works end to end without real Google keys. SaveTokens=true keeps the provider token on the server. Swap for AddGoogle with real credentials later; the Identity + native plumbing is unchanged. - ExternalAuthEndpoints.cs: the mock provider, the native BFF handshake (/native-auth/external/start -> /complete -> one-time code -> /exchange for a cookie session), and GET /me/external which uses the SERVER-stored provider token to fetch and relay the external profile (the "do something" step). The browser-facing authorize endpoint uses the public host; the token and userinfo backchannels use localhost so they don't depend on tunnel DNS. - Extend the cookie->401 translation to /me so native calls get a clean 401. Client (Essentials.Sample): - "Sign in with an external account (OAuth)" drives the flow via WebAuthenticator: the server brokers the OAuth exchange and returns a one-time code to the custom scheme; the app exchanges it over its own HttpClient so the Identity cookie lands in its CookieContainer. The result is the same account as password/passkey, so passkeys can then be added. - "Show my external profile (server relay)" calls /me/external and shows the server-relayed profile, demonstrating that the client never holds the provider token. Verified end to end on Android: external sign-in creates a local account, the post-login passkey offer + enrollment work on that account, and the profile relay returns the provider data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 101 out of 159 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (4)
src/Essentials/src/Passkeys/Passkeys.android.cs:39
- CreateAsync null-forgives RegistrationResponseJson. If the credential provider returns a response object but the JSON payload is null/empty, this will throw later with a less actionable exception. Validate the JSON and throw a PasskeyException with a clear message.
src/Essentials/src/Passkeys/Passkeys.android.cs:68 - AssertAsync null-forgives AuthenticationResponseJson. If the provider returns a PublicKeyCredential but omits the JSON payload, the code will throw with a null argument. Validate the JSON and throw a PasskeyException with a clearer error.
src/Essentials/src/Passkeys/Passkeys.android.cs:116 - MapException duplicates the same "Cancellation" string check logic that's already factored into IsCancellation. Consolidating this reduces duplication and keeps cancellation detection consistent between sync and callback error paths.
NuGet.config:34 - NuGet.config adds nuget.org as a package source. This bypasses the repo's controlled feeds and can break CI restores in restricted-network environments; it also increases supply-chain risk. This source should be removed before merge once Xamarin.AndroidX.Credentials is mirrored into the dotnet-public feed.
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
| public Task<string> DisplayPromptAsync(string title, string message, string initialValue) | ||
| { | ||
| return DoDisplayPrompt?.Invoke(title, message, initialValue) ?? Task.FromResult<string>(null); | ||
| } |
Rework the external-login sample to use only real ASP.NET Core providers and a fully provider-agnostic flow, replacing the temporary "Development Test Login" mock. No hand-rolled auth: the framework's OAuth handlers, SignInManager, and Data Protection do the security-relevant work. Server (Samples.WebServer): - Remove the mock /dev-oauth/* OAuth provider and its in-memory token stores. - ExternalProviders.cs: register Google, Microsoft, Facebook, and Apple from configuration (Authentication section), each only when its credentials are present — so the sample runs with none, some, or all. Add the matching provider packages (Microsoft.AspNetCore.Authentication.Google/MicrosoftAccount/ Facebook + AspNet.Security.OAuth.Apple), mirroring the Web Authenticator sample. - Add GET /native-auth/external/providers so clients discover the configured providers instead of hard-coding them. - Replace the in-memory one-time code with an ITimeLimitedDataProtector token (framework-signed + expiring) — no custom code store. - /me/external relay is now generic (Google/Microsoft Graph/Facebook userinfo; providers without a userinfo endpoint fall back to the linked local account). - appsettings.json documents the Authentication provider config. Client (Essentials.Sample): - Discover providers from the server and render one "Continue with <provider>" button per configured provider (BindableLayout) — no provider is hard-coded. ExternalSignInAsync takes the provider name; providers reload when the server URL changes. Shows a hint when none are configured. Verified: with no credentials the app shows the "no providers configured" hint and discovery returns []; configuring providers makes them appear automatically in both the web login page and the native app (dynamic buttons). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 101 out of 160 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (5)
NuGet.config:34
- NuGet.config adds a nuget.org package source. This is explicitly marked “DO NOT MERGE” and can also break builds that rely on the repo’s isolated/approved feeds. Please remove this source before merging (mirror Xamarin.AndroidX.Credentials into the dotnet-public feed instead).
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
src/Essentials/samples/Samples/ViewModel/BaseViewModel.cs:47
- DisplayPromptAsync returns a Task but falls back to Task.FromResult(null) when the handler isn’t wired up. That makes the “no UI binding” path return null unexpectedly and can cause null-reference bugs in callers that treat the result as a usable string.
src/Essentials/samples/Samples.WebServer/Program.cs:107 - ForwardedHeadersOptions clears KnownIPNetworks/KnownProxies, which makes the server trust forwarded headers from any source. Even for a sample, that’s a security footgun (host/proto spoofing) if someone runs it outside a dev-tunnel scenario. Consider scoping this to Development only, or reading an explicit allow-list from configuration.
src/Essentials/src/Passkeys/Passkeys.android.cs:83 - InvokeAsync uses TaskCompletionSource without RunContinuationsAsynchronously. Since the TCS is completed from Java callbacks, continuations may run inline on the callback thread, which can lead to reentrancy or unexpected blocking. Using RunContinuationsAsynchronously is the safer pattern for bridging callbacks to Tasks.
src/Essentials/samples/Samples.WebServer/Components/Account/Shared/PasskeySubmit.razor.js:36 - requestCredential interpolates the email value directly into the query string. If the form field is empty, FormData.get(...) yields null and the URL becomes
username=null; and if it contains characters like+,&, or?, the query will be malformed. Encode the value and treat null/empty as “no username” so the server can do discoverable (username-less) requests.
…nfig - Rename the per-provider button text from "Continue with X" to "Sign in with X" so the sample shows one clear "Sign in with Google" / "Sign in with Microsoft" / "Sign in with Facebook" button per registered provider. - Only register the Apple provider when a private key path is also configured. Apple derives its client secret from the private key, so a partial Apple config (ClientId but no key) previously failed options validation on every request, breaking authentication for all providers. Requiring the key avoids that. Verified on Android with three providers configured: the app renders three distinct "Sign in with …" buttons, one per provider. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 101 out of 160 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (5)
src/Essentials/samples/Samples.WebServer/Components/Account/Shared/PasskeySubmit.razor.js:36
requestCredentialinterpolatesemaildirectly into the query string (...username=${email}), which will break for common values likeuser+tag@example.comand can produce an invalid URL. Encode the value (and coerce to string) before building the URL.
src/Essentials/samples/Samples.WebServer/Program.cs:106ForwardedHeadersOptionsis configured to accept forwarded headers from any source by clearingKnownIPNetworksandKnownProxies. That makes it easy for a direct client to spoof scheme/host viaX-Forwarded-*headers (host header injection), which is risky even for a sample since it can affect origin validation and redirects.
src/Essentials/src/Passkeys/Passkeys.android.cs:80TaskCompletionSourceis created withoutRunContinuationsAsynchronously, so continuations can run inline on the Credential Manager callback thread. That can cause surprising re-entrancy and hangs (especially if a continuation blocks).
src/Essentials/src/Passkeys/Passkeys.android.cs:103- The
IExecutorServicecreated viaExecutors.NewSingleThreadExecutor()is onlyShutdown()'d; it should also be disposed to avoid leaking the underlying Java resources/thread in long-running apps (multiple passkey operations).
NuGet.config:34 - This adds
nuget.orgas a package source. The repo normally relies on the Azure Artifacts feeds, and leaving nuget.org enabled can bypass feed isolation in CI (and was already called out in the PR description as “DO NOT MERGE”). This needs to be removed before merging, after the required package is mirrored into the internal feed.
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
Shorten the passkey and external-auth comments across the sample and reference
server: remove internal shorthand ("Flow 1", "BFF") and narrative about how the
code came to be, and keep only concise notes on what each part does now. No
behavior change. Also reword the one user-facing "(BFF)" hint on the passkeys
page.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 103 out of 160 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (4)
NuGet.config:34
- The repo-wide NuGet.config adds a nuget.org package source. dotnet/maui CI runs under CFSClean/network isolation and expects dependencies to resolve from the dotnet-public feeds; leaving nuget.org enabled is a merge blocker (even if temporary).
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
src/Essentials/samples/Samples.WebServer/Program.cs:98
- Forwarded headers are accepted from any remote by clearing KnownIPNetworks/KnownProxies, then applied unconditionally. If this sample is ever run outside a trusted dev-tunnel scenario, that enables host/scheme spoofing via X-Forwarded-* headers.
src/Essentials/samples/Samples.WebServer/Components/Account/Shared/PasskeySubmit.razor.js:33 - The username is interpolated into a query string without URL-encoding. This breaks for valid emails containing '+' and can allow query-string injection (e.g. adding extra parameters).
src/Essentials/src/Passkeys/Passkeys.android.cs:84 - InvokeAsync spins up a new single-thread executor per call and doesn’t dispose the Java objects it creates. This is relatively expensive and can leak JNI resources; also, TaskCompletionSource should use RunContinuationsAsynchronously (see other Essentials Android async bridges) to avoid running continuations inline on the callback thread.
The passkeys sample now covers only passwords and passkeys. Server-brokered external OAuth sign-in (Google/Microsoft/Facebook/Apple via the BFF pattern) moves to a follow-up PR stacked on this branch so this PR stays focused on the Passkeys Essentials API. Removes the two OAuth-only server files (ExternalAuthEndpoints, ExternalProviders) and the OAuth hunks in Program.cs, appsettings.json, the server csproj, the sample view model and the sample page. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 102 out of 158 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (4)
NuGet.config:34
- NuGet.config currently adds a nuget.org package source, which breaks the repo’s mirrored-feed/deterministic restore assumptions and can also fail in CI environments that block nuget.org. This needs to be removed before merge after Xamarin.AndroidX.Credentials is mirrored into the appropriate internal feed.
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
src/Essentials/src/Passkeys/Passkeys.android.cs:80
- TaskCompletionSource is created without RunContinuationsAsynchronously. Because the AndroidX callback may complete the TCS on its executor thread, continuations (including the awaiting code) can run inline on that thread, which is a common source of reentrancy/deadlocks. Use TaskCreationOptions.RunContinuationsAsynchronously for safer callback-to-Task bridging.
src/Essentials/samples/Samples/ViewModel/BaseViewModel.cs:47 - DisplayPromptAsync falls back to Task.FromResult(null) when no UI handler is wired. Returning null from a method typed as Task can easily lead to NullReferenceExceptions in callers that assume a string. Prefer returning an empty string (or change the method/event contract to allow null explicitly).
src/Essentials/samples/Samples.WebServer/Program.cs:91 - ForwardedHeadersOptions clears KnownIPNetworks/KnownProxies, which makes ForwardedHeadersMiddleware trust X-Forwarded-* from any remote client. Even in a sample, this is an easy footgun if someone runs it outside a local dev-tunnel scenario. Safer default is to keep the built-in loopback-only trust unless explicitly configured.
Extends Configure.ps1 to configure iOS / Mac Catalyst passkey testing: with -AppleTeamId it writes the App Site Association app-id (<TeamID>.<BundleID>) into the server's user-secrets and adds the webcredentials associated-domains entitlement to the sample app's iOS Entitlements.plist (a local edit, not committed). Turns the samples testing guide into src/Essentials/samples/README.md so it auto-renders when browsing the folder. It opens with a short orientation (the sample app, the reference server, Configure.ps1) and then a single testing flow — Prerequisites, Server (shared), and per-platform Apple / Android / Windows sections (with the Apple App ID + signing + AASA walkthrough and troubleshooting). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 102 out of 158 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (3)
NuGet.config:34
- This adds a
nuget.orgpackage source. MAUI CI runs under network isolation and restores must come from the repo feeds (dotnet-public), so this source needs to be removed/disabled before merge.
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
src/Essentials/src/Passkeys/Passkeys.android.cs:83
- When bridging callback-based APIs to
Task, useTaskCreationOptions.RunContinuationsAsynchronouslysoawaitcontinuations don’t run inline on the credential-manager executor thread (this pattern is used elsewhere in Essentials).
src/Essentials/samples/Samples.WebServer/Components/Account/Shared/PasskeySubmit.razor.js:33 usernameis interpolated directly into the query string without URL encoding. If it contains&,?, or non-ASCII characters the request can break (and it’s generally unsafe to put unencoded user input in URLs).
The .NET iOS SDK applies Platforms/iOS/Entitlements.plist by convention, so drop the redundant CodesignEntitlements guidance: simulator and Mac Catalyst need no signing changes, and only a real iOS device needs Team + provisioning. Add a troubleshooting note that the web UI must be opened via the tunnel URL, not localhost (passkeys are bound to the RP ID domain). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
Mac Catalyst runs as a real signed macOS app, so — like a physical iOS device — its associated-domains entitlement needs an explicit provisioning profile; only the iOS Simulator is exempt. Fix the earlier note that grouped Mac Catalyst with the Simulator, and add an MT7139 troubleshooting row. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 102 out of 158 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (3)
NuGet.config:34
nuget.orgis added as a package source. This is flagged in-file as temporary and will bypass the repo's feed isolation; it should be removed before merge onceXamarin.AndroidX.Credentialsis mirrored intodotnet-public.
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
src/Essentials/samples/Samples.WebServer/Program.cs:90
- This config clears
KnownIPNetworks/KnownProxies, which makesUseForwardedHeaders()trust forwarded host/scheme from any client. That’s unsafe outside a controlled dev-tunnel scenario; it should be gated (e.g., Development-only and/or only whenPasskeys:ServerDomainis set).
src/Essentials/samples/Samples.WebServer/Components/Account/Shared/PasskeySubmit.razor.js:35 - The
usernamequery parameter is built via string interpolation without URL-encoding. Email addresses commonly include+and other reserved characters, which can be mangled in query parsing (e.g.,+becoming a space). Encode the username before sending.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 102 out of 158 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (3)
src/Essentials/samples/Samples/ViewModel/BaseViewModel.cs:47
DisplayPromptAsyncdeclares a non-nullableTask<string>result but falls back toTask.FromResult<string>(null)when no UI handler is attached. This violates the method contract and can leak nulls into call sites.
src/Essentials/src/Passkeys/Passkeys.android.cs:83TaskCompletionSourceis created withoutRunContinuationsAsynchronously, so continuations may run inline on the CredentialManager callback/executor thread. UsingRunContinuationsAsynchronouslyavoids reentrancy surprises and is the typical pattern when bridging callback APIs toTask.
NuGet.config:34NuGet.configaddsnuget.orgas a package source. This changes restore behavior repo-wide and should not be merged; the referenced package needs to be mirrored into the appropriate internal feed (dotnet-public) and the external source removed.
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
…mple Configure.ps1 -AppleTeamId now also auto-detects the Apple Development signing identity from the keychain and the installed provisioning profile that matches the app id + Associated Domains, and writes them to a git-ignored Samples/Signing.local.props that the sample csproj imports. Mac Catalyst is then ready to build and run signed with no extra flags (the props also set MtouchLink=None, since this sample's runtime XAML inflation trips the default Mac Catalyst linker). -AppleSigningIdentity / -AppleProvisioningProfile override auto-detection. Also skip the dev-tunnel sign-in when already authenticated (check 'devtunnel user show' first) so re-runs don't force a re-auth. README: document the one-stop flow, that an explicit App ID is globally unique to one team (contributors must use their own bundle id), and per-target build/run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 103 out of 160 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (2)
NuGet.config:34
nuget.orgis enabled as a package source. This will break repo/CI assumptions (CFSClean/offline restore) and is explicitly called out as “DO NOT MERGE” in the file. Please mirrorXamarin.AndroidX.Credentialsintodotnet-publicand remove this source before merging.
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
src/Essentials/samples/Samples.WebServer/Components/Account/Shared/PasskeySubmit.razor.js:36
requestCredentialinterpolatesemaildirectly into the query string. When conditional mediation runs and the email field is empty,FormData.get(...)can benull, producingusername=null(string) and changing server behavior (it will look up a user named "null"). It should omit the query parameter when no username is provided, and URL-encode the value when present.
| var tcs = new TaskCompletionSource<Java.Lang.Object>(); | ||
| var signal = new CancellationSignal(); | ||
| var executor = Executors.NewSingleThreadExecutor()!; | ||
| var callback = new CredentialManagerCallback(tcs); |
Configure.ps1 no longer edits any committed file. Instead it writes everything developer-specific into git-ignored files (plus the server user-secrets): - Samples/Passkeys.Local.props (imported by the app csproj): the default server URL — baked into the app via AssemblyMetadata and read by PasskeysViewModel, replacing the hard-coded URL that had to be kept out of commits — plus the auto-detected Mac Catalyst signing identity + provisioning profile and MtouchLink=None. - Samples/Platforms/iOS/Entitlements.Local.plist: a copy of the committed Entitlements.plist plus the webcredentials associated-domains entry, so the committed plist is never modified. The props point CodesignEntitlements at it for iOS and Mac Catalyst. A committed Passkeys.Local.in.props template documents the shape for anyone who prefers to set it up by hand. Both generated files are git-ignored. Web server config stays in user-secrets (the official, leak-proof mechanism); trim the // doc-comment keys from appsettings.json to empty safe defaults and document the keys in the server README instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 104 out of 161 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (5)
NuGet.config:34
- nuget.org is added as a package source, which breaks the repo’s usual isolated/approved feeds and is explicitly marked “DO NOT MERGE”. This needs to be removed before merging (the AndroidX package should be mirrored into the dotnet-public feed instead).
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
src/Essentials/samples/Samples/ViewModel/BaseViewModel.cs:47
- DisplayPromptAsync falls back to returning a Task whose result is null when DoDisplayPrompt is not wired up. That violates the method’s declared non-null return type and can lead to NullReferenceExceptions downstream (and nullable warnings if enabled).
src/Essentials/samples/Samples.WebServer/Components/Account/Shared/PasskeySubmit.razor.js:34 - The username/email value is interpolated directly into the query string and can be null/non-string (FormData.get returns FormDataEntryValue). This can produce URLs like “?username=null” or “[object File]”, and it isn’t URL-encoded.
src/Essentials/src/Passkeys/Passkeys.android.cs:84 - TaskCompletionSource is created without RunContinuationsAsynchronously. Since completions happen from a Java callback/executor thread, running continuations inline can lead to unexpected reentrancy and thread-affinity issues. Use RunContinuationsAsynchronously to decouple continuations from the callback thread.
src/Essentials/samples/Samples.WebServer/Program.cs:91 - Forwarded headers are enabled while clearing KnownProxies/KnownNetworks, which makes the app trust X-Forwarded-* from any client. That’s unsafe outside a controlled dev-tunnel scenario (scheme/host spoofing). Scope this behavior to Development (or gate it behind explicit configuration of trusted proxies).
…emplate-driven props - Rename -AndroidPackage to -ApplicationId and drop -AppleBundleId: it's the one app id shared by every platform (read from <ApplicationId>), used for both the Android package and the Apple <TeamID>.<ApplicationId>. - Fail fast instead of limping on: hard-error when <ApplicationId> can't be read from the project (no silent default), and when the dev tunnel URL can't be resolved (a public HTTPS domain is required — there is no localhost fallback for Android/Apple passkeys), instead of a soft warning that continues. - Generate Entitlements.Local.plist with pure XmlDocument (cross-platform, no PlistBuddy/external process) and a fixed Apple header so the output is byte-clean. - Make Passkeys.Local.props template-driven: load the committed Passkeys.Local.in.props and fill values via XML (strip the Apple-only groups when Apple isn't set up), so the .in template is the single source of truth for the file's shape and comments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 104 out of 161 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (4)
src/Essentials/samples/Samples/ViewModel/BaseViewModel.cs:47
DisplayPromptAsyncis declared asTask<string>but the fallback path returnsnull(Task.FromResult<string>(null)), so callers can receive a null string while the API advertises non-null. This also conflicts with the typicalDisplayPromptAsyncbehavior where cancel returns null. Make the return type nullable (Task<string?>) and update the event signature + fallback accordingly.
src/Essentials/src/Passkeys/Passkeys.android.cs:80TaskCompletionSourceis created withoutRunContinuationsAsynchronously, so continuations may run inline on the CredentialManager callback/executor thread. This can lead to unexpected re-entrancy and makes cancellation/exception paths harder to reason about. UseTaskCreationOptions.RunContinuationsAsynchronouslyfor the TCS.
src/Essentials/samples/Samples.WebServer/Program.cs:91- The forwarded headers configuration clears
KnownIPNetworks/KnownProxiesandUseForwardedHeaders()is enabled unconditionally. This makes the app trustX-Forwarded-*from any client by default (host/scheme spoofing), which is risky even for a sample. Consider enabling this only in Development (or behind an explicit config switch) and leaving the default proxy restrictions intact for other environments.
NuGet.config:34 nuget.orgis added as a package source. Even though it’s marked temporary, leaving it in the repo config changes restore behavior for all contributors/CI and is called out in the PR description as “DO NOT MERGE”. This needs to be removed before merge onceXamarin.AndroidX.Credentialsis mirrored into the official feeds.
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
The committed csproj carried both the AssemblyMetadata plumbing and a placeholder default, while the git-ignored Passkeys.Local.props overrode it — so the placeholder lived in two committed places (csproj + PasskeysViewModel). Move the whole mechanism into the local props / template: the AssemblyMetadata item (with the URL) now lives in Passkeys.Local.props alongside the value, and the committed csproj just imports it. When the props is absent (default checkout / CI), no metadata is emitted and PasskeysViewModel falls back to its built-in placeholder — now the single committed default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 104 out of 161 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (3)
NuGet.config:34
- Repository-level NuGet.config should not include nuget.org, as it breaks isolated/offline builds and violates the repo’s internal-feed-only assumption. This temporary source needs to be removed before merge (and the package mirrored into dotnet-public instead).
<!-- TEMPORARY: nuget.org added to restore Xamarin.AndroidX.Credentials for the Passkeys Android
implementation. This package must be mirrored into the dotnet-public feed and this source
REMOVED before merging. DO NOT MERGE with nuget.org present. -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
src/Essentials/samples/Samples/ViewModel/BaseViewModel.cs:47
- DisplayPromptAsync returns Task but falls back to a null string when there is no UI handler. That violates the non-nullable return contract and can surface as null reference issues for callers.
src/Essentials/samples/Samples.WebServer/Program.cs:91 - Forwarded headers are trusted from any source by clearing KnownIPNetworks/KnownProxies. That enables X-Forwarded-* spoofing if this sample is deployed directly, and it can weaken origin/scheme/host-based logic. Consider enabling this only in Development (dev-tunnel scenario).
Xamarin.AndroidX.Credentials 1.6.0.1 (used by the Passkeys Android implementation) is now mirrored into the dotnet-public feed, so the temporary nuget.org source added to restore it is no longer needed. Verified a clean restore (cache cleared) resolves the package from dotnet-public with nuget.org removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fa25a04-ac6e-44ad-ab51-96d594e4107d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 103 out of 160 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- src/Essentials/samples/Samples.WebServer/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs: Generated file
Comments suppressed due to low confidence (2)
src/Essentials/src/Passkeys/Passkeys.android.cs:83
InvokeAsynccreates aTaskCompletionSourcewithoutTaskCreationOptions.RunContinuationsAsynchronously. In this repo, other Android callback-to-Task bridges useRunContinuationsAsynchronously(e.g.,ActivityForResultRequest.android.cs) to avoid running continuations inline on the callback/executor thread, which can cause reentrancy and hard-to-debug deadlocks.
src/Essentials/samples/Samples.WebServer/Program.cs:88ForwardedHeadersOptionsis configured by clearingKnownIPNetworks/KnownProxies, which disables source validation and makesX-Forwarded-*spoofable. Even for a sample, it’s safer to only trust forwarded headers in Development (or behind an explicitly trusted reverse proxy) so this code isn’t accidentally deployed as-is.
| protected override void OnInitialized() | ||
| { | ||
| tokens = Services.GetService<IAntiforgery>()?.GetTokens(HttpContext); | ||
| } |
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description
Adds a cross-platform Passkeys (WebAuthn / FIDO2 public-key credentials) Essentials API — the design spec, the implementation across all platforms, a runnable sample, and a reference relying-party server.
docs/specs/Passkeys.mdsrc/Essentials/src/Passkeys/, namespaceMicrosoft.Maui.Authentication, inMicrosoft.Maui.Essentials.The API brokers the standard WebAuthn JSON between the app's relying-party (RP) server and the OS authenticator (Face ID / Touch ID / Windows Hello / Android biometric). It does not do server-side verification, challenge generation, or attestation validation — that stays on the RP server.
Note
This PR is stacked on the Blazor Identity template baseline (#36768) — the reference server's non-passkey code is that stock template. The base is currently set to
net11.0so CI runs, which means the GitHub "Files changed" tab temporarily includes the baseline template files. Once #36768 merges intonet11.0, this diff collapses back to just the passkey-specific changes.👉 To review only what this PR actually changes (passkey code, on top of the stock template), use this compare:
mattleibow-blazor-identity-template-baseline...mattleibow-spec-passkeys-essentials
Public API (
Microsoft.Maui.Authentication)PublicAPI.Unshipped.txtis updated for all 7 TFM folders (net,netstandard,net-android,net-ios,net-maccatalyst,net-windows,net-tizen).Platform status
androidx.credentials), callback API bridged to a Task;PreferImmediatelyAvailable, cancellation, Activity, error mapping. Exercised end-to-end on-device (register, list, name, delete, password + passkey sign-in against the reference server).AuthenticationServices(ASAuthorizationPlatformPublicKeyCredentialProvider+ASAuthorizationController). Builds clean.webauthn.dllP/Invoke (WebAuthNAuthenticatorMakeCredential/GetAssertion), GUID-based cancellation. Compiles cleanly; on-device behavior still to be verified on Windows 11 hardware.IsSupported == false).net-macostarget today; enabling it is an assembly-wide change out of scope here.Tests
src/Essentials/test/UnitTests/Passkeys_Tests.cs— 16 unit tests covering the options/response JSON round-tripping and the not-supported stub behaviour.Sample & reference server
A full, runnable demo lives under
src/Essentials/samples/:View/PasskeysPage.xaml+ViewModel/PasskeysViewModel.cs) that behaves like a real login screen: create an account / sign in with a password, get offered a passkey, name it on creation, list your passkeys, remove one, and sign in username-less with a passkey. The server URL is editable from a toolbar button.Samples.WebServer, an ASP.NET Core Identity app that does the WebAuthn server half. Native-app-facing JSON endpoints inComponents/Account/PasskeyApiEndpoints.cs:POST /passkeys/register/begin·POST /passkeys/register/finish?name=(enroll a passkey for the signed-in user)POST /passkeys/login/begin·POST /passkeys/login/finish(username-less assertion)GET /passkeys/list·DELETE /passkeys/delete?credentialId=(manage passkeys —[Authorize]-guarded; the cookie challenge is translated to a clean 401 for the native client)MapIdentityApiunder/account(register/login/refresh) and/account/logout.WellKnownEndpoints.cs):/.well-known/assetlinks.json(Android) and/.well-known/apple-app-site-association(Apple AASA), andIdentityPasskeyOptions.ValidateOriginaccepting the platform native origins.Configure.ps1provisions a dev tunnel and writes the server user-secrets (RP domain, allowed origins, and the Android debug-key SHA-256 fingerprint +apk-key-hashorigin). Docs:Samples.WebServer/README.mdandsamples/README.md.Still to do (follow-ups)
Stacked follow-up
Related
Targets the
net11.0feature branch (this PR adds public API).