.NET - OAuth2 Workflow: Part 3 - Refresh Token

garth
Community Champion
5
3595

If you have arrived here from scratch, you may want to work through the first two parts of this blog and come back:

At the end of .NET LTI Project - Part 2 - Launch Request we were authenticating and grabbing an access token for the logged in user.  But, each time the application was launched it was creating a new token, filling the user profile with multiple Access Tokens that would need to be manually deleted.

This is messy, and for a user who keeps an eye on their profile will likely look suspicious.  So there are two possible solutions to prevent the build up of excess useless tokens:

  1. Take advantage of the "refresh token", and only ever create a single access token
  2. Delete the token each time a user session expires, or when the user exits your application

The good news is, the heavy lifting is done.  Only some logic to be added to leverage the existing methods that we have already worked through.

To follow along with this post, download the associated source code from this git branch:

Taking advantage of the refresh token

If you users will be spending more than one hour at a time in your application, or if they will be leaving and coming back at a later date, refreshing the token you already have is something you should consider.  At the very top of the Canvas OAuth2 document, in a bright orange box, it clearly states that a token has a 1 hour life span, and refresh tokens must be used:

240214_oauth2box.png

Database Change

For the sake of this simple demonstration project, I've added a field to the [accessTokenCache] table.

In the SqlServer folder, execute the accessTokenCache.sql script to recrete the table with the new field.

This is not the ideal place to store this value, in a live environment this would be an actual config varible.  

However, this will allow the code in this part of the project to grab this value for you, for the sake of the exercise.

Detecting the Existing Access Token

The logic changes for this demo application to handle the token refresh is restricted almost exclusively to the HomeController.oauth2Request() method.  Storage and retrieval of the access tokens was included in

.NET - OAuth2 Workflow: Part 2 - Access Token & State

Of course we are still validating the OAuth signature, to ensure the LTI launch request is coming from a valid source.

Now, as soon as we validate the signature, we try to retrieve a previously stored token for the user.

If we found a toke, we check the life of that token to see if it needs to be refreshed.  To handle this I added a simple property to userAccessToken:  tokenRefreshRequired

If we have found an existing token, and it requires a refresh, we call our existing method: requestUserToken

This time when we make the call we pass a different value for grantType:  refresh_token

The existing logic will take over, and Canvas will return to you a new token.

The user token can expire at any time during the use of your application, not just during the initial LTI launch.  Once the user has launched your application, your application will likely continue to make API calls to Canvas for specific information.  If the user stays active in your application for one hour or more, the token will expire on the Canvas server and you will need to refresh.  Each time you prepare to make an API call you need to check to see if the token has expired, and if it has you need to execute the token refresh logic.

Scenarios to get you thinking about testing:

  • Delete the access token in the Canvas user profile.  How do you want to handle that?
  • Manually set the token timestamp in the database to force a token refresh.  Where in your application do you need to consider token expiration?
  • Delete the access token from the database.  What affect does this have on the Canvas user profile?

You can test this scenario with this simple demo application.  As your business logic becomes more complex you will need to consider handling this potential scenario in all appropriate places.  

OAuth2 Logout

If you want to delete user access tokens as you go, then you want your application to execute the OAuth2 Logout.

This is how you clean up the list of your application tokens in the user profile settings.

The sample application for this blog includes a simple "Logout" button and associated method to execute the logout API call.  This example also shows how to recover the LTI parameters from the database, where we stored them in the [stateCache] table.

For purposes of a simple demonstration, the unique state id is stored on the client page in a hidden field named "stateToken".  The value of the "stateToken" is passed back to the client during validation, the submitted back to the server when the logout button is clicked.  Passing the state id in this fashion allows you to continue to track the client even when they have cookies disabled.

Hidden fields are not necessarily the best way to do this, but is very easy for the purpose of this simple demo app.

Do your own research on state-less applications and decide on what method is best for you.

Closing Comments

This three part blog should give you an overview of OAuth2, and hopefully help some get over the hump.

If anyone has ideas to add, please comment and start a discussion.

Keep in mind that this application is meant only as a deomonstration of how to execute the OAuth2 workflow.  As you develop your own applications, make sure you flush out your test scenarios and understand what the expected behavior is.

5 Comments
barbaracoen
Community Contributor

Hi Garth  @garth 

