DuendeSoftware / Support

Support for Duende Software products
21 stars 0 forks source link

Blazor BFF InvalidOperationException when refreshing tokens #34

Closed jkdba closed 2 years ago

jkdba commented 2 years ago

Which version of Duende BFF are you using?

1.2.1

Which version of .NET are you using? 6.0

Describe the bug

Blazor Server Side app, configured with AddBff and AddServerSideSessions, using http client loaded into DI with AddUserAccessTokenHttpClient, open ID connect configured to for a local Duende IDP authority, with offline access and save tokens true.

The client is configured on the IDP with a low access token life time of 120 seconds for testing refresh token behavior.

the HttpClient is used to fetch data from a remote api on the users behalf from blazor server to an aspnet core web api,

after login the user is able to successfully browse to page that calls the api passing its token along with the BFF token management client, after a 60 second period where the token is now eligible for refresh by the BFF token management code, the token is successfully refresh but an exception is throw when it attempts to update the User's session cookie using HttpContext.SignInAsync().

"The response headers cannot be modified because the response has already started."

To Reproduce

configure a blazor server side app with open id connect, bff, serversidesessions (optional happens regardless), AddUserAccessTokenHttpClient, refresh tokens wait for token to expire and let the AddUserAccessTokenHttpClient refresh token and cause the issue.

Expected behavior

AddUserAccessTokenHttpClient is able to update the cookie successfully.

Log output/exception with stacktrace

 Microsoft.AspNetCore.Components.Server.Circuits.RemoteRenderer[100]
      Unhandled exception rendering component: Headers are read-only, response has already started.
      System.InvalidOperationException: Headers are read-only, response has already started.
         at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpHeaders.ThrowHeadersReadOnlyException()
         at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpResponseHeaders.Microsoft.AspNetCore.Http.IHeaderDictionary.set_SetCookie(StringValues value)
         at Microsoft.AspNetCore.Http.ResponseCookies.Append(String key, String value, CookieOptions options)
         at Microsoft.AspNetCore.Authentication.Cookies.ChunkingCookieManager.AppendResponseCookie(HttpContext context, String key, String value, CookieOptions options)
         at Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler.HandleSignInAsync(ClaimsPrincipal user, AuthenticationProperties properties)
         at Microsoft.AspNetCore.Authentication.AuthenticationService.SignInAsync(HttpContext context, String scheme, ClaimsPrincipal principal, AuthenticationProperties properties)
         at IdentityModel.AspNetCore.AccessTokenManagement.AuthenticationSessionUserAccessTokenStore.StoreTokenAsync(ClaimsPrincipal user, String accessToken, DateTimeOffset expiration, String refreshToken, UserA
ccessTokenParameters parameters) in D:\a\IdentityModel.AspNetCore\IdentityModel.AspNetCore\src\AccessTokenManagement\UserAccessToken\AuthenticationSessionUserTokenStore.cs:line 180
         at IdentityModel.AspNetCore.AccessTokenManagement.UserAccessAccessTokenManagementService.RefreshUserAccessTokenAsync(ClaimsPrincipal user, UserAccessTokenParameters parameters, CancellationToken cancella
tionToken) in D:\a\IdentityModel.AspNetCore\IdentityModel.AspNetCore\src\AccessTokenManagement\UserAccessToken\UserAccessTokenManagementService.cs:line 145
         at IdentityModel.AspNetCore.AccessTokenManagement.UserAccessAccessTokenManagementService.<>c__DisplayClass7_0.<<GetUserAccessTokenAsync>b__1>d.MoveNext() in D:\a\IdentityModel.AspNetCore\IdentityModel.As
pNetCore\src\AccessTokenManagement\UserAccessToken\UserAccessTokenManagementService.cs:line 99
      --- End of stack trace from previous location ---
         at IdentityModel.AspNetCore.AccessTokenManagement.UserAccessAccessTokenManagementService.GetUserAccessTokenAsync(ClaimsPrincipal user, UserAccessTokenParameters parameters, CancellationToken cancellation
Token) in D:\a\IdentityModel.AspNetCore\IdentityModel.AspNetCore\src\AccessTokenManagement\UserAccessToken\UserAccessTokenManagementService.cs:line 95
         at Microsoft.AspNetCore.Authentication.TokenManagementHttpContextExtensions.GetUserAccessTokenAsync(HttpContext httpContext, UserAccessTokenParameters parameters, CancellationToken cancellationToken) in
