DuendeSoftware / Support

Support for Duende Software products
21 stars 0 forks source link

Bloated Cookie Sizes in Client Applications #461

Closed StuFrankish closed 1 year ago

StuFrankish commented 1 year ago

Which version of Duende IdentityServer are you using? Duende.IdentityServer (AspNetIdentity, Storage & EntityFramework) 6.2.1

Which version of .NET are you using? .Net 7.0.1

Describe the bug The client appears to be trying to create or at least negotiate with Identity Server with a header size that exceeds the 8KB limit in our Akamai WAF, which is resulting in a 502 Error when Identity Server redirects to /signin-oidc

We have turned off AlwaysIncludeUserClaimsInIdToken in the Client Entity and have configured our client to get claims from the User Info Endpoint but are still seeing issues when logging into this specific client, only once deployed as an Azure App Service.

The client is a hybrid Vue app with an MVC backend that is handling the authentication etc..

From our program.cs, we're calling services.ConfigureAuthentication(builder.Configuration); Which in the non-working client is defined as so;

public static void ConfigureAuthentication(this IServiceCollection services, ConfigurationManager configuration)
{
    var sentinelOptions = new SentinelOptions();
    configuration.Bind(SentinelOptions.ConfigurationKey, sentinelOptions);

    services
        .AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
        })
        .AddCookie()
        .AddOpenIdConnect(options =>
        {
            options.Authority = sentinelOptions.BaseUrl;
            options.ClientId = sentinelOptions.AppClientId;
            options.ClientSecret = sentinelOptions.AppClientSecret;
            options.SaveTokens = true;
            options.ResponseType = OpenIdConnectResponseType.Code;
            options.CallbackPath = "/signin-oidc";
            options.UsePkce = true;
            options.Scope.Add("openid");
            options.Scope.Add("profile");
            options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
            options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
            options.GetClaimsFromUserInfoEndpoint = true;
        });
}

Our working clients are full MVC applications which are configured like this;

