Using Cookies to Authenticate Next.js + Apollo GraphQL Requests

Updated 1 month ago

In the spirit of over-complicating the hell out of my personal website, I spent time this weekend trying to solve one very small and seemingly-simple problem: can I make my statically-generated website know when I am viewing it?

Background and problem

I have a bookmarks page where I store helpful links. To add new links, I set up a workflow where I can text myself a url from anywhere. Here's the code to do this. New links get stored in Firebase, which triggers a cloud function to populate metadata for the url by scraping the web page. Here's the cloud function to do this. This flow is really great for saving links while I'm away from my computer.

But, when I'm on my laptop, two problems emerge:

  1. If I want to add a new bookmark, I can't just go to https://brianlovin.com/bookmarks and paste a link.
  2. If I do save a link by texting myself, it can often scrape incorrect metadata in the cloud function. Usually it's because people set their <title> tags like {actually useful content about the page} · Site Name and I don't want the Site Name included in my bookmarks list.

So what I want is:

  1. When visiting /bookmarks, determine if I am the one viewing the page.
  2. If so, disclose UI controls to add and edit bookmarks.
  3. Protect the adding/deleting mutations from being run by anyone else, since my GraphQL endpoint is exposed to the internet (another problem, another day).
Hiccups

The hiccups came when I tried to figure out how this should work with GraphQL (which I use on the backend to stitch together multiple third party API services - see code) and Next.js's recently-release Static Site Generation feature.

  • Right now the /bookmarks route is statically generated at build time. This means that every initial page view will assume an unauthenticated render. So I'll need to check for authentication after the JavaScript rehydrates the client.
  • I'm not interested in maintaining more database surface area for some kind of users record. This functionality is just for me. Firebase's authentication implementation was a pain, so I abandoned that path in favor of simple cookie authentication.
Useful context

First, some useful information that I dug up through while working on this problem:

  • Next.js automatically wraps API routes with a middleware to add a cookies object to the http request.
  • I found this helpful cookies middleware wrapper that will add a cookie helper function to all response objects in the backend. This will be used to set and nullify cookies.
Setting up the frontend

The client side of this project ended up being quite complex. Remember:

  • /bookmarks should be statically generated at build time, always rendering a "logged out" view.
  • When /bookmarks is loaded, it needs to mount with a pre-populated ApolloProvider cache to have access to the mutation and query hooks that come with @apollo/client.
  • After the page renders, it needs to kick off a query to determine if the viewer is me and progressively disclose UI controls if it is.

Fortunately, I found this comment in the Next.js discussion forum which explains how to implement a withApollo higher-order component that can instantiate itself with props from the static build phase.

I made some small modifications, but you can see the implementation here.

Next, we need to instantiate an ApolloClient during build time in getStaticProps:

// graphql/api/index.ts

const CLIENT_URL =
  process.env.NODE_ENV === 'production'
    ? 'https://brianlovin.com'
    : 'http://localhost:3000'

const endpoint = `${CLIENT_URL}/api/graphql`

const link = new HttpLink({ uri: endpoint })
const cache = new InMemoryCache()

export async function getStaticApolloClient() {
  return new ApolloClient({
    link,
    cache,
  })
}

Now, in any of our page routes we can use Apollo to fetch data at build time:

// pages/bookmarks.tsx

import { getStaticApolloClient } from '~/graphql/api'
import { gql } from '@apollo/client'

// ... component up here, detailed later

const GET_BOOKMARKS = gql`
  query GetBookmarks {
    bookmarks {
      id
      title
      url
    }
  }
`

export async function getStaticProps() {
  const client = await getStaticApolloClient()
  await client.query({ query: GET_BOOKMARKS })
  return {
    props: {
      // this hydrates the clientside Apollo cache in the `withApollo` HOC
      apolloStaticCache: client.cache.extract(),
    },
  }
}
Logging in

Because I'll want to add new links to my bookmarks from many devices, I'll need some way to programmatically set a cookie in the browser by "logging in."

