skip to content
Jerrie Pelser's Blog

Automatically refresh access tokens for 3rd party services

/ 9 min read

Introduction

When obtaining access tokens for calling external APIs, those access tokens are often valid for a short period of time. One such example is the access token obtained when authenticating using Google OAuth 2.0. In the case of Google, the access tokens expire after 60 minutes.

In this blog post I will demonstrate how you can seamlessly refresh the access token when making calls with an expired token.

Background

One of the many external APIs I connect with in Cloudpress is the Google Docs API. I use the Google Docs API to read the contents from a Google Doc, convert it to the correct format for the target Content Management System (CMS), and then export the content to the CMS.

Inside Cloudpress, I allow users to connect their Google account. During the Google OAuth flow, I request an access token and refresh token and store both of those in secure storage. When a user wants to export content for a specific document, I read the tokens for their connected Google account from the secure storage and use those to call the Google Docs API.

As mentioned in the introduction, Google access tokens expire after 60 minutes, so the odds are that, when I make the call to the Google Docs API, the access token has expired. In cases like this, the Google Docs API will return an HTTP status code 401 (Unauthorized).

There are a few ways to handle this.

  1. The first is to always refresh the token prior to making the API call. However, if a users export multiple documents in succession, this means I would needlessly request new access tokens every time.
  2. Google provides a token info endpoint (at https://oauth2.googleapis.com/tokeninfo?access_token=<your access token>) that allows you to inspect an access token. You could call this endpoint before making the request to the Google Docs API, check whether the access token is still valid and, if not, request a new one.
  3. The final way to handle this is to assume that the access token is valid. If the token is invalid, you can handle the error response, request a new access token, and retry the API call.

I chose the final method since this is fairly simple to implement with a Polly retry strategy. In the rest of this blog post, I will demonstrate how you can implement something similar in your application.

Overview of demo application

The accompanying demo application allows a user to connect any number of Google accounts. These accounts are stored in a SQLite database in the ConnectedAccounts table. After connecting their account(s), a list of connected accounts is displayed to the user.

User can connect and view a list of connected accounts

A user can select an account to view the files in the Google Drive for that account.

View Google Drive files for an account

Listing files

Let’s dive into the code.

I created a lightweight API wrapper class (and interface) using Flurl to list the files from a user’s Google Drive.

public interface IGoogleDriveApi
{
Task<string[]> ListFiles(string accessToken);
}
public class GoogleDriveApi(HttpClient httpClient, ILogger<GoogleDriveApi> logger) : IGoogleDriveApi
{
private readonly FlurlClient _flurlClient = new FlurlClient(httpClient);
public async Task<string[]> ListFiles(string accessToken)
{
var response = await _flurlClient.Request("files")
.WithOAuthBearerToken(accessToken)
.AppendQueryParam("orderBy", "recency")
.AppendQueryParam("pageSize", 10)
.GetJsonAsync<GetFileListResponseModel>();
return response.Files.Select(f => f.Name).ToArray();
}
}

Inside the application, I load the access token from the database and call the ListFiles() method passing the access token. Within the first hour of connecting an account, the API call will be successful.

API call with valid access token

After one hour, the access token have expired and calling the Google Drive API will result in an error, returning an HTTP status code 401 (Unauthorized).

API call with an expired access token

Notice that Flurl throws a FlurlHttpException. We’ll handle this exception shortly in our Polly resilience pipeline.

Refreshing the access token

Google provides an endpoint for refreshing access tokens. The first thing we need to do is to create another wrapper for this API.

public interface IGoogleAuthApi
{
Task<string> RefreshAccessTokenAsync(string refreshToken);
}
public class GoogleAuthApi(HttpClient httpClient, IConfiguration configuration) : IGoogleAuthApi
{
private readonly FlurlClient _flurlClient = new FlurlClient(httpClient);
public async Task<string> RefreshAccessTokenAsync(string refreshToken)
{
var tokenResponse = await _flurlClient
.Request("token")
.PostUrlEncodedAsync(new
{
client_id = configuration["Authentication:Google:ClientId"] ?? throw new InvalidOperationException("Google Client ID is missing"),
client_secret = configuration["Authentication:Google:ClientSecret"] ?? throw new InvalidOperationException("Google Client Secret is missing"),
grant_type = "refresh_token",
refresh_token = refreshToken
}).ReceiveJson<TokenResponse>();
return tokenResponse.AccessToken;
}
private record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] long ExpiresIn,
[property: JsonPropertyName("token_type")] string TokenType
);
}

The RefreshAccessTokenAsync method calls the https://oauth2.googleapis.com/token endpoint, passing the client_id, client_secret, and refresh_token as parameters. This will return a TokenResponse from which we will extract the new access token and return that to the caller.

Next, we’ll need to update our GoogleDriveApi class (as well as the IGoogleDriveApi interface). Let’s add a GoogleApiCredentials class and update the method signature of the ListFiles method to take an instance of this class as input parameter. We’ll also inject an instance of the IGoogleAuthApi implementation.

