Post

YARP Transformations

Use YARP Transformations to seamlessly renew sessions.

YARP Transformations

Story

I was working on a project where I was creating a separate web application for an existing backend service. Since I had no way to modify the existing backend, I routed all requests through the YARP reverse proxy. With the reverse proxy in the middle of the request chain, I could easily add new features to my web application without touching the original backend. While creating new API endpoints for new features is straightforward because YARP uses .NET and the infrastructure of ASP.NET, there was one thing I had to deal with.

The idea was to keep the user signed in to the application, but the problem was that the existing backend service did not provide such a capability like a token refresh mechanism. Instead, it just issued a session cookie that was valid for a certain amount of time.

Requirements

Implement an automatic session refresh mechanism to keep users logged in to the application without having to enter credentials and manually log in again too often.

Solution

Stack used:

  • C#, ASP.NET, YARP.

I can think of two possible approaches to solve the problem. One is to handle the 401 Unauthorized response from the frontend, the other from the backend. However, the main question here is where to store the user’s credentials to be able to reuse them to authenticate the user on his behalf, since there are no other mechanisms available to authenticate the user. As far as the browser is concerned, there is only one place that might be suitable for storing sensitive data - cookies. Cookies could be marked as secure to prevent XSS attacks. However, it is still not a good idea to store user credentials in plain text in cookies.

I decided to handle 401 Unauthorized response from backend while storing user credentials in cookies. To protect user credentials from unauthorized access, I have encrypted them with a key known only to the backend system. So even if someone manages to get access to the cookies, they will not be able to use them to authenticate the user in other systems.

Approach

So here is the general idea of the solution:

  • When the user logs in, the reverse proxy forwards the request to the API and returns the “credentials” cookie along with the API response. This is the first transformation I need to implement.
  • When the user sends any request to the API, the reverse proxy forwards the request to the API, and if the API returns 401 Unauthorized, then the reverse proxy uses the “credentials” cookie to authenticate the user. Then I need to update the original session cookie on the client side and ask the client to retry the request to succeed this time. This should be handled by the browser itself. This is the second transformation.
  • When the user logs out, the reverse proxy forwards the request to the API and also removes the “credentials” cookie on the client. This is the third transformation.
  • For the sake of completeness, I need to implement the fourth transformation to remove the credential cookie before passing the request to the API because it does not belong to the API.
  • When the user submits an authentication request, there is a query parameter that enables the session refresh mechanism. This query parameter should be removed by the transformation.

To get a better idea of the flows, here are sequence diagrams of the solution:

Initial authentication

sequenceDiagram
  Web App->>Reverse Proxy: POST /api/auth?enableSessionRefresh=true
  Note over Web App,Reverse Proxy: Initial authentication
  Reverse Proxy-->>Reverse Proxy: Request transform:<br/>Remove "enableSessionRefresh" query param
  Reverse Proxy-->>Reverse Proxy: Transform:<br/>Read body and preserve credentials
  Reverse Proxy->>API: POST /api/auth
  API->>Reverse Proxy: 200 OK<br/>+<br/>Session cookie
  Reverse Proxy-->>Reverse Proxy: Response transform:<br/>Set "Credentials" cookie
  Reverse Proxy->>Web App: 200 OK<br/>+<br/>Session cookie<br/>+<br/>Encrypted "Credentials" cookie

Handle requests when session is active

