This article shows how IdentityServer4 with Identity, a data Web API, and an Angular 2 SPA could be setup inside a single ASP.NET Core project. The application uses the OpenID Connect Implicit Flow with reference tokens to access the API. The Angular 2 application uses webpack to build.
Code: https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow
Other posts in this series:
- OAuth2 Implicit Flow with Angular and ASP.NET Core 1.0 IdentityServer4
- Authorization Policies and Data Protection with IdentityServer4 in ASP.NET Core
- Angular OpenID Connect Implicit Flow with IdentityServer4
- Angular2 OpenID Connect Implicit Flow with IdentityServer4
- Secure file download using IdentityServer4, Angular2 and ASP.NET Core
- Angular2 secure file download without using an access token in URL or cookies
- Full Server logout with IdentityServer4 and OpenID Connect Implicit Flow
- IdentityServer4, Web API and Angular2 in a single project
Step 1: Create app and add IdentityServer4
Use the Quickstart6 AspNetIdentity from IdentityServer 4 to setup the application. Then edit the project json file to add your packages as required. I added the Microsoft.AspNetCore.Authentication.JwtBearer package and also the IdentityServer4.AccessTokenValidation package. The buildOptions have to be extended to ignore the node_modules folder.
{ "userSecretsId": "aspnet-IdentityServerWithAspNetIdentity-1e7bf5d8-6c32-4dd3-b77d-2d7d2e0f5099", "dependencies": { "IdentityServer4": "1.0.0-rc1-update2", "IdentityServer4.AspNetIdentity": "1.0.0-rc1-update2", "Microsoft.NETCore.App": { "version": "1.0.1", "type": "platform" }, "Microsoft.AspNetCore.Authentication.Cookies": "1.0.0", "Microsoft.AspNetCore.Diagnostics": "1.0.0", "Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore": "1.0.0", "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "1.0.0", "Microsoft.AspNetCore.Mvc": "1.0.0", "Microsoft.AspNetCore.Razor.Tools": { "version": "1.0.0-preview2-final", "type": "build" }, "Microsoft.AspNetCore.Server.IISIntegration": "1.0.0", "Microsoft.AspNetCore.Server.Kestrel": "1.0.0", "Microsoft.AspNetCore.StaticFiles": "1.0.0", "Microsoft.EntityFrameworkCore.Sqlite": "1.0.0", "Microsoft.EntityFrameworkCore.Sqlite.Design": { "version": "1.0.0", "type": "build" }, "Microsoft.EntityFrameworkCore.Tools": { "version": "1.0.0-preview2-final", "type": "build" }, "Microsoft.Extensions.Configuration.EnvironmentVariables": "1.0.0", "Microsoft.Extensions.Configuration.Json": "1.0.0", "Microsoft.Extensions.Configuration.UserSecrets": "1.0.0", "Microsoft.Extensions.Logging": "1.0.0", "Microsoft.Extensions.Logging.Console": "1.0.0", "Microsoft.Extensions.Logging.Debug": "1.0.0", "Microsoft.Extensions.Options.ConfigurationExtensions": "1.0.0", "Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0", "Microsoft.VisualStudio.Web.CodeGeneration.Tools": { "version": "1.0.0-preview2-final", "type": "build" }, "Microsoft.VisualStudio.Web.CodeGenerators.Mvc": { "version": "1.0.0-preview2-final", "type": "build" }, "Microsoft.AspNetCore.Authentication.JwtBearer": "1.0.0", "IdentityServer4.AccessTokenValidation": "1.0.1-rc1" }, "tools": { "BundlerMinifier.Core": "2.0.238", "Microsoft.AspNetCore.Razor.Tools": "1.0.0-preview2-final", "Microsoft.AspNetCore.Server.IISIntegration.Tools": "1.0.0-preview2-final", "Microsoft.EntityFrameworkCore.Tools": "1.0.0-preview2-final", "Microsoft.Extensions.SecretManager.Tools": "1.0.0-preview2-final", "Microsoft.VisualStudio.Web.CodeGeneration.Tools": { "version": "1.0.0-preview2-final", "imports": [ "portable-net45+win8" ] } }, "frameworks": { "netcoreapp1.0": { "imports": [ "dotnet5.6", "portable-net45+win8" ] } }, "buildOptions": { "emitEntryPoint": true, "preserveCompilationContext": true, "compile": { "exclude": [ "node_modules" ] } }, "runtimeOptions": { "configProperties": { "System.GC.Server": true } }, "publishOptions": { "include": [ "wwwroot", "Views", "Areas/**/Views", "appsettings.json", "web.config" ] }, "scripts": { "prepublish": [ "bower install", "dotnet bundle" ], "postpublish": [ "dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%" ] } }
The IProfileService interface is implemented to add your user claims to the tokens. The IdentityWithAdditionalClaimsProfileService class implements the IProfileService interface in this example and is added to the services in the Startup class.
using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using IdentityModel; using IdentityServer4.Extensions; using IdentityServer4.Models; using IdentityServer4.Services; using IdentityServerWithAspNetIdentity.Models; using Microsoft.AspNetCore.Identity; namespace ResourceWithIdentityServerWithClient { public class IdentityWithAdditionalClaimsProfileService : IProfileService { private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory; private readonly UserManager<ApplicationUser> _userManager; public IdentityWithAdditionalClaimsProfileService(UserManager<ApplicationUser> userManager, IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory) { _userManager = userManager; _claimsFactory = claimsFactory; } public async Task GetProfileDataAsync(ProfileDataRequestContext context) { var sub = context.Subject.GetSubjectId(); var user = await _userManager.FindByIdAsync(sub); var principal = await _claimsFactory.CreateAsync(user); var claims = principal.Claims.ToList(); if (!context.AllClaimsRequested) { claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList(); } claims.Add(new Claim(JwtClaimTypes.GivenName, user.UserName)); //new Claim(JwtClaimTypes.Role, "admin"), //new Claim(JwtClaimTypes.Role, "dataEventRecords.admin"), //new Claim(JwtClaimTypes.Role, "dataEventRecords.user"), //new Claim(JwtClaimTypes.Role, "dataEventRecords"), //new Claim(JwtClaimTypes.Role, "securedFiles.user"), //new Claim(JwtClaimTypes.Role, "securedFiles.admin"), //new Claim(JwtClaimTypes.Role, "securedFiles") if (user.IsAdmin) { claims.Add(new Claim(JwtClaimTypes.Role, "admin")); } else { claims.Add(new Claim(JwtClaimTypes.Role, "user")); } if (user.DataEventRecordsRole == "dataEventRecords.admin") { claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.admin")); claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.user")); claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords")); } else { claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords.user")); claims.Add(new Claim(JwtClaimTypes.Role, "dataEventRecords")); } if (user.SecuredFilesRole == "securedFiles.admin") { claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles.admin")); claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles.user")); claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles")); } else { claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles.user")); claims.Add(new Claim(JwtClaimTypes.Role, "securedFiles")); } claims.Add(new System.Security.Claims.Claim(StandardScopes.Email.Name, user.Email)); context.IssuedClaims = claims; } public async Task IsActiveAsync(IsActiveContext context) { var sub = context.Subject.GetSubjectId(); var user = await _userManager.FindByIdAsync(sub); context.IsActive = user != null; } } }
Step 2: Add the Web API for the resource data
The MVC Controller DataEventRecordsController is used for CRUD API requests. This is just a dummy implementation. I would implement all resource server logic in a separate project. The Authorize attribute is used with and without policies. The policies are configured in the Startup class.
using ResourceWithIdentityServerWithClient.Model; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Collections.Generic; using System; namespace ResourceWithIdentityServerWithClient.Controllers { [Authorize] [Route("api/[controller]")] public class DataEventRecordsController : Controller { [Authorize("dataEventRecordsUser")] [HttpGet] public IActionResult Get() { return Ok(new List<DataEventRecord> { new DataEventRecord { Id =1, Description= "Fake", Name="myname", Timestamp= DateTime.UtcNow } }); } [Authorize("dataEventRecordsAdmin")] [HttpGet("{id}")] public IActionResult Get(long id) { return Ok(new DataEventRecord { Id = 1, Description = "Fake", Name = "myname", Timestamp = DateTime.UtcNow }); } [Authorize("dataEventRecordsAdmin")] [HttpPost] public void Post([FromBody]DataEventRecord value) { } [Authorize("dataEventRecordsAdmin")] [HttpPut("{id}")] public void Put(long id, [FromBody]DataEventRecord value) { } [Authorize("dataEventRecordsAdmin")] [HttpDelete("{id}")] public void Delete(long id) { } } }
Step 3: Add client Angular 2 client API
The Angular 2 client part of the application is setup and using the ASP.NET Core, Angular2 with Webpack and Visual Studio article. Webpack is then used to build the client application.
Any SPA client can be used which supports the OpenID Connect Implicit Flow. IdentityServer4 (IdentityModel) also have good examples using the OIDC javascript client.
Step 4: Configure application host URL
The URL host is the same for both the client and the server. This is configured in the Config class as a static property HOST_URL and used throughout the server side of the application.
public class Config { public static string HOST_URL = "https://localhost:44363";
The client application reads the configuration from the app.constants.ts provider.
import { Injectable } from '@angular/core'; @Injectable() export class Configuration { public Server: string = "https://localhost:44363"; }
IIS Express is configured to run with HTTPS and matches these configurations. If a different port is used, you need to change these two code configurations. In a production environment, the data should be configurable pro deployment.
Step 5: Deactivate the consent view
The consent view is deactivated because the client is the only client to use this data resource and always requires the same consent. To improve the user experience, the consent view is removed from the flow. This is done by setting the RequireConsent property to false in the client configuration.
public static IEnumerable<Client> GetClients() { // client credentials client return new List<Client> { new Client { ClientName = "singleapp", ClientId = "singleapp", RequireConsent = false, AccessTokenType = AccessTokenType.Reference, //AccessTokenLifetime = 600, // 10 minutes, default 60 minutes AllowedGrantTypes = GrantTypes.Implicit, AllowAccessTokensViaBrowser = true, RedirectUris = new List<string> { HOST_URL }, PostLogoutRedirectUris = new List<string> { HOST_URL + "/Unauthorized" }, AllowedCorsOrigins = new List<string> { HOST_URL }, AllowedScopes = new List<string> { "openid", "dataEventRecords" } } }; }
Step 6: Deactivate logout screens
When the Angular 2 client requests a logout, the client is logged out, reference tokens are invalidated for this application and user, and the user is redirected back to the Angular 2 application without the server account logout views. This improves the user experience.
The existing 2 Logout action methods are removed from the AccountController and the following is implemented. The controller requires the IPersistedGrantService to remove the reference tokens.
/// <summary> /// special logout to skip logout screens /// </summary> /// <param name="logoutId"></param> /// <returns></returns> [HttpGet] public async Task<IActionResult> Logout(string logoutId) { var user = HttpContext.User.Identity.Name; var subjectId = HttpContext.User.Identity.GetSubjectId(); // delete authentication cookie await HttpContext.Authentication.SignOutAsync(); // set this so UI rendering sees an anonymous user HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); // get context information (client name, post logout redirect URI and iframe for federated signout) var logout = await _interaction.GetLogoutContextAsync(logoutId); var vm = new LoggedOutViewModel { PostLogoutRedirectUri = logout?.PostLogoutRedirectUri, ClientName = logout?.ClientId, SignOutIframeUrl = logout?.SignOutIFrameUrl }; await _persistedGrantService.RemoveAllGrantsAsync(subjectId, "singleapp"); return Redirect(Config.HOST_URL + "/Unauthorized"); }
Step 7: Configure Startup to use all three application parts
The Startup class configures all three application parts to run together. The Angular 2 application requires that its client routes are routed on the client and not the server. Middleware is added so that the server does not handle the client routes.
The API service needs to check the reference token and validate. Policies are added for this and also the extension method UseIdentityServerAuthentication is used to check the reference tokens for each request.
IdentityServer4 is setup to use Identity with a SQLite database.
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using IdentityServerWithAspNetIdentity.Data; using IdentityServerWithAspNetIdentity.Models; using IdentityServerWithAspNetIdentity.Services; using QuickstartIdentityServer; using IdentityServer4.Services; using System.Security.Cryptography.X509Certificates; using System.IO; using System.Linq; using Microsoft.AspNetCore.Http; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System; using Microsoft.AspNetCore.Authorization; using IdentityServer4.AccessTokenValidation; namespace ResourceWithIdentityServerWithClient { public class Startup { private readonly IHostingEnvironment _environment; public Startup(IHostingEnvironment env) { var builder = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true); if (env.IsDevelopment()) { builder.AddUserSecrets(); } _environment = env; builder.AddEnvironmentVariables(); Configuration = builder.Build(); } public IConfigurationRoot Configuration { get; } public void ConfigureServices(IServiceCollection services) { var cert = new X509Certificate2(Path.Combine(_environment.ContentRootPath, "damienbodserver.pfx"), ""); services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(Configuration.GetConnectionString("DefaultConnection"))); services.AddIdentity<ApplicationUser, IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders(); var guestPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .RequireClaim("scope", "dataEventRecords") .Build(); services.AddAuthorization(options => { options.AddPolicy("dataEventRecordsAdmin", policyAdmin => { policyAdmin.RequireClaim("role", "dataEventRecords.admin"); }); options.AddPolicy("dataEventRecordsUser", policyUser => { policyUser.RequireClaim("role", "dataEventRecords.user"); }); }); services.AddMvc(); services.AddTransient<IProfileService, IdentityWithAdditionalClaimsProfileService>(); services.AddTransient<IEmailSender, AuthMessageSender>(); services.AddTransient<ISmsSender, AuthMessageSender>(); services.AddDeveloperIdentityServer() .SetSigningCredential(cert) .AddInMemoryScopes(Config.GetScopes()) .AddInMemoryClients(Config.GetClients()) .AddAspNetIdentity<ApplicationUser>() .AddProfileService<IdentityWithAdditionalClaimsProfileService>(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { loggerFactory.AddConsole(Configuration.GetSection("Logging")); loggerFactory.AddDebug(); var angularRoutes = new[] { "/Unauthorized", "/Forbidden", "/home", "/dataeventrecords/", "/dataeventrecords/create", "/dataeventrecords/edit/", "/dataeventrecords/list", }; app.Use(async (context, next) => { if (context.Request.Path.HasValue && null != angularRoutes.FirstOrDefault( (ar) => context.Request.Path.Value.StartsWith(ar, StringComparison.OrdinalIgnoreCase))) { context.Request.Path = new PathString("/"); } await next(); }); app.UseDefaultFiles(); if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); app.UseDatabaseErrorPage(); app.UseBrowserLink(); } else { app.UseExceptionHandler("/Home/Error"); } app.UseIdentity(); app.UseIdentityServer(); app.UseStaticFiles(); JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); IdentityServerAuthenticationOptions identityServerValidationOptions = new IdentityServerAuthenticationOptions { Authority = Config.HOST_URL + "/", ScopeName = "dataEventRecords", ScopeSecret = "dataEventRecordsSecret", AutomaticAuthenticate = true, SupportedTokens = SupportedTokens.Both, // TokenRetriever = _tokenRetriever, // required if you want to return a 403 and not a 401 for forbidden responses AutomaticChallenge = true, }; app.UseIdentityServerAuthentication(identityServerValidationOptions); app.UseMvcWithDefaultRoute(); } } }
The application can then be run and tested. To test, right click the project and debug.
Links
https://github.com/IdentityServer/IdentityServer4
http://docs.identityserver.io/en/dev/
https://github.com/IdentityServer/IdentityServer4.Samples
https://docs.asp.net/en/latest/security/authentication/identity.html
https://github.com/IdentityServer/IdentityServer4/issues/349
ASP.NET Core, Angular2 with Webpack and Visual Studio
