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?
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:
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.
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?
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
.So, in the end, we solved it using the following approach:
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}";
}
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);
}
}
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;
}
}
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.
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.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.