Making Egg Trainer 6

In this article, we're going to explore the concept of JSON web tokens (or JWTs for short), and how I've implemented them in the MERN-template. If you're coming here from previous articles, you should know that this authentication method has replaced the "key" system outlined in "Making Egg Trainer 2" - such is the nature of evolving software.

Background

First, I should outline what lead me to JWTs. I decided that the login/logout system of the MERN-template deserved it's own microservice treatment, to match the news-server and chat-server. This has come to be called the auth-server (I also seem to have a thing for four-lettered words).

At first I had the chat logic running through the MERN-template to "reserve" a player's name in the chat-server, but once the auth-server needed separating I knew this wouldn't suffice. One of the developers in the awesome PBBG discord recommended I research JWTs, and I fell in love (with the JWT, not the person).

Premise

JWTs, are an interesting concept; basically, you begin with a "payload", which is what you want to encode in the token.

{
  "id": "4",
  "username": "Ratstail91",
  "privilege": "administrator",
  "exp": "600"
}
"exp" will actually be much larger than this.

You also need a header containing the algorithm information.

{
  "alg":"HS256",
  "typ":"JWT"
}
I didn't know the algorithm used until now.

Then, you combine the header and payload, converting both to base64 and separating them with a period.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidXNlcm5hbWUiOiJSYXRzdGFpbDkxIiwicHJpdmlsZWdlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTYxNTQ4MjgwMiwiZXhwIjoxNjE1NDgzNDAyfQ
This makes sense when decoded.

Finally, you hash this string using the algorithm identified by the header, and add that to the end (separated by another period).

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidXNlcm5hbWUiOiJSYXRzdGFpbDkxIiwicHJpdmlsZWdlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTYxNTQ4MjgwMiwiZXhwIjoxNjE1NDgzNDAyfQ.G-pTYEnP5kSzOad72FmGBSKW53a6GNaUqb_1i0A070U
An actual JSON web token generated by the auth-server.

The beauty of this is that you can decode it easily for the payload information (such as username, account privilege, and any other information stored within), and you can also ensure that it hasn't been altered by a malicious party, thanks to the hashed information - if the hash doesn't match, then there's a problem.

Also, there's a concept called public key cryptography that I am not currently using, but will likely end up using by the end of this process. It's a bit too advanced to cover in this article though. just know that it's just as valid for JWTs as what I'm currently using. Maths is fun!

Refresh Tokens

So, now that we have a token that proves our identity, we need to keep it super secure to ensure that nobody can steal it. The problem with this is that no system is ever 100% secure...

To resolve this, I've added an "exp" field to the payload, defining the number of seconds since Jan 1st 1970 (also called unix time). This lets me simply reject any tokens that are too old. By default, the auth-server gives tokens about 10 minutes of validity.

Again, you've probably noticed a problem - if tokens are only valid for 10 minutes, then the user will always be logged out automatically when it expires. I wish i had come up with some brilliant solution for this, but I'm just using the same technique everyone else does; I split the token into two tokens: the access token and the refresh token.

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidXNlcm5hbWUiOiJSYXRzdGFpbDkxIiwicHJpdmlsZWdlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTYxNTQ4MjgwMiwiZXhwIjoxNjE1NDgzNDAyfQ.G-pTYEnP5kSzOad72FmGBSKW53a6GNaUqb_1i0A070U",
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidXNlcm5hbWUiOiJSYXRzdGFpbDkxIiwicHJpdmlsZWdlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTYxNTQ4MjgwMiwiZXhwIjoxNjE4MDc0ODAyfQ.o2ZI8Dlgv95gx2EcFDIwdklNcZ7varcwWGXdGer0BCE"
}
I've already invalidated this refresh token - no hacking allowed!

The basics concept is simple - the access token is used for general communication with the game's network of servers, and when it expires, you ping the auth-server again with the refresh token to get another token pair. This way, the access token which gets shared around can only be stolen for up to 10 minutes at most.

