Jackson
Jackson

Reputation: 1805

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

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/

Upvotes: 4

Views: 4052

Answers (4)

huancz
huancz

Reputation: 375

I had to deal with the same problem, in a .NET Core 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);
    }
}

Upvotes: 0

gogo
gogo

Reputation: 3

There is a little bit subtle way to check if your token has expired. After you get your token

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

Your authenticationResult object has ExpiresOn property and you can check if your token has expired and then refresh it.

Something like:

if (DateTime.Compare(authenticationResult.ExpiresOn.LocalDateTime, DateTimeOffset.Now.LocalDateTime) <= 0)
   {
     // Do your refresh token logic
   }

Upvotes: 0

PaulG
PaulG

Reputation: 14021

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!

Upvotes: 0

Fei Xue
Fei Xue

Reputation: 14649

Another way is when you communicate with Exchange online via EWS Managed API, you need to provide the exchangeService object. And you need to catch the 401 exception for every request and after got this exception, you need to re-set the Credentials property for the exchangeService object or re-create this object.

Upvotes: 1

Related Questions