GRPC-web RPCException Bad gRPC response. Invalid content-type value: text/html; charset=utf-8

ghz 1years ago ⋅ 5327 views

Question

I got an error when trying to fetch gRPC API (using C#) to blazor client, at first it worked fine but after adding IdentityServer4 and use CORS for gRPC- Web similar like in the [docs](https://learn.microsoft.com/en- us/aspnet/core/grpc/browser?view=aspnetcore-5.0). Here's the code relevant to the error.
BackEnd/Startup.cs

namespace BackEnd
{
    public class Startup
    {
        public IWebHostEnvironment Environment { get; }
        public IConfiguration Configuration { get; }
        private string _clientId = null;
        private string _clientSecret = null;

        public Startup(IWebHostEnvironment environment, IConfiguration configuration)
        {
            Environment = environment;
            Configuration = configuration;
        }

        public void ConfigureServices(IServiceCollection services)
        {
            // Initialize certificate
            var cert = new X509Certificate2(Path.Combine(".", "IdsvCertificate.pfx"), "YouShallNotPass123");

            var migrationAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
            // The connection strings is in user secret
            string connectionString = Configuration["ConnectionStrings:DefaultConnection"];

            _clientId = Configuration["OAuth:ClientId"];
            _clientSecret = Configuration["OAuth:ClientSecret"];

            services.AddControllersWithViews();

            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseNpgsql(connectionString));

            services.AddIdentity<ApplicationUser, IdentityRole>(options => options.SignIn.RequireConfirmedAccount = true)
                .AddRoles<IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddClaimsPrincipalFactory<ClaimsFactory>()
                .AddDefaultTokenProviders();


            var builder = services.AddIdentityServer(options =>
            {
                options.Events.RaiseErrorEvents = true;
                options.Events.RaiseInformationEvents = true;
                options.Events.RaiseFailureEvents = true;
                options.Events.RaiseSuccessEvents = true;

                // see https://identityserver4.readthedocs.io/en/latest/topics/resources.html
                options.EmitStaticAudienceClaim = true;
                options.UserInteraction = new UserInteractionOptions() 
                { 
                    LoginUrl = "/Account/Login", 
                    LogoutUrl = "/Account/Logout" 
                };
            })
                .AddInMemoryIdentityResources(Config.IdentityResources)
                .AddInMemoryApiResources(Config.ApiResources)
                .AddInMemoryApiScopes(Config.ApiScopes)
                .AddInMemoryClients(Config.Clients)
                .AddProfileService<ProfileService>()
                .AddAspNetIdentity<ApplicationUser>()
                .AddConfigurationStore(options => 
                {
                    options.ConfigureDbContext = b => b.UseNpgsql(connectionString, 
                        sql => sql.MigrationsAssembly(migrationAssembly));
                })
                .AddOperationalStore(options => 
                {
                    options.ConfigureDbContext = b => b.UseNpgsql(connectionString, 
                        sql => sql.MigrationsAssembly(migrationAssembly));
                });

            // Add signed certificate to identity server
            builder.AddSigningCredential(cert);
            JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

            // Enable CORS for gRPC
            services.AddCors(o => o.AddPolicy("AllowAll", builder =>
            {
                builder.AllowAnyOrigin()
                    .AllowAnyMethod()
                    .AllowAnyHeader()
                    .WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding");
            }));

            // Add profile service
            services.AddScoped<IProfileService, ProfileService>();

            services.AddAuthentication()
                .AddGoogle("Google", options =>
                {
                    options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;

                    options.ClientId = _clientId;
                    options.ClientSecret = _clientSecret;
                    options.SaveTokens = true;
                    options.ClaimActions.MapJsonKey("role", "role");
                });

                services.AddAuthorization();

                services.AddGrpc(options => 
                {
                    options.EnableDetailedErrors = true;
                });
        }

        public void Configure(IApplicationBuilder app)
        {
            InitializeDatabase(app);

            if (Environment.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseStaticFiles();

            app.UseRouting();
            app.UseIdentityServer();
            app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
            app.UseAuthentication();
            app.UseCors("AllowAll");
            app.UseAuthorization();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGrpcService<UserService>().RequireCors("AllowAll");
                endpoints.MapDefaultControllerRoute().RequireAuthorization();
            });
        }
        
        // Based on IdentityServer4 document
        private void InitializeDatabase(IApplicationBuilder app)
        {
            using (var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope())
            {
                serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.Migrate();

                var context = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
                context.Database.Migrate();
                if (!context.Clients.Any())
                {
                    foreach (var client in Config.Clients)
                    {
                        context.Clients.Add(client.ToEntity());
                    }
                    context.SaveChanges();
                }

                if (!context.IdentityResources.Any())
                {
                    foreach (var resource in Config.IdentityResources)
                    {
                        context.IdentityResources.Add(resource.ToEntity());
                    }
                    context.SaveChanges();
                }

                if (!context.ApiScopes.Any())
                {
                    foreach (var resource in Config.ApiScopes)
                    {
                        context.ApiScopes.Add(resource.ToEntity());
                    }
                    context.SaveChanges();
                }
            }
        }
    }
}

