AzureAD/azure-activedirectory-library-for-dotnet

Refresh Token not working in ADAL 5.2.2

jcus0006 opened this issue · 15 comments

Which Version of ADAL are you using ?

Latest stable - 5.2.2

Which platform has the issue?
.Net framework 4.6

What authentication flow has the issue?

Web App - Authorization Code

Is this a new or existing app?
This is an existing app, however, this is the first time we are integrating it with Azure AD.

Repro

app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType)
            app.UseCookieAuthentication(New CookieAuthenticationOptions With { 
                .AuthenticationType = CookieAuthenticationDefaults.AuthenticationType })
            app.UseOpenIdConnectAuthentication(New OpenIdConnectAuthenticationOptions With {
                .ClientId = clientId,
                .Authority = aadAuthority, 
                .RedirectUri = redirectUrl,
                .PostLogoutRedirectUri = postLogoutRedirectURL,
                .Scope = OpenIdConnectScope.OfflineAccess,
                .ResponseType = OpenIdConnectResponseType.CodeIdToken,
                .TokenValidationParameters = New TokenValidationParameters() With {
                    .ValidateIssuer = False
                },
                .Notifications = New OpenIdConnectAuthenticationNotifications() With {
                    .SecurityTokenValidated = Function(context) Task.FromResult(0),
                    .AuthorizationCodeReceived =
                        Function(context)
                            Dim code = context.Code
                            Dim credential As ClientCredential = New ClientCredential(clientId, aadAppKey)                         
                            Dim signedInUserID As String = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value                           
                            Dim authContext As Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext = New Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext(aadAuthority, New ADALTokenCache(signedInUserID))
                            Dim tempResult = authContext.AcquireTokenByAuthorizationCodeAsync(code, New Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Path)), credential, aadGraphResourceID)

                            Dim result As AuthenticationResult = tempResult.Result

                            Return Task.FromResult(0)
                        End Function,
                    .AuthenticationFailed =
                        Function(context)
                            context.OwinContext.Response.Redirect("/login.aspx")
                            context.HandleResponse()
                            Return Task.FromResult(0)
                        End Function
                    }
                })
Dim clientcred As ClientCredential = New ClientCredential(aadClientId, aadAppKey)
            Dim authenticationContext As AuthenticationContext = New AuthenticationContext(aadInstance & aadTenant, New ADALTokenCache(uId))
            Dim authResult As AuthenticationResult = Nothing
            Dim adalExceptionBool As Boolean = False
            Dim adalExceptionMessage As String = ""

            Try                 
                authResult = Await authenticationContext.AcquireTokenSilentAsync(aadGraphResourceID, aadClientId)

                Dim userDetails As UserInfo = authResult.UserInfo

                Dim claimsList As New List(Of Claim)

                claimsList.Add(New System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, userDetails.GivenName & " " & userDetails.FamilyName))
                claimsList.Add(New System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Upn, userDetails.DisplayableId ))
                claimsList.Add(New System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, uId))

                Dim claimsIdentity = New ClaimsIdentity(claimsList, CookieAuthenticationDefaults.AuthenticationType)
               
                HttpContext.GetOwinContext().Authentication.SignIn(New AuthenticationProperties With {.RedirectUri = "/", .AllowRefresh = True}, claimsIdentity)

            Catch adalException As AdalException
                If adalException.ErrorCode = AdalError.FailedToAcquireTokenSilently OrElse adalException.ErrorCode = AdalError.InteractionRequired Then
                    adalExceptionBool = True
                    adalExceptionMessage = adalException.Message
                End If
            End Try

Expected behavior
In the configuration we are setting 'offline_access' as scope; online research seemed to suggest that this enables the refresh token to be cached along with the access token. When logging in using the username and password, the AuthorizationCodeReceived handler is hit and the token is cached into the ADALTokenCache custom db. The expected outcome is that next time a user can log in silently using the cached token, even past its expiry date (via a refresh of the access token).

Actual behavior

If using the AcquireTokenSilentAsync within the hour time frame (before expiry) with the 'offline_access' scope set within the Open ID Configuration, the authResult.UserInfo object (which is used to build the ClaimsList for ClaimsIdentity) only returns null values. In this case we built the ClaimsIdentity using hard-coded values for Name, Upn, NameIdentifier, and the AcquireTokenSilentAsync method works successfully. The UserInfo object used to return valid values (non-null) when we were using 'openid profile' as Scope, but we think we need offline_access for refresh token purposes. We also tried to combine the Scopes, e.g. 'openid profile offline_access' but the UserInfo object still returns null values, whenever 'offline_access' is present within the scope.

If beyond the expiry date, the following exception is returned: Failed to acquire token silently as no token was found in the cache. Call method AcquireToken.