sequenceDiagram
  Web App->>Reverse Proxy: /api/*
  Note over Web App,Reverse Proxy: Any success request<br/>when session is active
  Reverse Proxy-->>Reverse Proxy: Transform:<br/>Remove "Credentials" cookie
  Reverse Proxy->>API: /api/*
  API->>Reverse Proxy: 200 OK
  Reverse Proxy->>Web App: 200 OK

Handle requests when session is expired

sequenceDiagram
  Web App->>Reverse Proxy: /api/*
  Note over Web App,Reverse Proxy: Any failed request<br/>due to expired session
  Reverse Proxy->>API: /api/*
  API->>Reverse Proxy: 401 Unauthorized
  Reverse Proxy-->>Reverse Proxy: Transform:<br/>Extract credentials from cookie
  Reverse Proxy->>API: /api/auth
  Note over Reverse Proxy,API: Authenticate
  API->>Reverse Proxy: 200 OK<br/>+<br/>Session cookie
  Reverse Proxy-->>Reverse Proxy: Response transform:<br/>Set new session cookie
  Reverse Proxy->>Web App: 307 Temporary Redirect<br/>+<br/>Session cookie
  Web App->>Reverse Proxy: /api/*
  Note over Web App,Reverse Proxy: Follow redirect
  Reverse Proxy->>API: /api/*
  Note over Reverse Proxy,API: Process request as usual
  API->>Reverse Proxy: Response
  Reverse Proxy->>Web App: Response

Handle sign out request

sequenceDiagram
  Web App->>Reverse Proxy: DELETE /api/auth
  Note over Web App,Reverse Proxy: Sign out request
  Reverse Proxy->>API: DELETE /api/auth
  API->>Reverse Proxy: 204 No Content
  Reverse Proxy-->>Reverse Proxy: Transform:<br/>Set-Cookie "Credentials" as expired
  Reverse Proxy->>Web App: 204 No Content<br/>+<br/>Expire "Credentials" cookie

Implementation

Encryption service

I use the ASP.NET Core Data Protection API to encrypt and decrypt the credentials. It allows to avoid using predefined passwords or keys for encryption and decryption. Instead, it uses a key that is automatically generated and stored on the server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using Microsoft.AspNetCore.DataProtection;

internal class EncryptionService(IDataProtectionProvider dataProtectionProvider)
{
  private readonly IDataProtector _protector =
    dataProtectionProvider.CreateProtector("Credentials");

  public string Encrypt(string cookieValue)
  {
    return _protector.Protect(cookieValue);
  }

  public string Decrypt(string protectedCookieValue)
  {
    return _protector.Unprotect(protectedCookieValue);
  }
}

API service

I need a service that will talk to API to issue session cookie when user’s session expires on his behalf. Below I use HttpClient to make requests to API. All it needs is to provide credentials to API endpoint and get cookies from response including session cookie. This is because I want to preserve all possible cookies from the API response so as not to interfere with the original authentication flow.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using Configuration;

internal class OriginalApiService(
  HttpClient httpClient,
  IOptions<EnableSessionRefreshOptions> options)
  : IOriginalApiService
{
  private const string SetCookieHeader = "Set-Cookie";

  private readonly string _originalServiceBaseAddress =
    options.Value.Authentication.BaseAddress;
  private readonly string _authApiEndpoint =
    options.Value.Authentication.Endpoint;

  public async Task<IDictionary<string, string>> SignInAndGetSessionCookies(
    string username,
    string password)
  {
    var payload = JsonContent.Create(new
    {
      username,
      password
    });

    httpClient.BaseAddress = new Uri(_originalServiceBaseAddress);

    var response = await httpClient.PostAsync(_authApiEndpoint, payload);
    if (!response.IsSuccessStatusCode ||
      !response.Headers.TryGetValues(SetCookieHeader, out var cookies))
      return new Dictionary<string, string>();

    return cookies.Select(cookie =>
    {
      var parts = cookie.Split('=');
      var cookieName = parts[0];
      var cookieValue = parts[1].Split(';')[0];
      return new KeyValuePair<string, string>(cookieName, cookieValue);
    }).ToDictionary(x => x.Key, x => x.Value);
  }
}

Transformation #1: Read and preserve credentials

The first step is to read the credentials from the authentication request and store them in the request’s HttpContext. This is necessary to preserve the credentials for future use. The logic is executed only if requested by the user, e.g. the appropriate checkbox is checked, so the web application sends the request parameter enableSessionRefresh=true. Then, if the authentication request is successful, the credentials are encrypted and set as a cookie to be stored in the browser on the client side. This way, I can have the user’s credentials available for any future request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
using System.Text;
using System.Text.Json;
using Configuration;
using Models;
using Services;

internal class SignInTransform : ITransformProvider
{
  private const string SetCredentialsKey = "SetCredentials";

  private readonly string _authApiEndpoint;
  private readonly string _enableSessionRefreshKeyName;
  private readonly string _credentialsCookieName;
  private readonly int _persistCredentialsMaxDays;

  private readonly JsonSerializerOptions _jsonSerializerOptions = new()
  {
    PropertyNameCaseInsensitive = true
  };

  public SignInTransform(IOptions<EnableSessionRefreshOptions> options)
  {
    var settings = options.Value;
    _authApiEndpoint = settings.Authentication.Endpoint;
    _enableSessionRefreshKeyName = settings.QueryParamName;
    _credentialsCookieName = settings.CredentialsCookieName;
    _persistCredentialsMaxDays = settings.PersistCredentialsMaxDays;
  }

  public void ValidateRoute(TransformRouteValidationContext context)
  {
  }

  public void ValidateCluster(TransformClusterValidationContext context)
  {
  }

  public void Apply(TransformBuilderContext transformBuilderContext)
  {
    transformBuilderContext.AddRequestTransform(async context =>
    {
      if (context.HttpContext.Request.Path != _authApiEndpoint ||
        context.HttpContext.Request.Method != "POST")
        return;

      if (!context.HttpContext.Request.Query.TryGetValue(_enableSessionRefreshKeyName,
        out var enableSessionRefreshValue) ||
        !bool.TryParse(enableSessionRefreshValue,
        out var enableSessionRefresh) || !enableSessionRefresh)
        return;

      context.HttpContext.Request.EnableBuffering();
      var buffer =
        new byte[Convert.ToInt32(context.HttpContext.Request.ContentLength)];
      _ = await context.HttpContext.Request.Body.ReadAsync(buffer);
      var body = Encoding.UTF8.GetString(buffer);
      context.HttpContext.Request.Body.Position = 0;
      context.HttpContext.Request.QueryString = QueryString.Empty;

      try
      {
        var credentials =
          JsonSerializer.Deserialize<Credentials>(body, _jsonSerializerOptions);
        if (credentials != null)
        {
          context.HttpContext.Items[SetCredentialsKey] =
            $"{credentials.User}|{credentials.Password}";
        }
      }
      catch
      {
        // ignored
      }
    });

    transformBuilderContext.AddResponseTransform(context =>
    {
      if (context.HttpContext.Request.Path != _authApiEndpoint ||
        context.HttpContext.Request.Method != HttpMethod.Post.ToString() ||
        context.HttpContext.Response.StatusCode != (int)HttpStatusCode.NoContent)
        return default;

      if (!context.HttpContext.Items.TryGetValue(SetCredentialsKey, out var credentials) ||
        credentials == null)
        return default;

      var encryptionService =
        transformBuilderContext.Services.GetService<EncryptionService>()!;
      var encryptedCredentials = encryptionService.Encrypt(credentials.ToString()!);
      context.HttpContext.Response.Cookies.Append(
        _credentialsCookieName,
        encryptedCredentials,
        new CookieOptions
        {
          Secure = true,
          HttpOnly = true,
          MaxAge = TimeSpan.FromDays(_persistCredentialsMaxDays)
        });

      return default;
    });
  }
}

The second transformation is to remove the credentials cookie from requests. Since this cookie is only related to my reverse proxy feature, I don’t want to send it to the original API.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using Configuration;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Cookies = (string?[]? cookiesExceptCredentials, string? credentialsCookie);

internal class RemoveCookieTransform : ITransformProvider
{
  private const string CookieHeader = "Cookie";

  private readonly string _credentialsCookieName;

  public RemoveCookieTransform(IOptions<EnableSessionRefreshOptions> options)
  {
    var settings = options.Value;
    _credentialsCookieName = settings.CredentialsCookieName;
  }

  public void ValidateRoute(TransformRouteValidationContext context)
  {
  }

  public void ValidateCluster(TransformClusterValidationContext context)
  {
  }

  public void Apply(TransformBuilderContext transformBuilderContext)
  {
    transformBuilderContext.AddRequestTransform(context =>
    {
      var headers = context.HttpContext.Request.Headers;
      if (!TryExtractCookies(headers, out var cookies))
        return default;

      headers.Remove(CookieHeader);
      headers.Append(CookieHeader, cookies.cookiesExceptCredentials);

      context.HttpContext.Items[_credentialsCookieName] = cookies.credentialsCookie;

      return default;
    });
  }

  private bool TryExtractCookies(IHeaderDictionary headers, out Cookies cookies)
  {
    cookies = new Cookies(null, null);

    if (!headers.TryGetValue(CookieHeader, out var existingValues))
      return false;

    if (existingValues.Count == 0)
      return false;

    var parsedCookies = CookieHeaderValue.ParseList(existingValues.ToList()!);
    var credentials =
      parsedCookies.FirstOrDefault(x => x.Name == _credentialsCookieName)?.Value.Value;
    var allOthersExceptCredentials =
      new StringValues(parsedCookies
      .Where(x => x.Name != _credentialsCookieName)
      .Select(x => x.ToString()).ToArray());

    cookies = new Cookies(allOthersExceptCredentials, credentials);
    return true;
  }
}

Transformation #3: Refresh session

This is where the refresh session logic is implemented. Whenever a request is made to the API and the response is 401 Unauthorized, if the user has the refresh feature enabled and so the credentials are set in cookies, then a refresh session is triggered. I decrypt the credentials from the cookie and call the authentication API to get a new session cookie. The original response that caused the 401 Unauthorized is completely removed and replaced with the new response that contains the new session cookie and prompts the client to retry the request. Because the session cookie contains a valid session value, the request will be successful this time.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
using Configuration;
using Services;

internal class RefreshSessionTransform : ITransformProvider
{
  private readonly string _credentialsCookieName;
  private readonly string _authApiEndpoint;

  public RefreshSessionTransform(IOptions<EnableSessionRefreshOptions> options)
  {
    var settings = options.Value;
    _credentialsCookieName = settings.CredentialsCookieName;
    _authApiEndpoint = settings.Authentication.Endpoint;
  }

  public void ValidateRoute(TransformRouteValidationContext context)
  {
  }

  public void ValidateCluster(TransformClusterValidationContext context)
  {
  }

  public void Apply(TransformBuilderContext transformBuilderContext)
  {
    var originalServiceService =
      transformBuilderContext.Services.GetService<IOriginalApiService>()!;

    transformBuilderContext.AddResponseTransform(async context =>
    {
      if (context.HttpContext.Response.StatusCode != (int)HttpStatusCode.Unauthorized ||
        context.HttpContext.Request.Path == _authApiEndpoint)
        return;

      var credentialItems = context.HttpContext.Items[_credentialsCookieName];
      if (credentialItems == null)
        return;

      var encryptionService =
        transformBuilderContext.Services.GetService<EncryptionService>()!;
      var unencryptedCredentials = encryptionService.Decrypt(credentialItems.ToString()!);
      var credentials = Uri.UnescapeDataString(unencryptedCredentials);
      var separatorIndex = credentials.IndexOf('|');

      if (separatorIndex < 0)
        return;

      var username = credentials.Substring(0, separatorIndex);
      var password =
        credentials.Substring(separatorIndex + 1, credentials.Length - separatorIndex - 1);

      var cookies =
        await originalServiceService.SignInAndGetSessionCookies(username, password);
      if (cookies.Count == 0)
        return;

      context.HttpContext.Response.Clear();

      foreach (var cookie in cookies)
      {
        context.HttpContext.Response.Cookies.Append(
          cookie.Key,
          cookie.Value,
          new CookieOptions
          {
            Secure = true,
            HttpOnly = true
          });
      }

      context.HttpContext.Response.Redirect(context.HttpContext.Request.Path, false, true);
    });
  }
}

Transformation #4: Sign out

When the user signs out, we need to remove the credentials cookie from the client’s browser.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using Configuration;

internal class SignOutTransform : ITransformProvider
{
  private readonly string _authApiEndpoint;
  private readonly string _credentialsCookieName;

  public SignOutTransform(IOptions<EnableSessionRefreshOptions> options)
  {
    var settings = options.Value;
    _authApiEndpoint = settings.Authentication.Endpoint;
    _credentialsCookieName = settings.CredentialsCookieName;
  }

  public void ValidateRoute(TransformRouteValidationContext context)
  {
  }

  public void ValidateCluster(TransformClusterValidationContext context)
  {
  }

  public void Apply(TransformBuilderContext transformBuilderContext)
  {
    transformBuilderContext.AddResponseTransform(context =>
    {
      if (context.HttpContext.Request.Path != _authApiEndpoint ||
        context.HttpContext.Request.Method != HttpMethod.Delete.ToString() ||
        context.HttpContext.Response.StatusCode != (int)HttpStatusCode.NoContent)
        return default;

      context.HttpContext.Response.Cookies.Append(_credentialsCookieName, string.Empty,
        new CookieOptions
        {
          Secure = true,
          MaxAge = TimeSpan.Zero
        });

      return default;
    });
  }
}

Put it all together

Put it all together in the Program.cs file: enable reverse proxy and register transformations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using MyApp.Api.Configuration;
using MyApp.Api.Services;
using MyApp.Api.Transforms;
using MyApp.Api.Extensions;

var builder = WebApplication.CreateBuilder(args);

var sessionRefreshConfiguration = builder.Configuration.GetSection("EnableSessionRefreshSettings");
var sessionRefreshOptions = keepMeSignedInConfiguration.Get<EnableSessionRefreshOptions>();

builder.Services
  .Configure<EnableSessionRefreshOptions>(sessionRefreshConfiguration)
	.ConfigureDataProtection(sessionRefreshOptions)
  .AddSingleton<EncryptionService>()
  .AddReverseProxy()
  .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
  .AddTransforms<SignInTransform>()
  .AddTransforms<RemoveCookieTransform>()
  .AddTransforms<SignOutTransform>()
  .AddTransforms<RefreshSessionTransform>();

builder.Services
  .AddHttpClient<IOriginalApiService, OriginalApiService>();

var app = builder.Build();

app.MapReverseProxy();
app.UseStatusCodePagesWithReExecute("/");
app.UseFileServer();
app.Run();

Some configuration pieces are extracted into the appsettings.json file like API endpoint to authenticate, cookie name, and so on.

1
2
3
4
5
6
7
8
9
10
"EnableSessionRefreshSettings": {
  "QueryParamName": "enableSessionRefresh",
  "CredentialsCookieName": "Credentials",
  "PersistCredentialsMaxDays": 90,
	"ProtectionKeyStoragePath": "/etc/myapp",
  "Authentication": {
    "BaseAddress": "https://original-service.com/",
    "Endpoint": "/api/auth"
  }
}

Here is an extension method to configure data protection based on the settings above.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using Configuration;
using Microsoft.AspNetCore.DataProtection;

internal static class DataProtection
{
   private const string DotNetRunningInContainer = "DOTNET_RUNNING_IN_CONTAINER";

   public static IServiceCollection ConfigureDataProtection(
    this IServiceCollection services,
    KeepMeSignedInOptions? options)
   {
     if (options == null)
     {
        return services;
     }

     var isRunningInContainer =
        bool.TryParse(Environment.GetEnvironmentVariable(DotNetRunningInContainer),
         out var value) && value;

     if (isRunningInContainer)
     {
        services.AddDataProtection()
           .SetApplicationName("MyApp")
           .SetDefaultKeyLifetime(TimeSpan.FromDays(options.PersistCredentialsMaxDays))
           .PersistKeysToFileSystem(new DirectoryInfo(options.ProtectionKeyStoragePath));
     }

     return services;
   }
}

Now I can launch the application and have fun with the application without being asked to sign in too often.

Conclusion

The proposed solution works as expected, but there are pros and cons. Also, key retention policies, key encryption, and key storage are not described much here. If someone tries to reuse the solution, it may need to be reviewed and adjusted a little.

Pros:

  • No central storage of credentials on proxy side.
  • Clients keep their credentials safe.
  • No predefined master key. New encryption key generated for each reverse proxy instance.

Cons:

  • The encryption key had to be kept on the backend to be able to decrypt credentials stored in user cookies after a reverse proxy restart or in case of multiple instances running e.g. in Kubernetes.
  • The data protection API is not primary intended for indefinite persistence of confidential payloads according to documentation. Even though there are more suitable storages available like cloud management storages, ASP.NET Core data protection API can be used for long-term protection of confidential data. The developer can choose which encryption algorithm to use.
This post is licensed under CC BY 4.0 by the author.