IdentityServer4 in ASP.NET Core Part 2

In part one we set up our IdentityServer4 project and our data and core projects. If you haven’t read part one, you can do so here. In part two we’re going to add in an Angular web application using the implicit flow and an API that the web app will interact with. You can see the full source code here.

Configure IdentityServer4 in SQL

Now that we have our IdentityServer4 application set up, we need to go ahead and configure the database for CORS, API Resources, and more. You can do this in C# and use the startup to configure it, but I tend to just use a SQL script since I prefer using it in production anyway. Go ahead and add in an ASP.NET Core Web API project which in our case will be called IdentityServer4Example.Api, and a web app aptly named IdentityServer4Example.Web. Look in the properties of each project and note the URL they are running on locally. We’re going to need this to configure everything going forward.


USE [IdServer4Example];
GO
--Clients

INSERT INTO Dbo.Clients( [Absoluterefreshtokenlifetime], [Accesstokenlifetime], [Accesstokentype], [Allowaccesstokensviabrowser], [Allowofflineaccess], [Allowplaintextpkce], [Allowrememberconsent],
[Alwaysincludeuserclaimsinidtoken], [Alwayssendclientclaims], [Authorizationcodelifetime], [BackChannelLogoutSessionRequired], [Clientid], [Clientname], [Clienturi], [Enablelocallogin], [Enabled], [FrontChannelLogoutSessionRequired], [Identitytokenlifetime],
[Includejwtid], [Logouri], [Protocoltype], [Refreshtokenexpiration], [Refreshtokenusage], [Requireclientsecret], [Requireconsent],
[Requirepkce], [Slidingrefreshtokenlifetime], [Updateaccesstokenclaimsonrefresh] )
VALUES( 
	   2592000, 3600, 0, 1, 1, 0, 1, 0, 0, 300, 0, 'examplewebclient', 'ExampleWebClient', 'https://localhost:44322', 1, 1, 0, 300, 0, NULL, 'oidc', 1, 1, 1, 0, 0, 1296000, 0 );
GO

INSERT INTO IdentityResources([Description], [DisplayName], [Emphasize], [Enabled], [Name], [Required], [ShowInDiscoveryDocument])
VALUES  ('', 'Your user identifier', 0, 1, 'openid', 1, 1),
		('Your user profile information (first name, last name, etc.)', 'User profile', 1, 1, 'profile', 0, 1),
		('', 'Your email address', 1, 1, 'email', 0, 1)
--ClientScopes

DECLARE @exampleWebClientId INT;

SELECT @exampleWebClientId = [C].[Id]
FROM Dbo.Clients AS C
WHERE [C].[Clientname] = 'ExampleWebClient';

INSERT INTO Dbo.Clientscopes( [Clientid], [Scope] )
VALUES (@exampleWebClientId, 'exampleapi' ),
	   (@exampleWebClientId, 'openid' ),
	   (@exampleWebClientId, 'profile'),
	   (@exampleWebClientId, 'email');
--ClientSecrets

INSERT INTO Dbo.Clientsecrets( [Clientid], [Description], [Expiration], [Type], [Value] )
VALUES( 
	   @exampleWebClientId, NULL, NULL, 'SharedSecret', 'K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=' );
--ClientRedirectUris

INSERT INTO Dbo.Clientredirecturis( [Clientid], [Redirecturi] )
VALUES( 
	   @exampleWebClientId, 'https://localhost:44322/signin-callback.html'),
	   (@exampleWebClientId, 'https://localhost:44322/silent-renew-callback.html');
--ClientPostLogoutRedirectUris

INSERT INTO Dbo.Clientpostlogoutredirecturis( [Clientid], [Postlogoutredirecturi] )
VALUES( 
	   @exampleWebClientId, 'https://localhost:44322/signout-callback-oidc' );
--ClientGrantTypes

INSERT INTO Dbo.Clientgranttypes( [Clientid], [Granttype] )
VALUES( 
	   @exampleWebClientId, 'implicit' );
