13

I'm developing an application in ASP.NET Core 2.1, and running it on a Kubernetes cluster. I've implemented authentication using OpenIDConnect, using Auth0 as my provider.

This all works fine. Actions or controllers marked with the [Authorize] attribute redirect anonymous user to the identity provider, they log in, redirects back, and Bob's your uncle.

The problems start occurring when I scale my deployment to 2 or more containers. When a user visits the application, they log in, and depending on what container they get served during the callback, authentication either succeeds or fails. Even in the case of authentication succeeding, repeatedly F5-ing will eventually redirect to the identity provider when the user hits a container they aren't authorized on.

My train of thought on this would be that, using cookie authentication, the user stores a cookie in their browser, that gets passed along with each request, the application decodes it and grabs the JWT, and subsequently the claims from it, and the user is authenticated. This makes the whole thing stateless, and therefore should work regardless of the container servicing the request. As described above however, it doesn't appear to actually work that way.

My configuration in Startup.cs looks like this:

services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie()
    .AddOpenIdConnect("Auth0", options =>
    {
        options.Authority = $"https://{Configuration["Auth0:Domain"]}";

        options.ClientId = Configuration["Auth0:ClientId"];
        options.ClientSecret = Configuration["Auth0:ClientSecret"];

        options.ResponseType = "code";

        options.Scope.Clear();
        options.Scope.Add("openid");
        options.Scope.Add("profile");
        options.Scope.Add("email");

        options.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = "name"
        };

        options.SaveTokens = true;

        options.CallbackPath = new PathString("/signin-auth0");

        options.ClaimsIssuer = "Auth0";

        options.Events = new OpenIdConnectEvents
        {
            OnRedirectToIdentityProviderForSignOut = context =>
            {
                var logoutUri =
                    $"https://{Configuration["Auth0:Domain"]}/v2/logout?client_id={Configuration["Auth0:ClientId"]}";

                var postLogoutUri = context.Properties.RedirectUri;
                if (!string.IsNullOrEmpty(postLogoutUri))
                {
                    if (postLogoutUri.StartsWith("/"))
                    {
                        var request = context.Request;
                        postLogoutUri = request.Scheme + "://" + request.Host + request.PathBase +
                                        postLogoutUri;
                    }

                    logoutUri += $"&returnTo={Uri.EscapeDataString(postLogoutUri)}";
                }

                context.Response.Redirect(logoutUri);
                context.HandleResponse();

                return Task.CompletedTask;
            },
            OnRedirectToIdentityProvider = context =>
            {
                context.ProtocolMessage.SetParameter("audience", "https://api.myapp.com");

                // Force the scheme to be HTTPS, otherwise we end up redirecting back to HTTP in production.
                // They should seriously make it easier to make Kestrel serve over TLS in the same way ngninx does...
                context.ProtocolMessage.RedirectUri = context.ProtocolMessage.RedirectUri.Replace("http://",
                    "https://", StringComparison.OrdinalIgnoreCase);

                Debug.WriteLine($"RedirectURI: {context.ProtocolMessage.RedirectUri}");

                return Task.FromResult(0);
            }
        };
    });

I've spent hours trying to address this issue, and came up empty. The only thing I can think of that could theoretically work now is using sticky load balancing, but that's more applying a band-aid than actually fixing the problem.

One of the main reasons to use Kubernetes is its resilience and ability to handle scaling very well. As it stands, I can only scale my backing services, and my main application would have to run as a single pod. That's far from ideal.

Perhaps there is some mechanism somewhere that creates affinity with a specific instance that I'm not aware of?

I hope someone can point me in the right direction.

Thanks!

aevitas
  • 3,753
  • 2
  • 28
  • 39

2 Answers2

16

The cookie issued by authentication is encrypted via Data Protection. Data Protection by default is scoped to a particular application, or instance thereof. If you need to share an auth cookie between instances, you need to ensure that the data protection keys are persisted to a common location and that the application name is the same.