public class GoogleApiCredentials
{
public required string AccessToken { get; set; }
public required string RefreshToken { get; set; }
}
public interface IGoogleDriveApi
{
Task<string[]> ListFiles(GoogleApiCredentials credentials);
}
public class GoogleDriveApi(HttpClient httpClient, IGoogleAuthApi googleAuthApi, ILogger<GoogleDriveApi> logger) : IGoogleDriveApi
{
//... code omitted for brevity
public async Task<string[]> ListFiles(GoogleApiCredentials credentials)
{
//...
}
}

Next, we can create a Polly resilience pipeline. This pipeline will handle any FlurlHttpException where the StatusCode is Unauthorized (401). The retry code will make a call to the RefreshAccessTokenAsync method and update the AccessToken property of the GoogleApiCredentials instance.

public class GoogleDriveApi(HttpClient httpClient, IGoogleAuthApi googleAuthApi, ILogger<GoogleDriveApi> logger) : IGoogleDriveApi
{
//... some code omitted for brevity
private ResiliencePipeline CreateTokenRefreshPipeline(GoogleApiCredentials credentials)
{
return new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<FlurlHttpException>(exception => exception.StatusCode == (int)HttpStatusCode.Unauthorized),
MaxRetryAttempts = 1,
OnRetry = async (_) =>
{
logger.LogInformation("Refreshing Google access token");
var newAccessToken = await googleAuthApi.RefreshAccessTokenAsync(credentials.RefreshToken);
credentials.AccessToken = newAccessToken;
}
})
.Build();
}
}

The final piece of the puzzle is to wrap the call to the Google Drive API inside the new resilience handler.

public class GoogleDriveApi(HttpClient httpClient, IGoogleAuthApi googleAuthApi, ILogger<GoogleDriveApi> logger) : IGoogleDriveApi
{
//... some code omitted for brevity
public async Task<string[]> ListFiles(GoogleApiCredentials credentials)
{
var tokenRefreshPipeline = CreateTokenRefreshPipeline(credentials);
var response = await tokenRefreshPipeline.ExecuteAsync(async token => await _flurlClient.Request("files")
.WithOAuthBearerToken(credentials.AccessToken)
.AppendQueryParam("orderBy", "recency")
.AppendQueryParam("pageSize", 10)
.GetJsonAsync<GetFileListResponseModel>(cancellationToken: token));
return response.Files.Select(f => f.Name).ToArray();
}
private ResiliencePipeline CreateTokenRefreshPipeline(GoogleApiCredentials credentials)
{
//...
}
}

With this in place, we can run the application again and have a look at the log.

API call with expired access token
  1. The Google Drive API returns an HTTP Status code 401, since we are using an expired access token.
  2. The retry strategy is executed and we refresh the access token.
  3. The call to the Google Drive API is retried with the new access token and succeeds.

Saving access token back to database

As it stands, the code works correctly, but we never store the new access token in the database. This means that, once the original access token expires, we will continue using the original (expired) access token, resulting in a token refresh every time we call the Google Drive API.

Saving the new access token to the database is not the responsibility of the GoogleAuthApi or GoogleDriveApi classes as neither of them should care where the access token and refresh token is stored. Instead, we want to delegate this responsibility back to the caller.

First, let’s update the GoogleApiCredentials class and add a TokenRefreshed event handler.

public class GoogleApiCredentials
{
public required string AccessToken { get; set; }
public required string RefreshToken { get; set; }
public required Func<string, Task> TokenRefreshed { get; set; }
}

Next, we can update our resilience pipeline to invoke the TokenRefreshed event.

public class GoogleDriveApi(HttpClient httpClient, IGoogleAuthApi googleAuthApi, ILogger<GoogleDriveApi> logger) : IGoogleDriveApi
{
//... some code omitted for brevity
private ResiliencePipeline CreateTokenRefreshPipeline(GoogleApiCredentials credentials)
{
return new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<FlurlHttpException>(exception => exception.StatusCode == (int)HttpStatusCode.Unauthorized),
MaxRetryAttempts = 1,
OnRetry = async (_) =>
{
logger.LogInformation("Refreshing Google access token");
var newAccessToken = await googleAuthApi.RefreshAccessTokenAsync(credentials.RefreshToken);
credentials.AccessToken = newAccessToken;
await credentials.TokenRefreshed(newAccessToken);
}
})
.Build();
}
}

And finally, inside the Razor page where we call the ListFiles method, we can save the new access token back to the database.

public class FilesModel(ApplicationDbContext dbContext, IGoogleDriveApi googleDriveApi) : PageModel
{
//... some code omitted for brevity
public async Task<IActionResult> OnGet(int accountId)
{
Account = await dbContext.ConnectedAccounts.FindAsync(accountId);
if (Account == null)
{
return NotFound();
}
Files = await googleDriveApi.ListFiles(new GoogleApiCredentials
{
AccessToken = Account.AccessToken,
RefreshToken = Account.RefreshToken,
TokenRefreshed = async accessToken =>
{
Account.AccessToken = accessToken;
await dbContext.SaveChangesAsync();
}
});
return Page();
}
}

With this in place, refresh access tokens are stored in the database and will be used next time we call the Google Drive API.

Conclusion

In this blog post I demonstrated how you can use a Polly retry handler to automatically refresh expired access tokens and retry the original API call with the new access token. I like this method as it hide the complexity of dealing with expired access tokens from the consumer of the API.

The code for the demo application can be found at https://github.com/jerriepelser-blog/auto-refresh-access-tokens.