D:\a\IdentityModel.AspNetCore\IdentityModel.AspNetCore\src\AccessTokenManagement\TokenManagementHttpContextExtensions.cs:line 31
         at IdentityModel.AspNetCore.AccessTokenManagement.UserAccessTokenHandler.SetTokenAsync(HttpRequestMessage request, Boolean forceRenewal) in D:\a\IdentityModel.AspNetCore\IdentityModel.AspNetCore\src\Acce
ssTokenManagement\UserAccessToken\UserAccessTokenHandler.cs:line 67
         at IdentityModel.AspNetCore.AccessTokenManagement.UserAccessTokenHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) in D:\a\IdentityModel.AspNetCore\IdentityModel.AspNetCo
re\src\AccessTokenManagement\UserAccessToken\UserAccessTokenHandler.cs:line 35
         at Microsoft.Extensions.Http.Logging.LoggingScopeHttpMessageHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
         at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRe
questsCts, CancellationToken originalCancellationToken)
         at BlazorServerBFF.Pages.FetchData.OnInitializedAsync() in C:\Users\jklann\Downloads\duende\Samples-main\IdentityServer\v6\BFF\BlazorWasm\BlazorServerBFF\Pages\FetchData.razor:line 50
         at Microsoft.AspNetCore.Components.ComponentBase.RunInitAndSetParametersAsync()
         at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle, ComponentState owningComponentState)

Additional context

tested in IIS Express and Kestrel, wrote my own delegate handler to mimic the basic implementation of the BFF automatic token management httpclient handler and resulted with the same error.

happy to provide a sample project if needed.

Program.cs of blazor app

using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

builder.Services.AddBff()
    .AddServerSideSessions();

builder.Services.AddUserAccessTokenHttpClient("test", null,
    client => { client.BaseAddress = new Uri("https://localhost:5555/"); });

builder.Services.AddUserAccessTokenHttpClient("testlocal", null,
    client => { client.BaseAddress = new Uri("https://localhost:7255/"); });

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
        options.DefaultSignOutScheme = OpenIdConnectDefaults.AuthenticationScheme;

    }).AddCookie(options =>
    {
        options.Cookie.Name = "_MF_bff-cookie";
        options.Cookie.SameSite = SameSiteMode.Strict;
    })
    .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
        {
            options.Authority = "https://localhost:44324/";
            options.ClientId = "client";
            options.ClientSecret = "secret";
            options.ResponseType = "code";
            options.ResponseMode = "query";

            options.Scope.Clear();
            options.Scope.Add("openid");
            options.Scope.Add("profile");
            options.Scope.Add("api");
            options.Scope.Add("offline_access");
            options.Scope.Add("roles");

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

            options.TokenValidationParameters = new TokenValidationParameters
            {
                NameClaimType = "name",
                RoleClaimType = "role"
            };

            options.ClaimActions.MapJsonKey("name", "name");
            options.ClaimActions.MapJsonKey("role", "role");

            options.Events.OnRedirectToIdentityProvider = n =>
            {
                n.ProtocolMessage.AcrValues = "idp:Windows";
                return Task.CompletedTask;
            };
        }
    );

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor().AddCircuitOptions(options => { options.DetailedErrors = true; });

builder.WebHost.UseWebRoot("wwwroot");
builder.WebHost.UseStaticWebAssets();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseRouting();

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

app.MapBffManagementEndpoints();

app.MapBlazorHub();

app.MapControllers()
    .RequireAuthorization()
    .AsBffApiEndpoint();

app.UseStaticFiles();

app.MapFallbackToPage("/_Host");

app.Run();
leastprivilege commented 2 years ago

The BFF framework does not make sense for Blazor Server since you are already on the server. You need to treat that more like a traditional MVC style application with server-side controllers.

Really the only useful part here would be the token management - which comes from https://github.com/IdentityModel/IdentityModel.AspNetCore

Due to the way MS has implemented the communication between the UI and back-end, you cannot use traditional cookie-based sessions to store tokens - here's a sample how to work around that:

https://github.com/IdentityModel/IdentityModel.AspNetCore/tree/main/samples/BlazorServer

The store implementation probably needs more work to be "production ready".

jkdba commented 2 years ago

Thanks for the follow up, this makes sense for the workaround.