The questions:

  1. What are the scope/s to be used in this case? How should they be set?
  2. Is it the correct way to use the Acquire Token Silent Async?
  3. Why doesn't the refresh token work when the access token has expired?

@jcus0006 given you start an integration with Azure AD, I recommend that you use MSAL.NET (not ADAL.NET, which is in maintenance mode). You'll find ASP.NET samples here: https://docs.microsoft.com/en-us/azure/active-directory/develop/sample-v2-code#web-applications

You'll find many answers to your questions in this article: Web app that signs-in users and the following articles, in particular about the scopes to request and the way to use AcquireTokenSilent/AcquireTokenXXX (on the Controller)

In your code above, can you share what the authority is? I suspect that this is not Azure v2.0, but Azure AD v1.0? and therefore that you'd need resources, not scopes

Finally, I'd think that the reason why the user needs to re-sign-in is not so much because of the refresh token, but because of the session cookie, which has expired.

HTH

Tried to use the v2.0 endpoint: https://login.microsoftonline.com/tenantname/v2.0 as Authority and got this error: ADALService exception: AADSTS90002: Tenant 'v2.0' not found

Then reverted to the v1.0 endpoint: https://login.microsoftonline.com/tenantname. Went into the App Registration in the Azure portal and set the permissions as shown below (I suspect that these are the resources?).

image

In the app.UseOpenIdConnectAuthentication configuration I removed the scopes param since with v1.0 endpoint these are not actually used.

And once again AcquireTokenSilentAsync works within the hour time frame and asks for re-acquiring token once expired.

Hi @jmprieur , did you by any chance have the time to look into my comments above? We are still facing this issue. We are planning to do the migration to MSAL, yet we're not sure whether that would fix our Refresh token problem! Thanks.

@jcus0006, sorry for the delay.

The access token and the refresh token for the user go in the token cache which is in SQL, however the key to this cache is something about the signed-in user (signedInUserId), in your case context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value

The thing is that the AuthenticationTicket is held in the AuthenticationCookie.
See CookieAuthenticationOptions Class.

There are properties to set the ExpireTimeSpan, for instance, which would enable you to make the session longer.

About the fact that using the v2.0 authority did not work: this is expected, you'd have to move to MSAL so that it works (ADAL uses the v1.0 authority, MSAL the v2.0 authority).

Hi @jmprieur - Thanks for your answer. I have tried setting the 'ExpiresUtc' to 'DateTimeOffset.UtcNow.AddDays(90)', and tested in a normal single browser environment. It worked, the app will be logged in for that particular browser, even over-night after having turned off the machine.

I wanted to try to extend the idea to multiple browsers on different machines, all using the same user-based cached token.

So the first time, the token is retrieved via AcquireTokenByAuthorizationCodeAsync method after logging using the Azure AD credentials (user & pass). The token is persisted inside the database.

After the token is saved the first time, I want users to be able to login using a system generated username and pin (the idea of the username and pin is to facilitate and make the process faster), and acquire the persisted token silently via the AcquireTokenSilentAsync method (queried by the signedInUserId). This should be possible for the period of the token's validity and until the refresh token allows so. At which point users will be asked to authenticate again using Azure AD credentials.

So based on your idea that the session cookie may have been the issue and not necessarily the refreshing process is not working; I thought why not first use the HttpContext.GetOwinContext().Authentication.SignIn method with the ClaimsIdentity object to login the user and obtain a valid session cookie, and then call the AcquireTokenSilentAsync method to refresh the access token? This way we ensure the session cookie is refreshed, and its 'ExpiresUtc' property can again be extended to 'DateTimeOffset.UtcNow.AddDays(90)'.

So I tested again as explained above and from a different browser than the one where the actual Azure AD credential login was done (incognito-mode in Chrome):

If less than an hour has passed from obtaining the token by the authorization code, the process works.

If more than an hour has passed, I get this error with Message: Failed to acquire token silently as no token was found in the cache. Call method AcquireToken, and, Inner Exception: AADSTS7000218: The request body must contain the following parameter: 'client_assertion' or 'client_secret'.

Does the idea make sense? And how about the error? Why could this be happening?

@jcus0006 thanks for your update.
The inner exception does not make sense to me.
Could you please share a stack trace? I'd like to understand which override of AcquireToken was used?

Hi @jmprieur - So it seems I have managed to solve this issue. I researched about the 'client_assertion' or 'client_secret' error and then tried a different approach to acquire the token silently.

Refer to the AcquireTokenSilentAsync method below. I am using another overload which requires an extra parameter with the UserIdentifier constructor. This takes the object identifier claim as parameter (I am persisting it upon credential login first time round and using it in the silent login going forward).

