GRANDcast.FM: User Auth & Podcast Subscribe Functionality

WL

William Lyon / December 11, 2020

11 min read

This is the second post in a series about building a podcast application using GRANDstack. Check out the first post, "Podcast Search GraphQL API With Neo4j And The Podcast Index" where we start building the GraphQL API and implement podcast search functionality.

Subscribe To Will's Newsletter

Want to know when the next blog post or video is published? Subscribe now!

In the previous post we started our GRANDstack podcast application by creating the GraphQL API and adding podcast search functionality using the Podcast Index API. After searching for podcasts the next thing our users will want to do is start subscribing to them, so in this episode we focus on allowing users to sign up and log in to our application and then implement "subscribe to podcast" functionality. We built this functionality on the Neo4j live stream which you can watch here:

User Authentication

Let's start with implementing user authentication. We'll need to allow users to sign up for our application, create a username and choose a password. We'll also need to enable users to log in to the application and generate an authorization token for them.

Setup#

We'll make use of two packages to enable user authentication:

npm install jsonwebtoken bcrypt

We'll also need to generate a random 256 bit secret to use for signing our tokens. By default jsonwebtoken will use the HS256 algorithm, we could also choose to use the RSA256 algorithm which uses public/private key pairs. We'll stick with the default HS256 algorithm and store the key as an environment variable by adding it to our .env file:

.env
JWT_SECRET=<RANDOM_256_BIT_SECRET_HERE>

Sign Up#

First, we'll add a new signup mutation to our GraphQL type definitions. This mutation field will take two arguments: username and password. Our new signup mutation will return an AuthToken object with a single string field called token.

schema.graphql
type Mutation {
  signup(username: String!, password: String!): AuthToken
}

type AuthToken {
  token: String!
}

Next, we implement the resolver function for the signup mutation field. We haven't created any resolvers yet because we've been taking advantage of the resolvers generated for us by neo4j-graphql.js but because we want to execute some custom logic in JavaScript that we can't express in Cypher we'll need to implement this resolver function.

Our signup resolver will take the user's password and hash it using bcrypt, store that hashed password and username in the database along with a randomly generated user id, then create a signed JWT that will include the username and id in the token's payload. The client application will then be able to use this auth token to make authenticated requests against our GraphQL API.

index.js
import jwt from 'jsonwebtoken';
import { compareSync, hashSync } from 'bcrypt';

const resolvers = {
  Mutation: {
    signup: (obj, args, context, info) => {
      args.password = hashSync(args.password, 10);
      const session = context.driver.session();

      return session
        .run(
          `CREATE (u:User) SET u += $args, u.id = randomUUID()
           RETURN u`,
          { args }
        )
        .then((res) => {
          session.close();
          const { id, username } = res.records[0].get('u').properties;

          return {
            token: jwt.sign({ id, username }, process.env.JWT_SECRET, {
              expiredIn: '30d'
            })
          };
        });
    }
  }
};

Now, if we execute the signup GraphQL mutation we can create new users and generate an authorization token for each user.

GraphQL
mutation {
  signup(username: "jennycat", password: "feedme") {
    token
  }
}

We can also take the token, paste it into the JWT debugger at jwt.io to decode the payload to see what values are encoded in the token. We should see the username and the random id generated for the user.

And if we check in Neo4j we'll see the User node created in the database with the username, generated user id, and hashed password all stored in the database.

New users are now able to sign up and register an account, but we also need to implement user login functionality so returning users can authenticate.

Login#

Implementing user login will be similar to how we implemented user sign up, but instead of creating the user node in the database we want to look it up by username in the database and compare the provided password with the hashed password stored in the database to make sure the user is providing the correct password in the GraphQL mutation argument.

First, we add a login field to the Mutation type in our GraphQL type definitions:

schema.graphql
type Mutation {
  signup(username: String!, password: String!): AuthToken
  login(username: String!, password: String!): AuthToken
}

And implement the resolver function for this mutation field in index.js. We'll look up the user by username and compare the provided password with the hashed password stored in the database. If the password doesn't match we throw an error, otherwise we generate a JWT authorization token.

index.js
const resolvers = {
  signup: (obj, args, context, info) => {...},
  login: (obj, args, context, info) => {
    const session = context.driver.session();

    return session
      .run(
        `MATCH (u:User {username: $username}))
        RETURN u LIMIT 1`,
        { username: args.username }
      )
      .then((res) => {
        session.close();

        const { id, username, password } = res.records[0].get('u').properties;
        if (!compareSync(args.password, password)) {
          throw new Error('Authorization Error');
        }

        return {
          token: jwt.sign({ id, username }, process.env.JWT_SECRET, {
            expiresIn: '30d'
          })
        };
      });
  }
};

Now let's test the login operation using the username and password we created in the user sign up mutation previously. If all is working we should see a new authorization token.

GraphQL
mutation {
  login(username: "jennycat", password: "feedme") {
    token
  }
}

Authenticated GraphQL Requests

Now that we're able to register new users and allow users to sign in, we can start to handle authenticated requests and take into account the identity of the user in our application. To make authenticated GraphQL requests the auth token will be added to the request headers as an authorization bearer token.

{
  "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImI3YjBmYmZkLTJjNzMtNDBiZS1hZGUxLTM1MjMzZWJhZDE5ZSIsInVzZXJuYW1lIjoiamVubnljYXQiLCJpYXQiOjE2MDc3NDI2MDUsImV4cCI6MTYxMDMzNDYwNX0._2xHiqrrwZR3ZXH9np9O2oWcx6iBsWd4OZLnd6DjtqY"
}

