guides
Guide

How to Implement Auth on Hasura with Magic

Magic Staff · January 12, 2021

#Resources

See the live demo https://magic-hasura.vercel.app/login

The full codebase can be found here.

#Introduction

Magic is a passwordless authentication sdk that lets you plug and play different auth methods into your app. Magic supports passwordless email login via magic links, social login , and WebAuthn (a protocol that lets users authenticate with a hardware device such as a YubiKey or fingerprint on supported devices).

Hasura is a popular tool that gives you a GraphQL API on a Postgres database and comes with built-in authorization. Hasura also provides a dashboard where you can manage your data, making it easy for anyone to use. Together, Magic and Hasura are a powerful combo to easily setup your app, complete with authentication, a GraphQL server and a SQL database.

Part one of this tutorial will cover setup and integration of Magic with Hasura, and authentication. Part two will expand on part one with CRUD functionality in the form of a to-do list.

#App Flow

Once the user authenticates with Magic, the server validates the DID token returned by Magic, issues a JWT back to the client, which is then used to access the Hasura GraphQL API.

#File structure

├── README.md
├── components
│   ├── header.js
│   ├── layout.js
│   ├── loading.js
│   └── todo
│       ├── add-todo-form.js
│       ├── todo-item.js
│       └── todolist.js
├── lib
│   ├── cookies.js
│   ├── hooks.js
│   ├── magic.js
│   └── magicAdmin.js
├── package.json
├── pages
│   ├── _app.js (to apply the optional magic global css)
│   ├── _document.js (to inject magic css variables)
│   ├── api
│   │   ├── login.js
│   │   ├── logout.js
│   │   └── user.js
│   ├── index.js
│   ├── login.js
│   └── profile.js
├── public (images)
├── .env.local
└── yarn.lock

#Quick Start Instructions

Follow the tutorial below for the instructions on how to fill out .env.local.

$ git clone https://github.com/magiclabs/example-hasura.git
$ cd example-hasura
$ mv .env.local.example .env.local
$ yarn install
$ yarn dev

#.env.local File

NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_123...
MAGIC_SECRET_KEY=sk_123...
JWT_SECRET=your-32+-character-secret
NEXT_PUBLIC_HASURA_GRAPHQL_URL=your-graphql-api-server
SESSION_LENGTH_IN_DAYS=7
  • Note one: if you just want authentication, and not the to-do list, delete the /components/todo folder and the reference to <TodoList> in index.js.

  • Note two: the tutorial was built using Magic UI components. If you swap them out for your own custom CSS, you can also delete the _app.js and _document.js files, and @magiclabs/ui, framer-motion and rxjs from your package.json dependencies.

#Part One - Authentication & Authorization

#Magic Setup

Your Magic setup will depend on what login options you want. For just magic link login, minimal setup is required. For social logins, follow our documentation for configuration instructions.

Once you have social logins configured (if applicable), grab your API keys from the Magic dashboard. In .env.local enter your Publishable Key such as NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk1234567890 and your Secret Key such as MAGIC_SECRET_KEY=sk_1234567890.

#Hasura Setup

If you don’t have a Hasura account, you can create one at https://cloud.hasura.io/signup.

Once you sign in, click “Try a free database with Heroku”.

Sign up for or sign into your Heroku account. Then you’ll be redirected back to Hasura. Create your project.

In your Hasura Project dashboard, create an Admin Secret, which is the key to access your GraphQL API in admin mode.

Remaining in the Hasura dashboard, in the "Env Vars" tab you can now set up your JWT secret. Hasura has detailed documentation for all of the different (mostly optional) fields but for this you are going to enter the type, which specifies the token signing algorithm, and the key, which is a secret value Hasura will use to verify the token, being sent from the client in the Authorization header. The key will also be used to sign and verify the JWT by your backend.

{
  "type": "HS256",
  "key": "your-32+-character-jwt-secret"
}

Back in your IDE, in .env.local you can enter the key in the JWT_SECRET variable in the form of JWT_SECRET=your-32+-character-jwt-secret, as well as your GraphQL API such as NEXT_PUBLIC_HASURA_GRAPHQL_URL=your-hasura-app-url.

Once that’s complete, click “Launch Console” to enter Hasura's UI for your project, where you’ll manage data, permissions and can run GraphQL queries.

In the Data tab, click “Create Table”, and fill it out accordingly. For this tutorial, we'll use issuer, publicAddress and email, all of which you can get from Magic. issuer is the primary key. When you’re all set, click “Add Table”.

