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

Viewed 882

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 - -
2 Answers

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")]

So I now solved it for myself differently by creating a custom Authentication Handler, which takes care of checking the Cookie that gets send to the Web API.

Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddSingleton(new KratosService("http://localhost:4433"));   
    services
        .AddAuthentication("Kratos")
        .AddScheme<KratosAuthenticationOptions, KratosAuthenticationHandler>("Kratos", null);
    // ...
}

If you are interested in the implementation that did the job for me, I have attached the additional files below. It is also worth mentioning, that Kratos supports two way of Authenticating: Cookies and Bearer Tokens, depending on if you talk to it via Web App or an API. My implementation supports both. You can find a working Sample with ASP.NET Core and React here: https://github.com/robinmanuelthiel/kratos-demo

KratosAuthenticationHandler.cs:

using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace KratosDemo.Server.Kratos
{
    public class KratosAuthenticationOptions : AuthenticationSchemeOptions
    {
    }

    public class KratosAuthenticationHandler : AuthenticationHandler<KratosAuthenticationOptions>
    {        
        readonly KratosService _kratosService;
        readonly string _sessionCookieName = "ory_kratos_session";

        public KratosAuthenticationHandler(
            IOptionsMonitor<KratosAuthenticationOptions> options, 
            ILoggerFactory logger, 
            UrlEncoder encoder, 
            ISystemClock clock,
            KratosService kratosService
        ) 
            : base(options, logger, encoder, clock)
        {
            _kratosService = kratosService;
        }

        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            // ORY Kratos can authenticate against an API through two different methods:
            // Cookie Authentication is for Browser Clients and sends a Session Cookie with each request.
            // Bearer Token Authentication is for Native Apps and other APIs and sends an Authentication header with each request.
            // We are validating both ways here by sending a /whoami request to ORY Kratos passing the provided authentication
            // methods on to Kratos.
            try
            {                
                // Check, if Cookie was set
                if (Request.Cookies.ContainsKey(_sessionCookieName))
                {
                    var cookie = Request.Cookies[_sessionCookieName];
                    var id = await _kratosService.GetUserIdByCookie(_sessionCookieName, cookie);
                    return ValidateToken(id);
                }

                // Check, if Authorization header was set
                if (Request.Headers.ContainsKey("Authorization"))
                {
                    var token = Request.Headers["Authorization"];
                    var id = await _kratosService.GetUserIdByToken(token);
                    return ValidateToken(id);
                }

                // If neither Cookie nor Authorization header was set, the request can't be authenticated.
                return AuthenticateResult.NoResult();
            }
            catch (Exception ex)
            {
                // If an error occurs while trying to validate the token, the Authentication request fails.
                return AuthenticateResult.Fail(ex.Message);
            }
        }

        private AuthenticateResult ValidateToken(string userId)
        {            
            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.NameIdentifier, userId),
            };
 
            var identity = new ClaimsIdentity(claims, Scheme.Name);
            var principal = new System.Security.Principal.GenericPrincipal(identity, null);
            var ticket = new AuthenticationTicket(principal, Scheme.Name);
            return AuthenticateResult.Success(ticket);
        } 
    }
}

KratosService.cs:

using System.Threading.Tasks;
using System.Net.Http;
using System.Text.Json;
using System;

namespace KratosDemo.Server.Kratos
{     
    public class KratosService
    {
        private readonly string _kratosUrl;
        private readonly HttpClient _client;

        public KratosService(string kratosUrl)
        {
            _client = new HttpClient();
            _kratosUrl = kratosUrl;
        }

        public async Task<string> GetUserIdByToken(string token)
        {
            var request = new HttpRequestMessage(HttpMethod.Get, $"{_kratosUrl}/sessions/whoami");
            request.Headers.Add("Authorization", token);
            return await SendWhoamiRequestAsync(request);            
        }
        
        public async Task<string> GetUserIdByCookie(string cookieName, string cookieContent)
        {
            var request = new HttpRequestMessage(HttpMethod.Get, $"{_kratosUrl}/sessions/whoami");
            request.Headers.Add("Cookie", $"{cookieName}={cookieContent}");
            return await SendWhoamiRequestAsync(request);            
        }

        private async Task<string> SendWhoamiRequestAsync(HttpRequestMessage request)
        {
            var res = await _client.SendAsync(request);
            res.EnsureSuccessStatusCode();

            var json = await res.Content.ReadAsStringAsync();
            var whoami = JsonSerializer.Deserialize<Whoami>(json);    
            if (!whoami.Active)
                throw new InvalidOperationException("Session is not active.");

            return whoami.Identity.Id;
        }
    }
}

Whoami.cs:

using System;
using System.Text.Json.Serialization;

namespace KratosDemo.Server.Kratos
{
    public class Whoami
    {
        [JsonPropertyName("id")]
        public string Id { get; set; } 

        [JsonPropertyName("active")]
        public bool Active { get; set; } 

        [JsonPropertyName("expires_at")]
        public DateTime ExpiresAt { get; set; } 

        [JsonPropertyName("authenticated_at")]
        public DateTime AuthenticatedAt { get; set; } 

        [JsonPropertyName("issued_at")]
        public DateTime IssuedAt { get; set; } 

        [JsonPropertyName("identity")]
        public Identity Identity { get; set; } 
    }

    public class Identity
    {
        [JsonPropertyName("id")]
        public string Id { get; set; }
    }
}
Related