Skip to content

Add passkeys to ASP.NET Core Identity #62112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open

Conversation

MackinnonBuck
Copy link
Member

@MackinnonBuck MackinnonBuck commented May 27, 2025

Add passkeys to ASP.NET Core Identity

This PR adds passkey support to ASP.NET Core Identity.

Description

Following is a summary of the changes in this PR:

  • Updated the Blazor Web App template to support passkey management and login
  • Added passkey (WebAuthn) support to ASP.NET Core Identity:
    • New passkey store abstractions with updated store implementations in Microsoft.AspNetCore.Identity.EntityFrameworkCore
    • New passkey abstractions for attestation and assertion
    • Extensibility points in the default passkey handler for e.g., attestation statement validation
    • Support for all cryptographic algorithms tested by the FIDO conformance testing tool, except EdDSA.
    • New APIs in SignInManager and UserManager for passkey management and sign in
  • Added a sample project that can be run against the FIDO conformance testing tool

Note that the goal of this PR is to add support for passkey authentication in ASP.NET Core Identity. While it implements core WebAuthn functionality, it does not provide a complete and general-purpose WebAuthn/FIDO2 library. The public API surface is limited in order to enable long-term stability of the feature. Targeted extensibility points were added to enable functionality not implemented by default, most notably attestation statement validation. This allows the use of third-party libraries to fill the missing gaps, when desired. Community feedback may result in additional extensibility APIs being added in the future.

This PR includes E2E tests validating that a passkey can be registered and used for logging in. I'll add unit tests after we agree on the design to avoid churn.

Fixes #53467

@github-actions github-actions bot added the area-identity Includes: Identity and providers label May 27, 2025
@JamesNK
Copy link
Member

JamesNK commented May 27, 2025

FYI I have a PR adding metrics to identity here: #62078. Whoever merges second will need to react and add counters and tags for passkey signins.

@MackinnonBuck MackinnonBuck requested a review from Copilot May 27, 2025 20:31
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds support for passkeys to ASP.NET Core Identity by extending the existing identity stores and sign‐in flows. Key changes include:

  • Introducing new generic abstractions and methods for managing passkeys in both UserStore and UserOnlyStore.
  • Updating IdentityUserContext model building to support passkeys under Identity Schema Version 3.
  • Extending SignInManager with new APIs for passkey sign in and for configuring/retrieving passkey creation and request options.

Reviewed Changes

Copilot reviewed 111 out of 111 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Identity/EntityFrameworkCore/src/UserStore.cs Added a new generic UserStore overload and methods to create, find, update, and remove user passkeys.
src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs Extended the store for users without roles to support passkey operations along with checks for DB support.
src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs Modified OnModelCreating to include a new entity for passkeys when using Identity Schema Version 3.
src/Identity/Core/src/SignInManager.cs Introduced new methods for passkey sign in and configuration of passkey creation/request options.
PublicAPI.Unshipped.txt and build files Updated the public API surface and project configuration to expose passkey-related features.

Copy link
Member Author

@MackinnonBuck MackinnonBuck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments to help with reviewing.

