From 13c80b3138e6175eb27cd7a8af99789305d0f4f9 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 17 Jun 2025 11:25:18 -0400 Subject: [PATCH 1/9] Add DefaultPasskeyHandler tests --- .../DefaultPasskeyHandlerTest.cs | 2222 +++++++++++++++++ 1 file changed, 2222 insertions(+) create mode 100644 src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.cs diff --git a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.cs new file mode 100644 index 000000000000..cbbdcc76c17b --- /dev/null +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.cs @@ -0,0 +1,2222 @@ +// 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.Binary; +using System.Buffers.Text; +using System.Formats.Cbor; +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; + +public class DefaultPasskeyHandlerTest +{ + [Fact] + public async Task Attestation_CanSucceed() + { + var test = new AttestationTest(); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Attestation_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 Attestation_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 Attestation_Fails_WhenCredentialIdIsNotBase64UrlEncoded() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlCredentialId = (string)credentialJson["id"]!; + var rawCredentialId = Base64Url.DecodeFromChars(base64UrlCredentialId); + var base64CredentialId = Convert.ToBase64String(rawCredentialId) + "=="; + credentialJson["id"] = base64CredentialId; + }); + + 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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + var base64UrlChallenge = (string)clientDataJson["challenge"]!; + var rawChallenge = Base64Url.DecodeFromChars(base64UrlChallenge); + var base64Challenge = Convert.ToBase64String(rawChallenge) + "=="; + clientDataJson["challenge"] = base64Challenge; + }); + + 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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Assertion_CanSucceed() + { + var test = new AssertionTest(); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Assertion_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 Assertion_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 Assertion_Fails_WhenCredentialIdIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlCredentialId = (string)credentialJson["id"]!; + var rawCredentialId = Base64Url.DecodeFromChars(base64UrlCredentialId); + var base64CredentialId = Convert.ToBase64String(rawCredentialId) + "=="; + credentialJson["id"] = base64CredentialId; + }); + + 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenAuthenticatorDataIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlAuthenticatorData = (string)credentialJson["response"]!["authenticatorData"]!; + var rawAuthenticatorData = Base64Url.DecodeFromChars(base64UrlAuthenticatorData); + var base64AuthenticatorData = Convert.ToBase64String(rawAuthenticatorData) + "=="; + credentialJson["response"]!["authenticatorData"] = base64AuthenticatorData; + }); + + 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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenResponseSignatureIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlSignature = (string)credentialJson["response"]!["signature"]!; + var rawSignature = Base64Url.DecodeFromChars(base64UrlSignature); + var base64Signature = Convert.ToBase64String(rawSignature) + "=="; + credentialJson["response"]!["signature"] = base64Signature; + }); + + 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + var base64UrlChallenge = (string)clientDataJson["challenge"]!; + var rawChallenge = Base64Url.DecodeFromChars(base64UrlChallenge); + var base64Challenge = Convert.ToBase64String(rawChallenge) + "=="; + clientDataJson["challenge"] = base64Challenge; + }); + + 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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); + } + + private sealed class AttestationTest : ConfigurableTestBase + { + 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]; + + 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 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 RunTestAsync() + { + var identityOptions = Options.Create(IdentityOptions); + var handler = new DefaultPasskeyHandler(identityOptions); + 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 = TestCredentialKeyPair.Generate(Algorithm); + var credentialPublicKey = credential.EncodePublicKeyCbor(); + var attestedCredentialDataArgs = AttestedCredentialDataArgs.Compute(new() + { + CredentialId = CredentialId, + CredentialPublicKey = credentialPublicKey, + }); + var attestedCredentialData = AttestedCredentialData.Compute(MakeAttestedCredentialData(attestedCredentialDataArgs)); + var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new() + { + 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() + { + AuthenticatorData = authenticatorData, + }); + 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(); + + var context = new PasskeyAttestationContext + { + CredentialJson = credentialJson, + OriginalOptionsJson = originalOptionsJson, + HttpContext = httpContext.Object, + UserManager = userManager.Object, + }; + + return await handler.PerformAttestationAsync(context); + } + } + + private sealed class AssertionTest : ConfigurableTestBase> + { + 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 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> Signature { get; } = new(); + public ComputedJsonObject OriginalOptionsJson { get; } = new(); + public ComputedJsonObject ClientDataJson { get; } = new(); + public ComputedJsonObject CredentialJson { get; } = new(); + + public void AddAllowCredentials(string userId) + { + _allowCredentials.Add(new() + { + Id = BufferSource.FromString(userId), + Type = "public-key", + Transports = ["internal"], + }); + } + + protected override async Task> RunTestAsync() + { + var identityOptions = Options.Create(IdentityOptions); + var handler = new DefaultPasskeyHandler(identityOptions); + var credential = TestCredentialKeyPair.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, + }); + 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 = SHA256.HashData(clientDataJsonBytes); + var dataToSign = (byte[])[.. authenticatorData.Span, .. clientDataHash]; + 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 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) => + { + if (user != User || !CredentialId.Span.SequenceEqual(credentialId)) + { + return Task.FromResult(null); + } + + var credentialPublicKey = credential.EncodePublicKeyCbor(); + + // Some properties don't affect validation, so we can + // use default values. + return Task.FromResult(new( + CredentialId.ToArray(), + credentialPublicKey.ToArray(), + name: null, + createdAt: default, + signCount: 0, // TODO: Make configurable + transports: null, + isUserVerified: true, // TODO: Make configurable + isBackupEligible: false, // TODO: Make configurable + isBackedUp: false, + attestationObject: [], + clientDataJson: [] + )); + }); + + 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); + } + } + + private static string ToJsonValue(string? value) + => value is null ? "null" : $"\"{value}\""; + + private static string ToBase64UrlJsonValue(ReadOnlyMemory? bytes) + => !bytes.HasValue ? "null" : $"\"{Base64Url.EncodeToString(bytes.Value.Span)}\""; + + private static string ToBase64UrlJsonValue(string? value) + => value is null ? "null" : $"\"{Base64Url.EncodeToString(Encoding.UTF8.GetBytes(value))}\""; + + private readonly struct AttestedCredentialDataArgs() + { + private static readonly ReadOnlyMemory _defaultAaguid = new byte[16]; + + public ReadOnlyMemory Aaguid { get; init; } = _defaultAaguid; + public required ReadOnlyMemory CredentialId { get; init; } + public required ReadOnlyMemory CredentialPublicKey { get; init; } + } + + private 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; + } + + private readonly struct AuthenticatorDataArgs() + { + public required AuthenticatorDataFlags Flags { get; init; } + public required ReadOnlyMemory RpIdHash { get; init; } + public ReadOnlyMemory? AttestedCredentialData { get; init; } + public ReadOnlyMemory? Extensions { get; init; } + public uint SignCount { get; init; } = 1; + } + + private 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; + } + + private readonly struct AttestationObjectArgs() + { + private static readonly byte[] _defaultAttestationStatement = [0xA0]; // Empty CBOR map + + public int? CborMapLength { get; init; } = 3; + public string? Format { get; init; } = "none"; + public ReadOnlyMemory? AttestationStatement { get; init; } = _defaultAttestationStatement; + public required ReadOnlyMemory? AuthenticatorData { get; init; } + } + + private 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(); + } + + private sealed class TestCredentialKeyPair + { + private readonly RSA? _rsa; + private readonly ECDsa? _ecdsa; + private readonly COSEAlgorithmIdentifier _alg; + private readonly COSEKeyType _keyType; + private readonly COSEEllipticCurve _curve; + + private TestCredentialKeyPair(RSA rsa, COSEAlgorithmIdentifier alg) + { + _rsa = rsa; + _alg = alg; + _keyType = COSEKeyType.RSA; + } + + private TestCredentialKeyPair(ECDsa ecdsa, COSEAlgorithmIdentifier alg, COSEEllipticCurve curve) + { + _ecdsa = ecdsa; + _alg = alg; + _keyType = COSEKeyType.EC2; + _curve = curve; + } + + public static TestCredentialKeyPair 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); + + // Note: WebAuthn expects signature in RFC3279 DER sequence format + return _ecdsa.SignData(data.ToArray(), hashAlgorithm, DSASignatureFormat.Rfc3279DerSequence); + } + + private static TestCredentialKeyPair GenerateRsaKeyPair(COSEAlgorithmIdentifier alg) + { + const int KeySize = 2048; + var rsa = RSA.Create(KeySize); + return new TestCredentialKeyPair(rsa, alg); + } + + private static TestCredentialKeyPair GenerateEcKeyPair(COSEAlgorithmIdentifier alg, ECCurve curve, COSEEllipticCurve coseCurve) + { + var ecdsa = ECDsa.Create(curve); + return new TestCredentialKeyPair(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, + } + } + + private abstract class ConfigurableTestBase + { + private bool _hasStarted; + + public Task RunAsync() + { + if (_hasStarted) + { + throw new InvalidOperationException("The test can only be run once."); + } + + _hasStarted = true; + return RunTestAsync(); + } + + protected abstract Task RunTestAsync(); + } + + private 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; + } + } + + private 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); + } + }); + } + } +} From 1361d2123846fa05413ca03f6457cabb5972788c Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 20 Jun 2025 11:48:45 -0400 Subject: [PATCH 2/9] Split out test into partial classes --- .../DefaultPasskeyHandlerTest.Assertion.cs | 806 ++++++ .../DefaultPasskeyHandlerTest.Attestation.cs | 968 +++++++ ...ultPasskeyHandlerTest.CredentialKeyPair.cs | 234 ++ .../DefaultPasskeyHandlerTest.Helpers.cs | 255 ++ .../DefaultPasskeyHandlerTest.cs | 2222 ----------------- 5 files changed, 2263 insertions(+), 2222 deletions(-) create mode 100644 src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs create mode 100644 src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs create mode 100644 src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.CredentialKeyPair.cs create mode 100644 src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs delete mode 100644 src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.cs diff --git a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs new file mode 100644 index 000000000000..9e60a8b7f5dd --- /dev/null +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs @@ -0,0 +1,806 @@ +// 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.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; + +public partial class DefaultPasskeyHandlerTest +{ + [Fact] + public async Task Assertion_CanSucceed() + { + var test = new AssertionTest(); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Assertion_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 Assertion_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 Assertion_Fails_WhenCredentialIdIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlCredentialId = (string)credentialJson["id"]!; + var rawCredentialId = Base64Url.DecodeFromChars(base64UrlCredentialId); + var base64CredentialId = Convert.ToBase64String(rawCredentialId) + "=="; + credentialJson["id"] = base64CredentialId; + }); + + 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenAuthenticatorDataIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlAuthenticatorData = (string)credentialJson["response"]!["authenticatorData"]!; + var rawAuthenticatorData = Base64Url.DecodeFromChars(base64UrlAuthenticatorData); + var base64AuthenticatorData = Convert.ToBase64String(rawAuthenticatorData) + "=="; + credentialJson["response"]!["authenticatorData"] = base64AuthenticatorData; + }); + + 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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenResponseSignatureIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlSignature = (string)credentialJson["response"]!["signature"]!; + var rawSignature = Base64Url.DecodeFromChars(base64UrlSignature); + var base64Signature = Convert.ToBase64String(rawSignature) + "=="; + credentialJson["response"]!["signature"] = base64Signature; + }); + + 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() + { + var test = new AssertionTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + var base64UrlChallenge = (string)clientDataJson["challenge"]!; + var rawChallenge = Base64Url.DecodeFromChars(base64UrlChallenge); + var base64Challenge = Convert.ToBase64String(rawChallenge) + "=="; + clientDataJson["challenge"] = base64Challenge; + }); + + 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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); + } + + private sealed class AssertionTest : PasskeyTestBase> + { + 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 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> Signature { get; } = new(); + public ComputedJsonObject OriginalOptionsJson { get; } = new(); + public ComputedJsonObject ClientDataJson { get; } = new(); + public ComputedJsonObject CredentialJson { get; } = new(); + + public void AddAllowCredentials(string userId) + { + _allowCredentials.Add(new() + { + Id = BufferSource.FromString(userId), + 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, + }); + 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 = SHA256.HashData(clientDataJsonBytes); + var dataToSign = (byte[])[.. authenticatorData.Span, .. clientDataHash]; + 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 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) => + { + if (user != User || !CredentialId.Span.SequenceEqual(credentialId)) + { + return Task.FromResult(null); + } + + var credentialPublicKey = credential.EncodePublicKeyCbor(); + + // Some properties don't affect validation, so we can + // use default values. + return Task.FromResult(new( + CredentialId.ToArray(), + credentialPublicKey.ToArray(), + name: null, + createdAt: default, + signCount: 0, // TODO: Make configurable + transports: null, + isUserVerified: true, // TODO: Make configurable + isBackupEligible: false, // TODO: Make configurable + isBackedUp: false, + attestationObject: [], + clientDataJson: [] + )); + }); + + 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/DefaultPasskeyHandlerTest.Attestation.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs new file mode 100644 index 000000000000..64cf1a0863f0 --- /dev/null +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs @@ -0,0 +1,968 @@ +// 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; + +public partial class DefaultPasskeyHandlerTest +{ + [Fact] + public async Task Attestation_CanSucceed() + { + var test = new AttestationTest(); + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Attestation_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 Attestation_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 Attestation_Fails_WhenCredentialIdIsNotBase64UrlEncoded() + { + var test = new AttestationTest(); + test.CredentialJson.TransformAsJsonObject(credentialJson => + { + var base64UrlCredentialId = (string)credentialJson["id"]!; + var rawCredentialId = Base64Url.DecodeFromChars(base64UrlCredentialId); + var base64CredentialId = Convert.ToBase64String(rawCredentialId) + "=="; + credentialJson["id"] = base64CredentialId; + }); + + 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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() + { + var test = new AttestationTest(); + test.ClientDataJson.TransformAsJsonObject(clientDataJson => + { + var base64UrlChallenge = (string)clientDataJson["challenge"]!; + var rawChallenge = Base64Url.DecodeFromChars(base64UrlChallenge); + var base64Challenge = Convert.ToBase64String(rawChallenge) + "=="; + clientDataJson["challenge"] = base64Challenge; + }); + + 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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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); + } + + private sealed class AttestationTest : PasskeyTestBase + { + 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]; + + 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 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 DefaultPasskeyHandler(identityOptions); + 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() + { + CredentialId = CredentialId, + CredentialPublicKey = credentialPublicKey, + }); + var attestedCredentialData = AttestedCredentialData.Compute(MakeAttestedCredentialData(attestedCredentialDataArgs)); + var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new() + { + 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() + { + AuthenticatorData = authenticatorData, + }); + 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(); + + var context = new PasskeyAttestationContext + { + CredentialJson = credentialJson, + OriginalOptionsJson = originalOptionsJson, + HttpContext = httpContext.Object, + UserManager = userManager.Object, + }; + + return await handler.PerformAttestationAsync(context); + } + } +} diff --git a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.CredentialKeyPair.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.CredentialKeyPair.cs new file mode 100644 index 000000000000..6dfc9f89829b --- /dev/null +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.CredentialKeyPair.cs @@ -0,0 +1,234 @@ +// 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; + +public partial class DefaultPasskeyHandlerTest +{ + private 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/DefaultPasskeyHandlerTest.Helpers.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs new file mode 100644 index 000000000000..6f4452962ade --- /dev/null +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs @@ -0,0 +1,255 @@ +// 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.Binary; +using System.Buffers.Text; +using System.Formats.Cbor; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Microsoft.AspNetCore.Identity.Test; + +public partial class DefaultPasskeyHandlerTest +{ + private static string ToJsonValue(string? value) + => value is null ? "null" : $"\"{value}\""; + + private static string ToBase64UrlJsonValue(ReadOnlyMemory? bytes) + => !bytes.HasValue ? "null" : $"\"{Base64Url.EncodeToString(bytes.Value.Span)}\""; + + private static string ToBase64UrlJsonValue(string? value) + => value is null ? "null" : $"\"{Base64Url.EncodeToString(Encoding.UTF8.GetBytes(value))}\""; + + private 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; + } + + private 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; + } + + private 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(); + } + + private readonly struct AttestedCredentialDataArgs() + { + private static readonly ReadOnlyMemory _defaultAaguid = new byte[16]; + + public ReadOnlyMemory Aaguid { get; init; } = _defaultAaguid; + public required ReadOnlyMemory CredentialId { get; init; } + public required ReadOnlyMemory CredentialPublicKey { get; init; } + } + + private readonly struct AuthenticatorDataArgs() + { + public required AuthenticatorDataFlags Flags { get; init; } + public required ReadOnlyMemory RpIdHash { get; init; } + public ReadOnlyMemory? AttestedCredentialData { get; init; } + public ReadOnlyMemory? Extensions { get; init; } + public uint SignCount { get; init; } = 1; + } + + private readonly struct AttestationObjectArgs() + { + private static readonly byte[] _defaultAttestationStatement = [0xA0]; // Empty CBOR map + + public int? CborMapLength { get; init; } = 3; + public string? Format { get; init; } = "none"; + public ReadOnlyMemory? AttestationStatement { get; init; } = _defaultAttestationStatement; + public required ReadOnlyMemory? AuthenticatorData { get; init; } + } + + private abstract class PasskeyTestBase + { + 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(); + } + + private 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; + } + } + + private 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/DefaultPasskeyHandlerTest.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.cs deleted file mode 100644 index cbbdcc76c17b..000000000000 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.cs +++ /dev/null @@ -1,2222 +0,0 @@ -// 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.Binary; -using System.Buffers.Text; -using System.Formats.Cbor; -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; - -public class DefaultPasskeyHandlerTest -{ - [Fact] - public async Task Attestation_CanSucceed() - { - var test = new AttestationTest(); - - var result = await test.RunAsync(); - - Assert.True(result.Succeeded); - } - - [Fact] - public async Task Attestation_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 Attestation_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 Attestation_Fails_WhenCredentialIdIsNotBase64UrlEncoded() - { - var test = new AttestationTest(); - test.CredentialJson.TransformAsJsonObject(credentialJson => - { - var base64UrlCredentialId = (string)credentialJson["id"]!; - var rawCredentialId = Base64Url.DecodeFromChars(base64UrlCredentialId); - var base64CredentialId = Convert.ToBase64String(rawCredentialId) + "=="; - credentialJson["id"] = base64CredentialId; - }); - - 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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() - { - var test = new AttestationTest(); - test.ClientDataJson.TransformAsJsonObject(clientDataJson => - { - var base64UrlChallenge = (string)clientDataJson["challenge"]!; - var rawChallenge = Base64Url.DecodeFromChars(base64UrlChallenge); - var base64Challenge = Convert.ToBase64String(rawChallenge) + "=="; - clientDataJson["challenge"] = base64Challenge; - }); - - 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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Assertion_CanSucceed() - { - var test = new AssertionTest(); - - var result = await test.RunAsync(); - - Assert.True(result.Succeeded); - } - - [Fact] - public async Task Assertion_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 Assertion_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 Assertion_Fails_WhenCredentialIdIsNotBase64UrlEncoded() - { - var test = new AssertionTest(); - test.CredentialJson.TransformAsJsonObject(credentialJson => - { - var base64UrlCredentialId = (string)credentialJson["id"]!; - var rawCredentialId = Base64Url.DecodeFromChars(base64UrlCredentialId); - var base64CredentialId = Convert.ToBase64String(rawCredentialId) + "=="; - credentialJson["id"] = base64CredentialId; - }); - - 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenAuthenticatorDataIsNotBase64UrlEncoded() - { - var test = new AssertionTest(); - test.CredentialJson.TransformAsJsonObject(credentialJson => - { - var base64UrlAuthenticatorData = (string)credentialJson["response"]!["authenticatorData"]!; - var rawAuthenticatorData = Base64Url.DecodeFromChars(base64UrlAuthenticatorData); - var base64AuthenticatorData = Convert.ToBase64String(rawAuthenticatorData) + "=="; - credentialJson["response"]!["authenticatorData"] = base64AuthenticatorData; - }); - - 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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenResponseSignatureIsNotBase64UrlEncoded() - { - var test = new AssertionTest(); - test.CredentialJson.TransformAsJsonObject(credentialJson => - { - var base64UrlSignature = (string)credentialJson["response"]!["signature"]!; - var rawSignature = Base64Url.DecodeFromChars(base64UrlSignature); - var base64Signature = Convert.ToBase64String(rawSignature) + "=="; - credentialJson["response"]!["signature"] = base64Signature; - }); - - 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() - { - var test = new AssertionTest(); - test.ClientDataJson.TransformAsJsonObject(clientDataJson => - { - var base64UrlChallenge = (string)clientDataJson["challenge"]!; - var rawChallenge = Base64Url.DecodeFromChars(base64UrlChallenge); - var base64Challenge = Convert.ToBase64String(rawChallenge) + "=="; - clientDataJson["challenge"] = base64Challenge; - }); - - 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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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); - } - - private sealed class AttestationTest : ConfigurableTestBase - { - 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]; - - 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 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 RunTestAsync() - { - var identityOptions = Options.Create(IdentityOptions); - var handler = new DefaultPasskeyHandler(identityOptions); - 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 = TestCredentialKeyPair.Generate(Algorithm); - var credentialPublicKey = credential.EncodePublicKeyCbor(); - var attestedCredentialDataArgs = AttestedCredentialDataArgs.Compute(new() - { - CredentialId = CredentialId, - CredentialPublicKey = credentialPublicKey, - }); - var attestedCredentialData = AttestedCredentialData.Compute(MakeAttestedCredentialData(attestedCredentialDataArgs)); - var authenticatorDataArgs = AuthenticatorDataArgs.Compute(new() - { - 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() - { - AuthenticatorData = authenticatorData, - }); - 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(); - - var context = new PasskeyAttestationContext - { - CredentialJson = credentialJson, - OriginalOptionsJson = originalOptionsJson, - HttpContext = httpContext.Object, - UserManager = userManager.Object, - }; - - return await handler.PerformAttestationAsync(context); - } - } - - private sealed class AssertionTest : ConfigurableTestBase> - { - 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 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> Signature { get; } = new(); - public ComputedJsonObject OriginalOptionsJson { get; } = new(); - public ComputedJsonObject ClientDataJson { get; } = new(); - public ComputedJsonObject CredentialJson { get; } = new(); - - public void AddAllowCredentials(string userId) - { - _allowCredentials.Add(new() - { - Id = BufferSource.FromString(userId), - Type = "public-key", - Transports = ["internal"], - }); - } - - protected override async Task> RunTestAsync() - { - var identityOptions = Options.Create(IdentityOptions); - var handler = new DefaultPasskeyHandler(identityOptions); - var credential = TestCredentialKeyPair.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, - }); - 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 = SHA256.HashData(clientDataJsonBytes); - var dataToSign = (byte[])[.. authenticatorData.Span, .. clientDataHash]; - 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 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) => - { - if (user != User || !CredentialId.Span.SequenceEqual(credentialId)) - { - return Task.FromResult(null); - } - - var credentialPublicKey = credential.EncodePublicKeyCbor(); - - // Some properties don't affect validation, so we can - // use default values. - return Task.FromResult(new( - CredentialId.ToArray(), - credentialPublicKey.ToArray(), - name: null, - createdAt: default, - signCount: 0, // TODO: Make configurable - transports: null, - isUserVerified: true, // TODO: Make configurable - isBackupEligible: false, // TODO: Make configurable - isBackedUp: false, - attestationObject: [], - clientDataJson: [] - )); - }); - - 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); - } - } - - private static string ToJsonValue(string? value) - => value is null ? "null" : $"\"{value}\""; - - private static string ToBase64UrlJsonValue(ReadOnlyMemory? bytes) - => !bytes.HasValue ? "null" : $"\"{Base64Url.EncodeToString(bytes.Value.Span)}\""; - - private static string ToBase64UrlJsonValue(string? value) - => value is null ? "null" : $"\"{Base64Url.EncodeToString(Encoding.UTF8.GetBytes(value))}\""; - - private readonly struct AttestedCredentialDataArgs() - { - private static readonly ReadOnlyMemory _defaultAaguid = new byte[16]; - - public ReadOnlyMemory Aaguid { get; init; } = _defaultAaguid; - public required ReadOnlyMemory CredentialId { get; init; } - public required ReadOnlyMemory CredentialPublicKey { get; init; } - } - - private 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; - } - - private readonly struct AuthenticatorDataArgs() - { - public required AuthenticatorDataFlags Flags { get; init; } - public required ReadOnlyMemory RpIdHash { get; init; } - public ReadOnlyMemory? AttestedCredentialData { get; init; } - public ReadOnlyMemory? Extensions { get; init; } - public uint SignCount { get; init; } = 1; - } - - private 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; - } - - private readonly struct AttestationObjectArgs() - { - private static readonly byte[] _defaultAttestationStatement = [0xA0]; // Empty CBOR map - - public int? CborMapLength { get; init; } = 3; - public string? Format { get; init; } = "none"; - public ReadOnlyMemory? AttestationStatement { get; init; } = _defaultAttestationStatement; - public required ReadOnlyMemory? AuthenticatorData { get; init; } - } - - private 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(); - } - - private sealed class TestCredentialKeyPair - { - private readonly RSA? _rsa; - private readonly ECDsa? _ecdsa; - private readonly COSEAlgorithmIdentifier _alg; - private readonly COSEKeyType _keyType; - private readonly COSEEllipticCurve _curve; - - private TestCredentialKeyPair(RSA rsa, COSEAlgorithmIdentifier alg) - { - _rsa = rsa; - _alg = alg; - _keyType = COSEKeyType.RSA; - } - - private TestCredentialKeyPair(ECDsa ecdsa, COSEAlgorithmIdentifier alg, COSEEllipticCurve curve) - { - _ecdsa = ecdsa; - _alg = alg; - _keyType = COSEKeyType.EC2; - _curve = curve; - } - - public static TestCredentialKeyPair 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); - - // Note: WebAuthn expects signature in RFC3279 DER sequence format - return _ecdsa.SignData(data.ToArray(), hashAlgorithm, DSASignatureFormat.Rfc3279DerSequence); - } - - private static TestCredentialKeyPair GenerateRsaKeyPair(COSEAlgorithmIdentifier alg) - { - const int KeySize = 2048; - var rsa = RSA.Create(KeySize); - return new TestCredentialKeyPair(rsa, alg); - } - - private static TestCredentialKeyPair GenerateEcKeyPair(COSEAlgorithmIdentifier alg, ECCurve curve, COSEEllipticCurve coseCurve) - { - var ecdsa = ECDsa.Create(curve); - return new TestCredentialKeyPair(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, - } - } - - private abstract class ConfigurableTestBase - { - private bool _hasStarted; - - public Task RunAsync() - { - if (_hasStarted) - { - throw new InvalidOperationException("The test can only be run once."); - } - - _hasStarted = true; - return RunTestAsync(); - } - - protected abstract Task RunTestAsync(); - } - - private 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; - } - } - - private 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); - } - }); - } - } -} From dc66a784838ef7d90146bb3ce5242e27b72afc5b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Sun, 22 Jun 2025 12:14:09 -0400 Subject: [PATCH 3/9] Add more assertion tests --- .../Core/src/DefaultPasskeyHandler.cs | 2 +- .../Core/src/PasskeyExceptionExtensions.cs | 2 +- .../DefaultPasskeyHandlerTest.Assertion.cs | 445 ++++++++++++++++-- .../DefaultPasskeyHandlerTest.Attestation.cs | 3 + 4 files changed, 423 insertions(+), 29 deletions(-) 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/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs index 9e60a8b7f5dd..c7914bc3fc77 100644 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs @@ -1,6 +1,8 @@ // 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; @@ -663,6 +665,398 @@ public async Task Assertion_Fails_WhenClientDataJsonTokenBindingStatusIsInvalid( Assert.StartsWith("Invalid token binding status 'unexpected-value'", result.Failure.Message); } + [Fact] + public async Task Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_Succeeds_WhenSignCountIsZero() + { + var test = new AssertionTest(); + test.AuthenticatorDataArgs.Transform(args => args with + { + SignCount = 0, // Normally 1 + }); + + 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 Assertion_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 Assertion_Succeeds_WithSupportedAlgorithms(int algorithm) + { + var test = new AssertionTest + { + Algorithm = (COSEAlgorithmIdentifier)algorithm, + }; + + var result = await test.RunAsync(); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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 Assertion_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); + } + private sealed class AssertionTest : PasskeyTestBase> { private static readonly byte[] _defaultChallenge = [1, 2, 3, 4, 5, 6, 7, 8]; @@ -679,15 +1073,19 @@ private sealed class AssertionTest : PasskeyTestBase 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 AddAllowCredentials(string userId) { @@ -731,8 +1129,8 @@ protected override async Task> RunCoreAsync() } """); var clientDataJsonBytes = Encoding.UTF8.GetBytes(clientDataJson?.ToString() ?? string.Empty); - var clientDataHash = SHA256.HashData(clientDataJsonBytes); - var dataToSign = (byte[])[.. authenticatorData.Span, .. clientDataHash]; + 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($$""" { @@ -749,6 +1147,20 @@ protected override async Task> RunCoreAsync() } """); + 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)); @@ -758,31 +1170,10 @@ protected override async Task> RunCoreAsync() .Returns(Task.FromResult(User)); userManager .Setup(m => m.GetPasskeyAsync(It.IsAny(), It.IsAny())) - .Returns((PocoUser user, byte[] credentialId) => - { - if (user != User || !CredentialId.Span.SequenceEqual(credentialId)) - { - return Task.FromResult(null); - } - - var credentialPublicKey = credential.EncodePublicKeyCbor(); - - // Some properties don't affect validation, so we can - // use default values. - return Task.FromResult(new( - CredentialId.ToArray(), - credentialPublicKey.ToArray(), - name: null, - createdAt: default, - signCount: 0, // TODO: Make configurable - transports: null, - isUserVerified: true, // TODO: Make configurable - isBackupEligible: false, // TODO: Make configurable - isBackedUp: false, - attestationObject: [], - clientDataJson: [] - )); - }); + .Returns((PocoUser user, byte[] credentialId) => Task.FromResult( + user == User && CredentialId.Span.SequenceEqual(credentialId) + ? storedPasskey + : null)); if (IsUserIdentified) { diff --git a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs index 64cf1a0863f0..d15598ace9e7 100644 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs @@ -538,6 +538,7 @@ public async Task Attestation_Succeeds_WhenAuthDataContainsExtensionData() }); var result = await test.RunAsync(); + Assert.True(result.Succeeded); } @@ -551,6 +552,7 @@ public async Task Attestation_Fails_WhenAuthDataIsNotBackupEligibleButBackedUp() }); 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); } @@ -620,6 +622,7 @@ public async Task Attestation_Fails_WhenAuthDataIsBackedUp(PasskeyOptions.Creden }); var result = await test.RunAsync(); + Assert.True(result.Succeeded); } From 3f78fb1a5bcd94bf8700be74b318d31ebb46333a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Sun, 22 Jun 2025 13:35:52 -0400 Subject: [PATCH 4/9] More UserManager tests --- .../Extensions.Core/src/UserManager.cs | 1 + .../DefaultPasskeyHandlerTest.Helpers.cs | 5 + .../test/Identity.Test/UserManagerTest.cs | 148 +++++++++++++++++- 3 files changed, 152 insertions(+), 2 deletions(-) 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/DefaultPasskeyHandlerTest.Helpers.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs index 6f4452962ade..e84f03a94cba 100644 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs @@ -145,6 +145,7 @@ private readonly struct AttestationObjectArgs() public required ReadOnlyMemory? AuthenticatorData { get; init; } } + // Represents a test scenario for a passkey operation (attestation or assertion) private abstract class PasskeyTestBase { private bool _hasStarted; @@ -163,6 +164,10 @@ public Task RunAsync() 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. private class ComputedValue { private bool _isComputed; 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] From e218f3a6fedd7d98e7e0b12e4fda1594cb702dcf Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 23 Jun 2025 10:29:35 -0400 Subject: [PATCH 5/9] Cleanups + new attesattion/assertion tests --- .../DefaultPasskeyHandlerTest.Assertion.cs | 144 ++++++- .../DefaultPasskeyHandlerTest.Attestation.cs | 364 +++++++++++++++++- .../DefaultPasskeyHandlerTest.Helpers.cs | 26 +- 3 files changed, 499 insertions(+), 35 deletions(-) diff --git a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs index c7914bc3fc77..c3d9433f1c74 100644 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs @@ -69,9 +69,7 @@ public async Task Assertion_Fails_WhenCredentialIdIsNotBase64UrlEncoded() test.CredentialJson.TransformAsJsonObject(credentialJson => { var base64UrlCredentialId = (string)credentialJson["id"]!; - var rawCredentialId = Base64Url.DecodeFromChars(base64UrlCredentialId); - var base64CredentialId = Convert.ToBase64String(rawCredentialId) + "=="; - credentialJson["id"] = base64CredentialId; + credentialJson["id"] = GetInvalidBase64UrlValue(base64UrlCredentialId); }); var result = await test.RunAsync(); @@ -164,6 +162,58 @@ public async Task Assertion_Fails_WhenCredentialResponseIsNotAnObject(string jso Assert.StartsWith("The assertion credential JSON had an invalid format", result.Failure.Message); } + [Fact] + public async Task Assertion_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 Assertion_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 Assertion_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 Assertion_Fails_WhenClientDataJsonIsMissing() { @@ -256,9 +306,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBase64UrlEncoded() test.CredentialJson.TransformAsJsonObject(credentialJson => { var base64UrlAuthenticatorData = (string)credentialJson["response"]!["authenticatorData"]!; - var rawAuthenticatorData = Base64Url.DecodeFromChars(base64UrlAuthenticatorData); - var base64AuthenticatorData = Convert.ToBase64String(rawAuthenticatorData) + "=="; - credentialJson["response"]!["authenticatorData"] = base64AuthenticatorData; + credentialJson["response"]!["authenticatorData"] = GetInvalidBase64UrlValue(base64UrlAuthenticatorData); }); var result = await test.RunAsync(); @@ -325,9 +373,7 @@ public async Task Assertion_Fails_WhenResponseSignatureIsNotBase64UrlEncoded() test.CredentialJson.TransformAsJsonObject(credentialJson => { var base64UrlSignature = (string)credentialJson["response"]!["signature"]!; - var rawSignature = Base64Url.DecodeFromChars(base64UrlSignature); - var base64Signature = Convert.ToBase64String(rawSignature) + "=="; - credentialJson["response"]!["signature"] = base64Signature; + credentialJson["response"]!["signature"] = GetInvalidBase64UrlValue(base64UrlSignature); }); var result = await test.RunAsync(); @@ -401,6 +447,25 @@ public async Task Assertion_Fails_WhenResponseUserHandleIsNull() Assert.StartsWith("The authenticator response was missing a user handle", result.Failure.Message); } + [Fact] + public async Task Assertion_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 Assertion_Fails_WhenClientDataJsonTypeIsMissing() { @@ -509,9 +574,7 @@ public async Task Assertion_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncod test.ClientDataJson.TransformAsJsonObject(clientDataJson => { var base64UrlChallenge = (string)clientDataJson["challenge"]!; - var rawChallenge = Base64Url.DecodeFromChars(base64UrlChallenge); - var base64Challenge = Convert.ToBase64String(rawChallenge) + "=="; - clientDataJson["challenge"] = base64Challenge; + clientDataJson["challenge"] = GetInvalidBase64UrlValue(base64UrlChallenge); }); var result = await test.RunAsync(); @@ -803,7 +866,7 @@ public async Task Assertion_Succeeds_WhenSignCountIsZero() var test = new AssertionTest(); test.AuthenticatorDataArgs.Transform(args => args with { - SignCount = 0, // Normally 1 + SignCount = 0, // Usually 1 by default }); var result = await test.RunAsync(); @@ -1057,6 +1120,53 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsBackupEligibleButStored result.Failure.Message); } + [Fact] + public async Task Assertion_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 Assertion_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 Assertion_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 sealed class AssertionTest : PasskeyTestBase> { private static readonly byte[] _defaultChallenge = [1, 2, 3, 4, 5, 6, 7, 8]; @@ -1075,6 +1185,7 @@ private sealed class AssertionTest : PasskeyTestBase Challenge { get; set; } = _defaultChallenge; public ReadOnlyMemory CredentialId { get; set; } = _defaultCredentialId; @@ -1087,11 +1198,11 @@ private sealed class AssertionTest : PasskeyTestBase StoredPasskey { get; } = new(); - public void AddAllowCredentials(string userId) + public void AddAllowedCredential(ReadOnlyMemory credentialId) { _allowCredentials.Add(new() { - Id = BufferSource.FromString(userId), + Id = BufferSource.FromBytes(credentialId), Type = "public-key", Transports = ["internal"], }); @@ -1119,6 +1230,7 @@ protected override async Task> RunCoreAsync() { RpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(RpId ?? string.Empty)), Flags = AuthenticatorDataFlags.UserPresent, + SignCount = 1, }); var authenticatorData = AuthenticatorData.Compute(MakeAuthenticatorData(authenticatorDataArgs)); var clientDataJson = ClientDataJson.Compute($$""" @@ -1171,7 +1283,7 @@ protected override async Task> RunCoreAsync() userManager .Setup(m => m.GetPasskeyAsync(It.IsAny(), It.IsAny())) .Returns((PocoUser user, byte[] credentialId) => Task.FromResult( - user == User && CredentialId.Span.SequenceEqual(credentialId) + DoesCredentialExistOnUser && user == User && CredentialId.Span.SequenceEqual(credentialId) ? storedPasskey : null)); diff --git a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs index d15598ace9e7..33257ba7f82a 100644 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs @@ -68,9 +68,7 @@ public async Task Attestation_Fails_WhenCredentialIdIsNotBase64UrlEncoded() test.CredentialJson.TransformAsJsonObject(credentialJson => { var base64UrlCredentialId = (string)credentialJson["id"]!; - var rawCredentialId = Base64Url.DecodeFromChars(base64UrlCredentialId); - var base64CredentialId = Convert.ToBase64String(rawCredentialId) + "=="; - credentialJson["id"] = base64CredentialId; + credentialJson["id"] = GetInvalidBase64UrlValue(base64UrlCredentialId); }); var result = await test.RunAsync(); @@ -163,6 +161,248 @@ public async Task Attestation_Fails_WhenCredentialResponseIsNotAnObject(string j Assert.StartsWith("The attestation credential JSON had an invalid format", result.Failure.Message); } + [Fact] + public async Task Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_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 Attestation_Fails_WhenClientDataJsonIsMissing() { @@ -371,9 +611,7 @@ public async Task Attestation_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEnc test.ClientDataJson.TransformAsJsonObject(clientDataJson => { var base64UrlChallenge = (string)clientDataJson["challenge"]!; - var rawChallenge = Base64Url.DecodeFromChars(base64UrlChallenge); - var base64Challenge = Convert.ToBase64String(rawChallenge) + "=="; - clientDataJson["challenge"] = base64Challenge; + clientDataJson["challenge"] = GetInvalidBase64UrlValue(base64UrlChallenge); }); var result = await test.RunAsync(); @@ -854,10 +1092,75 @@ public async Task Attestation_Fails_WhenAlgorithmIsNotSupported(int algorithm) Assert.StartsWith("The credential public key algorithm does not match any of the supported algorithms", result.Failure.Message); } + [Fact] + public async Task Attestation_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 Attestation_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 Attestation_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 Attestation_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 sealed class AttestationTest : PasskeyTestBase { 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"; @@ -866,6 +1169,8 @@ private sealed class AttestationTest : PasskeyTestBase 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; @@ -883,7 +1188,10 @@ private sealed class AttestationTest : PasskeyTestBase protected override async Task RunCoreAsync() { var identityOptions = Options.Create(IdentityOptions); - var handler = new DefaultPasskeyHandler(identityOptions); + var handler = new TestPasskeyHandler(identityOptions) + { + ShouldFailAttestationStatementVerification = ShouldFailAttestationStatementVerification, + }; var supportedPublicKeyCredentialParameters = SupportedPublicKeyCredentialParameters.Compute( PublicKeyCredentialParameters.AllSupportedParameters); var pubKeyCredParamsJson = JsonSerializer.Serialize( @@ -913,12 +1221,14 @@ protected override async Task RunCoreAsync() 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, @@ -926,7 +1236,10 @@ protected override async Task RunCoreAsync() 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($$""" @@ -957,6 +1270,22 @@ protected override async Task RunCoreAsync() 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, @@ -967,5 +1296,26 @@ protected override async Task RunCoreAsync() 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/DefaultPasskeyHandlerTest.Helpers.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs index e84f03a94cba..6dbefac53655 100644 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs +++ b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs @@ -23,6 +23,12 @@ private static string ToBase64UrlJsonValue(ReadOnlyMemory? bytes) private static string ToBase64UrlJsonValue(string? value) => value is null ? "null" : $"\"{Base64Url.EncodeToString(Encoding.UTF8.GetBytes(value))}\""; + private static string GetInvalidBase64UrlValue(string base64UrlValue) + { + var rawValue = Base64Url.DecodeFromChars(base64UrlValue); + return Convert.ToBase64String(rawValue) + "=="; + } + private static ReadOnlyMemory MakeAttestedCredentialData(in AttestedCredentialDataArgs args) { const int AaguidLength = 16; @@ -117,31 +123,27 @@ private static ReadOnlyMemory MakeAttestationObject(in AttestationObjectAr return writer.Encode(); } - private readonly struct AttestedCredentialDataArgs() + private readonly struct AttestedCredentialDataArgs { - private static readonly ReadOnlyMemory _defaultAaguid = new byte[16]; - - public ReadOnlyMemory Aaguid { get; init; } = _defaultAaguid; + public required ReadOnlyMemory Aaguid { get; init; } public required ReadOnlyMemory CredentialId { get; init; } public required ReadOnlyMemory CredentialPublicKey { get; init; } } - private readonly struct AuthenticatorDataArgs() + private 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; } - public uint SignCount { get; init; } = 1; } - private readonly struct AttestationObjectArgs() + private readonly struct AttestationObjectArgs { - private static readonly byte[] _defaultAttestationStatement = [0xA0]; // Empty CBOR map - - public int? CborMapLength { get; init; } = 3; - public string? Format { get; init; } = "none"; - public ReadOnlyMemory? AttestationStatement { get; init; } = _defaultAttestationStatement; + public required int? CborMapLength { get; init; } + public required string? Format { get; init; } + public required ReadOnlyMemory? AttestationStatement { get; init; } public required ReadOnlyMemory? AuthenticatorData { get; init; } } From 8611a10445be069e04e736c3bc5b6ac16c5ac508 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 23 Jun 2025 11:38:12 -0400 Subject: [PATCH 6/9] Add SignInManager tests --- .../test/Identity.Test/SignInManagerTest.cs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) 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(); From 7987e4b7627079a30fb8a360a27678e2cc9ed2f2 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 23 Jun 2025 11:46:33 -0400 Subject: [PATCH 7/9] Add VersionThreeSchemaTest.cs --- .../test/EF.Test/VersionThreeSchemaTest.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs 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..d9f2aabaf2fd --- /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"); + } +} From bdb2262ddc0feddc0dd5d79bdc504ac627147ece Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 23 Jun 2025 14:54:50 -0400 Subject: [PATCH 8/9] Update VersionThreeSchemaTest --- .../test/EF.Test/VersionTestDbContext.cs | 8 ++++++++ .../test/EF.Test/VersionThreeSchemaTest.cs | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) 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 index d9f2aabaf2fd..bb5814150f9d 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs @@ -20,7 +20,7 @@ public VersionThreeSchemaTest(ScratchDatabaseFixture fixture) services .AddSingleton(new ConfigurationBuilder().Build()) - .AddDbContext(o => + .AddDbContext(o => o.UseSqlite(fixture.Connection) .ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning))) .AddIdentity(o => @@ -28,13 +28,13 @@ public VersionThreeSchemaTest(ScratchDatabaseFixture fixture) // MaxKeyLength does not need to be set in version 3 o.Stores.SchemaVersion = IdentitySchemaVersions.Version3; }) - .AddEntityFrameworkStores(); + .AddEntityFrameworkStores(); services.AddLogging(); _builder = new ApplicationBuilder(services.BuildServiceProvider()); var scope = _builder.ApplicationServices.GetRequiredService().CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); } @@ -42,7 +42,7 @@ public VersionThreeSchemaTest(ScratchDatabaseFixture fixture) public void EnsureDefaultSchema() { using var scope = _builder.ApplicationServices.GetRequiredService().CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); VerifyVersion3Schema(db); } From a8d3980da2e6ff3842bb02e894ebe9ef88265364 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 26 Jun 2025 11:57:22 -0400 Subject: [PATCH 9/9] PR feedback --- ...ultPasskeyHandlerTest.CredentialKeyPair.cs | 234 ---------------- .../DefaultPasskeyHandlerTest.Helpers.cs | 262 ------------------ .../Passkeys/AttestationObjectArgs.cs | 14 + .../Passkeys/AttestedCredentialDataArgs.cs | 11 + .../Passkeys/AuthenticatorDataArgs.cs | 13 + .../Passkeys/CredentialHelpers.cs | 104 +++++++ .../Passkeys/CredentialKeyPair.cs | 231 +++++++++++++++ .../DefaultPasskeyHandlerAssertionTest.cs} | 142 +++++----- .../DefaultPasskeyHandlerAttestationTest.cs} | 147 +++++----- .../Identity.Test/Passkeys/JsonHelpers.cs | 21 ++ .../Passkeys/PasskeyScenarioTest.cs | 122 ++++++++ 11 files changed, 669 insertions(+), 632 deletions(-) delete mode 100644 src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.CredentialKeyPair.cs delete mode 100644 src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs create mode 100644 src/Identity/test/Identity.Test/Passkeys/AttestationObjectArgs.cs create mode 100644 src/Identity/test/Identity.Test/Passkeys/AttestedCredentialDataArgs.cs create mode 100644 src/Identity/test/Identity.Test/Passkeys/AuthenticatorDataArgs.cs create mode 100644 src/Identity/test/Identity.Test/Passkeys/CredentialHelpers.cs create mode 100644 src/Identity/test/Identity.Test/Passkeys/CredentialKeyPair.cs rename src/Identity/test/Identity.Test/{DefaultPasskeyHandlerTest.Assertion.cs => Passkeys/DefaultPasskeyHandlerAssertionTest.cs} (88%) rename src/Identity/test/Identity.Test/{DefaultPasskeyHandlerTest.Attestation.cs => Passkeys/DefaultPasskeyHandlerAttestationTest.cs} (88%) create mode 100644 src/Identity/test/Identity.Test/Passkeys/JsonHelpers.cs create mode 100644 src/Identity/test/Identity.Test/Passkeys/PasskeyScenarioTest.cs diff --git a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.CredentialKeyPair.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.CredentialKeyPair.cs deleted file mode 100644 index 6dfc9f89829b..000000000000 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.CredentialKeyPair.cs +++ /dev/null @@ -1,234 +0,0 @@ -// 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; - -public partial class DefaultPasskeyHandlerTest -{ - private 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/DefaultPasskeyHandlerTest.Helpers.cs b/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs deleted file mode 100644 index 6dbefac53655..000000000000 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Helpers.cs +++ /dev/null @@ -1,262 +0,0 @@ -// 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.Binary; -using System.Buffers.Text; -using System.Formats.Cbor; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Microsoft.AspNetCore.Identity.Test; - -public partial class DefaultPasskeyHandlerTest -{ - private static string ToJsonValue(string? value) - => value is null ? "null" : $"\"{value}\""; - - private static string ToBase64UrlJsonValue(ReadOnlyMemory? bytes) - => !bytes.HasValue ? "null" : $"\"{Base64Url.EncodeToString(bytes.Value.Span)}\""; - - private static string ToBase64UrlJsonValue(string? value) - => value is null ? "null" : $"\"{Base64Url.EncodeToString(Encoding.UTF8.GetBytes(value))}\""; - - private static string GetInvalidBase64UrlValue(string base64UrlValue) - { - var rawValue = Base64Url.DecodeFromChars(base64UrlValue); - return Convert.ToBase64String(rawValue) + "=="; - } - - private 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; - } - - private 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; - } - - private 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(); - } - - private readonly struct AttestedCredentialDataArgs - { - public required ReadOnlyMemory Aaguid { get; init; } - public required ReadOnlyMemory CredentialId { get; init; } - public required ReadOnlyMemory CredentialPublicKey { get; init; } - } - - private 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; } - } - - private 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; } - } - - // Represents a test scenario for a passkey operation (attestation or assertion) - private abstract class PasskeyTestBase - { - 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. - private 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; - } - } - - private 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/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/DefaultPasskeyHandlerTest.Assertion.cs b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs similarity index 88% rename from src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs rename to src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs index c3d9433f1c74..4cc77b0e88d7 100644 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Assertion.cs +++ b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAssertionTest.cs @@ -15,10 +15,13 @@ namespace Microsoft.AspNetCore.Identity.Test; -public partial class DefaultPasskeyHandlerTest +using static JsonHelpers; +using static CredentialHelpers; + +public class DefaultPasskeyHandlerAssertionTest { [Fact] - public async Task Assertion_CanSucceed() + public async Task CanSucceed() { var test = new AssertionTest(); @@ -28,10 +31,9 @@ public async Task Assertion_CanSucceed() } [Fact] - public async Task Assertion_Fails_WhenCredentialIdIsMissing() + public async Task Fails_WhenCredentialIdIsMissing() { var test = new AssertionTest(); - test.CredentialJson.TransformAsJsonObject(credentialJson => { Assert.True(credentialJson.Remove("id")); @@ -48,7 +50,7 @@ public async Task Assertion_Fails_WhenCredentialIdIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenCredentialIdIsNotString(string jsonValue) + public async Task Fails_WhenCredentialIdIsNotString(string jsonValue) { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -63,7 +65,7 @@ public async Task Assertion_Fails_WhenCredentialIdIsNotString(string jsonValue) } [Fact] - public async Task Assertion_Fails_WhenCredentialIdIsNotBase64UrlEncoded() + public async Task Fails_WhenCredentialIdIsNotBase64UrlEncoded() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -80,7 +82,7 @@ public async Task Assertion_Fails_WhenCredentialIdIsNotBase64UrlEncoded() } [Fact] - public async Task Assertion_Fails_WhenCredentialTypeIsMissing() + public async Task Fails_WhenCredentialTypeIsMissing() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -99,7 +101,7 @@ public async Task Assertion_Fails_WhenCredentialTypeIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenCredentialTypeIsNotString(string jsonValue) + public async Task Fails_WhenCredentialTypeIsNotString(string jsonValue) { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -114,7 +116,7 @@ public async Task Assertion_Fails_WhenCredentialTypeIsNotString(string jsonValue } [Fact] - public async Task Assertion_Fails_WhenCredentialTypeIsNotPublicKey() + public async Task Fails_WhenCredentialTypeIsNotPublicKey() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -129,7 +131,7 @@ public async Task Assertion_Fails_WhenCredentialTypeIsNotPublicKey() } [Fact] - public async Task Assertion_Fails_WhenCredentialResponseIsMissing() + public async Task Fails_WhenCredentialResponseIsMissing() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -148,7 +150,7 @@ public async Task Assertion_Fails_WhenCredentialResponseIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("\"hello\"")] - public async Task Assertion_Fails_WhenCredentialResponseIsNotAnObject(string jsonValue) + public async Task Fails_WhenCredentialResponseIsNotAnObject(string jsonValue) { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -163,7 +165,7 @@ public async Task Assertion_Fails_WhenCredentialResponseIsNotAnObject(string jso } [Fact] - public async Task Assertion_Fails_WhenOriginalOptionsChallengeIsMissing() + public async Task Fails_WhenOriginalOptionsChallengeIsMissing() { var test = new AssertionTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -180,7 +182,7 @@ public async Task Assertion_Fails_WhenOriginalOptionsChallengeIsMissing() } [Fact] - public async Task Assertion_Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEncoded() + public async Task Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEncoded() { var test = new AssertionTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -200,7 +202,7 @@ public async Task Assertion_Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEnco [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenOriginalOptionsChallengeIsNotString(string jsonValue) + public async Task Fails_WhenOriginalOptionsChallengeIsNotString(string jsonValue) { var test = new AssertionTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -215,7 +217,7 @@ public async Task Assertion_Fails_WhenOriginalOptionsChallengeIsNotString(string } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonIsMissing() + public async Task Fails_WhenClientDataJsonIsMissing() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -235,7 +237,7 @@ public async Task Assertion_Fails_WhenClientDataJsonIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenClientDataJsonIsNotString(string jsonValue) + public async Task Fails_WhenClientDataJsonIsNotString(string jsonValue) { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -250,7 +252,7 @@ public async Task Assertion_Fails_WhenClientDataJsonIsNotString(string jsonValue } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonIsEmptyString() + public async Task Fails_WhenClientDataJsonIsEmptyString() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -265,7 +267,7 @@ public async Task Assertion_Fails_WhenClientDataJsonIsEmptyString() } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsMissing() + public async Task Fails_WhenAuthenticatorDataIsMissing() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -285,7 +287,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenAuthenticatorDataIsNotString(string jsonValue) + public async Task Fails_WhenAuthenticatorDataIsNotString(string jsonValue) { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -300,7 +302,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsNotString(string jsonVa } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBase64UrlEncoded() + public async Task Fails_WhenAuthenticatorDataIsNotBase64UrlEncoded() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -317,7 +319,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBase64UrlEncoded() } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsEmptyString() + public async Task Fails_WhenAuthenticatorDataIsEmptyString() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -332,7 +334,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsEmptyString() } [Fact] - public async Task Assertion_Fails_WhenResponseSignatureIsMissing() + public async Task Fails_WhenResponseSignatureIsMissing() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -352,7 +354,7 @@ public async Task Assertion_Fails_WhenResponseSignatureIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenResponseSignatureIsNotString(string jsonValue) + public async Task Fails_WhenResponseSignatureIsNotString(string jsonValue) { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -367,7 +369,7 @@ public async Task Assertion_Fails_WhenResponseSignatureIsNotString(string jsonVa } [Fact] - public async Task Assertion_Fails_WhenResponseSignatureIsNotBase64UrlEncoded() + public async Task Fails_WhenResponseSignatureIsNotBase64UrlEncoded() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -384,7 +386,7 @@ public async Task Assertion_Fails_WhenResponseSignatureIsNotBase64UrlEncoded() } [Fact] - public async Task Assertion_Fails_WhenResponseSignatureIsEmptyString() + public async Task Fails_WhenResponseSignatureIsEmptyString() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -399,7 +401,7 @@ public async Task Assertion_Fails_WhenResponseSignatureIsEmptyString() } [Fact] - public async Task Assertion_Fails_WhenResponseSignatureIsInvalid() + public async Task Fails_WhenResponseSignatureIsInvalid() { var test = new AssertionTest(); test.Signature.Transform(signature => @@ -418,7 +420,7 @@ public async Task Assertion_Fails_WhenResponseSignatureIsInvalid() [Theory] [InlineData("42")] [InlineData("{}")] - public async Task Assertion_Fails_WhenResponseUserHandleIsNotString(string jsonValue) + public async Task Fails_WhenResponseUserHandleIsNotString(string jsonValue) { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -433,7 +435,7 @@ public async Task Assertion_Fails_WhenResponseUserHandleIsNotString(string jsonV } [Fact] - public async Task Assertion_Fails_WhenResponseUserHandleIsNull() + public async Task Fails_WhenResponseUserHandleIsNull() { var test = new AssertionTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -448,7 +450,7 @@ public async Task Assertion_Fails_WhenResponseUserHandleIsNull() } [Fact] - public async Task Assertion_Fails_WhenResponseUserHandleDoesNotMatchUserId() + public async Task Fails_WhenResponseUserHandleDoesNotMatchUserId() { var test = new AssertionTest { @@ -467,7 +469,7 @@ public async Task Assertion_Fails_WhenResponseUserHandleDoesNotMatchUserId() } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonTypeIsMissing() + public async Task Fails_WhenClientDataJsonTypeIsMissing() { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -486,7 +488,7 @@ public async Task Assertion_Fails_WhenClientDataJsonTypeIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenClientDataJsonTypeIsNotString(string jsonValue) + public async Task Fails_WhenClientDataJsonTypeIsNotString(string jsonValue) { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -504,7 +506,7 @@ public async Task Assertion_Fails_WhenClientDataJsonTypeIsNotString(string jsonV [InlineData("")] [InlineData("webauthn.create")] [InlineData("unexpected-value")] - public async Task Assertion_Fails_WhenClientDataJsonTypeIsNotExpected(string value) + public async Task Fails_WhenClientDataJsonTypeIsNotExpected(string value) { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -519,7 +521,7 @@ public async Task Assertion_Fails_WhenClientDataJsonTypeIsNotExpected(string val } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonChallengeIsMissing() + public async Task Fails_WhenClientDataJsonChallengeIsMissing() { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -538,7 +540,7 @@ public async Task Assertion_Fails_WhenClientDataJsonChallengeIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenClientDataJsonChallengeIsNotString(string jsonValue) + public async Task Fails_WhenClientDataJsonChallengeIsNotString(string jsonValue) { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -553,7 +555,7 @@ public async Task Assertion_Fails_WhenClientDataJsonChallengeIsNotString(string } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonChallengeIsEmptyString() + public async Task Fails_WhenClientDataJsonChallengeIsEmptyString() { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -568,7 +570,7 @@ public async Task Assertion_Fails_WhenClientDataJsonChallengeIsEmptyString() } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() + public async Task Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -585,7 +587,7 @@ public async Task Assertion_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncod } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonChallengeIsNotRequestChallenge() + public async Task Fails_WhenClientDataJsonChallengeIsNotRequestChallenge() { var test = new AssertionTest(); var modifiedChallenge = (byte[])[.. test.Challenge.Span]; @@ -606,7 +608,7 @@ public async Task Assertion_Fails_WhenClientDataJsonChallengeIsNotRequestChallen } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonOriginIsMissing() + public async Task Fails_WhenClientDataJsonOriginIsMissing() { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -625,7 +627,7 @@ public async Task Assertion_Fails_WhenClientDataJsonOriginIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Assertion_Fails_WhenClientDataJsonOriginIsNotString(string jsonValue) + public async Task Fails_WhenClientDataJsonOriginIsNotString(string jsonValue) { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -640,7 +642,7 @@ public async Task Assertion_Fails_WhenClientDataJsonOriginIsNotString(string jso } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonOriginIsEmptyString() + public async Task Fails_WhenClientDataJsonOriginIsEmptyString() { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -659,7 +661,7 @@ public async Task Assertion_Fails_WhenClientDataJsonOriginIsEmptyString() [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 Assertion_Fails_WhenClientDataJsonOriginDoesNotMatchTheExpectedOrigin(string expectedOrigin, string returnedOrigin) + public async Task Fails_WhenClientDataJsonOriginDoesNotMatchTheExpectedOrigin(string expectedOrigin, string returnedOrigin) { var test = new AssertionTest { @@ -679,7 +681,7 @@ public async Task Assertion_Fails_WhenClientDataJsonOriginDoesNotMatchTheExpecte [Theory] [InlineData("42")] [InlineData("\"hello\"")] - public async Task Assertion_Fails_WhenClientDataJsonTokenBindingIsNotObject(string jsonValue) + public async Task Fails_WhenClientDataJsonTokenBindingIsNotObject(string jsonValue) { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -694,7 +696,7 @@ public async Task Assertion_Fails_WhenClientDataJsonTokenBindingIsNotObject(stri } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonTokenBindingStatusIsMissing() + public async Task Fails_WhenClientDataJsonTokenBindingStatusIsMissing() { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -710,7 +712,7 @@ public async Task Assertion_Fails_WhenClientDataJsonTokenBindingStatusIsMissing( } [Fact] - public async Task Assertion_Fails_WhenClientDataJsonTokenBindingStatusIsInvalid() + public async Task Fails_WhenClientDataJsonTokenBindingStatusIsInvalid() { var test = new AssertionTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -729,7 +731,7 @@ public async Task Assertion_Fails_WhenClientDataJsonTokenBindingStatusIsInvalid( } [Fact] - public async Task Assertion_Succeeds_WhenUserVerificationIsRequiredAndUserIsVerified() + public async Task Succeeds_WhenUserVerificationIsRequiredAndUserIsVerified() { var test = new AssertionTest(); test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => @@ -747,7 +749,7 @@ public async Task Assertion_Succeeds_WhenUserVerificationIsRequiredAndUserIsVeri } [Fact] - public async Task Assertion_Succeeds_WhenUserVerificationIsDiscouragedAndUserIsVerified() + public async Task Succeeds_WhenUserVerificationIsDiscouragedAndUserIsVerified() { var test = new AssertionTest(); test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => @@ -765,7 +767,7 @@ public async Task Assertion_Succeeds_WhenUserVerificationIsDiscouragedAndUserIsV } [Fact] - public async Task Assertion_Fails_WhenUserVerificationIsRequiredAndUserIsNotVerified() + public async Task Fails_WhenUserVerificationIsRequiredAndUserIsNotVerified() { var test = new AssertionTest(); test.OriginalOptionsJson.TransformAsJsonObject(optionsJson => @@ -782,7 +784,7 @@ public async Task Assertion_Fails_WhenUserVerificationIsRequiredAndUserIsNotVeri } [Fact] - public async Task Assertion_Fails_WhenUserIsNotPresent() + public async Task Fails_WhenUserIsNotPresent() { var test = new AssertionTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -797,7 +799,7 @@ public async Task Assertion_Fails_WhenUserIsNotPresent() } [Fact] - public async Task Assertion_Succeeds_WhenAuthenticatorDataContainsExtensionData() + public async Task Succeeds_WhenAuthenticatorDataContainsExtensionData() { var test = new AssertionTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -812,7 +814,7 @@ public async Task Assertion_Succeeds_WhenAuthenticatorDataContainsExtensionData( } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataContainsExtraBytes() + public async Task Fails_WhenAuthenticatorDataContainsExtraBytes() { var test = new AssertionTest(); test.AuthenticatorData.Transform(authenticatorData => @@ -827,7 +829,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataContainsExtraBytes() } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataRpIdHashIsInvalid() + public async Task Fails_WhenAuthenticatorDataRpIdHashIsInvalid() { var test = new AssertionTest(); test.AuthenticatorDataArgs.Transform(args => @@ -844,7 +846,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataRpIdHashIsInvalid() } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataClientDataHashIsInvalid() + public async Task Fails_WhenAuthenticatorDataClientDataHashIsInvalid() { var test = new AssertionTest(); test.ClientDataHash.Transform(clientDataHash => @@ -861,7 +863,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataClientDataHashIsInvalid() } [Fact] - public async Task Assertion_Succeeds_WhenSignCountIsZero() + public async Task Succeeds_WhenSignCountIsZero() { var test = new AssertionTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -880,7 +882,7 @@ public async Task Assertion_Succeeds_WhenSignCountIsZero() [InlineData(42, 42)] [InlineData(41, 42)] [InlineData(0, 1)] - public async Task Assertion_Fails_WhenAuthenticatorDataSignCountLessThanOrEqualToStoredSignCount( + public async Task Fails_WhenAuthenticatorDataSignCountLessThanOrEqualToStoredSignCount( uint authenticatorDataSignCount, uint storedSignCount) { @@ -913,7 +915,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataSignCountLessThanOrEqualT [InlineData((int)COSEAlgorithmIdentifier.ES256)] [InlineData((int)COSEAlgorithmIdentifier.ES384)] [InlineData((int)COSEAlgorithmIdentifier.ES512)] - public async Task Assertion_Succeeds_WithSupportedAlgorithms(int algorithm) + public async Task Succeeds_WithSupportedAlgorithms(int algorithm) { var test = new AssertionTest { @@ -926,7 +928,7 @@ public async Task Assertion_Succeeds_WithSupportedAlgorithms(int algorithm) } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBackupEligibleButBackedUp() + public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButBackedUp() { var test = new AssertionTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -949,7 +951,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBackupEligibleButBac [Theory] [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] - public async Task Assertion_Succeeds_WhenAuthenticatorDataIsBackupEligible(PasskeyOptions.CredentialBackupPolicy backupEligibility) + public async Task Succeeds_WhenAuthenticatorDataIsBackupEligible(PasskeyOptions.CredentialBackupPolicy backupEligibility) { var test = new AssertionTest(); test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = backupEligibility; @@ -969,7 +971,7 @@ public async Task Assertion_Succeeds_WhenAuthenticatorDataIsBackupEligible(Passk } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsBackupEligibleButDisallowed() + public async Task Fails_WhenAuthenticatorDataIsBackupEligibleButDisallowed() { var test = new AssertionTest(); test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; @@ -992,7 +994,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsBackupEligibleButDisall } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBackupEligibleButRequired() + public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButRequired() { var test = new AssertionTest(); test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; @@ -1038,7 +1040,7 @@ public async Task Attestation_Fails_WhenAuthenticatorDataIsBackedUp(PasskeyOptio } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsBackedUpButDisallowed() + public async Task Fails_WhenAuthenticatorDataIsBackedUpButDisallowed() { var test = new AssertionTest(); test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; @@ -1062,7 +1064,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsBackedUpButDisallowed() } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBackedUpButRequired() + public async Task Fails_WhenAuthenticatorDataIsNotBackedUpButRequired() { var test = new AssertionTest(); test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; @@ -1085,7 +1087,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBackedUpButRequired( } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBackupEligibleButStoredPasskeyIs() + public async Task Fails_WhenAuthenticatorDataIsNotBackupEligibleButStoredPasskeyIs() { var test = new AssertionTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -1103,7 +1105,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsNotBackupEligibleButSto } [Fact] - public async Task Assertion_Fails_WhenAuthenticatorDataIsBackupEligibleButStoredPasskeyIsNot() + public async Task Fails_WhenAuthenticatorDataIsBackupEligibleButStoredPasskeyIsNot() { var test = new AssertionTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -1121,7 +1123,7 @@ public async Task Assertion_Fails_WhenAuthenticatorDataIsBackupEligibleButStored } [Fact] - public async Task Assertion_Fails_WhenProvidedCredentialIsNotInAllowedCredentials() + public async Task Fails_WhenProvidedCredentialIsNotInAllowedCredentials() { var test = new AssertionTest(); var allowedCredentialId = test.CredentialId.ToArray(); @@ -1137,7 +1139,7 @@ public async Task Assertion_Fails_WhenProvidedCredentialIsNotInAllowedCredential } [Fact] - public async Task Assertion_Succeeds_WhenProvidedCredentialIsInAllowedCredentials() + public async Task Succeeds_WhenProvidedCredentialIsInAllowedCredentials() { var test = new AssertionTest(); var otherAllowedCredentialId = test.CredentialId.ToArray(); @@ -1153,7 +1155,7 @@ public async Task Assertion_Succeeds_WhenProvidedCredentialIsInAllowedCredential [Theory] [InlineData(false)] [InlineData(true)] - public async Task Assertion_Fails_WhenCredentialDoesNotExistOnTheUser(bool isUserIdentified) + public async Task Fails_WhenCredentialDoesNotExistOnTheUser(bool isUserIdentified) { var test = new AssertionTest { @@ -1167,7 +1169,13 @@ public async Task Assertion_Fails_WhenCredentialDoesNotExistOnTheUser(bool isUse Assert.StartsWith("The provided credential does not belong to the specified user", result.Failure.Message); } - private sealed class AssertionTest : PasskeyTestBase> + 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]; diff --git a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs similarity index 88% rename from src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs rename to src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs index 33257ba7f82a..bb15253efbcc 100644 --- a/src/Identity/test/Identity.Test/DefaultPasskeyHandlerTest.Attestation.cs +++ b/src/Identity/test/Identity.Test/Passkeys/DefaultPasskeyHandlerAttestationTest.cs @@ -15,10 +15,13 @@ namespace Microsoft.AspNetCore.Identity.Test; -public partial class DefaultPasskeyHandlerTest +using static JsonHelpers; +using static CredentialHelpers; + +public class DefaultPasskeyHandlerAttestationTest { [Fact] - public async Task Attestation_CanSucceed() + public async Task CanSucceed() { var test = new AttestationTest(); @@ -28,7 +31,7 @@ public async Task Attestation_CanSucceed() } [Fact] - public async Task Attestation_Fails_WhenCredentialIdIsMissing() + public async Task Fails_WhenCredentialIdIsMissing() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -47,7 +50,7 @@ public async Task Attestation_Fails_WhenCredentialIdIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenCredentialIdIsNotString(string jsonValue) + public async Task Fails_WhenCredentialIdIsNotString(string jsonValue) { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -62,7 +65,7 @@ public async Task Attestation_Fails_WhenCredentialIdIsNotString(string jsonValue } [Fact] - public async Task Attestation_Fails_WhenCredentialIdIsNotBase64UrlEncoded() + public async Task Fails_WhenCredentialIdIsNotBase64UrlEncoded() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -79,7 +82,7 @@ public async Task Attestation_Fails_WhenCredentialIdIsNotBase64UrlEncoded() } [Fact] - public async Task Attestation_Fails_WhenCredentialTypeIsMissing() + public async Task Fails_WhenCredentialTypeIsMissing() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -98,7 +101,7 @@ public async Task Attestation_Fails_WhenCredentialTypeIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenCredentialTypeIsNotString(string jsonValue) + public async Task Fails_WhenCredentialTypeIsNotString(string jsonValue) { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -113,7 +116,7 @@ public async Task Attestation_Fails_WhenCredentialTypeIsNotString(string jsonVal } [Fact] - public async Task Attestation_Fails_WhenCredentialTypeIsNotPublicKey() + public async Task Fails_WhenCredentialTypeIsNotPublicKey() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -128,7 +131,7 @@ public async Task Attestation_Fails_WhenCredentialTypeIsNotPublicKey() } [Fact] - public async Task Attestation_Fails_WhenCredentialResponseIsMissing() + public async Task Fails_WhenCredentialResponseIsMissing() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -147,7 +150,7 @@ public async Task Attestation_Fails_WhenCredentialResponseIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("\"hello\"")] - public async Task Attestation_Fails_WhenCredentialResponseIsNotAnObject(string jsonValue) + public async Task Fails_WhenCredentialResponseIsNotAnObject(string jsonValue) { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -162,7 +165,7 @@ public async Task Attestation_Fails_WhenCredentialResponseIsNotAnObject(string j } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsRpNameIsMissing() + public async Task Fails_WhenOriginalOptionsRpNameIsMissing() { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -182,7 +185,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsRpNameIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenOriginalOptionsRpNameIsNotString(string jsonValue) + public async Task Fails_WhenOriginalOptionsRpNameIsNotString(string jsonValue) { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -197,7 +200,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsRpNameIsNotString(string } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsRpIsMissing() + public async Task Fails_WhenOriginalOptionsRpIsMissing() { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -213,7 +216,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsRpIsMissing() } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsUserIdIsMissing() + public async Task Fails_WhenOriginalOptionsUserIdIsMissing() { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -230,7 +233,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsUserIdIsMissing() } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsUserIdIsNotBase64UrlEncoded() + public async Task Fails_WhenOriginalOptionsUserIdIsNotBase64UrlEncoded() { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -250,7 +253,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsUserIdIsNotBase64UrlEncod [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenOriginalOptionsUserIdIsNotString(string jsonValue) + public async Task Fails_WhenOriginalOptionsUserIdIsNotString(string jsonValue) { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -265,7 +268,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsUserIdIsNotString(string } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsUserNameIsMissing() + public async Task Fails_WhenOriginalOptionsUserNameIsMissing() { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -285,7 +288,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsUserNameIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenOriginalOptionsUserNameIsNotString(string jsonValue) + public async Task Fails_WhenOriginalOptionsUserNameIsNotString(string jsonValue) { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -300,7 +303,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsUserNameIsNotString(strin } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsUserDisplayNameIsMissing() + public async Task Fails_WhenOriginalOptionsUserDisplayNameIsMissing() { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -320,7 +323,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsUserDisplayNameIsMissing( [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenOriginalOptionsUserDisplayNameIsNotString(string jsonValue) + public async Task Fails_WhenOriginalOptionsUserDisplayNameIsNotString(string jsonValue) { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -335,7 +338,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsUserDisplayNameIsNotStrin } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsUserIsMissing() + public async Task Fails_WhenOriginalOptionsUserIsMissing() { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -351,7 +354,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsUserIsMissing() } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsChallengeIsMissing() + public async Task Fails_WhenOriginalOptionsChallengeIsMissing() { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -368,7 +371,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsChallengeIsMissing() } [Fact] - public async Task Attestation_Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEncoded() + public async Task Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEncoded() { var test = new AttestationTest(); @@ -389,7 +392,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsChallengeIsNotBase64UrlEn [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenOriginalOptionsChallengeIsNotString(string jsonValue) + public async Task Fails_WhenOriginalOptionsChallengeIsNotString(string jsonValue) { var test = new AttestationTest(); test.OriginalOptionsJson.TransformAsJsonObject(originalOptionsJson => @@ -404,7 +407,7 @@ public async Task Attestation_Fails_WhenOriginalOptionsChallengeIsNotString(stri } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonIsMissing() + public async Task Fails_WhenClientDataJsonIsMissing() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -424,7 +427,7 @@ public async Task Attestation_Fails_WhenClientDataJsonIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenClientDataJsonIsNotString(string jsonValue) + public async Task Fails_WhenClientDataJsonIsNotString(string jsonValue) { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -439,7 +442,7 @@ public async Task Attestation_Fails_WhenClientDataJsonIsNotString(string jsonVal } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonIsEmptyString() + public async Task Fails_WhenClientDataJsonIsEmptyString() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -454,7 +457,7 @@ public async Task Attestation_Fails_WhenClientDataJsonIsEmptyString() } [Fact] - public async Task Attestation_Fails_WhenAttestationObjectIsMissing() + public async Task Fails_WhenAttestationObjectIsMissing() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -474,7 +477,7 @@ public async Task Attestation_Fails_WhenAttestationObjectIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenAttestationObjectIsNotString(string jsonValue) + public async Task Fails_WhenAttestationObjectIsNotString(string jsonValue) { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -489,7 +492,7 @@ public async Task Attestation_Fails_WhenAttestationObjectIsNotString(string json } [Fact] - public async Task Attestation_Fails_WhenAttestationObjectIsEmptyString() + public async Task Fails_WhenAttestationObjectIsEmptyString() { var test = new AttestationTest(); test.CredentialJson.TransformAsJsonObject(credentialJson => @@ -504,7 +507,7 @@ public async Task Attestation_Fails_WhenAttestationObjectIsEmptyString() } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonTypeIsMissing() + public async Task Fails_WhenClientDataJsonTypeIsMissing() { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -523,7 +526,7 @@ public async Task Attestation_Fails_WhenClientDataJsonTypeIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenClientDataJsonTypeIsNotString(string jsonValue) + public async Task Fails_WhenClientDataJsonTypeIsNotString(string jsonValue) { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -541,7 +544,7 @@ public async Task Attestation_Fails_WhenClientDataJsonTypeIsNotString(string jso [InlineData("")] [InlineData("webauthn.get")] [InlineData("unexpected-value")] - public async Task Attestation_Fails_WhenClientDataJsonTypeIsNotExpected(string value) + public async Task Fails_WhenClientDataJsonTypeIsNotExpected(string value) { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -556,7 +559,7 @@ public async Task Attestation_Fails_WhenClientDataJsonTypeIsNotExpected(string v } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonChallengeIsMissing() + public async Task Fails_WhenClientDataJsonChallengeIsMissing() { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -575,7 +578,7 @@ public async Task Attestation_Fails_WhenClientDataJsonChallengeIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenClientDataJsonChallengeIsNotString(string jsonValue) + public async Task Fails_WhenClientDataJsonChallengeIsNotString(string jsonValue) { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -590,7 +593,7 @@ public async Task Attestation_Fails_WhenClientDataJsonChallengeIsNotString(strin } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonChallengeIsEmptyString() + public async Task Fails_WhenClientDataJsonChallengeIsEmptyString() { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -605,7 +608,7 @@ public async Task Attestation_Fails_WhenClientDataJsonChallengeIsEmptyString() } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() + public async Task Fails_WhenClientDataJsonChallengeIsNotBase64UrlEncoded() { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -622,7 +625,7 @@ public async Task Attestation_Fails_WhenClientDataJsonChallengeIsNotBase64UrlEnc } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonChallengeIsNotRequestChallenge() + public async Task Fails_WhenClientDataJsonChallengeIsNotRequestChallenge() { var test = new AttestationTest(); var modifiedChallenge = (byte[])[.. test.Challenge.Span]; @@ -643,7 +646,7 @@ public async Task Attestation_Fails_WhenClientDataJsonChallengeIsNotRequestChall } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonOriginIsMissing() + public async Task Fails_WhenClientDataJsonOriginIsMissing() { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -662,7 +665,7 @@ public async Task Attestation_Fails_WhenClientDataJsonOriginIsMissing() [InlineData("42")] [InlineData("null")] [InlineData("{}")] - public async Task Attestation_Fails_WhenClientDataJsonOriginIsNotString(string jsonValue) + public async Task Fails_WhenClientDataJsonOriginIsNotString(string jsonValue) { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -677,7 +680,7 @@ public async Task Attestation_Fails_WhenClientDataJsonOriginIsNotString(string j } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonOriginIsEmptyString() + public async Task Fails_WhenClientDataJsonOriginIsEmptyString() { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -696,7 +699,7 @@ public async Task Attestation_Fails_WhenClientDataJsonOriginIsEmptyString() [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 Attestation_Fails_WhenClientDataJsonOriginDoesNotMatchTheExpectedOrigin(string expectedOrigin, string returnedOrigin) + public async Task Fails_WhenClientDataJsonOriginDoesNotMatchTheExpectedOrigin(string expectedOrigin, string returnedOrigin) { var test = new AttestationTest { @@ -716,7 +719,7 @@ public async Task Attestation_Fails_WhenClientDataJsonOriginDoesNotMatchTheExpec [Theory] [InlineData("42")] [InlineData("\"hello\"")] - public async Task Attestation_Fails_WhenClientDataJsonTokenBindingIsNotObject(string jsonValue) + public async Task Fails_WhenClientDataJsonTokenBindingIsNotObject(string jsonValue) { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -731,7 +734,7 @@ public async Task Attestation_Fails_WhenClientDataJsonTokenBindingIsNotObject(st } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonTokenBindingStatusIsMissing() + public async Task Fails_WhenClientDataJsonTokenBindingStatusIsMissing() { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -747,7 +750,7 @@ public async Task Attestation_Fails_WhenClientDataJsonTokenBindingStatusIsMissin } [Fact] - public async Task Attestation_Fails_WhenClientDataJsonTokenBindingStatusIsInvalid() + public async Task Fails_WhenClientDataJsonTokenBindingStatusIsInvalid() { var test = new AttestationTest(); test.ClientDataJson.TransformAsJsonObject(clientDataJson => @@ -766,7 +769,7 @@ public async Task Attestation_Fails_WhenClientDataJsonTokenBindingStatusIsInvali } [Fact] - public async Task Attestation_Succeeds_WhenAuthDataContainsExtensionData() + public async Task Succeeds_WhenAuthDataContainsExtensionData() { var test = new AttestationTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -781,7 +784,7 @@ public async Task Attestation_Succeeds_WhenAuthDataContainsExtensionData() } [Fact] - public async Task Attestation_Fails_WhenAuthDataIsNotBackupEligibleButBackedUp() + public async Task Fails_WhenAuthDataIsNotBackupEligibleButBackedUp() { var test = new AttestationTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -798,7 +801,7 @@ public async Task Attestation_Fails_WhenAuthDataIsNotBackupEligibleButBackedUp() [Theory] [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] - public async Task Attestation_Succeeds_WhenAuthDataIsBackupEligible(PasskeyOptions.CredentialBackupPolicy backupEligibility) + public async Task Succeeds_WhenAuthDataIsBackupEligible(PasskeyOptions.CredentialBackupPolicy backupEligibility) { var test = new AttestationTest(); test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = backupEligibility; @@ -812,7 +815,7 @@ public async Task Attestation_Succeeds_WhenAuthDataIsBackupEligible(PasskeyOptio } [Fact] - public async Task Attestation_Fails_WhenAuthDataIsBackupEligibleButDisallowed() + public async Task Fails_WhenAuthDataIsBackupEligibleButDisallowed() { var test = new AttestationTest(); test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; @@ -830,7 +833,7 @@ public async Task Attestation_Fails_WhenAuthDataIsBackupEligibleButDisallowed() } [Fact] - public async Task Attestation_Fails_WhenAuthDataIsNotBackupEligibleButRequired() + public async Task Fails_WhenAuthDataIsNotBackupEligibleButRequired() { var test = new AttestationTest(); test.IdentityOptions.Passkey.BackupEligibleCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; @@ -850,7 +853,7 @@ public async Task Attestation_Fails_WhenAuthDataIsNotBackupEligibleButRequired() [Theory] [InlineData(PasskeyOptions.CredentialBackupPolicy.Allowed)] [InlineData(PasskeyOptions.CredentialBackupPolicy.Required)] - public async Task Attestation_Fails_WhenAuthDataIsBackedUp(PasskeyOptions.CredentialBackupPolicy backedUpPolicy) + public async Task Fails_WhenAuthDataIsBackedUp(PasskeyOptions.CredentialBackupPolicy backedUpPolicy) { var test = new AttestationTest(); test.IdentityOptions.Passkey.BackedUpCredentialPolicy = backedUpPolicy; @@ -865,7 +868,7 @@ public async Task Attestation_Fails_WhenAuthDataIsBackedUp(PasskeyOptions.Creden } [Fact] - public async Task Attestation_Fails_WhenAuthDataIsBackedUpButDisallowed() + public async Task Fails_WhenAuthDataIsBackedUpButDisallowed() { var test = new AttestationTest(); test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Disallowed; @@ -883,7 +886,7 @@ public async Task Attestation_Fails_WhenAuthDataIsBackedUpButDisallowed() } [Fact] - public async Task Attestation_Fails_WhenAuthDataIsNotBackedUpButRequired() + public async Task Fails_WhenAuthDataIsNotBackedUpButRequired() { var test = new AttestationTest(); test.IdentityOptions.Passkey.BackedUpCredentialPolicy = PasskeyOptions.CredentialBackupPolicy.Required; @@ -901,7 +904,7 @@ public async Task Attestation_Fails_WhenAuthDataIsNotBackedUpButRequired() } [Fact] - public async Task Attestation_Fails_WhenAttestationObjectIsNotCborEncoded() + public async Task Fails_WhenAttestationObjectIsNotCborEncoded() { var test = new AttestationTest(); test.AttestationObject.Transform(bytes => Encoding.UTF8.GetBytes("Not a CBOR map")); @@ -913,7 +916,7 @@ public async Task Attestation_Fails_WhenAttestationObjectIsNotCborEncoded() } [Fact] - public async Task Attestation_Fails_WhenAttestationObjectFmtIsMissing() + public async Task Fails_WhenAttestationObjectFmtIsMissing() { var test = new AttestationTest(); test.AttestationObjectArgs.Transform(args => args with @@ -929,7 +932,7 @@ public async Task Attestation_Fails_WhenAttestationObjectFmtIsMissing() } [Fact] - public async Task Attestation_Fails_WhenAttestationObjectStmtFieldIsMissing() + public async Task Fails_WhenAttestationObjectStmtFieldIsMissing() { var test = new AttestationTest(); test.AttestationObjectArgs.Transform(args => args with @@ -945,7 +948,7 @@ public async Task Attestation_Fails_WhenAttestationObjectStmtFieldIsMissing() } [Fact] - public async Task Attestation_Fails_WhenAttestationObjectAuthDataFieldIsMissing() + public async Task Fails_WhenAttestationObjectAuthDataFieldIsMissing() { var test = new AttestationTest(); test.AttestationObjectArgs.Transform(args => args with @@ -961,7 +964,7 @@ public async Task Attestation_Fails_WhenAttestationObjectAuthDataFieldIsMissing( } [Fact] - public async Task Attestation_Fails_WhenAttestationObjectAuthDataFieldIsEmpty() + public async Task Fails_WhenAttestationObjectAuthDataFieldIsEmpty() { var test = new AttestationTest(); test.AttestationObjectArgs.Transform(args => args with @@ -976,7 +979,7 @@ public async Task Attestation_Fails_WhenAttestationObjectAuthDataFieldIsEmpty() } [Fact] - public async Task Attestation_Fails_WhenAttestedCredentialDataIsPresentButWithoutFlag() + public async Task Fails_WhenAttestedCredentialDataIsPresentButWithoutFlag() { var test = new AttestationTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -992,7 +995,7 @@ public async Task Attestation_Fails_WhenAttestedCredentialDataIsPresentButWithou } [Fact] - public async Task Attestation_Fails_WhenAttestedCredentialDataIsNotPresentButWithFlag() + public async Task Fails_WhenAttestedCredentialDataIsNotPresentButWithFlag() { var test = new AttestationTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -1008,7 +1011,7 @@ public async Task Attestation_Fails_WhenAttestedCredentialDataIsNotPresentButWit } [Fact] - public async Task Attestation_Fails_WhenAttestedCredentialDataIsNotPresent() + public async Task Fails_WhenAttestedCredentialDataIsNotPresent() { var test = new AttestationTest(); test.AuthenticatorDataArgs.Transform(args => args with @@ -1024,7 +1027,7 @@ public async Task Attestation_Fails_WhenAttestedCredentialDataIsNotPresent() } [Fact] - public async Task Attestation_Fails_WhenAttestedCredentialDataHasExtraBytes() + public async Task Fails_WhenAttestedCredentialDataHasExtraBytes() { var test = new AttestationTest(); test.AttestedCredentialData.Transform(attestedCredentialData => @@ -1048,7 +1051,7 @@ public async Task Attestation_Fails_WhenAttestedCredentialDataHasExtraBytes() [InlineData((int)COSEAlgorithmIdentifier.ES256)] [InlineData((int)COSEAlgorithmIdentifier.ES384)] [InlineData((int)COSEAlgorithmIdentifier.ES512)] - public async Task Attestation_Succeeds_WithSupportedAlgorithms(int algorithm) + public async Task Succeeds_WithSupportedAlgorithms(int algorithm) { var test = new AttestationTest { @@ -1074,7 +1077,7 @@ public async Task Attestation_Succeeds_WithSupportedAlgorithms(int algorithm) [InlineData((int)COSEAlgorithmIdentifier.ES256)] [InlineData((int)COSEAlgorithmIdentifier.ES384)] [InlineData((int)COSEAlgorithmIdentifier.ES512)] - public async Task Attestation_Fails_WhenAlgorithmIsNotSupported(int algorithm) + public async Task Fails_WhenAlgorithmIsNotSupported(int algorithm) { var test = new AttestationTest { @@ -1093,7 +1096,7 @@ public async Task Attestation_Fails_WhenAlgorithmIsNotSupported(int algorithm) } [Fact] - public async Task Attestation_Fails_WhenVerifyAttestationStatementAsyncReturnsFalse() + public async Task Fails_WhenVerifyAttestationStatementAsyncReturnsFalse() { var test = new AttestationTest { @@ -1109,7 +1112,7 @@ public async Task Attestation_Fails_WhenVerifyAttestationStatementAsyncReturnsFa [Theory] [InlineData(1024)] [InlineData(2048)] - public async Task Attestation_Fails_WhenCredentialIdIsTooLong(int length) + public async Task Fails_WhenCredentialIdIsTooLong(int length) { var test = new AttestationTest { @@ -1123,7 +1126,7 @@ public async Task Attestation_Fails_WhenCredentialIdIsTooLong(int length) } [Fact] - public async Task Attestation_Fails_WhenCredentialIdDoesNotMatchAttestedCredentialId() + public async Task Fails_WhenCredentialIdDoesNotMatchAttestedCredentialId() { var test = new AttestationTest(); test.AttestedCredentialDataArgs.Transform(args => @@ -1142,7 +1145,7 @@ public async Task Attestation_Fails_WhenCredentialIdDoesNotMatchAttestedCredenti } [Fact] - public async Task Attestation_Fails_WhenCredentialIdAlreadyExistsForAnotherUser() + public async Task Fails_WhenCredentialIdAlreadyExistsForAnotherUser() { var test = new AttestationTest { @@ -1155,7 +1158,13 @@ public async Task Attestation_Fails_WhenCredentialIdAlreadyExistsForAnotherUser( Assert.StartsWith("The credential is already registered for a user", result.Failure.Message); } - private sealed class AttestationTest : PasskeyTestBase + 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]; 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); + } + }); + } + } +}