It is the second article in the series of articles covering FaunaDB with Jamstack. The first article is about How to launch an MVP with Jamstack, FaunaDB and GraphQL with zero operational costs
As the title suggests, we will be talking about an important feature that most MVPs need - user authentication. Building your own reliable and secure solution is time-consuming. Using a third-party solution with a free tier is often expensive to scale.
In the first article where I share how I build my MVP with Jamstack and FaunaDB, I mentioned that FaunaDB offers secure user authentication and attribute-based access control (ABAC) out of the box.
What makes FaunaDB’s ABAC even more powerful is that changes to access rights are reflected immediately because ABAC is evaluated for every query. This means that access can be granted or revoked without requiring a user to re-login.
In this article, we will see how easy it is to add user authentication features to a website using FaunaDB. We will also allow authenticated users to bookmark courses. All bookmarks are public by default so anyone can see them. To see FaunaDB’s ABAC in action, we will add a feature to make bookmarks private. Here I'd like to stress that nothing is public in FaunaDB. All data that unauthenticated users will see still needs to be made accessible using either a Role or a Key system. FaunaDB is secure by default!
I assume that you have followed the first article and have the starter project with data coming from FaunaDB. If not, the best is to start here. Alternatively, checkout the branch with the finished implementation of what was covered in the first article running the following code:
git clone --single-branch --branch article-1/source-data-from-FaunaDB git@github.com:sandorTuranszky/Gatsby-FaunaDB-GraphQL.git gatsby-fauna-db
FaunaDB provides Login and Logout built-in functions that can be used to create and invalidate user authentication tokens.
For example, we can pass an email and password to the Login function to get an authentication token or an error, if credentials are invalid.
In the first article we didn't have to create resolvers, as FaunaDB did it for us and we were fine with the logic. However, the login process might vary depending on the requirements, For example, we might want to use a username instead of an email. This is why FaunaDB gives us the flexibility to create our own resolvers.
Let’s take a look at our current schema:
It allows us to create authors with the name
field only (see 3 - AuthorInput
). To log in, an author will need to have an email and password. Not a big deal that we did not do it right away, because modifying schema with FaunaDB is very easy. All we need is to upload an updated schema.
Here it is:
type Course {
title: String!
description: String
author: User!
bookmarks: [Bookmark!] @relation
}
type Bookmark {
title: String
private: Boolean!
user: User!
course: Course!
}
type User {
name: String!
email: String!
role: Role!
courses: [Course!] @relation
bookmarks: [Bookmark!] @relation
}
input CreateUserInput {
name: String!
email: String!
password: String!
role: Role!
}
input LoginUserInput {
email: String!
password: String!
}
input LogoutInput {
allTokens: Boolean
}
type AuthPayload {
token: String!
user: User!
}
type Query {
allCourses: [Course!]
allBookmarks: [Bookmark!]
allUsers(role: Role): [User!]
}
type Mutation {
createUser(data: CreateUserInput): User! @resolver(name: "create_user")
loginUser(data: LoginUserInput): AuthPayload! @resolver(name: "login_user")
logoutUser(data: LogoutInput): Boolean! @resolver(name: "logout_user")
}
enum Role {
AUTHOR
DEVELOPER
}
Notice that the Author
type was renamed to User
. The project requirements have changed and we want to have different types of users depending on their roles. This is why we added the role
field and the Role enum
.
We also added the bookmarks
relation for the “Bookmarks” feature and the email
and password
fields to the User
type for login purposes.
The allAuthors
query was renamed to a generic allUsers
.
There is also a relation added between Courses
and Bookmarks
types that will allow us to highlight which courses have been bookmarked by an authenticated user.
The createUser
, loginUser
and the logoutUser
mutations are self-explanatory. Notice the @resolver directive. It allows us to define our custom logic for resolvers. The provided create_user
, login_user
and logout_user
names are how we will name our custom resolver functions. In FaunaDB’s documentation, they are referred to as User-defined functions (UDF)
Schema update
We have two options to update the existing schema:
Option 1: UPDATE SCHEMA creates any missing collections, indexes, and functions and overrides existing ones. All other elements of the existing schema remain the same.
Option 2: OVERRIDE SCHEMA removes all database elements, such as collections, indexes and functions and creates new once. This may result in loss of data this is why it’s not suitable for production apps. The GraphQL schema evolution is a much better and safer approach.
You can read about updating schema in more details here
Since we are at an early stage in our project and changes to our schema are quite significant, we will go with option 2 and override our existing schema. This will require us to repopulate the database with test data and it’s acceptable for us.
Copy the above schema and paste inside a schema-courses.gql
file (you can name it whatever you want)
Click the OVERRIDE SCHEMA link, choose the file, click Open and wait 1 min. As explained here, there is a 60-second pause to allow all cluster nodes to process the schema changes.
Note that fields in the database collections that are no longer declared in the schema are not accessible via GraphQL queries. It might make sense to clean them up before overriding the schema. You can do it with delete*
mutations in the GraphQL playground.
Before the override, we had two collections populated with some data.
The Author
collection:
And the Course
collection:
After the schema update, we have two newly created Course
and User
collections, that are both empty.
As expected, everything was removed and we will need to repopulate our collections with test data, but this time users will have emails, passwords and roles.
UDF to create a user
Before we can create users, we need to define the create_user
resolver function and use FaunaDB’s built-in authentication feature to ensure passwords are hashed.
Although the create_user (UDF) has been created by FaunaDB automatically as a "template" UDF based on the @resolver directive, we will get an error if we try to run the createUser
mutation now. This is because no logic has been implemented yet.
We will use Fauna Query Language (FQL) to update the createUser
UDF. If you prefer, you can create and modify UDF via the dashboard under the FUNCTIONS menu.
Here is the FQL code:
Update(
Function("create_user"),
{
"body": Query(
Lambda(["data"],
Create(
Collection("User"),
{
credentials: { password: Select("password", Var("data")) },
data: {
name: Select("name", Var("data")),
email: Select("email", Var("data")),
role: Select("role", Var("data")),
},
}
)
)
)
}
)
What’s happening in this FQL code.
As mentioned before, the create_user
UDF has already been created by FaunaDB. It looks like this:
Query(
Lambda(
"_",
Abort(
"Function create_user was not implemented yet. Please access your database and provide an implementation for the create_user function."
)
)
)
To add custom logic to an existing UDF, we use the Update function. It accepts two arguments: ref
and param_object
.
As the first argument, we pass in the reference to our existing create_user
UDF with the help of the Function function.
As the second argument, we provide an object with a single key body
, that holds a query to be run when the function is executed. This query must be wrapped in a Query function, which takes a Lambda function and defers its execution because we want this Lambda function to only run when the mutation is called.
The Lambda function is used to execute custom code - in our case, the custom code is how we create a new user.
The UDF accepts an array of arguments (Lambda(["data"],...
), the same arguments as defined in the GraphQL schema. We have an argument data
in createUser(data: CreateUserInput)
mutation with values defined in CreateUserInput
.
The Create function is used to create a document in a collection. It takes two arguments: collection
and param_object
.
As the first argument, we pass in the references to the User
collection with the help of the Collection function.
As the second argument, we provide and object with two keys:
-
data
key is an object that holds fields to be stored in the document. In our case, those fields arename
,email
androle
. Thepassword
will NOT be stored here! -
credentials
key is an object that encrypts values and this is why we use it to store the password. Once created, this value can’t be retrieved anymore. This means that passwords or other sensitive data can’t be leaked accidentally.
The Var statement returns a value stored in a named variable - in the data
object in our case and the Select function extracts a single value by the key name. For example, the following Select("name", Var("data"))
is same as Select("name", {name: "Johns Austin", email: "johns.austin@email.com", password: "password", role: AUTHOR})
that will return the value of the name
key: "Johns Austin"
Now that it’s clear how exactly our custom createUser
UDF works, navigate to the Shell (1) page, copy and paste (2) the FQL code and click Run Query (3) as shown on the following screenshot:
Now, go to the Functions page, and you should see the create_user
function there:
Create test users
Now we can repopulate our database running two simple FQL queries.
The first one is an index that we will need to set up relations between bookmarks and courses. Copy, paste it into the Shell and run it (similarly like we did above)
CreateIndex({
name: "course_by_title",
source: Collection("Course"),
terms: [{ field: ["data", "title"] }],
values: [{ field: ["data", "title"] }]
})
The next script will add all the test data we need. It will create authors with courses, developers and bookmarks. Copy, paste it into the Shell and run it.
Map(
[
{
name: "Johns Austin",
email: "johns.austin@email.com",
password: "password",
role: "AUTHOR",
courses: [
{
title: "React for beginners"
}
]
},
{
name: "Andrews Winters",
email: "andrews.winters@email.com",
password: "password",
role: "AUTHOR",
courses: [
{
title: "Advanced React"
}
]
},
{
name: "Wiley Cardenas",
email: "wiley.cardenas@email.com",
password: "password",
role: "AUTHOR",
courses: [
{
title: "NodeJS Tips & Tricks"
},
{
title: "Build your first JAMstack site with FaunaDB"
},
{
title: "VueJS best practices"
}
]
},
{
name: "Blake Fletcher",
email: "blake.fletcher@email.com",
password: "password",
role: "AUTHOR",
courses: [
{
title: "Mastering Vue 3"
}
]
},
{
name: "Hamilton Lowe",
email: "hamilton.lowe@email.com",
password: "password",
role: "DEVELOPER",
bookmarks: [
{
title: "React for beginners",
private: false
},
{
title: "VueJS best practices",
private: true
}
]
},
{
name: "Melinda Haynes",
email: "melinda.haynes@email.com",
password: "password",
role: "DEVELOPER",
bookmarks: [
{
title: "Advanced React",
private: false
},
{
title: "Build your first JAMstack site with FaunaDB",
private: false
}
]
}
],
Lambda(
"data",
Let(
{
userRef: Select(
"ref",
Call(Function("create_user"), [
{
name: Select("name", Var("data")),
email: Select("email", Var("data")),
password: Select("password", Var("data")),
role: Select("role", Var("data"))
}
])
),
courses: Map(
Select("courses", Var("data"), []),
Lambda(
"course",
Create(Collection("Course"), {
data: {
title: Select("title", Var("course")),
author: Var("userRef")
}
})
)
),
bookmarks: Map(
Select("bookmarks", Var("data"), []),
Lambda(
"bookmark",
Create(Collection("Bookmark"), {
data: {
title: Select("title", Var("bookmark")),
private: Select("private", Var("bookmark")),
user: Var("userRef"),
course: Select("ref", Get(Match(Index("course_by_title"), Select("title", Var("bookmark")))))
}
})
)
)
},
{
result: "Success"
}
)
)
)
Rebuilding the app
If you rebuild our project with gatsby develop
, you will see an error:
"There was an error in your GraphQL query: Insufficient privileges to perform the action"
Because we replaced the Author
collection with the new User
collection, we need to update our Guest
role privileges that we created in the previous article. We have seen how to manage roles and privileges using the UI - it’s very comfy and easy to understand. In this article, I will use FQL for brevity reasons and will explain what changed.
This is what our Guest
role privileges look like now.
The Author
collection and allAuthors
index do not exist anymore - we need to fix it.
Run the following FQL query from the Shell to update the Guest
role privileges.
Update(Role("Guest"), {
privileges: [
{
resource: Collection("User"),
actions: {
read: true
}
},
{
resource: Collection("Course"),
actions: {
read: true
}
},
{
resource: Collection("Bookmark"),
actions: {
read: true
}
},
{
resource: Index("allUsers"),
actions: {
read: true
}
},
{
resource: Index("allCourses"),
actions: {
read: true
}
},
{
resource: Index("allBookmarks"),
actions: {
read: true
}
},
{
resource: Index("bookmark_user_by_user"),
actions: {
read: true
}
}
]
})
Head over to the role management section and select the Guest
role. You should see this:
The Author
collection is gone and the Guest
role can read the new User
collection and allUsers
index.
Since bookmarks can be public, unauthenticated users can see them. We need to allow the Guest
role to read the Bookmark
collection, the allBookmarks
and the bookmark_user_by_user
indexes.
If you rebuild our project with gatsby develop
now, you will see that the list of courses and authors looks exactly as it was before we updated the schema.
UDF for user login
To manage the Bookmarks feature, we need to allow users to log in. Let’s create the login_user
UDF. Here is the code:
Update(
Function("login_user"),
{
"body": Query(
Lambda(
["data"],
Let(
{
response: Login(
Match(Index("user_by_email"), Select("email", Var("data"))),
{ password: Select("password", Var("data")) }
)
},
{
data: {
token: Select("secret", Var("response")),
user: Select("instance", Var("response"))
}
}
)
)
)
}
)
It uses FaunaDB’s built-in Login function, which takes two arguments: identity
and param_object
.
For the identity
argument, we pass in a reference to a User
document which we look up by the provided email. To find a user by an email the user_by_email
index is used (we will create it soon).
The Match function finds the exact match in the given index for the provided search terms.
As the second argument, we pass in the provided password.
The Login function will return an object containing the secret
under the token
key, the reference to the document (user) and some other data.
However, we need to have a custom response structure with the token
and user
keys. To restructure the response, we first store the Login
function response in the response
variable using the Let statement and then shape the response object to match what we need and wrap it in the data
object because a UDF must return a GraphQL-compatible type.
When authentication fails, the Login function returns an error.
Before we add the login_user
UDF, we need to create the user_by_email
index. Click on the Indexes menu item and then click NEW INDEX link as shown on the next screenshot:
Fill in the form as shown in the following screenshot:
- Choose the
User
collection - Add index name
- Type
email
in the “Terms” input and click the “+” icon on the right - it will automatically prefix thedata.
part ->data.email
- Check "Unique" checkbox (we need an exact match)
- Save it
Now we can test the newly created index on the Index page (Indexes menu item)
- Paste the following email
wiley.cardenas@email.com
- Click the "Search" button
- Get the result
One more thing is left - we need to allow the Guest
role to read the user_by_email
index and call the login_user
UDF.
- Navigate to the Security page and click MANAGE ROLES, then choose the
Guest
role. - Select the
user_by_email
index from the dropdown - Add “Read” action
- Select the
login_user
UDF from the dropdown - Add “Call” action and save
Now we are ready to update the login_user
UDF the same way as we added the create_user
one. Copy the login_user
UDF code, paste it into the Shell and run the query:
Head over to the GraphQL Playground to test the Login
mutation:
Run the following mutation and you will receive the token and the user details:
mutation Login {
loginUser(data: {
email: "wiley.cardenas@email.com"
password: "password"
}) {
token
user {
name
email
role
}
}
}
Try to provide invalid credentials and you will get an error: authentication failed
UDF for user logout
We also need to create a logout_user
UDF to allow users to invalidate their login sessions. Here is the UDF code:
Update(
Function("logout_user"),
{
"body": Query(Lambda(["data"], Logout(Select("allTokens", Var("data"), false))))
}
)
Let’s quickly add it the same way as we did with the login_user
UDF - via the Shell. See the screenshot:
Testing how it works on a real app
The best way to test out the login and logout, as well as the ABAC features, is to see how it works on a real app. I’ve added features to our starter and you can clone the source code from a branch in the same repo. This way, we will save time on copy and pasting a lot of code.
Before you clone, note that you will need the .env.development
and .env.production
files from the first article containing the bootstrap key and other variables.
Follow these steps:
git clone --single-branch --branch article-2/authentication-ABAC git@github.com:sandorTuranszky/Gatsby-FaunaDB-GraphQL.git gatsby-fauna-db
cd gatsby-fauna-db
npm install
- copy the
.env.development
and.env.production
files into the root of the project gatsby develop
You should see our updated app with a few menu options including the Login.
Note if you get any TypeErrors
, delete the token
cookie and the user_data
object from the localStorage.
The main page contains the same static data as before. If you navigate to the Developers page, you will see all public bookmarks being loaded dynamically.
If you reload this page multiple times, you will see the Loading... message for a while before the bookmarks are listed. And if you navigate to the devs tools, you’ll notice the /graphql
request that is made to load bookmarks dynamically.
This is how easy it is to have a mix of static and dynamic pages with Gatsby.
There is one issue with bookmarks though. If you take a closer look, you will see that one private bookmark is listed together with the public once for unauthenticated users:
This is wrong and we will fix it using FaunaDB’s ABAC.
- Navigate to the role management page and select the
Guest
role. - Add the following FQL code as shown on the screenshot and save it.
Lambda(
"bookmarkRef",
Let(
{
bookmark: Get(Var("bookmarkRef")),
private: Select(["data", "private"], Var("bookmark"))
},
Equals(Var("private"), false)
)
)
The FQL code above makes sure that only public bookmarks are accessible for the Guest
role by checking whether the private
property on a bookmark is false
.
Reload the /developers
page, and you should see no private bookmarks listed. No need to rebuild the app, because bookmarks are loaded dynamically.
This is how easy it is to manage what users can access and what not using FaunaDB's ABAC.
Testing the login feature
Security notice: In our example app, we will call a FaunaDB’s GraphQL endpoint right from the client-side. I strongly discourage you from taking this approach if you will manage sensitive data in your application.
The right way is to call an endpoint on your server under the same domain which would then communicate with third-party APIs. It will also allow you to implement security techniques and best practices to mitigate common client-side vulnerabilities.
In our example app, we deal with bookmarks which aren't sensitive information at all and this is why we can go with this simplified approach without a backend although it would be easy to implement thanks to Netlify functions.
Token invalidation notice: Tokens created by FaunaDB’s Login function do not have a default Time-To-Live (TTL) value. You can set TTL optionally, however, at the time of the writing of this article, the TTL does not guarantee the token removal. The good news is that a reliable token invalidation is being developed by FaunaDB’s dev team and will become available soon. I will update this article once the feature is released.
It is not an issue for our MVP (users can stay logged-in indefinitely) therefore we will not address token invalidation in this tutorial.
More on authentication-related security you can read here
To test out how the login/logout works, we need to create a new, Developer
role and define privileges for it.
Run the following FQL query using the Shell to create the Developer
role with required privileges.
CreateRole({
name: "Developer",
membership: [
{
resource: Collection("User"),
predicate: Query(
Lambda("userRef",
Equals(Select(["data", "role"], Get(Var("userRef"))), "DEVELOPER")
)
)
}
],
privileges: [
{
resource: Collection("User"),
actions: {
read: true,
}
},
{
resource: Collection("Bookmark"),
actions: {
read: Query(
Lambda(
"bookmarkRef",
Let(
{
bookmark: Get(Var("bookmarkRef")),
userRef: Select(["data", "user"], Var("bookmark")),
private: Select(["data", "private"], Var("bookmark"))
},
Or(
Equals(Var("userRef"), Identity()),
Equals(Var("private"), false)
)
)
)
),
write: Query(
Lambda(
["oldData", "newData"],
And(
Equals(Identity(), Select(["data", "user"], Var("oldData"))),
Equals(
Select(["data", "user"], Var("oldData")),
Select(["data", "user"], Var("newData"))
)
)
)
),
create: Query(
Lambda(
"data",
Equals(Identity(), Select(["data", "user"], Var("data")))
)
),
delete: Query(
Lambda(
"ref",
Equals(Identity(), Select(["data", "user"], Get(Var("ref"))))
)
)
}
},
{
resource: Index("allUsers"),
actions: {
read: true
}
},
{
resource: Index("allBookmarks"),
actions: {
read: true
}
},
{
resource: Index("bookmark_user_by_user"),
actions: {
read: true
}
},
{
resource: Function("logout_user"),
actions: {
call: true
}
},
{
resource: Collection("Course"),
actions: {
read: true,
}
},
{
resource: Index("allCourses"),
actions: {
read: true
}
},
{
resource: Index("bookmark_course_by_course"),
actions: {
read: true
}
},
{
resource: Index("course_author_by_user"),
actions: {
read: true
}
}
]
})
Let’s inspect the Developer role:
- Just like the
Guest
role,Developer
role needs to read theUser
,Course
andBookmark
collections - Privileges to read indexes are needed to query data on collections
- Logged-in users need to call the
logout_user
function to invalidate the auth token. - For authenticated users, ABAC gets more complex. We need to control not only what users can read, but also what resources they can change and in what way. Our logged-in user can read, create, update and delete bookmarks.
Let’s look at the “Read” action:
Unlike the Guest
role, the Developer
role can see its own private bookmarks. The code above (the predicate function) makes sure that if the bookmark is private, the user defined on the bookmark is the same user that is logged-in.
We can see it in action. Navigate to the /app/login
page and log in with the following credentials:
email
: hamilton.lowe@email.com
password
: password
You will be redirected to the following page:
Now navigate to the /developers
page where the logged-in user can still see his own bookmarks including private once. However, for the developer Melinda Haynes only public bookmarks are listed.
Now log out and log in with melinda.haynes@email.com
email and password
password. Note that password is the same for all users to keep things simple.
It turns out, Melinda has no private bookmarks. Navigate to /developers
page:
We know that Hamilton has a private bookmark, but Melinda can’t see it.
You can add bookmarks for Melinda and then log in as Hamilton to see that it will work as expected. Or you can mark all bookmarks as private and they will not be visible for other logged-in or unauthenticated users.
You can also remove the FQL code that controls the “Read” action for bookmarks to see how things will get messed up.
While we were testing, we logged out users successfully that proves that logged-in users are allowed to call the logout_user
function.
The “Create” action is also controlled by a predicate function.
The idea here is to control that bookmarks are created by users for themselves only. This is more a business logic rather than a security consideration in our case. We can easily imagine a content manager or an admin creating content for others.
The “Delete” action is similar by logic to the “Create” action - allowing you to delete only your own bookmarks.
The “Write” action is controlled by a predicate function to make sure users can only update their own bookmarks and that the original bookmark owner does not change during an update.
Now let’s look at the membership. By adding the User
collection, we state that all users who are members for the User
collection will be granted the privileges we’ve defined for this role, once they obtain a valid token
using the Login function.
Conclusion
As you can see, it is really easy to create user authentication flow using FaunaDB built-in Auth and ABAC features. Although we have barely scratched the surface, it was still sufficient enough to demonstrate how powerful and flexible the FaunaDB ABAC is.
I mentioned in the first article, that ABAC rules can also check for date or time for each transaction offering simple solutions to common problems most web apps have - for example, the need to control free trials periods or manage access to paid content.
With that said, the second part of the challenge is done:
- Users can authenticate and access data depending on the privileges they have.
- Bookmarks are loaded dynamically on pages where some of the data is static - a mix of static and dynamic content.
- We reflect what courses have been bookmarked by the logged-in user on static courses data so they can’t bookmark them twice.
Now we have a half static and half dynamic website powered by FaunaDB. We still have a lot of room for optimization, and I will cover it in future posts.
But now, the last thing that is left to do is to explore the Temporality feature and see how it can be applied in real-life examples. I will do so in the next article.
UPDATE: Here is the third article.
The live demo of the app is available here
Top comments (6)
I just found your article, but i've implemented something related, although i'm still trying to understand and learn how the roles work. My implementation doesn't fetch the secret and the user at once yet, but i'm able to login and get a client token back. I think i should be able to fetch the corresponding user. First by creating a faunadb client, and then running the query. But when i do that i get "insufficient privileges to perform the action".
How does the token itself get the role attached? Is the "role" parameter enough for faunadb to automatically know the token it returns on login has the role or do i have to do something else?
Have you set the Membership for the role?
This is from the article:
And this is how I check that the all actions defined in the DEVELOPER role are actually applied to all users with DEVELOPER role.
You should also make sure, you allow your role to read the index that fetches a user.
Let me know if you need help.
Did this twice and It simply will no do auth.
Its a confusion between Author and User, did this 3 times to same effect. Author on Course is null on page query which is confusing as it works with mutations. Had to add author data manually.
Will update when I figure it out.
What exactly did not work? I posted only working code hence I can prove it works.
HAs something to do with membership I will figure it out.
Thanks a lot for the article. FaunaDB rocks and we need more articles of this kind to better understand and use the DB.