Skip to content

Latest commit

 

History

History
855 lines (619 loc) · 29.5 KB

additional-scenarios.md

File metadata and controls

855 lines (619 loc) · 29.5 KB
title author description monikerRange ms.author ms.custom ms.date uid
Server-side ASP.NET Core Blazor additional security scenarios
guardrex
Learn how to configure server-side Blazor for additional security scenarios.
>= aspnetcore-3.1
riande
mvc
02/09/2024
blazor/security/server/additional-scenarios

Server-side ASP.NET Core Blazor additional security scenarios

[!INCLUDE]

This article explains how to configure server-side Blazor for additional security scenarios, including how to pass tokens to a Blazor app.

Note

The code examples in this article adopt nullable reference types (NRTs) and .NET compiler null-state static analysis, which are supported in ASP.NET Core in .NET 6 or later. When targeting ASP.NET Core 5.0 or earlier, remove the null type designation (?) from the string?, TodoItem[]?, WeatherForecast[]?, and IEnumerable<GitHubBranch>? types in the article's examples.

Pass tokens to a server-side Blazor app

:::moniker range=">= aspnetcore-8.0"

Updating this section for Blazor Web Apps is pending Update section on passing tokens in Blazor Web Apps (dotnet/AspNetCore.Docs #31691). For more information, see Problem providing Access Token to HttpClient in Interactive Server mode (dotnet/aspnetcore #52390).

For Blazor Server, view the 7.0 version of this article section.

:::moniker-end

:::moniker range="< aspnetcore-8.0"

Tokens available outside of the Razor components in a server-side Blazor app can be passed to components with the approach described in this section. The example in this section focuses on passing access, refresh, and anti-request forgery (XSRF) token tokens to the Blazor app, but the approach is valid for other HTTP context state.

Note

Passing the XSRF token to Razor components is useful in scenarios where components POST to Identity or other endpoints that require validation. If your app only requires access and refresh tokens, you can remove the XSRF token code from the following example.

Authenticate the app as you would with a regular Razor Pages or MVC app. Provision and save the tokens to the authentication cookie.

:::moniker-end

:::moniker range=">= aspnetcore-6.0 < aspnetcore-8.0"

In the Program file:

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

...

builder.Services.Configure<OpenIdConnectOptions>(
    OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.SaveTokens = true;
    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
});

:::moniker-end

:::moniker range=">= aspnetcore-5.0 < aspnetcore-6.0"

In Startup.cs:

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

...

services.Configure<OpenIdConnectOptions>(
    OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.SaveTokens = true;
    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
});

:::moniker-end

:::moniker range="< aspnetcore-5.0"

In Startup.cs:

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

...

services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
{
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.SaveTokens = true;
    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
});

:::moniker-end

:::moniker range="< aspnetcore-8.0"

Optionally, additional scopes are added with options.Scope.Add("{SCOPE}");, where the {SCOPE} placeholder is the additional scope to add.

Define a scoped token provider service that can be used within the Blazor app to resolve the tokens from dependency injection (DI).

TokenProvider.cs:

public class TokenProvider
{
    public string? AccessToken { get; set; }
    public string? RefreshToken { get; set; }
    public string? XsrfToken { get; set; }
}

:::moniker-end

:::moniker range=">= aspnetcore-6.0 < aspnetcore-8.0"

In the Program file, add services for:

  • xref:System.Net.Http.IHttpClientFactory: Used in a WeatherForecastService class that obtains weather data from a server API with an access token.
  • TokenProvider: Holds the access and refresh tokens.
builder.Services.AddHttpClient();
builder.Services.AddScoped<TokenProvider>();

:::moniker-end

:::moniker range="< aspnetcore-6.0"

In Startup.ConfigureServices of Startup.cs, add services for:

  • xref:System.Net.Http.IHttpClientFactory: Used in a WeatherForecastService class that obtains weather data from a server API with an access token.
  • TokenProvider: Holds the access and refresh tokens.
services.AddHttpClient();
services.AddScoped<TokenProvider>();

:::moniker-end

:::moniker range="< aspnetcore-8.0"

Define a class to pass in the initial app state with the access and refresh tokens.

InitialApplicationState.cs:

