Skip to content

Add passkey tests verifying WebAuthn conformance #62441

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

Merged
merged 9 commits into from
Jun 26, 2025
Merged
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
2 changes: 1 addition & 1 deletion src/Identity/Core/src/DefaultPasskeyHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ await VerifyClientDataAsync(
// NOTE: We simply fail the ceremony in this case.
if (authenticatorData.SignCount <= storedPasskey.SignCount)
{
throw PasskeyException.SignCountLessThanStoredSignCount();
throw PasskeyException.SignCountLessThanOrEqualToStoredSignCount();
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Identity/Core/src/PasskeyExceptionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public static PasskeyException ExpectedBackupIneligibleCredential()
public static PasskeyException InvalidAssertionSignature()
=> new("The assertion signature was invalid.");

public static PasskeyException SignCountLessThanStoredSignCount()
public static PasskeyException SignCountLessThanOrEqualToStoredSignCount()
=> new("The authenticator's signature counter is unexpectedly less than or equal to the stored signature counter.");

public static PasskeyException InvalidAttestationObject(Exception ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ public VersionTwoDbContext(DbContextOptions options)
}
}

public class VersionThreeDbContext : IdentityDbContext<IdentityUser, IdentityRole, string>
{
public VersionThreeDbContext(DbContextOptions options)
: base(options)
{
}
}

public class EmptyDbContext : IdentityDbContext<IdentityUser, IdentityRole, string>
{
public EmptyDbContext(DbContextOptions options)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test;

public class VersionThreeSchemaTest : IClassFixture<ScratchDatabaseFixture>
{
private readonly ApplicationBuilder _builder;

public VersionThreeSchemaTest(ScratchDatabaseFixture fixture)
{
var services = new ServiceCollection();

services
.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build())
.AddDbContext<VersionThreeDbContext>(o =>
o.UseSqlite(fixture.Connection)
.ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)))
.AddIdentity<IdentityUser, IdentityRole>(o =>
{
// MaxKeyLength does not need to be set in version 3
o.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
})
.AddEntityFrameworkStores<VersionThreeDbContext>();

services.AddLogging();

_builder = new ApplicationBuilder(services.BuildServiceProvider());
var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<VersionThreeDbContext>();
db.Database.EnsureCreated();
}

[Fact]
public void EnsureDefaultSchema()
{
using var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
var db = scope.ServiceProvider.GetRequiredService<VersionThreeDbContext>();
VerifyVersion3Schema(db);
}

internal static void VerifyVersion3Schema(DbContext dbContext)
{
using var sqlConn = (SqliteConnection)dbContext.Database.GetDbConnection();
sqlConn.Open();
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUsers", "Id", "UserName", "Email", "PasswordHash", "SecurityStamp",
"EmailConfirmed", "PhoneNumber", "PhoneNumberConfirmed", "TwoFactorEnabled", "LockoutEnabled",
"LockoutEnd", "AccessFailedCount", "ConcurrencyStamp", "NormalizedUserName", "NormalizedEmail"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetRoles", "Id", "Name", "NormalizedName", "ConcurrencyStamp"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserRoles", "UserId", "RoleId"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserClaims", "Id", "UserId", "ClaimType", "ClaimValue"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserLogins", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserTokens", "UserId", "LoginProvider", "Name", "Value"));
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserPasskeys", "UserId", "CredentialId", "PublicKey", "Name", "CreatedAt",
"SignCount", "Transports", "IsUserVerified", "IsBackupEligible", "IsBackedUp", "AttestationObject",
"ClientDataJson"));

Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUsers", 256, "UserName", "Email", "NormalizedUserName", "NormalizedEmail", "PhoneNumber"));
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetRoles", 256, "Name", "NormalizedName"));
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUserLogins", 128, "LoginProvider", "ProviderKey"));
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUserTokens", 128, "LoginProvider", "Name"));