BackEnd/Services/UserService.cs

namespace BackEnd
{
    [Authorize(Roles="User")]
    public class UserService : User.UserBase
    
    {
        private readonly ILogger<UserService> _logger;
        private readonly ApplicationDbContext _dataContext;
        public UserService(ILogger<UserService> logger, ApplicationDbContext dataContext)
        {
            _logger = logger;
            _dataContext = dataContext;
        }

        public override async Task<Empty> GetUser(UserInfo request, ServerCallContext context)
        {
            var response = new Empty();
            var userList = new UserResponse();

            if (_dataContext.UserDb.Any(x => x.Sub == request.Sub))
            {
                var newUser = new UserInfo(){ Id = userList.UserList.Count, Sub = request.Sub, Email = request.Email };

                _dataContext.UserDb.Add(newUser);
                userList.UserList.Add(newUser);

                await _dataContext.SaveChangesAsync();
            }
            else
            {
                var user = _dataContext.UserDb.Single(u => u.Sub == request.Sub);
                userList.UserList.Add(user);
            }
            
            return await Task.FromResult(response);
        }

        public override async Task<ToDoItemList> GetToDoList(UuidParameter request, ServerCallContext context)
        {
            var todoList = new ToDoItemList();
            var userInfo = new UserInfo();

            var getTodo = (from data in _dataContext.ToDoDb
                           where data.Uuid == userInfo.Sub
                           select data).ToList();

            todoList.ToDoList.Add(getTodo);

            return await Task.FromResult(todoList);
        }

        public override async Task<Empty> AddToDo(ToDoStructure request, ServerCallContext context)
        {
            var todoList = new ToDoItemList();
            var userInfo = new UserInfo();
            var newTodo = new ToDoStructure()
            {
                Id = todoList.ToDoList.Count,
                Uuid = request.Uuid,
                Description = request.Description,
                IsCompleted = false
            };

            todoList.ToDoList.Add(newTodo);
            await _dataContext.ToDoDb.AddAsync(newTodo);
            await _dataContext.SaveChangesAsync();

            return await Task.FromResult(new Empty());
        }

        public override async Task<Empty> PutToDo(ToDoStructure request, ServerCallContext context)
        {
            var response = new Empty();
            _dataContext.ToDoDb.Update(request);
            await _dataContext.SaveChangesAsync();

            return await Task.FromResult(response);
        }

        public override async Task<Empty> DeleteToDo(DeleteToDoParameter request, ServerCallContext context)
        {
            var item = (from data in _dataContext.ToDoDb
                        where data.Id == request.Id
                        select data).First();
                        
            _dataContext.ToDoDb.Remove(item);
            var result = await _dataContext.SaveChangesAsync();

            return await Task.FromResult(new Empty());
            
        }
    } 
}

FrontEnd/Program.cs

