CS 312 - Software Development

CS 312 - Practical Eight

Goals

  • Learn how to use NextAuth.js to allow users to sign in to the client
  • learn about protecting routes on the server
  • Learn about sessions for maintaining authentication across multiple communications with the server

Background

In this practical I will walk you through the development of a very simple site that illustrates a fairly straightforward way to build authentication into your projects.

We are going to use a library called NextAuth.js, which makes the process worlds easier than it could be.

Why use NextAuth.js?

Asking the user for a password and comparing it to one stored in a database is not terribly hard. However, there is a lot more that we have to worry about. Getting security right is hard. This is a situation where DIY is not the way to go -- use something tested and trustworthy.

One issue is that we need to deal with sessions. Remember that when the client talks to the server, it is a single request for a resource, and then the reply from the server. It is not a conversation (this is a slight simplification, there can be some back and forth while fetching initial resources for a page). Without sessions, the server treats every request as coming from a stranger. In many cases, this is just fine. But, if we want secure data, we would have to send credentials with every request. Sessions allow the server to "remember" us -- which is to say, we can provide data (in the form of cookies) that the server can use to look up anything it needs.

There are libraries that can help us with sessions, but they are designed for full-blown servers. Since we are just using API routes, it is a little less straightforward to set things up.

The second issue is that storing passwords is risky. We need to find a way to store them securely. This means encrypting them, which we can do, but we are in an arms race with people working on ways to decrypt passwords. While you may decide that your website doesn't really have anything truly important (like credit card numbers), the truth is, many people re-use passwords. So, little DIY websites are the perfect place for hackers to target, because they likely have minimal security and they can get user credentials to try at higher stakes sites.

What does NextAuth.js offer us?

We won't use everything the library provides. I just want to get you up and running so that you can support user logins, protected data (server-side) and protected pages (client-side).

The library is fundamentally about providing a way for users to authenticate, to prove that they are who they say they are. Authorization, controlling which resources a user has access to, is still in our hands.

NextAuth.js provides us as developers a wide assortment of different ways to authenticate users. It will support passwords, if you want to go that route, or email based magic links, but most of the options are via OAuth providers, which include Apple, Google, GitHub, Facebook, Spotify, Facebook, Twitter, and many more. These are an increasingly popular, and I know that you have encountered 'Login with Facebook' or 'Login with GitHub' multiple times by now.

OAuth is designed to allow applications to request tokens on behalf of resource owners (users) so they can access those resources. Imagine you had written an application that made use of users' Google data (like their calendar, for example). Users shouldn't trust your app with their account passwords, but if they want to allow the application to have access to their data, they could log into Google through your application and allow your application to access their Google data, and then Google would then give your application a token that could be used to request the account data.

While originally designed to grant applications access to user's data, it quickly gained traction as a way to perform authentication where we ask the user to log into the service, but we don't use the token as anything more than proof that user is a valid Apple/Google/GitHub user.

As developers, this is great because we don't have to worry about managing user passwords. We get some form of token that we can use to identify unique users and the rest is not our problem.

As users, this is great because they have fewer passwords they have to take care of, and they can know that their credentials are stored with a large company that can devote resources to maintaining their data.

As for the providers? They get to further enmesh their users, and (bonus) know something about their activity when they aren't on the provider's site. This is, of course, a bit of a disadvantage for users. I've decided not to use services because the only access was through 'Login with Facebook'.

Prerequisites

  1. Click through to the GitHub classroom assignment to create your private repository. Then clone that newly created repository to your local computer as you have done previously.

  2. Execute npm install to install the dependencies. As a reference (i.e., I already did this for you), to install everything we want, I ran:

npm install next-auth next-connect
  1. Spin up the dev server with npm run dev

Sign up with Auth0

The first thing we need to do is to register as a developer with an OAuth provider. The process is roughly the same for all of them. You will register an 'application' with them, tell them how to identify instances of your application, and in turn they will give you a token or id (a big, long, unique string) that you can pass along with authentication requests.

For your projects, you can use any provider you like (or an assortment). Today, we are going to use Auth0, which is an independent provider.

Visit the site and sign up (appreciate the irony of the fact that they support sign in with GitHub, Google, and MS).

On the 'Getting Started' page, select 'Create Application'.

Give your application a good name and then select 'Regular Web Application' (we want both the front and back end).

On the next page, you can select the technology (Node), but it isn't essential.

Go to the Settings tab. On this page, there are three pieces of information that you want:

  • the domain
  • the client ID
  • the client secret

Environment variables

