File: Passkeys\PasskeyHandlerAssertionTest.cs
Web Access
Project: src\src\Identity\test\Identity.Test\Microsoft.AspNetCore.Identity.Test.csproj (Microsoft.AspNetCore.Identity.Test)
// 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 PasskeyHandlerAssertionTest
{
    [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_WhenAssertionStateChallengeIsMissing()
    {
        var test = new AssertionTest();
        test.AssertionStateJson.TransformAsJsonObject(originalOptionsJson =>
        {
            Assert.True(originalOptionsJson.Remove("challenge"));
        });
 
        var result = await test.RunAsync();
 
        Assert.False(result.Succeeded);
 
        Assert.StartsWith("The assertion state JSON had an invalid format", result.Failure.Message);
        Assert.Contains("was missing required properties including: 'challenge'", result.Failure.Message);
    }
 
    [Theory]
    [InlineData("42")]
    [InlineData("{}")]
    public async Task Fails_WhenAssertionStateChallengeIsNotString(string jsonValue)
    {
        var test = new AssertionTest();
        test.AssertionStateJson.TransformAsJsonObject(assertionStateJson =>
        {
            assertionStateJson["challenge"] = JsonNode.Parse(jsonValue);
        });
 
        var result = await test.RunAsync();
 
        Assert.False(result.Succeeded);
        Assert.StartsWith("The assertion state JSON 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();
        test.ClientDataJson.TransformAsJsonObject(clientDataJson =>
        {
            var challenge = Base64Url.DecodeFromChars((string)clientDataJson["challenge"]!);
            challenge[0]++;
            clientDataJson["challenge"] = Base64Url.EncodeToString(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_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.PasskeyOptions.UserVerificationRequirement = "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.PasskeyOptions.UserVerificationRequirement = "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.PasskeyOptions.UserVerificationRequirement = "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);
    }
 
    [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);
    }
 
    [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<PasskeyAssertionResult<PocoUser>>
    {
        private static readonly byte[] _defaultCredentialId = [1, 2, 3, 4, 5, 6, 7, 8];
 
        public IdentityPasskeyOptions PasskeyOptions { get; } = new();
        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<byte> CredentialId { get; set; } = _defaultCredentialId;
        public ComputedValue<AuthenticatorDataArgs> AuthenticatorDataArgs { get; } = new();
        public ComputedValue<ReadOnlyMemory<byte>> AuthenticatorData { get; } = new();
        public ComputedValue<ReadOnlyMemory<byte>> ClientDataHash { get; } = new();
        public ComputedValue<ReadOnlyMemory<byte>> Signature { get; } = new();
        public ComputedJsonObject AssertionStateJson { get; } = new();
        public ComputedJsonObject ClientDataJson { get; } = new();
        public ComputedJsonObject CredentialJson { get; } = new();
        public ComputedValue<UserPasskeyInfo> StoredPasskey { get; } = new();
 
        protected override async Task<PasskeyAssertionResult<PocoUser>> RunCoreAsync()
        {
            var credential = CredentialKeyPair.Generate(Algorithm);
            var credentialPublicKey = credential.EncodePublicKeyCbor();
            var storedPasskey = StoredPasskey.Compute(new(
                CredentialId.ToArray(),
                credentialPublicKey.ToArray(),
                createdAt: default,
                signCount: 0,
                transports: null,
                isUserVerified: true,
                isBackupEligible: IsStoredPasskeyBackupEligible,
                isBackedUp: IsStoredPasskeyBackedUp,
                attestationObject: [],
                clientDataJson: []));
 
            var httpContext = new Mock<HttpContext>();
            httpContext.Setup(c => c.Request.Headers.Origin).Returns(new StringValues(Origin));
 
            var userManager = MockHelpers.MockUserManager<PocoUser>();
            userManager
                .Setup(m => m.FindByIdAsync(User.Id))
                .Returns(Task.FromResult<PocoUser?>(User));
            userManager
                .Setup(m => m.GetPasskeyAsync(It.IsAny<PocoUser>(), It.IsAny<byte[]>()))
                .Returns((PocoUser user, byte[] credentialId) => Task.FromResult(
                    DoesCredentialExistOnUser && user == User && CredentialId.Span.SequenceEqual(credentialId)
                        ? storedPasskey
                        : null));
            userManager
                .Setup(m => m.GetPasskeysAsync(It.IsAny<PocoUser>()))
                .Returns((PocoUser user) => Task.FromResult<IList<UserPasskeyInfo>>(
                    DoesCredentialExistOnUser && user == User ? [storedPasskey] : []));
 
            if (IsUserIdentified)
            {
                userManager
                    .Setup(m => m.GetUserIdAsync(User))
                    .Returns(Task.FromResult(User.Id));
            }
 
            var passkeyOptions = Options.Create(PasskeyOptions);
            var handler = new PasskeyHandler<PocoUser>(userManager.Object, passkeyOptions);
 
            var requestOptionsResult = await handler.MakeRequestOptionsAsync(
                IsUserIdentified ? User : null,
                httpContext.Object);
 
            var requestOptions = JsonSerializer.Deserialize(
                requestOptionsResult.RequestOptionsJson,
                IdentityJsonSerializerContext.Default.PublicKeyCredentialRequestOptions)
                ?? throw new InvalidOperationException("Failed to deserialize request options JSON.");
            var assertionStateJson = AssertionStateJson.Compute(requestOptionsResult.AssertionState);
            var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new()
            {
                RpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(requestOptions.RpId ?? string.Empty)),
                Flags = AuthenticatorDataFlags.UserPresent,
                SignCount = 1,
            });
            var authenticatorData = AuthenticatorData.Compute(MakeAuthenticatorData(authenticatorDataArgs));
            var clientDataJson = ClientDataJson.Compute($$"""
                {
                  "challenge": {{ToBase64UrlJsonValue(requestOptions.Challenge.AsMemory())}},
                  "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 context = new PasskeyAssertionContext
            {
                CredentialJson = credentialJson!,
                AssertionState = assertionStateJson,
                HttpContext = httpContext.Object,
            };
 
            return await handler.PerformAssertionAsync(context);
        }
    }
}