Token-based Authentication

Building off from the session-based authentication method, the next iteration of experimentation is on the use of tokens to manage identity on web applications. Token-based authentication leverages tokens, a compact piece of information, to validate an identity. Tokens come in different forms (or types):

  • Connected token: The token is generated and managed by a physical device that is connected to the computer.
  • Contactless token: Unlike connected token, the device is not connected to the computer but within the range to allow for connection and communication.
  • Disconnected token: This is typically a digitally generated token that is used to confirm an entity’s identity. This could also be called software or soft token.

My focus will be on JSON Web Token (JWT) because it has the least implementation dependency among others. JWT is a disconnected token that uses an open industry standard for communication between two entities.

Features

In order to make it more practical, I’ll be building a web application with the following features:

  • Login: Accepts a user name and password. It returns a JSON response containing the JWT.
  • Base path: An endpoint that requires authentication to be accessed

Log out?

JWT is stateless by default, so there’s nothing to do on logout. Except state is introduced, then the log out feature isn’t necessary.

How JWT works

A sequence diagram showing the client and server interaction for token-based authentication

JWT tokens provide a means of authorization for an entity. On a successful authentication, it is generated and sent back to the client. A JWT consists of three parts:

  • Header: This contains the token type and the signing algorithm:

    Sample header
    {
    "alg": "HS256",
    "typ": "JWT"
    }
  • Payload: This is a set of information (claims) about the entity and token. The key for each item in the payload (claim name) could either be: registered, public and private. Registered claims are managed in the IANA JWT Claims registry. Public claims are custom name that are either added to the IANA JWT Claims registry or unique name not existing in the registry. Private claims are those that are agreed upon by the entities interacting with each other with a possibility of being non-unique from the ones on the registry.

    Sample payload
    {
    "iss": "",
    "sub": "",
    "exp": "",
    "name": ""
    }
  • Signature: This is a Message Authentication Code (MAC) or digital signing used to verify the authenticity of the token. The signature generation is a 3-step process:

    • Generate a Base64URL string of the Header
    • Generate a Base64URL string of the Payload
    • Encrypt the generated Base64URLs with a secret key

    The Base64URL of the Header and Payload are concatenated with a dot (.) before encryption. The encryption algorithm can be done with either a symmetric or asymmetric key such as: HMAC256, RSA and ECDSA.

The Base64URL string of each part is joined together with dots(.) to produce the token:

JWT components
Base64URL(header) + "." + Base64URL(payload) + "." + Base64URL(signature)

A diagram showing the parts that make up a JWT

The client passes this token for subsequent request while the server validates the authenticity of the token before responding to the request. For this reason, it is a stateless flow as tokens aren’t persisted in the DB.

Implementation

Go is the language of choice for implementation, harnessing the standard libraries only. I’ll be using the TDD approach to help with validating my thought before implementing the feature.

The project set up is straight forward:

Project set up
mkdir jwt
cd jwt
go mod init authentication/jwt

Generate JWT

The central part of the POST /login endpoint is the token generation, so I’ll be getting that out of the way first. The requirements include:

  • The ability to generate the Base64URL of the Header
  • The ability to generate the Base64URL of the Payload
  • The ability to generate a token

These requirements will serve as the test cases. A token_test.go file can be created to capture them as follow:

token_test.go
package main
import (
"encoding/base64"
"encoding/json"
"testing"
"time"
)
func TestGenerateHeaderString(t *testing.T) {
header := Header {
Alg: "HS256",
Typ: "JWT",
}
headerString := generateHeaderString(header)
b, err := base64.RawURLEncoding.DecodeString(headerString)
if err != nil {
t.Error("Unable to decode Base64 string")
43 collapsed lines
}
got := string(b)
expected := `{"Alg":"HS256","Typ":"JWT"}`
if got != expected {
t.Errorf("Expected %s, got %s", expected, got)
}
}
func TestGeneratePayloadString(t *testing.T) {
payload := Payload {
Iss: "MEEE",
Name: "admin",
Exp: time.Now().Unix(),
}
payloadString := generatePayloadString(payload)
b, err := base64.RawURLEncoding.DecodeString(payloadString)
if err != nil {
t.Error("Unable to decode Base64 string")
}
var p Payload
jsonErr := json.Unmarshal(b, &p)
if jsonErr != nil {
t.Error("Unable to parse bytes to Payload")
}
if p.Name != payload.Name {
t.Errorf("Expected %s, got %s", payload.Name, p.Name)
}
}
func TestGenerateToken(t *testing.T) {
token := GenerateToken("admin")
if len(token) <= 0 {
t.Error("Expected a JWT but got nothing")
}
}

