CS 312 Software Development

CS 312 - Practical Authentication

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. This example will only walk you through some of its features, so you are encouraged to consult the documentation for more details.

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. So, when you can, avoid collecting anything that could be risky to your users if it was shared.

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 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/whatever 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 in the past because the only access was through 'Login with Facebook'.

Prerequisites

  1. Visit the Practical Authentication page on Replit (or click the 'Start Project' link for Practical Authentication on our team page).

  2. Click through to the GitHub classroom assignment to create your private repository.

  3. Return to the assignment on Replit and go to the Version Control panel.

  4. Click the button to create a new git repository (not the one for connecting to an existing repo).

  5. Open the shell and follow the instructions on your GitHub repository for connecting an existing repository:

    1. git remote add origin repo-name where repo-name is the name of your repository (e.g., https://github.com/csci312-s21/practical-auth-ChristopherPAndrews.git)
    2. git branch -m main
    3. git push -u origin main

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_SECRET=
NEXTAUTH_URL=

Fill in the missing values with the data you got from the Auto0 dashboard. The NEXTAUTH_SECRET can be any string you want to put in there.

The NEXTAUTH_URL should be the URL of your website. Pop out the website preview and copy the URL from there.

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

You should also take a moment to pop out the running client to its own tab. The authorization process will temporarily leave your website, and that isn't supported in the Replit preview pane.

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,
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET
};

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.

This will be http://SOME_SERVER/api/auth/callback/auth0, where SOME_SERVER is the name of the server running your code. You can get this by looking at the URL of your application running on Replit. Use the URL from the popped out window, not the one from your code or the one above the preview pane.

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). When you deploy to Heroku, you will add another entry to this list to include the new allowed destination.

Logging in

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

I've left you a file called LoginWidget.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 LoginWidget() {
  const [ session ] = useSession()


  if (session){
    return (<div>
          <p>Signed in as {session.user.email} <button onClick={signOut}>Sign out</button> </p>
         </div>);
  }else{
    return (<div>
            <button onClick={signIn}>Sign in</button>
         </div>);

  }
}

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 LoginWidget 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 -- more on this shortly!

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 everywhere.

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 some content

Let's restrict some access in the client.

Create a new component in the components directory. Call it SecureItem.js.

Set this as the code for the component:

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

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


    return (
        <div>
            <p>{(session) ? `Welcome ${session.user.name}` : "You are not logged in"}</p>
        </div>
    )
}

You will note that this is not fundamentally different from LoginWidget -- we are using the session data to determine if the user is authenticated.

In this particular example, we are just showing the user a different message, but you could imagine providing a completely different interface based on whether or not the user was logged in.

Let's hook this up to the main page so we can see it in action. Return to index.js and add it to the page. To make it look nice, wrap it in a <div className={styles.card}></div>.

Test it out -- it should change the message based on whether or not you are logged in.

Secure an endpoint

As hopefully you realize, this approach should not be used to protect any actual secure data. Imagine that I had written the status checker website using the approach we took for Simplepedia, bundling the data for everyone in as a JSON import. I could certainly use the above mechanism to just only show you status information after you had logged in, and then only the data associated with your email address. However, while the interface wouldn't allow you to see other people's status, all of the data would still be sent to client, so anyone could just pop open the developer tools in their browser and read the data. So, that isn't a great approach.

For properly private information, we are going to want to secure the server side and only transmit it to the client when we are sure they are authorized to receive it.

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:`${session.user.name}: Don't Panic!`});
} else {
  res.status(401); // not signed in, reject
}
res.end();

Admittedly, this isn't the most exciting thing ever, but hopefully you can imagine that having access to the user's session data on the server side, opens up the door to fetching personal information out of the database. Of course, it doesn't even have to be data that is specifically tailored to the particular user, this is also a technique to only share data with users who are logged in.

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 SecureItem.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.

  useEffect(()=>{
    const getSecret = async ()=>{
      const response = await fetch("/api/secret");

      if (response.ok){
        const data = await response.json();
        setSecret(data.message);
      }else{
        setSecret(response.statusText);
      }
    
    };
    getSecret();
  }, [session]);

Display the secret in the component. Make a <p></p> and have it report "Server message: " and then the message from the server.

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

It appears that reporting the statusText does not work in Chrome. The tests will still pass, but you will not see the desired "Unauthorized" message.

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 (i.e., stop the Replit and restart it).

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

Going a little bit farther

As I said above, I encourage you to read through the NextAuth documentation if you want some additional functionality. However, there are two things that I suspect many of you will find useful for projects, but were more than I wanted to include in the practical.

Limiting who can log in

