Skip to content

feat: mfa handshake infrastructure#17

Open
Thomas-Shephard wants to merge 2 commits intomainfrom
feature/mfa-handshake-infrastructure
Open

feat: mfa handshake infrastructure#17
Thomas-Shephard wants to merge 2 commits intomainfrom
feature/mfa-handshake-infrastructure

Conversation

@Thomas-Shephard
Copy link
Copy Markdown
Owner

No description provided.

Copilot AI review requested due to automatic review settings January 18, 2026 22:03
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements the foundational infrastructure for multi-factor authentication (MFA) handshakes in the Ashlar identity library. It introduces a session ticket mechanism that tracks verified authentication factors during a multi-step authentication flow, enabling support for requiring multiple authentication factors before granting access.

Changes:

  • Adds MFA handshake abstractions and models (IAuthenticationHandshake, SessionTicket, AuthenticationHandshake) for tracking authentication state across multiple factors
  • Extends IAuthenticationProvider with MFA-related properties (IsPrimary, IsSecondary, BypassesMfa) to classify authentication factors
  • Implements SessionTicketSerializer for secure serialization and deserialization of MFA session state with configurable expiry
  • Refactors IdentityService.LoginAsync to support both email-based and session-ticket-based authentication flows, with automatic MFA requirement detection
  • Adds GetCredentialsForUserAsync to IIdentityRepository to check if secondary factors are configured for a user

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 17 comments.

Show a summary per file
File Description
src/Ashlar/Identity/Abstractions/IAuthenticationHandshake.cs New interface defining MFA handshake state contract
src/Ashlar/Identity/Abstractions/ISessionTicketSerializer.cs New interface for session ticket serialization/deserialization
src/Ashlar/Identity/Abstractions/IAuthenticationProvider.cs Extended with MFA classification properties and bypass method
src/Ashlar/Identity/Abstractions/IIdentityRepository.cs Added method to retrieve all user credentials
src/Ashlar/Identity/Abstractions/IIdentityService.cs Added session ticket LoginAsync overload and MfaRequired status
src/Ashlar/Identity/AuthenticationHandshake.cs Concrete implementation of IAuthenticationHandshake
src/Ashlar/Identity/SessionTicketSerializer.cs Implements encrypted session ticket serialization with expiry
src/Ashlar/Identity/IdentityService.cs Refactored to support MFA flows with internal authentication method
src/Ashlar/Identity/Models/SessionTicket.cs Value object wrapping encrypted session ticket with redacted ToString
src/Ashlar/Identity/Models/ProviderType.cs Added JSON converter for serialization support
src/Ashlar/Identity/Models/IdentityServiceOptions.cs Added HandshakeExpiry configuration option
tests/Ashlar.Tests/Identity/SessionTicketSerializerTests.cs Unit tests for session ticket serializer
tests/Ashlar.Tests/Identity/MfaHandshakeTests.cs Integration tests for MFA handshake flows
tests/Ashlar.Tests/Identity/IdentityServiceTests.cs Updated tests for refactored IdentityService constructor and new MFA scenarios
tests/Ashlar.Tests/Identity/ModelTests.cs Added test for SessionTicket model
tests/Ashlar.Tests/Identity/AuthenticationProviderTests.cs Added test for GetProviderName with various types

var providerMock = new Mock<IAuthenticationProvider>();
providerMock.Setup(p => p.IsPrimary).Returns(true);
providerMock.Setup(p => p.SupportedType).Returns((ProviderType)"MOCK");
providerMock.Setup(p => p.IsPrimary).Returns(true);
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate setup call detected. Line 659 sets IsPrimary to true, but this is immediately repeated on line 659. The second setup on line 659 is redundant and should be removed.

Suggested change
providerMock.Setup(p => p.IsPrimary).Returns(true);

Copilot uses AI. Check for mistakes.
using Ashlar.Identity.Models;

namespace Ashlar.Identity;

Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing XML documentation for the AuthenticationHandshake class. This is a public class implementing IAuthenticationHandshake and should have a summary comment explaining its purpose, particularly noting that it's the concrete implementation used for MFA handshake state.

Suggested change
/// <summary>
/// Represents the state of an authentication handshake, including the user and verified
/// authentication factors. This is the concrete implementation of <see cref="IAuthenticationHandshake"/>
/// used to track multi-factor authentication (MFA) handshake state.
/// </summary>

