Secure your Webservice with JWT in Saturn and F#

How do I secure my web service or webapp with a json web token and use the service itself as the one, which also manages the user itself. (no oauth with google or something else.)

I do not explain the JWT at all. I show only step by step, how to implement it in saturn, an awesome mvc f# web framework.

I use the saturn template base app for this tutorial.

I put all the needed code in one module except for the routes. Feel free to order your code, how do you need. For example I use the module name AuthStuff.

module AuthStuff

What do we need.

Prepare, we need some namespaces to open in that file

open System
open System.Text
open Saturn
open System.Security.Claims
open System.IdentityModel.Tokens.Jwt
open Microsoft.IdentityModel.Tokens
open Microsoft.AspNetCore.Authentication.JwtBearer

1. We need 2 record types. One is the model for the login data and the other is the model for the token return data.

type LoginModel ={
    UserName : string
    Password : string
}

type TokenResult ={
    Token : string
}

2. Now we need some values, like the secret key, which signs our token. The key itself has to be at least 16 characters long (128 Bit). Also an value for the issuer and the audience. (feel free to search for yourself, what for are these values.)

// min 128 Bit - min 16 character
let secret = "ineeda128bitkey!"
let issuer = "myauthapp.de"
let audience = "myauthapp.de"

3. Then we need a saturn pipeline to activate the authentication for the routes, that needed to secured

open Saturn

let onlyLoggedIn = pipeline {
    requires_authentication  (Giraffe.Auth.challenge JwtBearerDefaults.AuthenticationScheme)
}

4. Also we need a function, which generates the JWT Token itself

let generateToken username claims =
    let claims = [|
        Claim(JwtRegisteredClaimNames.Sub, username);
        Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) 
        // here you can implement other claims, which are encoded in the token itself
        yield! claims
    |]

    let expires = Nullable(DateTime.UtcNow.AddHours(8.0))
    let notBefore = Nullable(DateTime.UtcNow)
    let securityKey = SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))
    let signingCredentials = SigningCredentials(key = securityKey, algorithm = SecurityAlgorithms.HmacSha256)

    let token =
        JwtSecurityToken(
            issuer = issuer,
            audience = audience,
            claims = claims,
            expires = expires,
            notBefore = notBefore,
            signingCredentials = signingCredentials)

    let tokenResult = {
        Token = JwtSecurityTokenHandler().WriteToken(token)
    }

    tokenResult

5. The next Step is to tell saturn to use JWT Token for authentication. To do this, we need to extend the Saturn ApplicationBuilder object with our own function

open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.DependencyInjection

type Saturn.Application.ApplicationBuilder with

    [<CustomOperation("my_own_jwt_auth")>]
    member __.UseMyMehAuth(state: ApplicationState,secret:string) =
        let middleware (app : IApplicationBuilder) =
            app.UseAuthentication()

        let service (s : IServiceCollection) =
            s.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(fun options ->
                    options.TokenValidationParameters <- TokenValidationParameters(
                        ValidateActor = true,
                        ValidateAudience = true,
                        ValidateLifetime = true,
                        ValidateIssuerSigningKey = true,
                        ValidIssuer = issuer,
                        ValidAudience = audience,
                        IssuerSigningKey = SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)))
                ) |> ignore
            s

        { state with
            ServicesConfig = service::state.ServicesConfig
            AppConfigs = middleware::state.AppConfigs
            CookiesAlreadyAdded = true
        }

As you see, I use the CustomOperation attribute with ne name my_own_jwt_auth, these is the expression we use in the application setup from you saturn app.
The function itself has 1 extra parameter beside the state (which is a necessary parameter to extend the ApplicationBuilder), and this is the secret. You can use more to make this more flexible, if you want.

6. Now we put our new ApplicationBuilder extension to the application setup. In this example you see the app setup from the saturn template.

open AuthStuff

let app = application {
    pipe_through endpointPipe
    
    // here! you see, this is our extension!
    my_own_jwt_auth AuthStuff.secret

    error_handler (fun ex _ -> pipeline { render_html (InternalError.layout ex) })
    use_router Router.appRouter
    url "http://0.0.0.0:8085/"

    memory_cache
    use_static "static"
    use_gzip
    use_config (fun _ -> {connectionString = "DataSource=database.sqlite"} ) //TODO: Set development time configuration
}

7. So next step is actually add a endpoint, where we can get our token and one endpoint, which we secure for testing purposes. This code I put in the file "Router.fs" from the basic saturn template, if you wonder.

The Token Endpoint:

let tokenRouter = router {
    post "/gettoken" (fun next ctx ->
        task {
            let! model = ctx.BindJsonAsync<LoginModel>()

            // handle the authentication here itself. So ask the db if the user and password is correct. What ever is needed.

            let tokenResult = 
                AuthStuff.generateToken model.UserName [| (* here add your claims *) |]

            return! json tokenResult next ctx
        })
}

The Test Endpoint:
Here you see, we are adding out onlyLoggedIn pipeline

let authRouter = router {
    pipe_through onlyLoggedIn
    get "/checksec" (fun next ctx ->
        task {
            
            let username = ctx.User.FindFirst ClaimTypes.NameIdentifier
            let txt = sprintf "Hello %s" username.Value

            return! text txt next ctx
        }
    )
}

8. And the last Step is to add the 2 endpoints

let appRouter = router {
    // here add the new endpoint
    forward "/hidden" authRouter
    forward "/token" tokenRouter

    forward "" browserRouter
}

Let's test our stuff:

Send the request to the topken endpoint:

postman_saturn_token_request-1

Now copy the token and use it in the bearer token authentication header:

postman_saturn_use_token

Havin fun!