Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
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
28 changes: 22 additions & 6 deletions src/Api/Dirt/Controllers/ReportsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<ReportsController> _logger;

public ReportsController(
Expand All @@ -35,8 +35,7 @@ public ReportsController(
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery,
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand,
IGetOrganizationReportQuery getOrganizationReportQuery,
IAddOrganizationReportCommand addOrganizationReportCommand,
IGetPasskeyDirectoryQuery getPasskeyDirectoryQuery,
ILogger<ReportsController> logger
)
{
Expand All @@ -46,8 +45,7 @@ ILogger<ReportsController> logger
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
_dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand;
_getOrganizationReportQuery = getOrganizationReportQuery;
_addOrganizationReportCommand = addOrganizationReportCommand;
_getPasskeyDirectoryQuery = getPasskeyDirectoryQuery;
_logger = logger;
}

Expand Down Expand Up @@ -206,4 +204,22 @@ public async Task DropPasswordHealthReportApplication(

await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request);
}

/// <summary>
/// Gets the list of domains that support passkeys from the 2FA Directory
/// </summary>
/// <returns>List of domains with passkey support details</returns>
[HttpGet("passkey-directory")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not critical: instead of placing the endpoint under /reports/, I'm wondering if we want to consolidate endpoints that call third party APIs elsewhere. They could share a controller with a base route like /external or similar.

Alternatively, we could follow what was done for HIBP and just introduce a new controller for 2FA directory API proxy requests, like /2fa-directory/.

It is worth noting that the Inactive 2FA report in the web client has direct calls to the 2FA directory API still, which should also be refactored to make requests through our API like this.

[RequireFeature(FeatureFlagKeys.PasskeyDirectoryReport)]
public async Task<IEnumerable<PasskeyDirectoryResponseModel>> GetPasskeyDirectoryAsync()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❓ QUESTION: No AccessReports authorization check on this endpoint

Details

Every other endpoint in ReportsController gates access with _currentContext.AccessReports(orgId), but GetPasskeyDirectoryAsync only requires basic authentication (class-level [Authorize("Application")]). This means any authenticated Bitwarden user can call this endpoint, not just organization admins with report access.

The returned data originates from a public API, so there may be no confidentiality concern. However, the PR description states this powers a report for "organization administrators," and placing the endpoint in this controller without the same authorization pattern could be an intentional choice or an oversight.

Was the decision to skip AccessReports intentional given the data is publicly available, or should this endpoint require an orgId parameter with the standard permission check to stay consistent with the controller's authorization model?

{
var entries = await _getPasskeyDirectoryQuery.GetPasskeyDirectoryAsync();
return entries.Select(e => new PasskeyDirectoryResponseModel
{
DomainName = e.DomainName,
Passwordless = e.Passwordless,
Mfa = e.Mfa,
Instructions = e.Instructions
});
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ public static class FeatureFlagKeys
public const string Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements";
public const string AccessIntelligenceTrendChart = "pm-26961-access-intelligence-trend-chart";
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";

/* UIF Team */
Expand Down
9 changes: 9 additions & 0 deletions src/Core/Dirt/Reports/Models/Data/PasskeyDirectoryEntry.cs
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<GetPasskeyDirectoryQuery> 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<IEnumerable<PasskeyDirectoryEntry>> GetPasskeyDirectoryAsync()
{
var entries = await cache.GetOrSetAsync(
key: _cacheKey,
factory: async _ => await FetchPasskeyDirectoryAsync(),
options: new FusionCacheEntryOptions(duration: _cacheDuration)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎨 SUGGESTED: Passing a bare FusionCacheEntryOptions discards the configured fail-safe/timeout defaults for this external-API call.

Details and fix

AddReportingServices registers this cache via AddExtendedCache, which configures resilience defaults through WithDefaultEntryOptions β€” notably IsFailSafeEnabled = true, FactorySoftTimeout, and FactoryHardTimeout (see GlobalSettings.ExtendedCacheSettings).

Constructing new FusionCacheEntryOptions(duration: _cacheDuration) replaces those defaults for this call rather than merging, so fail-safe is disabled here. Because the factory (FetchPasskeyDirectoryAsync) calls a third-party API and does EnsureSuccessStatusCode(), a transient outage on a cold/expired cache now surfaces as a 500 to the caller, whereas fail-safe would let a slightly-stale cached copy be served.

The repo's CACHING.md documents the duration-only-override pattern, which starts from a duplicate of the default entry options:

var entries = await cache.GetOrSetAsync(
    key: _cacheKey,
    factory: async _ => await FetchPasskeyDirectoryAsync(),
    options => options.SetDuration(_cacheDuration)
);

This keeps the 24h duration while preserving fail-safe and factory timeouts. Reference: src/Core/Utilities/CACHING.md.

);

return entries;
}

private async Task<List<PasskeyDirectoryEntry>> 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<Dictionary<string, JsonElement>>(stream);

if (directory is null)
{
return [];
}

var entries = new List<PasskeyDirectoryEntry>();

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ο»Ώusing Bit.Core.Dirt.Reports.Models.Data;

namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;

public interface IGetPasskeyDirectoryQuery
{
/// <summary>
/// Passkey directory data from the cache or source.
/// </summary>
/// <returns>
/// 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.
/// </returns>
Task<IEnumerable<PasskeyDirectoryEntry>> GetPasskeyDirectoryAsync();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not critical: Where should this README live/is it appropriate to include here?

Original file line number Diff line number Diff line change
@@ -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<PasskeyDirectoryResponseModel>`
Original file line number Diff line number Diff line change
Expand Up @@ -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<IRiskInsightsReportQuery, RiskInsightsReportQuery>();
services.AddScoped<IMemberAccessReportQuery, MemberAccessReportQuery>();
Expand All @@ -25,6 +27,7 @@ public static void AddReportingServices(this IServiceCollection services, IGloba
services.AddScoped<IGetOrganizationReportSummaryDataByDateRangeQuery, GetOrganizationReportSummaryDataByDateRangeQuery>();
services.AddScoped<IGetOrganizationReportApplicationDataQuery, GetOrganizationReportApplicationDataQuery>();
services.AddScoped<IUpdateOrganizationReportApplicationDataCommand, UpdateOrganizationReportApplicationDataCommand>();
services.AddScoped<IGetPasskeyDirectoryQuery, GetPasskeyDirectoryQuery>();

// v2 file storage commands
services.AddScoped<ICreateOrganizationReportCommand, CreateOrganizationReportCommand>();
Expand Down
28 changes: 28 additions & 0 deletions test/Api.Test/Dirt/ReportsControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ReportsController> sutProvider)
{
// Arrange
var entries = new List<PasskeyDirectoryEntry>
{
new() { DomainName = "example.com", Passwordless = true, Mfa = false, Instructions = "https://example.com/help" },
new() { DomainName = "test.com", Passwordless = false, Mfa = true, Instructions = "" }
};
sutProvider.GetDependency<IGetPasskeyDirectoryQuery>()
.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);
}
}
Loading
Loading