Comment on lines 15 to 20
public interface IPasskeyRequestContextProvider
{
/// <summary>
/// Gets the current <see cref="PasskeyRequestContext"/>.
/// </summary>
PasskeyRequestContext Context { get; }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of this abstraction is to allow passkey attestation and assertion logic to access information about the current request, like the host and origin.

The Microsoft.Extensions.Identity.Core package doesn't reference Microsoft.AspNetCore.Http.Abstractions, so we can't directly grab information off the current HttpContext unless we move parts of the passkey implementation to Microsoft.AspNetCore.Identity.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MackinnonBuck Make the context type a generic parameter, allowing users to pass their own context implementation that could include HttpContext. This opens up various possibilities. For example, such a context could accept a transaction to ensure the request is either fully processed or not executed at all.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that you are on to something WRT to moving the implementation into Microsoft.AspNetCore.Identity. Passkeys are inherently "web based" and doesn't make a ton of sense for them to live in a location without access to it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and that would allow us to remove abstractions like this one altogether.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The relayering is now done and I've removed this type since it's less necessary now (see this comment). Please let me know if you think I need to reintroduce it.

Comment on lines 15 to 23
public interface IPasskeyOriginValidator
{
/// <summary>
/// Determines whether the specified origin is valid for passkey operations.
/// </summary>
/// <param name="originInfo">Information about the passkey's origin.</param>
/// <returns><c>true</c> if the origin is valid; otherwise, <c>false</c>.</returns>
bool IsValidOrigin(PasskeyOriginInfo originInfo);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interface exists because the meaning of a "valid origin" can vary widely between applications. See https://www.w3.org/TR/webauthn-3/#sctn-validating-origin.

Comment on lines +17 to +25
/// <summary>
/// Adds a new passkey credential in the store for the specified <paramref name="user"/>,
/// or updates an existing passkey.
/// </summary>
/// <param name="user">The user to create the passkey credential for.</param>
/// <param name="passkey">The passkey to add.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The <see cref="Task"/> that represents the asynchronous operation.</returns>
Task SetPasskeyAsync(TUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering splitting this out into AddPasskeyAsync and UpdatePasskeyAsync.

Comment on lines 32 to 40
/// <summary>
/// Gets the JSON representation of the options.
/// </summary>
/// <remarks>
/// The structure of the JSON string matches the description in the WebAuthn specification.
/// See <see href="https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptionsjson"/>.
/// </remarks>
public string AsJson()
=> _optionsJson;
Copy link
Member Author

@MackinnonBuck MackinnonBuck May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR currently just exposes the credential creation/request options as a JSON string rather than returning a strongly-typed object. That way we're not locked into making the entirety of the Passkeys/ folder public. In the future, if we wanted to e.g., write a hand-crafted JSON writer that constructed a string without first building a .NET representation, we could do that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making this method virtual would allow users to override its logic if needed.

Comment on lines +55 to +68
/// <summary>
/// Gets or sets whether the passkey has a verified user.
/// </summary>
public virtual bool IsUserVerified { get; set; }

/// <summary>
/// Gets or sets whether the passkey is eligible for backup.
/// </summary>
public virtual bool IsBackupEligible { get; set; }

/// <summary>
/// Gets or sets whether the passkey is currently backed up.
/// </summary>
public virtual bool IsBackedUp { get; set; }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These flags are all present on the authenticator data flags. We could just store the flags byte directly so that we don't introduce another schema change in the future, should we find ourselves wanting to store more flags.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These flags (per the specification) must be persisted and validated during authentication processing for previously created passkeys.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The specification states in the Credential Backup State section:

"The value of the BE flag is set during authenticatorMakeCredential operation and MUST NOT change."

This means:

  • The BE (Backup Eligible) flag must remain unchanged after Passkey creation.
  • If BE was initially 0 (false), the BS (Backup State) flag must never be 1 (true).

Storing these flags in binary form would be impractical. The current implementation (keeping them as separate values) provides the right balance of correctness and usability.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback, @vanbukin.

Storing these flags in binary form would be impractical. The current implementation (keeping them as separate values) provides the right balance of correctness and usability.

I agree it's more usable to keep them as separate properties. The main reason for my initial suggestion was to avoid cases where spec changes require additional flags to be stored, incurring an update to this model and a migration in customers' apps.

For example, the BS and BE flags weren't defined in the WebAuthn Level 2 spec, but were added in the Level 3 spec. A similar adjustment could theoretically be made in the future.

Comment on lines +1 to +25
async function createCredential(optionsJSON) {
// See: https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential

// 1. Let options be a new PublicKeyCredentialCreationOptions structure configured to
// the Relying Party’s needs for the ceremony.
// See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-parsecreationoptionsfromjson
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);

// 2. Call navigator.credentials.create() and pass options as the publicKey option.
// Let credential be the result of the successfully resolved promise.
// If the promise is rejected, abort the ceremony with a user-visible error,
// or otherwise guide the user experience as might be determinable from the
// context available in the rejected promise.
const credential = await navigator.credentials.create({ publicKey: options });

// 3. Let response be credential.response. If response is not an instance of
// AuthenticatorAttestationResponse, abort the ceremony with a user-visible error.
if (!(credential?.response instanceof AuthenticatorAttestationResponse)) {
throw new Error('The authenticator failed to provide a valid credential.');
}

// Continue the ceremony on the server.
// See: https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-tojson
return JSON.stringify(credential);
}
Copy link
Member Author

@MackinnonBuck MackinnonBuck May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could move this code to a package, but it's so small that I think it's safe to include it in the template.
However, I'm considering changing the options parameter here to represent the top-level object passed to the navigator.credentials.create() function instead of representing the publicKey property. That's because the WebAuthn level 3 spec references another top-level property, mediation in the ceremony, and we might want a way to configure it (and other not-yet-introduced properties) in the future.

Also, this script assumes it's loaded as part of a full page navigation, not an enhanced navigation. This strategy currently works because the script gets rendered as a result of a non-enhanced form post.

Comment on lines 31 to 41
[
new(COSEAlgorithmIdentifier.ES256),
new(COSEAlgorithmIdentifier.PS256),
new(COSEAlgorithmIdentifier.ES384),
new(COSEAlgorithmIdentifier.PS384),
new(COSEAlgorithmIdentifier.PS512),
new(COSEAlgorithmIdentifier.RS256),
new(COSEAlgorithmIdentifier.ES512),
new(COSEAlgorithmIdentifier.RS384),
new(COSEAlgorithmIdentifier.RS512),
];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list does not include RS1, even though the FIDO conformance testing tool checks for it. Maybe we should make this list configurable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine RS1 is RSASHA1, so I think it's ok to leave it out, but @blowdart might care.

@MackinnonBuck MackinnonBuck marked this pull request as ready for review May 28, 2025 22:37
@MackinnonBuck MackinnonBuck requested review from halter73, a team and wtgodbe as code owners May 28, 2025 22:38
@mguinness
Copy link

Is there any provision for passkey integration into identity API endpoints via IdentityApiEndpointRouteBuilderExtensions?

Copy link
Member

@javiercn javiercn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some initial review feedback.

I need to do a more detailed pass, specially over the crypto stuff.

Overall looks good, and I'm happy to see this landing as it is a great improvement for security. The main pieces of feedback I have are:

