Search code examples
c#blazor-webassemblygrpc-webduende-identity-serverms-yarp

Duende BFF Yarn does not pass tokens when using gRpc Web Client from Blazor WebAssembly app


I am building a web application with ASP.NET Core 6.
I have:

  1. Frontend.Client - a Blazor WebAssembly with the UI
  2. Frontend.Server - ASP.NET Core, hosting the Blazor WebAssembly
  3. Web Api - a remote REST Service
  4. gRpc Service - a remote gRpc Service
  5. Identity Provider - a Duende project using Duende.Bff.Yarp

My Frontend.Client is configured to call its own BFF (the Frontend.Server), while the server forwards the calls to the REST and gRpc services using Duende.Bff.YARP.
The calls to the REST service work as expected: the client passes the token automatically as by documentation.
My problem is with the calls to gRpc, which don't seem to use the correct HttpClient with the AntiForgeryToken and the Access Token as it should.
I know I'm missing some setting somewhere but I can't find any example on how to use Duende with a gRpcWebClient.

My Frontend.Client configuration contains:

builder.Services.AddScoped<AuthenticationStateProvider, BffAuthenticationStateProvider>();

builder.Services.AddTransient<AntiforgeryHandler>();

builder.Services.AddHttpClient("backend", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
    .AddHttpMessageHandler<AntiforgeryHandler>();
builder.Services.AddTransient(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("backend"));

builder.Services.AddSingleton(services => {
    var backendUrl = new Uri(builder.HostEnvironment.BaseAddress);
    var channel = GrpcChannel.ForAddress(backendUrl, new GrpcChannelOptions {
        HttpHandler = new GrpcWebHandler(new HttpClientHandler()),
    });
    return new Commenter.CommenterClient(channel);
});

My Frontend.Server configuration contains:

builder.Services.AddBff();
var proxyBuilder = builder.Services.AddReverseProxy().AddTransforms<AccessTokenTransformProvider>();
// Initialize the reverse proxy from the "ReverseProxy" section of configuration
proxyBuilder.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
builder.Services.AddAuthentication(options => {
    options.DefaultScheme = "cookie";
    options.DefaultChallengeScheme = "oidc";
    options.DefaultSignOutScheme = "oidc";
})
.AddCookie("cookie", options => {
    options.Cookie.Name = "__Host-blazor";
    options.Cookie.SameSite = SameSiteMode.Strict;
})
.AddOpenIdConnect("oidc", options => {
    options.Authority = "https://localhost:5007";

    options.ClientId = "photosharing.bff";
    options.ClientSecret = "A9B27D26-E71C-4C53-89A8-3DAB53CE1854";
    options.ResponseType = "code";
    options.ResponseMode = "query";

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("photosrest");
    options.Scope.Add("commentsgrpc");
    options.Scope.Add("offline_access");

    options.MapInboundClaims = false;
    options.GetClaimsFromUserInfoEndpoint = true;
    options.SaveTokens = true;
});

//code omitted for brevity

app.UseAuthentication();
app.UseBff();
app.UseAuthorization();
app.MapBffManagementEndpoints();

app.MapReverseProxy().AsBffApiEndpoint();

The appsettings.json file used to read the configuration contains:

"ReverseProxy": {
    "Routes": {
      "photosrestroute": {
        "ClusterId": "photosrestcluster",
        "Match": {
          "Path": "/photos/{*any}"
        },
        "Metadata": {
          "Duende.Bff.Yarp.TokenType": "User"
        }
      },
      "commentsgrpcroute": {
        "ClusterId": "commentsgrpccluster",
        "Match": {
          "Path": "/comments.Commenter/{*any}"
        },
        "Metadata": {
          "Duende.Bff.Yarp.TokenType": "User"
        }
      }
    },
    "Clusters": {
      "photosrestcluster": {
        "Destinations": {
          "photosrestdestination": {
            "Address": "https://localhost:5003/"
          }
        }
      },
      "commentsgrpccluster": {
        "Destinations": {
          "commentsgrpdestination": {
            "Address": "https://localhost:5005/"
          }
        }
      }
    }
  }

When my client calls the gRpc, I get a 401 Unauthorized response and Duende.Bff logs that the AntiForgery check did not pass, in fact the request does not have the header with the X-CSRF 1 (while the calls to the REST Api do). This would suggest that the gRpc client is not using the HTTP client that Duende uses.
How do I connect my gRpc client to Duende?

NOTE: before introducing the Authentication / Authorization bit, I was using YARP directly and the calls to gRpc were working just fine. It's when I added Duende that it broke.


Solution

  • The problem was the AntiforgeryHandler, since I had not added it to the chain of the HttpHandlers of my gRpcChannel. What I did to solve it was

    1. Add a constructor to my AntiforgeryHandler to accept an innerhandler and pass it to its base class
    2. Attach my AntiforgeryHandler to the chain of HttpHandlers turing the construction of the grpc client

    The AntiforgeryHandler becomes:

    namespace PhotoSharingApplication.Frontend.Client.DuendeAuth;
    
    public class AntiforgeryHandler : DelegatingHandler {
        public AntiforgeryHandler() { }
        public AntiforgeryHandler(HttpClientHandler innerHandler) : base(innerHandler) { }
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
        request.Headers.Add("X-CSRF", "1");
        return base.SendAsync(request, cancellationToken);
      }
    }
    

    The construction of the grpc client in my Frontend.Client project becomes:

    builder.Services.AddSingleton(services => {
        var backendUrl = new Uri(builder.HostEnvironment.BaseAddress);
        var channel = GrpcChannel.ForAddress(backendUrl, new GrpcChannelOptions {
            HttpHandler = new GrpcWebHandler(new AntiforgeryHandler(new HttpClientHandler())),
        });
        return new Commenter.CommenterClient(channel);
    });