authResult = Await authenticationContext.AcquireTokenSilentAsync(aadGraphResourceID, clientcred, New UserIdentifier(adObjectId, UserIdentifierType.UniqueId))

That seems to have solved the latest error and the token seems to be silently refreshed, also beyond the 1 hour mark. I will be closing this issue. Many thanks :)

Thanks for the update @jcus0006. I'm glad it works for you now.

Hey @jcus0006 , I am facing the same issue, can you please tell me what is adObjectId in
authResult = Await authenticationContext.AcquireTokenSilentAsync(aadGraphResourceID, clientcred, New UserIdentifier(adObjectId, UserIdentifierType.UniqueId)) ?

Hi @Druffl3 - The adObjectId refers to the Unique Object ID of the user within the Azure Active directory.

Within the User Profile, you can view this ID under Object ID.

After success credential login, using the Authorization Code Grant flow (Azure AD OAuth 2.0 method), the User Object ID is returned as part of the Claims.

I am extracting it as below -

ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value

Thanks for the response @jcus0006 . Yes, in ASP.NET Core we can get the principal as you have mentioned. Since, I was trying it in Xamarin.Forms (.NET Standard), I found out that the user ID will be available in the AuthenticationResult after successful token acquire. Yet, to find out if this works on UWP though :/

Even that didn't work for me. I got the UniqueId after successful AcquireTokenAsync and stored in local.

var objId = authResult.UserInfo.UniqueId;
Preferences.Set("UniqueId", objId);

And consumed it in AcquireTokenSilentAsync like this:

var id = Preferences.Get("UniqueId", "");
if(string.IsNullOrEmpty(id))
    authResult = await authContext.AcquireTokenSilentAsync(graphResourceUri, clientId);
else
   authResult = await authContext.AcquireTokenSilentAsync(graphResourceUri, clientId, new UserIdentifier(id, UserIdentifierType.UniqueId));

Yet, I receive this error after 1 hour:

Failed to acquire token silently as no token was found in the cache. Call method AcquireToken
at Microsoft.IdentityModel.Clients.ActiveDirectory.Internal.Flows.AcquireTokenSilentHandler.SendTokenRequestAsync()
at Microsoft.IdentityModel.Clients.ActiveDirectory.Internal.Flows.AcquireTokenHandlerBase.d__62.MoveNext()

Shouldn't the below line

new UserIdentifier("", UserIdentifierType.UniqueId)

actually be

new UserIdentifier(id, UserIdentifierType.UniqueId)

If the missing "id" is actually just a typo, can you make sure the Object ID for the user in question actually matches the Object ID in the Azure AD?

Are you creating the AuthenticationContext object with the ADALTokenCache object? Are you using the ADALTokenCache model as offered within the documentation? The token needs to be uniquely identified. The documentation suggests using the "SignedInUserID". This ID is also retrievable from the Claims; it's called "NameIdentifier" if I am not mistaken.

Are you ensuring the token to be actually stored in the database with the right "SignedInUserID" before attempting to refresh it after 1 hour?

Are you sure the login session is not invalidating the token for other reasons? In my initial problem, my login cookie was becoming expired within 1 hour; the fix was to extend it to the same expiry date as the refresh token.

Shouldn't the below line

new UserIdentifier("", UserIdentifierType.UniqueId)

actually be

new UserIdentifier(id, UserIdentifierType.UniqueId)

If the missing "id" is actually just a typo, can you make sure the Object ID for the user in question actually matches the Object ID in the Azure AD?

Yes, sorry, it was a typo(Have corrected it in my edit).

Are you creating the AuthenticationContext object with the ADALTokenCache object? Are you using the ADALTokenCache model as offered within the documentation? The token needs to be uniquely identified. The documentation suggests using the "SignedInUserID". This ID is also retrievable from the Claims; it's called "NameIdentifier" if I am not mistaken.

Yes, If TokenCache is already available then I am creating an AuthenticationContext using the cached Authority like this:
authContext = new AuthenticationContext(authContext.TokenCache.ReadItems().First().Authority);
Although, I need to look into the use of "SignedInUserId". Could you share the document that you are referring to?
I followed this sample.

Are you ensuring the token to be actually stored in the database with the right "SignedInUserID" before attempting to refresh it after 1 hour?

Can you please tell me how to verify this?

Are you sure the login session is not invalidating the token for other reasons? In my initial problem, my login cookie was becoming expired within 1 hour; the fix was to extend it to the same expiry date as the refresh token.

The tokens expire after 1 hour as the default configured value. I don't have access to the azure portal as another team is taking care of it. Even if the expiry time is modified to match that of refresh token, would AcquireSilentTokenAsync work after the TTL and not fail with the same exception?