services.AddAuthentication(options => {
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie(authenticationScheme: "Cookies", (options) => {
    options.Events.OnSigningIn = FilterGroupClaimsAsync;
    options.LoginPath = "/Account/SignIn/";
    options.LogoutPath = "/Account/SignOut";
    options.AccessDeniedPath = "/Account/AccessDenied";
    options.SlidingExpiration = true;
    options.ReturnUrlParameter = "origin";
    options.SessionStore = new RedisCacheTicketStore(options: new() {
        Configuration = Configuration.GetValue<string>(key: "Azure:Redis:ConnectionString")
    });
})
.AddOpenIdConnect(authenticationScheme: "oidc", options => {
    Configuration.Bind(key: "Sentinel", options);
    options.SetCustomOidcOptions();
});

public static void SetCustomOidcOptions(this OpenIdConnectOptions options)
{
    options.Events = new()
    {
        OnRemoteFailure = failureContext => {
            failureContext.HandleResponse();

            switch (failureContext.Failure.Message)
            {
                case "Correlation failed.":
                    failureContext.Response.Redirect(location: "/account/signout");
                    break;

                default:
                    var baseException = failureContext.Failure.GetBaseException();
                    failureContext.Response.WriteAsync(baseException.Message);
                    break;
            }

            return Task.FromResult(0);
        }
    };

    options.TokenValidationParameters = new()
    {
        NameClaimType = SentinelClaimType.Name,
        RoleClaimType = SentinelClaimType.Role
    };

    options.SignInScheme = "Cookies";
    options.ResponseType = "code";
    options.UsePkce = true;
    options.Scope.Add(item: "openid");
    options.Scope.Add(item: "profile");
    options.Scope.Add(item: "roles");
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;

    options.SetCustomClaimActions();
}

I'm not sure if we're missing something else here as the bits I think matter, are all the same?

Expected behavior User is able to establish a Single Sign On session in any of our other applications, navigate to this Vue application, Identity Server authenticates the single sign on session and the client application saves a sparse cookie as the client application is designed to pull and cache them using IMemoryCache as needed.

Because of how our line of business applications operate, Identity Server is acting as a complete repository for user information, where users can have potentially hundreds of associated claims in the dbo.UserClaims table, the majority of which are role type claims.

We are going to try configuring AddCookie() to use a Redis service as well like our working app, but at this point I'm not learned enough to understand the full process. Hoping to learn a bit more when I attend the NDC Workshop end of this month run by @josephdecock - possibly ask if our usage (stashing mountains of role claims) is atypical, bad design/misunderstanding etc..

leastprivilege commented 1 year ago

You are using

options.SaveTokens = true;

in your configuration. This instructs the asp.net core handler to store the id_token, access_token and refresh_token in the cookie (in addition to your claims). This can lead to the problems you are seeing.

So either you need to reduce the size of all these artefacts, or store them somewhere else.

The Microsoft cookie handler has support for storing the cookie content server side.

StuFrankish commented 1 year ago

Hi @leastprivilege, thanks for your really quick reply!

You are using

options.SaveTokens = true;

in your configuration. This instructs the asp.net core handler to store the id_token, access_token and refresh_token in the cookie (in addition to your claims). This can lead to the problems you are seeing. So either you need to reduce the size of all these artefacts, or store them somewhere else. The Microsoft cookie handler has support for storing the cookie content server side.

We are planning to store the cookie using Redis cache - is the recommendation here then to turn off options.SaveTokens which we don't set in our working clients and allow our redis store to manage the cookie content?

leastprivilege commented 1 year ago

This depends how you plug-in Redis. Just do it like you did with your other working clients I guess.

josephdecock commented 1 year ago

Yeah, if you disable SaveTokens then after login you no longer will have any tokens. So you're only using OIDC to start your session. You won't have an access token to make api calls, etc. You'll have to decide if that's what you want, but a thing to keep in mind is that, even if you don't need access tokens to make api calls, if you want to call the end_session endpoint at IdentityServer, you probably will want the Id Token to send as part of that request.

StuFrankish commented 1 year ago

Hi @josephdecock & @leastprivilege, We're struggling to get this app to work properly - the application is logging that it's receiving a status code of 400 from our Identity Server (trace below).

We've amended the application to use Redis for session storage, filtered out all the non-applicable claims and we're still getting errors for some users.

We have a test user with very few role type claims, this test user can sign in without a problem. But any other user with a realistic number of role type claims is receiving an error.

Identity Server authenticates the session and returns the user to /signin-oidc as expected. but we still end up with a 502 error. Attempting to refresh the page results in;

{
    "message": "An error was encountered while handling the remote login.",
    "stackTrace": "   at async Task<bool> Microsoft.AspNetCore.Authentication.RemoteAuthenticationHandler<TOptions>.HandleRequestAsync()\n   at async Task Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)\n   at async Task Web.Exceptions.ExceptionHandlerMiddleware.Invoke(HttpContext context) in /home/vsts/work/1/s/Web/Exceptions/ExceptionHandlerMiddleware.cs:line 34",
    "userVisibleMessage": null
}
2023-01-24T09:57:10.851464867Z fail: Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler[52]
2023-01-24T09:57:10.851543367Z       Message contains error: 'invalid_grant', error_description: 'error_description is null', error_uri: 'error_uri is null', status code '400'.
2023-01-24T09:57:10.901729944Z fail: Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler[17]
2023-01-24T09:57:10.901795544Z       Exception occurred while processing message.
2023-01-24T09:57:10.903055848Z       Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectProtocolException: Message contains error: 'invalid_grant', error_description: 'error_description is null', error_uri: 'error_uri is null'.
2023-01-24T09:57:10.903076048Z          at Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler.RedeemAuthorizationCodeAsync(OpenIdConnectMessage tokenEndpointRequest)
2023-01-24T09:57:10.903082749Z          at Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler.HandleRemoteAuthenticateAsync()
2023-01-24T09:57:11.049675563Z fail: Web.Exceptions.ExceptionHandlerMiddleware[0]
2023-01-24T09:57:11.049822064Z       An unexpected error occurred
2023-01-24T09:57:11.049834064Z       System.Exception: An error was encountered while handling the remote login.
2023-01-24T09:57:11.051097168Z        ---> Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectProtocolException: Message contains error: 'invalid_grant', error_description: 'error_description is null', error_uri: 'error_uri is null'.
2023-01-24T09:57:11.051119668Z          at async Task<OpenIdConnectMessage> Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler.RedeemAuthorizationCodeAsync(OpenIdConnectMessage tokenEndpointRequest)
2023-01-24T09:57:11.067517526Z          at async Task<HandleRequestResult> Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler.HandleRemoteAuthenticateAsync()
2023-01-24T09:57:11.067540026Z          --- End of inner exception stack trace ---
2023-01-24T09:57:11.067549226Z          at async Task<bool> Microsoft.AspNetCore.Authentication.RemoteAuthenticationHandler<TOptions>.HandleRequestAsync()
2023-01-24T09:57:11.067555126Z          at async Task Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
2023-01-24T09:57:11.067560126Z          at async Task Web.Exceptions.ExceptionHandlerMiddleware.Invoke(HttpContext context) in /home/vsts/work/1/s/Web/Exceptions/ExceptionHandlerMiddleware.cs:line 34

I've pulled the trace level logs from our Identity Server, which are below - I've tried to capture as much of the stack as relevant as possible; Removed: https://pastebin.com/s9Sj5pi2

josephdecock commented 1 year ago

The identity server logs will tell you why you're getting the invalid grant error. Unfortunately your pastebin link isn't working for me, but there should be part of the log that says that validation failed and why. Can you put that part of the log into the thread please?

StuFrankish commented 1 year ago

The identity server logs will tell you why you're getting the invalid grant error. Unfortunately your pastebin link isn't working for me, but there should be part of the log that says that validation failed and why. Can you put that part of the log into the thread please?

Hi @josephdecock

Sorry, I thought the PasteBin was available just de-listed. Here's the log file from Identity Server. s9Sj5pi2.txt

josephdecock commented 1 year ago

@StuFrankish I don't think you have the right part of the identity server log file here. The exception you posted from the client side is timestamped 9:57:10, but the log file starts after that, at 10:04:47. Also, there's no need to include this much trace information from the logs - when identity server generates an error response, it logs the reason why typically at the warning or error level. I'd expect that the validation failure in IdentityServer would happen first - it logs the validation problem before sending the response. Only after that response arrives at the client would it see and log its error.

StuFrankish commented 1 year ago

@josephdecock I do apologize for the massive trace log - I've been struggling to find the issue as the the logs all appear to indicate a completely normal validation and return, so hoped you might be able to spot something.

The logs, regardless of the time stamp differences are all the same;

Identity Server appears to complete an absolutely fine authentication and validation, then pass back to the client at /signin-oidc. No errors are raised anywhere and nothing that includes the word grant other than PersistedGrants which is unrelated.

No errors are raised by the client application either until I attempt to refresh the page after being redirected to the broken /signin-oidc - so I think the invalid_grant I'm getting is a result of the manual refresh, rather than the actual root cause, which I'm still investigating.

josephdecock commented 1 year ago

I just keep coming back to the fact that the exception from the client application is saying that the request to the token endpoint failed and returned an invalid grant error. That means identityServer rejected the code exchange, and that should definitely be logged by IdentityServer.

StuFrankish commented 1 year ago

I'm closing this as shifting to a pattern where we hook into the Events.OnSigningIn event to filter the claims there, in addition to pushing session state into Redis, appears to have resolved this issue for us.

We've left SaveTokens switched on as we will want to be making API calls with the Access Token in future updates.