JWT Authentication and Authorization for your Giraffe API Server
2022-01-16
What We'll Cover
This article is about authenticating and authorizing requests in a Giraffe server using JWT, or JSON Web Tokens.
It does not cover any sort of login page, user registration flow, or anything to do with the frontend. Also, it does not cover storing user account information in a database. Those topic will be covered in future articles. The basics covered here will serve as a foundation, however.
Despite being very narrow in scope, this article will cover two very real and useful scenarios:
- Backend API servers meant to be consumed from other servers and automated scripts/programs (i.e. anything besides the browser).
- Backend API servers for Single Page Applications. Generally, SPA's retrieve a JWT via some sort of OAuth2 based flow involving a service like Azure Active Directory, Auth0, Google/Facebook/Twitter, etc.
The code for these two cases will be largely the same. For #2, you'd have to adjust the JWT middleware parameters for the authorizative server you chose, and also worry about CORS. We'll be concentrating on #1 in this article.
Please be aware, I assume you have basic F# and Giraffe knowledge covered in my other articles Understanding the ASP.NET Core Boilerplate in Giraffe and Anatomy of a Giraffe HttpHandler.
See this repo for working source code.
What is JWT?
If you already know what JSON Web Tokens are, skip to the next section.
JWT's are strings that look like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdXN0b21DbGFpbSI6InZhbHVlIiwiaHR
0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmF
tZSI6IkphbmUgQWRtaW50b24iLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzI
wMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJBZG1pbiIsImh0dHA6Ly9zY2hlbWFzLnh
tbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL2RhdGVvZmJpcnRoIjoiMjA
wMy8xMC8zMSIsImV4cCI6MTcwNTQ2ODUwOCwiaXNzIjoiaHR0cHM6Ly9jZXJ0aWZpY2F0ZWl
zc3Vlci5leGFtcGxlLmNvbS8iLCJhdWQiOiJodHRwczovL2JhY2tlbmRzZXJ2ZXIuZXhhbXB
sZS5jb20ifQ.kDUqyb_GtBAKmgSAtMIoRClN7LYSgOx_Ww8_KXKZykE
And they are usually passed through an HTTP request header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5...
There are three sections separated by a ".". The first is a Base64-URL encoded "header" section which describes the signature algorithm (among other things). The above section decodes to:
{"alg":"HS256","typ":"JWT"}
This header specifies that the string is indeed JWT, and the algorithm used for signing is HMAC-SHA256.
Typical values for alg
include hs256
and rs256
, but the official list can be see here.
These all have corresponding members in the SecurityAlgorithms
class in the Microsoft.IdentityModel.Tokens
namespace.
For example, SecurityAlgorithms.HmacSha256
resolves to hs256
.
Beware that this class has other non-standard, algorithms such as SecurityAlgorithms.HmacSha256Signature
, which may not work outside the .NET ecosystem.
The second is a similarly encoded "payload" section. The above decodes to:
{
"exp": 1705468508,
"iss": "https://certificateissuer.example.com/",
"aud": "https://backendserver.example.com",
"CustomClaim": "value",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "Jane Adminton",
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Admin",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dateofbirth": "2003/10/31"
}
There are several types of key-value pairs here, known as "claims" in JWT. The first three are "registered" claims, which are reserved by the spec and have official meaning.
exp
, the expiration in Unix time, should be self-explanatory. Other time-related fields you may see includenbf
, "not before", andiat
, "issued at".iss
is the issuer. It signifies the authority that created this token. It can be any string (not just a URI as shown above), and is not verified for hostname like in TLS.aud
is the audience of the JWT. It should signify the backend server who accepts and validates this JWT. As withiss
, it can be any string.- See this full list of registered claims.
The next, CustomClaim
, is an arbitrary field I added to demonstrate that you can put anything in the payload section.
The next three are what I call "namespaced" claims. They are just as arbitrary as any other non-registered field, but they are namespaced in some official-looking URI. Large companies use namespaced claims to avoid conflict with other uses of common fields like "name" and "role" above.
Incidentally, these namespaced claim names are available via the ClaimTypes
class in System.Security.Claims
.
Notably, ClaimTypes.Role
resolves to http://schemas.microsoft.com/ws/2008/06/identity/claims/role
, which is recognized by the authorization middleware as the role.
The third section is a Base64-URL encoded signature of the header and payload. The exact content here varies by the algorithm used and not of much concern to us, since the JWT middleware handles it for us.
The Client
JWT are generated and signed by some authority service, signified by the iss
claim in the payload.
This service can live on the API server, but it doesn't have to.
For example, your customer could use a separate webapp to generate a token, and use that token to query a completely separate API server.
For this article we are going to manually generate a JWT and pass it to the API server using curl
.
This gist shows how to generate a token in F#.
You can run it using FSI, or adapt it to write your own authority server.
For convenience, though, I've pre-generated a pair of tokens using the above script:
You'll see that the $ADMIN_TOKEN
defines a client who is in the Admin role, and less than 21 years old.
$USER_TOKEN
defines a client who is in the User role, but is older than 21 years old.
This will become relevant in later below.
After running the two export commands, you should be able to query the server we build below with:
curl -H "Authorization: Bearer $ADMIN_TOKEN" -k https://localhost:5001/some-path -v
curl -H "Authorization: Bearer $USER_TOKEN" -k https://localhost:5001/some-path -v
-k
makes curl
ignore self-signed certificates, and -v
makes it show the request and response headers.
Authentication
Enough talk, let's get coding.
We'll be using Microsoft.AspNetCore.Authentication.JwtBearer
, an ASP.NET Core middleware.
In order to use it, we need to open the namespace and make sure we have some constants defined:
open Microsoft.AspNetCore.Authentication.JwtBearer
let key = "pleaseReplaceWithYourSecretKeyRetrievedFromSomeSecureLocation"
let issuer = "https://certificateissuer.example.com/"
let audience = "https://backendserver.example.com"
Obviously, for a real application, you'd pull these values from an environment variables, a secret store, or anywhere else that isn't hard coded in your source. If you're using the test tokens from the previous section, make sure all three values match the sample values from the token generation script.
Next, we modify the configureServices
function to enable authentication:
// enable authentication system-wide... needed for all authentication middleware.
services.AddAuthentication(fun opt ->
// See Note 1
opt.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
// Not needed, see Note 2 below
// opt.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme
// Second, we configure our middleware
).AddJwtBearer(fun (opt : JwtBearerOptions)->
// You can set general options of JWT authentication here.
// Find more details at:
// https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.jwtbearer.jwtbeareroptions?view=aspnetcore-5.0
// Note, however, most of it is not relevant for our simple case
opt.TokenValidationParameters <- TokenValidationParameters(
// You can configure the actual authentication parameters and options.
// See more at:
// https://docs.microsoft.com/en-us/dotnet/api/microsoft.identitymodel.tokens.tokenvalidationparameters?view=azure-dotnet
// SecurityKey that is to be used for signature validation.
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
// boolean to control if the issuer will be validated during token validation.
ValidateIssuer = true,
// String that represents a valid issuer that will be used to check against the token's issuer. The default is null.
ValidIssuer = issuer,
// boolean to control if the audience will be validated during token validation.
ValidateAudience = true,
// string that represents a valid audience that will be used to check against the token's audience. The default is null.
ValidAudience = audience
)) |> ignore
Note 1:
Above, we set the default authentication scheme to JwtBearerDefaults.AuthenticationScheme
.
This is actually just the string "Bearer"
... ASP.NET Core differentiates between schemes using simple strings.
Although currently not supported directly in Giraffe (there is a GH Issue at about it),
you can add another JWT middleware layer here, under a different name.
This could be useful if you are supporting multiple services (like Azure-AD + Google).
Note 2:
You'll see most C# examples set the DefaultChallengeScheme
in AddAuthentication
.
Technically, though, we don't need this because Giraffe's challenge
function (details below) calls the ctx.ChallengeAsync
method with an explicit scheme.
There's an overload of this method without the explicit scheme, which will instead use DefaultChallengeScheme
, but Giraffe currently does not have a function that calls this overload.
Finally, we'll modify configureApp
to actually add the middleware to our pipeline:
.UseStaticFiles()
.UseAuthentication() // <-- Add here, before UseGiraffe
.UseGiraffe(webApp)
That's all the ASP.NET Core specific configuration you need.
In order to utilize this now-enabled middleware, we'll use requiresAuthentication
, which takes an error-handling HttpHandler
as it's one and only parameter.
Here, we wrap it a convenience function:
let authenticate =
requiresAuthentication (challenge JwtBearerDefaults.AuthenticationScheme >=> text "please authenticate")
When the JWT middleware detects and authenticates a JWT token, it modifies the HttpContext
variable by setting the ctx.User
property and setting ctx.User.Identity.IsAuthenticated
to true.
The requiresAuthentication
function above checks for this.
(It does not actually do authentication itself).
The error handler uses the challenge
function, which calls ctx.ChallengeAsync authScheme
.
That means it sets the status to 401 and adds the header WWW-Authenticate: Bearer
.
It tells the client what kind of authentication is expected on the page.
We compose it with the text
handler to set the contents of the response to a useful message.
Now, the authenticate
handler can be used in a pipeline to protect restricted resources:
let webApp =
GET >=>
choose [
route "/" >=> text "This page is unprotected"
route "/protected" >=> authenticate >=> text "This page is restricted!"
]
Assuming you have $ADMIN_TOKEN
and $USER_TOKEN
set to valid JWT tokens (see previous section), these will all return 200:
curl -k https://localhost:5001/ -v
curl -H "Authorization: Bearer $ADMIN_TOKEN" -k https://localhost:5001/protected -v
curl -H "Authorization: Bearer $USER_TOKEN" -k https://localhost:5001/protected -v
And this will return 401 (with the "challenge" header added to the response):
curl -k https://localhost:5001/protected -v
Role-Based Authorization
If you'd like more fine-grained authentication than simply knowing whether authentication succeeded or not, you can consider segregating clients by roles.
JWT are declared in the Payload/Claims section of JWT by using the ClaimTypes.Role
claim name, which resolves to http://schemas.microsoft.com/ws/2008/06/identity/claims/role
.
This is Microsoft's "namespaced" claim used for roles.
The requiresRole
checks if the client is in a particular role:
let requireAdminRole : HttpHandler =
requiresRole "Admin" (RequestErrors.FORBIDDEN "Permission denied. You must be an admin.")
and requiresRoleOf
checks if the client is in any of the roles given (useful here because we don't want to stop an admin from accessing a mere user's page):
let requireUserRole : HttpHandler =
requiresRoleOf ["Admin" ; "User"] (RequestErrors.FORBIDDEN "Permission denied. You must be an admin or user.")
Both function take an error handler as their last parameter. To use it:
let webApp =
GET >=> authenticate >=> choose [
route "/admin-only" >=> requireAdminRole >=> text "welcome, admin"
route "/users" >=> requireUserRole >=> text "welcome, user or admin"
]
Notice we are wrapping the inner pipelines with an authenticate
handler.
As before, let's check that these succeed:
curl -H "Authorization: Bearer $ADMIN_TOKEN" -k https://localhost:5001/admin-only -v
curl -H "Authorization: Bearer $USER_TOKEN" -k https://localhost:5001/users -v
and these fail:
curl -H "Authorization: Bearer $USER_TOKEN" -k https://localhost:5001/admin-only -v
Claims-Based Authorization
Sometimes, the presence or absence of a role is not enough: you need logic.
When the JWT middleware authenticates a request, it sets the User
property (of type ClaimsPrincipal
) on the ctx
variable passed to every HttpHandler
in Giraffe.
User
has many useful methods.
For example, .IsInRole(rolename)
allows you to check for roles in a manner similar to above.
You can also directly access the claim values using the .Claims
property.
For example, below, we extract the date of birth and check whether the client is 21 years old:
let userMinimumAge (minAge : int) (user : ClaimsPrincipal) =
match user.Claims |> Seq.tryFind (fun c -> c.Type = ClaimTypes.DateOfBirth) with
| Some dobClaim ->
let dob = Convert.ToDateTime(dobClaim.Value) // please add error handling in prod
let age = DateTime.Today.Year - dob.Year
// account for leap year https://stackoverflow.com/a/1404/
let correctedAge = if dob > DateTime.Today.AddYears(age) then age - 1 else age
correctedAge >= minAge
| None ->
false
let requireMustBe21Manually : HttpHandler =
fun next ctx ->
if userMinimumAge 21 ctx.User then
next ctx
else
RequestErrors.FORBIDDEN "You have to be at least 21" next ctx
requireMustBe21Manually
is just another HttpHandler, so it can be incorporated in a Giraffe pipeline like any other handler:
let webApp =
GET
>=> authenticate
>=> route "/tavern"
>=> requireMustBe21Manually
>=> text "welcome, here's your gin and tonic!"
This time, since $USER_TOKEN
contains a user older than 21 but $ADMIN_TOKEN
does not, this succeeds:
curl -H "Authorization: Bearer $USER_TOKEN" -k https://localhost:5001/tavern -v
and this fails:
curl -H "Authorization: Bearer $ADMIN_TOKEN" -k https://localhost:5001/tavern -v
Policy-Based Authorization
You can also collect logic and register a "policy" with the Authorization service.
Below we define an AdminPolicy
policy to check for the Admin
role, and another MustBe21
to check for minimum age.
Notice that we reuse the userMinimumAge function from above:
services.AddAuthorization(fun (options : AuthorizationOptions) ->
options.AddPolicy( "AdminPolicy", fun policy ->
policy.RequireClaim(ClaimTypes.Role, "Admin") |> ignore
// you can require other claims as well here
) |> ignore
options.AddPolicy("MustBe21", fun policy ->
policy.RequireAssertion(
fun (context : AuthorizationHandlerContext) ->
userMinimumAge 21 context.User
) |> ignore
) |> ignore
) |> ignore
(Yes, that's a lot of ignore
-ance!)
Then, we can define handlers to filter by these policies by using the authorizeByPolicyName
function (the second parameter is an error handler):
let requireAdminPolicy : HttpHandler =
authorizeByPolicyName "AdminPolicy" (RequestErrors.FORBIDDEN "Permission denied. Failed Admin Policy")
let requireMustBe21ByPolicy : HttpHandler =
authorizeByPolicyName "MustBe21" (RequestErrors.FORBIDDEN "Permission denied. Not old enough!")
Again, the handlers can be used like any other:
let webApp =
GET >=> authenticate >=> choose [
route "/admin-policy" >=> requireAdminPolicy >=> text "welcome, admin, you were authorized by policy"
route "/tavern-policy" >=> requireMustBe21ByPolicy >=> text "welcome, here's your gin and tonic and policy!"
]
However, I don't know when Giraffe programmers would want to use a policy over a simple HttpHandler, as shown in the previous section.
For C# programmers, policies make things more succinct since they apply policies using attributes on Controllers.
However, the plain-old F# syntax (using handlers and >=>
) is even more succinct.