Search code examples
asp.netasp.net-coreauthenticationasp.net-web-apisession-cookies

Validate 3rd Party Cookies with ASP.NET Core Web Api


I am using ORY Kratos for identity and my frontend SPA (React App) is authenticating against the Kratos Login Server and gets a session cookie back.

Now I want to secure my ASP.NET Core Web Api in a way, that a user can only call certain methods protected with the [Authorize] attribute when attaching a valid cookie to the request. For this, I need to validate the cookie from every incoming request. So I am looking for a way to configure Authentication and add custom logic to validate the cookie (I need to make an API call to Kratos to validate it).

The cookie I want to validate has not been issued by the ASP.NET Core App that wants to validate it.

All the samples I found so far, are also issuing the cookie on the same server but I need to validate an external one.

This is what my cookie looks like: enter image description here

In the Dev Tools, I can validate that the Cookie is attached to the requests header:

enter image description here

This is, what I've tried so far:

public void ConfigureServices(IServiceCollection services)
{
    // ...

    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
      .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => 
      {
          options.Cookie.Name = "ory_kratos_session";
          options.Cookie.Path = "/";
          options.Cookie.Domain = "localhost";
          options.Cookie.HttpOnly = true;
          options.EventsType = typeof(CustomCookieAuthenticationEvents);
      });
    services.AddScoped<CustomCookieAuthenticationEvents>();

    // ...
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ...

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

    // ...
}
public class CustomCookieAuthenticationEvents : CookieAuthenticationEvents
{
    public CustomCookieAuthenticationEvents() {}

    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
    {
        // Never gets called
    }
}

Logs:

info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[7]
      Cookies was not authenticated. Failure message: Unprotect ticket failed
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed. These requirements were not met:
      DenyAnonymousAuthorizationRequirement: Requires an authenticated user.
info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[12]
      AuthenticationScheme: Cookies was challenged.
dbug: Microsoft.AspNetCore.Server.Kestrel[9]
      Connection id "0HM6IBAO4PLLL" completed keep alive response.
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET https://localhost:5001/weatherforecast - - - 302 0 - 75.3183ms
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET https://localhost:5001/Account/Login?ReturnUrl=%2Fweatherforecast - -

Solution

  • According to the cookie authentication handler's source codes, I found it will read the cookie before goes to the CustomCookieAuthenticationEvents .

    Some part of codes as below:

        private async Task<AuthenticateResult> ReadCookieTicket()
        {
            var cookie = Options.CookieManager.GetRequestCookie(Context, Options.Cookie.Name!);
            if (string.IsNullOrEmpty(cookie))
            {
                return AuthenticateResult.NoResult();
            }
    
            var ticket = Options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding());
            if (ticket == null)
            {
                return AuthenticateResult.Fail("Unprotect ticket failed");
            }
    
            if (Options.SessionStore != null)
            {
                var claim = ticket.Principal.Claims.FirstOrDefault(c => c.Type.Equals(SessionIdClaim));
                if (claim == null)
                {
                    return AuthenticateResult.Fail("SessionId missing");
                }
                // Only store _sessionKey if it matches an existing session. Otherwise we'll create a new one.
                ticket = await Options.SessionStore.RetrieveAsync(claim.Value);
                if (ticket == null)
                {
                    return AuthenticateResult.Fail("Identity missing in session store");
                }
                _sessionKey = claim.Value;
            }
    
            var currentUtc = Clock.UtcNow;
            var expiresUtc = ticket.Properties.ExpiresUtc;
    
            if (expiresUtc != null && expiresUtc.Value < currentUtc)
            {
                if (Options.SessionStore != null)
                {
                    await Options.SessionStore.RemoveAsync(_sessionKey!);
                }
                return AuthenticateResult.Fail("Ticket expired");
            }
    
            CheckForRefresh(ticket);
    
            // Finally we have a valid ticket
            return AuthenticateResult.Success(ticket);
        }
    

    If you still want to use cookie authentication, you need to rewrite the handler. So I suggest you could write a custom AuthenticationHandler and AuthenticationSchemeOptions class like below and register the class in the startup.cs directly. Then you could use [Authorize(AuthenticationSchemes = "Test")] to set the special AuthenticationSchemes.

    Codes:

    public class ValidateHashAuthenticationSchemeOptions : AuthenticationSchemeOptions
    {
    
    }
    
    public class ValidateHashAuthenticationHandler
    : AuthenticationHandler<ValidateHashAuthenticationSchemeOptions>
    {
        public ValidateHashAuthenticationHandler(
            IOptionsMonitor<ValidateHashAuthenticationSchemeOptions> options,
            ILoggerFactory logger,
            UrlEncoder encoder,
            ISystemClock clock)
            : base(options, logger, encoder, clock)
        {
        }
    
        protected override Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            //TokenModel model;
    
            // validation comes in here
            if (!Request.Headers.ContainsKey("X-Base-Token"))
            {
                return Task.FromResult(AuthenticateResult.Fail("Header Not Found."));
            }
    
            var token = Request.Headers["X-Base-Token"].ToString();
    
            try
            {
                // convert the input string into byte stream
                using (MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(token)))
                {
                    // deserialize stream into token model object
                    //model = Serializer.Deserialize<TokenModel>(stream);
                }
            }
            catch (System.Exception ex)
            {
                Console.WriteLine("Exception Occured while Deserializing: " + ex);
                return Task.FromResult(AuthenticateResult.Fail("TokenParseException"));
            }
    
            //if (model != null)
            //{
            //    // success case AuthenticationTicket generation
            //    // happens from here
    
            //    // create claims array from the model
            //    var claims = new[] {
            //        new Claim(ClaimTypes.NameIdentifier, model.UserId.ToString()),
            //        new Claim(ClaimTypes.Email, model.EmailAddress),
            //        new Claim(ClaimTypes.Name, model.Name) };
    
            //    // generate claimsIdentity on the name of the class
            //    var claimsIdentity = new ClaimsIdentity(claims,
            //                nameof(ValidateHashAuthenticationHandler));
    
            //    // generate AuthenticationTicket from the Identity
            //    // and current authentication scheme
            //    var ticket = new AuthenticationTicket(
            //        new ClaimsPrincipal(claimsIdentity), this.Scheme.Name);
    
            //    // pass on the ticket to the middleware
            //    return Task.FromResult(AuthenticateResult.Success(ticket));
            //}
    
            return Task.FromResult(AuthenticateResult.Fail("Model is Empty"));
        }
    
    }
    public class TokenModel
    {
        public int UserId { get; set; }
        public string Name { get; set; }
        public string EmailAddress { get; set; }
    }
    

    Startup.cs add below codes into the ConfigureServices method:

                services.AddAuthentication(options =>
                {
                    options.DefaultScheme
                        = "Test";
                })
    .AddScheme<ValidateHashAuthenticationSchemeOptions, ValidateHashAuthenticationHandler>
            ("Test", null);
    

    Usage:

    Controller:

    [Authorize(AuthenticationSchemes = "Test")]