  • Layering -> I think a lot of this code should go into Identity directly rather than Core.
  • UserManager vs SignInManager: Some of the code might fit better in SignInManager, specially if it requires access to HttpContext (or you are accessing it behind an abstraction).
    • Maybe collect the HttpContext based info you need in SignInManager and call UserManager with it.
  • Json strings through the API or S.T.J types on the DTOs. My understanding is that part of it is to avoid serializing/deserializing things and making additional types public.
    • An option here could be to create an "opaque" type that contains the Json data. Maybe with a GetRawData or something like that which returns the original JSON string. That way in the future you can add fields to the type if you want/need to and use those directly over the string, and anyone who needs access to the data, can get the string and deserialize it. (which is no different than what they have to do today, isn't it?)

Comment on lines +1 to +76
@using BlazorWeb_CSharp.Data
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity

<form id="passkey-response-form" @formname="passkey-response" @onsubmit="OnSubmitAsync" method="post">
<AntiforgeryToken />
<input id="passkey-response" type="hidden" name="@(nameof(ResponseJson))" />
<input id="passkey-error" type="hidden" name="@(nameof(Error))" />
</form>

@if (options is not null)
{
<script type="application/json" id="passkey-options">@((MarkupString)options)</script>
<script src="./Components/Account/Shared/PasskeyHandler.razor.js" data-action="@action"></script>
}

@code {
private string? action;
private string? options;

[Parameter]
public string? CurrentCreationOptions { get; set; }

[Parameter]
public string? CurrentRequestOptions { get; set; }

[Parameter]
[EditorRequired]
public EventCallback<string> OnResponse { get; set; }

[Parameter]
[EditorRequired]
public EventCallback<string?> OnError { get; set; }

[SupplyParameterFromForm]
private string? ResponseJson { get; set; }

[SupplyParameterFromForm]
private string? Error { get; set; }

[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;

protected override async Task OnInitializedAsync()
{
if (HttpMethods.IsGet(HttpContext.Request.Method))
{
// Clear the existing two factor cookie to ensure a clean ceremony
await HttpContext.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme);
}
}

protected override void OnParametersSet()
{
(options, action) = (CurrentCreationOptions, CurrentRequestOptions) switch
{
(null, null) => (null, null),
(var createOptions, null) => (createOptions, "create"),
(null, var requestOptions) => (requestOptions, "get"),
(not null, not null) => throw new InvalidOperationException(
$"Only one of '{nameof(CurrentCreationOptions)}' and '{nameof(CurrentRequestOptions)}' should be specified."),
};
}

private async Task OnSubmitAsync()
{
if (ResponseJson is { Length: > 0 } responseJson)
{
await OnResponse.InvokeAsync(responseJson);
}
else
{
await OnError.InvokeAsync(Error);
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be a minimal API endpoint?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial prototype actually did have this as a minimal API endpoint, with more of the WebAuthn ceremony defined in JS. Rewriting it to be more Razor-centric was maybe a bit of a "grass is greener" move by me. The initial, prototype JS logic fairly intertwined with the UI, since it had to, e.g., read form fields and manually insert error messages into the DOM when failures occur. I was partly concerned that someone could modify the Razor code without expecting hidden JS to depend on things like form field names, but also thought that it was a better separation of concerns to have Razor handle the UI and JS handle only the parts that must be JS (like calling navigator.credentials.create()).

However, after implementing the Razor-based approach and receiving this feedback, I can see that performing this handshake as a series of renders makes the logical sequence of events less aligned with how the code looks. So I'm fine with considering moving back to the old strategy.

Another option is to make the passkey login and management pages interactive, although if we plan to make similar UI changes to Microsoft.AspNetCore.Identity.UI, then we'd need a JS-based implementation anyway.

Comment on lines +107 to +115
var user = Input.Email is { Length: > 0 } email
? await UserManager.FindByEmailAsync(email)
: null;

var passkeyRequestArgs = new PasskeyRequestArgs<ApplicationUser>
{
User = user,
};
var options = await SignInManager.ConfigurePasskeyRequestOptionsAsync(passkeyRequestArgs);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might make sense for ConfigurePasskeyRequestOptionsAsync to take in the email as a separate parameter and perform this bit of logic internally to set the user.

That way you avoid having to inject the UserManager in addition to the sign in manager.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Identity doesn't require that users have an email though, right? Or is the suggestion to add a convenience method that wraps the one accepting a TUser directly?

@@ -16,8 +17,9 @@
<div class="row">
<div class="col-lg-6">
<section>
<PasskeyHandler CurrentRequestOptions="@currentPasskeyRequestOptions" OnResponse="LoginUserWithPasskey" OnError="SetPasskeyError" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we want to do this dance through .razor as opposed to making the handler a minimal API and handle the entire process via JS?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment here. Happy to consider changing the implementation to be more JS-centric.

/// <param name="originalOptionsJson">The JSON representation of the original passkey creation options provided to the browser.</param>
/// <param name="userManager">The <see cref="UserManager{TUser}"/> to retrieve user information from.</param>
/// <returns>A task object representing the asynchronous operation containing the <see cref="PasskeyAssertionResult{TUser}"/>.</returns>
Task<PasskeyAssertionResult<TUser>> PerformAssertionAsync(TUser? user, string credentialJson, string originalOptionsJson, UserManager<TUser> userManager);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all these APIs, dealing in the serialized JSON string feels "strange". I'm not 100% sure how big these strings are, but I would think it makes more sense for these payloads to get deserialized into DTOs and passed in as objects rather than having the guts of Identity handle the serialization concerns.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main reason I did it this way is to avoid exposing the details of the WebAuthn spec as public C# API. But I agree it feels unusual. Maybe we could do something similar to the PasskeyCreationOptions type added in this PR, which exposes a small number of strongly-typed properties but has a method to get the underlying JSON representation.

Comment on lines 15 to 20
public interface IPasskeyRequestContextProvider
{
/// <summary>
/// Gets the current <see cref="PasskeyRequestContext"/>.
/// </summary>
PasskeyRequestContext Context { get; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that you are on to something WRT to moving the implementation into Microsoft.AspNetCore.Identity. Passkeys are inherently "web based" and doesn't make a ton of sense for them to live in a location without access to it.

/// <remarks>
/// See <see href="https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-extensions"/>.
/// </remarks>
public JsonElement? Extensions { get; set; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using JsonElement here is a bit "strange", is there another type that could be used instead? (IDictionary<string,object>) or similar?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure it's possible - this property gets forwarded to a PublicKeyCredentialRequestOptions object, whose sole purpose is to get serialized into a JSON payload that gets passed to the JavaScript navigator.credentials.get() method.

This JSON serialization happens internally within Identity and uses the JSON source generator (the customer doesn't get to specify their own JsonSerializerOptions because the serialization mechanism is an internal implementation detail).

So, if the Extensions property was an IDictionary<string, object>, our implementation would throw for any dictionary values we haven't anticipated in the IdentityJsonSerializerContext. Whereas, the current approach allows the developer to either create a JsonElement directly or serialize another .NET type into one via JsonSerializer.SerializeToElement(), where they can pass their own JsonSerializerOptions.

All that said, there might be a better way of handling this than JsonElement that I haven't thought of.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think JsonElement is that strange. We've been using it a lot in the MCP cshar-sdk repo for things like InputSchema. If the goal is to serialize/deserialize extensions as JSON, IDictionary<string,object> feels inappropriate because the object would probably just JsonElements most of the time anyway.

This does prevent us from switching to a different JSON library in the future or using something other than JSON for this, but I highly doubt we'd switch from System.Text.Json, and it appears the webauthn standard requires JSON for extensions.

@eiriktsarpalis Do you think JsonElement makes sense?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whereas, the current approach allows the developer to either create a JsonElement directly

You can't just create a JsonElement. But instead, you need to get a JsonDocument and get the JsonElement out of that.

Would using JsonNode work better here if we expect people to create these directly?

{
public string Fmt => fmt;

public ReadOnlyMemory<byte> AttStmt => attStmt;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would avoid the abbreviations

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property names match https://www.w3.org/TR/webauthn-3/#attestation-object. I aimed to have as close to a 1:1 mapping between the WebAuthn-related models and the types described in the spec as possible, but if we think it's clearer to expand these names, I'd be fine doing so 🙂

Comment on lines 21 to 34
var value = reader.GetString();
if (value is null)
{
return null;
}

return BufferSource.FromBase64UrlString(value);
}

public override void Write(Utf8JsonWriter writer, BufferSource value, JsonSerializerOptions options)
{
var base64UrlString = value.AsBase64UrlString();
writer.WriteStringValue(base64UrlString);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how much you care about the allocation, but you can grab the ValueTextSpan or ValueTextSequence (don't remember the exact names) and deserialize directly that way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I started to go down that rabbit hole, but the value variable in the Read method here eventually gets passed to our internal Base64 URL decoder, which only accepts strings as input, so we'd need to modify the implementation be span based but keep the existing string APIs for compatibility... Totally feasible, but since it wasn't a required step to get a working implementation, I put that on hold. Probably worth it to resume that optimization now 🙂

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the Base64Url class be utilized?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the Base64Url class be utilized?

I didn't utilize this because Microsoft.Extensions.Identity.Core needs to support TFMs for which that type doesn't exist - however, we could use Base64Url if we move most of the passkey implementation to Microsoft.AspNetCore.Identity, which seems to be the likeliest path forward.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to support TFMs for which that type doesn't exist

https://www.nuget.org/packages/Microsoft.Bcl.Memory/ allows you to use Base64Url on netstandard2.0, if you still need it from Microsoft.Extensions.Identity.Core.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code now lives in Microsoft.AspNetCore.Identity and utilizes the Base64Url class. I've also implemented a more optimized version of this JSON converter that uses ValueTextSpan and ValueTextSequence.

Copy link

@mguinness mguinness Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using Authenticate with a passkey through form autofill with autocomplete="username webauthn" for a better user experience.

Copy link
Member

@halter73 halter73 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make it possible to register with just a passkey and not a password with the Blazor project template? I'm fine if we do this as a follow up.

I'm also a fan of the layering adjustment that Javier suggested to move the HttpContext-related stuff up to the AspNetCore package and moving more stuff from UserManager to the SignInManager.

protected enum AuthenticationFeatures
{
None = 0,
Basic = 1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
Basic = 1,
PasswordOnly = 1,

I know it's just a test, but I wouldn't want people to think it's testing "basic auth".

@@ -86,7 +87,7 @@ protected async Task TestBasicInteractionInNewPageAsync(
string listeningUri,
string appName,
BlazorTemplatePages pagesToExclude = BlazorTemplatePages.None,
bool usesAuth = false)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was nothing using this parameter previously?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, it was unused.

[InlineData(BrowserKind.Chromium, "None")]
[InlineData(BrowserKind.Chromium, "Server")]
[InlineData(BrowserKind.Chromium, "WebAssembly")]
[InlineData(BrowserKind.Chromium, "Auto")]
public async Task BlazorWebTemplate_Works(BrowserKind browserKind, string interactivityOption)
[InlineData(BrowserKind.Chromium, "None", "Individual")]
[InlineData(BrowserKind.Chromium, "None", "Individual", true)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm with sharing the logic in TestBasicInteractionInNewPageAsync, but I wonder if this would be easier to track possible issues with tests relying on passkey and/or password if we gave it its own method instead of making it just another variation of the BlazorWebTemplate_Works theory.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something I thought about and I agree, it would probably be better if the different sub-cases were split into their own methods that could be composed at the top level.

*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken>.UserTokens.get -> Microsoft.EntityFrameworkCore.DbSet<TUserToken!>!
*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext<TUser, TKey, TUserClaim, TUserLogin, TUserToken>.UserTokens.set -> void
*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore<TUser, TContext, TKey, TUserClaim, TUserLogin, TUserToken>.Context.get -> TContext!
*REMOVED*virtual Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore<TUser, TRole, TContext, TKey, TUserClaim, TUserRole, TUserLogin, TUserToken, TRoleClaim>.Context.get -> TContext!
Copy link
Member

@halter73 halter73 Jun 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these removals just because our API checking tool doesn't know that these members are now inherited? Also, do you think we're ready for an API proposal issue? You might be able to use AI to generate one from this PR.

I think it's okay to merge the PR ahead of API review as long as we go through the process. Keeping such a large PR open for a long time can make it harder to keep track of what's changed and review although separate commits can help with that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these removals just because our API checking tool doesn't know that these members are now inherited?

Yes, that's exactly right.

I think it's okay to merge the PR ahead of API review as long as we go through the process. Keeping such a large PR open for a long time can make it harder to keep track of what's changed and review although separate commits can help with that.

Totally agreed. I'll put together an API proposal issue after I complete a few more pending updates to this PR.


internal static class PasskeyExceptionExtensions
{
extension(PasskeyException)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool use of the new syntax, but why not just make these normal internal static methods? Also, would application level code ever need to do a runtime check of the kind of PasskeyException? Would it make sense for there to be an PasskeyExceptionReason enum or something like that? The names of these methods seem like they could be good enum values.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool use of the new syntax, but why not just make these normal internal static methods?

I thought it might be better to separate out these helper methods from the core PasskeyException declaration, but it was also an excuse to use static extension members :) I'm fine with moving them back if that's preferable.

Also, would application level code ever need to do a runtime check of the kind of PasskeyException? Would it make sense for there to be an PasskeyExceptionReason enum or something like that? The names of these methods seem like they could be good enum values.

Hm, maybe. We do something similar in IdentityErrorDescriber. I'm just now sure how common it'll be to programmatically react to different passkey validation failures. It might be useful for metrics, though. For example, if the server keeps rejecting credentials and the developer wants to find out why, reporting the exception "reason" might be helpful? I wonder if we should consider this as a potential follow-up item.

Comment on lines 17 to 18
public static PasskeyException InvalidCredentialType(string expectedType, string actualType)
=> new($"Expected credential type '{expectedType}', got '{actualType}'.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
public static PasskeyException InvalidCredentialType(string expectedType, string actualType)
=> new($"Expected credential type '{expectedType}', got '{actualType}'.");
[DoesNotReturn]
public static void ThrowInvalidCredentialType(string expectedType, string actualType)
=> throw new($"Expected credential type '{expectedType}', got '{actualType}'.");

It looks like in 99% or cases we're throwing immediately, and this is a bit more conventional.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had that initially, but unfortunately [DoesNotReturn] isn't sufficient enough to remove throw null; in all cases :(

ArgumentNullException.ThrowIfNull(creationArgs);

var excludeCredentials = await GetExcludeCredentialsAsync();
var serverDomain = Options.Passkey.ServerDomain ?? Context.Request.Host.Host;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a previous iteration of this PR, the computed domain was configurable via custom IPasskeyRequestContextProvider implementation. I've since removed that abstraction because it became less necessary after moving most of the passkey implementation to the Microsoft.AspNetCore.Identity package. Curious if others think we need to allow customization of the "fallback" logic here in the case of Options.Passkey.ServerDomain not being specified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-identity Includes: Identity and providers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Passkeys Authentication support in ASP.NET Core
7 participants