14 Mar 2019
Let’s enable the external login, so that Microsoft and LinkedIn users don’t need to sign up in Music Store.
-
Microsoft
- Create an app in Microsoft via https://apps.dev.microsoft.com/
- Fill in Name
-
Generate New Password

- Add Platform

NOTE: Redirect URLs need to be consistent with CallbackPath in Startup.cs, otherwise, you will get an error:
The provided value for the input parameter 'redirect_uri' is not valid
-
Update IdentityServer4 to authenticate Microsoft users
- Install NuGet Package - Microsoft.AspNetCore.Authentication.MicrosoftAccount
- Add Microsoft Authentication
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
......
services.AddAuthentication()
.AddMicrosoftAccount(options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.ClientId = Configuration["Microsoft:ApplicationId"];
options.ClientSecret = Configuration["Microsoft:Password"];
options.CallbackPath = new PathString("/microsoft");
options.SaveTokens = true;
});
......
}
public void Configure(IApplicationBuilder app)
{
......
app.UseAuthentication();
......
}
- Store Application Id and Password from #1.1 to appsettings.Development.json
{
......
"Microsoft": {
"ApplicationId": "0d1c72bb-ec52-4a0c-a582-4ad79f9a9c5a",
"Password": "vnssBKZWR237*ujtZW44@*|"
}
......
}
-
LinkedIn
NOTE: LinkedIn upgraded their API recently, it won't carry back user email, a key field in ApplicationUser, so LinkedIn user could login.
-
Authenticate users from external OAuth 2.0 providers
-
Display user information in WebUI
-
Update welcome message in nav-menu component
<pre class="wp-block-code"><code>// nav-menu.component.html
<div class='main-nav'>
<div class='navbar navbar-inverse'>
<div class='navbar-header'>
......
<a class='navbar-brand' [routerLink]='["/"]'>Welcome </a>
</div>
......
</div>
</div>
// nav-menu.component.ts
import { Component } from '@angular/core';
import { UserProfileService } from '../userprofile/userprofile.service';
@Component({
selector: 'app-nav-menu',
templateUrl: './nav-menu.component.html',
styleUrls: ['./nav-menu.component.css']
})
export class NavMenuComponent {
isExpanded = false;
firstName: string = '';
lastName: string = '';
constructor(
private _userProfileService: UserProfileService
) {
var that = this;
this._userProfileService.identityClaimsReady.subscribe(function (claims) {
if (claims) {
that.firstName = claims["FirstName"];
that.lastName = claims["LastName"];
}
});
}
......
}
-
Send claims to subscribers
// app.component.ts
......
import { UserProfileService } from './userprofile/userprofile.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
title = 'app';
constructor(
@Inject(PLATFORM_ID) private platformId: Object,
private _oauthService: OAuthService,
private _userProfileService: UserProfileService
) {
......
}
/**
* On init
*/
ngOnInit(): void {
if (isPlatformBrowser(this.platformId)) {
this._oauthService.loadDiscoveryDocumentAndTryLogin().then(_ => {
if (!this._oauthService.hasValidIdToken() || !this._oauthService.hasValidAccessToken()) {
this._oauthService.initImplicitFlow();
} else {
this._userProfileService.onIdentityClaimsReadyChanged(this._oauthService.getIdentityClaims());
}
});
}
}
}
-
Add userprofile.service.ts to observe claim changes
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
@Injectable()
export class UserProfileService {
identityClaimsReady: Subject<any> = new Subject();
onIdentityClaimsReadyChanged(claims): void {
this.identityClaimsReady.next(claims);
}
}
-
Include FirstName and LastName in Claims from IdentityServer4
// ProfileService.cs
using IdentityServer4.Models;
using IdentityServer4.Services;
using JayCoder.MusicStore.Core.Domain.SQLEntities;
using Microsoft.AspNetCore.Identity;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
namespace JayCoder.MusicStore.Projects.IdentityServer.Profile
{
public class ProfileService : IProfileService
{
protected UserManager<ApplicationUser> _userManager;
public ProfileService(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
public Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var user = _userManager.GetUserAsync(context.Subject).Result;
if (user != null)
{
var claims = new List<Claim>();
claims.Add(new Claim("FirstName", string.IsNullOrEmpty(user.FirstName) ? string.Empty : user.FirstName));
claims.Add(new Claim("LastName", string.IsNullOrEmpty(user.LastName) ? string.Empty : user.LastName));
context.IssuedClaims.AddRange(claims);
}
return Task.FromResult(0);
}
......
}
}
NOTE: In order to access user claims from Angular, AlwaysIncludeUserClaimsInIdToken needs to be true when you register Client in IdentityServer4.
-
Test
Login from my Microsoft account and be able to access values API