Next you're going to add user roles, controlling what a user can and cannot do. For this app, add a custom check, which ensures that only the issuer, specified in the X-Hasura-User-Id API header, is able to perform CRUD operations on their own data. With this in place, one user cannot send a query to delete another user’s information in the database. Additionally, if a user sends a query to get all users, the only data returned will be that person.

Set these custom permissions for insert, select, update and delete, or however is best for your app. Click "Toggle All" for Column Insert Permissions so a user can be created in the database when they sign up.

Setting user roles with custom permissions is one way to protect your Hasura API. Here are some others.

  • Allow-listing queries so only queries you predefined are able to be run by your API.
  • API limits so that users cannot overload your API with requests.
  • CORS domain settings so that your GraphQL API can only be accessed by requests from your domain.

Now that your .env.local file is filled out and Hasura has been configured properly, you can start building the application.

#Implementing Magic Auth

In login.js, call magic.auth.loginWithEmailOTP() which is what triggers the 6 digit OTP to be emailed to the user. Once the user enters the 6 digits in the Magic modal, send the DID token to your server endpoint at /api/login to complete the login.

async function handleLoginWithEmail(e) {
  try {
    e.preventDefault();
    if (!email) return;
    setDisabled(true);

    const didToken = await magic.auth.loginWithEmailOTP({ email });

    const res = await fetch('/api/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer ' + didToken,
      },
    });
    
    res.status === 200 && Router.push('/');
  } catch (error) {
    setDisabled(false);
    console.log(error);
  }
}

#Authenticating user (server-side)

In /api/login, verify the DID token, then create a JWT that will allow the user to access Hasura from client-side calls to the API. If this issuer doesn’t exist in the users table, create a new user, otherwise, return the JWT inside a cookie.

export default async function login(req, res) {
  try {
    const didToken = req.headers.authorization.substr(7);
    await magic.token.validate(didToken);
    const metadata = await magic.users.getMetadataByToken(didToken);

    let token = jwt.sign(
      {
        ...metadata,
        'https://hasura.io/jwt/claims': {
          'x-hasura-allowed-roles': ['user'],
          'x-hasura-default-role': 'user',
          'x-hasura-user-id': `${metadata.issuer}`,
        },
        exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * process.env.SESSION_LENGTH_IN_DAYS,
      },
      process.env.JWT_SECRET,
    );

    // Check if user trying to log in already exists
    let newUser = await isNewUser(metadata.issuer, token);

    // If not, create a new user in Hasura
    newUser && (await createNewUser(metadata, token));

    setTokenCookie(res, token);
    res.status(200).send({ done: true });
  } catch (error) {
    // handle error
  }
}

async function isNewUser(issuer, token) {
  let query = {
    query: `{
      users( where: {issuer: {_eq: "${issuer}"}}) {
        email
      }
    }`,
  };
  try {
    let data = await queryHasura(query, token);
    return data?.users.length ? false : true;
  } catch (error) {
    // handle error
  }
}

async function createNewUser({ issuer, publicAddress, email }, token) {
  let query = {
    query: `mutation {
      insert_users_one( object: { email: "${email}", issuer: "${issuer}", publicAddress: "${publicAddress}" }) {
        email
      }
    }`,
  };
  try {
    await queryHasura(query, token);
  } catch (error) {
    // handle error
  }
}

async function queryHasura(query, token) {
  try {
    let res = await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        Authorization: 'Bearer ' + token,
      },
      body: JSON.stringify(query),
    });
    let { data } = await res.json();
    return data;
  } catch (error) {
    // handle error
  }
}

To make sessions persist, you can rely on the JWT that’s stored in a cookie and automatically sent on each request to your server. The endpoint you send it to is /api/user which verifies the token.

export default async function user(req, res) {
  try {
    if (!req.cookies.token) return res.json({ user: null });
    let token = req.cookies.token;
    let user = jwt.verify(token, process.env.JWT_SECRET);
    user.token = token;
    res.status(200).json({ user });
  } catch (error) {
    res.status(200).json({ user: null });
  }
}

Client-side, you want to check if a user is logged in, and can create a custom hook in /lib/hooks.js that leverages Vercel’s SWR (stale while revalidate) library. This hook sends a request to the server with the JWT which verifies the authenticity of the token, and as long as you get a user object returned, you know it’s valid and to keep the user logged in.

const fetchUser = async (url) =>{
  const res = await fetch(url);
  const data = await res.json();
  return { user: data?.user || null };
}

