Well that’s a bit of a mouthful of a title – but it does describe what I was trying to do, and I didn’t find it easy. It’s not that complicated when you know how, but there are an awful lot of options and it wasn’t clear (to me at least) which ones to pick, so I hope this saves others some time.
Aim
I wanted a cloudscribe site running allowing users to be able to logon as usual using cookies, but I also wanted to have an API that could be secured for clients external to the site without having to use the cookie login and I wanted to use Identity Server 4 with a SQL data store.
Starting setup
Server
I used Joe Audette's excellent cloudscribe Visual Studio template https://www.cloudscribe.com/blog/2017/09/11/announcing-cloudscribe-project-templates and selected use MSSQL Server and the Include Identity Server integration option. Also selecting the 2 options in the “Expert Zone” gave me an example API to test with.
This gave me the basic website and was the one I wanted to add the secured API into. The VS project has a weather forecast API as an example.
Client
I then setup a separate MVC project using a basic template to act as the client application. This was all done using Visual Studio 2017 and .Net Core 2.
Server application
By default, the weather forecast API is accessible to all users. Try: http://localhost:35668/api/SampleData/WeatherForecasts
You can secure this by adding the [Authorize] statement to the API on the SampleDataController.cs page e.g.
[Authorize]
[HttpGet("[action]")]
public IEnumerable<WeatherForecast> WeatherForecasts()
{
var rng = new Random();
but you will find this presents the standard cloudscribe logon screen to access it – not exactly what’s wanted for an API.
In order to solve this we need to use JWT authorisation alongside the standard cookie authentication, but tell the API to only secure using the JWT authorisation . This is done by filtering the authentication scheme used by the Authorize statement as below (you will probably have to add the following assemblies to your code)
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
and then add the filter to the authorize statement.
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[HttpGet("[action]")]
public IEnumerable<WeatherForecast> WeatherForecasts()
{
var rng = new Random();
We have now told the API to authenticate using JWTBearer but we haven’t yet added JWT authentication to our applications pipeline. So in the startup.cs page we need to add in some assemblies:
using Microsoft.AspNetCore.Authentication.JwtBearer;
and then add the JWT service into the ConfigureServices method. (I added the statement below just above services.AddCors(options =>)
services.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = "http://localhost:35668"; //No trailing /
options.Audience = "api2"; //Name of api
options.RequireHttpsMetadata = false;
});
Where:
.Authority is the address of the website with the api (note no trailing slash)
.Audience is the name you have given to the api in the Identity server 4 security setup (see more details below)
And then we need to tell our pipeline to use Authentication. So add the app.UseAuthentication() into the end of the ConfigureServices method just above the UseMVC call
app.UseAuthentication();
UseMvc(app, multiTenantOptions.Mode == cloudscribe.Core.Models.MultiTenantMode.FolderName);
Now if you try and access the api - http://localhost:35668/api/SampleData/WeatherForecasts - you should get an unauthorised message - even if you are logged onto the cloudscribe site using cookie authentication.
Identity Server 4 configuration (through cloudscribe)
Identity server has many options – which can be bewildering to start with. Full documentation is here: http://docs.identityserver.io/en/release/index.html
For our purposes here – I’m outlining the bare minimum that we need to setup security for our API, either using:
· A client credential using a secret
· A username and password
API resources
Under the admin menu in cloudscribe select security settings / API resources and create a new API record giving it a name (e.g. api2) making sure it matches the name you entered as the .Audience in the startup.cs .
Then we need to add a single scope record – called “allowapi2” in this example.
Client resources
Under the admin menu in cloudscribe select security settings / API Clients and create a new client (I’ve called it client2 – remember this name for when we make the call from the client application). Edit the new client record and add:
· Allowed Scope record – e.g. allowapi2 – this must match the scope we entered for the api and is used to specify which apis this client can access
· Client Secrets – the value is the SHA56 value of the secret we wish to use (in this example secret2) – at the moment the cloudscribe interface doesn’t do this conversion for us so we have to do it manually somewhere (e.g. I used string s = "secret2".ToSha256();)
I added the secret using the web page and then pasted the converted secret direct into the relevant field in the record in the csids_ClientSecrets table in the database - but I think it would work equally well just pasting the converted value into the web page.
.ToSha256() is a string extension method in the IdentityModel assembly - this seems to do more than simply convert to sha256 - see https://github.com/IdentityModel/IdentityModel/blob/master/source/IdentityModel.Net45/Extensions/HashStringExtensions.cs.
It’s important that we set the secret type as well – in our example here it must be “SharedSecret”
Joe Audette has updated his nuget packages so saving a client secret now gives you a range of options for the secret type - in our example we need to pick "SharedSecret" and select to encrypt using Sha256 (see Joe's post in comments below for other options) which should make things easier.
· Allowed Grant types – we are entering “password” and “client_credentials”. These determine how we can authenticate from the client app as we see below in the next section. Password means that authentication can use a username / pwd combination (i.e. a cloudscribe login). Client_credentials means we can login using a client secret and don’t have to be a known user on the site.
Client application
To connect securely to the API using a client connection with a secret use:
var tokenClient = new TokenClient(disco.TokenEndpoint, "client2", "secret2");
To connect using a username and password use:
var tokenResponsePassword = await tokenClient.RequestResourceOwnerPasswordAsync("admin", "admin", "allowapi2");
Note that the user name is the user name not the email which can be used to login interactively.
The whole method in the controller looked something like this – the rest of the code is deserializing the JSON return from the API and putting it into an object that can be displayed on a view page
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using ESDM.Models;
using System.Net.Http;
using Newtonsoft.Json;
using IdentityModel.Client;
using IdentityServerClient.Models;
using IdentityModel;
and then
//Hosted web API REST Service base url
string Baseurl = "http://localhost:35668";
public async Task<ActionResult> Index()
{
List<WeatherForecast> ans = new List<WeatherForecast>();
using (var client = new HttpClient())
{
// discover endpoints from metadata
var disco = await DiscoveryClient.GetAsync(Baseurl);
var tokenClient = new TokenClient(disco.TokenEndpoint, "client2", "secret2");
var tokenResponse = await tokenClient.RequestClientCredentialsAsync("allowapi2");
//Example getting alternative token if you want to use username / pwd
var tokenResponsePassword = await tokenClient.RequestResourceOwnerPasswordAsync("admin", "admin", "allowapi2");
// call api - change for tokenResponsePassword if you want to use username / pwd
client.SetBearerToken(tokenResponse.AccessToken);
var response = await client.GetAsync(Baseurl + "/api/SampleData/WeatherForecasts");
if (response.IsSuccessStatusCode)
{
var content = response.Content.ReadAsStringAsync().Result;
ans = JsonConvert.DeserializeObject<List<WeatherForecast>>(content);
}
return View(ans);
}
The model for the forecast data was:
namespace ESDM.Models
{
public partial class WeatherForecast
{
public string DateFormatted { get; set; }
public int TemperatureC { get; set; }
public string Summary { get; set; }
public int TemperatureF
{
get
{
return 32 + (int)(TemperatureC / 0.5556);
}
}
}
}
And my view contained
@model IEnumerable<ESDM.Models.WeatherForecast>
<div>
<ul>
@foreach (var forecast in Model)
{
<li>@forecast.Summary</li>
}
</ul>
</div>