File: Passkeys\DefaultPasskeyHandlerAttestationTest.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 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<byte>.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<PasskeyAttestationResult>
    {
        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<byte> Challenge { get; set; } = _defaultChallenge;
        public ReadOnlyMemory<byte> CredentialId { get; set; } = _defaultCredentialId;
        public ComputedValue<IReadOnlyList<PublicKeyCredentialParameters>> SupportedPublicKeyCredentialParameters { get; } = new();
        public ComputedValue<AttestedCredentialDataArgs> AttestedCredentialDataArgs { get; } = new();
        public ComputedValue<AuthenticatorDataArgs> AuthenticatorDataArgs { get; } = new();
        public ComputedValue<AttestationObjectArgs> AttestationObjectArgs { get; } = new();
        public ComputedValue<ReadOnlyMemory<byte>> AttestedCredentialData { get; } = new();
        public ComputedValue<ReadOnlyMemory<byte>> AuthenticatorData { get; } = new();
        public ComputedValue<ReadOnlyMemory<byte>> AttestationObject { get; } = new();
        public ComputedJsonObject OriginalOptionsJson { get; } = new();
        public ComputedJsonObject ClientDataJson { get; } = new();
        public ComputedJsonObject CredentialJson { get; } = new();
 
        protected override async Task<PasskeyAttestationResult> 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>();
            httpContext.Setup(c => c.Request.Headers.Origin).Returns(new StringValues(Origin));
 
            var userManager = MockHelpers.MockUserManager<PocoUser>();
 
            if (DoesCredentialAlreadyExistForAnotherUser)
            {
                var existingUser = new PocoUser(userName: "existing_user");
                userManager
                    .Setup(m => m.FindByPasskeyIdAsync(It.IsAny<byte[]>()))
                    .Returns((byte[] credentialId) =>
                    {
                        if (CredentialId.Span.SequenceEqual(credentialId))
                        {
                            return Task.FromResult<PocoUser?>(existingUser);
                        }
 
                        return Task.FromResult<PocoUser?>(null);
                    });
            }
 
            var context = new PasskeyAttestationContext<PocoUser>
            {
                CredentialJson = credentialJson,
                OriginalOptionsJson = originalOptionsJson,
                HttpContext = httpContext.Object,
                UserManager = userManager.Object,
            };
 
            return await handler.PerformAttestationAsync(context);
        }
 
        private sealed class TestPasskeyHandler(IOptions<IdentityOptions> options) : DefaultPasskeyHandler<PocoUser>(options)
        {
            public bool ShouldFailAttestationStatementVerification { get; init; }
 
            protected override Task<bool> VerifyAttestationStatementAsync(
                ReadOnlyMemory<byte> attestationObject,
                ReadOnlyMemory<byte> clientDataHash,
                HttpContext httpContext)
            {
                if (ShouldFailAttestationStatementVerification)
                {
                    return Task.FromResult(false);
                }
 
                return base.VerifyAttestationStatementAsync(
                    attestationObject,
                    clientDataHash,
                    httpContext);
            }
        }
    }
}