File: Builder\OpaqueRedirection.cs
Web Access
Project: src\src\Components\Endpoints\src\Microsoft.AspNetCore.Components.Endpoints.csproj (Microsoft.AspNetCore.Components.Endpoints)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Microsoft.AspNetCore.Components.Endpoints;
 
internal partial class OpaqueRedirection
{
    // During streaming SSR, a component may try to perform a redirection. Since the response has already started
    // this can only work if we communicate the redirection back via some command that can get handled by JS,
    // rather than a true 301/302/etc. But we don't want to disclose the redirection target URL to JS because that
    // info would not normally be available, e.g., when using 'fetch'. So we data-protect the URL and round trip
    // through a special endpoint that can issue a true redirection.
    //
    // The same is used during enhanced navigation if it happens to go to a Blazor endpoint that calls
    // NavigationManager.NavigateTo, for the same reasons.
    //
    // However, if enhanced navigation goes to a non-Blazor endpoint, the server won't do anything special and just
    // returns a regular 301/302/etc. To handle this,
    //
    //  - If it's redirected to an internal URL, the browser will just follow the redirection automatically
    //    and client-side code will then:
    //    - Check if it went to a Blazor endpoint, and if so, simply update the client-side URL to match
    //    - Or if it's a non-Blazor endpoint, behaves like "external URL" below
    //  - If it's to an external URL:
    //    - If it's a GET request, the client-side code will retry as a non-enhanced request
    //    - For other request types, we have to let it fail as it would be unsafe to retry
 
    private const string RedirectionDataProtectionProviderPurpose = "Microsoft.AspNetCore.Components.Endpoints.OpaqueRedirection,V1";
    private const string RedirectionEndpointBaseRelativeUrl = "_framework/opaque-redirect";
 
    public static string CreateProtectedRedirectionUrl(HttpContext httpContext, string destinationUrl)
    {
        var protector = CreateProtector(httpContext);
        var options = httpContext.RequestServices.GetRequiredService<IOptions<RazorComponentsServiceOptions>>();
        var lifetime = options.Value.TemporaryRedirectionUrlValidityDuration;
        var protectedUrl = protector.Protect(destinationUrl, lifetime);
        return $"{RedirectionEndpointBaseRelativeUrl}?url={UrlEncoder.Default.Encode(protectedUrl)}";
    }
 
    public static EndpointBuilder GetBlazorOpaqueRedirectionEndpoint()
    {
        var routeEndpointBuidler = new RouteEndpointBuilder(
            OpaqueRedirect,
            RoutePatternFactory.Parse($"/{RedirectionEndpointBaseRelativeUrl}"),
            0)
        {
            DisplayName = "Blazor Opaque Redirection",
        };
 
        routeEndpointBuidler.Metadata.Add(new HttpMethodMetadata([HttpMethods.Get]));
 
        return routeEndpointBuidler;
    }
 
    private static Task OpaqueRedirect(HttpContext httpContext)
    {
        if (!httpContext.Request.Query.TryGetValue("url", out var protectedUrl))
        {
            httpContext.Response.StatusCode = 400;
            return Task.CompletedTask;
        }
 
        var protector = CreateProtector(httpContext);
        string url;
 
        try
        {
            url = protector.Unprotect(protectedUrl[0]!);
        }
        catch (CryptographicException ex)
        {
            if (httpContext.RequestServices.GetService<ILogger<OpaqueRedirection>>() is { } logger)
            {
                Log.OpaqueUrlUnprotectionFailed(logger, ex);
            }
 
            httpContext.Response.StatusCode = 400;
            return Task.CompletedTask;
        }
 
        httpContext.Response.Redirect(url);
        return Task.CompletedTask;
    }
 
    private static ITimeLimitedDataProtector CreateProtector(HttpContext httpContext)
    {
        var dataProtectionProvider = httpContext.RequestServices.GetRequiredService<IDataProtectionProvider>();
        return dataProtectionProvider.CreateProtector(RedirectionDataProtectionProviderPurpose).ToTimeLimitedDataProtector();
    }
 
    public static partial class Log
    {
        [LoggerMessage(1, LogLevel.Information, "Opaque URL unprotection failed.", EventName = "OpaqueUrlUnprotectionFailed")]
        public static partial void OpaqueUrlUnprotectionFailed(ILogger<OpaqueRedirection> logger, Exception exception);
    }
}