export function useUser() {
  const { data, error } = useSWR('/api/user', fetchUser);
  if (error) return null;
  if (!data) return { loading: true };
  return data.user;
}

To complete the authentication, you need to allow users to log out. In /api/logout you clear the cookie containing the JWT and log the user out of their session with Magic if it's still valid.

  • Note: when a user logs in with Magic, they create a session between Magic and the user that's valid for 7 days by default (the session length is customizable by the developer through the Magic dashboard). This is doesn't matter in the context of this app, since we're relying on the JWT being issued by our server for session management, but it's always good to ensure the user is being logged out with Magic when logging out of your app as well.
export default async function logout(req, res) {
  try {
    if (!req.cookies.token) return res.status(401).json({ message: 'User is not logged in' });
    let token = req.cookies.token;
    let user = jwt.verify(token, process.env.JWT_SECRET);
    removeTokenCookie(res);

    // Add the try/catch because a user's session may have already expired with Magic (expired 7 days after login)
    try {
      await magic.users.logoutByIssuer(user.issuer);
    } catch (error) {
      console.log('Users session with Magic already expired');
    }
    res.writeHead(302, { Location: '/login' });
    res.end();
  } catch (error) {
    res.status(401).json({ message: 'User is not logged in' });
  }
}

At this point, your app is complete with authentication and authorization, connecting Magic with Hasura. When a user signs up, a new row is inserted in the users table. Additionally, the developer is able to control how long users stay logged in for, just by editing the cookie’s MAX_AGE in the cookie.js file, and the exp field in the JSON web token.

#Part Two - CRUD

Create a new todos table in Hasura. Each todo will have an id, the todo itself, an is_completed boolean field and a user_id. The user_id will be a foreign key that’s mapped to your users table, specifically the issuer value.

Allow a similar custom check in the Permissions tab for this table that you enabled for the users table. So a user is only able to access only their tasks.

To create a new todo, send a mutation query to your Hasura API with the JWT inside your Authorization header. Hasura also has the JWT secret, and will verify the token with that secret. If it matches, the user is authorized and the query is run.

const handleSubmit = async e => {
  e.preventDefault();
  if (!todo) return;
  await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      Authorization: 'Bearer ' + user?.token,
    },
    body: JSON.stringify(addTodoQuery),
  });
};

const addTodoQuery = {
  query: `mutation {
   insert_todos_one(object: {todo: "${todo}", user_id: "${user?.issuer}"}) {
     todo
   }
 }`,
};

To fetch all todos, similarly, send a query to your Hasura API with the JWT inside the Authorization header.

const getTodos = async () => {
  const getTodosQuery = {
    query: `{
        todos(where: {user_id: {_eq: "${user?.issuer}"}}, order_by: {is_completed: asc, id: asc}) {
          id
          todo
          is_completed
        }
      }`,
  };
  try {
    let res = await queryHasura(getTodosQuery);
    let { data, error } = await res.json();
    error ? console.log(error) : data.todos && setTodos(data.todos);
  } catch (error) {
    // handle error
  }
};

const queryHasura = async query => {
  try {
    let res = await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        Authorization: 'Bearer ' + token,
      },
      body: JSON.stringify(query),
    });
    return res;
  } catch (error) {
    // handle error
  }
};

To update a todo (toggle completed), it follows the same format as creating one.

const toggleCompleted = async (id, isCompleted) => {
  const toggleCompletedQuery = {
    query: `mutation {
       update_todos(where: {id: {_eq: "${id}"}, user_id: {_eq: "${user?.issuer}"}}, _set: {is_completed: "${isCompleted}"}) {
         returning {
           id
         }
       }
     }`,
  };
  await queryHasura(toggleCompletedQuery);
};

And again, deleting (one or many) todos follows the same structure.

// delete one todo
const deleteTodo = async todoId => {
  let deleteQuery = {
    query: `mutation {
       delete_todos(where: {id: {_eq: "${todoId}"}, user_id: {_eq: "${user?.issuer}"}}) {
         affected_rows
       }
     }`,
  };
  await queryHasura(deleteQuery);
};

// delete all completed todos
const clearCompleted = async () => {
  let clearCompletedQuery = {
    query: `mutation {
       delete_todos(where: {is_completed: {_eq: true}, user_id: {_eq: "${user?.issuer}"}})
       {
         returning {
           id
         }
       }
     }`,
  };
  await queryHasura(clearCompletedQuery);
};

And that’s it! You now have a full CRUD application using Magic for auth and Hasura for your GraphQL API and database. It’s a powerful and easy combination to get any application going quickly.

Let's make some magic!