08 Mar 2019
After finishing the secondment to innovation team, I had to re-configure my local Sitecore environment and upgrade SXA from 1.3 to 1.7 which had been done by others when I was away.
After getting the latest code from VSTS and restoring the database, I got this welcome message:
Server Error in '/' Application.
A route named 'MS_attributerouteWebApi' is already in the route collection. Route names must be unique. Parameter name: name Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code.
Exception Details: System.ArgumentException: A route named 'MS_attributerouteWebApi' is already in the route collection. Route names must be unique. Parameter name: name
Source Error:
An unhandled exception was generated during the execution of the current web request. Information regarding the origin and location of the exception can be identified using the exception stack trace below.
Stack Trace:
[ArgumentException: A route named 'MS_attributerouteWebApi' is already in the route collection. Route names must be unique. Parameter name: name] System.Web.Routing.RouteCollection.Add(String name, RouteBase item) +391
System.Web.Http.Routing.AttributeRoutingMapper.MapAttributeRoutes(HttpConfiguration configuration, IInlineConstraintResolver constraintResolver, IDirectRouteProvider directRouteProvider) +166
Sitecore.Services.Infrastructure.Web.Http.HttpConfigurationBuilder.MapHttpAttributeRoutes() +89 Sitecore.Services.Infrastructure.Web.Http.ServicesConfigurator.Configure(HttpConfiguration config, RouteCollection routes) +501
Sitecore.Services.Infrastructure.Sitecore.Pipelines.ServicesWebApiInitializer.Process(PipelineArgs args) +194 (Object , Object[] ) +74
Sitecore.Pipelines.CorePipeline.Run(PipelineArgs args) +469
Sitecore.Pipelines.DefaultCorePipelineManager.Run(String pipelineName, PipelineArgs args, String pipelineDomain) +22
Sitecore.Nexus.Web.HttpModule.Application_Start() +161
Sitecore.Nexus.Web.HttpModule.Init(HttpApplication app) +764
System.Web.HttpApplication.RegisterEventSubscriptionsWithIIS(IntPtr appContext, HttpContext context, MethodInfo[] handlers) +581
System.Web.HttpApplication.InitSpecial(HttpApplicationState state, MethodInfo[] handlers, IntPtr appContext, HttpContext context) +172
System.Web.HttpApplicationFactory.GetSpecialApplicationInstance(IntPtr appContext, HttpContext context) +418
System.Web.Hosting.PipelineRuntime.InitializeApplication(IntPtr appContext) +369
[HttpException (0x80004005): A route named 'MS_attributerouteWebApi' is already in the route collection. Route names must be unique. Parameter name: name]
System.Web.HttpRuntime.FirstRequestInit(HttpContext context) +534
System.Web.HttpRuntime.EnsureFirstRequestInit(HttpContext context) +111 System.Web.HttpRuntime.ProcessRequestNotificationPrivate(IIS7WorkerRequest wr, HttpContext context) +718
Luckily, I got WinMerge. After comparing the working version, I realized someone accidentally created a global.asax and overwrote the default one.
Then I managed to login by deleting that asax file. However, when I clicked on tree nodes in Content Editor, I got exception again:
Server Error in '/' Application.
Value cannot be null.
Parameter name: fieldNameTranslator
Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code.
Exception Details: System.ArgumentNullException: Value cannot be null.
Parameter name: fieldNameTranslator
Source Error:
An unhandled exception was generated during the execution of the current web request. Information regarding the origin and location of the exception can be identified using the exception stack trace below.
Stack Trace:
[ArgumentNullException: Value cannot be null.
Parameter name: fieldNameTranslator]
Sitecore.ContentSearch.Linq.Solr.SolrIndexParameters..ctor(IIndexValueFormatter valueFormatter, IFieldQueryTranslatorMap`1 fieldQueryTranslators, FieldNameTranslator fieldNameTranslator, IExecutionContext[] executionContexts, IFieldMapReaders fieldMap, Boolean convertQueryDatesToUtc) +328
Sitecore.ContentSearch.SolrProvider.LinqToSolrIndex`1..ctor(SolrSearchContext context, IExecutionContext[] executionContexts) +188
Sitecore.ContentSearch.SolrProvider.SolrSearchContext.GetQueryable(IExecutionContext[] executionContexts) +268
Sitecore.Social.Search.SearchProvider.SearchItems(Expression`1 whereExpression, Func`2 selector) +164
Sitecore.Social.Search.SearchProvider.GetMessagesByContainer(String container) +492
Sitecore.Social.MessageBusinessManager.SearchMessagesByContainer(String container) +203
Sitecore.Social.MessageBusinessManager.GetMessagesCount(String container) +16
Sitecore.Social.Client.MessagePosting.Commands.SocialCenter.RunGetHeader(CommandContext context, String header) +342
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.FillParamsFromCommand(CommandContext commandContext, RibbonCommandParams ribbonCommandParams) +98
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.GetCommandParameters(Item controlItem, CommandContext commandContext) +79
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderLargeButton(HtmlTextWriter output, Item button, CommandContext commandContext) +78
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderButton(HtmlTextWriter output, Item button, CommandContext commandContext) +440
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderChunk(HtmlTextWriter output, Item chunk, CommandContext commandContext) +342
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderChunk(HtmlTextWriter output, Item chunk, CommandContext commandContext, Boolean isContextual, String id) +244
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderChunk(HtmlTextWriter output, Item chunk, CommandContext commandContext, Boolean isContextual) +161
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderChunks(HtmlTextWriter output, Item strip, CommandContext commandContext, Boolean isContextual) +445
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderStrips(HtmlTextWriter output, Item ribbon, Boolean isContextual, ListString visibleStripList) +1613
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderStrips(HtmlTextWriter output, Item defaultRibbon, Item contextualRibbon, ListString visibleStripList) +162
Sitecore.Web.UI.WebControls.Ribbons.Ribbon.Render(HtmlTextWriter output) +747
System.Web.UI.Control.RenderControlInternal(HtmlTextWriter writer, ControlAdapter adapter) +79
Sitecore.Web.HtmlUtil.RenderControl(Control ctl) +80
Sitecore.Shell.Applications.ContentManager.ContentEditorForm.UpdateRibbon(Item folder, Boolean isCurrentItemChanged, Boolean showEditor) +502
Sitecore.Shell.Applications.ContentManager.ContentEditorForm.Update() +582
Sitecore.Shell.Applications.ContentManager.ContentEditorForm.OnPreRendered(EventArgs e) +205
[TargetInvocationException: Exception has been thrown by the target of an invocation.]
System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor) +0
System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(Object obj, Object[] parameters, Object[] arguments) +128
System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture) +142
Sitecore.Reflection.ReflectionUtil.InvokeMethod(MethodInfo method, Object[] parameters, Object obj) +89
Sitecore.Shell.Applications.ContentManager.ContentEditorPage.OnPreRender(EventArgs e) +143
System.Web.UI.Control.PreRenderRecursiveInternal() +162
System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint) +6875
And some warnings from crawling log:
18828 22:59:24 WARN Failed to initialize 'sitecore_marketing_asset_index_master' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'sitecore_marketing_asset_index_web' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'sitecore_marketingdefinitions_master' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'sitecore_marketingdefinitions_web' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'sitecore_testing_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'sitecore_suggested_test_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'sitecore_fxm_master_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'sitecore_fxm_web_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'sitecore_list_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'social_messages_master' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
18828 22:59:24 WARN Failed to initialize 'social_messages_web' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN DONE
It’s because I didn’t create those indexes yet:
- sitecore_marketing_asset_index_master
- sitecore_marketing_asset_index_web
- ……
Finally, Sitecore started working after adding those indexes in Solr configuration, hooray!
02 Mar 2019
In MusicStore - Part1 - Init, in memory resources and test users had been used for test purpose.
// Startup.cs in IdentityServer
public void ConfigureServices(IServiceCollection services)
{
......
services.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryIdentityResources(Resources.GetIdentityResources())
.AddInMemoryApiResources(Resources.GetApiResources())
.AddInMemoryClients(new PortalClientFactory(Configuration).GetClients())
.AddTestUsers(TestUsers.Users);
......
}
In real world, credentials always be stored in database. Thus, I will use SQL Server + Entity Framework to replace in memory resources.
-
Create a new database called MusicStore_IdentityServer
// appsettings.Development.json
"ConnectionStrings": {
"IdentityServer4Connection": "Data Source=.;Initial Catalog=MusicStore_IdentityServer;Integrated Security=False;User ID=musicstore_user;Password=P@ssword!@#$;"
}
-
Migrate resources to SQL Server
- Run CMD and navigate to Projects > IdentityServer folder
-
Use EF Code First to migrate PersistedGrantDb schema to C# script
dotnet ef migrations add InitialIdentityServerPersistedGrantDbMigration -c PersistedGrantDbContext -o Migrations/IdentityServer/PersistedGrantDb
-
Use EF Code First to migrate ConfigurationDb schema to C# script
dotnet ef migrations add InitialIdentityServerConfigurationDbMigration -c ConfigurationDbContext -o Migrations/IdentityServer/ConfigurationDb
- Import EF scripts to database in IdentityServer start-up
// Startup.cs
public void Configure(IApplicationBuilder app)
{
// Init Database using EF Code First
InitializeDatabase(app);
app.UseStaticFiles();
app.UseIdentityServer();
app.UseMvcWithDefaultRoute();
}
private void InitializeDatabase(IApplicationBuilder app)
{
using (var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope())
{
//PersistedGrant DB Context
var persistedGrantContext = serviceScope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>();
persistedGrantContext.Database.Migrate();
//Configuration DB Context
var configContext = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
configContext.Database.Migrate();
if (!configContext.Clients.Any())
{
var clientList = new PortalClientFactory(Configuration).GetClients();
if (clientList != null && clientList.Any())
{
foreach (var client in clientList)
{
configContext.Clients.Add(client.ToEntity());
}
configContext.SaveChanges();
}
}
if (!configContext.IdentityResources.Any())
{
foreach (var resource in Resources.GetIdentityResources())
{
configContext.IdentityResources.Add(resource.ToEntity());
}
configContext.SaveChanges();
}
if (!configContext.ApiResources.Any())
{
foreach (var resource in Resources.GetApiResources())
{
configContext.ApiResources.Add(resource.ToEntity());
}
configContext.SaveChanges();
}
}
}
- Run IdentityServer project
- Check if database tables are auto generated
-
Check if clients are imported correctly

