diff --git a/Demo/Program.cs b/Demo/Program.cs index 0dfe698a..b83e24bf 100644 --- a/Demo/Program.cs +++ b/Demo/Program.cs @@ -39,13 +39,8 @@ options.BackupEligibleCredentialPolicy = builder.Configuration.GetValue("fido2:backupEligibleCredentialPolicy"); options.BackedUpCredentialPolicy = builder.Configuration.GetValue("fido2:backedUpCredentialPolicy"); }) -.AddCachedMetadataService(config => -{ - config.AddFidoMetadataRepository(httpClientBuilder => - { - //TODO: any specific config you want for accessing the MDS - }); -}); +.AddFidoMetadataRepository() +.AddCachedMetadataService(); var app = builder.Build(); diff --git a/Documentation/metadata-service-guide.md b/Documentation/metadata-service-guide.md new file mode 100644 index 00000000..087a049e --- /dev/null +++ b/Documentation/metadata-service-guide.md @@ -0,0 +1,224 @@ +# FIDO2 Metadata Service (MDS) Developer Guide + +This guide explains how the FIDO2 Metadata Service (MDS) components work together and how to implement and register custom metadata services and repositories in the FIDO2 .NET Library. + +## Architecture Overview + +The MDS system follows a clean separation of concerns with two main layers: + +``` +IMetadataService (Caching/Access Layer) + ↓ +IMetadataRepository (Data Source Layer) +``` + +### Key Concepts + +- **`IMetadataRepository`** - Handles the complexity of fetching, validating, and parsing metadata from various sources (FIDO Alliance, local files, conformance endpoints) +- **`IMetadataService`** - Provides a simple caching wrapper to allow sourcing attestation data from multiple repositories and support multi-level caching strategies +- **Registration API** - Fluent builder pattern for easy configuration and dependency injection + +## Core Interfaces + +### IMetadataService + +The service layer provides a simple API for retrieving metadata entries: + +```csharp +public interface IMetadataService +{ + /// + /// Gets the metadata payload entry by AAGUID asynchronously. + /// + Task GetEntryAsync(Guid aaguid, CancellationToken cancellationToken = default); + + /// + /// Gets a value indicating whether conformance testing mode is active. This should return false in production. + /// + bool ConformanceTesting(); +} +``` + +### IMetadataRepository + +The repository layer handles the heavy lifting of metadata retrieval and validation: + +```csharp +public interface IMetadataRepository +{ + /// + /// Downloads and validates the metadata BLOB from the source. + /// + Task GetBLOBAsync(CancellationToken cancellationToken = default); + + /// + /// Gets a specific metadata statement from the BLOB. + /// + Task GetMetadataStatementAsync(MetadataBLOBPayload blob, MetadataBLOBPayloadEntry entry, CancellationToken cancellationToken = default); +} +``` + +## Built-in Implementations + +### Repositories + +| Repository | Purpose | Features | +| ---------------------------------- | --------------------------- | ------------------------------------------------ | +| **Fido2MetadataServiceRepository** | Official FIDO Alliance MDS3 | JWT validation, certificate chains, CRL checking | +| **FileSystemMetadataRepository** | Local file storage | Fast local access, offline/development/testing | +| **ConformanceMetadataRepository** | FIDO conformance testing | Multiple test endpoints, fake certificates | + +### Services + +| Service | Purpose | Features | +| ----------------------------------- | -------------------------- | ------------------------------------------------------ | +| **DistributedCacheMetadataService** | Production caching service | multi-tier caching (Memory → Distributed → Repository) | + +## Quick Start + +### Basic Setup with Official MDS + +```csharp +services + .AddFido2(config => { + config.ServerName = "My FIDO2 Server"; + config.ServerDomain = "example.com"; + config.Origins = new HashSet { "https://example.com/" }; + }) + .AddFidoMetadataRepository() // Official FIDO Alliance MDS + .AddCachedMetadataService(); // 2-tier caching +``` + +### Multiple Repositories + +```csharp +services + .AddFido2(config => { /* ... */ }) + .AddFidoMetadataRepository() // Official MDS (primary) + .AddFileSystemMetadataRepository("/mds/path") // Local files (fallback) + .AddCachedMetadataService(); // Caching wrapper +``` + +### Custom HTTP Client Configuration + +```csharp +services + .AddFido2(config => { /* ... */ }) + .AddFidoMetadataRepository(httpBuilder => { + httpBuilder.ConfigureHttpClient(client => { + client.Timeout = TimeSpan.FromSeconds(30); + }); + httpBuilder.AddRetryPolicy(); + }) + .AddCachedMetadataService(); +``` + +## Custom Implementation Guide + +### Creating a Custom Repository + +Implement `IMetadataRepository` to create your own metadata source: + +```csharp +public class DatabaseMetadataRepository : IMetadataRepository +{ + private readonly IDbContext _context; + private readonly ILogger _logger; + + public DatabaseMetadataRepository(IDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task GetBLOBAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Loading metadata BLOB from database"); + // TODO: Implement + } + + public Task GetMetadataStatementAsync( + MetadataBLOBPayload blob, + MetadataBLOBPayloadEntry entry, + CancellationToken cancellationToken = default) + { + // Statement is already loaded in the entry from GetBLOBAsync + return Task.FromResult(entry.MetadataStatement); + } +} +``` + +### Creating a Custom Service + +Implement `IMetadataService` for custom caching strategies: + +```csharp +public class SimpleMetadataService : IMetadataService +{ + private readonly IEnumerable _repositories; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _cache = new(); + private DateTime _lastRefresh = DateTime.MinValue; + private readonly TimeSpan _refreshInterval = TimeSpan.FromHours(6); + + public SimpleMetadataService( + IEnumerable repositories, + ILogger logger) + { + _repositories = repositories; + _logger = logger; + } + + public async Task GetEntryAsync(Guid aaguid, CancellationToken cancellationToken = default) + { + await RefreshIfNeededAsync(cancellationToken); + return _cache.TryGetValue(aaguid, out var entry) ? entry : null; + } + + public bool ConformanceTesting() => false; + + private async Task RefreshIfNeededAsync(CancellationToken cancellationToken) + { + foreach (var repository in _repositories) + { + try + { + var blob = await repository.GetBLOBAsync(cancellationToken); + foreach (var entry in blob.Entries) + { + // Cache it + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to refresh from repository {Repository}", repository.GetType().Name); + } + } + } +} +``` + +### Registration + +Register your custom implementations: + +```csharp +// Register custom service + repository +services + .AddFido2(config => { /* ... */ }) + .AddMetadataRepository() // Custom repository + .AddMetadataService(); // Custom service + +// Register custom service +services + .AddFido2(config => { /* ... */ }) + .AddFidoMetadataRepository() // FIDO Alliance repository + .AddMetadataService(); // Custom service + + +// Or use with built-in caching service +services + .AddFido2(config => { /* ... */ }) + .AddMetadataRepository() // Custom repository + .AddCachedMetadataService(); // Built-in caching +``` diff --git a/Src/Fido2.AspNet/Fido2NetLibBuilderExtensions.cs b/Src/Fido2.AspNet/Fido2NetLibBuilderExtensions.cs index 6107069c..9af6729e 100644 --- a/Src/Fido2.AspNet/Fido2NetLibBuilderExtensions.cs +++ b/Src/Fido2.AspNet/Fido2NetLibBuilderExtensions.cs @@ -1,4 +1,6 @@ -using Fido2NetLib; +using System.ComponentModel; + +using Fido2NetLib; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -7,8 +9,27 @@ namespace Microsoft.Extensions.DependencyInjection; +/// +/// Extension methods for configuring FIDO2 services in an . +/// public static class Fido2NetLibBuilderExtensions { + /// + /// Adds FIDO2 services to the specified service collection using configuration from an IConfiguration instance. + /// + /// The service collection to add FIDO2 services to. + /// The configuration containing FIDO2 settings. + /// An for configuring additional FIDO2 services. + /// + /// This method registers the core FIDO2 services: + /// + /// as a scoped service + /// as a singleton from configuration + /// as a singleton (if not already registered) + /// + /// No metadata service is registered by default. Use the returned builder to add metadata services. + /// + /// Thrown when or is null. public static IFido2NetLibBuilder AddFido2(this IServiceCollection services, IConfiguration configuration) { services.Configure(configuration); @@ -16,18 +37,28 @@ public static IFido2NetLibBuilder AddFido2(this IServiceCollection services, ICo services.AddSingleton( resolver => resolver.GetRequiredService>().Value); - services.AddServices(); - - return new Fido2NetLibBuilder(services); - } - - private static void AddServices(this IServiceCollection services) - { services.AddScoped(); - services.AddSingleton(); //Default implementation if we choose not to enable MDS services.TryAddSingleton(); + + return new Fido2NetLibBuilder(services); } + /// + /// Adds FIDO2 services to the specified service collection using a configuration action. + /// + /// The service collection to add FIDO2 services to. + /// An action to configure the FIDO2 configuration options. + /// An for configuring additional FIDO2 services. + /// + /// This method registers the core FIDO2 services: + /// + /// as a scoped service + /// as a singleton from the setup action + /// as a singleton (if not already registered) + /// + /// No metadata service is registered by default. Use the returned builder to add metadata services. + /// + /// Thrown when or is null. public static IFido2NetLibBuilder AddFido2(this IServiceCollection services, Action setupAction) { services.Configure(setupAction); @@ -35,19 +66,82 @@ public static IFido2NetLibBuilder AddFido2(this IServiceCollection services, Act services.AddSingleton( resolver => resolver.GetRequiredService>().Value); - services.AddServices(); + services.AddScoped(); + services.TryAddSingleton(); return new Fido2NetLibBuilder(services); } - - public static void AddCachedMetadataService(this IFido2NetLibBuilder builder, Action configAction) + + /// + /// Adds a custom metadata service implementation to the FIDO2 builder. + /// + /// The type of metadata service to add. Must implement . + /// The FIDO2 builder instance. + /// The for method chaining. + /// + /// This method registers the specified metadata service implementation as a scoped service, + /// replacing any previously registered . + /// + /// Thrown when is null. + public static IFido2NetLibBuilder AddMetadataService(this IFido2NetLibBuilder builder) + where T : class, IMetadataService + { + builder.Services.AddScoped(); + return builder; + } + + /// + /// Adds the distributed cache-based metadata service to the FIDO2 builder. + /// + /// The FIDO2 builder instance. + /// The for method chaining. + /// + /// This method registers the as a scoped service. + /// This service provides caching capabilities for metadata using both memory and distributed cache. + /// Ensure that memory cache and distributed cache services are registered before calling this method. + /// Use the returned builder to add metadata repositories. + /// + /// Thrown when is null. + public static IFido2NetLibBuilder AddCachedMetadataService(this IFido2NetLibBuilder builder) { builder.Services.AddScoped(); - - configAction(new Fido2NetLibBuilder(builder.Services)); + return builder; } - - public static IFido2MetadataServiceBuilder AddFileSystemMetadataRepository(this IFido2MetadataServiceBuilder builder, string directoryPath) + + /// + /// Adds a custom metadata repository implementation to the FIDO2 builder. + /// + /// The type of metadata repository to add. Must implement . + /// The FIDO2 builder instance. + /// The for method chaining. + /// + /// This method registers the specified metadata repository implementation as a scoped service, + /// replacing any previously registered . + /// The repository provides metadata statements for authenticator validation and attestation verification. + /// + /// Thrown when is null. + public static IFido2NetLibBuilder AddMetadataRepository(this IFido2NetLibBuilder builder) + where T : class, IMetadataRepository + { + builder.Services.AddScoped(); + return builder; + } + + /// + /// Adds a file system-based metadata repository to the FIDO2 builder. + /// + /// The FIDO2 builder instance. + /// The directory path containing metadata statement JSON files. + /// The for method chaining. + /// + /// This method registers a as a scoped service. + /// The repository loads metadata statements from JSON files in the specified directory. + /// Each file should contain a valid metadata statement JSON document. + /// This is typically used for development, testing, or offline scenarios. + /// + /// Thrown when or is null. + /// Thrown when the specified directory does not exist at runtime. + public static IFido2NetLibBuilder AddFileSystemMetadataRepository(this IFido2NetLibBuilder builder, string directoryPath) { builder.Services.AddScoped(provider => { @@ -57,8 +151,23 @@ public static IFido2MetadataServiceBuilder AddFileSystemMetadataRepository(this return builder; } - public static IFido2MetadataServiceBuilder AddConformanceMetadataRepository( - this IFido2MetadataServiceBuilder builder, + /// + /// DO NOT USE IN PRODUCTION: Adds a conformance metadata repository to the FIDO2 builder for FIDO Alliance conformance testing. + /// + /// The FIDO2 builder instance. + /// Optional HTTP client to use for requests. If null, a default client will be created. + /// The origin URL for conformance testing requests. + /// The for method chaining. + /// + /// This method registers a as a scoped service. + /// This repository is specifically designed for FIDO Alliance conformance testing and fetches + /// metadata from the conformance testing endpoints. It combines multiple metadata sources + /// into a single BLOB for comprehensive testing scenarios. + /// + /// Thrown when is null. + [EditorBrowsable(EditorBrowsableState.Never)] + public static IFido2NetLibBuilder AddConformanceMetadataRepository( + this IFido2NetLibBuilder builder, HttpClient client = null, string origin = "") { @@ -69,8 +178,25 @@ public static IFido2MetadataServiceBuilder AddConformanceMetadataRepository( return builder; } - - public static IFido2MetadataServiceBuilder AddFidoMetadataRepository(this IFido2MetadataServiceBuilder builder, Action clientBuilder = null) + + /// + /// Adds the official FIDO Alliance Metadata Service (MDS) repository to the FIDO2 builder. + /// + /// The FIDO2 builder instance. + /// Optional action to configure the HTTP client used for MDS requests. + /// The for method chaining. + /// + /// This method registers a as a scoped service + /// and configures an HTTP client specifically for communicating with the FIDO Alliance MDS v3 + /// endpoint at https://mds3.fidoalliance.org/. The repository fetches and validates + /// JWT-signed metadata BLOBs containing authenticator metadata and certification status. + /// + /// The HTTP client is registered with a specific name and can be further configured + /// using the optional action (e.g., for adding authentication, + /// custom headers, or timeout settings). + /// + /// Thrown when is null. + public static IFido2NetLibBuilder AddFidoMetadataRepository(this IFido2NetLibBuilder builder, Action clientBuilder = null) { var httpClientBuilder = builder.Services.AddHttpClient(nameof(Fido2MetadataServiceRepository), client => { @@ -86,17 +212,18 @@ public static IFido2MetadataServiceBuilder AddFidoMetadataRepository(this IFido2 } } +/// +/// Provides a builder interface for configuring FIDO2 services. +/// public interface IFido2NetLibBuilder { IServiceCollection Services { get; } } -public interface IFido2MetadataServiceBuilder -{ - IServiceCollection Services { get; } -} - -public class Fido2NetLibBuilder : IFido2NetLibBuilder, IFido2MetadataServiceBuilder +/// +/// Default implementation of for configuring FIDO2 services. +/// +public class Fido2NetLibBuilder : IFido2NetLibBuilder { /// /// Initializes a new instance of the class. diff --git a/Tests/Fido2.AspNet.Tests/AddFido2ExtensionTests.cs b/Tests/Fido2.AspNet.Tests/AddFido2ExtensionTests.cs index 7cab3346..4b4cddd2 100644 --- a/Tests/Fido2.AspNet.Tests/AddFido2ExtensionTests.cs +++ b/Tests/Fido2.AspNet.Tests/AddFido2ExtensionTests.cs @@ -95,4 +95,142 @@ public void AddFido2_WithSetupAction_RegistersServices() // var mds = serviceProvider.GetService(); // Assert.Null(mds); } + + [Fact] + public void AddMetadataService_RegistersCustomMetadataService() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddFido2(config => { }); + + // Act + builder.AddMetadataService(); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var metadataService = serviceProvider.GetService(); + Assert.NotNull(metadataService); + Assert.IsType(metadataService); + } + + [Fact] + public void AddCachedMetadataService_RegistersCachedService() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMemoryCache(); + services.AddSingleton(); + var builder = services.AddFido2(config => { }); + + // Act + builder.AddCachedMetadataService(); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var metadataService = serviceProvider.GetService(); + Assert.NotNull(metadataService); + Assert.IsType(metadataService); + } + + [Fact] + public void AddMetadataRepository_RegistersCustomMetadataRepository() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddFido2(config => { }); + + // Act + builder.AddMetadataRepository(); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var metadataRepository = serviceProvider.GetService(); + Assert.NotNull(metadataRepository); + Assert.IsType(metadataRepository); + } + + [Fact] + public void AddFileSystemMetadataRepository_RegistersFileSystemRepository() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddFido2(config => { }); + var testPath = "/tmp/test"; + + // Act + builder.AddFileSystemMetadataRepository(testPath); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var metadataRepository = serviceProvider.GetService(); + Assert.NotNull(metadataRepository); + Assert.IsType(metadataRepository); + } + + [Fact] + public void AddConformanceMetadataRepository_RegistersConformanceRepository() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddFido2(config => { }); + + // Act + builder.AddConformanceMetadataRepository(); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var metadataRepository = serviceProvider.GetService(); + Assert.NotNull(metadataRepository); + Assert.IsType(metadataRepository); + } + + [Fact] + public void AddFidoMetadataRepository_RegistersFidoRepository() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddFido2(config => { }); + + // Act + builder.AddFidoMetadataRepository(); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var metadataRepository = serviceProvider.GetService(); + Assert.NotNull(metadataRepository); + Assert.IsType(metadataRepository); + } + + [Fact] + public void Fido2NetLibBuilder_Constructor_ThrowsWhenServicesNull() + { + // Act & Assert + Assert.Throws(() => new Fido2NetLibBuilder(null)); + } + + [Fact] + public void Fido2NetLibBuilder_ServicesProperty_ReturnsServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var builder = new Fido2NetLibBuilder(services); + + // Assert + Assert.Same(services, builder.Services); + } +} + +public class TestMetadataService : IMetadataService +{ + public bool ConformanceTesting() => false; + public Task GetEntryAsync(Guid aaguid, CancellationToken cancellationToken = default) => Task.FromResult(null); +} + +public class TestMetadataRepository : IMetadataRepository +{ + public Task GetBLOBAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task GetMetadataStatementAsync(MetadataBLOBPayload blob, MetadataBLOBPayloadEntry entry, CancellationToken cancellationToken = default) => Task.FromResult(null); }