Find The Authenticated User#

First, let's add a GraphQL query field to return the currently authenticated user. To do this we'll need to figure out which user is making the authenticated GraphQL request.

Cypher Params#

We've already seen the powerful @cypher directive functionality of the neo4j-graphql.js library that allows us to define custom logic using Cypher in our GraphQL schema. We saw that any arguments of the GraphQL field are passed into the Cypher statement as Cypher parameters. Now, we will take advantage of the "Cypher Parameters" feature of the @cypher directive that allow us to pass values into the Cypher statement using the GraphQL context object.

Any values in the context.cypherParams object will be available in Cypher queries using the @cypher schema directive. This can be used to inject user specific information into these Cypher queries.

Let's update our instantiation of ApolloServer where we specify the value of the context object. Instead of an object, we can also define the context object using a function. This function is called on each request and is passed the request object, which will include the authorization header when making an authenticated GraphQL request. We'll grab the authorization token from the request header, validate it, and add the user id into the cypherParams object (remember that each token encodes the user id and username). This user id will then be available within the Cypher query when using the @cypher schema directive.

index.js
const server = new ApolloServer({
  context: ({ req }) => {
    const token = req?.headers?.authorization?.slice(7);
    let userId;

    if (token) {
      const decoded = jwt.verify(token, process.env.JWT_SECRET);
      userId = decoded.id;
    }
    return {
      cypherParams: { userId },
      driver,
      neo4jDatabase: process.env.NEO4J_DATABASE
    };
  },
  schema
});

Now, if an authorization token is specified in the GraphQL request, we can reference $cypheParams.userId in our Cypher query to refer to the currently authenticated user.

Let's add a GraphQL query field to return the current user, looking them up in the database by user id.

schema.graphql
type Query {
  currentUser: User
  @cypher(
    statement: """
    MATCH (u:User {id: $cypherParams.userId})
    RETURN u
    """
  )
}

type User {
  username: String
  id: ID!
}

We can now query for the currently authenticated user.

GraphQL
{
  currentUser {
    username
    id
  }
}

Podcast Subscribe#

Now that we're able to make authenticated requests to the API we can implement functionality that allows a user to subscribe to podcasts. A user should only be able to see their own podcast subscriptions and should not be able to subscribe podcasts for other users.

Subscribe Mutation#

We'll add one more GraphQL mutation field, subscribeToPodcast, which will take a single argument, the itunesId of a podcast, and subscribe the user to the podcast. However, we haven't yet stored any podcast data in the database - our podcast search functionality is calling the Podcast Index API and returning results but not updating the database. In the subscribeToPodcast mutation we want to make sure we have the podcast details to store in the database so we'll first make a call to the Podcast Index to fetch the podcast details, store the details in a Podcast node, then create a SUBSCRIBES_TO relationship connecting the User node and Podcast node. We also add a Podcast type to our GraphQL type definitions to represent these nodes.

schema.graphql
type Mutation {
  signup(username: String!, password: String!): AuthToken
  login(username: String!, password: String!): AuthToken
  subscribeToPodcast(itunesId: String!): Podcast
  @cypher(
    statement: """
    WITH toString(timestamp()/1000) AS timestamp
    WITH {
    `User-Agent`: 'GRANDstackFM',
    `X-Auth-Date`: timestamp,
    `X-Auth-Key`: apoc.static.get('podcastkey'),
    `Authorization`: apoc.util.sha1([apoc.static.get('podcastkey') + apoc.static.get('podcastsecret') + timestamp])
    } AS headers
    CALL apoc.load.jsonParams('https://api.podcastindex.org/api/1.0/podcasts/byitunesid?id=' + apoc.text.urlencode($itunesId), headers, '', '') YIELD value
    WITH value.feed AS feed
    MATCH (u:User {id: $cypherParams.userId})
    MERGE (p:Podcast {itunesId: $itunesId})
    SET p.title       = feed.title,
        p.link        = feed.link,
        p.description = feed.description,
        p.feedURL     = feed.url,
        p.image       = feed.artwork
    MERGE (u)-[:SUBSCRIBES_TO]->(p)
    RETURN p
    """
  )
}

type Podcast {
  itunesId: ID!
  title: String
  link: String
  feedURL: String
  description: String
  image: String
}

After a few users log in and start subscribing to podcasts our graph starts to look like this:

Get List of Subscribed Podcasts For The Authenticated User#

We also want to return the list of podcasts the authenticated user has subscribed to. To do this we add a subscribedPodcasts query field that will use the $cypherParams.userID value to find the User node in the database and any subscribed podcasts for the user.

schema.graphql
type Query {
  subscribedPodcasts: [Podcast]
  @cypher(
    statement: """
    MATCH (u:User {id: $cypherParams.userId})-[:SUBSCRIBES_TO]->(p:Podcast)
    RETURN p
    """
  )
}

We can now query for our subscribed podcasts - be sure to include our authorization token as an authorization header.

GraphQL
{
  subscribedPodcasts {
    title
    description
    itunesId
    feedURL
    image
  }
}

We've now implemented user registration, log in, and podcast subscriptions in our GraphQL API. There are a few more cases we'll need to address (making sure users can't register the same username, better error handling, handling password reset, etc) but we now have basic authentication in our GraphQL API. In the next episode we'll start parsing feed URLs and adding episodes and playlists to the graph.

Resources

Subscribe To Will's Newsletter

Want to know when the next blog post or video is published? Subscribe now!