How to Implement FaunaDB with Magic Authentication
#Introduction
In this article, we utilize a variety of cutting-edge tools popular with the Jamstack crowd.
We're going to show you how to secure a FaunaDB-powered Jamstack application with Magic's simple-to-use passwordless auth. The example app we're building is a basic TodoMVC interpretation (like the one pictured below) using Next.js, a best-in-class React framework. We'll deploy on Vercel, which offers integrated, configuration-less infrastructure for Next.js apps. The primary focus of this tutorial is implementing FaunaDB with a Magic authentication layer, so we'll be breezy with the higher-level Next.js, React, and CRUD concepts. Before going further we suggest you explore the Next.js interactive learning guide, our Next.js deployment example and FaunaDB's getting started documentation. You will also require basic knowledge of React Hooks, Vercel's SWR library, and HTTP cookies.
- View the live demo.
- See the completed code.
#1. Setting up FaunaDB
First, make sure you've registered a FaunaDB account. Once you're logged in, create a new database and name it todomvc
.
After clicking Save, you are redirected to the Database Overview page. Next, select Shell from the sidebar. From here, you can execute arbitrary Fauna Query Language (FQL) queries to populate your database schema.
For our purposes, we'll need to run a few initial queries. But first, a brief introduction into how data modeling works in FaunaDB. The most important central concept is Collections. We use Collections to define our database schema, similar to tables in other databases. Collections hold Documents, which are akin to records. To enable the organization and retrieval of Documents (other than by direct reference), we use Indexes. If you have a background in SQL, you may find this resource helpful in understanding the similarities and differences to FQL.
#Step 1.1: Creating a database schema
First, we need to define a "users" Collection. You can copy/paste the following query into the FaunaDB Dashboard Shell and click Run Query.
01CreateCollection({ name: "users" });
Then, we'll define a "todos" Collection, with create
permissions specified for Documents in the "users" Collection we created before.
01CreateCollection({ name: "todos", permissions: { create: Collection("users") } });
#Step 1.2: Making our database searchable
Finally, we'll add some Indexes to search our data later on.
01CreateIndex({
02 name: "users_by_email",
03 source: Collection("users"),
04 terms: [{ field: ["data", "email"] }],
05 unique: true
06});
07CreateIndex({
08 name: "all_todos",
09 source: Collection("todos"),
10 permissions: { read: Collection("users") }
11});
12CreateIndex({
13 name: "todos_by_completed_state",
14 source: Collection("todos"),
15 terms: [{ field: ["data", "completed"] }],
16 permissions: { read: Collection("users") }
17});
Now that our FaunaDB database is configured, we can start building our app! 💪
#2. Cloning the example code
We've prepared a partial implementation to guide this tutorial, you can clone it by running the following command in your preferred local shell:
01git clone --single-branch --branch boilerplate https://github.com/magiclabs/example-nextjs-faunadb-todomvc.git
We'll also take this opportunity to install our dependencies:
01cd example-nextjs-faunadb-todomvc
02yarn install # or `npm install`
All the files we need are already in place, just missing some code blocks that we'll fill in as we go!
#3. Setting up local environment variables
There are four environment variables required by this project:
NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY
MAGIC_SECRET_KEY
(consider this top secret information!)FAUNADB_SECRET_KEY
(this is also top secret information!)ENCRYPTION_SECRET
(once again, top secret!)
We'll set these variables in a place our app can consume them: .env.local
. You can generate this file with the following shell command:
01cp .env.local.example .env.local
Now, your .env.local
file should look like this:
01# .env.local
02
03# We’ll use the NEXT_PUBLIC_ prefix
04# to expose this variable to the browser.
05# See: https://nextjs.org/docs/basic-features/environment-variables
06NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=
07
08MAGIC_SECRET_KEY=
09
10FAUNADB_SECRET_KEY=
11
12# We'll use this to encrypt session cookies
13# for our application!
14ENCRYPTION_SECRET=this-is-a-secret-value-with-at-least-32-characters
Next, we'll populate these variables.
#Step 3.1: Getting your Magic API keys
The first two variables—NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY
and MAGIC_SECRET_KEY
—are API keys related to Magic. We'll get these from the Magic Dashboard.
Assign the key starting with pk_live_...
to the NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY
variable and the key starting with sk_live_...
to MAGIC_SECRET_KEY
.
01# .env.local
02
03# We’ll use the NEXT_PUBLIC_ prefix
04# to expose this variable to the browser.
05# See: https://nextjs.org/docs/basic-features/environment-variables
06NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_live_...
07
08MAGIC_SECRET_KEY=sk_live_...
09
10FAUNADB_SECRET_KEY=
11
12ENCRYPTION_SECRET=this-is-a-secret-value-with-at-least-32-characters
#Step 3.2: Getting your FaunaDB access secret
The last environment variable, FAUNADB_SECRET_KEY
, can be generated from the Security page of your database.
Click Save to show your FaunaDB access secret (which should look like fnRB4Ld...
)—copy/paste this into .env.local
—it's the last time you'll ever see it from the FaunaDB dashboard!
01# .env.local
02
03# We’ll use the NEXT_PUBLIC_ prefix
04# to expose this variable to the browser.
05# See: https://nextjs.org/docs/basic-features/environment-variables
06NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_live_...
07
08MAGIC_SECRET_KEY=sk_live_...
09
10FAUNADB_SECRET_KEY=fnRB4Ld...
11
12ENCRYPTION_SECRET=this-is-a-secret-value-with-at-least-32-characters
#4. Authenticating FaunaDB using Magic passwordless login
#Step 4.1: Authenticate with Magic client-side
Open pages/login.js
, you'll see we've created a basic HTML form with React, we're just missing the Magic implementation.
01// pages/login.js
02
03...
04
05export default function Login() {
06 ...
07
08 const login = useCallback(async (email) => {
09 if (isMounted() && errorMsg) setErrorMsg(undefined)
10
11 try {
12 /* Step 4.1: Generate a DID token with Magic */
13
14 /* Step 4.4: POST to our /login endpoint */
15
16 // const res = await fetch()
17
18 // if (res.status === 200) {
19 // // If we reach this line, it means our
20 // // authentication succeeded, so we'll
21 // // redirect to the home page!
22 // router.push('/')
23 // } else {
24 // throw new Error(await res.text())
25 // }
26 } catch (err) {
27 console.error('An unexpected error occurred:', err)
28 if (isMounted()) setErrorMsg(err.message)
29 }
30 }, [errorMsg])
31
32 ...
33}
Search for /* Step 4.1: Generate a DID token with Magic */
. Here, we'll add a call to Magic SDK's loginWithMagicLink
method to send a magic link email to the user. The method resolves to a DID token once the user has confirmed the login request from their inbox—all without the complication and insecurity of passwords!
01// pages/login.js
02
03...
04
05export default function Login() {
06 ...
07
08 const login = useCallback(async (email) => {
09 if (isMounted() && errorMsg) setErrorMsg(undefined)
10
11 try {
12 /* Step 4.1: Generate a DID token with Magic */
13 const magic = new Magic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY)
14 const didToken = await magic.auth.loginWithMagicLink({ email })
15
16 /* Step 4.4: POST to our /login endpoint */
17
18 // const res = await fetch()
19
20 // if (res.status === 200) {
21 // // If we reach this line, it means our
22 // // authentication succeeded, so we'll
23 // // redirect to the home page!
24 // router.push('/')
25 // } else {
26 // throw new Error(await res.text())
27 // }
28 } catch (err) {
29 console.error('An unexpected error occurred:', err)
30 if (isMounted()) setErrorMsg(err.message)
31 }
32 }, [errorMsg])
33
34 ...
35}
#Step 4.2: Validating the user's identity server-side
One of the brilliant features of Next.js is the ability to deploy server-side routes as serverless functions. We'll take advantage of this feature to validate the DID token received in the previous step. Open pages/api/login.js
, where we'll be implementing a POST
method to handle our server-side logic.
01// pages/api/login.js
02
03...
04
05const handlers = {
06 POST: async (req, res) => {
07 /* Step 4.2: Validate the user's DID token */
08
09 /* Step 4.3: Get or create a user's entity in FaunaDB */
10
11 // Once we have the user's verified information, we can create
12 // a session cookie! As this is not the primary topic of our tutorial
13 // today, we encourage you to explore the implementation of
14 // `createSession` on-your-own to learn more!
15 // await createSession(res, { ... })
16
17 res.status(200).send({ done: true })
18 },
19}
20
21...
First, we need to verify the user's DID token, which we'll expect as the Authorization
header of the HTTP request in a Bearer {token}
format. Search for /* Step 4.2: Validate the user's DID token */
to add this logic.
01// pages/api/login.js
02
03...
04import { Magic } from '@magic-sdk/admin';
05
06const handlers = {
07 POST: async (req, res) => {
08 /* Step 4.2: Validate the user's DID token */
09 const magic = new Magic(process.env.MAGIC_SECRET_KEY)
10 const didToken = magic.utils.parseAuthorizationHeader(req.headers.authorization)
11 magic.token.validate(didToken);
12 const { email, issuer } = await magic.users.getMetadataByToken(didToken)
13
14 /* Step 4.3: Get or create a user's entity in FaunaDB */
15
16 // Once we have the user's verified information, we can create
17 // a session cookie! As this is not the primary topic of our tutorial
18 // today, we encourage you to explore the implementation of
19 // `createSession` on-your-own to learn more!
20 // await createSession(res, { ... })
21
22 res.status(200).send({ done: true })
23 },
24}
25
26...
#Step 4.3: Modifying users in FaunaDB and issuing sessions
Once we've retrieved the user information from the DID token (validating it in the process), we'll need a way to represent our user in FaunaDB. To start, we need to create and share our FaunaDB client instance(s). The boilerplate code has done this for you, take a peek at lib/faunadb.js
.
01// lib/faunadb.js
02
03import faunadb from 'faunadb';
04
05/** Alias to `faunadb.query` */
06export const q = faunadb.query;
07
08/**
09 * Creates an authenticated FaunaDB client
10 * configured with the given `secret`.
11 */
12export function getClient(secret) {
13 return new faunadb.Client({ secret });
14}
15
16/** FaunaDB Client configured with our server secret. */
17export const adminClient = getClient(process.env.FAUNADB_SECRET_KEY);
There are three export
statements in this file, each is notable:
q
is an alias tofaunadb.query
; we like to have it as a shortcut because we'll reference it quite often.getClient(secret) { ... }
returns a dynamically-created FaunaDB client instance. This will be used later on to execute queries in the context of a user's authenticated session.adminClient
is a static FaunaDB client instance authenticated by ourFAUNADB_SECRET_KEY
environment variable. This enables us to execute to root-level queries, such as creating a new user.
Now, let's shift our focus to lib/models/user-model.js
.
01// lib/models/user-model.js
02
03import { q, adminClient, getClient } from '../faunadb';
04
05export class UserModel {
06 async createUser(email) {
07 /* Step 4.3: Create a user in FaunaDB */
08 }
09
10 async getUserByEmail(email) {
11 /* Step 4.3: Get a user by their email in FaunaDB */
12 }
13
14 async obtainFaunaDBToken(user) {
15 /* Step 4.3: Obtain a FaunaDB access token for the user */
16 }
17
18 async invalidateFaunaDBToken(token) {
19 /* Step 4.3: Invalidate a FaunaDB access token for the user */
20 }
21}
There are four methods in our UserModel
requiring implementation. Let's tackle these one-by-one.
#UserModel.createUser(email)
Creating a new user in our FaunaDB database requires defining a Document in the "users" Collection. Check this out:
01// lib/models/user-model.js
02
03import { q, adminClient, getClient } from '../faunadb'
04
05export class UserModel {
06 async createUser(email) {
07 /* Step 4.3: Create a user in FaunaDB */
08 return adminClient.query(q.Create(q.Collection("users"), {
09 data: { email },
10 }))
11 }
12
13 ...
14}
First, importantly, we are formulating this query with the adminClient
object. We populate the call to adminClient.query(...)
with a FaunaDB expression, which we compose using the q
object (reminder: q
is just a shortcut to the global faunadb.query
object).
q.Create
returns an expression to create a new FaunaDB Document. It's first argument—q.Collection("users")
—targets the "users" Collection for the operation. The second argument describes the initial properties of the Document. Every document in FaunaDB contains a data
key, which here represents the Document's... well... initial data! For our use-case, we only need to save the user's email
. 💌
#UserModel.getUserByEmail(email)
We also want to be able to access existing user Documents in our database. For this, we'll implement getUserByEmail
.
01// lib/models/user-model.js
02
03import { q, adminClient, getClient } from '../faunadb'
04
05export class UserModel {
06 ...
07
08 async getUserByEmail(email) {
09 /* Step 4.3: Get a user by their email in FaunaDB */
10 return adminClient.query(
11 q.Get(q.Match(q.Index("users_by_email"), email))
12 ).catch(() => undefined)
13 }
14
15 ...
16}
Again, we're executing this query with root-level permissions using the adminClient
object. This time, though, we are making use of q.Get
to generate the query expression. q.Get
does just what you think—it "gets" a Document! But... how do we tell q.Get
about the Document we're searching for? Earlier in the tutorial we created a few FaunaDB Indexes. There's one in particular that's helpful here:
01# You don't have to do anything with this,
02# we're just surfacing it again for context!
03
04CreateIndex({
05 name: "users_by_email",
06 source: Collection("users"),
07 terms: [{ field: ["data", "email"] }],
08 unique: true
09});
We'll leverage the "users_by_email" Index to pick the data we need. From its implementation we can infer the following: "users_by_email" searches Documents in the "users" Collection based on the value of data.email
. Additionally, the value of our search term must be unique!
Now we can express our search using q.Match
, which (as the name suggests), matches Document(s) for a given Index. We provide q.Index("users_by_email")
as the first argument, followed by our search term: the user's email
. If we don't find a Document matching this user, FaunaDB will raise an error. For simplicity's sake, in the event of an error, we'll resolve undefined
.
#UserModel.obtainFaunaDBToken(user)
Once we've either created or retrieved a FaunaDB user, we need a way to authorize them to make further requests (like create todos and mark todos as completed). We'll implement obtainFaunaDBToken
to provide an access token to the authenticated user.
01// lib/models/user-model.js
02
03import { q, adminClient, getClient } from '../faunadb'
04
05export class UserModel {
06 ...
07
08 async obtainFaunaDBToken(user) {
09 /* Step 4.3: Obtain a FaunaDB access token for the user */
10 return adminClient.query(
11 q.Create(q.Tokens(), { instance: q.Select("ref", user) }),
12 ).then(res => res?.secret).catch(() => undefined)
13 }
14
15 ...
16}
Here, we make use of FaunaDB's built-in authentication powers using q.Tokens
to create a database access token scoped to the authenticated user. Just like users, todo items, and any other data we model in FaunaDB, access tokens are Documents, so we use q.Create
once again. As the second argument to q.Create
, we provide a structure containing a reference to our user Document. With q.Select
, we pick the "ref"
field from the user object. Every Document in FaunaDB contains a "ref"
field that can later be used to query for that specific Document (using—you guessed it—q.Get
!).
#UserModel.invalidateFaunaDBToken(token)
The last piece of functionality we'll need to build in our UserModel
is a way to invalidate previously generated FaunaDB tokens (effectively logging users out). Here's our implementation:
01// lib/models/user-model.js
02
03import { q, adminClient, getClient } from '../faunadb'
04
05export class UserModel {
06 ...
07
08 async invalidateFaunaDBToken(token) {
09 /* Step 4.3: Invalidate a FaunaDB access token for the user */
10 await getClient(token).query(q.Logout(true))
11 }
12}
This is the first time we're using the getClient
function to build a FaunaDB client instance dynamically instead of using our root-permissioned adminClient
. The token
parameter is the unencrypted result of UserModel.obtainFaunaDBToken
. Using the FaunaDB client associated to a specific user will scope all queries based on that user's permissions and authentication status. We only require a very simple FaunaDB expression here: q.Logout(true)
, which invalidates the user's FaunaDB session and burns any associated access tokens.
Now that we've fully implemented our UserModel
, we can shift our focus back to pages/api/login.js
. Let's implement the next step of our handler! Search for /* Step 4.3: Get or create a user's entity in FaunaDB */
and add the following:
01// pages/api/login.js
02
03...
04
05const handlers = {
06 POST: async (req, res) => {
07 /* Step 4.2: Validate the user's DID token */
08 const magic = new Magic(process.env.MAGIC_SECRET_KEY)
09 const didToken = magic.utils.parseAuthorizationHeader(req.headers.authorization)
10 const { email, issuer } = await magic.users.getMetadataByToken(didToken)
11
12 /* Step 4.3: Get or create a user's entity in FaunaDB */
13 const userModel = new UserModel()
14 // We auto-detect signups if `getUserByEmail` resolves to `undefined`
15 const user = await userModel.getUserByEmail(email) ?? await userModel.createUser(email);
16 const token = await userModel.obtainFaunaDBToken(user);
17
18 // Once we have the user's verified information, we can create
19 // a session cookie! As this is not the primary topic of our tutorial
20 // today, we encourage you to explore the implementation of
21 // `createSession` on-your-own to learn more!
22 await createSession(res, { token, email, issuer })
23
24 res.status(200).send({ done: true })
25 },
26}
27
28...
Let's discuss what's happening here. First, we create an instance of UserModel
. Then, we try to query for an existing user Document via UserModel.getUserByEmail(email)
. If the user does not already exist (i.e.: result is undefined
), we create one with UserModel.createUser(email)
. Finally, we obtain a FaunaDB access token via UserModel.obtainFaunaDBToken(user)
to encode as part of the user's encrypted server-side session.
In the encrypted session, notice that we're passing token
, email
, and issuer
as the session data. We'll need these pieces of information to later authorize the user for TodoMVC CRUD actions, as well as for logging the user out.
This marks the completion of our server-side login implementation, now we can call this API from the client-side!
#Step 4.4: Authenticating users end-to-end
Open pages/login.js
, which we left partially implemented before. Find /* Step 4.4: POST to our /login endpoint */
and add a fetch
call to POST
the user's DID token to our newly-minted /api/login
endpoint.
01// pages/login.js
02
03...
04
05export default function Login() {
06 ...
07
08 const login = useCallback(async (email) => {
09 if (isMounted() && errorMsg) setErrorMsg(undefined)
10
11 try {
12 /* Step 4.1: Generate a DID token with Magic */
13 const magic = new Magic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY)
14 const didToken = await magic.auth.loginWithMagicLink({ email })
15
16 /* Step 4.4: POST to our /login endpoint */
17
18 const res = await fetch('/api/login', {
19 method: 'POST',
20 headers: {
21 'Content-Type': 'application/json',
22 'Authorization': `Bearer ${didToken}`
23 },
24 body: JSON.stringify({ email })
25 })
26
27 if (res.status === 200) {
28 // If we reach this line, it means our
29 // authentication succeeded, so we'll
30 // redirect to the home page!
31 router.push('/')
32 } else {
33 throw new Error(await res.text())
34 }
35 } catch (err) {
36 console.error('An unexpected error occurred:', err)
37 if (isMounted()) setErrorMsg(err.message)
38 }
39 }, [errorMsg])
40
41 ...
42}
Once login completes, users are redirected to the /
route (handled by pages/index.js
). The rest of this TodoMVC example is already hooked up to CRUD endpoints for adding, removing, and completing todo items. You can try it for yourself by starting a local instance of the demo app with the following shell command:
01yarn start # or `npm start`
By this point in the tutorial, you should have a foundation to self-guide your learning and deepen your knowledge of FaunaDB. You should also have a comprehensive understanding of (and reference implementation for) Magic's passwordless login flow. But... now that users can log in, we should give them the option to log out.
#5. Logging users out
Our users need a way to end their authenticated session with the app. Earlier, we discussed how to obtain and invalidate FaunaDB access tokens. Now, we're going to make use of the UserModel.invalidateFaunaDBToken
method we created.
Open pages/api/logout.js
. We can see a familiar barebones implementation here, except this time we'll be using a GET
HTTP method instead of a POST
. This means that user's simply need to navigate to https://[our app domain]/api/logout
in order to log out.
01// pages/api/logout.js
02
03...
04
05const handlers = {
06 GET: async (req, res) => {
07 // We previously stored the user's FaunaDB token
08 // and DID issuer into an encrypted session cookie.
09 // We will retrieve that session with the `getSession` function.
10 const { token, issuer } = await getSession(req)
11
12 /* Step 5: Invalidate the user's token */
13
14 // As a final step, we'll clear our session cookie
15 // (erasing it from the user's browser).
16 removeSession(res)
17
18 res.writeHead(302, { Location: '/' })
19 res.end()
20 },
21}
22
23...
At /* Step 5: Invalidate the user's token */
, we'll need to accomplish two things: invalidate the user's FaunaDB token and end the user's authenticated session with Magic.
01// pages/api/logout.js
02
03...
04import { UserModel } from '../../lib/models/user-model'
05import { Magic } from '@magic-sdk/admin'
06
07const handlers = {
08 GET: async (req, res) => {
09 // We previously stored the user's FaunaDB token
10 // and DID issuer into an encrypted session cookie.
11 // We will retrieve that session with the `getSession` function.
12 const { token, issuer } = await getSession(req)
13
14 /* Step 5: Invalidate the user's token */
15 const magic = new Magic(process.env.MAGIC_SECRET_KEY)
16 const userModel = new UserModel()
17
18 await Promise.all([
19 userModel.invalidateFaunaDBToken(token),
20 magic.users.logoutByIssuer(issuer),
21 ])
22
23 // As a final step, we'll clear our session cookie
24 // (erasing it from the user's browser).
25 removeSession(res)
26
27 res.writeHead(302, { Location: '/' })
28 res.end()
29 },
30}
31
32...
By invoking userModel.invalidateFaunaDBToken(token)
and magic.users.logoutByIssuer(issuer)
simultaneously, we are effectively resetting the user's auth status in both the FaunaDB and Magic services. Logout achieved! 🎉
#6. Deploy
We'll be using the Vercel platform to deploy our app for some real, live todo-ing. Click here to import a project from your preferred hosted Git provider.
Once you're logged in, paste the URL to your repository. Follow the prompts until you reach the following screen, featuring a prominent Deploy button (tempting though it may be, don't click that button just yet).
Before we continue, we'll need to configure the same environment variables we previously set in .env.local
, including:
NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY
MAGIC_SECRET_KEY
FAUNADB_SECRET_KEY
ENCRYPTION_SECRET
Now you can click that big, beautiful Deploy button! Your app will be live for the whole world to see in a matter of minutes! 🌍 🌎 🌏
#Acknowledgements
- Vercel wrote a superb guide all about deploying FaunaDB-powered apps on their platform. Check it out!
- Eric Adamski contributed a simple Magic auth + Next.js tutorial to Vercel's blog. Eric's post inspired this one and it's worth a read!