public class InitialApplicationState
{
    public string? AccessToken { get; set; }
    public string? RefreshToken { get; set; }
    public string? XsrfToken { get; set; }
}

:::moniker-end

:::moniker range=">= aspnetcore-7.0 < aspnetcore-8.0"

In the Pages/_Host.cshtml file, create and instance of InitialApplicationState and pass it as a parameter to the app:

:::moniker-end

:::moniker range=">= aspnetcore-6.0 < aspnetcore-7.0"

In the Pages/_Layout.cshtml file, create and instance of InitialApplicationState and pass it as a parameter to the app:

:::moniker-end

:::moniker range="< aspnetcore-6.0"

In the Pages/_Host.cshtml file, create and instance of InitialApplicationState and pass it as a parameter to the app:

:::moniker-end

:::moniker range="< aspnetcore-8.0"

@using Microsoft.AspNetCore.Authentication
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Xsrf

...

@{
    var tokens = new InitialApplicationState
    {
        AccessToken = await HttpContext.GetTokenAsync("access_token"),
        RefreshToken = await HttpContext.GetTokenAsync("refresh_token"),
        XsrfToken = Xsrf.GetAndStoreTokens(HttpContext).RequestToken
    };
}

<component ... param-InitialState="tokens" ... />

In the App component (App.razor), resolve the service and initialize it with the data from the parameter:

@inject TokenProvider TokenProvider

...

@code {
    [Parameter]
    public InitialApplicationState? InitialState { get; set; }

    protected override Task OnInitializedAsync()
    {
        TokenProvider.AccessToken = InitialState?.AccessToken;
        TokenProvider.RefreshToken = InitialState?.RefreshToken;
        TokenProvider.XsrfToken = InitialState?.XsrfToken;

        return base.OnInitializedAsync();
    }
}

Note

An alternative to assigning the initial state to the TokenProvider in the preceding example is to copy the data into a scoped service within xref:Microsoft.AspNetCore.Components.ComponentBase.OnInitializedAsync%2A for use across the app.

Add a package reference to the app for the Microsoft.AspNet.WebApi.Client NuGet package.

[!INCLUDE]

In the service that makes a secure API request, inject the token provider and retrieve the token for the API request:

WeatherForecastService.cs:

using System;
using System.Net.Http;
using System.Threading.Tasks;

public class WeatherForecastService
{
    private readonly HttpClient http;
    private readonly TokenProvider tokenProvider;

    public WeatherForecastService(IHttpClientFactory clientFactory, 
        TokenProvider tokenProvider)
    {
        http = clientFactory.CreateClient();
        this.tokenProvider = tokenProvider;
    }

    public async Task<WeatherForecast[]> GetForecastAsync()
    {
        var token = tokenProvider.AccessToken;
        var request = new HttpRequestMessage(HttpMethod.Get, 
            "https://localhost:5003/WeatherForecast");
        request.Headers.Add("Authorization", $"Bearer {token}");
        var response = await http.SendAsync(request);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<WeatherForecast[]>() ?? 
            Array.Empty<WeatherForecast>();
    }
}

For an XSRF token passed to a component, inject the TokenProvider and add the XSRF token to the POST request. The following example adds the token to a logout endpoint POST. The scenario for the following example is that the logout endpoint (Areas/Identity/Pages/Account/Logout.cshtml, scaffolded into the app) doesn't specify an xref:Microsoft.AspNetCore.Mvc.IgnoreAntiforgeryTokenAttribute (@attribute [IgnoreAntiforgeryToken]) because it performs some action in addition to a normal logout operation that must be protected. The endpoint requires a valid XSRF token to successfully process the request.

In a component that presents a Logout button to authorized users:

@inject TokenProvider TokenProvider

...

<AuthorizeView>
    <Authorized>
        <form action="/Identity/Account/Logout?returnUrl=%2F" method="post">
            <button class="nav-link btn btn-link" type="submit">Logout</button>
            <input name="__RequestVerificationToken" type="hidden" 
                value="@TokenProvider.XsrfToken">
        </form>
    </Authorized>
    <NotAuthorized>
        ...
    </NotAuthorized>
</AuthorizeView>

:::moniker-end

Set the authentication scheme

:::moniker range=">= aspnetcore-6.0"

