Secure GitHub OAuth with cookies

Authenticating with GitHub using JWT, HttpOnly cookies and Apollo.

At Codegram we've just started working on a project where we need to authenticate the user using GitHub OAuth. What's that I hear you say 🤔. OAuth is an authentication protocol that allows a website or application to interact with another without sharing a password. You've probably used it or at least seen it a million times when logging in somewhere with the option to, for example, 'login with Facebook' or 'login with Twitter'. It basically allows the website you're logging into access your data on the other, for example see a list of Facebook friends or post a tweet on your behalf.

To get started, we first needed to create an OAuth app from our GitHub account. Go to developer settings > OAuth apps and create a new OAuth app. Fill in your apps name, url and redirect url (where you want to end up after the login). You will probably need to set up a couple, one for development (pointing to localhost) and one to production. You will be given a client ID and client secret for each OAuth app you create.

From your front end, when the user clicks to login, we want them to be redirected to the GitHub login url https://github.com/login/oauth/authorize?client_id=${clientId} where the clientID is taken from the OAuth app created in the previous step. More configuration options are available in the documentation for example via scopes to specify what data the website is requesting access to.

A user will be redirected to the typical 'login with' screen and once logged in with username and password, they will be redirected back to our app with a 'code' as a query param in the url. And tada 🎉, that's it! Unfortunately not, we've still got a while to go yet I'm afraid.

The code you just got is a temporary code which needs to be exchanged for an access token. This access token must then be sent with every request to interact with the GitHub API or with any other request which requires the user to prove they're authenticated. So let's send the temporary code to our back end and exchange it for an access token. We do this in the back end so as not to expose it on the browser. We want to keep everything secure here 🕵️‍.

We send a post request with the client ID, client secret and code as query parameters. We're using querystring.stringify to make our lives easier and generate the url query

const data = querystring.stringify({
  client_id: YOUR_CLIENT_ID,
  client_secret: YOUR_CLIENT_SECRET,
  code: CODE
})

And then pass it along to the axios call as so

axios.post('https://github.com/login/oauth/access_token', data)

This by default returns us something similar to 'access_token=e72e16c7e42f292c6912e7710c838347ae178b4a&token_type=bearer', so we can use querystring.parse to extract the access token we need

axios
  .post('https://github.com/login/oauth/access_token', data)
  .then(({ data }) => qs.parse(data).access_token)

Hurah we've got the access token! Now we just need to send it to the front end so that they can send it back on every request to show they're authenticated. But the whole reason we did all this in the back end was not to expose the token in the front 🤔So what we're going to do is send an encrypted version back, that only the back end knows how to decrypt. It's super simple and we're going to use a JSON web token for that, JWT for short.

import jwt from “jsonwebtoken”

const jwtToken = jwt.sign({
	token: YOUR_ACCESS_TOKEN
	},
	YOUR_SECRET_KEY
)

You'll notice that there's a second argument to the sign method. This is a secret that you define yourself and save in your environment variables. It will be used to encrypt the access token, and more importantly, it will be required to decrypt it again, meaning it would be almost impossible for anyone on the front end to decrypt it without knowing your secret.

So it's already looking pretty secure, right? Well we're about to make it even more secure! Instead of sending the JWT directly back to the client in the body of the response, we're going to send it as a HTTP only cookie 🍪. These types of cookies cannot be read by JavaScript and can only be read by the server that sent them. This will also make our life easier on the front end as a cookie is automatically sent back on every request to the same server that sent them.

In our project we're using apollo-client and apollo-server and we'll need to add some specific config to tell the client to send the cookies over.

import { InMemoryCache } from 'apollo-cache-inmemory'
import ApolloClient from 'apollo-client'
import { createHttpLink } from 'apollo-link-http'
import fetch from 'node-fetch'

const link = createHttpLink({
  uri: YOUR_GRAPHQL_ENDPOINT,
  credentials: 'include',
  fetch
})

export const apolloClient = new ApolloClient({
  link,
  cache: new InMemoryCache()
})

When we instantiate the client, along with all the required options, we must also pass credentials: 'include' to our HTTPLink. (Note that if your server and client are running on the same url, this should be passed as credentials: 'same-origin')

On the server, by default the 'Access-Control-Allow-Origin' header is set to '*', which tells it to accept requests from any url. Since we've set the option from the front to send all cookies, we will have an issue with CORS with this default setting. We need to specify to our server which URL to accept requests from and tell it to accept cookies by passing the following config to our instance of Apollo Server

import { ApolloServer } from 'apollo-server'

const server = new ApolloServer({
  cors: {
    origin: process.env.CLIENT_URL,
    credentials: true
  }

  // other config
})

Now, in the resolvers that require authentication (or this can be done in the context if authentication is required for all requests), we can read the cookies and check the user is authenticated.

async exampleResolver(_, __, { res }) {
	const cookies = res.headers.cookie
	if (!cookies) {
		res.status(401)
		throw new Error('no cookie')
	}
	const jwtCookie = cookies
		.split(';')
		.find(cookie => cookie.trim().startsWith('token='))
	const jwtToken = jwtCookie.split('=')[1]
	const decoded = jwt.verify(jwtToken, YOUR_SECRET_KEY)
	const token = decoded.token
	const user = await axios
		.get('https://api.github.com/user',
			{ headers:{
			Authorization: `token ${token}`,
			},
		})
	if (!user) {
		throw new Error('user not found')
	}

	// Do whatever now the user is authenticated

}

So what's happening here? The cookies come back in a header in the format 'token=TOKEN_VALUE' with each cookie separated by a ;. We get the JWT from the cookie named token and decode it with our secret, then make a call to GitHub with it to check we're legit and it works. Now we can do whatever it is we needed to do after confirming the user is who they say they are.

So that's a wrap! 🌮 We can now write our application and check that a user is properly authenticated!

Cover photo by Erol Ahmed