Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/Identity/Core/src/IPasskeyHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ namespace Microsoft.AspNetCore.Identity;
public interface IPasskeyHandler<TUser>
where TUser : class
{
/// <summary>
/// Gets a value indicating whether this handler supports conditionally mediated passkey creation.
/// </summary>
/// <remarks>
/// Returns <see langword="false"/> unless the handler implements
/// <see cref="MakeCreationOptionsAsync(PasskeyUserEntity, bool, HttpContext)"/>.
/// </remarks>
bool SupportsConditionalCreation => false;

/// <summary>
/// Generates passkey creation options for the specified user entity and HTTP context.
/// </summary>
Expand All @@ -20,6 +29,39 @@ public interface IPasskeyHandler<TUser>
/// <returns>A <see cref="PasskeyCreationOptionsResult"/> representing the result.</returns>
Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext);

/// <summary>
/// Generates passkey creation options for the specified user entity and HTTP context.
/// </summary>
/// <param name="userEntity">The passkey user entity for which to generate creation options.</param>
/// <param name="isConditionallyMediated">
/// <see langword="true"/> if the passkey will be created with conditional mediation; otherwise, <see langword="false"/>.
/// </param>
/// <param name="httpContext">The HTTP context associated with the request.</param>
/// <returns>A <see cref="PasskeyCreationOptionsResult"/> representing the result.</returns>
/// <remarks>
/// Conditional mediation lets a passkey be created without a user gesture, typically immediately
/// after the user signs in with a password. The corresponding <c>navigator.credentials.create()</c>
/// call must specify <c>mediation: "conditional"</c>.
/// The caller must only request conditional mediation after a recent successful password authentication.
/// An existing authenticated session by itself is not sufficient authorization to add a new passkey.
/// The protected attestation state prevents the client from changing the mediation mode after options
/// are issued, but it does not authorize issuing conditional options.
/// </remarks>
/// <exception cref="NotSupportedException">
/// Thrown when <paramref name="isConditionallyMediated"/> is <see langword="true"/> and the handler
/// does not support conditionally mediated passkey creation.
/// </exception>
Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, bool isConditionallyMediated, HttpContext httpContext)
{
if (isConditionallyMediated)
{
throw new NotSupportedException(
$"The passkey handler '{GetType()}' does not support conditionally mediated passkey creation.");
}

return MakeCreationOptionsAsync(userEntity, httpContext);
}

/// <summary>
/// Generates passkey request options for the specified user and HTTP context.
/// </summary>
Expand Down
43 changes: 34 additions & 9 deletions src/Identity/Core/src/PasskeyHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ public PasskeyHandler(UserManager<TUser> userManager, IOptions<IdentityPasskeyOp
}

/// <inheritdoc />
public async Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext)
public bool SupportsConditionalCreation => true;

/// <inheritdoc />
public Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, HttpContext httpContext)
=> MakeCreationOptionsAsync(userEntity, isConditionallyMediated: false, httpContext);

/// <inheritdoc />
public async Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(PasskeyUserEntity userEntity, bool isConditionallyMediated, HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(userEntity);
ArgumentNullException.ThrowIfNull(httpContext);
Expand Down Expand Up @@ -67,14 +74,15 @@ public async Task<PasskeyCreationOptionsResult> MakeCreationOptionsAsync(Passkey
{
AuthenticatorAttachment = _options.AuthenticatorAttachment,
ResidentKey = _options.ResidentKeyRequirement,
UserVerification = _options.UserVerificationRequirement,
UserVerification = GetUserVerificationRequirement(),
},
Attestation = _options.AttestationConveyancePreference,
};
var attestationState = new PasskeyAttestationState
{
Challenge = challenge,
UserEntity = userEntity,
IsConditionallyMediated = isConditionallyMediated,
};
var optionsJson = JsonSerializer.Serialize(options, IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions);
var attestationStateJson = JsonSerializer.Serialize(attestationState, IdentityJsonSerializerContext.Default.PasskeyAttestationState);
Expand Down Expand Up @@ -104,6 +112,18 @@ async Task<PublicKeyCredentialDescriptor[]> GetExcludeCredentialsAsync()
});
return [.. excludeCredentials];
}