--ClientCorsOrigins

INSERT INTO Dbo.Clientcorsorigins( [Clientid], [Origin] )
VALUES(@exampleWebClientId, 'https://localhost:44322');
--ApiResources

INSERT INTO Dbo.Apiresources( [Description], [Displayname], [Enabled], [Name] )
VALUES( 
	   NULL, 'Example Web API', 1, 'exampleapi' );

DECLARE @exampleApiId INT;

SELECT @exampleApiId = [A].[Id]
FROM Dbo.Apiresources AS A
WHERE [A].[Name] = 'exampleapi';
--ApiScopes

INSERT INTO Dbo.Apiscopes( [Apiresourceid], [Description], [Displayname], [Emphasize], [Name], [Required], [Showindiscoverydocument] )
VALUES( 
	   @exampleApiId, NULL, 'Example Web API', 0, 'exampleapi', 0, 0 );
GO

The first insert creates the Client record which represents our web client. Next, we create a list of identity resources that we want to include in the identity token. The client secret in this case is more for example purposes than actual use. Given that we are using an Implicit flow with JWT, we won’t be using the server to do any communication with IdentityServer4. Your ClientScopes are the scopes your web client has access to and the client redirect records are largely self explanatory. The client grant type as stated is implicit, and we will need to set up CORS origins in order for our web client to interact with our API. Next we define an API resource, which is our web API, and then we create an API scope which our web client will be configured for later on.

Configuring the API