For an app that uses more than one Authentication Middleware and thus has more than one authentication scheme, the scheme that Blazor uses can be explicitly set in the endpoint configuration of the Program file. The following example sets the OpenID Connect (OIDC) scheme:

:::moniker-end

:::moniker range="< aspnetcore-6.0"

For an app that uses more than one Authentication Middleware and thus has more than one authentication scheme, the scheme that Blazor uses can be explicitly set in the endpoint configuration of Startup.cs. The following example sets the OpenID Connect (OIDC) scheme:

:::moniker-end

:::moniker range=">= aspnetcore-8.0"

using Microsoft.AspNetCore.Authentication.OpenIdConnect;

...

app.MapRazorComponents<App>().RequireAuthorization(
    new AuthorizeAttribute
    {
        AuthenticationSchemes = OpenIdConnectDefaults.AuthenticationScheme
    })
    .AddInteractiveServerRenderMode();

:::moniker-end

:::moniker range=">= aspnetcore-5.0 < aspnetcore-8.0"

using Microsoft.AspNetCore.Authentication.OpenIdConnect;

...

app.MapBlazorHub().RequireAuthorization(
    new AuthorizeAttribute 
    {
        AuthenticationSchemes = OpenIdConnectDefaults.AuthenticationScheme
    });

:::moniker-end

:::moniker range="< aspnetcore-5.0"

For an app that uses more than one Authentication Middleware and thus has more than one authentication scheme, the scheme that Blazor uses can be explicitly set in the endpoint configuration of Startup.Configure. The following example sets the Microsoft Entra ID scheme:

endpoints.MapBlazorHub().RequireAuthorization(
    new AuthorizeAttribute 
    {
        AuthenticationSchemes = AzureADDefaults.AuthenticationScheme
    });

:::moniker-end

:::moniker range="< aspnetcore-5.0"

Use OpenID Connect (OIDC) v2.0 endpoints

In versions of ASP.NET Core prior to 5.0, the authentication library and Blazor templates use OpenID Connect (OIDC) v1.0 endpoints. To use a v2.0 endpoint with versions of ASP.NET Core prior to 5.0, configure the xref:Microsoft.AspNetCore.Builder.OpenIdConnectOptions.Authority?displayProperty=nameWithType option in the xref:Microsoft.AspNetCore.Builder.OpenIdConnectOptions:

services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, 
    options =>
    {
        options.Authority += "/v2.0";
    }

Alternatively, the setting can be made in the app settings (appsettings.json) file:

{
  "AzureAd": {
    "Authority": "https://login.microsoftonline.com/common/oauth2/v2.0/",
    ...
  }
}

If tacking on a segment to the authority isn't appropriate for the app's OIDC provider, such as with non-ME-ID providers, set the xref:Microsoft.AspNetCore.Builder.OpenIdConnectOptions.Authority property directly. Either set the property in xref:Microsoft.AspNetCore.Builder.OpenIdConnectOptions or in the app settings file with the xref:Microsoft.AspNetCore.Builder.OpenIdConnectOptions.Authority key.

Code changes

  • The list of claims in the ID token changes for v2.0 endpoints. Microsoft documentation on the changes has been retired, but guidance on the claims in an ID token is available in the ID token claims reference.

  • Since resources are specified in scope URIs for v2.0 endpoints, remove the xref:Microsoft.AspNetCore.Builder.OpenIdConnectOptions.Resource?displayProperty=nameWithType property setting in xref:Microsoft.AspNetCore.Builder.OpenIdConnectOptions:

    services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options => 
        {
            ...
            options.Resource = "...";    // REMOVE THIS LINE
            ...
        }

App ID URI

  • When using v2.0 endpoints, APIs define an App ID URI, which is meant to represent a unique identifier for the API.
  • All scopes include the App ID URI as a prefix, and v2.0 endpoints emit access tokens with the App ID URI as the audience.
  • When using V2.0 endpoints, the client ID configured in the Server API changes from the API Application ID (Client ID) to the App ID URI.

appsettings.json:

{
  "AzureAd": {
    ...
    "ClientId": "https://{TENANT}.onmicrosoft.com/{PROJECT NAME}"
    ...
  }
}

You can find the App ID URI to use in the OIDC provider app registration description.

:::moniker-end

Circuit handler to capture users for custom services