services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo(@"\\server\share\directory\"))
    .SetApplicationName("MyApp");

You can find more info in the docs.

Chris Pratt
  • 232,153
  • 36
  • 385
  • 444
  • You're an absolute lifesaver! I set it up to persist in Azure Blob Storage. Considering the container the key resides in is private, and the SAS is kept a secret, is it required to encrypt the keys? It seems surplus to me, as the keys are encrypted in transport because of HTTPS, and the only other way would be to have access to the Azure portal, at which point encryption isn't going to help either? – aevitas Nov 27 '18 at 17:38
  • Always encrypt keys at rest. I can't remember which company it was now, but there was just a story in the news about a company that leaked their storage. Never assume that storage is safe. – Chris Pratt Nov 27 '18 at 17:48
  • @aevitas There is an additional option you might wish to consider. Kubernetes can be configured to automatically handle session affinity for users on a per client basis. Essentially, this will result in a given user being sent to the same instance of a container based on the criteria you choose. – Matthew Brubaker Dec 05 '18 at 17:01
  • 4
    @MatthewBrubaker: that's called "sticky sessions", and they're an anti-pattern. It serves to partly negate the benefits of having multiple instances, and completely kills failover. – Chris Pratt Dec 05 '18 at 17:23
  • @ChrisPratt I agree that sticky sessions are a code smell (a pretty bad one at that), but I would not go so far as to call them a full anti-pattern. By allowing them, you also implicitly allow the possibility of internal state to exist. It does require you to be much more diligent in ensuring you do NOT allow other internal state to exist, but it can be managed if necessary. – Matthew Brubaker Dec 05 '18 at 17:36
  • 4
    updated link to [Docs](https://learn.microsoft.com/en-us/aspnet/core/security/cookie-sharing?view=aspnetcore-2.2#share-authentication-cookies-among-aspnet-core-apps) – Syed Ali Taqi May 10 '19 at 09:35
  • @ChrisPratt does this mean that it doesn't matter whether you use JWT or not? As long as you set your DataProtection to something like Azure Blob Storage your authentication will always scale well? – jcmontx Nov 06 '19 at 17:21
  • No. Not at all. First, JWTs are just a way of transmitting a principal. Their use alone doesn't matter for scale one way or another. Second, cookie sharing is probably the worst way to share authentication, so now, that doesn't scale well at all. All the sites would have to be on the same domain, for one. If you want something that can scale, then you need centralize auth, using IdentityServer, Auth0, AzureAD, etc. – Chris Pratt Nov 06 '19 at 19:06
  • I guess the best solution is what @ChrisPratt suggested but if some one wants to go by StickySession using nginx ingress controller, it gives options like 1) nginx.ingress.kubernetes.io/session-cookie-change-on-failure (this will take care of failover and won't use the same instance/pod on failure) 2) nginx.ingress.kubernetes.io/affinity-mode(This could help define how sticky the session is.) https://kubernetes.github.io/ingress-nginx/examples/affinity/cookie/ – GPuri Aug 04 '22 at 13:12
5

I ran into the same issue whenever I would restart my Azure App Service (PaaS) and my users' cookies were no longer valid. My app used ASP.NET Core Identity framework.

Here is the documentation explaining various ways to configure Data Protection to be scoped across multiple app instances or even multiple web apps:

https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview

I found using a blob storage account to be the quickest way to get it working:

var storageAccount = CloudStorageAccount.Parse(configuration["Configuration key to Azure storage connection string"]);
var client = storageAccount.CreateCloudBlobClient();
var container = client.GetContainerReference("key-container");

container.CreateIfNotExistsAsync().GetAwaiter().GetResult();

services.AddDataProtection()
    .SetApplicationName("Application Name")
    .PersistKeysToAzureBlobStorage(container, "keys.xml");
Matthew Steven Monkan
  • 8,170
  • 4
  • 53
  • 71
  • Just a small suggestion, if we can encrypt the keys persisted in blob. I guess there is a package which implements it with ProtectWithAzureKeyVault method. – GPuri Aug 04 '22 at 13:20