Skip to content

authentication

Ratstail91 edited this page Dec 23, 2023 · 4 revisions

Authentication

This page has been adapted from an article I wrote for the PBBG blog some time ago, with updated information.

Premise

A JWT (pronounced "jot") is a method for uniquely identifying an individual using a seemingly arbitrary set of characters.

To begin, you need a "payload" to be encoded into the JWT.

{
  "index": "1",
  "email": "krgamestudios@gmail.com",
  "username": "Ratstail91",
  "type": "alpha",
  "admin": true,
  "mod": true,
  "iat": 1703343779,
  "exp": 1703344379,
  "iss": "auth"
}

You also need a header containing the algorithm information.

{
  "alg":"HS256",
  "typ":"JWT"
}

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

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbmRleCI6IjEiLCJlbWFpbCI6ImtyZ2FtZXN0dWRpb3NAZ21haWwuY29tIiwidXNlcm5hbWUiOiJSYXRzdGFpbDkxIiwidHlwZSI6ImFscGhhIiwiYWRtaW4iOnRydWUsIm1vZCI6dHJ1ZSwiaWF0IjoxNzAzMzQzNzc5LCJleHAiOjE3MDMzNDQzNzksImlzcyI6ImF1dGgifQ

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

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbmRleCI6IjEiLCJlbWFpbCI6ImtyZ2FtZXN0dWRpb3NAZ21haWwuY29tIiwidXNlcm5hbWUiOiJSYXRzdGFpbDkxIiwidHlwZSI6ImFscGhhIiwiYWRtaW4iOnRydWUsIm1vZCI6dHJ1ZSwiaWF0IjoxNzAzMzQzNzc5LCJleHAiOjE3MDMzNDQzNzksImlzcyI6ImF1dGgifQ.7sIFMccspLmMNyl5FO2MBIHjmlm8rMd_8upnSnLri7I

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 it's a bit too advanced to cover in this page though. just know that it's just as valid for JWTs as what I'm currently using.

Access and Refresh Token Pairs

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.

The basics concept is simple - the access token is used for general communication with the website, 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.

Implementation

After much, much tinkering and experimenting, I've found the most straight forward way of handling these token pairs is by providing the access token directly (so the game, etc. can use the info inside), and storing the refresh token in a HttpOnly cookie (which is inaccessible to the webpage).

This is a little more complex than I wanted for an intermediate difficulty tool - so lets go through it together to understand what's happening.

//polyfills
import 'regenerator-runtime/runtime';

import React from 'react';
import ReactDOM from 'react-dom/client';

import App from './pages/app';
import TokenProvider from './pages/utilities/token-provider';

ReactDOM
	.createRoot(document.getElementById('root'))
	.render(
		<TokenProvider>
			<App />
		</TokenProvider>
	)
;

React has a concept called a "Context", which is a way to provide global utilities or information throughout 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 TokenProvider wrapping the MERN-template's client application component.

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,
  setAccessToken,
  tokenFetch,
  tokenCallback,
  getPayload: () => decode(accessToken)
}

Here are the arguments to the provider's "value" argument that I omitted above. The first two are actually easy - they're just the result of React's useState - this is the actual accessToken used throughout the program.

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

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='/' />;
	}
    ...

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

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 vanilla 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) < Date.now() / 1000;

	if (expired) {
		...
	}

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

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',
	credentials: 'include'
});

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

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

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

However, if the token IS expired, this happens first - a POST request to the auth-server carrying the refresh token (specified by the credentials flag). Then, if that response went OK, it retrieves the new accessToken, sets the bearer, and 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.

Conclusion

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.

The auth-server repository has a few tools for sending REST requests - you can invoke them directly from VSCode using this extension.

Clone this wiki locally