OAuth 2.0 with IMAP/SMTP for Microsoft Outlook.com and Google Gmail in ASP.NET MVC5 applications

This is the older version of OAuth 2.0 tutorial for web apps. It was intended for classic ASP.NET (not ASP.NET Core). Also, it assumes that Microsoft OAuth provider does not support localhost redirect_uri (which is no longer true) and explains how you can create a custom local domain. You can follow simpler route explained in the modern version of this tutorial which focuses on creating OAuth-enabled ASP.NET Core 2.0 app.

If you're looking for Office 365 OAuth 2.0 support, refer to OAuth 2.0 with IMAP/SMTP for Office 365 in ASP.NET Core 3.1 MVC applications guide.

Introduction

You can create a C# or VB web application which is able access Microsoft or Google account of a user via IMAP and SMTP without knowing the password of this user. This works with all the domains these providers support, such as:

We developed OAuthMVC5 sample which encapsulates the matters mentioned in this tutorial. You can open/edit this sample (see Get source code for details) or create your own ASP.NET project.

To write or build OAuthMVC5 sample, you need at least Visual Studio 2013 with .NET 4.5.1, MVC5, OWIN/Katana, ASP.NET Identity 2.0, and provider-specific OAuth 2.0 libraries - Microsoft's DotNetOpenAuth and Google APIs.

Update: Visual Studio 2015 and 2017 with .NET 4.6 is supported as well. However, .NET Core is not supported because Microsoft and Google OAuth 2.0 client libraries have't been ported to .NET Core yet.

The sample can work via both plain http and secure https. This tutorial includes the instructions for configuring local IIS Express server in both http and https modes on localhost and custom domains.

MailBee.NET Objects library provides means of OAuth 2.0 capable IMAP/SMTP client.

OAuthMVC5 sample was developed in both C# and VB versions.

Note on Google Service Accounts

Google Apps platform provides the special way of accessing user accounts in your domain with Service Accounts. IT'S VERY SIMPLE and can be used for both installed and web apps just the same way. See Google OAUTH2 for Service Accounts tutorial to learn more.

Microsoft does not yet support the concept of service accounts.

MVC5 OAuth 2.0 technology features and limitations

The current version of MVC (MVC5 at the moment of writing) provides native means of external authentication (including OAuth 2.0) using OWIN interface and its implementation for Windows, Katana.

