Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ private static void AddWebAuthnLoginCommands(this IServiceCollection services)
services.AddScoped<ICreateWebAuthnLoginCredentialCommand, CreateWebAuthnLoginCredentialCommand>();
services.AddScoped<IGetWebAuthnLoginCredentialAssertionOptionsCommand, GetWebAuthnLoginCredentialAssertionOptionsCommand>();
services.AddScoped<IAssertWebAuthnLoginCredentialCommand, AssertWebAuthnLoginCredentialCommand>();
services.AddScoped<IWebAuthnChallengeCacheProvider, WebAuthnChallengeCacheProvider>();
}

private static void AddTwoFactorCommandsQueries(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
ο»Ώnamespace Bit.Core.Auth.UserFeatures.WebAuthnLogin;

/// <summary>
/// Enforces single-use semantics for WebAuthn assertion challenges per the W3C WebAuthn
/// specification (Β§13.4.3). Tracks used challenges so that each challenge can only be
/// validated once.
/// </summary>
public interface IWebAuthnChallengeCacheProvider
{
/// <summary>
/// Attempts to mark a challenge as used. Returns <c>true</c> if this is the first use
/// (challenge was not in cache and has now been saved). Returns <c>false</c> if the
/// challenge was already marked as used (found in cache).
/// </summary>
/// <param name="challenge">The challenge bytes from <see cref="Fido2NetLib.AssertionOptions.Challenge"/>.</param>
Task<bool> TryMarkChallengeAsUsedAsync(byte[] challenge);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,27 @@ internal class AssertWebAuthnLoginCredentialCommand : IAssertWebAuthnLoginCreden
private readonly IFido2 _fido2;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
private readonly IUserRepository _userRepository;
private readonly IWebAuthnChallengeCacheProvider _webAuthnChallengeCache;

public AssertWebAuthnLoginCredentialCommand(IFido2 fido2, IWebAuthnCredentialRepository webAuthnCredentialRepository, IUserRepository userRepository)
public AssertWebAuthnLoginCredentialCommand(
IFido2 fido2,
IWebAuthnCredentialRepository webAuthnCredentialRepository,
IUserRepository userRepository,
IWebAuthnChallengeCacheProvider webAuthnChallengeCache)
{
_fido2 = fido2;
_webAuthnCredentialRepository = webAuthnCredentialRepository;
_userRepository = userRepository;
_webAuthnChallengeCache = webAuthnChallengeCache;
}

public async Task<(User, WebAuthnCredential)> AssertWebAuthnLoginCredential(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse)
{
if (!await _webAuthnChallengeCache.TryMarkChallengeAsUsedAsync(options.Challenge))
{
throw new BadRequestException("Invalid credential.");
}

if (!GuidUtilities.TryParseBytes(assertionResponse.Response.UserHandle, out var userId))
{
throw new BadRequestException("Invalid credential.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
ο»Ώusing Bit.Core.Utilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;

internal class WebAuthnChallengeCacheProvider(
[FromKeyedServices("persistent")] IDistributedCache distributedCache) : IWebAuthnChallengeCacheProvider
{
private const string _cacheKeyPrefix = "WebAuthnLoginAssertion_";
private static readonly DistributedCacheEntryOptions _cacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(17)
Comment thread
ike-kottlowski marked this conversation as resolved.
Outdated
};

private readonly IDistributedCache _distributedCache = distributedCache;

public async Task<bool> TryMarkChallengeAsUsedAsync(byte[] challenge)
{
var cacheKey = BuildCacheKey(challenge);
var cached = await _distributedCache.GetAsync(cacheKey);
if (cached != null)
{
return false;
}

await _distributedCache.SetAsync(cacheKey, [1], _cacheOptions);
return true;
}

private static string BuildCacheKey(byte[] challenge)
=> $"{_cacheKeyPrefix}{CoreHelpers.Base64UrlEncode(challenge)}";
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
ο»Ώ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
Comment thread
ike-kottlowski marked this conversation as resolved.

using System.Security.Claims;
ο»Ώusing System.Security.Claims;
using System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
Expand Down Expand Up @@ -101,7 +98,7 @@ public async Task ValidateAsync(ExtensionGrantValidationContext context)
token.TokenIsValid(WebAuthnLoginAssertionOptionsScope.Authentication);
var deviceResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(rawDeviceResponse);

if (!verified)
if (!verified || deviceResponse == null)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
ο»Ώusing System.Text;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
Expand All @@ -19,10 +20,27 @@ namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin;
[SutProviderCustomize]
public class AssertWebAuthnLoginCredentialCommandTests
{
[Theory, BitAutoData]
internal async Task ChallengeNotCacheable_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge)
.Returns(false);

// Act
var result = async () => await sutProvider.Sut.AssertWebAuthnLoginCredential(options, response);

// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}

[Theory, BitAutoData]
internal async Task InvalidUserHandle_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
response.Response.UserHandle = Encoding.UTF8.GetBytes("invalid-user-handle");

// Act
Expand All @@ -36,6 +54,8 @@ internal async Task InvalidUserHandle_ThrowsBadRequestException(SutProvider<Asse
internal async Task UserNotFound_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
response.Response.UserHandle = user.Id.ToByteArray();
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).ReturnsNull();

Expand All @@ -50,6 +70,8 @@ internal async Task UserNotFound_ThrowsBadRequestException(SutProvider<AssertWeb
internal async Task NoMatchingCredentialExists_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
response.Response.UserHandle = user.Id.ToByteArray();
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { });
Expand All @@ -65,6 +87,8 @@ internal async Task NoMatchingCredentialExists_ThrowsBadRequestException(SutProv
internal async Task AssertionFails_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
var credentialId = Guid.NewGuid().ToByteArray();
credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
response.Id = credentialId;
Expand All @@ -86,6 +110,8 @@ internal async Task AssertionFails_ThrowsBadRequestException(SutProvider<AssertW
internal async Task AssertionSucceeds_ReturnsUserAndCredential(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
var credentialId = Guid.NewGuid().ToByteArray();
credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
response.Id = credentialId;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
ο»Ώusing Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Caching.Distributed;
using NSubstitute;
using Xunit;

namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin;

[SutProviderCustomize]
public class WebAuthnChallengeCacheProviderTests
{
[Theory, BitAutoData]
internal async Task TryMarkChallengeAsUsedAsync_FirstUse_SavesAndReturnsTrue(
SutProvider<WebAuthnChallengeCacheProvider> sutProvider)
{
// Arrange
var challenge = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var expectedKey = $"WebAuthnLoginAssertion_{CoreHelpers.Base64UrlEncode(challenge)}";

sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedKey, Arg.Any<CancellationToken>())
.Returns((byte[])null);

// Act
var result = await sutProvider.Sut.TryMarkChallengeAsUsedAsync(challenge);

// Assert
Assert.True(result);
await sutProvider.GetDependency<IDistributedCache>()
.Received(1)
.SetAsync(
expectedKey,
Arg.Is<byte[]>(b => b.Length == 1 && b[0] == 1),
Arg.Is<DistributedCacheEntryOptions>(o =>
o.AbsoluteExpirationRelativeToNow == TimeSpan.FromMinutes(17)),
Arg.Any<CancellationToken>());
}

[Theory, BitAutoData]
internal async Task TryMarkChallengeAsUsedAsync_AlreadyUsed_ReturnsFalseAndDoesNotSave(
SutProvider<WebAuthnChallengeCacheProvider> sutProvider)
{
// Arrange
var challenge = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var expectedKey = $"WebAuthnLoginAssertion_{CoreHelpers.Base64UrlEncode(challenge)}";

sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedKey, Arg.Any<CancellationToken>())
.Returns(new byte[] { 1 });

// Act
var result = await sutProvider.Sut.TryMarkChallengeAsUsedAsync(challenge);

// Assert
Assert.False(result);
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.SetAsync(
Arg.Any<string>(),
Arg.Any<byte[]>(),
Arg.Any<DistributedCacheEntryOptions>(),
Arg.Any<CancellationToken>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
ο»Ώusing System.Collections.Specialized;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Entities;
using Bit.Core.Tokens;
using Bit.Identity.IdentityServer.RequestValidators;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityServer.Validation;
using Fido2NetLib;
using NSubstitute;
using Xunit;
using AuthFixtures = Bit.Identity.Test.AutoFixture;

namespace Bit.Identity.Test.IdentityServer.RequestValidators;

[SutProviderCustomize]
public class WebAuthnGrantValidatorTests
{
private static ExtensionGrantValidationContext CreateContext(
ValidatedTokenRequest tokenRequest,
string token = "test-token",
string deviceResponse = """{"id":"abc","rawId":"abc","type":"public-key","response":{"authenticatorData":"dGVzdA","signature":"dGVzdA","clientDataJSON":"dGVzdA","userHandle":"dGVzdA"}}""")
{
tokenRequest.Raw = new NameValueCollection
{
{ "token", token },
{ "deviceResponse", deviceResponse }
};

return new ExtensionGrantValidationContext { Request = tokenRequest };
}

[Theory, BitAutoData]
public async Task ValidateAsync_MissingToken_RejectsWithInvalidGrant(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<WebAuthnGrantValidator> sutProvider)
{
// Arrange - no token or deviceResponse in raw params
tokenRequest.Raw = new NameValueCollection();
var context = new ExtensionGrantValidationContext { Request = tokenRequest };

// Act
await sutProvider.Sut.ValidateAsync(context);

// Assert
Assert.Equal("invalid_grant", context.Result.Error);
}

[Theory, BitAutoData]
public async Task ValidateAsync_InvalidToken_RejectsWithInvalidRequest(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<WebAuthnGrantValidator> sutProvider)
{
// Arrange
var context = CreateContext(tokenRequest);

sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>()
.TryUnprotect(Arg.Any<string>(), out Arg.Any<WebAuthnLoginAssertionOptionsTokenable>())
.Returns(false);

// Act
await sutProvider.Sut.ValidateAsync(context);

// Assert
Assert.Equal("invalid_request", context.Result.Error);
}

[Theory, BitAutoData]
public async Task ValidateAsync_ValidToken_CallsAssertCommand(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
SutProvider<WebAuthnGrantValidator> sutProvider)
{
// Arrange
var challenge = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var options = new AssertionOptions { Challenge = challenge };
var tokenable = new WebAuthnLoginAssertionOptionsTokenable(
WebAuthnLoginAssertionOptionsScope.Authentication, options);

var context = CreateContext(tokenRequest);

sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>()
.TryUnprotect(Arg.Any<string>(), out Arg.Any<WebAuthnLoginAssertionOptionsTokenable>())
.Returns(x =>
{
x[1] = tokenable;
return true;
});

// Mock credential assertion to succeed
var user = new User { Id = Guid.NewGuid() };
var credential = new WebAuthnCredential();
sutProvider.GetDependency<IAssertWebAuthnLoginCredentialCommand>()
.AssertWebAuthnLoginCredential(Arg.Any<AssertionOptions>(), Arg.Any<AuthenticatorAssertionRawResponse>())
.Returns((user, credential));

// Act - the base validator pipeline may throw due to unmocked dependencies,
// but our code runs before that.
try
{
await sutProvider.Sut.ValidateAsync(context);
}
catch (NullReferenceException)
{
// Expected: base validator pipeline has unmocked dependencies
}

// Assert - verify the assert command was called
await sutProvider.GetDependency<IAssertWebAuthnLoginCredentialCommand>()
.Received(1)
.AssertWebAuthnLoginCredential(options, Arg.Any<AuthenticatorAssertionRawResponse>());
}
}
Loading