(Tutorial) Securing a single-page app with Auth0, via a Bun back-end (part 1)

(Tutorial) Securing a single-page app with Auth0, via a Bun back-end (part 1)

20 Mar 2024 auth auth0 bun jwt spa

If you don't want to get involved in rolling your own authentication solution with your single-page application (SPA), something like Auth0 is your friend.

You can get the code for this tutorial over here on GitHub.

Auth0 is a third-party platform that provides authentication via JWT (JSON web token). It's feature-rich, supports social sign-on (e.g. via Google), and means you can leave worries about things like bot detection to the experts. Better still, Auth0's free offering is generous and commendable.

In this tutorial we'll be setting it up for use with a static front end which talks to a JavaScript end.

What's a JWT? 🔗

If you've not used JWT before, it's a means to securely transmit data between two parties, in the form of JSON. The content of the JWT (e.g. it might contain information about the user requesting the login) is public; that is, you can decode a JWT via a tool like JWT.io. But the receiving server can trust the content because the JWT is digitally signed - either with a secret or with a public/private key combo.

This isn't a tutorial about JWTs or what they are and how they work - it's about using them, via Auth0, to power authentication - but you can read more about them over here.

Setting up Auth0 🔗

First up, go and set up a free account at Auth0. Choose "Other" when asked for account type, and optionally change the location where your users' account details will be processed (it defaults to the US) in case you need to comply with local privacy laws.

Setting up an application 🔗

Let's create an Auth0 Application. Head to Applications > Applications in the left nav.

You'll notice there's a default app already there. Ignore this; we'll be creating a new one.

Do the following:

  • Create a new application
  • Give it a name
  • Select the "Single page apps" tab
  • Continue