DbUtil.VerifyIndex(sqlConn, "AspNetRoles", "RoleNameIndex", isUnique: true);
DbUtil.VerifyIndex(sqlConn, "AspNetUsers", "UserNameIndex", isUnique: true);
DbUtil.VerifyIndex(sqlConn, "AspNetUsers", "EmailIndex");
}
}
1 change: 1 addition & 0 deletions src/Identity/Extensions.Core/src/UserManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2188,6 +2188,7 @@ public virtual Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user)
{
ThrowIfDisposed();
var passkeyStore = GetUserPasskeyStore();
ArgumentNullThrowHelper.ThrowIfNull(user);
ArgumentNullThrowHelper.ThrowIfNull(credentialId);

return passkeyStore.FindPasskeyAsync(user, credentialId, CancellationToken);
Expand Down
14 changes: 14 additions & 0 deletions src/Identity/test/Identity.Test/Passkeys/AttestationObjectArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

namespace Microsoft.AspNetCore.Identity.Test;

internal readonly struct AttestationObjectArgs
{
public required int? CborMapLength { get; init; }
public required string? Format { get; init; }
public required ReadOnlyMemory<byte>? AttestationStatement { get; init; }
public required ReadOnlyMemory<byte>? AuthenticatorData { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Identity.Test;

internal readonly struct AttestedCredentialDataArgs
{
public required ReadOnlyMemory<byte> Aaguid { get; init; }
public required ReadOnlyMemory<byte> CredentialId { get; init; }
public required ReadOnlyMemory<byte> CredentialPublicKey { get; init; }
}
13 changes: 13 additions & 0 deletions src/Identity/test/Identity.Test/Passkeys/AuthenticatorDataArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Identity.Test;

internal readonly struct AuthenticatorDataArgs
{
public required AuthenticatorDataFlags Flags { get; init; }
public required ReadOnlyMemory<byte> RpIdHash { get; init; }
public required uint SignCount { get; init; }
public ReadOnlyMemory<byte>? AttestedCredentialData { get; init; }
public ReadOnlyMemory<byte>? Extensions { get; init; }
}
104 changes: 104 additions & 0 deletions src/Identity/test/Identity.Test/Passkeys/CredentialHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers.Binary;
using System.Formats.Cbor;

namespace Microsoft.AspNetCore.Identity.Test;

internal static class CredentialHelpers
{
public static ReadOnlyMemory<byte> MakeAttestedCredentialData(in AttestedCredentialDataArgs args)
{
const int AaguidLength = 16;
const int CredentialIdLengthLength = 2;
var length = AaguidLength + CredentialIdLengthLength + args.CredentialId.Length + args.CredentialPublicKey.Length;
var result = new byte[length];
var offset = 0;

args.Aaguid.Span.CopyTo(result.AsSpan(offset, AaguidLength));
offset += AaguidLength;

BinaryPrimitives.WriteUInt16BigEndian(result.AsSpan(offset, CredentialIdLengthLength), (ushort)args.CredentialId.Length);
offset += CredentialIdLengthLength;

args.CredentialId.Span.CopyTo(result.AsSpan(offset));
offset += args.CredentialId.Length;

args.CredentialPublicKey.Span.CopyTo(result.AsSpan(offset));
offset += args.CredentialPublicKey.Length;

if (offset != result.Length)
{
throw new InvalidOperationException($"Expected attested credential data length '{length}', but got '{offset}'.");
}

return result;
}

public static ReadOnlyMemory<byte> MakeAuthenticatorData(in AuthenticatorDataArgs args)
{
const int RpIdHashLength = 32;
const int AuthenticatorDataFlagsLength = 1;
const int SignCountLength = 4;
var length =
RpIdHashLength +
AuthenticatorDataFlagsLength +
SignCountLength +
(args.AttestedCredentialData?.Length ?? 0) +
(args.Extensions?.Length ?? 0);
var result = new byte[length];
var offset = 0;

args.RpIdHash.Span.CopyTo(result.AsSpan(offset, RpIdHashLength));
offset += RpIdHashLength;

result[offset] = (byte)args.Flags;
offset += AuthenticatorDataFlagsLength;

BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(offset, SignCountLength), args.SignCount);
offset += SignCountLength;

if (args.AttestedCredentialData is { } attestedCredentialData)
{
attestedCredentialData.Span.CopyTo(result.AsSpan(offset));
offset += attestedCredentialData.Length;
}

if (args.Extensions is { } extensions)
{
extensions.Span.CopyTo(result.AsSpan(offset));
offset += extensions.Length;
}

if (offset != result.Length)
{
throw new InvalidOperationException($"Expected authenticator data length '{length}', but got '{offset}'.");
}

return result;
}

public static ReadOnlyMemory<byte> MakeAttestationObject(in AttestationObjectArgs args)
{
var writer = new CborWriter(CborConformanceMode.Ctap2Canonical);
writer.WriteStartMap(args.CborMapLength);
if (args.Format is { } format)
{
writer.WriteTextString("fmt");
writer.WriteTextString(format);
}
if (args.AttestationStatement is { } attestationStatement)
{
writer.WriteTextString("attStmt");
writer.WriteEncodedValue(attestationStatement.Span);
}
if (args.AuthenticatorData is { } authenticatorData)
{
writer.WriteTextString("authData");
writer.WriteByteString(authenticatorData.Span);
}
writer.WriteEndMap();
return writer.Encode();
}
}
Loading
Loading