Use a xref:Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler to capture a user from the xref:Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider and set the user in a service. If you want to update the user, register a callback to xref:Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider.AuthenticationStateChanged and queue a xref:System.Threading.Tasks.Task to obtain the new user and update the service. The following example demonstrates the approach.

In the following example:

  • xref:Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler.OnConnectionUpAsync%2A is called every time the circuit reconnects, setting the user for the lifetime of the connection. Only the xref:Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler.OnConnectionUpAsync%2A method is required unless you implement updates via a handler for authentication changes (AuthenticationChanged in the following example).
  • xref:Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler.OnCircuitOpenedAsync%2A is called to attach the authentication changed handler, AuthenticationChanged, to update the user.
  • The catch block of the UpdateAuthentication task takes no action on exceptions because there's no way to report them at this point in code execution. If an exception is thrown from the task, the exception is reported elsewhere in app.

UserService.cs:

:::moniker range=">= aspnetcore-5.0"

using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server.Circuits;

public class UserService
{
    private ClaimsPrincipal currentUser = new(new ClaimsIdentity());

    public ClaimsPrincipal GetUser()
    {
        return currentUser;
    }

    internal void SetUser(ClaimsPrincipal user)
    {
        if (currentUser != user)
        {
            currentUser = user;
        }
    }
}

internal sealed class UserCircuitHandler : CircuitHandler, IDisposable
{
    private readonly AuthenticationStateProvider authenticationStateProvider;
    private readonly UserService userService;

    public UserCircuitHandler(
        AuthenticationStateProvider authenticationStateProvider,
        UserService userService)
    {
        this.authenticationStateProvider = authenticationStateProvider;
        this.userService = userService;
    }

    public override Task OnCircuitOpenedAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        authenticationStateProvider.AuthenticationStateChanged += 
            AuthenticationChanged;

        return base.OnCircuitOpenedAsync(circuit, cancellationToken);
    }

    private void AuthenticationChanged(Task<AuthenticationState> task)
    {
        _ = UpdateAuthentication(task);

        async Task UpdateAuthentication(Task<AuthenticationState> task)
        {
            try
            {
                var state = await task;
                userService.SetUser(state.User);
            }
            catch
            {
            }
        }
    }

    public override async Task OnConnectionUpAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        var state = await authenticationStateProvider.GetAuthenticationStateAsync();
        userService.SetUser(state.User);
    }

    public void Dispose()
    {
        authenticationStateProvider.AuthenticationStateChanged -= 
            AuthenticationChanged;
    }
}

:::moniker-end

:::moniker range="< aspnetcore-5.0"

using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server.Circuits;

public class UserService
{
    private ClaimsPrincipal currentUser = new ClaimsPrincipal(new ClaimsIdentity());

    public ClaimsPrincipal GetUser()
    {
        return currentUser;
    }

    internal void SetUser(ClaimsPrincipal user)
    {
        if (currentUser != user)
        {
            currentUser = user;
        }
    }
}

internal sealed class UserCircuitHandler : CircuitHandler, IDisposable
{
    private readonly AuthenticationStateProvider authenticationStateProvider;
    private readonly UserService userService;

    public UserCircuitHandler(
        AuthenticationStateProvider authenticationStateProvider,
        UserService userService)
    {
        this.authenticationStateProvider = authenticationStateProvider;
        this.userService = userService;
    }

    public override Task OnCircuitOpenedAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        authenticationStateProvider.AuthenticationStateChanged += 
            AuthenticationChanged;

        return base.OnCircuitOpenedAsync(circuit, cancellationToken);
    }

    private void AuthenticationChanged(Task<AuthenticationState> task)
    {
        _ = UpdateAuthentication(task);

        async Task UpdateAuthentication(Task<AuthenticationState> task)
        {
            try
            {
                var state = await task;
                userService.SetUser(state.User);
            }
            catch
            {
            }
        }
    }

    public override async Task OnConnectionUpAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        var state = await authenticationStateProvider.GetAuthenticationStateAsync();
        userService.SetUser(state.User);
    }

    public void Dispose()
    {
        authenticationStateProvider.AuthenticationStateChanged -= 
            AuthenticationChanged;
    }
}

:::moniker-end

:::moniker range=">= aspnetcore-6.0"