You'll now be taken to your app. Ignore the quickstart guide - instead, hit the Settings tab and:

  • Make a note of the app's "Domain" and "Client ID" (these are found under its "Basic information"). We'll need these later.
  • Scroll down to the "Application URIs" section and add "http://localhost:5173" to the "Allowed callback URLs" field (this is the URL we'll run our front end app on.)
  • Do the same for the "Allowed logout URLs" field.

Setting up an API 🔗

Next we'll need to create an Auth0 API. Head to Applications > APIs in the left nav.

You'll notice there's already an API there. This is a management API, not our app's API. This is used for managing your Auth0 account e.g. managing your users. We won't be using this API in this tutorial.

Now do the following:

  • Create a new API
  • In the popup modal, give the API a name and an audience. The latter is an identifier for the domain, which will be used as its "audience", which we'll use later. By convention this tends to begin "https://", though it doesn't have to. We'll use "https://auth0-tut".
  • Still in the modal, keep the JWT signing algorithm on the default option, "Auth0" (it's also possible to use RFC 9068, associated with OAuth 2.0, but for this tutorial we'll stick with Auth0's algo)
  • Create the API

Back over in Applications > Applications, you'll find that creating your API has also implicitly created an application with the same name, of type "Machine to machine". You can safely delete this - we'll be using the application we made earlier.

With your API created, you can if you wish go into its settings and change the token expiry time (it defaults to 86,400 seconds, i.e. 24 hours).

Setting up a user 🔗

Obviously, to test our app we'll need a user to login with. Let's set that up now. In Auth0, click "User management" > "Usesr" in the left nav.

Click the "+ Create user" button. In the modal popup, provide an email and password (the email doesn't have to be a real email - we won't be using 2FA.)

After doing this, you'll be taken to the user's settings page. We won't be doing anything further here, though in part 2 of this tutorial we'll revisit this to look at setting up specific permissions for the user.

Setting up the back end 🔗

Now let's set up our back end app, the one that will be serving data to our front end and enforcing authentication. For this we'll use Bun rather than Node, and itty-router to handle our routes. Don't worry if you've not used either of these; they're not the focus of this tutorial and we could just as well use Node and Express instead.

Installation and setup 🔗

First, let's install Bun (globally):

npm i -g bun

Now let's set up our project. We'll store our front and back end apps in a directory called auth0-tut. Let's make that, then a sub-directory for our back end app called backend. Pull up a terminal and run:

mkdir auth0-tut cd auth0-tut mkdir backend cd backend bun init

Bun's project initialiser will walk you through some steps. You can ignore most of the prompts for now (just hit Enter) except, for "Entry point", change it to index.js from index.ts (we won't use TypeScript in this tutorial.)

In our back end directory there'll now be a bunch of files. Open up package.json and add a scripts definition, which will contain the command we'll run to run the back end.

"scripts": { "dev": "bun --hot index.js" },

(The --hot flag enables auto-reloading, so we don't have to restart the server each time we make a code change.)

Finally, let's install a couple of things - itty-router, and jose, a popular library for signing, decoding and generally working with JWTs:

bun add itty-router bun add jose

Adding our back end code 🔗

Now open our app's main file, index.js, and put the following content in it:

//bring in itty and jose import { AutoRouter, StatusError, cors } from 'itty-router' import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose' //create router, listening on port 3001 (Bun's default port) and with //CORS enabled const { preflight, corsify } = cors(); const router = AutoRouter({ port: 3001, before: [preflight], finally: [corsify] }); //set up an initial route router.get('/', () => 'Hello!') //export router export default router

itty-router actually provides three routers! We're using its AutoRouter, which is the most fully-featured and easiest to get started with, but you can compare the three over here.

Let's add one more file in the same directory, .env, where we'll store our Auth0 credentials. Bun will automatically load this file. (Remember to git-ignore this if you commit the repo, so you don't commit sensitive information to Github.) In it, put the following, replacing ? with your values.

AUTH0_DOMAIN = https://?/ AUTH0_CLIENT = ? AUTH0_API_AUDIENCE = https://auth0-tut AUTH0_JWKS_URI = .well-known/jwks.json

Make sure your Auth0 domain is prefixed with "https://" and "suffixed with "/" as shown above.

You'll find your Auth0 domain and client under Applications > Application > {your application} > Settings. The API audience is what what we set earlier, "https://auth0-tut".

We're ready to serve our app! Run the following:

bun dev

...and head to http://localhost:3001 in the browser. You should a page outputting "Hello!".

Setting up routes 🔗

Let's replace our initial "Hello!" route with something useful. We'll set up two routes - one public, one private. Our public route will output something non-secret, like the capital cities of different countries, while our private route will output sensitive information about the user's bank account (dummy info, of course).

A quick note about how routes work in itty-router:

  • Routes can have any number of callbacks (i.e. middleware functions) passed to handle them. The first non-undefined value returned by any of them is used as the response output, and later callbacks are then skipped.
  • Middleware functions are implicitly passed the request object and the env object (i.e. containing environment variables).
  • itty outputs responses with a JSON header by default, and object responses are automatically stringified to JSON.

With that in mind, let's replace the router.get(... line (our "Hello" route) with:

//public route router.get('/public', () => ({ Austria: 'Vienna', Indonesia: 'Jakarta', Slovenia: 'Ljubljana' })); //private route router.get('/private', withAuth, () => ({ balance: '£4,591', overdraft: '£500', accountNum: '95130012', sortCode: '416668' }));

Adding in authentication 🔗

Notice that our pviate route (which isn't yet private at all but soon will be) has an extra bit of middleware, withAuth. We haven't defined this yet (hence Bun is throwing an error at this point.) This middleware's job will be to check whether the user is authenticated, by checking for valid Authorization header in the request.

Let's define this middleware. Add the following somewhere before the route declarations:

const withAuth = async (req, env) => { try { const getJwksUrl = process.env.AUTH0_DOMAIN+process.env.AUTH0_JWKS_URI; const jwks = createRemoteJWKSet(new URL(getJwksUrl)); const header = req.headers.get('authorization'); if (!header) throw 'missing token'; const tkn = header.replace(/^Bearer\s/i, ''); const options = { algorithms: ['RS256'], issuer: process.env.AUTH0_DOMAIN, audience: process.env.AUTH0_API_AUDIENCE }; const result = await jwtVerify(tkn, jwks, options); } catch(e) { throw new StatusError(401, e); } }

Let's break down what's happening there. Don't worry if it looks a bit alien; the main thing is it works!

  • We enter a try-catch block, so that if the header is missing, or verification of the JWT fails, we can throw a 401 "unauthorised" response via our catch block.
  • We prep the URL to our "JSON web key sets" (JWKS). This is what Auth0 uses to sign our (specifically our) JWTs when the user logs in.
  • We use jose to fetch and parse the JWKS.
  • We check for the existence of an auth header in the user's request - if missing, we quit.
  • If found, we extract the token from it - everything after the "Bearer " prefix.
  • We configure our options for verifying the JWT by specifying the algorithm our Auth0 API is set to use (RS256), the issuer (our Auth0 domain) and the audience (our Auth0 API audience which we set earlier.)
  • We verify the JWT via jose's jwtVerify()

Right now our JWT payload doesn't contain anything interesting, but we'll see in part 2 of this tutorial how we can use it to store information about their user, such as permissions, which our back end can then reference in deciding what is and isn't allowed.

That's it for now with our back end app; let's head front end.

Setting up the front end 🔗

Now to the front end! Once again we'll use Bun, this time in conjunction with Vite, which is a super-fast JS web server and module bundler.

Installation and setup 🔗

From the root auth0-tut directory, run the following:

bun create vite

Again you'll be asked a series of prompts. Give the project a name of "frontend", select "vanillia" (no Vue, React etc.) and select "JavaScript" not TypeScript.

Now run this:

cd frontend bun add @auth0/auth0-spa-js bun install bun dev

Notice we add the Auth0 SPA JS package, which we'll use to power our authentication.

Finally, let's clean up the boilerplate project a little. Delete the files javascript.svg, counter.js, main.js and and style.css.

Adding the HTML 🔗

Open up index.html and replace its contents with the following:

<!doctype html> <head> <title>Auth0 tutorial</title> <script type='module' src='app.js'></script> </head> <body> <p id='login-or-logout'></p> <h1>Hello!</h1> <div id='data'></div> </body>

The p element will store either a login or logout link, depending on the user's current auth status. The div will be used to house our public or private data, again depending on login status. All elements will be populated by JavaScript; in our HTML, they're just empty containers right now.

Adding our auth logic 🔗

Now create a file called auth.js and populate it with the following:

//import Auth0's SPA SDK import { createAuth0Client } from '@auth0/auth0-spa-js' const domain = '<your-auth0-domain>' export const clientId = '<your-auth0-client-id>' const audience = '<your-auth0-api-audience>'

Replace the constant values with your real Auth0 details - they're the same ones we used before on the back end, namely Auth0 domain, client ID and API audience. However, for Auth0 domain, this time omit the "https://" prefix and "/" suffix that we glued onto it for the back end (in .env) - it should be the exact value Auth0 gives you.

Notice how we export clientId; this is because another file will need it later.

Along with clientId, auth.js file will export two other things: one, our Auth0 client (which it will first create), and two, a function whose job is to ascertain login status, returning an object containing the decoded JWT (via the token property) if it turns out the user is logged in.

First, let's add the Auth0 client export.

//Auth0 client export const auth0Client = createAuth0Client({ domain: domain, clientId: clientId, cacheLocation: 'localstorage', authorizationParams: { redirect_uri: window.location.origin, audience: audience } })

You can read more about what this function does over here in the Auth0 docs. There are options you can configure, but for now these ones will do for us.

Next let's add the final export, our login handler.

export const handleLogin = async () => { const client = await auth0Client; //handle login callback if (location.search.includes('state=') && ( location.search.includes('code=') || location.search.includes('error=')) ) { try { await client.handleRedirectCallback(); } catch(e) { } finally { window.history.replaceState(null, document.title, '/'); } } //logged in? Get and decode auth token const isAuthenticated = await client.isAuthenticated(); let token; if (isAuthenticated) { try { token = await client.getTokenSilently({aud: audience}); return { token, header: JSON.parse(window.atob(token.split('.')[0])), payload: JSON.parse(window.atob(token.split('.')[1])) } } catch(e) { window.location.reload(); } } }

There's quite a bit going on there but don't worry, it's not vital to know exactly how it all works.

Essentially, the function checks whether the URL is the result of a redirect to our app by Auth0 following a login attempt, and then acts accordingly, either processing the callback (including removing from the current URL any bits relating to Auth0 so that, if the user refreshes the page, it doesn't trigger another this flow again) or redirecting the user to the homepage to login (meaning they tried to access a page that requires login.)

As part of establishing whether the login attempt was successful, we utilise Auth0's isAuthenticated(). This checks that Auth0 sent back to us (via the redirect URL) an auth token. (Authentication is not to be confused with authorisation, which is a task for the back end and involves verifying the JWT against its private key.)

Ultimately, our handleLogin() function returns the parsed JWT, wrapped in a promise.

If you want to know more about the mechanics of this flow, head over here.

Adding our app logic 🔗

Now create a file named app.js and populate it with the following, to bring in the two exports we created in auth.js:

import { auth0Client, clientId, handleLogin } from './auth'

Next let's add some references to our container elements, and a reference to our back end domain.

const elements = { loginOrLogout: document.querySelector('#login-or-logout'), data: document.querySelector('#data') }; const backendDomain = 'http://localhost:3001';

Next, let's establish the login status, by calling the handleLogin() export from auth.js, assigning the return value to a variable. (Remember this function returns a promise, so we need to await its resolved value.)

//ascertain login status const loggedIn = await handleLogin();

Now, based on the login state, let's add either a login or logout button to our paragraph contianer:

//add login or logout button const btn = document.createElement('button'); btn.textContent = !loggedIn ? 'Login' : 'Logout'; elements.loginOrLogout.appendChild(btn);

Now let's add a click event handler to our button, which processes login or logout, depending on current login status:

btn.onclick = async () => !loggedIn ? (await auth0Client).loginWithRedirect() : (await auth0Client).logout({ clientId, logoutParams: {returnTo: location.protocol+'//'+location.host} });

Our handler calls one of two methods on the Auth0 client: loginWithRedirect() if the user isn't logged in, else logout() if they are, the latter being passed our client ID and a URL to return to (this must be a URL we specified earlier when setting things up on the Auth0 side.)

Testing the login flow 🔗

Now head back to the browser, refresh, and you should see a login button and a heading. Click the login button!

If we've set everything up correctly, you should be redirected to a login page hosted on Auth0's website.

Though we won't cover it in this tutorial, it's possible to customise the look of the login page. In Auth0, just head to Branding in the left nav. It's also possible to host it on your own domain, on paid accounts.

Now login with the credentials you set earlier when making a dummy account in Auth0. The first time you login, you'll be asked to confirm you wish to authorise our app to access your account. Accept, and you should then find you're redirected back to your front end - only this time you'll see a logout button not a login button, indicating we're logged in!

Fetching data from our back end 🔗

The last thing we need to do is fetch data from our back end in our front end.

Earlier we set up two routes, one private and one public. The idea is we'll fetch private data if we're logged in, or public data if we're not. The private route will need the JWT passing to it, so it can validate (via our withAuth middleware) that the JWT is valid.

In app.js, below our button click handler, add the following:

//get public and private data (the latter will fail if not logged in!) const data = loggedIn ? await fetch(backendDomain+'/private', { headers: { Authorization: 'Bearer '+loggedIn?.token } }) : await fetch(backendDomain+'/public'); if (data.status == 401) alert('Not authorised!'); else { elements.data.textContent = JSON.stringify(await data.json()); }

So we fetch public or private data, based on login status. We then check for a 401 unauthorised response, and alert the user accordingly. Otherwise, we display the data.

Try it! Refresh the page (you should still be logged in) and you should see the stringified JSON of the private data showing (bank details). Hit the logout button, and you'll be redirected back to your app, but this time the public data will show!

Checking that authorisation enforcement works 🔗

This is all very well, but how do we know for sure that the private data wouldn't show if the user weren't logged in?

Right now, our front end is explicitly fetching one or the other of public vs. private data, based on login. Couldn't a cheeky site user modify the request to go to the private route, even if they weren't logged in?

Let's check that won't succeed. It shouldn't, because they wouldn't have a valid JWT to send with the request.

Make sure you're logged out, then, in app.js, invert the ternary condition that governs which type of data we attempt to fetch. In other words, change:

const data = loggedIn ?

...to...

const data = !loggedIn ?

This means that, temporarily, our app will try to fetch the private data if they're not logged in, and public data if they are.

Refresh the page, and you should be met with an unequivical warning message saying nah-uh, not today!

---

And that's it! I hope you found this useful, and got a sense of how powerful and time-saving a robust, easy-to-use third-party auth provider like Auth0 can be.

In part 2, we'll look at integrating user permissions into our flow.

Did I help you? Feel free to be amazing and buy me a coffee on Ko-fi!