Now, we’re going to go ahead and set up our API to work with IdentityServer4 and accept requests from our web client. Go ahead and start off by looking at the following in startup:


    public class Startup
    {
        public Startup(«IConfigurationß configuration)
        {
            Configuration = configuration;
        }

        public «IConfigurationß Configuration { get; }

        public void ConfigureServices(«IServiceCollectionß services)
        {
            var connectionString = Configuration.GetConnectionString("IdServer4ExampleConnection");
            services.AddDbContext<╥IdServer4ExampleDbContextß>(options =>
            {
                options.UseSqlServer(connectionString);
            });
            services.AddDistributedMemoryCache();

            var lockoutOptions = new LockoutOptions()
            {
                AllowedForNewUsers = true,
                DefaultLockoutTimeSpan = ☼TimeSpanß.FromDays(99999),
                MaxFailedAccessAttempts = 5
            };

            services.AddIdentity<╥ApplicationUserß, ╥IdentityRoleß<☼Guidß>>(option =>
            {
                option.Lockout = lockoutOptions;
                option.User = new UserOptions { RequireUniqueEmail = true };
                option.Password.RequireDigit = false;
                option.Password.RequiredLength = 12;
                option.Password.RequiredUniqueChars = 0;
                option.Password.RequireLowercase = false;
                option.Password.RequireNonAlphanumeric = false;
                option.Password.RequireUppercase = false;
            })
            .AddEntityFrameworkStores<╥IdServer4ExampleDbContextß>()
            .AddDefaultTokenProviders();

            services.AddMvc().SetCompatibilityVersion(◙CompatibilityVersionß.Version_2_1);

            services.AddCors(options =>
            {
                options.AddPolicy("corspolicy", policy =>
                {
                    policy.WithOrigins("https://localhost:44322")
                        .AllowAnyHeader()
                        .AllowAnyMethod()
                        .AllowCredentials();
                });
            });

            services.AddAuthentication(╥JwtBearerDefaultsß.AuthenticationScheme)
            .AddIdentityServerAuthentication(options =>
            {
                options.Authority = "https://localhost:44386";
                options.RequireHttpsMetadata = false;
                options.ApiName = "exampleapi";
            });

            services.AddMvc(options => options.Filters.Add(new RequireHttpsAttribute()));

            services.AddAuthorization(c =>
            {
                c.AddPolicy("exampleapi", p => p.RequireClaim("scope", "exampleapi"));
            });
        }

        public void Configure(«IApplicationBuilderß app, «IHostingEnvironmentß env)
        {
            if (env.IsDevelopment())
            {
                app.UseCors("corspolicy");
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseCors("corspolicy");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseAuthentication();
            app.UseCookiePolicy();
            app.UseMvc();
        }
    }

Most of this should be familiar to you from the previous startup for our IdentityServer4 application. Take note of the Cors options which we are passing the URL of our web application for, and then the services.AddAuthorization call which we are requiring the client to have the exampleapi scope. Finally, let’s create a simple controller to return some data to the web client:


    [Authorize(AuthenticationSchemes = ╥JwtBearerDefaultsß.AuthenticationScheme)]
    [Route("api/[controller]")]
    public class UserController : ╥Controllerß
    {
        [╥Authorizeß]
        [╥HttpGetß("GetUsers")]
        public «IActionResultß GetUsers()
        {
            return new OkObjectResult(new ╥Listß<╥ApplicationUserß>
            {
                new ApplicationUser
                {
                    FirstName = "Mike",
                    LastName = "Stanford"
                },
                new ApplicationUser
                {
                    FirstName = "Jennfier",
                    LastName = "Smith"
                },
                new ApplicationUser
                {
                    FirstName = "John",
                    LastName = "Angel"
                }
            });
        }
    }

Setting Up the Angular Web Client

The last piece that we need to set up is our Angular web client. We’ll start off by adding in the two HTML pages that we are using in our redirects that we configured earlier in the SQL. Add these to your wwwroot in your project. signin-callback.html:


<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>
    <script src="scripts/oidc-client.min.js"></script>
    <script>
        new Oidc.UserManager().signinRedirectCallback().then(function () {
            window.location = "/";
        }).catch(function (e) {
            console.error(e);
        });
    </script>
</body>
</html>

silent-renew-callback.html:


<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>
    <h1 id="waiting">Waiting...</h1>
    <div id="error"></div>
    <script src="scripts/oidc-client.min.js"></script>
    <script>
         new UserManager().signinSilentCallback();
    </script>
</body>
</html>

Next, use NPM to add the oidc-client library to your application. Then create the following authentication service and inject it in your app module:


import { Injectable, EventEmitter, Inject } from '@angular/core';
import { Http } from '@angular/http';
import { Observable } from 'rxjs/Rx';

import { UserManager, User } from 'oidc-client';

@Injectable()
export class AuthenticationService {
  settings: any;

  userManager: UserManager;
  userLoadededEvent: EventEmitter = new EventEmitter();
  currentUser: User;
  loggedIn = false;

  constructor(private http: Http) {
    this.settings = {
      authority: 'https://localhost:44386',
      client_id: 'examplewebclient',
      redirect_uri: `https://localhost:44322/signin-callback.html`,
      post_logout_redirect_uri: `https://localhost:44322/signout-callback-oidc`,
      response_type: 'id_token token',
      scope: 'openid profile exampleapi',

      silent_redirect_uri: `https://localhost:44322/silent-renew-callback.html`,
      automaticSilentRenew: true,
      accessTokenExpiringNotificationTime: 10,
      silentRequestTimeout: 10000,

      filterProtocolClaims: true,
      loadUserInfo: true
    };

    this.userManager = new UserManager(this.settings);

    this.userManager.getUser()
      .then((user) => {
        if (user) {
          this.loggedIn = true;
          this.currentUser = user;
          this.userLoadededEvent.emit(user);
        }
        else {
          this.loggedIn = false;
        }
      })
      .catch((err) => {
        this.loggedIn = false;
      });

    this.userManager.events.addUserLoaded((user) => {
      this.currentUser = user;
    });

    this.userManager.events.addUserUnloaded((e) => {
      this.loggedIn = false;
    });

  }

  isLoggedInObs(): Observable {
    return Observable.fromPromise(this.userManager.getUser()).map((user) => {
      if (user) {
        return true;
      } else {
        return false;
      }
    });
  }

  clearState() {
    this.userManager.clearStaleState().then(function () {
    }).catch(function (e) {
      console.log('clearStateState error', e.message);
    });
  }

  getUser() {
    this.userManager.getUser().then((user) => {
      this.currentUser = user;
      this.userLoadededEvent.emit(user);
    }).catch(function (err) {
      console.log(err);
    });
  }

  removeUser() {
    this.userManager.removeUser().then(() => {
      this.userLoadededEvent.emit();
    }).catch(function (err) {
      console.log(err);
    });
  }

  startSigninMainWindow() {
    this.userManager.signinRedirect({ data: '' }).then(function () {
    }).catch(function (err) {
      console.log(err);
    });
  }

  endSigninMainWindow() {
    this.userManager.signinRedirectCallback().then(function (user) {
    }).catch(function (err) {
      console.log(err);
    });
  }

  startSignoutMainWindow() {
    this.userManager.signoutRedirect().then(function (resp) {

    }).catch(function (err) {
      console.log(err);
    });
  };

  endSignoutMainWindow() {
    this.userManager.signoutRedirectCallback().then(function (resp) {
      console.log('signed out', resp);
    }).catch(function (err) {
      console.log(err);
    });
  };
}

Pay special attention to the settings object we are creating in the constructor. This needs to match up properly with the previous configuration we did in the startup in the other projects as well as in the database using the SQL script. Add the following route check and place it on the routes you want to protect:


import { Injectable } from "@angular/core";
import { CanActivate, RouterStateSnapshot, ActivatedRouteSnapshot, Router } from "@angular/router";
import { Observable } from 'rxjs/Observable';

import { AuthenticationService } from './auth';

@Injectable()
export class AuthenticationCheck implements CanActivate {
    constructor(
        private router: Router,
        private authenticationService: AuthenticationService) { }

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {

        let isLoggedIn = this.authenticationService.isLoggedInObs();

        isLoggedIn.subscribe((loggedin) => {
            if (!loggedin) {
                this.authenticationService.startSigninMainWindow();
            }
        });

        return isLoggedIn;
    }
}

Now, when a user attempts to access a route it will call the authentication service and check to see that the user is logged in. If they’re not logged in, it is going to redirect them to the IdentityServer4 sign in page. On successful signin, the user will then be redirected back to the web application. Finally, let’s add in an API service to our application to call our API. We’re going to pass the access token we got from IdentityServer4 to the API to authenticate us.


import { Inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse, HttpHeaders } from '@angular/common/http';
import { Observable } from "rxjs/Rx";
import { ApplicationUser } from '../models/application-user';
import { AuthenticationService } from '../services/auth';

@Injectable()
export class UserApiService {
  apiUrl: string = 'https://localhost:44356';
  httpOptions: HttpHeaders;

  constructor(private httpClient: HttpClient, private authService: AuthenticationService) {
  }

  getUsers(): Observable {

    return this.httpClient.get(`${this.apiUrl}/api/User/GetUsers`, {
      headers: new HttpHeaders({
        'Authorization': `Bearer ${this.authService.currentUser.access_token}`
      })
    });
  }
}

Normally you would not hardcode the URLs for the API and the Authority like you have seen me do in the previous code. It’s a much better idea to inject these into your components and classes. This simple method calls the API to get a list of users and sets the Authorization header with the access token from the oidc-client. Take special note of the “Bearer” before the token. Failure to put this in the header will cause the API to return you a 401.

Conclusion

In this post we added in our API and web client and configured them in the database to work with IdentityServer4 and the identity application we created previously. We then configured the startup in the API to allow CORS origin requests from our web app, and point the API to IdentityServer4 to verify access tokens. We then set up our Javascript client using oidc-client and the Implicit flow. There are many other flows and configurations available with IdentityServer4 and I recommend you go check out their website and API to see what all is available to you.

Return to Part 1

Sean Leitzinger

Solutions Architect at Edgeside Solutions
.NET and C# aficionado with an interest in architecture, patterns, practices, and more. Microsoft fanatic.

Latest posts by Sean Leitzinger (see all)

Leave a Reply

Your email address will not be published. Required fields are marked *