- Integrate ASP.NET Identity
- Create a new .NET Standard project under Core folder called Domain
- Create a new class “ApplicationUser” to inherit from IdentityUser
public class ApplicationUser : IdentityUser
{
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DOB { get; set; }
public int Gender { get; set; }
public string Avatar { get; set; }
public int Language { get; set; }
public bool Enabled { get; set; }
public EnumUserType UserType { get; set; }
}
- Create a new .NET Standard project under Foundations folder called SQLServer
-
Create a new class “AspNetIdentityDbContext” to inherit from IdentityDbContext
public class AspNetIdentityDbContext : IdentityDbContext<ApplicationUser>
{
......
}
- Run CMD and navigate to Foundations > SQLServer
-
Use EF Code First to migrate AspNetIdentityDbContext schema to C# script
dotnet ef migrations add InitialAspNetIdentityDbMigration -c AspNetIdentityDbContext -o Migrations/AspNetIdentityDb -s ../../Projects/IdentityServer
NOTE: As SQLServer is not a executable project, it needs to refer to a startup project (--startup-project or -s)
-
Import EF scripts for AspNet Identity
Append this code snip to InitializeDatabase method
// AspNet Identity DB Context
var idContext = serviceScope.ServiceProvider.GetRequiredService<AspNetIdentityDbContext>();
if (!idContext.Users.Any())
{
var userManager = serviceScope.ServiceProvider.GetService<UserManager<ApplicationUser>>();
foreach (var user in Resources.GetApplicationUsers())
{
var result1 = userManager.CreateAsync(user , "Test123!").Result;
}
}
idContext.Database.Migrate();
- Run IdentityServer project
-
Check if database tables and fields are auto generated