namespace FrontEnd
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");

            builder.Services.AddScoped(sp => new HttpClient()
                { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

            // Connect server to client
            builder.Services.AddScoped(services => 
            {
                var baseAddressMessageHandler = services.GetRequiredService<AuthorizationMessageHandler>()
                    .ConfigureHandler(
                        authorizedUrls: new[] { "https://localhost:5001" },
                        scopes: new[] { "todoApi" }
                    );
                baseAddressMessageHandler.InnerHandler = new HttpClientHandler();
                var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler());
                var channel = GrpcChannel.ForAddress("https://localhost:5000", new GrpcChannelOptions
                    { 
                        HttpHandler = httpHandler
                    });

                return new User.UserClient(channel);
            });
            
            // Add Open-ID Connect authentication
            builder.Services.AddOidcAuthentication(options =>
            {
                builder.Configuration.Bind("Authentication:Google", options.ProviderOptions);
                options.ProviderOptions.DefaultScopes.Add("role");
                options.UserOptions.RoleClaim = "role";  // Important to get role claim
            }).AddAccountClaimsPrincipalFactory<CustomUserFactory>();

            builder.Services.AddOptions();
            
            builder.Services.AddAuthorizationCore();

            await builder.Build().RunAsync();

        }
    }
}

FrontEnd/Pages/ToDoList.razor.cs

namespace FrontEnd.Pages
{
    public partial class TodoList
    {
        [Inject]
        private User.UserClient UserClient { get; set; }
        [Inject]
        private IJSRuntime JSRuntime { get; set; }
        [CascadingParameter] 
        public Task<AuthenticationState> authenticationStateTask { get; set; }
        public string Description { get; set; }
        public string ToDoDescription { get; set; }
        public RepeatedField<ToDoStructure> ServerToDoResponse { get; set; } = new RepeatedField<ToDoStructure>();

        protected override async Task OnInitializedAsync()
        {
            var authState = await authenticationStateTask;
            var user = authState.User;
            Console.WriteLine($"IsAuthenticated: {user.Identity.IsAuthenticated} |  IsUser: {user.IsInRole("User")}");

            if (user.Identity.IsAuthenticated && user.IsInRole("User"))
            {
                await GetUser(); // Error when trying to call this function
            }
        }

        // Fetch usser from server
        public async Task GetUser()
        {
            var authState = await authenticationStateTask;
            var user = authState.User;
            var userRole = user.IsInRole("User");
            var userUuid = user.Claims.FirstOrDefault(c => c.Type == "preferred_username").Value;
            var subjectId = user.Claims.FirstOrDefault(c => c.Type == "sub").Value;
            var userEmail = user.Claims.FirstOrDefault(c => c.Type == "email").Value;
            var request = new UserInfo(){ Sub = subjectId, Email = userEmail };

            await UserClient.GetUserAsync(request);
            await InvokeAsync(StateHasChanged);
            await GetToDoList();
        }

        // Fetch to-do list from server
        private async Task GetToDoList()
        {
            var authState = await authenticationStateTask;
            var user = authState.User;
            var userUuid = user.Claims.FirstOrDefault(c => c.Type == "preferred_username").Value;
            var request = new UuidParameter(){ Uuid = userUuid };
            var response = await UserClient.GetToDoListAsync(request);
            ServerToDoResponse = response.ToDoList;
        }

        // Add to-do list to the server
        public async Task AddToDo(KeyboardEventArgs e)
        {
            var authState = await authenticationStateTask;
            var user = authState.User;
            var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;

            if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(Description) || 
                e.Key == "NumpadEnter" && !string.IsNullOrWhiteSpace(Description))
            {
                var request = new ToDoStructure()
                { 
                    Uuid = userUuid, 
                    Description = this.Description, 
                };
                await UserClient.AddToDoAsync(request);
                await InvokeAsync(StateHasChanged);
                await GetToDoList();
            } 
        }

        // Update the checkbox state of the to-do list
        public async Task PutToDoIsCompleted(int id, string description, bool isCompleted, MouseEventArgs e)
        {
            if (isCompleted == false && e.Button== 0)
            {
                isCompleted = true;
            } 
            else if (isCompleted == true && e.Button == 0)
            {
                isCompleted = false;
            }

            var request = new ToDoStructure()
            { 
                Id = id, 
                Description = description, 
                IsCompleted = isCompleted 
            };

            await UserClient.PutToDoAsync(request);
            await GetToDoList();
        }