We want to make these values available in the app, so we are going to set them as environment variables. While we could set them in the shell, it is more convenient to write them down somewhere. Next.js supports .env files. These are files that Next loads when the server is run, making the values available to use in the code as process.env.something.

Important: These files are not included in the repository. They carry secrets that should not be posted publicly. For integration and production purposes, we can set these variables through other ways for TravisCI and Heroku.

Create a file called .env.development.local. Add the following lines:

AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_DOMAIN=
NEXTAUTH_URL=http://localhost:3000/

Fill in the missing values with the data you got from the Auto0 dashboard.

If you have already started the dev server, restart it now to load the variables.

Enable authentication in the app

To support authentication, we are going to add a new endpoint.

In the api directory (if you don't have one, add one inside of pages), add a new directory called auth. Inside of that, create a new file called [...nextauth].js.

You will recognize the square brackets as meaning that nextauth is a variable that will match any name or value. The new syntax is the three dots. This means that it will also match to hierarchies (i.e., deeper levels in the route).

In that file, include the following:

import NextAuth from 'next-auth';
import Providers from 'next-auth/providers';

const options = {
  providers: [
    Providers.Auth0({
      clientId: process.env.AUTH0_CLIENT_ID,
      clientSecret: process.env.AUTH0_CLIENT_SECRET,
      domain: process.env.AUTH0_DOMAIN,
    }),
  ],
};

export default (req, res) => NextAuth(req, res, options);

This creates a new endpoint that supports authentication with Auth0. If you want more providers, you can add them to this list.

Now that we have the endpoint installed, we should tell Auth0 about it.

A common feature of OAuth providers is that they have a way to set the "allowed callbacks". When your application makes its request for authentication, it includes a link to the page we would like the provider to return to.

For our development environment this will be http://localhost:3000/api/auth/callback/auth0. When you deploy, you will want to add another accepted endpoint, replacing localhost:3000 with your server name.

Go tp the Auth0 dashboard and scroll down until you find 'Allowed Callback URLs'. Add the above URL to the list (which is probably empty currently).

Logging in

On our page, we want to have some way to log in and log out.

I've left you a file called LoginStatus.js in the src directory. Go ahead and open it up. The contents look like this:

import { signIn, signOut, useSession } from 'next-auth/client';

export default function LoginStatus() {
  const [session] = useSession();

  return (
    <>
      {!session && (
        <>
          Not signed in <br />
          <button onClick={signIn}>Sign in</button>
        </>
      )}
      {session && (
        <>
          Signed in as {session.user.email} <br />
          <button onClick={signOut}>Sign out</button>
        </>
      )}
    </>
  );
}

This is, in truth, almost completely copied from the NextAuth.js quick start example on their front page.

When run, this should show a 'Sign in' button when the user is logged out, and display the user's email and a 'Sign out' button when they are logged in.

The important pieces here, however, are signIn, signOut and useSession.

I want to stress here that you are not tied to the buttons -- you just need to call the signIn function and the user will be presented with log in opportunities.

The less familiar thing is the useSession hook. Yes, this is a new hook.

In this case, the hook merely provides information. It returns a list with two items in it: session and loading (we aren't using loading here, so it isn't shown).

The loading variable is a boolean that tells us if the page is still processing the authentication. If it is true, we don't yet know if the user has successfully authenticated so we should not show anything sensitive yet.

The session value contains information about the logged in user, such as their email or other credentials. It will only be valid when we have a user already logged in.

Try this out. Add a LoginStatus component to Home. You should be able to log in and log back out. *If you are immediately logged in, it is probably because you have visited my status checker and are still logged in to Auth0.

Add a session provider

The NextAuth documentation suggests that when using the useSession hook, you should set up a Provider.

The Provider is a form of React Context. We haven't talked about Context yet. Context allows us to make values available to the entire app, bypassing props. This is typically used so that you don't have to pass values down long prop chains. It is also a dandy way to share values between pages!

The NextAuth.js Provider caches session data and shares it across pages to reduce the number of times useSession forces a check-in with the server.

We will add the Provider to __app.js so it available to all pages.

At the top of the page, import the Provider.

import { Provider } from 'next-auth/client';

The Provider is then wrapped around the root component so it is available to all of that component's children. Replace the existing return statement with this:

return (
  <Provider session={pageProps.session}>
    <Component {...pageProps} />
  </Provider>
);

Secure a page

Let's secure a page.

Create a new page in the pages directory. Call it secure.js.

Set this as the code for the page:

import Head from 'next/head';
import styles from '../styles/Home.module.css';

export default function SecurePage() {
  return (
    <div className={styles.container}>
      <Head>
        <title>Auth test</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>Authentication test site</h1>

        <p>This is a private page only logged in users can see</p>
      </main>
    </div>
  );
}

Now, we can add a Link to this page from the Home component.

<p>
  <Link href="./secure">
    <a>Link to secure page</a>
  </Link>
</p>

You will need to import Link as well.

import Link from 'next/link';

Of course, while the secure page alleges it is secure, it isn't yet.

Go back to secure.js. Import useSession.

import { useSession } from 'next-auth/client';

Add the hook to the top of the component's render function

const [session, loading] = useSession();

Add a line before the return statement that returns null if loading is true (i.e., we don't know if the user is logged in or not yet so don't show anything).

Add a second line that checks if loading is false AND the session is false (i.e., we are don't loading the session, and the user isn't logged in). If this is the case, return

Access Denied

.

Test out the page. I have sometimes noticed an odd glitch where it won't leave the loading state until i click away from the browser, so if you get a blank page, that could be what is going on.

Secure a route

We can't rely on our authentication method to keep data safe. After all, all of the JavaScript is loaded into the user's browser. Our user could just read the source code if they were that curious.

For properly private information, we are going to want to secure the server side.

We will create a new endpoint for /api/secret and use it to convey the real secret.

Create a new file in api called secret.js. Start it off like this:

import nc from 'next-connect';

const handler = nc().get(async (req, res) => {
  res.end();
});

export default handler;

This creates the endpoint and not much more.

Our pattern will be somewhat similar here to what we just did. We want to check the see if we have a session before we return anything. Unfortunately, we can't use hooks on the server-side, so we will use another function from NextAuth called getSession.

Import the function at the top of the file.

import { getSession } from 'next-auth/client';

On the first line of the endpoint, call getSession and pass it the request object.

const session = await getSession({ req });

Note that getSession returns a Promise, so we need to await the answer.

If session is valid, then the user is logged in and we can proceed, otherwise we should reject the request.

Add the following to the endpoint:

if (session) {
  res.status(200).json({ message: "Don't Panic!" });
} else {
  res.status(401); // not signed in, reject
}
res.end();

In this case, we are returning the same thing to all logged in users. However, since the user is logged in, the session object will contain some identifying information that you could use to look up data that is specific to that user.

Get the secret data

Of course, for our secret route to have any meaning, we need to be able to get to it.

Return to secure.js.

We are going to fetch the data and display it. At this point, I expect you know this pattern pretty well:

Add a new state variable to hold the secret

const [secret, setSecret] = useState();

Create a useEffect to fetch the secret (put it after the two conditionals so we don't fetch unless there is a session).

useEffect(() => {
  const getSecret = async () => {
    const response = await fetch('/api/secret');
    if (response.ok) {
      const data = await response.json();
      setSecret(data.message);
    }
  };
  getSecret();
}, [session]);

Display the secret in the component.

Test out your site. If all is working, you now have a tool for authenticating users, a way to restrict access to certain pages, and a way to associate authenticated users with data specific to them.

Not working? Some common issues...

Login state issues

You may stay "signed in" to Auth0 even though your server has restarted. To get a valid session you may need to logout and then login in again whenever you restart the server (which will happen every time you save any of the JS files).

If you have 3rd party cookies turned off this will not work

In this context, Auth0 is creating a 3rd party cookie. You may need to turn on that feature in your browser for this work.

Do a "hard" restart of the server before your final test run

While in theory the hot reload should work for most changes, some, e.g. changing the .env file, requires you to restart the server entirely. Shut it down and execute npm run dev once you have all the above code in place.

Secure at last?

An obvious question would be to ask if the application is now fully locked down. Sadly, the answer is no. It is better than it was, but all of our communications between the client and the server is done using HTTP instead of HTTPS. As a result, some bad actor could sniff the traffic and use the cookies to pretend to be the real user. We can only really trust the cookie data when we have end-to-end encryption between the client and the server.

Unfortunately, switching to HTTPS is not completely trivial. In order to implement a secure communication, the server needs to have an SSL certificate that the user would trust. You can generate your own, but then the user would have no reason to trust it (at least they shouldn't). So, the best solution is to get one from a trusted certificate authority that signs the certificate in a way that your browser can verify independently. Mostly, this means laying out some cash. There are some "free" options out there, but many of them are trials, or require you to use a particular hosting provider, or are just not intended to be used for production websites (for example, Heroku has "free" SSL... if you are not on the free tier).

Finishing Up

  1. Add and commit your changes to Github. Make sure to add and commit the new files you created.
  2. Submit your repository to Gradescope