This does mean that if you steal the refresh token (which doesn't expire at all), you'll be able to refresh token pairs continuously. Thankfully, the auth-server actually checks the database for existing refresh tokens before issuing new pairs. If the player simply sends a valid logout request, then that refresh token is deleted and refreshing the access token is impossible. Security is fun!

Implementation

Here's the tricky part.

I spent several days fiddling with cookies, JWTs and the like trying to get some solution that worked smoothly and securely. Finally, I ended up with this react provider as the result.

What's going on here is way more complex than i wanted for an intermediate difficulty tool - so lets go through it together to understand what's happening.

ReactDOM.render(
	<TokenProvider>
		<App />
	</TokenProvider>,
	document.querySelector('#root')
);
TokenProvider

React has a concept called "Context", which is a way to provide global utilities or information through out an application without requiring that the user pass down props from parent to child. Contexts have "Providers" which are what actually provide the information to the render tree. just above you can see the token Provider wrapping the MERN-template's client application.

import React, { useState, useEffect, createContext } from 'react';
import decode from 'jwt-decode';

export const TokenContext = createContext();

const TokenProvider = props => {
	...

	return (
		<TokenContext.Provider value={...}>
			{props.children}
		</TokenContext.Provider>
	)
};

export default TokenProvider;

Here's the simplest breakdown of the token Provider. You can see the file creates and exports a context, then wraps the TokenProvider's children with the context's provider member. I've omitted stuff from the body and from the argument "value".

{
  accessToken,
  refreshToken,
  setAccessToken,
  setRefreshToken,
  tokenFetch,
  getPayload: () => decode(accessToken)
}

Here are the arguments to the providers "value" argument that I omitted. The first four are actually easy - they're just the result of useState - these are the actual accessToken and refreshToken that are used throughout the program.

The last argument is getPayload, which simply wraps a call to decode the access token - literally just a way to get the contents of the tokens.

import React, { useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom';

import { TokenContext } from '../utilities/token-provider';

const LogIn = props => {
	//context
	const authTokens = useContext(TokenContext);

	//misplaced?
	if (authTokens.accessToken) {
		return <Redirect to='/' />;
	}
    ...
Login uses the auth tokens to check if the user is already logged in, and redirects them to the homepage if they are.

Here's snippet from login.jsx, showing the usage of the contexts. By using the react hook useContext, and passing in TokenContext you gain access to the "value" argument that was passed to the created context's provider above.

tokenFetch

OK, last big section, I think.

There's one member of the context value that I skipped over earlier - tokenFetch. This is a wrapper function around the fetch() API. It's purpose is to refresh the token pair when the access token is expired - and do so invisibly to the user.

It should be noted that tokenFetch is only used for functions where it is appropriate - namely where the user is already logged in. Other times, just using fetch() is enough.

//wrap the default fetch function
const tokenFetch = async (url, options) => {
	//use this?
	let bearer = accessToken;

	//if expired (10 minutes, normally)
	const expired = new Date(decode(accessToken).exp * 1000) < Date.now();

	if (expired) {
		...
	}

	//finally, delegate to fetch
	return fetch(url, {
		...options,
		headers: {
			...options.headers,
			'Authorization': `Bearer ${bearer}`
		}
	});
};
tokenFetch, broken down.

Here, lets look at what happens when the token is NOT expired. Basically, the accessToken's value is stored in bearer, the expiry boolean is checked, and finally fetch is called, with the Authorization header injected (bearer is an argument for Authorization).

Straight forward; this is what should normally happen.

//ping the auth server for a new token
const response = await fetch(`${process.env.AUTH_URI}/token`, {
	method: 'POST',
	headers: {
		'Content-Type': 'application/json',
		'Access-Control-Allow-Origin': '*'
	},
	body: JSON.stringify({
		token: refreshToken
	})
});

//any errors, throw them
if (!response.ok) {
	throw `${response.status}: ${await response.text()}`;
}

//save the new auth stuff (setting bearer as well)
const newAuth = await response.json();

setAccessToken(newAuth.accessToken);
setRefreshToken(newAuth.refreshToken);
bearer = newAuth.accessToken;

However, if the token IS expired, this happens first - a POST request to the auth-server carrying the refresh token in it's body. Then, if that response went OK, it retrieves the new accessToken and refreshToken, sets them and sets bearer to the value of the new access token. Finally, it proceeds with tokenFetch's original task.

I should also mention that there's a bugfix inside the expired block - if the user is trying to log out, (by pinging ${process.env.AUTH_URI}/logout), then the process intercepts this and sends it's own logout message. This only happens when the user is trying to log out with an expired authToken.

const [accessToken, setAccessToken] = useState('');
	const [refreshToken, setRefreshToken] = useState('');

	//make the access and refresh tokens persist between reloads
	useEffect(() => {
		setAccessToken(localStorage.getItem("accessToken") || '');
		setRefreshToken(localStorage.getItem("refreshToken") || '');
	}, [])

	useEffect(() => {
		localStorage.setItem("accessToken", accessToken);
		localStorage.setItem("refreshToken", refreshToken);
	}, [accessToken, refreshToken]);
Let's stick the token's declaration here for completeness. They're stored in localStorage.

Conclusion

Don't you just love technical articles?

The JWTs are powerful, because they contain all of the authority that the player needs to play the game. They also carry information such as the admin status, so administrators can access the admin panel and do... admin stuff. This is also the reason you can no longer easily modify the content of the news-server, sorry!

Only my user account has admin privileges on the auth server... so if you want to try and modify the news-server's content, you can request an auth token pair using this:

POST https://dev-auth.eggtrainer.com/auth/login HTTP/1.1
Content-Type: application/json

{
	"email": "kayneruse@gmail.com",
	"password": "helloworld"
}

Using this, you send another request like this to the news server:

POST https://dev-news.eggtrainer.com/news HTTP/1.1
Content-Type: application/json
Authorization: Bearer XXX

{
	"title": "Hello World",
	"author": "Kayne Ruse",
	"body": "Lorem ipsum."
}

Replace XXX with your new accessToken. The full APIs for the auth and news servers are available in their README files.

GET request to the news-server should work as they always have (except "titles" was changed to "metadata" - always check the latest docs!). Software is fun!

My name is Kayne Ruse of KR Game Studios, you can find me on the net as Ratstail91, trying to decrypt the Voynich Manuscript using brute force.