diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs index 3e9f2f0e0d6a..ff58f7042696 100644 --- a/src/Api/Dirt/Controllers/ReportsController.cs +++ b/src/Api/Dirt/Controllers/ReportsController.cs @@ -9,6 +9,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,8 +25,7 @@ public class ReportsController : Controller private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand; private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand; - private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; - private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; + private readonly IGetPasskeyDirectoryQuery _getPasskeyDirectoryQuery; private readonly ILogger _logger; public ReportsController( @@ -35,8 +35,7 @@ public ReportsController( IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand, IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery, IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand, - IGetOrganizationReportQuery getOrganizationReportQuery, - IAddOrganizationReportCommand addOrganizationReportCommand, + IGetPasskeyDirectoryQuery getPasskeyDirectoryQuery, ILogger logger ) { @@ -46,8 +45,7 @@ ILogger logger _addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand; _getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery; _dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand; - _getOrganizationReportQuery = getOrganizationReportQuery; - _addOrganizationReportCommand = addOrganizationReportCommand; + _getPasskeyDirectoryQuery = getPasskeyDirectoryQuery; _logger = logger; } @@ -206,4 +204,22 @@ public async Task DropPasswordHealthReportApplication( await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request); } + + /// + /// Gets the list of domains that support passkeys from the 2FA Directory + /// + /// List of domains with passkey support details + [HttpGet("passkey-directory")] + [RequireFeature(FeatureFlagKeys.PasskeyDirectoryReport)] + public async Task> GetPasskeyDirectoryAsync() + { + var entries = await _getPasskeyDirectoryQuery.GetPasskeyDirectoryAsync(); + return entries.Select(e => new PasskeyDirectoryResponseModel + { + DomainName = e.DomainName, + Passwordless = e.Passwordless, + Mfa = e.Mfa, + Instructions = e.Instructions + }); + } } diff --git a/src/Api/Dirt/Models/Response/PasskeyDirectoryResponseModel.cs b/src/Api/Dirt/Models/Response/PasskeyDirectoryResponseModel.cs new file mode 100644 index 000000000000..ce881ef94ce6 --- /dev/null +++ b/src/Api/Dirt/Models/Response/PasskeyDirectoryResponseModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Api.Dirt.Models.Response; + +public class PasskeyDirectoryResponseModel +{ + public string DomainName { get; set; } = string.Empty; + public bool Passwordless { get; set; } + public bool Mfa { get; set; } + public string Instructions { get; set; } = string.Empty; +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 691f68e072a1..aec55109545b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -297,6 +297,7 @@ public static class FeatureFlagKeys public const string EventManagementForSplunk = "event-management-for-splunk"; public const string Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements"; public const string AccessIntelligenceNewArchitecture = "pm-31936-access-intelligence-new-architecture"; + public const string PasskeyDirectoryReport = "inno-passkey-directory-report"; public const string AccessIntelligenceAdoptionUxImprovements = "pm-34723-access-intelligence-adoption-ux-improvements"; public const string EventManagementForGenericHec = "event-management-for-generic-hec"; diff --git a/src/Core/Dirt/Reports/Models/Data/PasskeyDirectoryEntry.cs b/src/Core/Dirt/Reports/Models/Data/PasskeyDirectoryEntry.cs new file mode 100644 index 000000000000..7ab8401441f8 --- /dev/null +++ b/src/Core/Dirt/Reports/Models/Data/PasskeyDirectoryEntry.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.Dirt.Reports.Models.Data; + +public class PasskeyDirectoryEntry +{ + public string DomainName { get; set; } = string.Empty; + public bool Passwordless { get; set; } + public bool Mfa { get; set; } + public string Instructions { get; set; } = string.Empty; +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetPasskeyDirectoryQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetPasskeyDirectoryQuery.cs new file mode 100644 index 000000000000..120bd87db29d --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetPasskeyDirectoryQuery.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetPasskeyDirectoryQuery( + IHttpClientFactory httpClientFactory, + [FromKeyedServices(GetPasskeyDirectoryQuery.CacheName)] + IFusionCache cache, + ILogger logger) + : IGetPasskeyDirectoryQuery +{ + public const string HttpClientName = "PasskeyDirectoryHttpClient"; + public const string CacheName = "PasskeyDirectory"; + + private static readonly TimeSpan _cacheDuration = TimeSpan.FromDays(1); + private const string _cacheKey = "passkey-directory"; + private const string _passkeyDirectoryUrl = "https://passkeys-api.2fa.directory/v1/all.json"; + + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public async Task> GetPasskeyDirectoryAsync() + { + var entries = await cache.GetOrSetAsync( + key: _cacheKey, + factory: async _ => await FetchPasskeyDirectoryAsync(), + options: new FusionCacheEntryOptions(duration: _cacheDuration) + ); + + return entries; + } + + private async Task> FetchPasskeyDirectoryAsync() + { + logger.LogInformation(Constants.BypassFiltersEventId, + "Fetching passkey directory from external API"); + + var response = await _httpClient.GetAsync(_passkeyDirectoryUrl); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(); + var directory = await JsonSerializer.DeserializeAsync>(stream); + + if (directory is null) + { + return []; + } + + var entries = new List(); + + foreach (var (domain, serviceData) in directory) + { + var hasPasswordless = serviceData.TryGetProperty("passwordless", out var passwordlessElement) + && passwordlessElement.ValueKind == JsonValueKind.String; + var hasMfa = serviceData.TryGetProperty("mfa", out var mfaElement) + && mfaElement.ValueKind == JsonValueKind.String; + + if (!hasPasswordless && !hasMfa) + { + continue; + } + + var instructions = serviceData.TryGetProperty("documentation", out var docElement) + && docElement.ValueKind == JsonValueKind.String + ? docElement.GetString() ?? string.Empty + : string.Empty; + + entries.Add(new PasskeyDirectoryEntry + { + DomainName = domain, + Passwordless = hasPasswordless, + Mfa = hasMfa, + Instructions = instructions + }); + } + + logger.LogInformation(Constants.BypassFiltersEventId, + "Fetched {Count} passkey directory entries from external API", entries.Count); + + return entries; + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasskeyDirectoryQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasskeyDirectoryQuery.cs new file mode 100644 index 000000000000..c4cd6f9d82b0 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasskeyDirectoryQuery.cs @@ -0,0 +1,18 @@ +using Bit.Core.Dirt.Reports.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetPasskeyDirectoryQuery +{ + /// + /// Passkey directory data from the cache or source. + /// + /// + /// Enumerable response with entries each representing + /// a passkey directory entry with domain name, passwordless and MFA support, and + /// associated instructions. These are domains that may potentially support passkeys for either + /// Login or Mutli-Factor Authentication. These are matched up with ciphers client-side with link to documentation + /// to use passkeys. + /// + Task> GetPasskeyDirectoryAsync(); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/PasskeyDirectoryReport/README.md b/src/Core/Dirt/Reports/ReportFeatures/PasskeyDirectoryReport/README.md new file mode 100644 index 000000000000..86a0e0dec447 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/PasskeyDirectoryReport/README.md @@ -0,0 +1,62 @@ +# Passkey Directory Report + +## Overview + +The Passkey Directory Report provides a list of domains that support passkeys, sourced from the [2FA Directory API](https://2fa.directory). This data powers a report in the Bitwarden client that helps organization administrators understand which of their members' credentials could be upgraded to passkeys. + +For client-side implementation details, see the [clients README](https://github.com/bitwarden/clients/blob/d866c8126444bf95f2be2ee5f59646aa1237e8a7/apps/web/src/app/dirt/reports/pages/README.md). + +## Feature Flag + +This feature is gated behind the `PasskeyDirectoryReport` feature flag. + +## Architecture + +### Data Flow + +``` +2FA Directory API --> GetPasskeyDirectoryQuery (cached 24h) --> ReportsController --> Client +``` + +1. **External source**: The [2FA Directory v1 API](https://passkeys-api.2fa.directory/v1/all.json) provides a JSON dictionary of domains and their passkey/MFA support. +2. **Query layer** (`GetPasskeyDirectoryQuery`): Fetches and parses the external data, caching results for 24 hours via FusionCache. +3. **API endpoint** (`ReportsController`): Exposes `GET /reports/passkey-directory` which returns the cached directory entries. + +### Key Files + +| File | Purpose | +|------|---------| +| `GetPasskeyDirectoryQuery.cs` | Core query — fetches, parses, and caches the 2FA Directory data | +| `Interfaces/IGetPasskeyDirectoryQuery.cs` | Query interface | +| `ReportingServiceCollectionExtensions.cs` | DI registration for the query, HTTP client, and cache | +| `../../Models/Data/PasskeyDirectoryEntry.cs` | Domain model for a directory entry | +| `src/Api/Dirt/Controllers/ReportsController.cs` | API controller exposing the endpoint | +| `src/Api/Dirt/Models/Response/PasskeyDirectoryResponseModel.cs` | API response model | + +### Caching + +- **Provider**: FusionCache (keyed service `"PasskeyDirectory"`) +- **Duration**: 24 hours +- **Key**: `"passkey-directory"` +- Cache is registered in `ReportingServiceCollectionExtensions.AddReportingServices()`. + +### Response Shape + +Each entry in the response array contains: + +| Field | Type | Description | +|-------|------|-------------| +| `domainName` | `string` | The domain (e.g. `github.com`) | +| `passwordless` | `bool` | Whether the domain supports passwordless passkey login | +| `mfa` | `bool` | Whether the domain supports passkeys as an MFA method | +| `instructions` | `string` | URL to setup documentation (empty if unavailable) | + +### API Endpoint + +``` +GET /reports/passkey-directory +``` + +- **Auth**: Standard Bitwarden authentication +- **Feature flag**: `PasskeyDirectoryReport` +- **Response**: `IEnumerable` diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index c34b622710b6..6aca3160daca 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -11,6 +11,8 @@ public static class ReportingServiceCollectionExtensions public static void AddReportingServices(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddExtendedCache(OrganizationReportCacheConstants.CacheName, (GlobalSettings)globalSettings); + services.AddExtendedCache(GetPasskeyDirectoryQuery.CacheName, (GlobalSettings)globalSettings); + services.AddHttpClient(GetPasskeyDirectoryQuery.HttpClientName); services.AddScoped(); services.AddScoped(); @@ -25,6 +27,7 @@ public static void AddReportingServices(this IServiceCollection services, IGloba services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // v2 file storage commands services.AddScoped(); diff --git a/test/Api.Test/Dirt/ReportsControllerTests.cs b/test/Api.Test/Dirt/ReportsControllerTests.cs index 37a6cb79c399..be687a61dbd7 100644 --- a/test/Api.Test/Dirt/ReportsControllerTests.cs +++ b/test/Api.Test/Dirt/ReportsControllerTests.cs @@ -2,6 +2,7 @@ using Bit.Api.Dirt.Controllers; using Bit.Api.Dirt.Models; using Bit.Core.Context; +using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; @@ -142,4 +143,31 @@ public async Task DropPasswordHealthReportApplicationAsync_withAccess_success(Su _.OrganizationId == request.OrganizationId && _.PasswordHealthReportApplicationIds == request.PasswordHealthReportApplicationIds)); } + + [Theory, BitAutoData] + public async Task GetPasskeyDirectory_ReturnsExpectedEntries(SutProvider sutProvider) + { + // Arrange + var entries = new List + { + new() { DomainName = "example.com", Passwordless = true, Mfa = false, Instructions = "https://example.com/help" }, + new() { DomainName = "test.com", Passwordless = false, Mfa = true, Instructions = "" } + }; + sutProvider.GetDependency() + .GetPasskeyDirectoryAsync() + .Returns(entries); + + // Act + var result = (await sutProvider.Sut.GetPasskeyDirectoryAsync()).ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("example.com", result[0].DomainName); + Assert.True(result[0].Passwordless); + Assert.False(result[0].Mfa); + Assert.Equal("https://example.com/help", result[0].Instructions); + Assert.Equal("test.com", result[1].DomainName); + Assert.False(result[1].Passwordless); + Assert.True(result[1].Mfa); + } } diff --git a/test/Core.Test/Dirt/ReportFeatures/GetPasskeyDirectoryQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetPasskeyDirectoryQueryTests.cs new file mode 100644 index 000000000000..0453102a61d6 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetPasskeyDirectoryQueryTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.MockedHttpClient; +using NSubstitute; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetPasskeyDirectoryQueryTests +{ + private readonly MockedHttpMessageHandler _handler; + private readonly HttpClient _httpClient; + + public GetPasskeyDirectoryQueryTests() + { + _handler = new MockedHttpMessageHandler(); + _httpClient = _handler.ToHttpClient(); + } + + private SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(GetPasskeyDirectoryQuery.HttpClientName).Returns(_httpClient); + + var cache = Substitute.For(); + cache.GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>() + ).Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null!, CancellationToken.None)); + }); + + return new SutProvider() + .SetDependency(clientFactory) + .SetDependency(cache) + .Create(); + } + + [Fact] + public async Task GetPasskeyDirectoryAsync_ReturnsFilteredEntries() + { + var json = """ + { + "example.com": { + "passwordless": "allowed", + "mfa": "allowed", + "documentation": "https://example.com/help" + }, + "nopasskey.com": { + "contact": { "twitter": "nopasskey" } + }, + "mfaonly.com": { + "mfa": "required" + } + } + """; + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(json, System.Text.Encoding.UTF8, "application/json")); + + var sutProvider = GetSutProvider(); + var result = (await sutProvider.Sut.GetPasskeyDirectoryAsync()).ToList(); + + Assert.Equal(2, result.Count); + + var example = result.First(e => e.DomainName == "example.com"); + Assert.True(example.Passwordless); + Assert.True(example.Mfa); + Assert.Equal("https://example.com/help", example.Instructions); + + var mfaOnly = result.First(e => e.DomainName == "mfaonly.com"); + Assert.False(mfaOnly.Passwordless); + Assert.True(mfaOnly.Mfa); + Assert.Equal(string.Empty, mfaOnly.Instructions); + } + + [Fact] + public async Task GetPasskeyDirectoryAsync_EmptyResponse_ReturnsEmpty() + { + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("{}", System.Text.Encoding.UTF8, "application/json")); + + var sutProvider = GetSutProvider(); + var result = (await sutProvider.Sut.GetPasskeyDirectoryAsync()).ToList(); + + Assert.Empty(result); + } + + [Fact] + public async Task GetPasskeyDirectoryAsync_PasswordlessOnly_ReturnsEntry() + { + var json = """ + { + "passonly.com": { + "passwordless": "required", + "documentation": "https://passonly.com/setup" + } + } + """; + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(json, System.Text.Encoding.UTF8, "application/json")); + + var sutProvider = GetSutProvider(); + var result = (await sutProvider.Sut.GetPasskeyDirectoryAsync()).ToList(); + + Assert.Single(result); + Assert.Equal("passonly.com", result[0].DomainName); + Assert.True(result[0].Passwordless); + Assert.False(result[0].Mfa); + Assert.Equal("https://passonly.com/setup", result[0].Instructions); + } + + [Fact] + public async Task GetPasskeyDirectoryAsync_NoDocumentation_ReturnsEmptyInstructions() + { + var json = """ + { + "nodocs.com": { + "passwordless": "allowed" + } + } + """; + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(json, System.Text.Encoding.UTF8, "application/json")); + + var sutProvider = GetSutProvider(); + var result = (await sutProvider.Sut.GetPasskeyDirectoryAsync()).ToList(); + + Assert.Single(result); + Assert.Equal(string.Empty, result[0].Instructions); + } +}