Setup and deploy IdentityServer4
When creating miscellaneous app’s, I often want to authenticate users. For this reason, I though I’ll create my own Token service.
I’ll be using IdentityServer 4. IdentityServer is “middleware that adds the spec compliant OpenID Connect and OAuth 2.0 endpoints to an arbitrary ASP.NET Core application“. You can find the documentation for IdentityServer here.
I don’t want to have a local user store, so I will be using Facebook to get the user information. I.e. I will only be using an external IDP. I will therefore remove all the Login UI from the code.
This is an overview of what I will be doing:
- Create IdentityServer as an ASP.NET Core 3.1 web application
- Setup logging to use Azure Application Insights
- Deploy to Azure
- Setup a Facebook app and use the test application to check that it works
- Create a test application
Create the solution
The IdentityServer documentation has a a good explanation on how to get started here: QuickStarts in the IdentityServer documentation. They also made some project templates that I will be using to make my project.
First I made sure I had the latest templates. From a PowerShell (or Cmd) prompt, I ran:
dotnet new -i IdentityServer4.Templates
Then I created a new solution using Visual Studio (I called mine AHIDS), and added the IdentityServer code using the following this command: dotnet new is4empty -n AHIDS.IdentityServer
. It created a project file that I added to my solution. My solution now looks like this:
The AHIDS.IdentityServer project targets the .NET Core 3.1 framework.
Use SSL
I like to use HTTPS from the start, so I changed the project properties accordingly. In the launchSettings,json file, change iisSettings: (you can also right-click project -> Properties -> Debug -> Enable SSL)
"iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { "applicationUrl": "https://localhost:44344", "sslPort": 44344 } },
Add standard identity resources
Open Config.cs and add the following:
public static IEnumerable<IdentityResource> Ids => new IdentityResource[] { new IdentityResources.OpenId(), new IdentityResources.Profile(), new IdentityResources.Email(), new IdentityResources.Address(), new IdentityResources.Phone() };
Create a test client and scope
Just to check that everything is good so far, I created a ClientCredentials client and an api resource in the Config.cs file:
public static IEnumerable<ApiResource> Apis => new ApiResource[] { new ApiResource("test_api", "API for testing") }; public static IEnumerable<Client> Clients => new List<Client> { new Client { ClientId = "test_client", AllowedGrantTypes = GrantTypes.ClientCredentials, ClientSecrets = { new Secret("secret_for_testing_only".Sha256()) }, AllowedScopes = { "test_api" } } };
I used this client in Postman to test that I could get a token:
Adding UI
IdentityServer provide a basic UI for login, logout, consent and error. In PowerShell/Cmd, go to the “AHIDS.IdentityServer” folder and run: dotnet new is4ui
Rename the “Quickstart” folder to “Controllers”.
Uncomment MVC stuff in Startup.cs:
public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); var builder = services.AddIdentityServer() .AddInMemoryIdentityResources(Config.Ids) .AddInMemoryApiResources(Config.Apis) .AddInMemoryClients(Config.Clients); // not recommended for production - you need to store your key material somewhere secure builder.AddDeveloperSigningCredential(); } public void Configure(IApplicationBuilder app) { if (Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseStaticFiles(); app.UseRouting(); app.UseIdentityServer(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapDefaultControllerRoute(); }); }
Rename the “Quickstart” folder to “Controllers”.
In ExternalController.cs remove ProcessWindowsLoginAsync and code related to windows authentication (in Challenge). We’ll not be using windows authentication.
In AccountOptions.cs, set AllowLocalLogin to false.
In AccountsController.cs, just call RedirectToAction (remove check on IsExternalLoginOnly):
public async Task<IActionResult> Login(string returnUrl) { // build a model so we know what to show on the login page var vm = await BuildLoginViewModelAsync(returnUrl); if (vm.IsExternalLoginOnly) { // we only have one option for logging in and it's an external provider return RedirectToAction("Challenge", "External", new { provider = vm.ExternalLoginScheme, returnUrl }); } return View(vm); }
Application Insights and logging
Using the IdentityServer templates, you get SeriLog already installed. I’m going to change this setup a little. I will add the Application Insights sink and get Live metrics and the Log Stream setup. I will also use the appSettings file for the logging configuration. This is a little tricky. The logger is used before the service starts up (before the IConfigurationRoot object is created), so it basically means that I have to read the appSettings files twice. Once to get the logger setup, and then again as part of Host.CreateDefaultBuilder.
I will create a separate class for this.
Add a new top level folder in the AHIDS.IdentityServer project called Configuration. Then add a new class in this folder:
public static class ConfigurationHelper { public static IConfigurationRoot GetConfiguration(string secretsKey) { var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); var builder = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()); builder.AddJsonFile("appSettings.json", optional: false, reloadOnChange: true); if (!string.IsNullOrWhiteSpace(secretsKey)) builder.AddUserSecrets(secretsKey); builder.AddEnvironmentVariables(); return builder.Build(); } }
In the same folder, create a AppSettingPathConstants:
public class AppSettingPathConstants { public const string AppInsightsKey = "appInsights:instrumentationKey"; }
Change the appSettings.json file to this (use “User Secrets” to set the actual value for the appInsights instrumentation key):
{ "appInsights": { "instrumentationKey": "" }, "Serilog": { "MinimumLevel": { "Default": "Debug", "Override": { "System": "Warning", "Microsoft": "Warning", "Microsoft.AspNetCore.Authentication": "Information" } } } }
Add the following NuGet packages:
- Microsoft.ApplicationInsights.AspNetCore
- Serilog.Enrichers.Environment
- Serilog.Sinks.ApplicationInsights
Change the Program.cs file so it looks like this:
public class Program { public static int Main(string[] args) { SetupLogger(); try { Log.Information("Starting host..."); CreateHostBuilder(args).Build().Run(); return 0; } catch (Exception ex) { Log.Fatal(ex, "Host terminated unexpectedly."); return 1; } finally { Log.CloseAndFlush(); } } public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .UseSerilog() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); }); private static void SetupLogger() { var config = ConfigurationHelper .GetConfiguration("4211c1ee-d772-406c-a4bd-d2fec531dd93"); var loggerConfiguration = new LoggerConfiguration() .ReadFrom.Configuration(config); ConfigureAppInsightsSink(config, loggerConfiguration); ConfigureLogStreamSink(config, loggerConfiguration); Log.Logger = loggerConfiguration .Enrich.WithMachineName() .Enrich.FromLogContext() .CreateLogger(); } private static void ConfigureAppInsightsSink( IConfigurationRoot config, LoggerConfiguration loggerConfiguration) { var aiKey = config[AppSettingPathConstants.AppInsightsKey]; if (!string.IsNullOrWhiteSpace(aiKey)) { var telemetryConfiguration = TelemetryConfiguration.CreateDefault(); telemetryConfiguration.InstrumentationKey = aiKey; loggerConfiguration.WriteTo.ApplicationInsights( telemetryConfiguration, TelemetryConverter.Traces); } } private static void ConfigureLogStreamSink( IConfigurationRoot config, LoggerConfiguration loggerConfiguration) { loggerConfiguration.WriteTo.File( @"D:\home\LogFiles\Application\ahids.log", fileSizeLimitBytes: 5_000_000, rollOnFileSizeLimit: true, shared: true, flushToDiskInterval: TimeSpan.FromSeconds(1), outputTemplate: "[{Timestamp:dd HH:mm:ss} {Level:u3}] {SourceContext}" + "{NewLine}{Message:lj}" + "{NewLine}{Exception}{NewLine}"); } }
The parameter to ConfigurationHelper.GetConfiguration is the user secrets id you find in the project file. Change this to your own.
My Startup.cs file looks like this:
public class Startup { private readonly IConfiguration _config; public IWebHostEnvironment Environment { get; } public Startup( IWebHostEnvironment environment, IConfiguration config) { _config = config; Environment = environment; } public void ConfigureServices(IServiceCollection services) { var instrumentationKey = _config[AppSettingPathConstants.AppInsightsKey]; services.AddApplicationInsightsTelemetry(instrumentationKey); services.AddSingleton(Log.Logger); services.AddControllersWithViews(); var builder = services.AddIdentityServer() .AddInMemoryIdentityResources(Config.Ids) .AddInMemoryApiResources(Config.Apis) .AddInMemoryClients(Config.Clients); // not recommended for production - you need to store your key material somewhere secure builder.AddDeveloperSigningCredential(); } public void Configure(IApplicationBuilder app) { if (Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseStaticFiles(); app.UseSerilogRequestLogging(); app.UseRouting(); app.UseIdentityServer(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapDefaultControllerRoute(); }); } }
Build the code, and that it still works by getting a token using Postman (like we did earlier).
Deploying to Azure
We can now deploy to Azure and check that Azure Log Stream works;
- Right-click AHIDS.IdentityServer project and click “Publish…”
- Click “Create Profile”. I’ll create a new app service, so leave radio button at “Create New”:
My App service is now deployed, and I can test that Azure Log Stream works. Go to the “App Service logs” blade in the Azure Portal. Turn on Applicatino Logging and set Level to Verbose:
Click Save and go to the Log Stream blade.
Open another browser window and go to the discovery endpoint for your IdentityServer: https://ahids.azurewebsites.net/.well-known/openid-configuration.
Going back to the Log Stream, you should now see something like this:
You can also try Postman and see that you can get a token from the deployed app (same as before, just change the Access Token Url)
Setup external identity provider (Facebook)
We need to setup an app in Facebook. You can find a good description on how to do that here: Create an app in Facebook, so I will not go through that.
After you set up your Facebook app, you should have an App ID and an App secret. We’ll put these in the appSettings.json file:
"externalIdp": { "appId": "", "appSecret": "" },
Use User Secrets for the actual values. You don’t want them to accidentally be checked into source control!
In Visual Studio, add the following NuGet to our project: Microsoft.AspNetCore.Authentication.Facebook.
The we add Facebook authentication in ConfigureServices in Startup.cs
public void ConfigureServices(IServiceCollection services) { var instrumentationKey = _config[AppSettingPathConstants.AppInsightsKey]; services.AddApplicationInsightsTelemetry(instrumentationKey); services.AddSingleton(Log.Logger); services.AddControllersWithViews(); var builder = services.AddIdentityServer() .AddInMemoryIdentityResources(Config.Ids) .AddInMemoryApiResources(Config.Apis) .AddInMemoryClients(Config.Clients); services.AddAuthentication() .AddFacebook(FacebookDefaults.AuthenticationScheme, options => { options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; options.ClientId = _config[AppSettingPathConstants.FacebookAppId]; options.ClientSecret = _config[AppSettingPathConstants.FacebookAppSecret]; }); // not recommended for production - you need to store your key material somewhere secure builder.AddDeveloperSigningCredential(); }
Create a test program
Add an new ASP.NET Core Web Application to your solution. I’ll be using Web Application (Model-View-Controller).
Add NuGet package: Microsoft.AspNetCore.Authentication.OpenIdConnect
Startup.cs
This is what the Startup.cs looks like. I’ve marked the important lines. For an explanation of what’s going on, see the IdentityServer documentation.
public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; JwtSecurityTokenHandler.DefaultMapInboundClaims = false; } public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { options.Authority = "https://localhost:44344"; options.ClientId = "fb_client"; options.ClientSecret = "secret_for_testing_only"; options.ResponseType = "code"; options.SaveTokens = true; }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); }); } }
Use Authorize attribute
Change the Privacy view to this:
@using Microsoft.AspNetCore.Authentication <h2>Claims</h2> <dl> @foreach (var claim in User.Claims) { <dt>@claim.Type</dt> <dd>@claim.Value</dd> } </dl> <h2>Properties</h2> <dl> @foreach (var prop in (await Context.AuthenticateAsync()).Properties.Items) { <dt>@prop.Key</dt> <dd>@prop.Value</dd> } </dl>
Then add the [Authorize] attribute to the Privacy action in the HomeController.
Add Logout
In the _Layout.cshtml file, add the following (marked) code:
<li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> </li> @if (User.Identity.IsAuthenticated) { <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Logout">Logout</a> </li> }
Then in the HomeController, add the Logout action:
public IActionResult Logout() { return SignOut( CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme); }
Add MVC client
Next we need to add a new client in the IdentityServer configuration. Add the following in Config.cs:
new Client { ClientId = "fb_client", ClientSecrets = { new Secret("secret_for_testing_only".Sha256()) }, AllowedGrantTypes = GrantTypes.Code, RequireConsent = false, RequirePkce = true, RedirectUris = { "https://localhost:44388/signin-oidc" }, PostLogoutRedirectUris = { "https://localhost:44388/signout-callback-oidc" }, AllowedScopes = new List<string> { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile } }
Start IdentityServer, then the test app
Go to the Privacy page. You should see something like this:
A long post, but I hope it is helpful.
0 Comments on “Setup and deploy IdentityServer4”