The flow should be:

  • I enter a password on the client
  • The password gets sent as an argument to my GraphQL API in a login mutation
  • The login mutation resolver decides whether or not the password is correct. If it isn't, it rejects the request. If the password is correct, it sets a cookie on the response header and returns true.

Before I can do any of this, I'll need to ensure that my GraphQL mutations have access to cookies and a response object. We can add this information to the GraphQL context object in the server constructor:

// pages/api/graphql/index.ts

// https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
import cookies from './path/to/cookieHelper'
import typeDefs from './path/to/typeDefs'
import resolvers from './path/to/resolvers'
import { ApolloServer } from 'apollo-server-micro'

function isAuthenticated(req) {
  // I use a cookie called 'session'
  const { session } = req?.cookies
  
  // Cryptr requires a minimum length of 32 for any signing
  if (!session || session.length < 32) {
    return false
  }

  const secret = process.env.PASSWORD_TOKEN
  const validated = process.env.PASSWORD
  const cryptr = new Cryptr(secret)
  const decrypted = cryptr.decrypt(session)
  return decrypted === validated
}

function context(ctx) {
  return {
    // expose the cookie helper in the GraphQL context object
    cookie: ctx.res.cookie,
    // allow queries and mutations to look for an `isMe` boolean in the context object
    isMe: isAuthenticated(ctx.req),
  }
}


const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  context,
})

export const config = {
  api: {
    bodyParser: false, // required for Next.js to play nicely with GraphQL request bodies
  },
}

const handler = apolloServer.createHandler({ path: '/api/graphql' })

// attach cookie helpers to all response objects
export default cookies(handler)

The mutation:

// graphql/mutations/auth.ts

import { gql } from '@apollo/client'

export const LOGIN = gql`
  mutation login($password: String!) {
    login(password: $password)
  }
`

The resolver:

// graphql/resolvers/mutations/login.ts

import Cryptr from 'cryptr'

export function login(_, { password }, ctx) {
  const { cookie } = ctx

  const validator = process.env.PASSWORD
  if (password !== validator) return false

  const secret = process.env.PASSWORD_TOKEN
  const cryptr = new Cryptr(secret)
  const encrypted = cryptr.encrypt(password)

  // the password is correct, set a cookie on the response
  cookie('session', encrypted, {
    // cookie is valid for all subpaths of my domain
    path: '/',
    // this cookie won't be readable by the browser
    httpOnly: true,
    // and won't be usable outside of my domain
    sameSite: 'strict',
  })

  // tell the mutation that login was successful
  return true
 }

Next, let's log in from the client:

// pages/login.tsx

import * as React from 'react'
import { useRouter } from 'next/router'
import { useMutation } from '@apollo/client'
import { LOGIN } from '~/graphql/mutations/auth.ts'
import { withApollo } from '~/components/withApollo'

function Login() {
  const router = useRouter()
  const [password, setPassword] = React.useState('')

  const [handleLogin] = useMutation(LOGIN, {
    variables: { password },
    onCompleted: (data) => data.login && router.push('/'),
  })

  function onSubmit(e) {
    e.preventDefault()
    handleLogin()
  }

  return (
    <form onSubmit={onSubmit}>
      <input
        type="password"
        placeholder="password"
        onChange={(e) => setPassword(e.target.value)}
      />
    </form>
  )
}

// remember that withApollo wraps our component in an ApolloProvider, giving us access to use the `useMutation` and `useQuery` hooks in our component.
export default withApollo(Login)

So our flow should now work:

  1. I enter a password on the client
  2. The password gets sent as a variable to my mutation
  3. The mutation verifies the password, signs a session cookie, and returns it in the response headers to be saved in the browser
Validating my identity on the client

Okay, so now I have a signed cookie on my browser which will be used in all future requests to verify my identity. The next step is provide the client with some kind of isMe boolean that can be fetched from anywhere. We can write a small GraphQL mutation to provide this information:

// graphql/queries/isMe.ts

import { gql } from '@apollo/client'

export const IS_ME = gql`
  query IsMe {
    isMe
  }
`

Remember, we've already written an isMe helper into our GraphQL context object, so we can return that value in our resolver:

// graphql/resolvers/isMe.ts