Ideally, when a test case is written, the code required to make it pass is written immediately, but this isn’t a TDD post and the test cases are straightforward. The type (struct) for each part is created and passed to the function that creates a Base64URL string. The Base64URL string is decoded and compared with the expected string.

A token.go file can be created to implement the requirements:

token.go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
const (
SECRET_KEY = "@ $up3r s#cr3t k3y!"
)
type Header struct {
Alg string
Typ string
}
64 collapsed lines
type Payload struct {
Iss string
Name string
Exp int64
}
func GenerateToken(username string) string {
h := Header{
Alg: "HS256",
Typ: "JWT",
}
currentTime := time.Now()
tenMinutesLater := currentTime.Add(time.Minute * 10)
expiryTime := tenMinutesLater.Unix()
p := Payload{
Iss: "LJ_ODD",
Name: username,
Exp: expiryTime,
}
header := generateHeaderString(h)
payload := generatePayloadString(p)
signature := generateSignature(header, payload)
signatureBase64URL := base64.RawURLEncoding.EncodeToString(signature)
jwt := fmt.Sprintf("%s.%s.%s", header, payload, signatureBase64URL)
return jwt
}
func generateSignature(header, payload string) []byte {
mac := hmac.New(sha256.New, []byte(SECRET_KEY))
mac.Write([]byte(header + "." + payload))
signature := mac.Sum(nil)
return signature
}
func generateHeaderString(header Header) string {
headerJSON, err := json.Marshal(header)
if err != nil {
return ""
}
headerBase64String := base64.RawURLEncoding.EncodeToString(headerJSON)
return headerBase64String
}
func generatePayloadString(payload Payload) string {
payloadJSON, err := json.Marshal(payload)
if err != nil {
return ""
}
payloadBase64String := base64.RawURLEncoding.EncodeToString(payloadJSON)
return payloadBase64String
}

Some things to take note of:

  • The secret key should be managed outside the application
  • RawURLEncoding is used to avoid the padding character (add reference)
  • The expiry time could be shorter because of the risk of key hijacking

Login

The /login endpoint is a POST request accepting a username and password. The authentication requirements are:

  • Return a bad request (HTTP 400) when either the username or password isn’t provided
  • Return not found (HTTP 404) if user doesn’t exist
  • Return an unauthorized access error (HTTP 401) when the username and password don’t match
  • Return a JSON response containing a token (200 OK code) on a successful validation

A new test file, server_test.go, is created to capture these requirements:

server_test.go
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
)
func TestLogin(t *testing.T) {
t.Run("return 400 for missing credentials", func(t *testing.T) {
credentials := []byte(`{ "username": "admin", "password": ""}`)
request, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(credentials))
response := httptest.NewRecorder()
Login(response, request)
expected := 400
got := response.Result().StatusCode
5 collapsed lines
if got != expected {
t.Errorf("Expected %d, got %d", expected, got)
}
})
}

On running the test, I get the expected error that the Login function is undefined:

Go test
> go test -v
# authentication/token [authentication/token.test]
./server_test.go:16:3: undefined: Login
FAIL authentication/token [build failed]

To make the test pass, I created the server.go file and added the Login function:

server.go
package main
import "net/http"
func Login(w http.ResponseWriter, r *http.Request) {
}

Before fleshing out the Login function, I need to add the set up code that includes:

  • Credential struct: This is a custom type that will be used to capture the payload of the login request.
  • Constants: These are the fixed and reusable identifier needed across the package
  • Dummy users: The users map represent existing users that are being validated. This is to keep the implementation simple by not using any form of storage.

This will be added just above the Login function:

server.go
type Credential struct {
Username string `json:"username"`
Password string `json:"password"`
}
const (
ErrRequiredUsername = "username is required"
ErrRequiredPassword = "password is required"
ErrUserNotFound = "user not found"
ErrCredentialMismatch = "user and password don't match"
ErrMissingToken = "token not found"
ErrInvalidToken = "invalid token"
SigningSecret = "a very super secret"
)
var users = map[string]string {
"admin": "admin",
"user1": "user1",
}

