Being able to sign in with an external login provider (for example Google or Facebook) is a good way to simplify the process of getting new users to your website.
There’s quite a few steps required to make this happen in a “traditional” web application. As far as I know there’s no guide into how you can achieve this using a modern front-end framework such as Angular, React or Vue together with ASP.NET Core as the back-end.
That is what this blog post is about: a description of how you can have an Angular application rely on Google for authentication using ASP.NET Core as its back-end.
This is a very long blog post and I feel there are areas where it could be expanded more. Also, I had planned to describe a version using ASP.NET Identity and one without, but after more than 4000 words I decided that it would be too much. I went only with the ASP.NET Identity version. To complicate things even more, when I picked Google for the external login provider I wasn’t aware of Google Identity Platform. This blog post is still very much valid and useful, not only because this approach works for any external login provider but also because a great deal of what is described here would also be necessary when using Google Identity Platform.
You can find the github repository for the Angular and ASP.NET Core applications here. Here’s how it looks like when you run it:
Angular
For the front end the goal is to have a way of detecting if the user is authenticated that does not depend on the particular external login provider used.
Although the examples in the rest of the blog post use Google, there is nothing in the Angular application that is specific or depends on Google as an external login provider (i.e. it works with any login provider).
Another goal is to detect that the user has signed out. For example if the user opens two tabs and signs out in one of them, the other tab should be able to handle this scenario gracefully.
Finally, it must be possible to initiate the process of logging in and out from the Angular application. Let’s start with this.
Login and logout
As we will be relying on ASP.NET Core as the back-end we need to “send” the user to a particular URL handled by the ASP.NET Core application. That url will initialize the process of signing in with the external login provider.
Imagine this url is https://www.mywebsite.com/account/signInWithGoogle
.
If you are used to using Angular’s routing service you might think that we should use it to redirect the user to that URL. Unfortunately the router service only works with urls that are internal to the Angular application.
The solution to this is much more mundane. What we actually have to do is to change the href
property in the window’s location
.
The “right” way to do this in Angular is not to invoke window.location.href = ...
. That would work but you’d lose the ability to unit test the method that makes that call. And since that’s an important part of “doing” Angular (having testable code) here’s how you could do the same thing while not making the code harder to test:
import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
...
@Injectable()
export class LoginService {
//...
constructor(@Inject(DOCUMENT) private document: Document,...)
login() {
this.document.location.href = 'https://www.mywebsite.com/account/signInWithGoogle';
}
}
The advantage here is that since document
is injected using Angular’s dependency injection mechanism, when you are writing tests against this code you’ll be able to easily replace document
with something you control (a test spy).
Regarding logout, it’s tempting to think that we could use the same approach, i.e. just call an endpoint in ASP.NET Core that takes care of logging us out and redirecting the user back to the Angular application. This is not very good practice though.
The reason it is not considered good practice is because browsers prefetch urls while you are typing them in the address bar. That means that while you are typing for example https://www.mywebsite.com/account
the browser might autocomplete to https://www.mywebsite.com/account/logout
as one of the options and actually try to prefetch the url, logging you out when you didn’t intend to.
Instead, make the logout endpoint respond only to POST and use Angular’s HttpClient
service to perform a post request to it. For example:
import { HttpClient } from '@angular/common/http';
...
export class LoginService {
constructor(private httpClient: HttpClient, ...) {}
logout() {
this.httpClient.post(`/acount/logout`).subscribe(_ => {
//redirect the user to a page that does not require authentication
});
}
}
Detecting if the user is authenticated
Imagine someone sends you a link to a page in your application that requires the user to be logged in.
Ideally, your Angular application should be able to determine, while it’s starting up, if the user is already authenticated or not.
Thankfully Angular allows us to provide a function that runs when the application is initializing. We can take advantage of this to query the back-end and determine if the user is authenticated.
To specify your own initialization function you need to add it in the provider’s list in the main (AppModule
) module.
Here’s how that looks in AppModule
if your function is named checkIfUserIsAuthenticated
and depends on the AccountService
service.
import { HttpClientModule, HttpClient, ... } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { checkIfUserIsAuthenticated } from './check-login-intializer';
...
@NgModule({
...
providers: [
{ provide: APP_INITIALIZER, useFactory: checkIfUserIsAuthenticated, multi: true, deps: [AccountService]}
]
Here’s how the checkIfUserIsAuthenticated
function looks like:
import { AccountService } from './security.service';
export function checkIfUserIsAuthenticated(accountService: AccountService) {
return () => accountService.updateUserAuthenticationStatus().toPromise();
}
The updateUserAuthenticationStatus
method in the AccountService
is performs a request to the ASP.NET Core back-end that will return true or false depending on the user being logged-in or not.
Here’s an example of how that could look like:
@Injectable({
providedIn: 'root'
})
export class SecurityService {
private _isUserAuthenticatedSubject = new BehaviorSubject<boolean>(false);
isUserAuthenticated: Observable<boolean> = this._isUserAuthenticatedSubject.asObservable();
constructor(@Inject(DOCUMENT) private document: Document, private httpClient: HttpClient) { }
updateUserAuthenticationStatus(){
return this.httpClient.get<boolean>(`${environment.apiUrl}/account/isAuthenticated`, {withCredentials: true}).pipe(tap(isAuthenticated => {
this._isUserAuthenticatedSubject.next(isAuthenticated);
}));
}
...
The service above exposes an observable named isUserAuthenticated
that we can use in any component so that we can be notified about the user’s authentication status. Since this is an observable we can use it to signal that the user has signed out, which is what we are going to do next.
One note about using APP_INITIALIZER before we go there. The initializer function must return a promise (or nothing). When returning a promise, if that promise fails (is rejected) the application won’t start and you’ll be left with a blank screen. So error handling is definitely needed here if you want to provide a good user experience.
If you want more information on APP_INITIALIZER
I recommend “Hook into Angular’s Initialization Process”. Also, if you’ve never seen the withCredentials
option, it is only required if your ASP.NET Core back-end is in a different “origin” and therefore the request from Angular to ASP.NET Core is a CORS request. I recommend “Secure an ASP.NET Core Web Api using Cookies” if you want a detailed information about this topic.
Detecting if a user logged out
One scenario that we want to support is to detect if the user logs out (for example from a different tab) and we try to make a request expecting the user to be authenticated.
Angular provides a way to inspect each request before it is sent and the response when it is received through a type of service named Interceptor.
We can leverage this so that we can detect 401 Unauthorized responses and react appropriately.
What I did in the example project was to update an Observable
(isUserAuthenticated
) in an Angular service (AccountService
) so that any component that might be active in the page can subscribe to this observable and react appropriately to the user becoming unauthorized.
Here’s how the interceptor looks like:
import { HttpErrorResponse, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { tap } from 'rxjs/operators';
import { AccountService } from './account.service';
@Injectable()
export class Interceptor401Service implements HttpInterceptor {
constructor(private accountService: AccountService) { }
intercept(req: HttpRequest<any>, next: HttpHandler) {
return next.handle(req).pipe(tap(nonErrorEvent => {
//nothing to do there
}, (error : HttpErrorResponse) => {
if (error.status === 401)
this.accountService.setUserAsNotAuthenticated();
}));
}
}
And it needs to be added to the providers’ list in AppModule
:
import { HTTP_INTERCEPTORS, ... } from '@angular/common/http';
import { Interceptor401Service } from './interceptor401.service';
...
@NgModule({
...
providers: [
...
{ provide: HTTP_INTERCEPTORS, useClass: Interceptor401Service, multi: true },
]
To take advantage of this, we just need to add the AccountService
as a dependency in a component and subscribe to the isUserAuthenticated
observable. This is how that looks like:
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { AccountService } from '../account.service';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit, OnDestroy {
subscription: Subscription;
constructor(private accountService: AccountService) { }
ngOnInit() {
this.subscription = this.accountService.isUserAuthenticated.subscribe(isAuthenticated => {
if (isAuthenticated) {
//user became authenticated
} else {
//user is not authenticated
}
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
...
ASP.NET Core Using Identity
To allow users to authenticate using an external login providers in ASP.NET Core we can use ASP.NET Core Identity.
ASP.NET Core Identity offers us the ability to interact with several external login providers using OAuth and to save the users in a predefined set of tables (AspNetUsers, etc). Although we won’t be exploring it here, Identity even has functionality to associate and manage several different external login providers for the same user.
Before we can proceed we need to create an “app” in Google so that we can get a ClientId
and ClientSecret
that we will be needing to configure Google’s authentication middleware.
Create a new Google application
You can create a new “app” by following the following link https://console.developers.google.com/projectselector/apis/library.
First step is to create a new “project”/app:
Next you need to name the project. This is how the application will be called in the Google developers’ website. In this example I’m naming it “GoogleSignInExample”:
Next step is to enable the Google+ API
. You can access the search apis functionality by clicking “Library” on the left sidebar menu:
Click enable to confirm you want to enable the API:
Now go to the credentials section of the project:
Click create credentials and select OAuth client ID:
Now configure the OAuth consent screen. This is defines what will be displayed to your users when they try to login to your application using Google.
Back in the credentials screen you should now select “Web Application” and specify “Authorized JavaScript Origins” and “Authorized redirect URIs”.
The origins refers to where the requests will be coming from, which will be your application. Since ASP.NET Core runs on port 5000 by default if you use the command line I used that (that’s also what the github example project uses).
The authorized redirect URIs are the locations where Google will redirect the user to when the login process finishes.
When setting up Google as an external login provider in ASP.NET Core you can specify this value (the default is signin-google
). Since this is not obvious at all I’ve used a non-default (google-callback
) value so I can show you how you can explicitly specify what is the redirect URI.
Finally you should get your Client Id and Client Secret.
You should make note of these two values. We will be using them to configure the authentication middleware in ASP.NET Core.
Configure the Authentication middleware in ASP.NET Core
Even though the configuration for the authentication middleware is terse and at first glance seems simple, there’s a lot going on that is not obvious.
To fully comprehend how the authentication middleware works in this scenario you need to be familiar with how the middleware pipeline works, what ASP.NET Identity does when you register it in ConfigureServices
, and be familiar with OAuth flows, namely the authorization code grant flow.
I’m going to assume that you know how the pipeline works and what happens when the user gets redirected to an external login provider (such as Google) and back. If you want an in-depth discussion about that (that is a little bit outdated in terms of ASP.NET Core but very much still relevant), I recommend reading External Login Providers in ASP.NET Core.
Without further ado here’s the ConfigureServices
method in Startup.cs
:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddCors(corsOptions =>
{
corsOptions.AddPolicy("fully permissive", configurePolicy => configurePolicy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin().AllowCredentials());
});
services.AddDbContext<IdentityDbContext>(options =>
options.UseSqlite("Data Source=users.sqlite",
sqliteOptions => sqliteOptions.MigrationsAssembly("TheNameOfYourAspNetCoreProjectGoesHere")));
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<IdentityDbContext>()
.AddDefaultTokenProviders();
services.AddAuthentication(options =>
{
options.DefaultSignOutScheme = IdentityConstants.ApplicationScheme;
})
.AddGoogle("Google", options =>
{
options.CallbackPath = new PathString("/google-callback");
options.ClientId = "YourClientIdFromGoogle";
options.ClientSecret = "YourClientSecretFromGoogle";
});
}
The first thing being registered in ConfigureServices
is Mvc
and after that there’s CORS. I’ve setup CORS to be very permissive (as you might’ve noticed by how I named the policy). This might or might not be appropriate depending on how your Angular + ASP.NET Core setup is. If you want to read about different options regarding this I recommend my other post: Angular and ASP.NET Core where several deployment options are described, including ones that don’t require CORS.
Also, we will be relying on Cookies
to store the user’s identity. If for some reason this doesn’t work for you check out Secure a Web Api in ASP.NET Core that describes how you can use JWT instead of Cookies
. You can also refer to Secure an ASP.NET Core Web Api using Cookies if you want an in-depth example of how Cookies work in the approach we’ll be taking in this blog post.
After CORS there’s the setup of ASP.NET Core Identity. I’m not defining my own version IdentityDbContext so I have to specify that I want to create the migrations in the ASP.NET Core project (that’s the MigrationsAssembly
part). The default is that the migrations are created in the assembly where the DbContext derived class is defined. As IdentityDbContext
is defined in the Microsoft.AspNetCore.Identity.EntityFrameworkCore
assembly that would produce an error.
I won’t spend too much time on explaining the setup of ASP.NET Core Identity other than mentioning that when you call .AddIdentity
you are actually configuring the the authentication middleware (i.e. AddIdentity
calls .AddAuthentication
internally).
Since it is convenient to have the DefaultSignOutScheme
be the the ApplicationScheme
(this is the scheme that actually creates the user’s identity, i.e. sets HttpContext.User) the order you have .AddAuthentication
and .AddIdentity
is important. .AddAuthentication
needs to be after .AddIdentity
or else your custom settings will be overwritten.
If the above comment left you feeling lost I recommend spending some time reading External Login Providers in ASP.NET Core which is a little bit outdated but still is applicable to this version (2.1) of .Net Core (apart from there only being one authentication middleware now).
Finally, the configuration of Google
as an external login provider. I’ve named the authentication scheme as “Google” (first parameter in .AddGoogle
). This will be important later on when we want to produce a challenge to initiate the login process with Google.
You should use the ClientId
, ClientSecret
and CallbackPath
previously defined when registering your application with Google in the Google Developer’s website.
Regarding the definition of the pipeline (Configure
method) here’s how that could look like:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseCors("fully permissive");
app.UseAuthentication();
app.UseMvcWithDefaultRoute();
}
Initiating a sign in with Google
The way you initiate the authentication process using an external login provider such as Google is by issuing a “challenge” that targets the authentication scheme that corresponds to the external login provider.
When we configured Google as the external login provider in Startup.cs
we gave it an authentication scheme name of Google (.AddGoogle("
Google")
). That’s the name we have to use in our challenge.
In case you are wondering why the term “challenge” is used, it’s probably because in the HTTP protocol when a 401 Unauthorized response is returned, a header named WWW-Authenticate
is included. The value of that header defines which challenge the client must overcome in order to access the protected resource.
In ASP.NET challenging an authentication scheme just triggers the challenge behavior defined in the authentication handler associated with that authentication scheme. In this case that will be redirecting the user to the Google sign in page.
Here’s how that looks like in a controller action:
public class AccountController : Controller
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly UserManager<IdentityUser> _userManager;
public AccountController(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager)
{
_signInManager = signInManager;
_userManager = userManager;
}
public IActionResult SignInWithGoogle()
{
var authenticationProperties = _signInManager.ConfigureExternalAuthenticationProperties("Google", Url.Action(nameof(HandleExternalLogin)));
return Challenge(authenticationProperties, "Google");
}
...
Part of configuring the challenge with an external login provider requires us to provide a redirect url. This is the url to where the user will be redirected to after successfully authenticating (with Google in this case). We are redirecting the user to /Account/HandleExternalLogin
.
Handling a successful authentication
The first thing that happens when the “challenge” is initiated is that the user is redirected to the external login provider.
After the user successfully authenticates using the external login provider’s login page, the user gets redirected back to the application. This redirect url will be to the Callback url that is specified in the external login provider’s configuration in ASP.NET, and in the provider itself (in Google this is the “Authorized redirect URI”).
This redirect will contain a single-use code in the query string. The ASP.NET application’s authentication middleware for the external login provider will exchange that code (by making an http call to Google) for the user’s claims and sets them in an “External” cookie (this is transparent to you). You can find the authentication scheme name for this cookie in IdentityConstants.ExternalScheme
.
After this, the ASP.NET Core authentication middleware for the external login provider will redirect the user to the url specified in the challenge (in our example that’s /account/handleexternallogin
) and that’s the action we will be discussing in this section.
This is terribly complicated, and to make matters worse what comes next, even though it is just a few lines of code, has loads going on that is very obscure. This is however what comes out of the box if you are using ASP.NET Core Identity. I’ll do my best to explain what’s going on.
If you feel you need to get a better grasp of the process between the ASP.NET application and the external login provider have a look at External Login Providers in ASP.NET Core.
Here’s how HandleExternalLogin
looks like:
public async Task<IActionResult> HandleExternalLogin()
{
var info = await _signInManager.GetExternalLoginInfoAsync();
var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
if (!result.Succeeded) //user does not exist yet
{
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
var newUser = new IdentityUser {
UserName = email,
Email = email,
EmailConfirmed = true
};
var createResult = await _userManager.CreateAsync(newUser);
if (!createResult.Succeeded)
throw new Exception(createResult.Errors.Select(e => e.Description).Aggregate((errors, error) => $"{errors}, {error}"));
await _userManager.AddLoginAsync(newUser, info);
var newUserClaims = info.Principal.Claims.Append(new Claim("userId", newUser.Id));
await _userManager.AddClaimsAsync(newUser, newUserClaims);
await _signInManager.SignInAsync(newUser, isPersistent: false);
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
}
return Redirect("http://localhost:4200");
}
The first line (_signInManager.GetExternalLoginInfoAsync()
) retrieves the user information that was set by the external login provider. This information is stored in a cookie usually referred to as the external cookie whose authentication scheme name is IdentityConstants.ExternalScheme
.
The return value, info
, contains the LoginProvider
which is just a string that is unique to the login provider (e.g. Google
). It also contains another property named ProviderKey
which is an unique identifier of the user in the external login provider (for Google it’s the user’s Google+ profile id). Finally it also contains a list of Claims
that may vary depending of how the external login provider is configured (you might ask for additional Scopes) but usually contain at least the user’s email and name.
The next line (await _signInManager.ExternalLoginSignInAsync(...
) will check if the user has previously logged in using the external login provider. If that’s the case this method will effectively sign the user in.
It does this by checking the AspNetUserLogins
table using the LoginProvider
and ProviderKey
to find a user (from the AspNetUsers
table) and retrieve all the information for that user to create an identity (AspNetUserClaims
, AspNetRoles
, …) for the user. It also deletes the external cookie in this scenario.
If it’s the first time the user logs in then this method just returns a “failed sign in result” and doesn’t delete the external cookie.
This is to support the Visual Studio template’s use case where the user is redirected to a new account page. In that page the user is forced to create a local account which will be associated to the external login provider. The external cookie is maintained so that the information contained in it (the external login provider’s user identity) can be used when the local account is created in the new account page.
This is a perfect example of something you would not expect since the method’s name (ExternalLoginSignInAsync
) indicates a particular function (sign the user in using an external login provider) but in reality it’s doing something specific to a particular use case (catering for the needs of the default visual studio template), hence violating the principle of reasonable expectations. It’s also a bad abstraction since you need to look at the source code to fully understand what it’s doing.
The return type of SignInManager.ExternalLoginSignInAsync
is SignInResult
which contains a boolean property named Succeeded
. If its value is true
there’s nothing else to do other than redirecting the user to where the Angular application is hosted (in the example the redirect is for localhost:4200
which is the port you’d get if you start your angular app with ng serve
). What happens in this scenario is that SignInManager
will internally call HttpContext.AuthenticateAsync
with IdentityConstants.ApplicationScheme
and as mentioned previously will “sign out” of IdentityConstants.ExtrenalScheme
.
If the Succeeded
property is false
this means that there’s no user associated with the login provider, i.e. there’s no record in AspNetUserLogins
that maps the LoginProvider
and ProviderKey
to a local user in AspNetUsers
.
If that’s the case we need to create a new user and associate him with the login provider.
First thing we do is to get the user’s email from the claims that came from Google:
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
We create a new instance of IdentityUser:
var newUser = new IdentityUser {
UserName = email,
Email = email,
EmailConfirmed = true
};
We create the user in the AspNetUsers
table:
var createResult = await _userManager.CreateAsync(newUser);
Notice that the user will not have a password. In this example that’s not important because we only want to support logging in through the external login provider.
The createResult
might indicate that the user creation failed. This might happen because the email is already being used by another user, for example. You should not let the authentication proceed if that’s the case.
If the user creation was successful we need to associate the new user with the external login provider by creating a new record in AspNetUserLogins
. And we do that by calling the AddLoginAsync
method in SignInManager
with the newly created user and the information we got back from the external login provider:
await _userManager.AddLoginAsync(newUser, info);
Next we want to save the claims we got from the external login provider in AspNetUserClaims
. And maybe add our own. In the following example a claim named “userId” is added (after _userManager.CreateAsync
the newUser.Id property will be populated with the new user’s Id).
var newUserClaims = info.Principal.Claims.Append(new Claim("userId", newUser.Id));
await _userManager.AddClaimsAsync(newUser, newUserClaims);
Finally we use SignInManager
to “sign the user in”:
await _signInManager.SignInAsync(newUser, isPersistent: false);
What this does internally is to call HttpContext.AuthenticateAsync
on the IdentityConstants.ApplicationScheme
.
And finally we need to sign out of the IdentityConstants.ExternalScheme
(which contains the information received from the external login provider about the user).
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
Before we describe logging out a quick note about how the user’s claims is required. They are saved the first time the user logs in and then those claims’ values are re-used. The login provider’s claims are basically ignored from there on.
This kind of makes sense if you think that you can have several external login providers associated with the same user account, and in the end it’s that account in your ASP.NET Core website that is the source of truth about your user.
That also means that if you change your claims in your external login provider after creating a local account, those changes will not be visible in the ASP.NET Core website.
You can however manually manage this by using UserManager
‘s methods to handle claims (RemoveClaimAsync
, RemoveClaimsAsync
, AddClaimAsync
and AddClaimsAsync
).
Handling logout
The action of logging out is actually the simplest. It just needs to guarantee that the main authentication cookie from the IdentityConstants.ApplicationScheme
(usually named .AspNetCore.Identity.Application
) gets removed.
To do this we’ll use SignInManager
‘s SignOutAsync
method. This method actually signs out of all the authentication schemes that are automatically configured for you when you call ServiceCollection.AddIdentity...
in Startup.cs
.
After calling logging out you should redirect the user to a page that doesn’t require the user to be authenticated. In this example we are redirecting the user to the default url you get when you host an Angular application using ng serve --open
.
Here’s an example of the Logout action method:
public async Task<IActionResult> Logout()
{
await _signInManager.SignOutAsync();
return Redirect("http://localhost:4200");
}
That’s it. These steps should be enough that you can tailor this example to your particular needs. Let me know your thoughts in the comments below.