Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime
@inject IIdentityFeatureProvider Features

<IdentityPageTitle Title="Log in" />

Expand All @@ -28,25 +29,32 @@
ButtonStyle="ButtonStyle.Primary"
form="login-form" />

<PasskeySubmit Operation="PasskeyOperation.Request"
OptionsEndpointUrl="/Account/PasskeyRequestOptions"
ActionEndpointUrl="@PasskeyAssertionUrl"
OnSuccess="HandlePasskeySuccess"
OnError="HandlePasskeyError">
Log in with a passkey
</PasskeySubmit>
@if (_flags.Passkey)
{
<PasskeySubmit Operation="PasskeyOperation.Request"
OptionsEndpointUrl="/Account/PasskeyRequestOptions"
ActionEndpointUrl="@PasskeyAssertionUrl"
OnSuccess="HandlePasskeySuccess"
OnError="HandlePasskeyError">
Log in with a passkey
</PasskeySubmit>
}
</div>

<RadzenStack Orientation="Orientation.Horizontal" Gap="1.5rem" Wrap="FlexWrap.Wrap">
<RadzenLink Path="Account/ForgotPassword" Text="Forgot your password?" />
<RadzenLink Path="@(NavigationManager.GetUriWithQueryParameters("Account/Register", new Dictionary<string, object?> { ["ReturnUrl"] = ReturnUrl }))" Text="Register as a new user" />
@if (_flags.UserRegistration)
{
<RadzenLink Path="@(NavigationManager.GetUriWithQueryParameters("Account/Register", new Dictionary<string, object?> { ["ReturnUrl"] = ReturnUrl }))" Text="Register as a new user" />
}
<RadzenLink Path="Account/ResendEmailConfirmation" Text="Resend email confirmation" />
</RadzenStack>

@code {
private string? _error;
private DotNetObjectReference<Login>? _dotNetRef;
private IJSObjectReference? _jsModule;
private IdentityFeatureFlags _flags = new();

[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }
Expand All @@ -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<IJSObjectReference>(
"import", "./_content/AndreGoepel.Marten.Identity.Blazor/Components/Account/Shared/PasskeySubmit.razor.js");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace AndreGoepel.Marten.Identity.Blazor;

/// <summary>
/// Source of truth for identity feature availability (#66). The library ships an
/// options-backed default (<see cref="OptionsIdentityFeatureProvider"/>); 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.
/// </summary>
public interface IIdentityFeatureProvider
{
ValueTask<IdentityFeatureFlags> GetAsync(CancellationToken cancellationToken = default);
}
18 changes: 18 additions & 0 deletions src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace AndreGoepel.Marten.Identity.Blazor;

/// <summary>
/// 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.
/// </summary>
public enum IdentityFeature
{
/// <summary>Self-service account registration and its confirmation/resend pages.</summary>
UserRegistration,

/// <summary>Two-factor authenticator setup and management (the login-time challenge for
/// users who already enrolled is intentionally kept reachable).</summary>
TwoFactor,

/// <summary>WebAuthn passkey management and login/creation endpoints.</summary>
Passkey,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace AndreGoepel.Marten.Identity.Blazor;

/// <summary>
/// A snapshot of which identity features are enabled (#66). Every flag defaults to
/// <c>true</c>, so a host that configures nothing keeps the full feature set.
/// </summary>
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,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Builder;

namespace AndreGoepel.Marten.Identity.Blazor;

public static class IdentityFeatureGateApplicationBuilderExtensions
{
/// <summary>
/// Adds the middleware that makes disabled identity features (registration, 2FA setup,
/// passkeys) unreachable by direct URL (#66). Wire it after authentication/authorization
/// and before <c>MapRazorComponents</c> / <c>MapAdditionalIdentityEndpoints</c> so it can
/// intercept both the Razor pages and the passkey endpoints.
/// </summary>
public static IApplicationBuilder UseMartenIdentityFeatureGate(this IApplicationBuilder app) =>
app.UseMiddleware<IdentityFeatureGateMiddleware>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Http;

namespace AndreGoepel.Marten.Identity.Blazor;

/// <summary>
/// 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
/// <c>SetupRedirectMiddleware</c>: 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.
/// </summary>
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);
}

/// <summary>
/// Maps a request path to the feature that gates it, or <c>null</c> when the path is not
/// feature-gated. The two-factor <b>login challenge</b> (<c>/Account/LoginWith2fa</c>,
/// <c>/Account/LoginWithRecoveryCode</c>) is deliberately absent so disabling 2FA cannot
/// lock out users who already enrolled — only setup/management is gated.
/// </summary>
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);

/// <summary>
/// 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
/// <c>SetupRedirectMiddleware</c>.
/// </summary>
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
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Options;

namespace AndreGoepel.Marten.Identity.Blazor;

/// <summary>
/// Default <see cref="IIdentityFeatureProvider"/> that reads the flags from
/// <see cref="MartenIdentityBlazorOptions"/> (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.
/// </summary>
internal sealed class OptionsIdentityFeatureProvider(
IOptionsMonitor<MartenIdentityBlazorOptions> options
) : IIdentityFeatureProvider
{
public ValueTask<IdentityFeatureFlags> GetAsync(CancellationToken cancellationToken = default)
{
var current = options.CurrentValue;
return ValueTask.FromResult(
new IdentityFeatureFlags
{
UserRegistration = current.EnableUserRegistration,
TwoFactor = current.EnableTwoFactor,
Passkey = current.EnablePasskey,
}
);
}
}
5 changes: 5 additions & 0 deletions src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<IIdentityFeatureProvider, OptionsIdentityFeatureProvider>();

return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,18 @@ public sealed class MartenIdentityBlazorOptions
/// without a suffix — the library ships no default branding.
/// </summary>
public string? ApplicationName { get; set; }

/// <summary>
/// Configuration baseline for the identity feature flags (#66). These are the values the
/// default <see cref="IIdentityFeatureProvider"/> serves; a host that persists the flags
/// registers its own provider whose stored value takes precedence. All default to
/// <c>true</c>, so the full feature set is available unless a host opts out.
/// </summary>
public bool EnableUserRegistration { get; set; } = true;

/// <inheritdoc cref="EnableUserRegistration" />
public bool EnableTwoFactor { get; set; } = true;

/// <inheritdoc cref="EnableUserRegistration" />
public bool EnablePasskey { get; set; } = true;
}
57 changes: 57 additions & 0 deletions src/AndreGoepel.Marten.Identity.Blazor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,63 @@ app.MapRazorComponents<App>()

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<App>() /* … */;
```

**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<IIdentityFeatureProvider, MyDbFeatureProvider>();
```

```csharp
public sealed class MyDbFeatureProvider(IMyStore store) : IIdentityFeatureProvider
{
public async ValueTask<IdentityFeatureFlags> 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 |
Expand Down
Loading