-
Replace in memory resources
-
Init DbContext ConnectionString
//Startup.cs
public void ConfigureServices(IServiceCollection services)
{
......
var connectionString = Configuration.GetConnectionString("IdentityServer4Connection");
// Init DbContext ConnectionString
var aspnetIdentityAssembly = typeof(AspNetIdentityDbContext).GetTypeInfo().Assembly.GetName().Name;
services.AddDbContext<AspNetIdentityDbContext>(options =>
options.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(aspnetIdentityAssembly))
);
......
}
-
Authenticate via AspNet Identity
//Startup.cs
public void ConfigureServices(IServiceCollection services)
{
......
// Apply AspNetIdentity as default token provider
// UserManager can be used to manage users
services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
// Password settings
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireLowercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireUppercase = true;
// User settings
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AspNetIdentityDbContext>()
.AddDefaultTokenProviders();
}
-
Load resources from SQL Server
//Startup.cs
public void ConfigureServices(IServiceCollection services)
{
......
// Load resources from DB
var identityServerAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
var identityServer = services
.AddIdentityServer()
.AddAspNetIdentity<ApplicationUser>()
// this adds the config data from DB (clients, resources, CORS)
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = builder =>
builder.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly(identityServerAssembly));
})
// this adds the operational data from DB (codes, tokens, consents)
.AddOperationalStore(options =>
{
options.ConfigureDbContext = builder =>
builder.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly(identityServerAssembly));
// this enables automatic token cleanup. this is optional.
options.EnableTokenCleanup = true;
// options.TokenCleanupInterval = 15; // interval in seconds. 15 seconds useful for debugging
});
......
}
NOTE: There are 2 migrations from 2 projects - IdentityServer and SQLServer, in order to set the connection string, the assembly name needs to be specified as the second parameter to identify where the context comes from. For example:
services.AddDbContext<AspNetIdentityDbContext>(options =>
options.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(aspnetIdentityAssembly))
);
-
Test
Use either bob / Test123$ or alice / Test123$ to login, new password “Test123$” has been applied because of the password settings in #4
28 Feb 2019
Register Client in IdentityServer
"Clients": [
{
"ClientId": "portal-client",
"ClientName": "MusicStore Portal Client",
"ClientUri": "http://localhost:30002",
"RequireConsent": false,
"AllowedGrantTypes": "implicit",
"AllowAccessTokensViaBrowser": true,
"RedirectUris": "http://localhost:30002",
"PostLogoutRedirectUris": "http://localhost:30002/loggedout",
"AllowedCorsOrigins": "http://localhost:30002",
"AllowedScopes": "openid;profile;email;musicstore-api",
"AlwaysIncludeUserClaimsInIdToken": true,
"IdentityTokenLifetime": 3600,
"AccessTokenLifetime": 3600
}
]
-
Create a new Client called “portal-client”
- ClientUri => WebUI Uri
- RequireConsent => false means disable consent dialog after login
- AllowedGrantTypes => Grant type of SPA client needs to be implicit
- RedirectUris => Redirect Uri after login, id_token will be appended
- PostLogoutRedirectUris => Redirect Uri after logging out
- AllowedCorsOrigins => WebUI Uri
- AllowedScopes => API scopes can be consumed for this client
-
Register musicstore-api
// Configuration/Resources.cs
public static IEnumerable<ApiResource> GetApiResources()
{
return new[]
{
......
new ApiResource("musicstore-api", "MusicStore API")
};
}
Authenticate WebAPI from IdentityServer
- Add Authentication from IdentityServer
-
Add CORS to allow origins from WebUI
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
// Authenticated by IdentityServer
services
.AddAuthentication("Bearer")
.AddIdentityServerAuthentication(options =>
{
options.Authority = Configuration.GetValue<string>("Authority");
options.RequireHttpsMetadata = Configuration.GetValue<bool>("RequireHttpsMetadata");
options.ApiName = Configuration.GetValue<string>("ApiName");
});
// CORS Policy
string strOrigionList = Configuration.GetValue<string>("AllowedOrigions");
if (!string.IsNullOrEmpty(strOrigionList))
{
services.AddCors(options =>
{
// this defines a CORS policy called "default"
options.AddPolicy("default", policy =>
{
policy.WithOrigins(strOrigionList.Split(new string[] { ";" }, StringSplitOptions.RemoveEmptyEntries))
.AllowAnyHeader()
.AllowAnyMethod();
});
});
}
}
// Startup.cs
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseCors("default");
app.UseAuthentication();
app.UseMvc();
}
// appsettings.Development.json
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
},
// Authentication - IdentityServer
"Authority": "http://localhost:30000",
"RequireHttpsMetadata": false,
"ApiName": "musicstore-api",
// CORS - Allow WebUI
"AllowedOrigions": "http://localhost:30002"
}
-
Disable anonymous access to values controller
// ValuesController.cs
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
......
}
- Test from Postman
- Select “TYPE” as “OAuth 2.0” in Authorization tab
- Click “Get New Access Token”
-
Fill in the Client info and click “Request Token”