With the set up in place, the first requirement for the login request can be implemented:

server.go
func Login(w http.ResponseWriter, r *http.Request) {
var credential Credential
err := json.NewDecoder(r.Body).Decode(&credential)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
if credential.Username == "" || credential.Password == "" {
http.Error(w, ErrRequiredCredential, http.StatusBadRequest)
}
}

On running the test again, it should return a successful outcome:

Go test
> go test -v
=== RUN TestLogin
=== RUN TestLogin/return_400_for_missing_credentials
--- PASS: TestLogin (0.00s)
--- PASS: TestLogin/return_400_for_missing_credentials (0.00s)
PASS
ok authentication/token 0.633s

Following the TDD approach, the test cases and feature can be implemented for the remaining requirements:

Validate JWT

To validate the token, I will:

  • Get and decode the Header Base64URL string
  • Get and decode the Payload Base64URL string
  • Generate a new token using the same secret key
  • Compare the previous and newly generated token
token_test.go
func TestValidateToken(t *testing.T) {
t.Run("signature is incorrect", func(t *testing.T) {
expected := false
got := ValidateToken("8hijfifjiifokdubfhiehbd9jnkdkdj")
if got != expected {
t.Errorf("Expected %t, got %t", expected, got)
}
})
t.Run("signature is correct", func(t *testing.T) {
token := GenerateToken("admin")
got := ValidateToken(token)
expected := true
if got != expected {
t.Errorf("Expected %t, got %t", expected, got)
}
})
}

While the test cases didn’t capture these requirements, they simply call the ValidateJWT method in the token.go file to perform the required checks:

token.go
import (
// other packages
"strings"
// more packages
)
func ValidateToken(token string) bool {
tokenParts := strings.Split(token, ".")
if len(tokenParts) < 3 {
fmt.Println("Invalid token")
return false
}
headerString := tokenParts[0]
payloadString := tokenParts[1]
payloadJSON, err := base64.RawURLEncoding.DecodeString(payloadString)
if err != nil {
fmt.Println("Unable to decode payload")
return false
28 collapsed lines
}
var payload Payload
err = json.Unmarshal(payloadJSON, &payload)
if err != nil {
fmt.Println("Unable to generate Payload object")
return false
}
currentTime := time.Now().Unix()
if currentTime >= payload.Exp {
fmt.Println("Token expired")
return false
}
prevSignature, err := base64.RawURLEncoding.DecodeString(tokenParts[2])
if err != nil {
fmt.Println("Unable to decode signature")
return false
}
signature := generateSignature(headerString, payloadString)
fmt.Println("Checking hmac...")
return hmac.Equal(signature, prevSignature)
}

Home

With the token validation in place, I can now add the path that requires authentication to access. Here, are the requirements:

  • Return an unauthorized access (401) if the token is missing
  • Return an unauthorized access (401) for an invalid token
  • Return a success response if token exists and is valid

The server_test.go and server.go files can be updated to capture these requirements:

Router

To tie everything together, a router is introduced to allow clients communicate with the server. The implementation of the router is done in a new file, main.go, inside the main() function:

main.go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /login", Login)
mux.HandleFunc("GET /home", Home)
fmt.Println("Starting server on :9080")
log.Fatal(http.ListenAndServe(":9080", mux))
}

And that’s a wrap! Obviously, the implementation would be more complex in libraries, but this was a fun ride.

Lessons

Here are some takeaways from this experiment:

  1. I mixed up casing for the method names which isn’t right. Method names with PascalCasing is for public method while those with camelCasing is for private methods. In my defence, I’m not using packages yet 🌚
  2. Errors weren’t handled properly, particularly in the token.go file. I should be returning the error message along with the boolean from each method in the token.go file
  3. Order matters when validating credentials. I initially wrote the test for credentials mismatch before the nonexistent user, making the nonexistent user check fall into the credentials check
  4. I didn’t break down the flow of the token validation enough before implementation. Luckily, I caught it before going off track.
  5. The validation flow isn’t the best in class as described in RFC 8725. The goal of the experiment is to understand the concept of JWT and how libraries that manage it works.

References