In the current model, anyone who can create an Auth0 account (so basically everyone) can log into the site. You can certainly look at the user data you get from the session and decide to still reject their requests for data, but then you need to cope with the users who are logged in but are not getting valid data back from the server. A better approach would be to somehow reject them from the outset when they try to log in.

NextAuth provides a fairly simple way to do this through a sign in callback. The basic premise is that we add a callback function that the server-side authentication will call when a user has successfully logged in. It is our chance to look at the user information and decide whether this is someone we want to be logging in or not.

Let's say, for example, you wanted to restrict users to only those who have a @middlebury.edu email address.

Go into pages/api/[...nextauth].js and add callbacks:{signIn: signIn} to the options object.

Now we need to write the signIn callback itself. The function will be passed three arguments: user, account, and profile and should return a Boolean value indicating whether or not the user is allowed in.

For our purposes, this function can be quite simple -- we just need to look at the user's email address and verify that it ends with @middlebury.edu.

 async function signIn(user) {
  return user.email.endsWith("@middlebury.edu");
}

If you didn't use your @middlebury.edu account when created your Auth0 account, then you will now be locked out. If you did, and want to see what it looks like to be locked out, just change the tests to some other string.

User database

If you are storing user specific data in your database, it makes sense to let NextAuth store login data in there as well so you can associate your data with theirs. This is particularly important if your users have roles with different access levels (such as the difference between normal users and admins).

Fortunately there are a few steps that we can follow so that NextAuth automatically uses our configured database.

First, we need to actually have a database and the libraries to access it...

Step 1 Install knex, sqlite3, and pg.

Step 2 Create a new knexfile.json. It should look something like this:


module.exports = {
  development: {
    client: "sqlite3",
    connection: {
      filename: "./auth.sqlite3",
    },
    useNullAsDefault: true,
  },

  test: {
    client: "sqlite3",
    connection: ":memory:",
    useNullAsDefault: true
  },

  production: {
    client: "pg",
    connection: process.env.DATABASE_URL,
    migrations: {
      directory: "./migrations"
    },
    ssl: {
      require: true,
      rejectUnauthorized: false
    }
  },
};

This should look very familiar as it is virtually the same as the one we used in the database practical. The only difference is that I've removed all references to seed files -- are don't have anything we are seeding the database with.

Step 3 Create the migration with npx knex migrate:make setup-auth-tables (the name of the migration isn't specific).

Step 4 This is the first step that is specific to authentication. NextAuth has a variety of tables it would like to maintain in the database, so we need a somewhat more complex migration to support it. I've done the work of converting the requirements into a knexfile for you:


exports.up = function(knex) {
    return knex.schema.createTable("accounts", table=>{
      table.increments("id").primary();
      table.string("compound_id", 255).notNullable();
      table.integer("user_id").notNullable();
      table.string("provider_type", 255).notNullable();
      table.string("provider_id", 255).notNullable();
      table.string("provider_account_id", 255).notNullable();
      table.text("refresh_token");
      table.text("access_token");
      table.timestamp("access_token_expires");
      table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
      table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
  
      table.unique("compound_id");
      table.index("provider_account_id");
      table.index("provider_id");
      table.index("user_id");
    })
    .createTable("sessions", table=>{
      table.increments("id").primary();
      table.integer("user_id").notNullable();
      table.timestamp("expires").notNullable();
      table.string("session_token", 255).notNullable();
      table.string("access_token", 255).notNullable();
      table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
      table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
  
      table.unique("session_token");
      table.index("access_token");
    })
    .createTable("users", table=>{
      table.increments("id").primary();
      table.string("name", 255);
      table.string("email", 255);
      table.timestamp("email_verified");
      table.string("image", 255);
      table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
      table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
  
      table.unique("email");
    })
    .createTable("verification_requests", table=>{
      table.increments("id").primary();
      table.string("identifier", 255).notNullable();
      table.string("token", 255).notNullable();
      table.timestamp("expires").notNullable();
      table.timestamp("expires").notNullable();
    })
}

exports.down = function(knex) {
  return knex.schema
    .dropTableIfExists('accounts')
    .dropTableIfExists('sessions')
    .dropTableIfExists('users')
    .dropTableIfExists('verification_requests');
}

Step 5 Run the migration npx knex migrate:latest

Step 6 Add the database location into the .env.development.local file. This will vary but if you copied the knexfile.js from above, it will be DATABASE_URL=sqlite:///auth.sqlite3.

Step 7 Add a database option to the NextAuth configuration (database: process.env.DATABASE_URL).

So, there are a fair number of steps involved, but most of them are not unique to the authentication problem. Note that you can also do this if you already have a database populated with data in it. You can skip the initial steps about installing the libraries and setting up the knexfile.js and skip right to adding the migration. This is one of the advantages of the migration approach. You will have different migrations for adding different things to the database and they can be applied individually.


Last updated 05/07/2021