asp.net-mvcasp.net-coreazure-ad-b2crefresh-tokenmicrosoft-identity-web

Refresh Tokens and MicrosoftIdentityWebApp


We currently use the AddMicrosoftIdentityWebApp approach to enable authentication using Azure AD B2C. Specifically, we use the following code:

services.AddAuthentication()
.AddCookie(FRONTEND_COOKIE_SCHEME) // special cookie due to having a separate back-end auth protocol
.AddMicrosoftIdentityWebApp(options =>
{  
    // config info removed to be concise
    options.SignInScheme = FRONTEND_COOKIE_SCHEME;
    options.SignOutScheme = FRONTEND_COOKIE_SCHEME;
    options.UseTokenLifetime = true;
    options.SaveTokens = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = ClaimTypes.NameIdentifier
    };
})
.EnableTokenAcquisitionToCallDownstreamApi()
.AddInMemoryTokenCaches();

We're looking to implement the Refresh Token process (refdoc: https://learn.microsoft.com/en-us/azure/active-directory-b2c/authorization-code-flow). I can confirm that the .well-known endpoint indicates this is enabled:

    "response_types_supported": [
        "code",
        "code id_token",
        "code token",
        "code id_token token",
        "id_token",
        "id_token token",
        "token",
        "token id_token"
    ],

I can also confirm that Step 1 of that process is being invoked - when hitting the auth endpoint, I get redirected to oauth2/v2.0/authorize?client_id={clientId}&redirect_uri={redirectUri}&response_type=code&scope=openid%20profile%20offline_access&code_challenge={codeChallenge}&code_challenge_method=S256&response_mode=form_post&nonce={nonce}&state={state}&ui_locales=en. However, when looking at the response I receive back, at the OnTokenValidated step, I notice that I don't see a code. I figured this is probably because the code is being consumed at the OnAuthorizationCodeReceived step instead, and was going to start implementing the process of getting the Refresh Token there.

However, as I started looking into this more, I noticed that there doesn't seem to be a whole lot of reference docs about doing this. I figured this would be something fairly common, and was surprised to not find any results - so, before I really go down this route, I wanted to ask whether this is actually the correct approach to be taking.

My question, specifically, is whether we, as consumers of the MicrosoftIdentityWebApp process, need to actually explicitly call out the Refresh Token process, or whether this is something that happens natively, in which case perhaps there is simply a config setting we need to enable?

UPDATE 1

