Have you ever worked on migrating from a legacy system to a newer one? That’s what I’m doing, and I learn a lot from this every day. Today we’ll learn how to translate tokens with identity server. Specifically, we will use tokens issued with Forms authentication ticket to make authenticated API calls on APIs protected with OpenID connect via Identity server.
The scenario
Imagine we have a legacy monolith API that can be broken into several microservices. This monolith is written in the old ASP.net and uses Forms authentication ticket to authenticate users. We want to migrate the monolith bit by bit to smaller microservices running on ASP.net core, dotnet 7.
The problem
The issue is that Forms authentication ticket isn’t available on dotnet 7 and even if it was, we won’t use it cause we have decided to use OpenId connect on the new system. Since we decided to migrate the monolith bit by bit, modules of the monolith will be replaced by microservices and we’ll need the monolith or clients to communicate with our microservices on behalf of the authenticated users, in a secure manner.
With open id connect, client credentials could easily be used for machine to machine calls between the monolith and the microservices. But what about scenarios where calls should be made on behalf of the authenticated user? Migrating the monolith to open id connect will require a lot of work and will impact every client application so we need to find a way to make our new system understand the former system’s authentication method so that the monolith, or clients will communicate with the microservices seamlessly with the old token though the latter is protected by OpenID connect with Identity server.
NOTE: If you’re interested in knowing how to migrate old ASP.net authentication systems to OpenId connect with IdentityServer on ASP.net core, here is an article I wrote about this topic.
If you find this article useful, please follow me on Twitter, Github, Linkedin, or like my Facebook page to stay updated.Follow me on social media and stay updated
The solution
The best solution to this problem is one in which the microservice’s functioning will not require any modification, the open id connect server will work seamlessly without adding a new unsecured endpoint to it. The solution will be to:
- Modify our microservice to detect calls made with the former token.
- Then create a new grant on our open id server meaning that; We will tell our open id identity provider server how it can securely decode Forms Authentication ticket tokens, verify their validity, verify the validity of the client calling it, and issue a secure token with the appropriate claims. This new token will be a kind of translation of the old token.
- Forward the call to our microservice with the new translated token.
Implementation
We will start implementing this solution with parts 1 and 3. These will be implemented on the microservice. Taking into consideration that this microservice has controller methods and is protected with authentication and authorization and the authority is an open id connect server we control.
We have to add a middleware on our microservice that will intercept every request, detect if the request is from the old system, ask the identity server to translate the token, and replace the token in the request with the translated token. This is what the middleware will look like.
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 | public class LegacyTokenSupportMiddleWare { const string LegacyAuthHeader = "LEGACYAUTH"; private readonly ILogger<LegacyTokenSupportMiddleWare> _logger; private readonly LegacyApiClientSettings _apiClientSettings; private readonly ScopeSettings _scopeSettings; private readonly RequestDelegate _next; private readonly HttpClient _httpClient; private readonly IConfiguration _configRoot; public LegacyTokenSupportMiddleWare(RequestDelegate next, ILogger<LegacyTokenSupportMiddleWare> logger, IOptions<LegacyApiClientSettings> ApiClientSettings, IOptions<ScopeSettings> scopeSettings, IConfiguration configRoot, HttpClient httpClient) { _scopeSettings = scopeSettings.Value; _configRoot = configRoot; _httpClient = httpClient; _next = next; _logger = logger; _ApiClientSettings = ApiClientSettings.Value; } public async Task InvokeAsync(HttpContext httpContext) { const string BearerTokenHeader = "Bearer"; const string AuthorizationHeader = "Authorization"; _logger.LogInformation("Intercepting request in legacy compatibility middleware."); if (httpContext.Request.Headers.Any(h => h.Key == LegacyAuthHeader)) { string authToken = string.Empty; try { authToken = httpContext.Request.Headers[LegacyAuthHeader]; if (!string.IsNullOrEmpty(authToken)) { _logger.LogInformation("Intercepted an old request with Token: {Token}", authToken); var translatedAccessToken = await PerformTokenTranslationGrantAuthentication(authToken); if (httpContext.Response.Headers.Any(h => h.Key == BearerTokenHeader)) httpContext.Request.Headers[AuthorizationHeader] = $"{BearerTokenHeader} {translatedAccessToken}"; else httpContext.Request.Headers.Add(AuthorizationHeader, $"{BearerTokenHeader} {translatedAccessToken}"); } } catch (Exception e) { _logger.LogCritical(e, "Bad legacy token was passed in request. Token: {Token}", authToken); } } await _next(httpContext); } private async Task<string> PerformTokenTranslationGrantAuthentication(string Token) { const string GrantTypeName = "translation"; //We ask open id connect to authenticate us with our custom grant: translation var discoveryDocument = await _httpClient.GetDiscoveryDocumentAsync(_configRoot["AuthEndPoint"]); if (discoveryDocument?.IsError ?? true) throw new Exception($"An error occured while getting the discovery document. " + $"\nError: {discoveryDocument?.Error}" + $"\nErrorType:{discoveryDocument?.ErrorType}", discoveryDocument?.Exception); var tokenResponse = await _httpClient.RequestTokenAsync( new ClientCredentialsTokenRequest { Address = discoveryDocument.TokenEndpoint, ClientId = _ApiClientSettings.ClientId, ClientSecret = _apiClientSettings.ClientSecret, GrantType = GrantTypeName, Parameters = { { "token", Token }, { "scope", _scopeSettings.PushNotificationsScope } } }); if (tokenResponse?.IsError ?? true) throw new Exception($"An error occured while getting the access token." + $"\n Error:{tokenResponse?.Error}", tokenResponse?.Exception); return tokenResponse.AccessToken; } } |
To register the middleware in your microservice, follow this documentation.
Decrypting Forms authentication ticket token
The next step will be to translate the forms authentication ticket to an OpenId connect token. This is done by the OpenId connect server.
First, we need a way to decrypt Forms authentication tokens. On dotnet 4.x, these tokens were created using a machine key the classes used to create these tokens were not ported to dotnet core or dotnet 5+. Luckily a developer made a package available that can encrypt and decrypt these tokens. Here is the nugget package. The decryption logic will be placed in a class called “LegacyTokenValidator” with a method called “Decrypt”. This is its implementation:
1 2 3 4 5 6 7 8 9 10 11 | public FormsAuthenticationTicket Decrypt(string token) { var encryptor = new LegacyFormsAuthenticationTicketEncryptor(_decryptionKey, _validationKey, ShaVersion.Sha256, CompatibilityMode.Framework20SP2); var result = encryptor.DecryptCookie(token); return result; } |
The decryption of this legacy token will yield the user’s username and some other info. We will tell Identity server to use the username to get a user from our database and create the auth token with appropriate claims in it. To do that, we have to create a new grant. The steps to create and add a custom grant are listed here follow the example they show. Our own grant is named “translate” and its grant validator will be in charge of translating the old authentication token into a new one. This is what our grant validator will look like:
If you find this article useful, please follow me on Twitter, Github, Linkedin, or like my Facebook page to stay updated.
Follow me on social media and stay updated
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 | public class TranslationGrantValidator : IExtensionGrantValidator { public const string GrantTypeName = "translation"; public string GrantType => GrantTypeName; private readonly ILogger<TranslationGrantValidator> _logger; private readonly LegacyTokenValidator _legacyTokenValidator; private readonly UserManager<ApplicationUser> _userManager; public TranslationGrantValidator(ILogger<TranslationGrantValidator> logger, LegacyTokenValidator legacyTokenValidator, UserManager<ApplicationUser> userManager) { _logger = logger; _legacyTokenValidator = legacyTokenValidator; _userManager = userManager; } public async Task ValidateAsync(ExtensionGrantValidationContext context) { string AuthToken = context.Request.Raw.Get("token"); var scopes = context.Request.RequestedScopes; var claims = new List<Claim>(); if (scopes != null && scopes.Any()) { foreach (var scope in scopes) { claims.Add(new Claim("scope", scope)); } } try { _logger.LogInformation("Intercepted an old request"); if (!string.IsNullOrEmpty(AuthToken)) { var ticket = _legacyTokenValidator.Decrypt(AuthToken.ToString()); if (ticket.Expired) { _logger.LogWarning("An expired token was used to request this API. Token: {Token}", AuthToken); context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); return; } var userName = ticket.Name; var user = await _userManager.FindByNameAsync(userName); if (user == null) { _logger.LogWarning("An token for an unknown user was used to request this API. Token: {Token}", AuthToken); context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); return; } claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); context.Result = new GrantValidationResult( subject: user.Id.ToString(), authenticationMethod: GrantType, claims: claims); return; } context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant); return; } catch (Exception e) { _logger.LogCritical(e, "Bad legacy token was passed in request. Token: {Token}", AuthToken); throw; } } } |
You then need to register your grant provider in the DI container as follows:
1 2 3 4 | .AddInMemoryIdentityResources... .AddExtensionGrantValidator<TranslationGrantValidator>() .AddClientStore... |
Then, assign the grant to a client or several clients.
1 | client.AllowedGrantTypes = { TranslationGrantValidator.GrantTypeName }; |
Conclusion
With this, your requests from the legacy system will flow seamlessly to the new microservices and responses will be generated as if the requests were made using a token issued by the OpenID connect server. This approach could be used for several other translation scenarios. If you have a better approach, please, let me know in the comments section.
References
https://stackoverflow.com/questions/71592925/asp-net-core-modify-headers-in-middleware
Follow me on social media and stay updated