diff --git a/src/Identity/Core/src/DefaultPasskeyHandler.cs b/src/Identity/Core/src/DefaultPasskeyHandler.cs index dc9aa9c03509..3843ea3463ec 100644 --- a/src/Identity/Core/src/DefaultPasskeyHandler.cs +++ b/src/Identity/Core/src/DefaultPasskeyHandler.cs @@ -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(); } } diff --git a/src/Identity/Core/src/PasskeyExceptionExtensions.cs b/src/Identity/Core/src/PasskeyExceptionExtensions.cs index 9c640cb4edc0..82fc60c92464 100644 --- a/src/Identity/Core/src/PasskeyExceptionExtensions.cs +++ b/src/Identity/Core/src/PasskeyExceptionExtensions.cs @@ -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) diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs index 959fbb142a46..8e4ed3143493 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs @@ -24,6 +24,14 @@ public VersionTwoDbContext(DbContextOptions options) } } +public class VersionThreeDbContext : IdentityDbContext +{ + public VersionThreeDbContext(DbContextOptions options) + : base(options) + { + } +} + public class EmptyDbContext : IdentityDbContext { public EmptyDbContext(DbContextOptions options) diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs new file mode 100644 index 000000000000..bb5814150f9d --- /dev/null +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs @@ -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 +{ + private readonly ApplicationBuilder _builder; + + public VersionThreeSchemaTest(ScratchDatabaseFixture fixture) + { + var services = new ServiceCollection(); + + services + .AddSingleton(new ConfigurationBuilder().Build()) + .AddDbContext(o => + o.UseSqlite(fixture.Connection) + .ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning))) + .AddIdentity(o => + { + // MaxKeyLength does not need to be set in version 3 + o.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }) + .AddEntityFrameworkStores(); + + services.AddLogging(); + + _builder = new ApplicationBuilder(services.BuildServiceProvider()); + var scope = _builder.ApplicationServices.GetRequiredService().CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + } + + [Fact] + public void EnsureDefaultSchema() + { + using var scope = _builder.ApplicationServices.GetRequiredService().CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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"); + } +} diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index f7bcbf38368d..73b9a28c6b94 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -2188,6 +2188,7 @@ public virtual Task> GetPasskeysAsync(TUser user) { ThrowIfDisposed(); var passkeyStore = GetUserPasskeyStore(); + ArgumentNullThrowHelper.ThrowIfNull(user); ArgumentNullThrowHelper.ThrowIfNull(credentialId); return passkeyStore.FindPasskeyAsync(user, credentialId, CancellationToken); diff --git a/src/Identity/test/Identity.Test/Passkeys/AttestationObjectArgs.cs b/src/Identity/test/Identity.Test/Passkeys/AttestationObjectArgs.cs new file mode 100644 index 000000000000..15940c6816ea --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/AttestationObjectArgs.cs @@ -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? AttestationStatement { get; init; } + public required ReadOnlyMemory? AuthenticatorData { get; init; } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/AttestedCredentialDataArgs.cs b/src/Identity/test/Identity.Test/Passkeys/AttestedCredentialDataArgs.cs new file mode 100644 index 000000000000..95456788c370 --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/AttestedCredentialDataArgs.cs @@ -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 Aaguid { get; init; } + public required ReadOnlyMemory CredentialId { get; init; } + public required ReadOnlyMemory CredentialPublicKey { get; init; } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/AuthenticatorDataArgs.cs b/src/Identity/test/Identity.Test/Passkeys/AuthenticatorDataArgs.cs new file mode 100644 index 000000000000..9c98f9daa663 --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/AuthenticatorDataArgs.cs @@ -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 RpIdHash { get; init; } + public required uint SignCount { get; init; } + public ReadOnlyMemory? AttestedCredentialData { get; init; } + public ReadOnlyMemory? Extensions { get; init; } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/CredentialHelpers.cs b/src/Identity/test/Identity.Test/Passkeys/CredentialHelpers.cs new file mode 100644 index 000000000000..51d35b839f9f --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/CredentialHelpers.cs @@ -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 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 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 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(); + } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/CredentialKeyPair.cs b/src/Identity/test/Identity.Test/Passkeys/CredentialKeyPair.cs new file mode 100644 index 000000000000..210751c740fe --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/CredentialKeyPair.cs @@ -0,0 +1,231 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Formats.Cbor; +using System.Security.Cryptography; + +namespace Microsoft.AspNetCore.Identity.Test; + +internal sealed class CredentialKeyPair +{ + private readonly RSA? _rsa; + private readonly ECDsa? _ecdsa; + private readonly COSEAlgorithmIdentifier _alg; + private readonly COSEKeyType _keyType; + private readonly COSEEllipticCurve _curve; + + private CredentialKeyPair(RSA rsa, COSEAlgorithmIdentifier alg) + { + _rsa = rsa; + _alg = alg; + _keyType = COSEKeyType.RSA; + } + + private CredentialKeyPair(ECDsa ecdsa, COSEAlgorithmIdentifier alg, COSEEllipticCurve curve) + { + _ecdsa = ecdsa; + _alg = alg; + _keyType = COSEKeyType.EC2; + _curve = curve; + } + + public static CredentialKeyPair Generate(COSEAlgorithmIdentifier alg) + { + return alg switch + { + COSEAlgorithmIdentifier.RS1 or + COSEAlgorithmIdentifier.RS256 or + COSEAlgorithmIdentifier.RS384 or + COSEAlgorithmIdentifier.RS512 or + COSEAlgorithmIdentifier.PS256 or + COSEAlgorithmIdentifier.PS384 or + COSEAlgorithmIdentifier.PS512 => GenerateRsaKeyPair(alg), + + COSEAlgorithmIdentifier.ES256 => GenerateEcKeyPair(alg, ECCurve.NamedCurves.nistP256, COSEEllipticCurve.P256), + COSEAlgorithmIdentifier.ES384 => GenerateEcKeyPair(alg, ECCurve.NamedCurves.nistP384, COSEEllipticCurve.P384), + COSEAlgorithmIdentifier.ES512 => GenerateEcKeyPair(alg, ECCurve.NamedCurves.nistP521, COSEEllipticCurve.P521), + COSEAlgorithmIdentifier.ES256K => GenerateEcKeyPair(alg, ECCurve.CreateFromFriendlyName("secP256k1"), COSEEllipticCurve.P256K), + + _ => throw new NotSupportedException($"Algorithm {alg} is not supported for key pair generation") + }; + } + + public ReadOnlyMemory SignData(ReadOnlySpan data) + { + return _keyType switch + { + COSEKeyType.RSA => SignRsaData(data), + COSEKeyType.EC2 => SignEcData(data), + _ => throw new InvalidOperationException($"Unsupported key type {_keyType}") + }; + } + + private byte[] SignRsaData(ReadOnlySpan data) + { + if (_rsa is null) + { + throw new InvalidOperationException("RSA key is not available for signing"); + } + + var hashAlgorithm = GetHashAlgorithmFromCoseAlg(_alg); + var padding = GetRsaPaddingFromCoseAlg(_alg); + + return _rsa.SignData(data.ToArray(), hashAlgorithm, padding); + } + + private byte[] SignEcData(ReadOnlySpan data) + { + if (_ecdsa is null) + { + throw new InvalidOperationException("ECDSA key is not available for signing"); + } + + var hashAlgorithm = GetHashAlgorithmFromCoseAlg(_alg); + return _ecdsa.SignData(data.ToArray(), hashAlgorithm, DSASignatureFormat.Rfc3279DerSequence); + } + + private static CredentialKeyPair GenerateRsaKeyPair(COSEAlgorithmIdentifier alg) + { + const int KeySize = 2048; + var rsa = RSA.Create(KeySize); + return new CredentialKeyPair(rsa, alg); + } + + private static CredentialKeyPair GenerateEcKeyPair(COSEAlgorithmIdentifier alg, ECCurve curve, COSEEllipticCurve coseCurve) + { + var ecdsa = ECDsa.Create(curve); + return new CredentialKeyPair(ecdsa, alg, coseCurve); + } + + public ReadOnlyMemory EncodePublicKeyCbor() + => _keyType switch + { + COSEKeyType.RSA => EncodeCoseRsaPublicKey(_rsa!, _alg), + COSEKeyType.EC2 => EncodeCoseEcPublicKey(_ecdsa!, _alg, _curve), + _ => throw new InvalidOperationException($"Unsupported key type {_keyType}") + }; + + private static byte[] EncodeCoseRsaPublicKey(RSA rsa, COSEAlgorithmIdentifier alg) + { + var parameters = rsa.ExportParameters(false); + + var writer = new CborWriter(CborConformanceMode.Ctap2Canonical); + writer.WriteStartMap(4); // kty, alg, n, e + + writer.WriteInt32((int)COSEKeyParameter.KeyType); + writer.WriteInt32((int)COSEKeyType.RSA); + + writer.WriteInt32((int)COSEKeyParameter.Alg); + writer.WriteInt32((int)alg); + + writer.WriteInt32((int)COSEKeyParameter.N); + writer.WriteByteString(parameters.Modulus!); + + writer.WriteInt32((int)COSEKeyParameter.E); + writer.WriteByteString(parameters.Exponent!); + + writer.WriteEndMap(); + return writer.Encode(); + } + + private static byte[] EncodeCoseEcPublicKey(ECDsa ecdsa, COSEAlgorithmIdentifier alg, COSEEllipticCurve curve) + { + var parameters = ecdsa.ExportParameters(false); + + var writer = new CborWriter(CborConformanceMode.Ctap2Canonical); + writer.WriteStartMap(5); // kty, alg, crv, x, y + + writer.WriteInt32((int)COSEKeyParameter.KeyType); + writer.WriteInt32((int)COSEKeyType.EC2); + + writer.WriteInt32((int)COSEKeyParameter.Alg); + writer.WriteInt32((int)alg); + + writer.WriteInt32((int)COSEKeyParameter.Crv); + writer.WriteInt32((int)curve); + + writer.WriteInt32((int)COSEKeyParameter.X); + writer.WriteByteString(parameters.Q.X!); + + writer.WriteInt32((int)COSEKeyParameter.Y); + writer.WriteByteString(parameters.Q.Y!); + + writer.WriteEndMap(); + return writer.Encode(); + } + + private static HashAlgorithmName GetHashAlgorithmFromCoseAlg(COSEAlgorithmIdentifier alg) + { + return alg switch + { + COSEAlgorithmIdentifier.RS1 => HashAlgorithmName.SHA1, + COSEAlgorithmIdentifier.ES256 => HashAlgorithmName.SHA256, + COSEAlgorithmIdentifier.ES384 => HashAlgorithmName.SHA384, + COSEAlgorithmIdentifier.ES512 => HashAlgorithmName.SHA512, + COSEAlgorithmIdentifier.PS256 => HashAlgorithmName.SHA256, + COSEAlgorithmIdentifier.PS384 => HashAlgorithmName.SHA384, + COSEAlgorithmIdentifier.PS512 => HashAlgorithmName.SHA512, + COSEAlgorithmIdentifier.RS256 => HashAlgorithmName.SHA256, + COSEAlgorithmIdentifier.RS384 => HashAlgorithmName.SHA384, + COSEAlgorithmIdentifier.RS512 => HashAlgorithmName.SHA512, + COSEAlgorithmIdentifier.ES256K => HashAlgorithmName.SHA256, + _ => throw new InvalidOperationException($"Unsupported algorithm: {alg}") + }; + } + + private static RSASignaturePadding GetRsaPaddingFromCoseAlg(COSEAlgorithmIdentifier alg) + { + return alg switch + { + COSEAlgorithmIdentifier.PS256 or + COSEAlgorithmIdentifier.PS384 or + COSEAlgorithmIdentifier.PS512 => RSASignaturePadding.Pss, + + COSEAlgorithmIdentifier.RS1 or + COSEAlgorithmIdentifier.RS256 or + COSEAlgorithmIdentifier.RS384 or + COSEAlgorithmIdentifier.RS512 => RSASignaturePadding.Pkcs1, + + _ => throw new InvalidOperationException($"Unsupported RSA algorithm: {alg}") + }; + } + + private enum COSEKeyType + { + OKP = 1, + EC2 = 2, + RSA = 3, + Symmetric = 4 + } + + private enum COSEKeyParameter + { + Crv = -1, + K = -1, + X = -2, + Y = -3, + D = -4, + N = -1, + E = -2, + KeyType = 1, + KeyId = 2, + Alg = 3, + KeyOps = 4, + BaseIV = 5 + } + + private enum COSEEllipticCurve + { + Reserved = 0, + P256 = 1, + P384 = 2, + P521 = 3, + X25519 = 4, + X448 = 5, + Ed25519 = 6, + Ed448 = 7, + P256K = 8, + } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs new file mode 100644 index 000000000000..4cc77b0e88d7 --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs @@ -0,0 +1,1317 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace Microsoft.AspNetCore.Identity.Test; + +using static JsonHelpers; +using static CredentialHelpers; + +public class DefaultPasskeyHandlerAssertionTest +{ + [Fact] + public async Task CanSucceed() + { + var test = new AssertionTest(); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenCredentialIdIsMissing() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + Assert.True(credentialJson.Remove("id")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'id'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenCredentialIdIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["id"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialIdIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlCredentialId = (string)credentialJson["id"]!; + credentialJson["id"] = GetInvalidBase64UrlValue(base64UrlCredentialId); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialTypeIsMissing() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + Assert.True(credentialJson.Remove("type")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'type'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenCredentialTypeIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["type"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialTypeIsNotPublicKey() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["type"] = "unexpected-value"; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("Expected credential type 'public-key', got 'unexpected-value'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialResponseIsMissing() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + Assert.True(credentialJson.Remove("response")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'response'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("\"hello\"")] + public async Task Fails_WhenCredentialResponseIsNotAnObject(string jsonValue) + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsChallengeIsMissing() + { + var test = new AssertionTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + Assert.True(originalOptionsJson.Remove("challenge")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + + Assert.StartsWith("The original passkey request options had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'challenge'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + var base64UrlChallenge = (string)originalOptionsJson["challenge"]!; + originalOptionsJson["challenge"] = GetInvalidBase64UrlValue(base64UrlChallenge); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey request options had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenOriginalOptionsChallengeIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + originalOptionsJson["challenge"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey request options had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonIsMissing() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var response = credentialJson["response"]!.AsObject(); + Assert.True(response.Remove("clientDataJSON")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'clientDataJSON'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenClientDataJsonIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["clientDataJSON"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonIsEmptyString() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["clientDataJSON"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsMissing() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var response = credentialJson["response"]!.AsObject(); + Assert.True(response.Remove("authenticatorData")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'authenticatorData'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenAuthenticatorDataIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["authenticatorData"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlAuthenticatorData = (string)credentialJson["response"]!["authenticatorData"]!; + credentialJson["response"]!["authenticatorData"] = GetInvalidBase64UrlValue(base64UrlAuthenticatorData); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsEmptyString() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["authenticatorData"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator data had an invalid byte count of 0", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenResponseSignatureIsMissing() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var response = credentialJson["response"]!.AsObject(); + Assert.True(response.Remove("signature")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'signature'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenResponseSignatureIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["signature"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenResponseSignatureIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlSignature = (string)credentialJson["response"]!["signature"]!; + credentialJson["response"]!["signature"] = GetInvalidBase64UrlValue(base64UrlSignature); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenResponseSignatureIsEmptyString() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["signature"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion signature was invalid", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenResponseSignatureIsInvalid() + { + var test = new AssertionTest(); + test.Signature.Transform(signature => + { + // Add some invalid bytes to the signature + var invalidSignature = (byte[])[.. signature.Span, 0xFF, 0xFF, 0xFF, 0xFF]; + return invalidSignature; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion signature was invalid", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("{}")] + public async Task Fails_WhenResponseUserHandleIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["userHandle"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenResponseUserHandleIsNull() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["userHandle"] = null; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator response was missing a user handle", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenResponseUserHandleDoesNotMatchUserId() + { + var test = new AssertionTest + { + IsUserIdentified = true, + }; + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var newUserId = test.User.Id[..^1]; + credentialJson["response"]!["userHandle"] = Base64Url.EncodeToString(Encoding.UTF8.GetBytes(newUserId)); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The provided user handle", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonTypeIsMissing() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + Assert.True(clientDataJson.Remove("type")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'type'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenClientDataJsonTypeIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["type"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Theory] + [InlineData("")] + [InlineData("webauthn.create")] + [InlineData("unexpected-value")] + public async Task Fails_WhenClientDataJsonTypeIsNotExpected(string value) + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["type"] = value; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("Expected the client data JSON 'type' field to be 'webauthn.get'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonChallengeIsMissing() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + Assert.True(clientDataJson.Remove("challenge")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'challenge'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenClientDataJsonChallengeIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["challenge"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonChallengeIsEmptyString() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["challenge"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator response challenge does not match original challenge", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + var base64UrlChallenge = (string)clientDataJson["challenge"]!; + clientDataJson["challenge"] = GetInvalidBase64UrlValue(base64UrlChallenge); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonChallengeIsNotRequestChallenge() + { + var test = new AssertionTest(); + var modifiedChallenge = (byte[])[.. test.Challenge.Span]; + for (var i = 0; i < modifiedChallenge.Length; i++) + { + modifiedChallenge[i]++; + } + + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["challenge"] = Base64Url.EncodeToString(modifiedChallenge); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator response challenge does not match original challenge", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonOriginIsMissing() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + Assert.True(clientDataJson.Remove("origin")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'origin'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenClientDataJsonOriginIsNotString(string jsonValue) + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["origin"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonOriginIsEmptyString() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["origin"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator response had an invalid origin ''", result.Failure.Message); + } + + [Theory] + [InlineData("https://example.com", "http://example.com")] + [InlineData("http://example.com", "https://example.com")] + [InlineData("https://example.com", "https://foo.example.com")] + [InlineData("https://example.com", "https://example.com:5000")] + public async Task Fails_WhenClientDataJsonOriginDoesNotMatchTheExpectedOrigin(string expectedOrigin, string returnedOrigin) + { + var test = new AssertionTest + { + Origin = expectedOrigin, + }; + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["origin"] = returnedOrigin; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith($"The authenticator response had an invalid origin '{returnedOrigin}'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("\"hello\"")] + public async Task Fails_WhenClientDataJsonTokenBindingIsNotObject(string jsonValue) + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["tokenBinding"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonTokenBindingStatusIsMissing() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["tokenBinding"] = JsonNode.Parse("{}"); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'status'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonTokenBindingStatusIsInvalid() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["tokenBinding"] = JsonNode.Parse(""" + { + "status": "unexpected-value" + } + """); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("Invalid token binding status 'unexpected-value'", result.Failure.Message); + } + + [Fact] + public async Task Succeeds_WhenUserVerificationIsRequiredAndUserIsVerified() + { + var test = new AssertionTest(); + test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => + { + optionsJson["userVerification"] = "required"; + }); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.UserVerified, + }); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Succeeds_WhenUserVerificationIsDiscouragedAndUserIsVerified() + { + var test = new AssertionTest(); + test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => + { + optionsJson["userVerification"] = "discouraged"; + }); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.UserVerified, + }); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenUserVerificationIsRequiredAndUserIsNotVerified() + { + var test = new AssertionTest(); + test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => + { + optionsJson["userVerification"] = "required"; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "User verification is required, but the authenticator data flags did not have the 'UserVerified' flag", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenUserIsNotPresent() + { + var test = new AssertionTest(); + 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 Succeeds_WhenAuthenticatorDataContainsExtensionData() + { + var test = new AssertionTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.HasExtensionData, + Extensions = (byte[])[0xA0] // Empty CBOR map. + }); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataContainsExtraBytes() + { + var test = new AssertionTest(); + test.AuthenticatorData.Transform(authenticatorData => + { + return (byte[])[.. authenticatorData.Span, 0xFF, 0xFF, 0xFF, 0xFF]; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator data had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataRpIdHashIsInvalid() + { + var test = new AssertionTest(); + test.AuthenticatorDataArgs.Transform(args => + { + var newRpIdHash = args.RpIdHash.ToArray(); + newRpIdHash[0]++; + return args with { RpIdHash = newRpIdHash }; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator data included an invalid Relying Party ID hash", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataClientDataHashIsInvalid() + { + var test = new AssertionTest(); + test.ClientDataHash.Transform(clientDataHash => + { + var newClientDataHash = clientDataHash.ToArray(); + newClientDataHash[0]++; + return newClientDataHash; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The assertion signature was invalid", result.Failure.Message); + } + + [Fact] + public async Task Succeeds_WhenSignCountIsZero() + { + var test = new AssertionTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + SignCount = 0, // Usually 1 by default + }); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + // Having both sign counts be '0' is allowed, per the above test case, + // so we don't test for its invalidity here. + [Theory] + [InlineData(42, 42)] + [InlineData(41, 42)] + [InlineData(0, 1)] + public async Task Fails_WhenAuthenticatorDataSignCountLessThanOrEqualToStoredSignCount( + uint authenticatorDataSignCount, + uint storedSignCount) + { + var test = new AssertionTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + SignCount = authenticatorDataSignCount, + }); + test.StoredPasskey.Transform(passkey => + { + passkey.SignCount = storedSignCount; + return passkey; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "The authenticator's signature counter is unexpectedly less than or equal to the stored signature counter", + result.Failure.Message); + } + + [Theory] + [InlineData((int)COSEAlgorithmIdentifier.PS256)] + [InlineData((int)COSEAlgorithmIdentifier.PS384)] + [InlineData((int)COSEAlgorithmIdentifier.PS512)] + [InlineData((int)COSEAlgorithmIdentifier.RS256)] + [InlineData((int)COSEAlgorithmIdentifier.RS384)] + [InlineData((int)COSEAlgorithmIdentifier.RS512)] + [InlineData((int)COSEAlgorithmIdentifier.ES256)] + [InlineData((int)COSEAlgorithmIdentifier.ES384)] + [InlineData((int)COSEAlgorithmIdentifier.ES512)] + public async Task Succeeds_WithSupportedAlgorithms(int algorithm) + { + var test = new AssertionTest + { + Algorithm = (COSEAlgorithmIdentifier)algorithm, + }; + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButBackedUp() + { + var test = new AssertionTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = (args.Flags | AuthenticatorDataFlags.BackedUp) & ~AuthenticatorDataFlags.BackupEligible, + }); + + // This test simulates an RP policy failure, not a mismatch between the stored passkey + // and the authenticator data flags, so we'll make the stored passkey match the + // authenticator data flags + test.IsStoredPasskeyBackedUp = true; + test.IsStoredPasskeyBackupEligible = false; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The credential is backed up, but the authenticator data flags did not have the 'BackupEligible' flag", result.Failure.Message); + } + + [Theory] + [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] + [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] + public async Task Succeeds_WhenAuthenticatorDataIsBackupEligible(PasskeyOptions.CredentialBackupPolicy backupEligibility) + { + var test = new AssertionTest(); + test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = backupEligibility; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, + }); + + // This test simulates an RP policy failure, not a mismatch between the stored passkey + // and the authenticator data flags, so we'll make the stored passkey match the + // authenticator data flags + test.IsStoredPasskeyBackupEligible = true; + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsBackupEligibleButDisallowed() + { + var test = new AssertionTest(); + test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, + }); + + // This test simulates an RP policy failure, not a mismatch between the stored passkey + // and the authenticator data flags, so we'll make the stored passkey match the + // authenticator data flags + test.IsStoredPasskeyBackupEligible = true; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "Credential backup eligibility is disallowed, but the credential was eligible for backup", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButRequired() + { + var test = new AssertionTest(); + test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags & ~AuthenticatorDataFlags.BackupEligible, + }); + + // This test simulates an RP policy failure, not a mismatch between the stored passkey + // and the authenticator data flags, so we'll make the stored passkey match the + // authenticator data flags + test.IsStoredPasskeyBackupEligible = false; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "Credential backup eligibility is required, but the credential was not eligible for backup", + result.Failure.Message); + } + + [Theory] + [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] + [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] + public async Task Attestation_Fails_WhenAuthenticatorDataIsBackedUp(PasskeyOptions.CredentialBackupPolicy backedUpPolicy) + { + var test = new AssertionTest(); + test.IdentityOptions.Passkey.BackedUpCredentialPolicy = backedUpPolicy; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible | AuthenticatorDataFlags.BackedUp, + }); + + // This test simulates an RP policy failure, not a mismatch between the stored passkey + // and the authenticator data flags, so we'll make the stored passkey match the + // authenticator data flags + test.IsStoredPasskeyBackupEligible = true; + test.IsStoredPasskeyBackedUp = true; + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsBackedUpButDisallowed() + { + var test = new AssertionTest(); + test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible | AuthenticatorDataFlags.BackedUp, + }); + + // This test simulates an RP policy failure, not a mismatch between the stored passkey + // and the authenticator data flags, so we'll make the stored passkey match the + // authenticator data flags + test.IsStoredPasskeyBackupEligible = true; + test.IsStoredPasskeyBackedUp = true; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "Credential backup is disallowed, but the credential was backed up", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsNotBackedUpButRequired() + { + var test = new AssertionTest(); + test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags & ~AuthenticatorDataFlags.BackedUp, + }); + + // This test simulates an RP policy failure, not a mismatch between the stored passkey + // and the authenticator data flags, so we'll make the stored passkey match the + // authenticator data flags + test.IsStoredPasskeyBackedUp = false; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "Credential backup is required, but the credential was not backed up", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButStoredPasskeyIs() + { + var test = new AssertionTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags & ~AuthenticatorDataFlags.BackupEligible, + }); + test.IsStoredPasskeyBackupEligible = true; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "The stored credential is eligible for backup, but the provided credential was unexpectedly ineligible for backup.", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthenticatorDataIsBackupEligibleButStoredPasskeyIsNot() + { + var test = new AssertionTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, + }); + test.IsStoredPasskeyBackupEligible = false; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "The stored credential is ineligible for backup, but the provided credential was unexpectedly eligible for backup", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenProvidedCredentialIsNotInAllowedCredentials() + { + var test = new AssertionTest(); + var allowedCredentialId = test.CredentialId.ToArray(); + allowedCredentialId[0]++; + test.AddAllowedCredential(allowedCredentialId); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "The provided credential ID was not in the list of allowed credentials", + result.Failure.Message); + } + + [Fact] + public async Task Succeeds_WhenProvidedCredentialIsInAllowedCredentials() + { + var test = new AssertionTest(); + var otherAllowedCredentialId = test.CredentialId.ToArray(); + otherAllowedCredentialId[0]++; + test.AddAllowedCredential(test.CredentialId); + test.AddAllowedCredential(otherAllowedCredentialId); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Fails_WhenCredentialDoesNotExistOnTheUser(bool isUserIdentified) + { + var test = new AssertionTest + { + IsUserIdentified = isUserIdentified, + DoesCredentialExistOnUser = false + }; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The provided credential does not belong to the specified user", result.Failure.Message); + } + + private static string GetInvalidBase64UrlValue(string base64UrlValue) + { + var rawValue = Base64Url.DecodeFromChars(base64UrlValue); + return Convert.ToBase64String(rawValue) + "=="; + } + + private sealed class AssertionTest : PasskeyScenarioTest> + { + private static readonly byte[] _defaultChallenge = [1, 2, 3, 4, 5, 6, 7, 8]; + private static readonly byte[] _defaultCredentialId = [1, 2, 3, 4, 5, 6, 7, 8]; + + private readonly List _allowCredentials = []; + + public IdentityOptions IdentityOptions { get; } = new(); + public string? RpId { get; set; } = "example.com"; + public string? Origin { get; set; } = "https://example.com"; + public PocoUser User { get; set; } = new() + { + Id = "df0a3af4-bd65-440f-82bd-5b839e300dcd", + UserName = "johndoe", + }; + public bool IsUserIdentified { get; set; } + public bool IsStoredPasskeyBackupEligible { get; set; } + public bool IsStoredPasskeyBackedUp { get; set; } + public bool DoesCredentialExistOnUser { get; set; } = true; + public COSEAlgorithmIdentifier Algorithm { get; set; } = COSEAlgorithmIdentifier.ES256; + public ReadOnlyMemory Challenge { get; set; } = _defaultChallenge; + public ReadOnlyMemory CredentialId { get; set; } = _defaultCredentialId; + public ComputedValue AuthenticatorDataArgs { get; } = new(); + public ComputedValue> AuthenticatorData { get; } = new(); + public ComputedValue> ClientDataHash { get; } = new(); + public ComputedValue> Signature { get; } = new(); + public ComputedJsonObject OriginalOptionsJson { get; } = new(); + public ComputedJsonObject ClientDataJson { get; } = new(); + public ComputedJsonObject CredentialJson { get; } = new(); + public ComputedValue StoredPasskey { get; } = new(); + + public void AddAllowedCredential(ReadOnlyMemory credentialId) + { + _allowCredentials.Add(new() + { + Id = BufferSource.FromBytes(credentialId), + Type = "public-key", + Transports = ["internal"], + }); + } + + protected override async Task> RunCoreAsync() + { + var identityOptions = Options.Create(IdentityOptions); + var handler = new DefaultPasskeyHandler(identityOptions); + var credential = CredentialKeyPair.Generate(Algorithm); + var allowCredentialsJson = JsonSerializer.Serialize( + _allowCredentials, + IdentityJsonSerializerContext.Default.IReadOnlyListPublicKeyCredentialDescriptor); + var originalOptionsJson = OriginalOptionsJson.Compute($$""" + { + "challenge": {{ToBase64UrlJsonValue(Challenge)}}, + "rpId": {{ToJsonValue(RpId)}}, + "allowCredentials": {{allowCredentialsJson}}, + "timeout": 60000, + "userVerification": "preferred", + "hints": [] + } + """); + var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new() + { + RpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(RpId ?? string.Empty)), + Flags = AuthenticatorDataFlags.UserPresent, + SignCount = 1, + }); + var authenticatorData = AuthenticatorData.Compute(MakeAuthenticatorData(authenticatorDataArgs)); + var clientDataJson = ClientDataJson.Compute($$""" + { + "challenge": {{ToBase64UrlJsonValue(Challenge)}}, + "origin": {{ToJsonValue(Origin)}}, + "type": "webauthn.get" + } + """); + var clientDataJsonBytes = Encoding.UTF8.GetBytes(clientDataJson?.ToString() ?? string.Empty); + var clientDataHash = ClientDataHash.Compute(SHA256.HashData(clientDataJsonBytes)); + var dataToSign = (byte[])[.. authenticatorData.Span, .. clientDataHash.Span]; + var signature = Signature.Compute(credential.SignData(dataToSign)); + var credentialJson = CredentialJson.Compute($$""" + { + "id": {{ToBase64UrlJsonValue(CredentialId)}}, + "response": { + "authenticatorData": {{ToBase64UrlJsonValue(authenticatorData)}}, + "clientDataJSON": {{ToBase64UrlJsonValue(clientDataJson)}}, + "signature": {{ToBase64UrlJsonValue(signature)}}, + "userHandle": {{ToBase64UrlJsonValue(User.Id)}} + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + } + """); + + var credentialPublicKey = credential.EncodePublicKeyCbor(); + var storedPasskey = StoredPasskey.Compute(new( + CredentialId.ToArray(), + credentialPublicKey.ToArray(), + name: null, + createdAt: default, + signCount: 0, + transports: null, + isUserVerified: true, + isBackupEligible: IsStoredPasskeyBackupEligible, + isBackedUp: IsStoredPasskeyBackedUp, + attestationObject: [], + clientDataJson: [])); + + var httpContext = new Mock(); + httpContext.Setup(c => c.Request.Headers.Origin).Returns(new StringValues(Origin)); + + var userManager = MockHelpers.MockUserManager(); + userManager + .Setup(m => m.FindByIdAsync(User.Id)) + .Returns(Task.FromResult(User)); + userManager + .Setup(m => m.GetPasskeyAsync(It.IsAny(), It.IsAny())) + .Returns((PocoUser user, byte[] credentialId) => Task.FromResult( + DoesCredentialExistOnUser && user == User && CredentialId.Span.SequenceEqual(credentialId) + ? storedPasskey + : null)); + + if (IsUserIdentified) + { + userManager + .Setup(m => m.GetUserIdAsync(User)) + .Returns(Task.FromResult(User.Id)); + } + + var context = new PasskeyAssertionContext + { + CredentialJson = credentialJson, + OriginalOptionsJson = originalOptionsJson, + HttpContext = httpContext.Object, + UserManager = userManager.Object, + User = IsUserIdentified ? User : null, + }; + + return await handler.PerformAssertionAsync(context); + } + } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs new file mode 100644 index 000000000000..bb15253efbcc --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs @@ -0,0 +1,1330 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace Microsoft.AspNetCore.Identity.Test; + +using static JsonHelpers; +using static CredentialHelpers; + +public class DefaultPasskeyHandlerAttestationTest +{ + [Fact] + public async Task CanSucceed() + { + var test = new AttestationTest(); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenCredentialIdIsMissing() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + Assert.True(credentialJson.Remove("id")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'id'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenCredentialIdIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["id"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialIdIsNotBase64UrlEncoded() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlCredentialId = (string)credentialJson["id"]!; + credentialJson["id"] = GetInvalidBase64UrlValue(base64UrlCredentialId); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialTypeIsMissing() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + Assert.True(credentialJson.Remove("type")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'type'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenCredentialTypeIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["type"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialTypeIsNotPublicKey() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["type"] = "unexpected-value"; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("Expected credential type 'public-key', got 'unexpected-value'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialResponseIsMissing() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + Assert.True(credentialJson.Remove("response")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'response'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("\"hello\"")] + public async Task Fails_WhenCredentialResponseIsNotAnObject(string jsonValue) + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsRpNameIsMissing() + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + var rp = originalOptionsJson["rp"]!.AsObject(); + Assert.True(rp.Remove("name")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'name'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenOriginalOptionsRpNameIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + originalOptionsJson["rp"]!["name"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsRpIsMissing() + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + Assert.True(originalOptionsJson.Remove("rp")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'rp'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsUserIdIsMissing() + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + var user = originalOptionsJson["user"]!.AsObject(); + Assert.True(user.Remove("id")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'id'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsUserIdIsNotBase64UrlEncoded() + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + var base64UrlUserId = (string)originalOptionsJson["user"]!["id"]!; + originalOptionsJson["user"]!["id"] = GetInvalidBase64UrlValue(base64UrlUserId); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenOriginalOptionsUserIdIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + originalOptionsJson["user"]!["id"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsUserNameIsMissing() + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + var user = originalOptionsJson["user"]!.AsObject(); + Assert.True(user.Remove("name")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'name'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenOriginalOptionsUserNameIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + originalOptionsJson["user"]!["name"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsUserDisplayNameIsMissing() + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + var user = originalOptionsJson["user"]!.AsObject(); + Assert.True(user.Remove("displayName")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'displayName'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenOriginalOptionsUserDisplayNameIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + originalOptionsJson["user"]!["displayName"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsUserIsMissing() + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + Assert.True(originalOptionsJson.Remove("user")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'user'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsChallengeIsMissing() + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + Assert.True(originalOptionsJson.Remove("challenge")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'challenge'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEncoded() + { + var test = new AttestationTest(); + + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + var base64UrlChallenge = (string)originalOptionsJson["challenge"]!; + originalOptionsJson["challenge"] = GetInvalidBase64UrlValue(base64UrlChallenge); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenOriginalOptionsChallengeIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => + { + originalOptionsJson["challenge"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The original passkey creation options had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonIsMissing() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var response = credentialJson["response"]!.AsObject(); + Assert.True(response.Remove("clientDataJSON")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'clientDataJSON'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenClientDataJsonIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["clientDataJSON"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonIsEmptyString() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["clientDataJSON"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestationObjectIsMissing() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var response = credentialJson["response"]!.AsObject(); + Assert.True(response.Remove("attestationObject")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'attestationObject'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenAttestationObjectIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["attestationObject"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestationObjectIsEmptyString() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + credentialJson["response"]!["attestationObject"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation object had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonTypeIsMissing() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + Assert.True(clientDataJson.Remove("type")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'type'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenClientDataJsonTypeIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["type"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Theory] + [InlineData("")] + [InlineData("webauthn.get")] + [InlineData("unexpected-value")] + public async Task Fails_WhenClientDataJsonTypeIsNotExpected(string value) + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["type"] = value; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("Expected the client data JSON 'type' field to be 'webauthn.create'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonChallengeIsMissing() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + Assert.True(clientDataJson.Remove("challenge")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'challenge'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenClientDataJsonChallengeIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["challenge"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonChallengeIsEmptyString() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["challenge"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator response challenge does not match original challenge", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + var base64UrlChallenge = (string)clientDataJson["challenge"]!; + clientDataJson["challenge"] = GetInvalidBase64UrlValue(base64UrlChallenge); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("base64url string", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonChallengeIsNotRequestChallenge() + { + var test = new AttestationTest(); + var modifiedChallenge = (byte[])[.. test.Challenge.Span]; + for (var i = 0; i < modifiedChallenge.Length; i++) + { + modifiedChallenge[i]++; + } + + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["challenge"] = Base64Url.EncodeToString(modifiedChallenge); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator response challenge does not match original challenge", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonOriginIsMissing() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + Assert.True(clientDataJson.Remove("origin")); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'origin'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("null")] + [InlineData("{}")] + public async Task Fails_WhenClientDataJsonOriginIsNotString(string jsonValue) + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["origin"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonOriginIsEmptyString() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["origin"] = ""; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator response had an invalid origin ''", result.Failure.Message); + } + + [Theory] + [InlineData("https://example.com", "http://example.com")] + [InlineData("http://example.com", "https://example.com")] + [InlineData("https://example.com", "https://foo.example.com")] + [InlineData("https://example.com", "https://example.com:5000")] + public async Task Fails_WhenClientDataJsonOriginDoesNotMatchTheExpectedOrigin(string expectedOrigin, string returnedOrigin) + { + var test = new AttestationTest + { + Origin = expectedOrigin, + }; + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["origin"] = returnedOrigin; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith($"The authenticator response had an invalid origin '{returnedOrigin}'", result.Failure.Message); + } + + [Theory] + [InlineData("42")] + [InlineData("\"hello\"")] + public async Task Fails_WhenClientDataJsonTokenBindingIsNotObject(string jsonValue) + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["tokenBinding"] = JsonNode.Parse(jsonValue); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonTokenBindingStatusIsMissing() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["tokenBinding"] = JsonNode.Parse("{}"); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The client data JSON had an invalid format", result.Failure.Message); + Assert.Contains("was missing required properties including: 'status'", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenClientDataJsonTokenBindingStatusIsInvalid() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + clientDataJson["tokenBinding"] = JsonNode.Parse(""" + { + "status": "unexpected-value" + } + """); + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("Invalid token binding status 'unexpected-value'", result.Failure.Message); + } + + [Fact] + public async Task Succeeds_WhenAuthDataContainsExtensionData() + { + var test = new AttestationTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.HasExtensionData, + Extensions = (byte[])[0xA0] // Empty CBOR map. + }); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenAuthDataIsNotBackupEligibleButBackedUp() + { + var test = new AttestationTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = (args.Flags | AuthenticatorDataFlags.BackedUp) & ~AuthenticatorDataFlags.BackupEligible, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The credential is backed up, but the authenticator data flags did not have the 'BackupEligible' flag", result.Failure.Message); + } + + [Theory] + [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] + [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] + public async Task Succeeds_WhenAuthDataIsBackupEligible(PasskeyOptions.CredentialBackupPolicy backupEligibility) + { + var test = new AttestationTest(); + test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = backupEligibility; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, + }); + + var result = await test.RunAsync(); + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenAuthDataIsBackupEligibleButDisallowed() + { + var test = new AttestationTest(); + test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "Credential backup eligibility is disallowed, but the credential was eligible for backup", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthDataIsNotBackupEligibleButRequired() + { + var test = new AttestationTest(); + test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags & ~AuthenticatorDataFlags.BackupEligible, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "Credential backup eligibility is required, but the credential was not eligible for backup", + result.Failure.Message); + } + + [Theory] + [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] + [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] + public async Task Fails_WhenAuthDataIsBackedUp(PasskeyOptions.CredentialBackupPolicy backedUpPolicy) + { + var test = new AttestationTest(); + test.IdentityOptions.Passkey.BackedUpCredentialPolicy = backedUpPolicy; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible | AuthenticatorDataFlags.BackedUp, + }); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Fails_WhenAuthDataIsBackedUpButDisallowed() + { + var test = new AttestationTest(); + test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags | AuthenticatorDataFlags.BackupEligible | AuthenticatorDataFlags.BackedUp, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "Credential backup is disallowed, but the credential was backed up", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAuthDataIsNotBackedUpButRequired() + { + var test = new AttestationTest(); + test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags & ~AuthenticatorDataFlags.BackedUp, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "Credential backup is required, but the credential was not backed up", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestationObjectIsNotCborEncoded() + { + var test = new AttestationTest(); + test.AttestationObject.Transform(bytes => Encoding.UTF8.GetBytes("Not a CBOR map")); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation object had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestationObjectFmtIsMissing() + { + var test = new AttestationTest(); + test.AttestationObjectArgs.Transform(args => args with + { + Format = null, + CborMapLength = args.CborMapLength - 1, // Because of the removed format + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation object did not include an attestation statement format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestationObjectStmtFieldIsMissing() + { + var test = new AttestationTest(); + test.AttestationObjectArgs.Transform(args => args with + { + AttestationStatement = null, + CborMapLength = args.CborMapLength - 1, // Because of the removed attestation statement + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation object did not include an attestation statement", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestationObjectAuthDataFieldIsMissing() + { + var test = new AttestationTest(); + test.AttestationObjectArgs.Transform(args => args with + { + AuthenticatorData = null, + CborMapLength = args.CborMapLength - 1, // Because of the removed authenticator data + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation object did not include authenticator data", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestationObjectAuthDataFieldIsEmpty() + { + var test = new AttestationTest(); + test.AttestationObjectArgs.Transform(args => args with + { + AuthenticatorData = ReadOnlyMemory.Empty, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator data had an invalid byte count of 0", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestedCredentialDataIsPresentButWithoutFlag() + { + var test = new AttestationTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + // Remove the flag without removing the attested credential data + Flags = args.Flags & ~AuthenticatorDataFlags.HasAttestedCredentialData, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator data had an invalid format", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestedCredentialDataIsNotPresentButWithFlag() + { + var test = new AttestationTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + // Remove the attested credential data without changing the flags + AttestedCredentialData = null, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attested credential data had an invalid byte count of 0", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestedCredentialDataIsNotPresent() + { + var test = new AttestationTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + Flags = args.Flags & ~AuthenticatorDataFlags.HasAttestedCredentialData, + AttestedCredentialData = null, + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("No attested credential data was provided by the authenticator", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenAttestedCredentialDataHasExtraBytes() + { + var test = new AttestationTest(); + test.AttestedCredentialData.Transform(attestedCredentialData => + { + return (byte[])[.. attestedCredentialData.Span, 0xFF, 0xFF, 0xFF, 0xFF]; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The authenticator data had an invalid format", result.Failure.Message); + } + + [Theory] + [InlineData((int)COSEAlgorithmIdentifier.PS256)] + [InlineData((int)COSEAlgorithmIdentifier.PS384)] + [InlineData((int)COSEAlgorithmIdentifier.PS512)] + [InlineData((int)COSEAlgorithmIdentifier.RS256)] + [InlineData((int)COSEAlgorithmIdentifier.RS384)] + [InlineData((int)COSEAlgorithmIdentifier.RS512)] + [InlineData((int)COSEAlgorithmIdentifier.ES256)] + [InlineData((int)COSEAlgorithmIdentifier.ES384)] + [InlineData((int)COSEAlgorithmIdentifier.ES512)] + public async Task Succeeds_WithSupportedAlgorithms(int algorithm) + { + var test = new AttestationTest + { + Algorithm = (COSEAlgorithmIdentifier)algorithm, + }; + + // Only include the specific algorithm we're testing, + // just to sanity check that we're using the algorithm we expect + test.SupportedPublicKeyCredentialParameters.Transform(_ => [new((COSEAlgorithmIdentifier)algorithm)]); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData((int)COSEAlgorithmIdentifier.PS256)] + [InlineData((int)COSEAlgorithmIdentifier.PS384)] + [InlineData((int)COSEAlgorithmIdentifier.PS512)] + [InlineData((int)COSEAlgorithmIdentifier.RS256)] + [InlineData((int)COSEAlgorithmIdentifier.RS384)] + [InlineData((int)COSEAlgorithmIdentifier.RS512)] + [InlineData((int)COSEAlgorithmIdentifier.ES256)] + [InlineData((int)COSEAlgorithmIdentifier.ES384)] + [InlineData((int)COSEAlgorithmIdentifier.ES512)] + public async Task Fails_WhenAlgorithmIsNotSupported(int algorithm) + { + var test = new AttestationTest + { + Algorithm = (COSEAlgorithmIdentifier)algorithm, + }; + test.SupportedPublicKeyCredentialParameters.Transform(parameters => + { + // Exclude the specific algorithm we're testing, which should cause the failure + return [.. parameters.Where(p => p.Alg != (COSEAlgorithmIdentifier)algorithm)]; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The credential public key algorithm does not match any of the supported algorithms", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenVerifyAttestationStatementAsyncReturnsFalse() + { + var test = new AttestationTest + { + ShouldFailAttestationStatementVerification = true, + }; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The attestation statement was not valid", result.Failure.Message); + } + + [Theory] + [InlineData(1024)] + [InlineData(2048)] + public async Task Fails_WhenCredentialIdIsTooLong(int length) + { + var test = new AttestationTest + { + CredentialId = RandomNumberGenerator.GetBytes(length), + }; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("Expected the credential ID to have a length between 1 and 1023 bytes", result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialIdDoesNotMatchAttestedCredentialId() + { + var test = new AttestationTest(); + test.AttestedCredentialDataArgs.Transform(args => + { + var newCredentialId = args.CredentialId.ToArray(); + newCredentialId[0]++; + return args with { CredentialId = newCredentialId }; + }); + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith( + "The provided credential ID does not match the credential ID in the attested credential data", + result.Failure.Message); + } + + [Fact] + public async Task Fails_WhenCredentialIdAlreadyExistsForAnotherUser() + { + var test = new AttestationTest + { + DoesCredentialAlreadyExistForAnotherUser = true, + }; + + var result = await test.RunAsync(); + + Assert.False(result.Succeeded); + Assert.StartsWith("The credential is already registered for a user", result.Failure.Message); + } + + private static string GetInvalidBase64UrlValue(string base64UrlValue) + { + var rawValue = Base64Url.DecodeFromChars(base64UrlValue); + return Convert.ToBase64String(rawValue) + "=="; + } + + private sealed class AttestationTest : PasskeyScenarioTest + { + private static readonly byte[] _defaultChallenge = [1, 2, 3, 4, 5, 6, 7, 8]; + private static readonly byte[] _defaultCredentialId = [1, 2, 3, 4, 5, 6, 7, 8]; + private static readonly byte[] _defaultAaguid = new byte[16]; + private static readonly byte[] _defaultAttestationStatement = [0xA0]; // Empty CBOR map + + public IdentityOptions IdentityOptions { get; } = new(); + public string? RpId { get; set; } = "example.com"; + public string? RpName { get; set; } = "Example"; + public string? UserId { get; set; } = "df0a3af4-bd65-440f-82bd-5b839e300dcd"; + public string? UserName { get; set; } = "johndoe"; + public string? UserDisplayName { get; set; } = "John Doe"; + public string? Origin { get; set; } = "https://example.com"; + public bool ShouldFailAttestationStatementVerification { get; set; } + public bool DoesCredentialAlreadyExistForAnotherUser { get; set; } + public COSEAlgorithmIdentifier Algorithm { get; set; } = COSEAlgorithmIdentifier.ES256; + public ReadOnlyMemory Challenge { get; set; } = _defaultChallenge; + public ReadOnlyMemory CredentialId { get; set; } = _defaultCredentialId; + public ComputedValue> SupportedPublicKeyCredentialParameters { get; } = new(); + public ComputedValue AttestedCredentialDataArgs { get; } = new(); + public ComputedValue AuthenticatorDataArgs { get; } = new(); + public ComputedValue AttestationObjectArgs { get; } = new(); + public ComputedValue> AttestedCredentialData { get; } = new(); + public ComputedValue> AuthenticatorData { get; } = new(); + public ComputedValue> AttestationObject { get; } = new(); + public ComputedJsonObject OriginalOptionsJson { get; } = new(); + public ComputedJsonObject ClientDataJson { get; } = new(); + public ComputedJsonObject CredentialJson { get; } = new(); + + protected override async Task RunCoreAsync() + { + var identityOptions = Options.Create(IdentityOptions); + var handler = new TestPasskeyHandler(identityOptions) + { + ShouldFailAttestationStatementVerification = ShouldFailAttestationStatementVerification, + }; + var supportedPublicKeyCredentialParameters = SupportedPublicKeyCredentialParameters.Compute( + PublicKeyCredentialParameters.AllSupportedParameters); + var pubKeyCredParamsJson = JsonSerializer.Serialize( + supportedPublicKeyCredentialParameters, + IdentityJsonSerializerContext.Default.IReadOnlyListPublicKeyCredentialParameters); + var originalOptionsJson = OriginalOptionsJson.Compute($$""" + { + "rp": { + "name": {{ToJsonValue(RpName)}}, + "id": {{ToJsonValue(RpId)}} + }, + "user": { + "id": {{ToBase64UrlJsonValue(UserId)}}, + "name": {{ToJsonValue(UserName)}}, + "displayName": {{ToJsonValue(UserDisplayName)}} + }, + "challenge": {{ToBase64UrlJsonValue(Challenge)}}, + "pubKeyCredParams": {{pubKeyCredParamsJson}}, + "timeout": 60000, + "excludeCredentials": [], + "attestation": "none", + "hints": [], + "extensions": {} + } + """); + var credential = CredentialKeyPair.Generate(Algorithm); + var credentialPublicKey = credential.EncodePublicKeyCbor(); + var attestedCredentialDataArgs = AttestedCredentialDataArgs.Compute(new() + { + Aaguid = _defaultAaguid, + CredentialId = CredentialId, + CredentialPublicKey = credentialPublicKey, + }); + var attestedCredentialData = AttestedCredentialData.Compute(MakeAttestedCredentialData(attestedCredentialDataArgs)); + var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new() + { + SignCount = 1, + RpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(RpId ?? string.Empty)), + AttestedCredentialData = attestedCredentialData, + Flags = AuthenticatorDataFlags.UserPresent | AuthenticatorDataFlags.HasAttestedCredentialData, + }); + var authenticatorData = AuthenticatorData.Compute(MakeAuthenticatorData(authenticatorDataArgs)); + var attestationObjectArgs = AttestationObjectArgs.Compute(new() + { + CborMapLength = 3, // Format, AuthenticatorData, AttestationStatement + Format = "none", + AuthenticatorData = authenticatorData, + AttestationStatement = _defaultAttestationStatement, + }); + var attestationObject = AttestationObject.Compute(MakeAttestationObject(attestationObjectArgs)); + var clientDataJson = ClientDataJson.Compute($$""" + { + "challenge": {{ToBase64UrlJsonValue(Challenge)}}, + "origin": {{ToJsonValue(Origin)}}, + "type": "webauthn.create" + } + """); + var credentialJson = CredentialJson.Compute($$""" + { + "id": {{ToBase64UrlJsonValue(CredentialId)}}, + "response": { + "attestationObject": {{ToBase64UrlJsonValue(attestationObject)}}, + "clientDataJSON": {{ToBase64UrlJsonValue(clientDataJson)}}, + "transports": [ + "internal" + ] + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + } + """); + + var httpContext = new Mock(); + httpContext.Setup(c => c.Request.Headers.Origin).Returns(new StringValues(Origin)); + + var userManager = MockHelpers.MockUserManager(); + + if (DoesCredentialAlreadyExistForAnotherUser) + { + var existingUser = new PocoUser(userName: "existing_user"); + userManager + .Setup(m => m.FindByPasskeyIdAsync(It.IsAny())) + .Returns((byte[] credentialId) => + { + if (CredentialId.Span.SequenceEqual(credentialId)) + { + return Task.FromResult(existingUser); + } + + return Task.FromResult(null); + }); + } + + var context = new PasskeyAttestationContext + { + CredentialJson = credentialJson, + OriginalOptionsJson = originalOptionsJson, + HttpContext = httpContext.Object, + UserManager = userManager.Object, + }; + + return await handler.PerformAttestationAsync(context); + } + + private sealed class TestPasskeyHandler(IOptions options) : DefaultPasskeyHandler(options) + { + public bool ShouldFailAttestationStatementVerification { get; init; } + + protected override Task VerifyAttestationStatementAsync( + ReadOnlyMemory attestationObject, + ReadOnlyMemory clientDataHash, + HttpContext httpContext) + { + if (ShouldFailAttestationStatementVerification) + { + return Task.FromResult(false); + } + + return base.VerifyAttestationStatementAsync( + attestationObject, + clientDataHash, + httpContext); + } + } + } +} diff --git a/src/Identity/test/Identity.Test/Passkeys/JsonHelpers.cs b/src/Identity/test/Identity.Test/Passkeys/JsonHelpers.cs new file mode 100644 index 000000000000..5a8b4f46b96d --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/JsonHelpers.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Buffers.Text; +using System.Text; + +namespace Microsoft.AspNetCore.Identity.Test; + +internal static class JsonHelpers +{ + public static string ToJsonValue(string? value) + => value is null ? "null" : $"\"{value}\""; + + public static string ToBase64UrlJsonValue(ReadOnlyMemory? bytes) + => !bytes.HasValue ? "null" : $"\"{Base64Url.EncodeToString(bytes.Value.Span)}\""; + + public static string ToBase64UrlJsonValue(string? value) + => value is null ? "null" : $"\"{Base64Url.EncodeToString(Encoding.UTF8.GetBytes(value))}\""; +} diff --git a/src/Identity/test/Identity.Test/Passkeys/PasskeyScenarioTest.cs b/src/Identity/test/Identity.Test/Passkeys/PasskeyScenarioTest.cs new file mode 100644 index 000000000000..8f34905bbfbb --- /dev/null +++ b/src/Identity/test/Identity.Test/Passkeys/PasskeyScenarioTest.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Microsoft.AspNetCore.Identity.Test; + +// Represents a test for a passkey scenario (attestation or assertion) +internal abstract class PasskeyScenarioTest +{ + private bool _hasStarted; + + public Task RunAsync() + { + if (_hasStarted) + { + throw new InvalidOperationException("The test can only be run once."); + } + + _hasStarted = true; + return RunCoreAsync(); + } + + protected abstract Task RunCoreAsync(); + + // While some test configuration can be set directly on scenario classes (AttestationTest and AssertionTest), + // individual tests may need to modify values computed during execution (e.g., JSON payloads, hashes). + // This helper enables trivial customization of test scenarios by allowing injection of custom logic to + // transform runtime values. + public class ComputedValue + { + private bool _isComputed; + private TValue? _computedValue; + private Func? _transformFunc; + + public TValue GetValue() + { + if (!_isComputed) + { + throw new InvalidOperationException("Cannot get the value because it has not yet been computed."); + } + + return _computedValue!; + } + + public virtual TValue Compute(TValue initialValue) + { + if (_isComputed) + { + throw new InvalidOperationException("Cannot compute a value multiple times."); + } + + if (_transformFunc is not null) + { + initialValue = _transformFunc(initialValue) ?? initialValue; + } + + _isComputed = true; + _computedValue = initialValue; + return _computedValue; + } + + public virtual void Transform(Func transform) + { + if (_transformFunc is not null) + { + throw new InvalidOperationException("Cannot transform a value multiple times."); + } + + _transformFunc = transform; + } + } + + public sealed class ComputedJsonObject : ComputedValue + { + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + WriteIndented = true, + }; + + private JsonElement? _jsonElementValue; + + public JsonElement GetValueAsJsonElement() + { + if (_jsonElementValue is null) + { + var rawValue = GetValue() ?? throw new InvalidOperationException("Cannot get the value as a JSON element because it is null."); + try + { + _jsonElementValue = JsonSerializer.Deserialize(rawValue, _jsonSerializerOptions); + } + catch (JsonException ex) + { + throw new InvalidOperationException("Cannot get the value as a JSON element because it is not valid JSON.", ex); + } + } + + return _jsonElementValue.Value; + } + + public void TransformAsJsonObject(Action transform) + { + Transform(value => + { + try + { + var jsonObject = JsonNode.Parse(value)?.AsObject() + ?? throw new InvalidOperationException("Could not transform the JSON value because it was unexpectedly null."); + transform(jsonObject); + return jsonObject.ToJsonString(_jsonSerializerOptions); + } + catch (JsonException ex) + { + throw new InvalidOperationException("Could not transform the value because it was not valid JSON.", ex); + } + }); + } + } +} diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 99e525cb275e..e40efe48fab4 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -1,7 +1,10 @@ // 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.Text; using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; @@ -1375,6 +1378,114 @@ public async Task TwoFactorSignInLockedOutResultIsDependentOnTheAccessFailedAsyn auth.Verify(); } + [Fact] + public async Task GeneratePasskeyCreationOptionsAsyncReturnsExpectedOptions() + { + // Arrange + var user = new PocoUser { UserName = "Foo" }; + var userManager = SetupUserManager(user); + var context = new DefaultHttpContext(); + var identityOptions = new IdentityOptions() + { + Passkey = new() + { + ChallengeSize = 32, + Timeout = TimeSpan.FromMinutes(10), + ServerDomain = "example.com", + }, + }; + var signInManager = SetupSignInManager(userManager.Object, context, identityOptions: identityOptions); + var userEntity = new PasskeyUserEntity(id: "1234", name: "Foo", displayName: "Foo"); + var creationArgs = new PasskeyCreationArgs(userEntity) + { + Attestation = "some-attestation-value", + AuthenticatorSelection = new AuthenticatorSelectionCriteria + { + AuthenticatorAttachment = "cross-platform", + ResidentKey = "required", + UserVerification = "preferred" + }, + Extensions = JsonElement.Parse(""" + { + "my.bool.extension": true, + "my.object.extension": { + "key": "value" + } + } + """), + }; + + // Act + var options = await signInManager.GeneratePasskeyCreationOptionsAsync(creationArgs); + var optionsJson = JsonNode.Parse(options.AsJson()).AsObject(); + var challenge = Base64Url.DecodeFromChars(optionsJson["challenge"].ToString()); + + // Assert + Assert.NotNull(options); + Assert.Same(userEntity, options.UserEntity); + Assert.Equal(identityOptions.Passkey.ServerDomain, optionsJson["rp"]["id"].ToString()); + Assert.Equal(identityOptions.Passkey.ServerDomain, optionsJson["rp"]["name"].ToString()); + Assert.Equal(identityOptions.Passkey.ChallengeSize, challenge.Length); + Assert.Equal((uint)identityOptions.Passkey.Timeout.TotalMilliseconds, (uint)optionsJson["timeout"]); + Assert.Equal(creationArgs.Attestation, optionsJson["attestation"].ToString()); + Assert.Equal( + creationArgs.AuthenticatorSelection.AuthenticatorAttachment, + optionsJson["authenticatorSelection"]["authenticatorAttachment"].ToString()); + Assert.Equal( + creationArgs.AuthenticatorSelection.ResidentKey, + optionsJson["authenticatorSelection"]["residentKey"].ToString()); + Assert.Equal( + creationArgs.AuthenticatorSelection.UserVerification, + optionsJson["authenticatorSelection"]["userVerification"].ToString()); + Assert.True((bool)optionsJson["extensions"]["my.bool.extension"]); + Assert.Equal("value", optionsJson["extensions"]["my.object.extension"]["key"].ToString()); + } + + [Fact] + public async Task GeneratePasskeyRequestOptionsAsyncReturnsExpectedOptions() + { + // Arrange + var user = new PocoUser { UserName = "Foo" }; + var userManager = SetupUserManager(user); + var context = new DefaultHttpContext(); + var identityOptions = new IdentityOptions() + { + Passkey = new() + { + ChallengeSize = 32, + Timeout = TimeSpan.FromMinutes(10), + ServerDomain = "example.com", + }, + }; + var signInManager = SetupSignInManager(userManager.Object, context, identityOptions: identityOptions); + var requestArgs = new PasskeyRequestArgs + { + UserVerification = "preferred", + Extensions = JsonElement.Parse(""" + { + "my.bool.extension": true, + "my.object.extension": { + "key": "value" + } + } + """), + }; + + // Act + var options = await signInManager.GeneratePasskeyRequestOptionsAsync(requestArgs); + var optionsJson = JsonNode.Parse(options.AsJson()).AsObject(); + var challenge = Base64Url.DecodeFromChars(optionsJson["challenge"].ToString()); + + // Assert + Assert.NotNull(options); + Assert.Equal(identityOptions.Passkey.ServerDomain, optionsJson["rpId"].ToString()); + Assert.Equal(identityOptions.Passkey.ChallengeSize, challenge.Length); + Assert.Equal((uint)identityOptions.Passkey.Timeout.TotalMilliseconds, (uint)optionsJson["timeout"]); + Assert.Equal(requestArgs.UserVerification, optionsJson["userVerification"].ToString()); + Assert.True((bool)optionsJson["extensions"]["my.bool.extension"]); + Assert.Equal("value", optionsJson["extensions"]["my.object.extension"]["key"].ToString()); + } + private static SignInManager SetupSignInManagerType(UserManager manager, HttpContext context, string typeName) { var contextAccessor = new Mock(); diff --git a/src/Identity/test/Identity.Test/UserManagerTest.cs b/src/Identity/test/Identity.Test/UserManagerTest.cs index 04f9e2afa476..5dc451f83ba3 100644 --- a/src/Identity/test/Identity.Test/UserManagerTest.cs +++ b/src/Identity/test/Identity.Test/UserManagerTest.cs @@ -668,6 +668,81 @@ public async Task RemoveClaimCallsStore() store.VerifyAll(); } + [Fact] + public async Task SetPasskeyAsyncCallsStore() + { + // Setup + var store = new Mock>(); + var user = new PocoUser { UserName = "Foo" }; + var passkey = new UserPasskeyInfo(null, null, null, default, 0, null, false, false, false, null, null); + store.Setup(s => s.SetPasskeyAsync(user, passkey, CancellationToken.None)).Returns(Task.CompletedTask).Verifiable(); + store.Setup(s => s.UpdateAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Success).Verifiable(); + var userManager = MockHelpers.TestUserManager(store.Object); + + // Act + var result = await userManager.SetPasskeyAsync(user, passkey); + + // Assert + Assert.True(result.Succeeded); + store.VerifyAll(); + } + + [Fact] + public async Task GetPasskeysAsyncCallsStore() + { + // Setup + var store = new Mock>(); + var user = new PocoUser { UserName = "Foo" }; + var passkey = new UserPasskeyInfo(null, null, null, default, 0, null, false, false, false, null, null); + var passkeys = (IList)[passkey]; + store.Setup(s => s.GetPasskeysAsync(user, CancellationToken.None)).Returns(Task.FromResult(passkeys)).Verifiable(); + var userManager = MockHelpers.TestUserManager(store.Object); + + // Act + var result = await userManager.GetPasskeysAsync(user); + + // Assert + Assert.Same(passkeys, result); + store.VerifyAll(); + } + + [Fact] + public async Task FindByPasskeyIdCallsStore() + { + // Setup + var store = new Mock>(); + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[1, 2, 3, 4, 5, 6, 7, 8]; + store.Setup(s => s.FindByPasskeyIdAsync(credentialId, CancellationToken.None)).Returns(Task.FromResult(user)).Verifiable(); + var userManager = MockHelpers.TestUserManager(store.Object); + + // Act + var result = await userManager.FindByPasskeyIdAsync(credentialId); + + // Assert + Assert.Equal(user, result); + store.VerifyAll(); + } + + [Fact] + public async Task RemovePasskeyAsyncCallsStore() + { + // Setup + var store = new Mock>(); + var user = new PocoUser { UserName = "Foo" }; + var credentialId = (byte[])[1, 2, 3, 4, 5, 6, 7, 8]; + store.Setup(s => s.RemovePasskeyAsync(user, credentialId, CancellationToken.None)).Returns(Task.CompletedTask).Verifiable(); + store.Setup(s => s.UpdateAsync(user, CancellationToken.None)).ReturnsAsync(IdentityResult.Success).Verifiable(); + var userManager = MockHelpers.TestUserManager(store.Object); + + // Act + var result = await userManager.RemovePasskeyAsync(user, credentialId); + + // Assert + Assert.True(result.Succeeded); + store.VerifyAll(); + } + [Fact] public async Task CheckPasswordWithNullUserReturnsFalse() { @@ -1040,6 +1115,10 @@ await Assert.ThrowsAsync("providerKey", Assert.Throws("provider", () => manager.RegisterTokenProvider("whatever", null)); await Assert.ThrowsAsync("roles", async () => await manager.AddToRolesAsync(new PocoUser(), null)); await Assert.ThrowsAsync("roles", async () => await manager.RemoveFromRolesAsync(new PocoUser(), null)); + await Assert.ThrowsAsync("passkey", async () => await manager.SetPasskeyAsync(new PocoUser(), null)); + await Assert.ThrowsAsync("credentialId", async () => await manager.GetPasskeyAsync(new PocoUser(), null)); + await Assert.ThrowsAsync("credentialId", async () => await manager.FindByPasskeyIdAsync(null)); + await Assert.ThrowsAsync("credentialId", async () => await manager.RemovePasskeyAsync(new PocoUser(), null)); } [Fact] @@ -1141,6 +1220,14 @@ await Assert.ThrowsAsync("user", async () => await manager.GetLockoutEndDateAsync(null)); await Assert.ThrowsAsync("user", async () => await manager.IsLockedOutAsync(null)); + await Assert.ThrowsAsync("user", + async () => await manager.SetPasskeyAsync(null, null)); + await Assert.ThrowsAsync("user", + async () => await manager.GetPasskeysAsync(null)); + await Assert.ThrowsAsync("user", + async () => await manager.GetPasskeyAsync(null, null)); + await Assert.ThrowsAsync("user", + async () => await manager.RemovePasskeyAsync(null, null)); } [Fact] @@ -1180,6 +1267,11 @@ public async Task MethodsThrowWhenDisposedTest() await Assert.ThrowsAsync(() => manager.GenerateEmailConfirmationTokenAsync(null)); await Assert.ThrowsAsync(() => manager.IsEmailConfirmedAsync(null)); await Assert.ThrowsAsync(() => manager.ConfirmEmailAsync(null, null)); + await Assert.ThrowsAsync(() => manager.SetPasskeyAsync(null, null)); + await Assert.ThrowsAsync(() => manager.GetPasskeysAsync(null)); + await Assert.ThrowsAsync(() => manager.GetPasskeyAsync(null, null)); + await Assert.ThrowsAsync(() => manager.FindByPasskeyIdAsync(null)); + await Assert.ThrowsAsync(() => manager.RemovePasskeyAsync(null, null)); } private class BadPasswordValidator : IPasswordValidator where TUser : class @@ -1213,7 +1305,8 @@ private class EmptyStore : IUserLockoutStore, IUserTwoFactorStore, IUserRoleStore, - IUserSecurityStampStore + IUserSecurityStampStore, + IUserPasskeyStore { public Task> GetClaimsAsync(PocoUser user, CancellationToken cancellationToken = default(CancellationToken)) { @@ -1463,6 +1556,31 @@ public void Dispose() { return Task.FromResult(0); } + + public Task SetPasskeyAsync(PocoUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task> GetPasskeysAsync(PocoUser user, CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + + public Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task FindPasskeyAsync(PocoUser user, byte[] credentialId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task RemovePasskeyAsync(PocoUser user, byte[] credentialId, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } } private class NoOpTokenProvider : IUserTwoFactorTokenProvider @@ -1493,7 +1611,8 @@ private class NotImplementedStore : IUserEmailStore, IUserPhoneNumberStore, IUserLockoutStore, - IUserTwoFactorStore + IUserTwoFactorStore, + IUserPasskeyStore { public Task> GetClaimsAsync(PocoUser user, CancellationToken cancellationToken = default(CancellationToken)) { @@ -1734,6 +1853,31 @@ Task IUserStore.DeleteAsync(PocoUser user, Cancellatio { throw new NotImplementedException(); } + + public Task SetPasskeyAsync(PocoUser user, UserPasskeyInfo passkey, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task> GetPasskeysAsync(PocoUser user, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task FindByPasskeyIdAsync(byte[] credentialId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task FindPasskeyAsync(PocoUser user, byte[] credentialId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task RemovePasskeyAsync(PocoUser user, byte[] credentialId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } } [Fact]