diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/Login.razor b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/Login.razor
index 55f5d78..048d5ea 100644
--- a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/Login.razor
+++ b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/Login.razor
@@ -6,6 +6,7 @@
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime
+@inject IIdentityFeatureProvider Features
@@ -28,18 +29,24 @@
ButtonStyle="ButtonStyle.Primary"
form="login-form" />
-
- Log in with a passkey
-
+ @if (_flags.Passkey)
+ {
+
+ Log in with a passkey
+
+ }
- { ["ReturnUrl"] = ReturnUrl }))" Text="Register as a new user" />
+ @if (_flags.UserRegistration)
+ {
+ { ["ReturnUrl"] = ReturnUrl }))" Text="Register as a new user" />
+ }
@@ -47,6 +54,7 @@
private string? _error;
private DotNetObjectReference? _dotNetRef;
private IJSObjectReference? _jsModule;
+ private IdentityFeatureFlags _flags = new();
[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }
@@ -56,9 +64,12 @@
? $"/Account/PasskeyAssertion?returnUrl={Uri.EscapeDataString(ReturnUrl)}"
: "/Account/PasskeyAssertion";
+ protected override async Task OnInitializedAsync() => _flags = await Features.GetAsync();
+
protected override async Task OnAfterRenderAsync(bool firstRender)
{
- if (!firstRender) return;
+ // Don't start passkey conditional mediation when passkeys are disabled (#66).
+ if (!firstRender || !_flags.Passkey) return;
_jsModule = await JsRuntime.InvokeAsync(
"import", "./_content/AndreGoepel.Marten.Identity.Blazor/Components/Account/Shared/PasskeySubmit.razor.js");
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IIdentityFeatureProvider.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IIdentityFeatureProvider.cs
new file mode 100644
index 0000000..de0468e
--- /dev/null
+++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IIdentityFeatureProvider.cs
@@ -0,0 +1,13 @@
+namespace AndreGoepel.Marten.Identity.Blazor;
+
+///
+/// Source of truth for identity feature availability (#66). The library ships an
+/// options-backed default (); a host that
+/// persists the flags (e.g. an admin-editable store) registers its own implementation
+/// whose value takes precedence over configuration. The method is asynchronous because a
+/// host implementation typically reads a database.
+///
+public interface IIdentityFeatureProvider
+{
+ ValueTask GetAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeature.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeature.cs
new file mode 100644
index 0000000..1cfed01
--- /dev/null
+++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeature.cs
@@ -0,0 +1,18 @@
+namespace AndreGoepel.Marten.Identity.Blazor;
+
+///
+/// Identity UI features whose availability a host can toggle at configuration or runtime
+/// (#66). Disabling a feature hides its UI and makes its setup pages/endpoints unreachable.
+///
+public enum IdentityFeature
+{
+ /// Self-service account registration and its confirmation/resend pages.
+ UserRegistration,
+
+ /// Two-factor authenticator setup and management (the login-time challenge for
+ /// users who already enrolled is intentionally kept reachable).
+ TwoFactor,
+
+ /// WebAuthn passkey management and login/creation endpoints.
+ Passkey,
+}
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureFlags.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureFlags.cs
new file mode 100644
index 0000000..27df644
--- /dev/null
+++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureFlags.cs
@@ -0,0 +1,21 @@
+namespace AndreGoepel.Marten.Identity.Blazor;
+
+///
+/// A snapshot of which identity features are enabled (#66). Every flag defaults to
+/// true, so a host that configures nothing keeps the full feature set.
+///
+public sealed record IdentityFeatureFlags
+{
+ public bool UserRegistration { get; init; } = true;
+ public bool TwoFactor { get; init; } = true;
+ public bool Passkey { get; init; } = true;
+
+ public bool IsEnabled(IdentityFeature feature) =>
+ feature switch
+ {
+ IdentityFeature.UserRegistration => UserRegistration,
+ IdentityFeature.TwoFactor => TwoFactor,
+ IdentityFeature.Passkey => Passkey,
+ _ => true,
+ };
+}
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateApplicationBuilderExtensions.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateApplicationBuilderExtensions.cs
new file mode 100644
index 0000000..d28f74c
--- /dev/null
+++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateApplicationBuilderExtensions.cs
@@ -0,0 +1,15 @@
+using Microsoft.AspNetCore.Builder;
+
+namespace AndreGoepel.Marten.Identity.Blazor;
+
+public static class IdentityFeatureGateApplicationBuilderExtensions
+{
+ ///
+ /// Adds the middleware that makes disabled identity features (registration, 2FA setup,
+ /// passkeys) unreachable by direct URL (#66). Wire it after authentication/authorization
+ /// and before MapRazorComponents / MapAdditionalIdentityEndpoints so it can
+ /// intercept both the Razor pages and the passkey endpoints.
+ ///
+ public static IApplicationBuilder UseMartenIdentityFeatureGate(this IApplicationBuilder app) =>
+ app.UseMiddleware();
+}
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateMiddleware.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateMiddleware.cs
new file mode 100644
index 0000000..1d5724b
--- /dev/null
+++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateMiddleware.cs
@@ -0,0 +1,94 @@
+using Microsoft.AspNetCore.Http;
+
+namespace AndreGoepel.Marten.Identity.Blazor;
+
+///
+/// Blocks the pages and endpoints of a disabled identity feature so it is unreachable by
+/// direct URL, not merely hidden in the UI (#66). Modelled on
+/// SetupRedirectMiddleware: a browser page navigation to a disabled feature is
+/// redirected to the login page; any other request (a fetch, a passkey/attestation
+/// endpoint call) gets a 404. The login-time two-factor challenge is intentionally left
+/// reachable so users who already enrolled can still complete sign-in.
+///
+public sealed class IdentityFeatureGateMiddleware(RequestDelegate next)
+{
+ public async Task Invoke(HttpContext context, IIdentityFeatureProvider features)
+ {
+ var path = context.Request.Path.Value ?? "";
+ var feature = MapPathToFeature(path);
+
+ if (feature is { } gated)
+ {
+ var flags = await features.GetAsync(context.RequestAborted);
+ if (!flags.IsEnabled(gated))
+ {
+ if (IsPageNavigation(context))
+ context.Response.Redirect("/Account/Login");
+ else
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return;
+ }
+ }
+
+ await next.Invoke(context);
+ }
+
+ ///
+ /// Maps a request path to the feature that gates it, or null when the path is not
+ /// feature-gated. The two-factor login challenge (/Account/LoginWith2fa,
+ /// /Account/LoginWithRecoveryCode) is deliberately absent so disabling 2FA cannot
+ /// lock out users who already enrolled — only setup/management is gated.
+ ///
+ private static IdentityFeature? MapPathToFeature(string path)
+ {
+ if (
+ PathIs(path, "/Account/Register")
+ || PathIs(path, "/Account/RegisterConfirmation")
+ || PathIs(path, "/Account/ResendEmailConfirmation")
+ )
+ return IdentityFeature.UserRegistration;
+
+ if (
+ PathIs(path, "/Account/Manage/TwoFactorAuthentication")
+ || PathIs(path, "/Account/Manage/EnableAuthenticator")
+ || PathIs(path, "/Account/Manage/Disable2fa")
+ || PathIs(path, "/Account/Manage/GenerateRecoveryCodes")
+ || PathStartsWith(path, "/Account/Manage/ResetAuthenticator")
+ )
+ return IdentityFeature.TwoFactor;
+
+ if (
+ PathStartsWith(path, "/Account/Manage/Passkeys")
+ || PathIs(path, "/Account/Manage/PasskeyAttestation")
+ || PathIs(path, "/Account/PasskeyCreationOptions")
+ || PathIs(path, "/Account/PasskeyRequestOptions")
+ || PathIs(path, "/Account/PasskeyAssertion")
+ )
+ return IdentityFeature.Passkey;
+
+ return null;
+ }
+
+ private static bool PathIs(string path, string target) =>
+ path.Equals(target, StringComparison.OrdinalIgnoreCase);
+
+ private static bool PathStartsWith(string path, string prefix) =>
+ path.Equals(prefix, StringComparison.OrdinalIgnoreCase)
+ || path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// True only for browser page navigations, so sub-resource fetches and endpoint calls
+ /// get a 404 rather than an HTML redirect. Mirrors the heuristic in
+ /// SetupRedirectMiddleware.
+ ///
+ private static bool IsPageNavigation(HttpContext context)
+ {
+ var dest = context.Request.Headers["Sec-Fetch-Dest"].FirstOrDefault();
+ if (!string.IsNullOrEmpty(dest))
+ return dest is "document" or "iframe" or "embed" or "object";
+
+ return context.Request.Headers.Accept.Any(v =>
+ v?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true
+ );
+ }
+}
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/OptionsIdentityFeatureProvider.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/OptionsIdentityFeatureProvider.cs
new file mode 100644
index 0000000..e69aa17
--- /dev/null
+++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/OptionsIdentityFeatureProvider.cs
@@ -0,0 +1,27 @@
+using Microsoft.Extensions.Options;
+
+namespace AndreGoepel.Marten.Identity.Blazor;
+
+///
+/// Default that reads the flags from
+/// (the configuration baseline). Replace it with
+/// a host implementation to source the flags from a runtime store where the persisted value
+/// takes precedence over configuration.
+///
+internal sealed class OptionsIdentityFeatureProvider(
+ IOptionsMonitor options
+) : IIdentityFeatureProvider
+{
+ public ValueTask GetAsync(CancellationToken cancellationToken = default)
+ {
+ var current = options.CurrentValue;
+ return ValueTask.FromResult(
+ new IdentityFeatureFlags
+ {
+ UserRegistration = current.EnableUserRegistration,
+ TwoFactor = current.EnableTwoFactor,
+ Passkey = current.EnablePasskey,
+ }
+ );
+ }
+}
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs b/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs
index 070d06b..c689ef2 100644
--- a/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs
+++ b/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs
@@ -1,6 +1,7 @@
using AndreGoepel.Marten.Identity.Blazor.Components.Account;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
namespace AndreGoepel.Marten.Identity.Blazor;
@@ -23,6 +24,10 @@ public static IServiceCollection AddMartenIdentityBlazor(
options.Configure(configureOptions);
}
+ // Default feature-flag provider reads the options baseline (#66). TryAdd lets a host
+ // register a persistence-backed provider that takes precedence.
+ services.TryAddScoped();
+
return services;
}
}
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/MartenIdentityBlazorOptions.cs b/src/AndreGoepel.Marten.Identity.Blazor/MartenIdentityBlazorOptions.cs
index 59f995a..34b25a7 100644
--- a/src/AndreGoepel.Marten.Identity.Blazor/MartenIdentityBlazorOptions.cs
+++ b/src/AndreGoepel.Marten.Identity.Blazor/MartenIdentityBlazorOptions.cs
@@ -8,4 +8,18 @@ public sealed class MartenIdentityBlazorOptions
/// without a suffix — the library ships no default branding.
///
public string? ApplicationName { get; set; }
+
+ ///
+ /// Configuration baseline for the identity feature flags (#66). These are the values the
+ /// default serves; a host that persists the flags
+ /// registers its own provider whose stored value takes precedence. All default to
+ /// true, so the full feature set is available unless a host opts out.
+ ///
+ public bool EnableUserRegistration { get; set; } = true;
+
+ ///
+ public bool EnableTwoFactor { get; set; } = true;
+
+ ///
+ public bool EnablePasskey { get; set; } = true;
}
diff --git a/src/AndreGoepel.Marten.Identity.Blazor/README.md b/src/AndreGoepel.Marten.Identity.Blazor/README.md
index bfac252..0d5a680 100644
--- a/src/AndreGoepel.Marten.Identity.Blazor/README.md
+++ b/src/AndreGoepel.Marten.Identity.Blazor/README.md
@@ -41,6 +41,63 @@ app.MapRazorComponents()
This makes the routable pages in the RCL (e.g. `/Account/Login`) discoverable by the Blazor router.
+## Feature flags (registration, 2FA, passkeys)
+
+The registration, two-factor and passkey features can be turned off. Disabling a feature
+hides its UI **and** makes its setup pages/endpoints unreachable by direct URL — the
+login-time 2FA/recovery challenge stays reachable so users who already enrolled can still
+sign in, and passkey login falls back to password.
+
+**1. Baseline from configuration** (all default `true`):
+
+```csharp
+builder.Services.AddMartenIdentityBlazor(options =>
+{
+ options.EnableUserRegistration = true;
+ options.EnableTwoFactor = true;
+ options.EnablePasskey = false; // e.g. passkeys off
+});
+```
+
+**2. Enforce the gate** — add the middleware after authentication/authorization and before
+`MapRazorComponents` / `MapAdditionalIdentityEndpoints` so it can block both pages and the
+passkey endpoints:
+
+```csharp
+app.UseAuthentication();
+app.UseAuthorization();
+app.UseMartenIdentityFeatureGate(); // blocks disabled-feature pages/endpoints
+app.MapAdditionalIdentityEndpoints();
+app.MapRazorComponents() /* … */;
+```
+
+**3. Runtime override (optional).** To source the flags from a store (e.g. an admin
+settings page) where the persisted value beats configuration, register your own
+`IIdentityFeatureProvider` — it takes precedence over the built-in options-backed default:
+
+```csharp
+builder.Services.AddScoped();
+```
+
+```csharp
+public sealed class MyDbFeatureProvider(IMyStore store) : IIdentityFeatureProvider
+{
+ public async ValueTask GetAsync(CancellationToken ct = default)
+ {
+ var saved = await store.GetIdentityFlagsAsync(ct); // DB value takes precedence
+ return new IdentityFeatureFlags
+ {
+ UserRegistration = saved.Registration,
+ TwoFactor = saved.TwoFactor,
+ Passkey = saved.Passkey,
+ };
+ }
+}
+```
+
+Your own account-navigation links should call the same provider to hide entry points to
+disabled features.
+
## What's included
| Area | Components / Pages |
diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/Login.Tests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/Login.Tests.cs
new file mode 100644
index 0000000..9ba0d77
--- /dev/null
+++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/Login.Tests.cs
@@ -0,0 +1,92 @@
+using AndreGoepel.Marten.Identity.Blazor;
+using AndreGoepel.Marten.Identity.Blazor.Components.Account.Pages;
+using AndreGoepel.Marten.Identity.Http;
+using AndreGoepel.Marten.Identity.Users;
+using Bunit;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using Radzen;
+
+namespace AndreGoepel.Marten.Identity.Blazor.Tests.Account.Pages;
+
+public class LoginTests : BunitContext
+{
+ private const string RegisterLink = "Register as a new user";
+ private const string PasskeyButton = "Log in with a passkey";
+
+ [Fact]
+ public void AllEnabled_ShowsRegisterLinkAndPasskeyButton()
+ {
+ var cut = Render(new IdentityFeatureFlags());
+
+ Assert.Contains(RegisterLink, cut.Markup);
+ Assert.Contains(PasskeyButton, cut.Markup);
+ }
+
+ [Fact]
+ public void RegistrationDisabled_HidesRegisterLink()
+ {
+ var cut = Render(new IdentityFeatureFlags { UserRegistration = false });
+
+ Assert.DoesNotContain(RegisterLink, cut.Markup);
+ Assert.Contains(PasskeyButton, cut.Markup); // unrelated feature untouched
+ }
+
+ [Fact]
+ public void PasskeyDisabled_HidesPasskeyButton()
+ {
+ var cut = Render(new IdentityFeatureFlags { Passkey = false });
+
+ Assert.DoesNotContain(PasskeyButton, cut.Markup);
+ Assert.Contains(RegisterLink, cut.Markup);
+ }
+
+ private IRenderedComponent Render(IdentityFeatureFlags flags)
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var um = Substitute.For>(
+ Substitute.For>(),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+ var sm = Substitute.For>(
+ um,
+ Substitute.For(),
+ Substitute.For>(),
+ Options.Create(new IdentityOptions()),
+ Substitute.For>>(),
+ Substitute.For(),
+ Substitute.For>()
+ );
+
+ Services.AddSingleton(um);
+ Services.AddSingleton(sm);
+ Services.AddSingleton>(new PasswordHasher());
+ Services.AddSingleton(Substitute.For>());
+ Services.AddSingleton(new NotificationService());
+ Services.AddSingleton(new LoginTokenProtector(DataProtectionProvider.Create("Tests")));
+ Services.AddSingleton(new StubProvider(flags));
+
+ return Render();
+ }
+
+ private sealed class StubProvider(IdentityFeatureFlags flags) : IIdentityFeatureProvider
+ {
+ public ValueTask GetAsync(
+ CancellationToken cancellationToken = default
+ ) => ValueTask.FromResult(flags);
+ }
+}
diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs
new file mode 100644
index 0000000..57e1194
--- /dev/null
+++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs
@@ -0,0 +1,150 @@
+using AndreGoepel.Marten.Identity.Blazor;
+using Microsoft.AspNetCore.Http;
+
+namespace AndreGoepel.Marten.Identity.Blazor.Tests.Features;
+
+public class IdentityFeatureGateMiddlewareTests
+{
+ [Theory]
+ [InlineData("/Account/Register")]
+ [InlineData("/Account/RegisterConfirmation")]
+ [InlineData("/Account/ResendEmailConfirmation")]
+ public async Task Registration_Disabled_PageNavigation_RedirectsToLogin(string path)
+ {
+ var (mw, ctx, called) = Build(path, secFetchDest: "document");
+
+ await mw.Invoke(ctx, Provider(new() { UserRegistration = false }));
+
+ Assert.Equal("/Account/Login", ctx.Response.Headers.Location.ToString());
+ Assert.False(called.Value);
+ }
+
+ [Fact]
+ public async Task Registration_Disabled_Fetch_Returns404()
+ {
+ var (mw, ctx, called) = Build("/Account/Register", secFetchDest: "empty");
+
+ await mw.Invoke(ctx, Provider(new() { UserRegistration = false }));
+
+ Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
+ Assert.False(called.Value);
+ }
+
+ [Fact]
+ public async Task Registration_Enabled_PassesThrough()
+ {
+ var (mw, ctx, called) = Build("/Account/Register", secFetchDest: "document");
+
+ await mw.Invoke(ctx, Provider(new() { UserRegistration = true }));
+
+ Assert.True(called.Value);
+ }
+
+ [Theory]
+ [InlineData("/Account/Manage/TwoFactorAuthentication")]
+ [InlineData("/Account/Manage/EnableAuthenticator")]
+ [InlineData("/Account/Manage/Disable2fa")]
+ [InlineData("/Account/Manage/GenerateRecoveryCodes")]
+ [InlineData("/Account/Manage/ResetAuthenticator")]
+ [InlineData("/Account/Manage/ResetAuthenticator/ConfirmReset")]
+ public async Task TwoFactorSetup_Disabled_IsBlocked(string path)
+ {
+ var (mw, ctx, called) = Build(path, secFetchDest: "empty");
+
+ await mw.Invoke(ctx, Provider(new() { TwoFactor = false }));
+
+ Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
+ Assert.False(called.Value);
+ }
+
+ [Theory]
+ [InlineData("/Account/LoginWith2fa")]
+ [InlineData("/Account/LoginWithRecoveryCode")]
+ public async Task TwoFactorLoginChallenge_NotGated_EvenWhenDisabled(string path)
+ {
+ // Graceful gating (#66): a user who already enrolled must still be able to complete
+ // the login-time 2FA challenge even after the feature is turned off.
+ var (mw, ctx, called) = Build(path, secFetchDest: "document");
+
+ await mw.Invoke(ctx, Provider(new() { TwoFactor = false }));
+
+ Assert.True(called.Value);
+ }
+
+ [Theory]
+ [InlineData("/Account/Manage/Passkeys")]
+ [InlineData("/Account/Manage/Passkeys/Create")]
+ [InlineData("/Account/Manage/Passkeys/Rename/abc")]
+ [InlineData("/Account/Manage/PasskeyAttestation")]
+ [InlineData("/Account/PasskeyCreationOptions")]
+ [InlineData("/Account/PasskeyRequestOptions")]
+ [InlineData("/Account/PasskeyAssertion")]
+ public async Task Passkey_Disabled_IsBlocked(string path)
+ {
+ var (mw, ctx, called) = Build(path, secFetchDest: "empty");
+
+ await mw.Invoke(ctx, Provider(new() { Passkey = false }));
+
+ Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
+ Assert.False(called.Value);
+ }
+
+ [Theory]
+ [InlineData("/Account/Login")]
+ [InlineData("/Account/ForgotPassword")]
+ [InlineData("/Account/Manage/Profile")]
+ [InlineData("/dashboard")]
+ public async Task UngatedPaths_AlwaysPassThrough(string path)
+ {
+ var (mw, ctx, called) = Build(path, secFetchDest: "document");
+
+ // Everything off, but these paths are not feature-gated.
+ await mw.Invoke(
+ ctx,
+ Provider(
+ new()
+ {
+ UserRegistration = false,
+ TwoFactor = false,
+ Passkey = false,
+ }
+ )
+ );
+
+ Assert.True(called.Value);
+ }
+
+ private static (
+ IdentityFeatureGateMiddleware Middleware,
+ DefaultHttpContext Context,
+ Box Called
+ ) Build(string path, string? secFetchDest)
+ {
+ var called = new Box();
+ var middleware = new IdentityFeatureGateMiddleware(_ =>
+ {
+ called.Value = true;
+ return Task.CompletedTask;
+ });
+ var context = new DefaultHttpContext();
+ context.Request.Path = path;
+ if (secFetchDest is not null)
+ context.Request.Headers["Sec-Fetch-Dest"] = secFetchDest;
+ return (middleware, context, called);
+ }
+
+ private static IIdentityFeatureProvider Provider(IdentityFeatureFlags flags) =>
+ new StubProvider(flags);
+
+ private sealed class StubProvider(IdentityFeatureFlags flags) : IIdentityFeatureProvider
+ {
+ public ValueTask GetAsync(
+ CancellationToken cancellationToken = default
+ ) => ValueTask.FromResult(flags);
+ }
+
+ private sealed class Box
+ {
+ public T Value { get; set; } = default!;
+ }
+}
diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/OptionsIdentityFeatureProviderTests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/OptionsIdentityFeatureProviderTests.cs
new file mode 100644
index 0000000..7df0736
--- /dev/null
+++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/OptionsIdentityFeatureProviderTests.cs
@@ -0,0 +1,48 @@
+using AndreGoepel.Marten.Identity.Blazor;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AndreGoepel.Marten.Identity.Blazor.Tests.Features;
+
+public class OptionsIdentityFeatureProviderTests
+{
+ [Fact]
+ public async Task Default_AllFeaturesEnabled()
+ {
+ var flags = await ResolveAsync(configure: null);
+
+ Assert.True(flags.UserRegistration);
+ Assert.True(flags.TwoFactor);
+ Assert.True(flags.Passkey);
+ }
+
+ [Fact]
+ public async Task ReflectsConfiguredOptionValues()
+ {
+ var flags = await ResolveAsync(o =>
+ {
+ o.EnableUserRegistration = false;
+ o.EnableTwoFactor = false;
+ o.EnablePasskey = true;
+ });
+
+ Assert.False(flags.UserRegistration);
+ Assert.False(flags.TwoFactor);
+ Assert.True(flags.Passkey);
+ }
+
+ // Resolves the default provider through the real DI wiring (AddMartenIdentityBlazor),
+ // proving both the registration and the options-to-flags mapping.
+ private static async Task ResolveAsync(
+ Action? configure
+ )
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddMartenIdentityBlazor(configure);
+
+ await using var provider = services.BuildServiceProvider();
+ using var scope = provider.CreateScope();
+ var featureProvider = scope.ServiceProvider.GetRequiredService();
+ return await featureProvider.GetAsync();
+ }
+}