export function isMe(_, __, { isMe }) {
  return isMe
}

Next, let's write our GraphQL query on the client to find out if it's me viewing the page:

// src/hooks/useAuth.tsx

import { IS_ME } from '~/graphql/queries/isMe.ts'
import { useQuery } from '@apollo/client'

export function useAuth() {
  const { data } = useQuery(IS_ME)

  return {
    isMe: data && data.isMe,
  }
}

With this helper hook, we can now start checking for isMe anywhere in the client:

// src/pages/bookmarks.tsx

import * as React from 'react'
import { useQuery } from '@apollo/client'
import BookmarksList from '~/components/Bookmarks'
import { GET_BOOKMARKS } from '~/graphql/queries'
import { useAuth } from '~/hooks/useAuth'
import { getStaticApolloClient } from '~/graphql/api'
import { withApollo } from '~/components/withApollo'
import AddBookmark from '~/components/AddBookmark'

function Bookmarks() {
  // cache-and network is used because after I add a new bookmark, other people will still be seeing the statically-served HTML created at build time. In this way, the user will see a page rendered _instantly_, and the client will kick off a network request to ensure it has the latest bookmarks data.
  const { data } = useQuery(GET_BOOKMARKS, { fetchPolicy: 'cache-and-network' })
  const { bookmarks } = data
  const { isMe } = useAuth()

  return (
    <div>
      <h1>Bookmarks</h1>
      {isMe && <AddBookmark />}
      {bookmarks && <BookmarksList bookmarks={bookmarks} />}
    </div>
  )
}

export async function getStaticProps() {
  const client = await getStaticApolloClient()
  await client.query({ query: GET_BOOKMARKS })
  return {
    props: {
      apolloStaticCache: client.cache.extract(),
    },
  }
}

export default withApollo(Bookmarks)
Adding bookmarks

Okay, so now I can progressively disclose UI on the client once the site knows it's me. But because my GraphQL endpoint is exposed to the internet, we'll need to make sure that random people can't write their own POSTs to maliciously save bookmarks.

Here's the mutation resolver on the backend checking the isMe flag set in the context object, some input validation, and then persisting the bookmark.

// graphql/resolvers/mutations/bookmarks.ts

import { URL } from 'url'
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
import firebase from '~/graphql/api/firebase'
import getBookmarkMetaData from './getBookmarkMetaData'

function isValidUrl(string) {
  try {
    new URL(string)
    return true
  } catch (err) {
    return false
  }
}

export async function addBookmark(_, { url }, { isMe }) {
  if (!isMe) throw new AuthenticationError('You must be logged in')
  if (!isValidUrl(url)) throw new UserInputError('URL was invalid')

  const metadata = await getBookmarkMetaData(url)

  const id = await firebase
    .collection('bookmarks')
    .add({
      createdAt: new Date(),
      ...metadata,
    })
    .then(({ id }) => id)

  return await firebase
    .collection('bookmarks')
    .doc(id)
    .get()
    .then((doc) => doc.data())
    .then((res) => ({ ...res, id }))
}
Conclusion

This is all a bit...complicated, to say the least. But when it all works, it actually works quite well! And as I incrementally add more mutation types, it should all Just Work™.

At the end of the day, the site gets all the benefits of super-fast initial page loads thanks to static generation at build time, with all the downstream client side functionality of a regular React application.

I hope the pseudocode above will help unblock anyone that is following a similar path as me, but just in case, here's the full pull request containing all the changes that eventually made this work. You'll notice I spent some time hacking in automatic type generation and hook generation using GraphQL Code Generator, and added some polish to the overall experience (like a /logout page which clears the cookie, in case I'm on a device I don't own).

Please don't hesitate to reach out with questions, I'd love to help! Otherwise, the Next.js discussions have been a fantastic resource for finding solutions to a lot of common problems.

Good luck!

A small favor

Was anything I wrote confusing, outdated, or incorrect? Please let me know! Just write a few words below and I’ll be sure to amend this post with your suggestions.

Follow along

If you want to know about new posts, add your email below. Alternatively, you can subscribe with RSS.