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
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.- Generate a
The Base64URL string of each part is joined together with dots(.) to produce the token:
Base64URL(header) + "." + Base64URL(payload) + "." + Base64URL(signature)
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:
mkdir jwtcd jwtgo 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:
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:
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:
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 -v# authentication/token [authentication/token.test]./server_test.go:16:3: undefined: LoginFAIL authentication/token [build failed]
To make the test pass, I created the server.go
file and added the Login
function:
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:
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:
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 -v=== RUN TestLogin=== RUN TestLogin/return_400_for_missing_credentials--- PASS: TestLogin (0.00s) --- PASS: TestLogin/return_400_for_missing_credentials (0.00s)PASSok 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
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:
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 false28 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:
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:
- I mixed up casing for the method names which isn’t right. Method names with
PascalCasing
is for public method while those withcamelCasing
is for private methods. In my defence, I’m not using packages yet 🌚 - 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 thetoken.go
file - 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
- I didn’t break down the flow of the token validation enough before implementation. Luckily, I caught it before going off track.
- 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
- What Is An Authentication Token? | Fortinet
- RFC 7515: JSON Web Signature (JWS)
- RFC 7516: JSON Web Encryption (JWE)
- RFC 7518: JSON Web Algorithm (JWA)
- RFC 7519: JSON Web Token (JWT)
- JWT.IO - JSON Web Tokens Introduction
- Why is JWT popular?
- JSON Web Token (JWT) | IANA
- RFC 8725 JSON Web Token Best Current Practices
- Encryption choices
- Base64 Encoding | Go Packages