At my $CURRENT_JOB
we are working on introducing a new back-end
service, and as usual, teams entirely composed of new-ish employees
face some hard time discovering all the small pieces required to make
the gears turn.
This time the challenge was to implement the authentication layer. It is actually quite simple as it is just a regular JWT token, but the devil's in the details:
- the token is on a custom header called `x-jwt-payload`
- the token does not contain the
alg
attribute - the validation is done internally at the reverse proxy level
OK, this doesn't sound too bad. However, it does take some tools
from our hands... ASP.NET has the UseJwtBearerAuthentication
middleware that would take care of this workflow for us, but this
requires access to the Authority1 server which we don't have,
and also requires the alg
attribute to decode the token.
Having said that, let's develop another middleware to take of our authentication. I tried to reach the official documentation on how to write a custom authentication scheme for ASP.NET but it was less than useless. Then I tried to reach for blog posts, Stack Overflow questions and open source projects, but they all seemed so convoluted for such a small feature... When I was almost going to brute force the solution out of my IDE through auto completion and debugging, this answer appeared!
That's it! This is what I needed, a really concise example going through each step of the authentication workflow. I wonder why Microsoft doesn't have something like this on their docs. Or at least not something easy to find there.
Alright, time to implement piece by piece of this code. Starting with the Authentication scheme definition:
type CustomJwtAuthenticationOptions() =
inherit AuthenticationSchemeOptions()
member this.DefaultScheme = "CustomJwtAuthentication"
member this.HeaderName = "x-jwt-payload"
The next missing part is the Authentication Handler. For this, I'll
use the great FsToolkit.ErrorHandling package to help structure the
code, so do a dotnet add package FsToolkit.ErrorHandling
.
type CustomJwtAuthenticationHandler
(
options: IOptionsMonitor<CustomJwtAuthenticationOptions>,
logger: ILoggerFactory,
encoder: UrlEncoder,
clock: ISystemClock
) =
inherit AuthenticationHandler<CustomJwtAuthenticationOptions>(options, logger, encoder, clock)
override this.HandleAuthenticationAsync() =
result {
let! token = this.RetrieveTokenValue this.Options.HeaderName
let! jwt = this.DecodeToken token
let name =
let firstName =
jwt.Item("firstName") |> string
let lastName =
jwt.Item("lastName") |> string
$"{firstName} {LastName}"
let claims =
[ Claim(ClaimTypes.NameIdentifier, jwt.Sub)
Claim(ClaimTypes.Name, name) ]
let claimIdentity =
ClaimsIdentity(claims, this.Options.DefaultSchemeName)
let ticket =
AuthenticationTicket(
ClaimsPrincipal(claimsIdentity),
AuthenticationProperties(),
this.Options.DefaultSchemeName
)
return Task.FromResult(AuthenticateResult.Success(ticket))
}
|> function
| Ok value -> value
| Error e -> Task.FromResult(AuthenticateResult.Fail(e))
And that's it! I now have the custom JWT authentication I needed for my ASP.NET application. Of course, we are missing some helper methods I used on the code. Let's take a look at them.
This function is used to extract the Base 64 token from the header.
member private this.RetrieveTokenValue name =
let found, value =
this.Request.Headers.TryGetValue(name)
if not found then
Error $"Missing header '{name}'"
else
value.ToString()
|> String.IsNullOrWhiteSpace
|> function
| false -> Ok value
| true -> Error $"Missing header '{name}' value"
Now the function responsible to decode the JWT token itself.
member private this.DecodeToken token =
try
let jwt =
token
|> Convert.FromBase64String
|> Encoding.UTF8.GetString
|> Jwt.JwtPayload.Deserialize
Ok jwt
with
| exn -> Error $"Error decoding token: {exn.Message}"
OK, now we have everything needed to use our brand new authentication scheme. How can we plug this together on our application's startup? Considering that we're using Saturn to configure it, it would look just like this:
let configureApp (app: IApplicationBuilder) =
app.UseAuthentication()
let configureServices (services: IServiceCollection) =
services
.AddAuthentication(
CustomJwtAuthenticationOptions().DefaultScheme
)
.AddScheme<CustomJwtAuthenticationOptions, CustomJwtAuthenticationHandler>(
CustomJwtAuthenticationOptions().DefaultScheme, (fun options -> ())
)
|> ignore
services
let main _ =
let app =
application {
// ...
app_config configureApp
service_config configureServices
}
run app
-
The address of the token-issuing authentication server. The JWT bearer authentication middleware will use this URI to find and retrieve the public key that can be used to validate the token’s signature. It will also confirm that the iss parameter in the token matches this URI.↩