        // Edit mode function
        private async Task EditToDo(int todoId, string description, bool isCompleted)
        {
            var authState = await authenticationStateTask;
            var user = authState.User;
            var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;
            // Get the index of the to-do list
            int grpcIndex = ServerToDoResponse.IndexOf(new ToDoStructure() 
            { 
                Id = todoId, 
                Uuid = userUuid,
                Description = description, 
                IsCompleted = isCompleted
            });

            ToDoDescription = ServerToDoResponse[grpcIndex].Description;

            // Make text area appear and focus on text area and edit icon dissapear based on the to-do list index
            await JSRuntime.InvokeVoidAsync("editMode", "edit-icon", "todo-description", "edit-todo", grpcIndex);
            await JSRuntime.InvokeVoidAsync("focusTextArea", todoId.ToString(), ToDoDescription);
        }

        // Update the to-do description
        public async Task PutToDoDescription(int id, string htmlId, string oldDescription, string newDescription, bool isCompleted)
        {
            var authState = await authenticationStateTask;
            var user = authState.User;
            var userUuid = user.Claims.FirstOrDefault(c => c.Type == "Sub").Value;
            var request = new ToDoStructure()
            { 
                Id = id, 
                Uuid = userUuid,
                Description = newDescription, 
            };

            int grpcIndex = ServerToDoResponse.IndexOf(new ToDoStructure() 
            { 
                Id = id, 
                Description = oldDescription, 
                IsCompleted = isCompleted
            });

            // Text area auto resize function
            await JSRuntime.InvokeVoidAsync("theRealAutoResize", htmlId);
            // Make text area display to none and edit icon appear base on the to-do list index
            await JSRuntime.InvokeVoidAsync("initialMode", "edit-icon", "todo-description", "edit-todo", grpcIndex);
            await UserClient.PutToDoAsync(request);
            await GetToDoList();
        }

        // Delete to-do
        public async Task DeleteToDo(int id)
        {
            var request = new DeleteToDoParameter(){ Id = id };
            
            await UserClient.DeleteToDoAsync(request);
            await GetToDoList();
        }
    }
}

This is the output of the console

Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: Status(StatusCode="Cancelled", Detail="Bad gRPC response. Invalid content-type value: text/html; charset=utf-8")
Grpc.Core.RpcException: Status(StatusCode="Cancelled", Detail="Bad gRPC response. Invalid content-type value: text/html; charset=utf-8")
   at FrontEnd.Pages.TodoList.GetUser() in C:\Users\bryan\source\repos\Productivity_App\frontend\Pages\TodoList.razor.cs:line 50
   at FrontEnd.Pages.TodoList.OnInitializedAsync() in C:\Users\bryan\source\repos\Productivity_App\frontend\Pages\TodoList.razor.cs:line 35
   at Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle)

This is the output in the terminal when trying to authenticate with IdentityServer4 (the authentication and authorization is working fine though)

[21:11:15 Debug] Grpc.AspNetCore.Web.Internal.GrpcWebMiddleware
Detected gRPC-Web request from content-type 'application/grpc-web'.

[21:11:15 Information] Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler
AuthenticationScheme: Identity.Application was challenged.

[21:11:15 Debug] IdentityServer4.Hosting.CorsPolicyProvider
CORS request made for path: /Account/Login from origin: https://localhost:5001 but was ignored because path was not for an allowed IdentityServer CORS endpoint

Answer

You can't do OpenID Connect authentication as part of gRPC, the user must have first authenticated on your web site and you should then have received the access token.

Then you can send the access token with gRPC to the API. If you then get a 401 http status back, then you need to refresh(get a new one) the access token.

To make your life easier and to reduce complexity and your sanity, I recommend that you put IdentityServer in its own service, standalone from the client/api. Otherwise its hard to reason about the system and it will be very hard to debug.

My recommendation is that you have this architecture, in three different services:

enter image description
here

gRPC is just a transport, similar to HTTP and in the API, you have this basic architecture (slide taken from one of my training classes):

enter image description
here

The JwtBearer will examine the access token to verify who you are and after that the authorization module takes over and check if you are allowed in.