- Username / Password - bob / bob or alice / alice
-
After getting the JWT token, attach it to Header and fire “GET” request to http://localhost:30001/api/values will return [“value1”, “value2”]

Consume WebAPI from WebUI
-
Install OIDC to bridge WebUI and IdentityServer
npm install angular-oauth2-oidc@^3 --save
NOTE: As our WebUI utilized Angular 5.2, it could not be compatible with the latest oidc (version 4), otherwise, it will throw this error:
ERROR TypeError: Object(…) is not a function
-
Apply OIDC
- Update AppModule to include OAuthModule
@NgModule({
declarations: [
......
],
imports: [
......
OAuthModule.forRoot()
],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: TokenInterceptor,
multi: true,
}
]
bootstrap: [AppComponent]
})
export class AppModule { }
-
Add AuthConfig
export const authConfig: AuthConfig = {
// Url of the Identity Provider
issuer: environment.identityServerUrl,
// URL of the SPA to redirect the user to after login
redirectUri: window.location.origin,
// The SPA's id. The SPA is registerd with this id at the auth-server
clientId: 'portal-client',
// set the scope for the permissions the client should request
// The first three are defined by OIDC. The 4th is a usecase-specific one
scope: 'openid profile email ' + environment.apiScope,
postLogoutRedirectUri: environment.identityServerUrl + '/account/login'
}
NOTE: AuthConfig needs to be consistent with the client registered in IdentityServer e.g. clientId, scope.
- Environment configuration
// environment.ts
export const environment = {
production: false,
apiScope: "musicstore-api",
apiUrl: 'http://localhost:30001/api/',
identityServerUrl: 'http://localhost:30000'
};
// environment.prod.ts
export const environment = {
production: true,
apiScope: "musicstore-api",
apiUrl: 'http://api.jaycoder.net/api/',
identityServerUrl: 'https://id.jaycoder.net'
};
NOTE: environment.ts has the configuration for development environment, while environment.prod.ts is for production, environment.ts will be replaced by environment.prod.ts if env argument is prod at build stage.
-
Initialize OAuthService in AppComponent
Token will be validated in ngOnInit method, if it’s expired, initImplicitFlow will redirect to IdentityServer login to reissue a new one.
export class AppComponent {
title = 'app';
constructor(
@Inject(PLATFORM_ID) private platformId: Object,
private _oauthService: OAuthService
) {
if (isPlatformBrowser(this.platformId)) {
this._oauthService.configure(authConfig);
this._oauthService.tokenValidationHandler = new JwksValidationHandler();
if (!environment.production) {
this._oauthService.events.subscribe(e => {
console.log("oauth/oidc event", e);
});
}
}
}
/**
* On init
*/
ngOnInit(): void {
if (isPlatformBrowser(this.platformId)) {
this._oauthService.loadDiscoveryDocumentAndTryLogin().then(_ => {
if (!this._oauthService.hasValidIdToken() || !this._oauthService.hasValidAccessToken()) {
this._oauthService.initImplicitFlow();
} else {
}
});
}
}
}
- Add a new component “Sample” to call /api/values
export class SampleComponent {
public values: string[];
constructor(http: HttpClient) {
var valuesApiUrl = environment.apiUrl + 'values';
http.get<string[]>(valuesApiUrl).subscribe(result => {
this.values = result;
}, error => console.error(error));
}
}
- Add TokenInterceptor to append Bearer Authorization in header
export class TokenInterceptor implements HttpInterceptor {
private _authService: OAuthService;
// Would like to inject authService directly but it causes a cyclic dependency error
// see https://github.com/angular/angular/issues/18224
constructor(private _injector: Injector) { }
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (this.getAuthService().hasValidAccessToken()) {
request = request.clone({
// Remove cache in IE
headers: request.headers.set('Cache-Control', 'no-cache')
.set('Pragma', 'no-cache')
.set('Expires', 'Sat, 01 Jan 1900 00:00:00 GMT')
.set('Authorization', 'Bearer ' + this.getAuthService().getAccessToken())
});
}
return next.handle(request);
}
getAuthService(): OAuthService {
if (typeof this._authService === 'undefined') {
this._authService = this._injector.get(OAuthService);
}
return this._authService;
}
}
NOTE: TokenInterceptor needs to be registered as a provider in AppModule.
Test