However, this API and Visual Studio MVC5 template provide only basic means of OAuth 2.0 functionality, while some important matters like refreshing short-lived access tokens are not fully covered. It also lacks the ability to share access tokens obtained during OAuth process with other applications (such as Windows services which need to perform background activities in the user's account). This tutorial aims to address these issues as well.

OAuthMVC5 project summary

OAuthMVC5 sample highlights these aspects related to OAuth 2.0 development with ASP.NET MVC5:

Most of the provider-specific code resides in a separate ExternalProviders library which is, just like OAuthMVC5 core project, available in both C# and VB versions.

The steps to get things done

The below is the common description of the procedure. Every step will be explained in detail in subsequent sections.

Other steps are only needed if you're creating a new ASP.NET project (rather than using OAuthMVC5 sample):

Considerations for "web server is your local machine" case

If you develop on your local machine (IIS Express), it can be tricky to support both Microsoft and Google providers for the same application, especially if you want to use HTTPS. This is because Microsoft and Google may provide the means for accessing local servers differently. This tutorial provides the unified way which works for both providers simultaneously.

However, if you need to support only a single provider, you can configure things in a simpler way (this tutorial covers the simple way as well).

In short, Google allows your project to reside at localhost domain (the default domain in IIS Express) while Microsoft does not allow for localhost name, they require you to have some unique string in the domain name instead.

Thus, if you want to support Microsoft (or both Microsoft and Google) in your web app, your local URL must be http://unique-string.com[:port]. Port can be omitted in theory but local IIS Express usually runs on non-standard ports so you'll need it.

The above covers the situation at the moment of writing. For instance, Microsoft or Google may decide to require https someday. This tutorial also provides HTTPS way as well, and https://unique-string.com[:port] approach is unlikely to get outdated by Microsoft or Google in the foreseeable future.

EDIT As of 2019, Microsoft now permits using localhost (just like Google). No need to create a custom local domain any longer. You can now use http://localhost:port for both Microsoft and Google.

Register Google project

Skip this if you target Microsoft provider only.

Google might be very restrictive in authorizing your app for SMTP/IMAP access via OAuth 2.0. You may have to spend some time and efforts to convince their techsupport that your app indeed needs IMAP/SMTP and cannot use their proprietary REST API for that.

First, you need to register a project in your Google Developer Console:

If you already have a Google project where you have credentials for an installed application, you can just create web application credentials for that project instead of creating a new project (useful in case if you want to have both web and Windows installed applications use the same credentials).

Now, at "API Manager/Credentials/OAuth consent screen" section specify your support e-mail address and your product name. People will see this info when authorizing your application on access to your mailbox:

Click Save.

Then, at "API Manager/Credentials/Credentials" section click Create Credentials and select OAuth Client ID. Create a new Client ID selecting "Web application" type, you can leave any name for client ID.

In this chapter, https protocol is being used for screenshots and examples. Use http in all URLs if you don't need https for your development server.

If you plan to support Google only (and drop Microsoft), use localhost domain:

Or, if you want to support both Microsoft and Google, use https://unique-string.com:44301/ instead of https://localhost:44301/.

I originally developed localhost version and then edited it to idh.mailbee.test.com for compatibility with Microsoft provider (but I could have used idh.mailbee.test.com from the very beginning with the same effect):

With localhost or unique-string.com choice, you can follow any of these three routes:

  1. Use localhost domain only (if Google is all you need).

  2. Start with localhost domain, quickly make Google work with your app, then change it to unique-string.com and make Microsoft work as well.

  3. Use unique-string.com domain from the very beginning and do both Microsoft and Google at once.

For #2 and #3 it's up to you to decide if you want to split a bigger task into two smaller ones or solve it as a whole. Just note that configuring IIS Express and Windows for https://localhost is much easier than for https://unique-string.com. For plain http, http://unique-string.com is not so hard to configure but http://localhost is still easier.

Now, let's have a closer look at the settings.

Authorized JavaScript Origins must point to the URL of your application.

In Google-only case, we set it as https://localhost:44301/ for the following purposes:

In case of Microsoft+Google, there is https://idh.mailbee.test.com:44301/. You can use something like https://whatever-but-unique.com:44301/ (Microsoft won't allow you to use the domain name someone else has already used for their project).

Authorized Redirect URI denotes the particular URL where Google will redirect the application upon successful authorization. Set it to:

Or, if you're on http:

EDIT As of 2019, you can use localhost for both Microsoft and Google. Custom domain is no longer necessary.

MVC5 template in Visual Studio by default reserves /signin-google as the redirect landing page so we tell Google to redirect there. Actually, it can be changed but we're fine with the default value.

Now, in "API Manager/Credentials/OAuth consent screen" section specify your support e-mail address and your product name. People will see this info when authorizing your application to access their mailboxes:

Also, make sure you have email, profile and openid scopes added in Scopes for Google APIs section:

Google Client ID and Client Secret

In "API Manager/Credentials/Credentials" section, click your web client's name to grab your Client ID and Client Secret which you'll specify in your ASP.NET app later:

Enable access to Google e-mail address, IMAP and SMTP services

You will also need to go to "API Manager/Library" and locate "Gmail API" and "Google+ API":

Click and enable "Gmail API" to let your application authenticate in Gmail services with XOAUTH2 (OAuth 2.0 extension for IMAP and SMTP):

Finally, repeat the same for "Google+ API" to let your application see the e-mail address of the user:

There can be a few minutes delay on Google end for these changes to take effect.

Register Microsoft project

Skip this if you target Google provider only.

Create an application in Application Registration Portal. You may need to create an account there first.

For OAuth 2.0, you create a Live SDK application. Click Add an app in Live SDK applications section.

EDIT As of 2019, creating Converged application seems to work better because it allows you to use localhost endpoints on your local server rather than custom domain (which can be tricky).

On the next screen, click Add Platform and and select Web application, then save changes. You'll be redirected to the App Registration page:

Application ID (Client ID) and password (application secret) are there as well.

You'll need to use your own unique domain name in Redirect URLs. This tutorial assumes you'll enter the same domain name and port you set for Google project (this will let you use the same ASP.NET app for both Microsoft and Google).

As you can see on the screenshot, we're using https there. You can use http if this is your development server. However, in production it's strongly recommended to use https anyway.

Finally, /signin-microsoft part of the URL is the default redirect target page for Microsoft provider in the MVC5 template.

Microsoft Client ID and Client Secret

On the same App Registration page, you can grab your Client ID and Client Secret which you'll specify in your ASP.NET app later:

In case if you created a Converged application (not Live SDK application), make sure to add the required permissions (scopes) as well:

Create Visual Studio MVC5 project for Google OAuth provider

Run Visual Studio as Administrator (might be needed for IIS Express configuration in case if you're using custom domain name, not localhost).

Instead of creating a new ASP.NET app, you can open OAuthMCV5 sample.

First we'll create a project which is configured to support Google (runs on localhost), and then modify it to support Microsoft as well (custom-domain-name).

Create a new ASP.NET Web Application (C# or VB) and click OK:

If you have multiple choices there, select ASP.NET Web Application (.NET Framework):

Select MVC template and leave other values in their default state (Individual User Accounts, no Azure, etc):

In Visual Studio 2013/2015/2017, this creates a so-called ASP.NET MVC5 project.

Now, if you want https for your app, enable SSL in the project:

Visual Studio assigns the SSL port automatically. In case if it's not 44301 (or whatever you specified when creating Client ID in Google Developer Console), you have two options:

Note that you cannot have two projects with the same port number open at the same time (that's why Visual Studio increments port number for every new project).

Now copy SSL URL value into buffer. In my case, it's https://localhost:44303/.

If you're on http, copy URL value instead. For instance, http://localhost:12969/.

Then open WebApplication Properties in Projects menu, select Web tab and paste it into "Project Url" and "Override application root URL" fields (setting the checkbox if necessary):

And click Create Virtual Directory button. Run the project to make sure it successfully opens at that URL.

If you're on https, Visual Studio may ask you to generate a self-signed certificate for localhost. Confirm that to let the browser trust your https://localhost connection. For this to succeed, Visual Studio must run as Administrator.

You don't need to always run Visual Studio as Administrator, this is needed only at the stage of configuring the application and IIS Express.

You can now update the port number in Google Developer Console to match the exact value assigned by Visual Studio.

Create Visual Studio MVC5 project for Microsoft OAuth provider

Run Visual Studio as Administrator.

Instead of creating a new ASP.NET app, you can open OAuthMCV5 sample.

You can create a new ASP.NET Web Application (C# or VB) just the same way it was described for Google:

...and then configure it for some custom domain name. Recommended if you never did Google app (i.e. you need Microsoft alone).

Or, you can upgrade the app you already created for Google to make it use non-localhost domain name (if you need both Microsoft and Google).

By default, Visual Studio wizard assigns ASP.NET apps to use localhost domain which is not allowed by Live SDK applications. To make an ASP.NET app running under IIS Express use another domain, follow this guide.

EDIT As of 2019, Microsoft now permits using localhost (for Converged applications but not for Live SDK applications). No need to create a custom local domain any longer. You can now use http://localhost:port for both Microsoft and Google.

Setting custom domain

EDIT As of 2019, setting custom domain for local server is now optional in case if you created a Converged application on apps.dev.microsoft.com portal. You can use localhost which is much easier to set up.

This part is inspired by this great article: Custom domain url in IIS Express with Visual Studio 2013. Here's my version:

44301 entry will be there only if you enabled SSL for your project (https case). Adjust the binding for http or https depending on which protocol you're using. - Run your ASP.NET app to see if it now opens on the custom domain. If you're using the custom domain with https, the browser will complain that the certificate is not trusted. Follow the next step if this bothers you.

Setting SSL certificate on custom domain (makes sense for Microsoft over https, not needed for Google-only)

Skip this chapter if you're on plain http or using localhost.

Setting SSL certificate on custom domain is only required if you cannot live with browser warnings (or you cannot add an exception in the browser to avoid them in the future). Complete this step only if want to configure a trusted self-signed certificate for your custom domain (like Visual Studio does for localhost).

If you want a custom domain on your local server and at the same time want to use https in IIS Express and avoid browser warnings, follow the instructions below.

This will work with Chrome and IE only. Firefox does not use Windows storage of certificates. In Firefox, you can add a security exception.

Basically, the idea is to generate a self-signed certificate for your custom local domain, then register this certificate in IIS Express and Windows.

The guide above was inspired by Working with SSL at Development Time is easier with IISExpress article by Scott Hanselman. However, the original article uses more complex approach when the certificate is first created in another place and thus needs to be moved manually with MMC console. This is troublesome and, moreover, works only until reboot.

If you ever decide to delete changes you made to certificate and SSL port associations:

Associating port with custom domain (not required for localhost)

Skip this chapter if you always run Visual Studio as Administrator or if you're on localhost (Google-only case).

Make sure you run Command line as Administrator.

Now, run netsh http add urlacl url=https://idh.mailbee.test.com:44301/ user=everyone (correct the domain and port to match your case, e.g. url=http://idh.mailbee.test.com:12969/ if you're on http).

The above lets you avoid running Visual Studio as Administrator in order to start your app. Without this command, IIS Express will be unable to start listening on the given port if not being run as Administrator.

Now restart Visual Studio in normal mode (not as Administrator), and make sure the app starts without IIS Express errors in system tray.

To ever delete urlacl association, open Command line as Administrator and run netsh http delete urlacl url=https://idh.mailbee.test.com:44301/ (correct url to your domain name and port).

Add references

OAuthMVC5 sample references ExternalProviders library (included in the solution as a project) and MailBee.NET Objects.

In your own projects, you'll need to add references to MailBee.NET and ExternalProviders. To add MailBee.NET reference, open NuGet console and type:

Install-Package MailBee.NET

For ExternalProviders library, you have 3 ways:

In your own projects you'll also need to make sure "Microsoft.Owin.Security" and "Microsoft ASP.NET Identity Framework" packages are referenced. ASP.NET MVC5 template should add them by default, however.

If you get compilation errors like 'System.Web.HttpContextBase' does not contain a definition for 'GetOwinContext', make sure Microsoft.Owin.Host.SystemWeb is referenced.

In case if you added ExternalProviders code files right inside your project, add references to their dependencies in NuGet console:

Install-Package DotNetOpenAuth
Install-Package Google.Apis.Auth
Install-Package Google.Apis
Install-Package Google.Apis.Oauth2.v2
Update-Package

The above assumes you need support for both Microsoft and Google OAuth providers.

If you need just Microsoft, Google packages are not needed. You must remove GoogleProviderHelper.cs(vb) from ExternalProviders if you don't install these packages.

For Google-only case, DotNetOpenAuth package is not required, so remove MicrosoftProviderHelper.cs(vb) from ExternalProviders.

OAuthMVC5 sample goals

Our goal is to use OAuth tokens (received from Microsoft or Google during external authentication mechanism of an MVC5 app) for IMAP or SMTP authentication against the mail provider (Outlook.com, Live.com, Hotmail.com, Gmail). Once the authentication process is done, we can check the user's mailbox or send e-mails from that user.

By default, MVC5 template lets the developer enable OAuth 2.0 for Microsoft and Google but doesn't provide any means to read the received access token in other places of the application (the access token is by default available in an object which gets freed in the end of authentication procedure). We need to extract the access token received during the authentication and store it elsewhere in the application.

We also need to implement token refresh mechanism in case if the app must be able to access the user's mailbox without asking the user for permission every hour or so (because access tokens expire shortly). In other words, we want the app to support Offline access.

It would also be great if the received access token could be consumed by another application of us. For instance, the user logs in our web app with OAuth, then ANOTHER app running in background (such as a Windows service) will poll the user's mailbox for new e-mails and send SMS notifications on that. You can use ReadAspNetUsers sample to get the idea.

We achieve these goals with the following methods:

OAuthMVC5 overview

All the code is available in OAuthMVC5 samples (C# and VB). We'll now explore this sample in its C# version and explain the most interesting pieces. VB version is identical.

OAuthMVC5 project heavily uses ExternalProviders library which will be discussed later.

OAuth provider configuration

App_Start\Startup.Auth.cs contains templates of external authentication against Microsoft, Google, Facebook and other providers in ConfigureAuth method. We configured it for Microsoft and Google. Interesting part is that we also implemented OnAuthenticated callback. This lets us get the details of OAuth process (access tokens, refresh tokens, time-to-live) for later use.

Microsoft version

MicrosoftProviderHelper.Register(MicrosoftClientId, MicrosoftClientSecret);

MicrosoftAccountAuthenticationOptions microsoftOptions = new MicrosoftAccountAuthenticationOptions()
{
	ClientId = MicrosoftClientId,
	ClientSecret = MicrosoftClientSecret,
	Provider = new MicrosoftAccountAuthenticationProvider()
	{
		OnAuthenticated = (context) =>
		{
			MicrosoftProviderHelper tokenHelper = new MicrosoftProviderHelper();

			// Any exception here will result in 'loginInfo == null' in AccountController.ExternalLoginCallback.
			// Be sure to add exception handling here in case of production code.
			context.Identity.AddClaim(new Claim(tokenHelper.AccessTokenFieldName, context.AccessToken));

			// For clarity, we don't check most values for null but RefreshToken is another kind of thing. It's usually
			// not set unless we specially request it. Typically, you receive the refresh token only on the initial request,
			// store it permanently and reuse it when you need to refresh the access token.
			if (context.RefreshToken != null)
			{
				context.Identity.AddClaim(new Claim(tokenHelper.RefreshTokenFieldName, context.RefreshToken));
			}

			// We want to use the e-mail account of the external identity (for which we doing OAuth). For that we save
			// the external identity's e-mail address separately as it can be different from the main e-mail address
			// of the current user. 
			context.Identity.AddClaim(new Claim(tokenHelper.EmailAddressFieldName, context.Email));
			context.Identity.AddClaim(new Claim(tokenHelper.NameFieldName, context.Name));

			context.Identity.AddClaim(new Claim(tokenHelper.TokenIssuedFieldName, DateTime.Now.ToString()));
			context.Identity.AddClaim(new Claim(tokenHelper.TokenExpiresInFieldName,
				((long)context.ExpiresIn.Value.TotalSeconds).ToString()));

			return Task.FromResult(0);
		}
	}
};

microsoftOptions.Scope.Add(MicrosoftProviderHelper.MicrosoftScopeImap);
microsoftOptions.Scope.Add(MicrosoftProviderHelper.MicrosoftScopeEmailAddress);

if (RequireOfflineAccess)
{
	microsoftOptions.Scope.Add(MicrosoftProviderHelper.MicrosoftScopeOfflineAccess);
}

app.UseMicrosoftAccountAuthentication(microsoftOptions);
MicrosoftProviderHelper.Register(MicrosoftClientId, MicrosoftClientSecret)

Dim microsoftOptions As New MicrosoftAccountAuthenticationOptions() With { _
	.ClientId = MicrosoftClientId, _
	.ClientSecret = MicrosoftClientSecret, _
	.Provider = New MicrosoftAccountAuthenticationProvider() With { _
		.OnAuthenticated = _
		Function(context)
			Dim tokenHelper As New MicrosoftProviderHelper()

			' Any exception here will result in 'loginInfo Is Nothing' in AccountController.ExternalLoginCallback.
			' Be sure to add exception handling here in case of production code.
			context.Identity.AddClaim(New Claim(tokenHelper.AccessTokenFieldName, context.AccessToken))

			' For clarity, we don't check most values for Nothing but RefreshToken is another kind of thing. It's usually
			' not set unless we specially request it. Typically, you receive the refresh token only on the initial request,
			' store it permanently and reuse it when you need to refresh the access token.
			If context.RefreshToken IsNot Nothing Then
				context.Identity.AddClaim(New Claim(tokenHelper.RefreshTokenFieldName, context.RefreshToken))
			End If

			' We want to use the e-mail account of the external identity (for which we doing OAuth). For that we save
			' the external identity's e-mail address separately as it can be different from the main e-mail address
			' of the current user. 
			context.Identity.AddClaim(New Claim(tokenHelper.EmailAddressFieldName, context.Email))
			context.Identity.AddClaim(New Claim(tokenHelper.NameFieldName, context.Name))

			context.Identity.AddClaim(New Claim(tokenHelper.TokenIssuedFieldName, DateTime.Now.ToString()))
			context.Identity.AddClaim(New Claim(tokenHelper.TokenExpiresInFieldName, CLng(context.ExpiresIn.Value.TotalSeconds).ToString()))

			Return Task.FromResult(0)
		End Function
	} _
}

microsoftOptions.Scope.Add(MicrosoftProviderHelper.MicrosoftScopeImap)
microsoftOptions.Scope.Add(MicrosoftProviderHelper.MicrosoftScopeEmailAddress)

If RequireOfflineAccess Then
	microsoftOptions.Scope.Add(MicrosoftProviderHelper.MicrosoftScopeOfflineAccess)
End If

app.UseMicrosoftAccountAuthentication(microsoftOptions)

Google version

GoogleProviderHelper.Register(GoogleClientId, GoogleClientSecret);

GoogleOAuth2AuthenticationOptions googleOptions = new GoogleOAuth2AuthenticationOptions()
{
	ClientId = GoogleClientId,
	ClientSecret = GoogleClientSecret,
	Provider = new GoogleOAuth2AuthenticationProvider()
	{
		OnAuthenticated = (context) =>
		{
			GoogleProviderHelper tokenHelper = new GoogleProviderHelper();

			// Any exception here will result in 'loginInfo == null' in AccountController.ExternalLoginCallback.
			// Be sure to add exception handling here in case of production code.
			context.Identity.AddClaim(new Claim(tokenHelper.AccessTokenFieldName, context.AccessToken));

			// For clarity, we don't check most values for null but RefreshToken is another kind of thing. It's usually
			// not set unless we specially request it. Typically, you receive the refresh token only on the initial request,
			// store it permanently and reuse it when you need to refresh the access token.
			if (context.RefreshToken != null)
			{
				context.Identity.AddClaim(new Claim(tokenHelper.RefreshTokenFieldName, context.RefreshToken));
			}

			// We want to use the e-mail account of the external identity (for which we doing OAuth). For that we save
			// the external identity's e-mail address separately as it can be different from the main e-mail address
			// of the current user. 
			context.Identity.AddClaim(new Claim(tokenHelper.EmailAddressFieldName, context.Email));
			context.Identity.AddClaim(new Claim(tokenHelper.NameFieldName, context.Name));

			context.Identity.AddClaim(new Claim(tokenHelper.TokenIssuedFieldName, DateTime.Now.ToString()));
			context.Identity.AddClaim(new Claim(tokenHelper.TokenExpiresInFieldName,
				((long)context.ExpiresIn.Value.TotalSeconds).ToString()));

			return Task.FromResult(0);
		}
	}
};

googleOptions.Scope.Add(GoogleProviderHelper.GoogleScopeProfile);
googleOptions.Scope.Add(GoogleProviderHelper.GoogleScopeEmailAddress);
googleOptions.Scope.Add(GoogleProviderHelper.GoogleScopeGmail);

// See AccountController.ChallengeResult.ExecuteResult on how to request Offline access with Google.

app.UseGoogleAuthentication(googleOptions);
GoogleProviderHelper.Register(GoogleClientId, GoogleClientSecret)

Dim googleOptions As New GoogleOAuth2AuthenticationOptions() With { _
	.ClientId = GoogleClientId, _
	.ClientSecret = GoogleClientSecret, _
	.Provider = New GoogleOAuth2AuthenticationProvider() With { _
		.OnAuthenticated = _
		Function(context)
			Dim tokenHelper As New GoogleProviderHelper()

			' Any exception here will result in 'loginInfo Is Nothing' in AccountController.ExternalLoginCallback.
			' Be sure to add exception handling here in case of production code.
			context.Identity.AddClaim(New Claim(tokenHelper.AccessTokenFieldName, context.AccessToken))

			' For clarity, we don't check most values for Nothing but RefreshToken is another kind of thing. It's usually
			' not set unless we specially request it. Typically, you receive the refresh token only on the initial request,
			' store it permanently and reuse it when you need to refresh the access token.
			If context.RefreshToken IsNot Nothing Then
				context.Identity.AddClaim(New Claim(tokenHelper.RefreshTokenFieldName, context.RefreshToken))
			End If

			' We want to use the e-mail account of the external identity (for which we doing OAuth). For that we save
			' the external identity's e-mail address separately as it can be different from the main e-mail address
			' of the current user. 
			context.Identity.AddClaim(New Claim(tokenHelper.EmailAddressFieldName, context.Email))
			context.Identity.AddClaim(New Claim(tokenHelper.NameFieldName, context.Name))

			context.Identity.AddClaim(New Claim(tokenHelper.TokenIssuedFieldName, DateTime.Now.ToString()))
			context.Identity.AddClaim(New Claim(tokenHelper.TokenExpiresInFieldName, CLng(context.ExpiresIn.Value.TotalSeconds).ToString()))

			Return Task.FromResult(0)
		End Function
	} _
}

googleOptions.Scope.Add(GoogleProviderHelper.GoogleScopeProfile)
googleOptions.Scope.Add(GoogleProviderHelper.GoogleScopeEmailAddress)
googleOptions.Scope.Add(GoogleProviderHelper.GoogleScopeGmail)

' See AccountController.ChallengeResult.ExecuteResult on how to request Offline access with Google.

app.UseGoogleAuthentication(googleOptions)

To enable Offline mode for Google, something extra needs to be configured. OAuthMVC5\Controllers\AccountController.cs contains ChallengeResult class provided by the MVC5 template. We extended its ExecuteResult method to enable Offline access during OAuth process. It actually contains more code which you can find well-documented there but the most important line is:

properties.Dictionary[GoogleProviderHelper.GoogleAccessType] = GoogleProviderHelper.GoogleOfflineAccessType;
properties.Dictionary(GoogleProviderHelper.GoogleAccessType) = GoogleProviderHelper.GoogleOfflineAccessType

The above is Google-specific fix, Microsoft provider is configured for Offline mode just in ConfigureAuth method.

Due to some specifics of MVC5 external auth pipeline, token data gets lost after OAuth process completes, that's why we need to intercept this data before it's lost. In fact, we have to do this even twice:

We don't want to save to database immediately in OnAuthenticated callback because at that point it's not guaranteed that the entire authentication process succeeded yet (saving data at this point would probably cause out-of-sync situation when different parts of the app would have different opinion on whether or not the user has successfully logged in).

In OAuthMVC5\Controllers\AccountController.cs we also modified ExternalLoginCallback and ExternalLoginConfirmation methods to read token data from the external user's claims and save to AspNetUserClaims table (these methods are executed in different scenarios of logging in the user which has already been registered before). The same is done in LinkLoginCallback method in Controllers\ManageController.cs (it's executed when the user registers for the first time). The method call which saves token data to database looks like:

ExternalProviderHelper helper = ExternalProviderHelper.Create(loginInfo.Login.LoginProvider);
if (helper != null)
{
	await helper.MoveClaimsFromExternalIdenityToAuthenticatedUserAsync<ApplicationUser>(
		AuthenticationManager, UserManager, user.Id);
}
Dim helper As ExternalProviderHelper = ExternalProviderHelper.Create(info.Login.LoginProvider)
If helper IsNot Nothing Then
	Await helper.MoveClaimsFromExternalIdenityToAuthenticatedUserAsync(Of ApplicationUser)( _
		AuthenticationManager, UserManager, userInfo.Id)
End If

The database itself is in App_Data folder of the project, MVC5 template is already pre-configured to use it. See connectionStrings section in web.config if interested.

Preparing OAuth tokens for use

The actual IMAP/SMTP thing occurs in HomeController.MailAction (it replaces HomeController.About of the standard MVC5 template).

First, we extract the access token from AspNetUserClaims table, then check if it's already expired and refresh it if needed (updating AspNetUserClaims table and ASP.NET cookies accordingly).

ApplicationUser currentUser = UserManager.FindById(uid);

bool refreshed;
actualAccessToken = helper.GetActualAccessToken<ApplicationUser>(
	UserManager, uid, Startup.RequireOfflineAccess, out refreshed);

if (refreshed)
{
	// The previous method has refreshed the access token (and updated AspNetUserClaims accordingly). Now we need
	// to update the application cookie to save the updated identity claims there (as the claims need to be stored
	// in both places: AspNetUserClaims table and cookies).
	ClaimsIdentity identity = await currentUser.GenerateUserIdentityAsync(UserManager);
	HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie, DefaultAuthenticationTypes.TwoFactorCookie);
	HttpContext.GetOwinContext().Authentication.SignIn(new AuthenticationProperties { IsPersistent = false }, identity);
}
Dim currentUser As ApplicationUser = UserManager.FindById(uid)

Dim refreshed As Boolean
actualAccessToken = helper.GetActualAccessToken(Of ApplicationUser)(UserManager, uid, Startup.RequireOfflineAccess, refreshed)

If refreshed Then
	' The previous method has refreshed the access token (and updated AspNetUserClaims accordingly). Now we need
	' to update the application cookie to save the updated identity claims there (as the claims need to be stored
	' in both places: AspNetUserClaims table and cookies).
	Dim identity As ClaimsIdentity = Await currentUser.GenerateUserIdentityAsync(UserManager)
	HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie, DefaultAuthenticationTypes.TwoFactorCookie)
	HttpContext.GetOwinContext().Authentication.SignIn(New AuthenticationProperties() With { _
		.IsPersistent = False _
	}, identity)
End If

helper is an instance of MicrosoftProviderHelper or GoogleProviderHelper classes from ExternalProviders library.

We also read the user's e-mail address from the database as it participates in building XOAUTH2 key required for IMAP or SMTP OAuth login. Then, we build that key.

ExternalUserProfile userProfile = helper.GetUserProfile(UserManager, uid);
string xoAuthKey = OAuth2.GetXOAuthKeyStatic(userProfile.EmailAddress, actualAccessToken);
Dim userProfile As ExternalUserProfile = helper.GetUserProfile(UserManager, uid)
Dim xoAuthKey As String = OAuth2.GetXOAuthKeyStatic(userProfile.EmailAddress, actualAccessToken)

Note that cannot use User.Identity.Name as the user's e-mail address. This address can be different from the external login's e-mail address. Also, this address is only one per user while there can be many external logins, each one with its own e-mail address.

Check or send e-mail with XOAUTH2 key

Finally we can use the XOAUTH2 key for authentication on Microsoft or Gmail server.

IMAP login:

Imap imp = new Imap();
imp.Connect(helper.ImapHost);
imp.Login(null, xoAuthKey, AuthenticationMethods.SaslOAuth2,
	MailBee.AuthenticationOptions.None, null);
Dim imp As Imap = New Imap()
imp.Connect(helper.ImapHost)
imp.Login(Nothing, xoAuthKey, AuthenticationMethods.SaslOAuth2, MailBee.AuthenticationOptions.None, Nothing)

We're using the full name of MailBee.AuthenticationOptions type to avoid confusion with Microsoft.Owin.Security.AuthenticationOptions class.

Send e-mail with SMTP:

Smtp mailer = new Smtp();
mailer.SmtpServers.Add(helper.SmtpHost, null, xoAuthKey, AuthenticationMethods.SaslOAuth2);
mailer.From.Email = userProfile.EmailAddress;
mailer.From.DisplayName = userProfile.DisplayName;
mailer.To.Add(userProfile.EmailAddress, userProfile.DisplayName);
mailer.Subject = "empty email to myself";
mailer.Send();
Dim mailer As Smtp = New Smtp()
mailer.SmtpServers.Add(helper.SmtpHost, Nothing, xoAuthKey, AuthenticationMethods.SaslOAuth2)
mailer.From.Email = userProfile.EmailAddress
mailer.From.DisplayName = userProfile.DisplayName
mailer.To.Add(userProfile.EmailAddress, userProfile.DisplayName)
mailer.Subject = "empty email to myself"
mailer.Send()

You can use async methods instead, such as Imap.ConnectAsync, Smtp.SendAsync, etc. OAuthMVC5 sample uses exactly async methods.

If XOAUTH2 fails, the access token is invalid. For instance, this may happen if Offline access wasn't initially requested and the current access token expired as it can't be refreshed. Or, the application issued too many refresh tokens so that Microsoft or Google started to "forget" older tokens (for instance, Google remembers only 25 last tokens). Relogging in OAuthMVC5 app or clearing AspNetUsers and AspNetUserClaims tables may help.

Other changes to the standard MVC5 template

Startup.UnsecureTokenFormatter class in App_Start\Startup.Auth.cs enables you to turn off ASP.NET cookie encryption for debug purposes (see the class definition for details). This can be activated by setting Startup.EncryptCookies to false.

ManageLogins in Controllers\ManageController.cs was extended with better error handling. For instance, by default MVC5 template may just say you "error occurred" without any explanation (even when the external account you're trying to register just already exists in the database).

Views\Shared_Layout.cshtml now adds buttons to check mail with external providers, instead of About button.

Views\Home\MailAction.cshtml supersedes About.cshtml and provides "check mail" and "send mail" controls.

ExternalProviders library overview

Disclaimer: ExternalProviders is not a production-level software. To make it easier to explain how things work, they are often implemented not the best way possible. In your production apps, you may consider refactoring for sake of performance optimization or better conforming to ASP.NET development guidelines.

The library serves these purposes:

ImapHost, SmtpHost and some other properties of MicrosoftProviderHelper and GoogleProviderHelper classes get provider-specific constants.

ExternalProviderHelper.MoveClaimsFromExternalIdenityToAuthenticatedUserAsync actually stores authentication data received from the external provider into AspNetUserClaims table.

Refreshing access tokens is the most interesting and bigger part.

Refreshing access tokens

Refreshing an access token enables the application to work with the user's Microsoft or Google account for more than an hour. We assume Offline access is enabled and we have the refresh token. The procedure:

To find how exactly the methods of refreshing tokens for Microsoft and Google providers are implemented, refer to RefreshAccessTokenIfNeeded methods in the corresponding Helper classes.

Syncing access tokens between AspNetUserClaims table and ASP.NET cookies

Now you have the access token which is ready for use. However, there is a small side effect that the user's browser still keeps the previous version of the access token (we updated the database but not cookies). This out-of-sync is not a disaster as this cookie may never be used later but there is still a way to update it to keep ASP.NET cookie in sync with the copy in AspNetUserClaims table. Remember this code in HomeController.MailAction?

ClaimsIdentity identity = await currentUser.GenerateUserIdentityAsync(UserManager);
HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie, DefaultAuthenticationTypes.TwoFactorCookie);
HttpContext.GetOwinContext().Authentication.SignIn(new AuthenticationProperties { IsPersistent = false }, identity);
Dim identity As ClaimsIdentity = Await currentUser.GenerateUserIdentityAsync(UserManager)
HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie, DefaultAuthenticationTypes.TwoFactorCookie)
HttpContext.GetOwinContext().Authentication.SignIn(New AuthenticationProperties() With { _
	.IsPersistent = False _
}, identity)

This code does the trick (you may disable cookie encryption in Startup class and set breakpoints in Startup.UnsecureTokenFormatter class to actually see that the cookie gets updated). Again, even if you never update this cookie, there is a good chance you'll never get into trouble.

Also, the ASP.NET browser cookie may still go out-of-sync if you refresh the access token in another non-web application (see next topic). For instance, you have a console server app which runs on a timer and polls the user's mailbox every 10 minutes. This non-web application can access AspNetUserClaims table of your web application but it can't access the browser cookie as it works without any user interaction (so there is no browser to send cookie to). Still no big issue with that, however.

Using and refreshing access tokens in non-web applications

If you need to use access tokens stored in AspNetUserClaims table in other applications (such as Windows services which can do background activities in user accounts), check ReadAspNetUsers C#/VB samples in My Documents\MailBee.NET Objects\Samples\WinForms\.NET 4.5 OAuth folder.

The basic idea can be expressed as the code below:

IdentityDbContext dbCtx = new IdentityDbContext();
UserManager<IdentityUser> userMan = new UserManager<IdentityUser>(new UserStore<IdentityUser>(dbCtx));
IdentityUser user = userMan.FindByEmail(userEmail); // Primary e-mail address of the user.
string uid = user.Id;

// You may register only one provider if you don't need another one.
GoogleProviderHelper.Register(GoogleClientId, GoogleClientSecret);
MicrosoftProviderHelper.Register(MicrosoftClientId, MicrosoftClientSecret);

ExternalProviderHelper helper = ExternalProviderHelper.Create(provider); // "Microsoft" or "Google".
bool refreshed;
string accessToken = helper.GetActualAccessToken<IdentityUser>(userMan, uid, true, out refreshed);
string email = helper.GetUserProfile(userMan, uid).EmailAddress;
string xoAuthKey = MailBee.OAuth2.GetXOAuthKeyStatic(email, accessToken);

Imap imp = new Imap();
imp.Connect(helper.ImapHost);
imp.Login(null, xoAuthKey, AuthenticationMethods.SaslOAuth2, MailBee.AuthenticationOptions.None, null);
Dim dbCtx As New IdentityDbContext()
Dim userMan As New UserManager(Of IdentityUser)(New UserStore(Of IdentityUser)(dbCtx))
Dim user As IdentityUser = userMan.FindByEmail(userEmail)
Dim uid As String = user.Id

' You may register only one provider if you don't need another one.
GoogleProviderHelper.Register(GoogleClientId, GoogleClientSecret)
MicrosoftProviderHelper.Register(MicrosoftClientId, MicrosoftClientSecret)

Dim helper As ExternalProviderHelper = ExternalProviderHelper.Create(provider)	' "Microsoft" or "Google".
Dim refreshed As Boolean
Dim accessToken As String = helper.GetActualAccessToken(Of IdentityUser)(userMan, uid, True, refreshed)
Dim email As String = helper.GetUserProfile(userMan, uid).EmailAddress
Dim xoAuthKey As String = MailBee.OAuth2.GetXOAuthKeyStatic(email, accessToken)

Dim imp As New Imap()
imp.Connect(helper.ImapHost)
imp.Login(Nothing, xoAuthKey, AuthenticationMethods.SaslOAuth2, MailBee.AuthenticationOptions.None, Nothing)

ReadAspNetUsers sample actually provides more code including error checking, more explanations in code comments (including other ways to access users in AspNetUserClaims table), etc.

In case if you're searching the user by their e-mail address (with UserManager.FindByEmail method), be sure to specify the primary e-mail address of the user. External logins' e-mail addresses can be different (moreover, there can be multiple external logins per user account, each external login with its own e-mail address).

The sample also shows how to dynamically change the location where to find the ASP.NET Identity database (ChangeDataDirLocation method there). This lets ReadAspNetUsers sample use the database of OAuthMVC5 sample.

Get source code

All C# and VB samples discussed in this article are shipped with MailBee.NET Objects installer and get installed in My Documents\MailBee.NET Objects\Samples as follows:

ReadAspNetUsers sample is also available for .NET Core 2.0 (in \WinForms\.NET Core 2.0\C#\ReadAspNetUsers folder). It's a different sample intended for use with ASP.NET Core (and \ASP.NET\cs_netcore_oauth_samples\OAuthNetCoreMVC2 sample).


Send feedback to AfterLogic

Copyright © 2006-2024 AfterLogic Corporation. All rights reserved.