guides
Guide

How to Implement Auth on Hasura with Magic

Magic Staff · January 12, 2021
How to Implement Auth on Hasura with Magic

#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

01├── README.md
02├── components
03│   ├── header.js
04│   ├── layout.js
05│   ├── loading.js
06│   └── todo
07│       ├── add-todo-form.js
08│       ├── todo-item.js
09│       └── todolist.js
10├── lib
11│   ├── cookies.js
12│   ├── hooks.js
13│   ├── magic.js
14│   └── magicAdmin.js
15├── package.json
16├── pages
17│   ├── _app.js (to apply the optional magic global css)
18│   ├── _document.js (to inject magic css variables)
19│   ├── api
20│   │   ├── login.js
21│   │   ├── logout.js
22│   │   └── user.js
23│   ├── index.js
24│   ├── login.js
25│   └── profile.js
26├── public (images)
27├── .env.local
28└── yarn.lock

#Quick Start Instructions

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

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

#.env.local File

01NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_123...
02MAGIC_SECRET_KEY=sk_123...
03JWT_SECRET=your-32+-character-secret
04NEXT_PUBLIC_HASURA_GRAPHQL_URL=your-graphql-api-server
05SESSION_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.

01{
02  "type": "HS256",
03  "key": "your-32+-character-jwt-secret"
04}

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.

Javascript
01async function handleLoginWithEmail(e) {
02  try {
03    e.preventDefault();
04    if (!email) return;
05    setDisabled(true);
06
07    const didToken = await magic.auth.loginWithEmailOTP({ email });
08
09    const res = await fetch('/api/login', {
10      method: 'POST',
11      headers: {
12        'Content-Type': 'application/json',
13        Authorization: 'Bearer ' + didToken,
14      },
15    });
16    
17    res.status === 200 && Router.push('/');
18  } catch (error) {
19    setDisabled(false);
20    console.log(error);
21  }
22}

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

Javascript
01export default async function login(req, res) {
02  try {
03    const didToken = req.headers.authorization.substr(7);
04    await magic.token.validate(didToken);
05    const metadata = await magic.users.getMetadataByToken(didToken);
06
07    let token = jwt.sign(
08      {
09        ...metadata,
10        'https://hasura.io/jwt/claims': {
11          'x-hasura-allowed-roles': ['user'],
12          'x-hasura-default-role': 'user',
13          'x-hasura-user-id': `${metadata.issuer}`,
14        },
15        exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * process.env.SESSION_LENGTH_IN_DAYS,
16      },
17      process.env.JWT_SECRET,
18    );
19
20    // Check if user trying to log in already exists
21    let newUser = await isNewUser(metadata.issuer, token);
22
23    // If not, create a new user in Hasura
24    newUser && (await createNewUser(metadata, token));
25
26    setTokenCookie(res, token);
27    res.status(200).send({ done: true });
28  } catch (error) {
29    // handle error
30  }
31}
32
33async function isNewUser(issuer, token) {
34  let query = {
35    query: `{
36      users( where: {issuer: {_eq: "${issuer}"}}) {
37        email
38      }
39    }`,
40  };
41  try {
42    let data = await queryHasura(query, token);
43    return data?.users.length ? false : true;
44  } catch (error) {
45    // handle error
46  }
47}
48
49async function createNewUser({ issuer, publicAddress, email }, token) {
50  let query = {
51    query: `mutation {
52      insert_users_one( object: { email: "${email}", issuer: "${issuer}", publicAddress: "${publicAddress}" }) {
53        email
54      }
55    }`,
56  };
57  try {
58    await queryHasura(query, token);
59  } catch (error) {
60    // handle error
61  }
62}
63
64async function queryHasura(query, token) {
65  try {
66    let res = await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
67      method: 'POST',
68      headers: {
69        'Content-Type': 'application/json',
70        Accept: 'application/json',
71        Authorization: 'Bearer ' + token,
72      },
73      body: JSON.stringify(query),
74    });
75    let { data } = await res.json();
76    return data;
77  } catch (error) {
78    // handle error
79  }
80}

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.