26 Feb 2019
Let’s start building a platform where musicians can sell their albums online.
Code has been uploaded to github https://github.com/jay-coder/MusicStore
What technologies are we going to use:
- WebUI - Angular 5.2 SPA application hosted on .NET Core 2.1
- WebAPI - .NET Core 2.1 API
- IdentityServer4 - .NET Core 2.1 Web Application
Identity Server 4 is an OpenID Connect and OAuth 2.0 framework which can be used to manage tokens that being used between WebUI and WebAPI.
Here is the diagram of the solution:

- WebUI will ask user to login
- After login successfully, IdentityServer will issue JWT token back to WebUI
- WebUI will consume API resources with token attached in header
- WebAPI will talk to IdentityServer to validate token
- If token is valid, WebAPI will return JSON data back to WebUI
Setup WebUI project

In Properties > launchSettings.json, update the applicationUrl port to 30002
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:30002",
"sslPort": 0
}
}
Setup WebAPI project

In Properties > launchSettings.json, update the applicationUrl port to 30001
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:30001",
"sslPort": 0
}
},
Setup IdentityServer project
Clone IdentityServer from its official github:
git clone https://github.com/IdentityServer/IdentityServer4.Quickstart.UI
In Properties > launchSettings.json, update the applicationUrl port to 30000
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:30000",
"sslPort": 0
}
},
Why set port over 30000
When we deploy web applications to local Kubernetes in the future, we can only access pod by port-forward to NodePort, which only allows port 30000 to 32767. To make local dev URL consistent with Kubernetes, I would set URL port between 30000 and 32767.
kubectl port-forward musicstore-project-webui-65c8bcb489-lhjcr 30002:80
Run these projects
-
WebUI - should see Hello, world!

-
WebAPI - should see the default values API

-
IdentityServer - should see welcome page

Click “here” link to login to IdentityServer, username and password can be:
NOTE: I added test users instead of retrieving users from database as of now, I will add database support in MusicStore - Part3 - EF to SQL Server.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryIdentityResources(Resources.GetIdentityResources())
.AddInMemoryApiResources(Resources.GetApiResources())
.AddInMemoryClients(Clients.Get())
.AddTestUsers(TestUsers.Users);
}
-
Enable Swagger to WebAPI
Swagger is a great tool that can help developers test their Restful API quickly, for example, we can easily send Get, Post, Put, Delete request to Restful API like Postman.
Swagger UI once configured correctly