string? GetUserVerificationRequirement()
{
// A conditionally mediated creation cannot collect user verification, and the browser rejects
// the ceremony outright if we ask for it, so the requirement is reduced to "preferred".
if (isConditionallyMediated && string.Equals("required", _options.UserVerificationRequirement, StringComparison.Ordinal))
{
return "preferred";
}

return _options.UserVerificationRequirement;
}
}

/// <inheritdoc />
Expand Down Expand Up @@ -283,7 +303,10 @@ await VerifyClientDataAsync(
// bit of the flags in authData.
// NOTE: It's up to application code to evaluate BE and BS flags on the returned passkey and determine
// whether any action should be taken based on them.
VerifyAuthenticatorData(authenticatorData, context.HttpContext);
VerifyAuthenticatorData(
authenticatorData,
context.HttpContext,
isConditionallyMediated: attestationState.IsConditionallyMediated);

if (!authenticatorData.HasAttestedCredentialData)
{
Expand Down Expand Up @@ -491,7 +514,7 @@ await VerifyClientDataAsync(
// 17. If user verification was determined to be required, verify that the UV bit of the flags in authData is set.
// Otherwise, ignore the value of the UV flag.
// 18. If the BE bit of the flags in authData is not set, verify that the BS bit is not set.
VerifyAuthenticatorData(authenticatorData, context.HttpContext);
VerifyAuthenticatorData(authenticatorData, context.HttpContext, isConditionallyMediated: false);

// 19. If the credential backup state is used as part of Relying Party business logic or policy, let currentBe and currentBs
// be the values of the BE and BS bits, respectively, of the flags in authData. Compare currentBe and currentBs with
Expand Down Expand Up @@ -619,7 +642,8 @@ private async Task VerifyClientDataAsync(

private void VerifyAuthenticatorData(
AuthenticatorData authenticatorData,
HttpContext httpContext)
HttpContext httpContext,
bool isConditionallyMediated)
{
// Verify that the rpIdHash in authenticatorData is the SHA-256 hash of the RP ID expected by the Relying Party.
var originalRpId = GetServerDomain(httpContext);
Expand All @@ -630,16 +654,17 @@ private void VerifyAuthenticatorData(
}

// If options.mediation is not set to conditional, verify that the UP bit of the flags in authData is set.
// NOTE: We currently check for the UserPresent flag unconditionally. Consider making this optional via options.mediation
// after the level 3 draft becomes standard.
if (!authenticatorData.IsUserPresent)
if (!isConditionallyMediated && !authenticatorData.IsUserPresent)
{
throw PasskeyException.UserNotPresent();
}

// If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set.
// NOTE: A conditionally mediated creation cannot collect user verification, so the requirement doesn't apply.
var originalUserVerificationRequirement = _options.UserVerificationRequirement;
if (string.Equals("required", originalUserVerificationRequirement, StringComparison.Ordinal) && !authenticatorData.IsUserVerified)
if (!isConditionallyMediated &&
string.Equals("required", originalUserVerificationRequirement, StringComparison.Ordinal) &&
!authenticatorData.IsUserVerified)
{
throw PasskeyException.UserNotVerified();
}
Expand Down
2 changes: 2 additions & 0 deletions src/Identity/Core/src/Passkeys/PasskeyAttestationState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ internal sealed class PasskeyAttestationState
public required ReadOnlyMemory<byte> Challenge { get; init; }

public required PasskeyUserEntity UserEntity { get; init; }

public bool IsConditionallyMediated { get; init; }
}
6 changes: 6 additions & 0 deletions src/Identity/Core/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
#nullable enable
Microsoft.AspNetCore.Identity.IPasskeyHandler<TUser>.MakeCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, bool isConditionallyMediated, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult!>!
Microsoft.AspNetCore.Identity.IPasskeyHandler<TUser>.SupportsConditionalCreation.get -> bool
Microsoft.AspNetCore.Identity.PasskeyHandler<TUser>.MakeCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, bool isConditionallyMediated, Microsoft.AspNetCore.Http.HttpContext! httpContext) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Identity.PasskeyCreationOptionsResult!>!
Microsoft.AspNetCore.Identity.PasskeyHandler<TUser>.SupportsConditionalCreation.get -> bool
virtual Microsoft.AspNetCore.Identity.SignInManager<TUser>.MakePasskeyCreationOptionsAsync(Microsoft.AspNetCore.Identity.PasskeyUserEntity! userEntity, bool isConditionallyMediated) -> System.Threading.Tasks.Task<string!>!
virtual Microsoft.AspNetCore.Identity.SignInManager<TUser>.SupportsPasskeyConditionalCreation.get -> bool
39 changes: 39 additions & 0 deletions src/Identity/Core/src/SignInManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ public HttpContext Context
}
}