So, I did some digging, and found that the MicrosoftIdentityWebApp does indeed handle the token exchange process. However, I've now encountered two new problems:

  1. In order to get the access_token as part of the default workflow, I needed to change the response_type and scope, specifically to go from response_type=code&scope=openid profile offline_access to response_type=code id_token token&scope=openid profile offline_access {clientId} (not sure if I specifically needed to add id_token, but figured it couldn't hurt). However, setting those parameters during the AddMicrosoftIdentityWebApp setup, specifically by setting options.ResponseType and options.Scope, didn't seem to do anything at all - when making the request it still defaulted just to doing what it had been set up with originally. To make it work, I needed to intercept the OnRedirectToIdentityProvider action, and add it in directly instead. Any idea why? Not really a huge concern to me, just curious as to why it wouldn't work at config level.

  2. Despite adding token to ResponseType, I don't seem to get a refresh_token as the docs indicate I should (https://learn.microsoft.com/en-us/azure/active-directory-b2c/authorization-code-flow#2-get-an-access-token). Looking at the token request itself, in the OnAuthorizationCodeReceived action, I see that the request being made looks correct, with one strange oddity - despite including offline_access earlier, the scope property doesn't seem to be set here. Not sure if this is what is causing the problem?

Access Token Request

  1. I tried removing id_token from the ResponseType list, and leaving just code token - strangely, when I do that, I no longer get back the access_token, and I still get the id_token.

Solution

  • So, in the end, we solved it using the following approach:

    1. In OnRedirectToIdentityProvider, in order to resolve the problems listed in point 1 of the update, we added the following code. I'm still not entirely sure why we needed to do this, but there didn't seem to be any other way to have the proper 302 redirect to authorize
    var defaultPolicy = _configuration.GetValue<string>($"{_policySectionName}");
    if (!context.Properties.Items.TryGetValue("policy", out var policy) ||
        policy.Equals(defaultPolicy))
    {
        var clientId = _configuration.GetValue<string>($"{_clientIdSectionName}");
        context.ProtocolMessage.ResponseType = OpenIdConnectResponseType.CodeIdTokenToken;
        context.ProtocolMessage.Scope += $" {clientId}";
    }
    
    1. In OnAuthorizationCodeReceived, we added the following code, to get the refresh_token:
    var data = new Dictionary<string, string>()
            {
                { "grant_type", _authorizationCodeTypeName},
                { "client_id", context.TokenEndpointRequest.ClientId },
                { "client_secret", context.TokenEndpointRequest.ClientSecret },
                { "code", context.TokenEndpointRequest.Code },
                { "redirect_uri", context.TokenEndpointRequest.RedirectUri },
                { "code_verifier", context.TokenEndpointRequest.Parameters["code_verifier"] }
            };
    
    var instance = _configuration.GetValue<string>($"{_instanceSectionName}");
    var domain = _configuration.GetValue<string>($"{_domainSectionName}");
    var policy = _configuration.GetValue<string>($"{_policySectionName}");
    var tokenUrl = $"{instance}/{domain}/{policy}/{_tokenPath}";
    
    var refreshTokenRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
    {
        Content = new FormUrlEncodedContent(data)
    };
    
    var response = await _client.SendAsync(refreshTokenRequest);
    
    if (response.IsSuccessStatusCode)
    {
        var retVal = await response.Content.ReadFromJsonAsync<RefreshTokenResponse>();
    
        if (retVal?.AccessToken != null && retVal?.RefreshToken != null && retVal?.IdToken != null)
        {
            identity.AddClaims(new List<Claim> {
                new(_accessTokenClaimName, retVal.AccessToken),
                new(_refreshTokenClaimName, retVal.RefreshToken)
            });
    
            if (context.Properties != null)
            {
                var accessToken = new JwtSecurityToken(retVal.AccessToken);
    
                context.Properties.IsPersistent = true;
                context.Properties.ExpiresUtc = accessToken.ValidTo;
            }
    
            context.HandleCodeRedemption(retVal.AccessToken, retVal.IdToken);
        }
    }
    
    1. In a custom ActionFilterAttribute, we added the following code to refresh the token:
    var accessTokenClaim = claimsIdentity.FindFirst(Constants.Authentication.AccessTokenClaimName);
    
    if (accessTokenClaim != null)
    {
        var jwt = new JwtSecurityToken(accessTokenClaim.Value);
    
        if (jwt.ValidTo < DateTime.UtcNow)
        {
            var refreshTokenClaim = claimsIdentity.FindFirst(Constants.Authentication.RefreshTokenClaimName);
    
            if (refreshTokenClaim != null)
            {
                var configuration = _configuration.Value;
    
                var data = new Dictionary<string, string>()
                        {
                            { "grant_type", Constants.Authentication.RefreshTokenTypeName},
                            { "client_id", configuration.GetValue<string>($"{Constants.Authentication.AzureAdB2cSectionName}:{Constants.Authentication.ClientIdSectionName}") },
                            { "client_secret", configuration.GetValue<string>($"{Constants.Authentication.AzureAdB2cSectionName}:{Constants.Authentication.ClientSecretSectionName}") },
                            { Constants.Authentication.RefreshTokenTypeName, refreshTokenClaim.Value }
                        };
    
                var instance = configuration.GetValue<string>($"{Constants.Authentication.AzureAdB2cSectionName}:{Constants.Authentication.InstanceSectionName}");
                var domain = configuration.GetValue<string>($"{Constants.Authentication.AzureAdB2cSectionName}:{Constants.Authentication.DomainSectionName}");
                var policy = configuration.GetValue<string>($"{Constants.Authentication.AzureAdB2cSectionName}:{Constants.Authentication.SignUpSignInPolicyIdSectionName}");
                var tokenUrl = $"{instance}/{domain}/{policy}/{Constants.Authentication.TokenEndpointPath}";
    
                var refreshTokenRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl)
                {
                    Content = new FormUrlEncodedContent(data)
                };
    
                try
                {
                    var response = await _client.SendAsync(refreshTokenRequest);
    
                    if (response.IsSuccessStatusCode)
                    {
                        var retVal = await response.Content.ReadFromJsonAsync<RefreshTokenResponse>();
    
                        if (retVal?.AccessToken != null && retVal?.RefreshToken != null)
                        {
                            claimsIdentity.RemoveClaim(accessTokenClaim);
                            claimsIdentity.RemoveClaim(refreshTokenClaim);
                            claimsIdentity.AddClaim(new(Constants.Authentication.AccessTokenClaimName, retVal.AccessToken));
                            claimsIdentity.AddClaim(new(Constants.Authentication.RefreshTokenClaimName, retVal.RefreshToken));
                        }
                        else
                            context.Result = logoutRedirect;
                    }
                    else
                        context.Result = logoutRedirect;
                }
                catch (Exception)
                {
                    context.Result = logoutRedirect;
                }
            }
            else
                context.Result = logoutRedirect;
        }
    }
    

    UPDATE

    Okay, so we did some testing on this, and unfortunately it doesn't quite solve the problem. While we are able to get the new refresh token, we were not able to update the Claims Principal / Claims Identity using this approach. We tried a whole bunch of things, including trying to use SignInAsync, however, nothing worked. In the end, we had to instead divide this into two halves.

    1. For the Refresh Token part, rather than storing it as a Claim, we store the Refresh Token as a cookie. Whenever the user browses the site, we check and see if their IPrincipal is authenticated and, if they are, we check and see if their Access Token has expired. If it has, we make a call to the /token endpoint using the Refresh Token and get a new Refresh Token and Access Token.
    2. Natively, the IPrincipal gets signed out from inactivity. When this happens, if there is a Refresh Token, we remove the refresh token and force a logout to clear any other remaining pieces.