Javascript
01export default async function user(req, res) {
02  try {
03    if (!req.cookies.token) return res.json({ user: null });
04    let token = req.cookies.token;
05    let user = jwt.verify(token, process.env.JWT_SECRET);
06    user.token = token;
07    res.status(200).json({ user });
08  } catch (error) {
09    res.status(200).json({ user: null });
10  }
11}

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.

Javascript
01const fetchUser = async (url) =>{
02  const res = await fetch(url);
03  const data = await res.json();
04  return { user: data?.user || null };
05}
06
07export function useUser() {
08  const { data, error } = useSWR('/api/user', fetchUser);
09  if (error) return null;
10  if (!data) return { loading: true };
11  return data.user;
12}

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.
Javascript
01export default async function logout(req, res) {
02  try {
03    if (!req.cookies.token) return res.status(401).json({ message: 'User is not logged in' });
04    let token = req.cookies.token;
05    let user = jwt.verify(token, process.env.JWT_SECRET);
06    removeTokenCookie(res);
07
08    // Add the try/catch because a user's session may have already expired with Magic (expired 7 days after login)
09    try {
10      await magic.users.logoutByIssuer(user.issuer);
11    } catch (error) {
12      console.log('Users session with Magic already expired');
13    }
14    res.writeHead(302, { Location: '/login' });
15    res.end();
16  } catch (error) {
17    res.status(401).json({ message: 'User is not logged in' });
18  }
19}

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.

Javascript
01const handleSubmit = async e => {
02  e.preventDefault();
03  if (!todo) return;
04  await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
05    method: 'POST',
06    headers: {
07      'Content-Type': 'application/json',
08      Accept: 'application/json',
09      Authorization: 'Bearer ' + user?.token,
10    },
11    body: JSON.stringify(addTodoQuery),
12  });
13};
14
15const addTodoQuery = {
16  query: `mutation {
17   insert_todos_one(object: {todo: "${todo}", user_id: "${user?.issuer}"}) {
18     todo
19   }
20 }`,
21};

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

Javascript
01const getTodos = async () => {
02  const getTodosQuery = {
03    query: `{
04        todos(where: {user_id: {_eq: "${user?.issuer}"}}, order_by: {is_completed: asc, id: asc}) {
05          id
06          todo
07          is_completed
08        }
09      }`,
10  };
11  try {
12    let res = await queryHasura(getTodosQuery);
13    let { data, error } = await res.json();
14    error ? console.log(error) : data.todos && setTodos(data.todos);
15  } catch (error) {
16    // handle error
17  }
18};
19
20const queryHasura = async query => {
21  try {
22    let res = await fetch(process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL, {
23      method: 'POST',
24      headers: {
25        'Content-Type': 'application/json',
26        Accept: 'application/json',
27        Authorization: 'Bearer ' + token,
28      },
29      body: JSON.stringify(query),
30    });
31    return res;
32  } catch (error) {
33    // handle error
34  }
35};

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

Javascript
01const toggleCompleted = async (id, isCompleted) => {
02  const toggleCompletedQuery = {
03    query: `mutation {
04       update_todos(where: {id: {_eq: "${id}"}, user_id: {_eq: "${user?.issuer}"}}, _set: {is_completed: "${isCompleted}"}) {
05         returning {
06           id
07         }
08       }
09     }`,
10  };
11  await queryHasura(toggleCompletedQuery);
12};

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

Javascript
01// delete one todo
02const deleteTodo = async todoId => {
03  let deleteQuery = {
04    query: `mutation {
05       delete_todos(where: {id: {_eq: "${todoId}"}, user_id: {_eq: "${user?.issuer}"}}) {
06         affected_rows
07       }
08     }`,
09  };
10  await queryHasura(deleteQuery);
11};
12
13// delete all completed todos
14const clearCompleted = async () => {
15  let clearCompletedQuery = {
16    query: `mutation {
17       delete_todos(where: {is_completed: {_eq: true}, user_id: {_eq: "${user?.issuer}"}})
18       {
19         returning {
20           id
21         }
22       }
23     }`,
24  };
25  await queryHasura(clearCompletedQuery);
26};

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!