Copilot uses AI. Check for mistakes.
Comment on lines +306 to +323
[Test]
public void ProviderTypeJsonConverterShouldHandleNullOrWhitespace()
{
var jsonNull = "null";
var jsonEmpty = "\"\"";
var jsonSpace = "\" \"";

var resultNull = System.Text.Json.JsonSerializer.Deserialize<ProviderType>(jsonNull);
var resultEmpty = System.Text.Json.JsonSerializer.Deserialize<ProviderType>(jsonEmpty);
var resultSpace = System.Text.Json.JsonSerializer.Deserialize<ProviderType>(jsonSpace);

using (Assert.EnterMultipleScope())
{
Assert.That(resultNull.Value, Is.EqualTo(string.Empty));
Assert.That(resultEmpty.Value, Is.EqualTo(string.Empty));
Assert.That(resultSpace.Value, Is.EqualTo(string.Empty));
}
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the ProviderType JSON converter's Write method. The test on line 307 only tests deserialization (Read method), but doesn't verify that serialization (Write method) produces the expected JSON string output.

Copilot uses AI. Check for mistakes.
Comment on lines 10 to 11

Task<AuthenticationResponse> LoginAsync(string email, IAuthenticationAssertion assertion, Guid? tenantId = null, CancellationToken cancellationToken = default);
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing XML documentation for the new LoginAsync overload. This method should have a summary explaining its purpose for continuing an MFA authentication flow using a session ticket, along with parameter descriptions and return value documentation.

Suggested change
Task<AuthenticationResponse> LoginAsync(string email, IAuthenticationAssertion assertion, Guid? tenantId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Initiates an authentication flow for a user identified by email using the provided authentication assertion.
/// </summary>
/// <param name="email">The email address used to identify the user.</param>
/// <param name="assertion">The authentication assertion (for example, a password or external provider assertion) to validate.</param>
/// <param name="tenantId">An optional tenant identifier for multi-tenant scenarios.</param>
/// <param name="cancellationToken">A token that can be used to cancel the asynchronous operation.</param>
/// <returns>
/// An <see cref="AuthenticationResponse"/> describing the outcome of the authentication attempt, which may include
/// the authenticated user, status, claims, and an optional session ticket when additional factors are required.
/// </returns>
Task<AuthenticationResponse> LoginAsync(string email, IAuthenticationAssertion assertion, Guid? tenantId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Continues a multi-factor authentication flow using an existing <see cref="SessionTicket"/> and a follow-up authentication assertion.
/// </summary>
/// <param name="sessionTicket">The session ticket issued by a previous authentication step, typically when MFA is required.</param>
/// <param name="assertion">The follow-up authentication assertion (such as an MFA code or verification) to validate.</param>
/// <param name="cancellationToken">A token that can be used to cancel the asynchronous operation.</param>
/// <returns>
/// An <see cref="AuthenticationResponse"/> describing the outcome of the continued authentication flow, which may include
/// the authenticated user, status, claims, and an updated session ticket.
/// </returns>

Copilot uses AI. Check for mistakes.

var result = await service.LoginAsync(sessionTicket, assertion);
Assert.That(result.Status, Is.EqualTo(AuthenticationStatus.Failed));
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for ArgumentNullException when calling LoginAsync with a null SessionTicket parameter. The LoginAsync method on line 64 of IdentityService.cs validates the sessionTicket parameter with ArgumentNullException.ThrowIfNull, but there is no test verifying this behavior.

Copilot uses AI. Check for mistakes.

var result = await service.LoginAsync(sessionTicket, assertion);
Assert.That(result.Status, Is.EqualTo(AuthenticationStatus.Failed));
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for ArgumentNullException when calling LoginAsync(SessionTicket, IAuthenticationAssertion) with a null assertion parameter. The LoginAsync method on line 63 of IdentityService.cs validates the assertion parameter with ArgumentNullException.ThrowIfNull, but there is no test verifying this behavior for the SessionTicket overload.

Copilot uses AI. Check for mistakes.

mockProvider.Setup(p => p.SupportedType).Returns(ProviderType.OAuth);
Assert.That(mockProvider.Object.GetProviderName(assertion.Object), Is.EqualTo(ProviderType.OAuth.Value));
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the new default interface members IsPrimary, IsSecondary, and BypassesMfa on IAuthenticationProvider. These default implementations should be tested to verify they return the expected values (true, false, and false respectively).

Copilot uses AI. Check for mistakes.
Comment on lines +282 to +286
public void SessionTicketSerializerShouldReturnNullOnInvalidFormat()
{
var result = _ticketSerializer.Deserialize("not-a-valid-ticket");
Assert.That(result, Is.Null);
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate test detected. This test is identical to the test on line 39 of SessionTicketSerializerTests.cs. Tests for SessionTicketSerializer should be consolidated in SessionTicketSerializerTests.cs rather than duplicated in MfaHandshakeTests.cs.

Copilot uses AI. Check for mistakes.
Comment on lines +140 to 146
if (_providers.TryGetValue(assertion.ProviderType, out var currentProvider))
{
if (result.IsCredentialConsumed)
if (currentProvider.BypassesMfa(assertion))
{
// Fail authentication if we cannot guarantee the credential was consumed (prevent replay/race conditions).
return new AuthenticationResponse(false, user);
return false;
}
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants