As part of my Building Solid Systems course, I'll be talking about authentication in distributed systems. I wanted a practical demonstration that people could play with, so I added token bearer authentication to a Freya API.
Here's how.
System design
Over the years, I have become a big believer in using standards where standards exist (unless they're actively terrible); as such, for authentication we'll be assuming that our system includes an OAuth2 compliant authorization server. Depending on our needs, this might be an external service or a self hosted solution such as IdentityServer.
We're going to set up an API which will use "token bearer" authentication. This means that the client is responsible for obtaining a valid token from our authorization server which includes a claim for access to the resource our API represents. How the client gets the token, we don't really care: there are several ways of obtaining a grant from an OAuth2 server and I won't be going too far down that rabbit hole here (although check the end of the article for an example).
The code
Let's start coding, and add authentication to the "hello" endpoint of the Freya template project. Set up a new file for our Auth
module, and open up everything we need.
1 2 3 4 5 6 7 8 9 10 11 |
|
Most of these should make sense; the additions are IdentityModel
and a Logging
module. IdentityModel is a NuGet package supplied by the IdentityServer project which implements the basics of the OAuth2 specification from a consumers point of view, and gives a nice client API over the top of the various endpoints an OAuth2 compliant server should implement.
The Logging
module is the one from my previous blog post; any logging here is optional, but in practice is really very helpful in an actual production distributed system.
The first thing we're going to do is create a DiscoveryClient
. OAuth2 servers provide a discovery document which specifies things like it's public key and the locations of the other endpoints. In theory, this information can change over time - in this case I'm going to statically grab it on service start up.
1 2 3 4 5 6 7 8 9 10 11 |
|
Your configuration here will vary considerably: I'm running within a kubernetes cluster using an internal DNS record, so I'm overriding the normal safety checks. If you are deploying a service which will be calling the identity server on an external network, you obviously shouldn't do this…
The freyaMachine
has separate decision points for whether the request is authorized
and whether it's allowed
. Authorized is the simplest: a request is authorized if it has an authorization header. Let's build a method which checks that for us:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Most of the code here is actually logging - but you won't regret it when your customers ask you why they can't authenticate against your API.
Now we're onto the more interesting case; the caller has made an attempt to access a secured resource, and they've supplied some authentication to try and do so.
Let's check first if they've supplied a "Bearer" token; this is the only authentication style we're allowing at the moment.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
Now we can check the token to see if it is valid. If the token is a JWT token we could choose to check it locally; we have the public key of the issuer available. Here I've decided to go the route of checking each token with the issuer, as that means that we pick up things like token cancellation. Your strategy here will depend a lot on your use case, and IdentityModel
also allows for caching to allow a good compromise.
Checking the token can be done via an asynchronous call with the IntrospectionClient
. As I'm using Freya compiled against Hopac
I'm wrapping it in a job
- you could equally wrap it in an async
block if you've using Async Freya.
1 2 3 4 5 6 7 8 9 |
|
And now the last step is to build a allowed
decision point. Our decision point takes three parameters: the name of this API resource, as known to the identity server, the shared secret between resource and identity server, and the scope this particular resource within the API requires. Normally this will be something like read
or write
. An entire API will normally share a single name and secret, while each endpoint may require a different scope.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
Apart from actually checking whether access is allowed, the other important thing we do here is add the calling clientId to the OWIN state. This means that we can make use of the clientId in any further pipeline steps (and in our logging).
So: we now have an authMachine
which will check if you're allowed to do something… but doesn't actually do anything itself.
Time to switch back to Api.fs
from the template project (making sure you've added in both the Logging
and Auth
modules to the project).
Amend your helloMachine
as follows:
1 2 3 4 5 |
|
and finally make sure that you remember to inject your logger (see the previous blog post):
1 2 3 4 |
|
Now we should be able to spin everything up.
Trying it all out
We'll be using Client Credential authentication for this example; this is a grant type used when a "client" is requesting access to a "resource" when no "user" is present. It's a standard grant type covered by the OAuth specification, and we're going to assume that we have an OAuth2 compliant authority available to issue allow introspection of tokens.
This type of grant is generally used for service to service communication - there's no user interaction at all, just an agreed pre-shared "client secret" (an API key).
First we need to get a token from our identity server using our clientId and clientSecret (this client must be configured in the identity server).
If you're using IdentityServer4 like I am, your request will look like this (curl format):
1 2 3 4 5 |
|
You'll get back a response including a token:
1 2 3 4 5 |
|
Now when you call the secured API, you need to add the token to your headers:
1 2 3 |
|
If you don't supply the authorization
header at all, you correctly get a 401
response; if the token is invalid or you (for example) try and use Basic
authentication, you receive a 403
. Both return with an empty body; if you wanted to make the pages pretty you would need to add handleUnauthorized
and handleForbidden
to your freyaMachine
. Here, for an API it's probably as meaningful to just leave the response empty. There isn't any further information to supply, after all.
And there it is: token bearer authentication set up for Freya.
Interested in how you can set up the whole environment in Kubernetes including IdentityServer, logging, metrics and all the other mod cons you could desire? There's still time to sign up for Building Solid Systems in F# at the end of the month!