In the Program file:

using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.Extensions.DependencyInjection.Extensions;

...

builder.Services.AddScoped<UserService>();
builder.Services.TryAddEnumerable(
    ServiceDescriptor.Scoped<CircuitHandler, UserCircuitHandler>());

:::moniker-end

:::moniker range="< aspnetcore-6.0"

In Startup.ConfigureServices of Startup.cs:

using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.Extensions.DependencyInjection.Extensions;

...

services.AddScoped<UserService>();
services.TryAddEnumerable(
    ServiceDescriptor.Scoped<CircuitHandler, UserCircuitHandler>());

:::moniker-end

Use the service in a component to obtain the user:

@inject UserService UserService

<h1>Hello, @(UserService.GetUser().Identity?.Name ?? "world")!</h1>

To set the user in middleware for MVC, Razor Pages, and in other ASP.NET Core scenarios, call SetUser on the UserService in custom middleware after the Authentication Middleware runs, or set the user with an xref:Microsoft.AspNetCore.Authentication.IClaimsTransformation implementation. The following example adopts the middleware approach.

UserServiceMiddleware.cs:

public class UserServiceMiddleware
{
    private readonly RequestDelegate next;

    public UserServiceMiddleware(RequestDelegate next)
    {
        this.next = next ?? throw new ArgumentNullException(nameof(next));
    }

    public async Task InvokeAsync(HttpContext context, UserService service)
    {
        service.SetUser(context.User);
        await next(context);
    }
}

:::moniker range=">= aspnetcore-8.0"

Immediately before the call to app.MapRazorComponents<App>() in the Program file, call the middleware:

:::moniker-end

:::moniker range=">= aspnetcore-6.0 < aspnetcore-8.0"

Immediately before the call to app.MapBlazorHub() in the Program file, call the middleware:

:::moniker-end

:::moniker range="< aspnetcore-6.0"

Immediately before the call to app.MapBlazorHub() in Startup.Configure of Startup.cs, call the middleware:

:::moniker-end

app.UseMiddleware<UserServiceMiddleware>();

:::moniker range=">= aspnetcore-8.0"

Access AuthenticationStateProvider in outgoing request middleware

The xref:Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider from a xref:System.Net.Http.DelegatingHandler for xref:System.Net.Http.HttpClient created with xref:System.Net.Http.IHttpClientFactory can be accessed in outgoing request middleware using a circuit activity handler.

Note

For general guidance on defining delegating handlers for HTTP requests by xref:System.Net.Http.HttpClient instances created using xref:System.Net.Http.IHttpClientFactory in ASP.NET Core apps, see the following sections of xref:fundamentals/http-requests:

  • Outgoing request middleware
  • Use DI in outgoing request middleware

The following example uses xref:Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider to attach a custom user name header for authenticated users to outgoing requests.

First, implement the CircuitServicesAccessor class in the following section of the Blazor dependency injection (DI) article:

Access server-side Blazor services from a different DI scope

Use the CircuitServicesAccessor to access the xref:Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider in the xref:System.Net.Http.DelegatingHandler implementation.

AuthenticationStateHandler.cs:

public class AuthenticationStateHandler : DelegatingHandler
{
    readonly CircuitServicesAccessor circuitServicesAccessor;

    public AuthenticationStateHandler(
        CircuitServicesAccessor circuitServicesAccessor)
    {
        this.circuitServicesAccessor = circuitServicesAccessor;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var authStateProvider = circuitServicesAccessor.Services
            .GetRequiredService<AuthenticationStateProvider>();
        var authState = await authStateProvider.GetAuthenticationStateAsync();
        var user = authState.User;

        if (user.Identity is not null && user.Identity.IsAuthenticated)
        {
            request.Headers.Add("X-USER-IDENTITY-NAME", user.Identity.Name);
        }

        return await base.SendAsync(request, cancellationToken);
    }
}

In the Program file, register the AuthenticationStateHandler and add the handler to the xref:System.Net.Http.IHttpClientFactory that creates xref:System.Net.Http.HttpClient instances:

builder.Services.AddTransient<AuthenticationStateHandler>();

builder.Services.AddHttpClient("HttpMessageHandler")
    .AddHttpMessageHandler<AuthenticationStateHandler>();

:::moniker-end