Refresh access token when talking to Exchange Online via EWS Managed API

Viewed 2053

I am writing an application to communicate with Exchange Online using the EWS Managed API and authenticate my application via OAuth 2.0 utilizing ADAL library.

The access token expires after 60 minutes. After which I need to refresh the access token. Currently, I am doing it in StreamSubscriptionConnection OnNotificationEvent handler, as well as my OnDisconnect event handler to refresh the OAuth access token using the following code.

private void OnNotificationEventHandler(object sender, NotificationEventArgs args)
{
    exchangeService.Credentials = new OAuthCredentials(GetOAuthAccessToken().Result);

    // Do my work
}

I also added the same refresh access token code in my OnDisconnect event handler since StreamSubscriptionConnection is only kept open for 30 minutes at most.

private void OnDisconnectEventHandler(object sender, SubscriptionErrorEventArgs args)
{
    exchangeService.Credentials = new OAuthCredentials(GetOAuthAccessToken().Result);
    streamingSubscriptionConnection.Open();
}

Here is the code I have for access token.

private async Task<string> GetOAuthAccessToken(PromptBehavior promptBehavior = PromptBehavior.Auto)
{
    var authenticationContext = new AuthenticationContext(myAadTenant);

    var authenticationResult = await authenticationContext.AcquireTokenAsync(exchangeOnlineServerName, myClientId, redirectUri, new PlatformParameters(promptBehavior));

    return authenticationResult.AccessToken;
}

Even thought the above approach "works", I feel like this isn't the best way of handling the situation because I pretty much need to make sure I refresh my access token whenever I communicate with EWS. If I add another event handler and I forget to add token refresh logic inside the event handler, I could potentially get a 401 while handling that event if my access token expires and yet I need to call EWS in the event handler.

The code above is simplified, I could put try catch whenever I communicate with EWS and if I get 401, I refresh my access token and try again but that doesn't solve the inconvenience I mentioned above.

I think there should be an easier way to handle the problem but I haven't found the right documentations. Here is the material I referred to while doing my development. https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/

3 Answers

I agree that there should be a nicer way to handle token refresh. It would be nice if the EWS API itself could manage token refresh, but as a workaround what I did was..

Put the reference to the EWS service into a public/internal property that can a) instantiate the service if it has not yet been instantiated, and b) ensures the authentication token is still valid (and if not, then perform token refresh). Then we need to make sure that this property is the single access point to the EWS service.

Broadly, this looks like

public class Mailbox 
{
    private ExchangeService exchangeService;

    public ExchangeService ExchangeService
    {
        get
        {
            if (this.exchangeService == null)
            {
                // Initialise the service
                CreateExchangeService();
            }
            else
            {
                // Ensure token is still valid
                ValidateAuthentication();
            }

            return this.exchangeService;
        }
    }
}

I'm not going to detail the CreateExchangeService, but the ValidateAuthentication is a basic call to EWS that will throw an exception if unauthenticated

private void ValidateAuthentication()
{
    try
    {
        Folder inbox = Folder.Bind(this.exchangeService, WellKnownFolderName.Inbox);
    }
    catch //(WebException webEx) when (((HttpWebResponse)webEx.Response).StatusCode == HttpStatusCode.Unauthorized)
    {
        RefreshOAuthCredentials();
    }
}

RefreshOAuthCredentials will simply refresh the token. Similarly I also have a OnDisconnect event handler on the StreamingSubscription that will attempt to reopen the connection (and reauth if the reopen fails). Neither of these are shown here for brevity.

The main point though is that we now have a single reference to the EWS service that performs an authentication check and reauth (if necessary) prior to every call. For example binding to a mail message looks like..

var mailMessage = EmailMessage.Bind(this.mailbox.ExchangeService, itemId);

No doubt this adds traffic/latency which could be avoided if there was a better way. And if anyone has a better way - I'm all ears!

I had to deal with the same problem, in netcore app (using fork of the official EWS managed API client, which doesn't touch this aspect in any way). Proactively doing .Bind before the full request adds traffic/latency as mentioned in PaulG's answer. And is vulnerable to timing failures - the token can time out while the Bind itself is running, and next request will fail anyway.

Hope my of single-point "retry on 401" solution can help someone.

private async Task Example() {
   var view = new FolderView(100);
   view.Traversal = FolderTraversal.Shallow;
   view.Offset = 0;
   await EWS(service => service.FindFolders((FolderId)WellKnownFolderName.MsgFolderRoot, view));
}

private async Task<T> EWS<T>(Func<ExchangeService, Task<T>> action)
{
    // just creates instance/returns one from cache and does some other unimportant stuff
    var service = await _getExchangeServiceAndBuildFolderIdCache();
    try
    {
        return await action(service);
    }
    // catch (ServiceRequestException e) when ((e.InnerException as System.Net.WebException)?.Response.StatusCode == StatusCode401Unauthorized)
    // ^^ is the more correct version. Would be. Except e.InnerException.Response is already disposed here.
    // Next best option is to just do substring match on message (and hope localization doesn't mess it up).
    catch (ServiceRequestException e) when (e.InnerException?.Message.Contains("(401)") ?? false)
    {
        // this tries AcquireTokenSilent first (which has internal cache for access token, and transparently falls back to refresh token if one is available). If silent fails, it also tries full AcquireTokenXxx call
        service.Credentials = await GetEWSCredentials();
        if (service.Credentials == null)
        {
            throw;
        }
        return await action(service);
    }
}
Related