/// <summary>
/// Gets a value indicating whether the configured passkey handler supports conditionally mediated passkey creation.
/// </summary>
public virtual bool SupportsPasskeyConditionalCreation => _passkeyHandler?.SupportsConditionalCreation ?? false;

/// <summary>
/// Creates a <see cref="ClaimsPrincipal"/> for the specified <paramref name="user"/>, as an asynchronous operation.
/// </summary>
Expand Down Expand Up @@ -526,6 +531,40 @@ public virtual async Task<string> MakePasskeyCreationOptionsAsync(PasskeyUserEnt

var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, Context);
await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState);

return result.CreationOptionsJson;
}

/// <summary>
/// Generates passkey creation options for the specified <paramref name="userEntity"/>.
/// </summary>
/// <param name="userEntity">The user entity for which to create passkey options.</param>
/// <param name="isConditionallyMediated">
/// <see langword="true"/> if the passkey will be created with conditional mediation; otherwise, <see langword="false"/>.
/// </param>
/// <returns>A JSON string representing the created passkey options.</returns>
/// <remarks>
/// Conditional mediation lets a passkey be created without a user gesture, typically immediately
/// after the user signs in with a password. The corresponding <c>navigator.credentials.create()</c>
/// call must specify <c>mediation: "conditional"</c>.
/// The caller must only request conditional mediation after a recent successful password authentication.
/// An existing authenticated session by itself is not sufficient authorization to add a new passkey.
/// The protected attestation state prevents the client from changing the mediation mode after options
/// are issued, but it does not authorize issuing conditional options.
/// </remarks>
public virtual async Task<string> MakePasskeyCreationOptionsAsync(PasskeyUserEntity userEntity, bool isConditionallyMediated)
{
if (!isConditionallyMediated)
{
return await MakePasskeyCreationOptionsAsync(userEntity);
}

ThrowIfNoPasskeyHandler();
ArgumentNullException.ThrowIfNull(userEntity);

var result = await _passkeyHandler.MakeCreationOptionsAsync(userEntity, isConditionallyMediated, Context);
await StorePasskeyAuthenticationInfoAsync(PasskeyOperations.Attestation, result.AttestationState);

return result.CreationOptionsJson;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ namespace Microsoft.AspNetCore.Identity.Test;

public class PasskeyHandlerAttestationTest
{
[Fact]
public void SupportsConditionalCreation()
{
var userManager = MockHelpers.MockUserManager<PocoUser>();
var handler = new PasskeyHandler<PocoUser>(
userManager.Object,
Options.Create(new IdentityPasskeyOptions()));

Assert.True(handler.SupportsConditionalCreation);
}

[Fact]
public async Task CanSucceed()
{
Expand Down Expand Up @@ -966,6 +977,94 @@ public async Task Fails_WhenCredentialIdAlreadyExistsForAnotherUser()
Assert.StartsWith("The credential is already registered for a user", result.Failure.Message);
}

[Fact]
public async Task Fails_WhenUserIsNotPresent()
{
var test = new AttestationTest();
test.AuthenticatorDataArgs.Transform(args => args with
{
Flags = args.Flags & ~AuthenticatorDataFlags.UserPresent,
});

var result = await test.RunAsync();

Assert.False(result.Succeeded);
Assert.StartsWith("The authenticator data flags did not include the 'UserPresent' flag", result.Failure.Message);
}

[Fact]
public async Task Fails_WhenUserIsNotVerifiedAndUserVerificationIsRequired()
{
var test = new AttestationTest();
test.PasskeyOptions.UserVerificationRequirement = "required";

var result = await test.RunAsync();

Assert.False(result.Succeeded);
Assert.StartsWith("User verification is required", result.Failure.Message);
}

[Fact]
public async Task CanSucceed_WhenConditionallyMediatedAndUserIsNotPresentOrVerified()
{
var test = new AttestationTest
{
IsConditionallyMediated = true,
};
test.PasskeyOptions.UserVerificationRequirement = "required";
test.AuthenticatorDataArgs.Transform(args => args with
{
Flags = args.Flags & ~(AuthenticatorDataFlags.UserPresent | AuthenticatorDataFlags.UserVerified),
});

var result = await test.RunAsync();

Assert.True(result.Succeeded);
Assert.False(result.Passkey.IsUserVerified);
}

[Theory]
[InlineData("required", false, "required")]
[InlineData("required", true, "preferred")]
[InlineData("preferred", true, "preferred")]
[InlineData("discouraged", true, "discouraged")]
public async Task ReducesRequiredUserVerification_WhenConditionallyMediated(
string configuredRequirement,
bool isConditionallyMediated,
string expectedRequirement)
{
var test = new AttestationTest
{
IsConditionallyMediated = isConditionallyMediated,
};
test.PasskeyOptions.UserVerificationRequirement = configuredRequirement;

await test.RunAsync();

var creationOptions = test.CreationOptionsJson.GetValueAsJsonElement();
var userVerification = creationOptions
.GetProperty("authenticatorSelection")
.GetProperty("userVerification")
.GetString();
Assert.Equal(expectedRequirement, userVerification);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ConditionalMediationRoundTripsThroughAttestationState(bool isConditionallyMediated)
{
var test = new AttestationTest
{
IsConditionallyMediated = isConditionallyMediated,
};

await test.RunAsync();

var attestationState = test.AttestationStateJson.GetValueAsJsonElement();
Assert.Equal(isConditionallyMediated, attestationState.GetProperty("isConditionallyMediated").GetBoolean());
}

private static string GetInvalidBase64UrlValue(string base64UrlValue)
{
var rawValue = Base64Url.DecodeFromChars(base64UrlValue);
Expand All @@ -986,6 +1085,7 @@ private sealed class AttestationTest : PasskeyScenarioTest<PasskeyAttestationRes
public string? UserName { get; set; } = "johndoe";
public string? UserDisplayName { get; set; } = "John Doe";
public string? Origin { get; set; } = "https://example.com";
public bool IsConditionallyMediated { get; set; }
public bool DoesCredentialAlreadyExistForAnotherUser { get; set; }
public COSEAlgorithmIdentifier Algorithm { get; set; } = COSEAlgorithmIdentifier.ES256;
public ReadOnlyMemory<byte> CredentialId { get; set; } = _defaultCredentialId;
Expand All @@ -996,6 +1096,7 @@ private sealed class AttestationTest : PasskeyScenarioTest<PasskeyAttestationRes
public ComputedValue<ReadOnlyMemory<byte>> AuthenticatorData { get; } = new();
public ComputedValue<ReadOnlyMemory<byte>> AttestationObject { get; } = new();
public ComputedJsonObject AttestationStateJson { get; } = new();
public ComputedJsonObject CreationOptionsJson { get; } = new();
public ComputedJsonObject ClientDataJson { get; } = new();
public ComputedJsonObject CredentialJson { get; } = new();

Expand Down Expand Up @@ -1031,9 +1132,13 @@ protected override async Task<PasskeyAttestationResult> RunCoreAsync()
DisplayName = UserDisplayName!,
};

var creationOptionsResult = await handler.MakeCreationOptionsAsync(userEntity, httpContext.Object);
var creationOptionsResult = await handler.MakeCreationOptionsAsync(
userEntity,
IsConditionallyMediated,
httpContext.Object);
var creationOptionsJson = CreationOptionsJson.Compute(creationOptionsResult.CreationOptionsJson);
var creationOptions = JsonSerializer.Deserialize(
creationOptionsResult.CreationOptionsJson,
creationOptionsJson!,
IdentityJsonSerializerContext.Default.PublicKeyCredentialCreationOptions)
?? throw new InvalidOperationException("Failed to deserialize creation options JSON.");

Expand Down
Loading