We have implemented the oAuth solution to enable users to login to Canvas from our website, however we don't particularly want to have to ask the user to authorize the oAuth request every time they go into a course from the website as it's not a great user experience to be prompted to do this every time.  Do you have any thoughts on how we might get around this or is there a different solution we should use to oAuth.  Needless to say we're trying to avoid storing passwords outside of Canvas.

Thanks
Barbara

oauth‌

oauth2‌

garth
Community Champion

sorry for the late response, i replied to your direct contact, i hope the info helps.

barbaracoen
Community Contributor

Hi 

We didn't manage to solve it ... the user is prompted to authenticate every time, which is definitely annoying ;( 

The only way around this would be to have the primary password managed outside of Canvas and always passed through but as we added this later and passwords were already setup in Canvas this wasn't really an option for us.

Thanks
Barbara

garth
Community Champion

 @barbaracoen ‌ you will need to cache the user token in your database.

The first time the user launches your application they accept the OAuth prompt and authorize your application to run with their identity, and your application is assigned an access token (which the user can revoke at any time in their settings)

You need to cache that token data.

The next time the user logs in, grab their user id from the LTI data and lookup their token data in your database.

If you don't find it then you have to redirect to the OAuth authorization page.

If you do find it, use it and bypass the OAuth authorization page.

Bottom line, you will need to cache data in a database that is accessible by your LTI application.

Let me know if this helps.

mpiedra
Community Member

Hi @garth  thanks for your post, it is very useful, right now I am working to connect with the Canvas in Core 3.1  API and  I have this issue, and I have no idea how to fix it. 

the Error that I have received is this 

Exception: The oauth state was missing or invalid.

Unknown location

Exception: An error was encountered while handling the remote login.

Microsoft.AspNetCore.Authentication.RemoteAuthenticationHandler<TOptions>.HandleRequestAsync()

in my URL I have this 

https://localhost:44388/oauth/callback?error=invalid_scope&error_description=A+requested+scope+is+in...

my code is this 

 services.AddAuthentication(config =>
            {
                config.DefaultAuthenticateScheme = "CanvasCookies";
                config.DefaultSignInScheme = "CanvasCookies";
                config.DefaultChallengeScheme = "CanvasLMS";
            })
                .AddCookie("CanvasCookies")
                .AddOAuth("CanvasLMS", config => 
                {
                    var canvas_domain = Configuration.GetValue<string>("Canvas:Domain");
                    var client_secret = Configuration.GetValue<string>("Canvas:Secret");
                    var client_id = Configuration.GetValue<string>("Canvas:Client_id");

                    config.ClientId = client_id;
                    config.ClientSecret = client_secret;
                    
                    config.CallbackPath = new PathString("/oauth/callback");
                    //config.Scope.Add("google.com")


                    config.AuthorizationEndpoint = $"{canvas_domain}login/oauth2/auth";
                    config.TokenEndpoint = $"{canvas_domain}login/oauth2/token";
                    config.UserInformationEndpoint = $"{canvas_domain}api/v1/users/xxxx/courses";

                    config.SaveTokens = true;
         

                    config.Events = new OAuthEvents()
                    {
                        OnAccessDenied = async context =>
                        {
                            Console.WriteLine($"***** Access Denied: {context.AccessDeniedPath.Value}");

                        },
                        OnRemoteFailure = async context =>
                        {
                            Console.WriteLine($"***** Failure: {context.Failure.Message}");
                            Console.WriteLine($"***** StackTrace: {context.Failure.StackTrace}");
                        },
                        OnCreatingTicket =  context =>
                        {
                            var accessToken = context.AccessToken;
                            var base64payload = accessToken.Split('.')[1];
                            var bytes = Convert.FromBase64String(base64payload);
                            var jsonPayload = Encoding.UTF8.GetString(bytes);
                            var claims = JsonConvert.DeserializeObject<Dictionary<string, string>>(jsonPayload);

                            foreach(var claim in claims)
                            {
                                context.Identity.AddClaim(new Claim(claim.Key, claim.Value));
                            }

                            return Task.CompletedTask;
                        }

 Right now I know that I have to refresh the token, but my code never execute that part  (in the controller )

 

When I call this

       [Authorize]
        public async Task<IActionResult> Secret()
        {
            var serverResponse = await AccessTokenRefreshWrapper(
                        () => SecuredGetRequest("https://localhost:44388/secret/index"));

            var apiResponse = await AccessTokenRefreshWrapper(
                () => SecuredGetRequest("https://localhost:44388/secret/index"));
return View();
        }

The code fail  in OnRemoteFailure

Any idea what i doing wrong??

thanks !