How to Implement Passkey Authentication and Fine-Grained Authorization in JavaScript

gemanor

Gabriel L. Manor

Posted on October 27, 2023

How to Implement Passkey Authentication and Fine-Grained Authorization in JavaScript

Introduction

Some applications maintain the same access control for years. You use a Login and password to sign in, and then you are permitted to perform operations based on Access Control Lists (ACLs) and flat Roles. The modern era of cloud applications, and the fact that the internet is a space where everything is public, makes this model obsolete. In a world where everyone can access everything, and passwords can be cracked within minutes, we must find new ways to redefine access control.

In this article, we’ll build a full-stack application that demonstrates two new trends in access control: Passkeys and Fine-Grained Authorization.

Let's start with the details of what each of these terms mean.

Authentication - Who Are You?

The first phase in access control is authentication - verifying the identity of the user.

Previously, this verification could be completed by providing an answer to a rather basic question - What do you know?. This question could have been answered by providing a secret only the user should know - like a password.

These days, data can be easily stolen and exposed to the whole internet - thus, assuming only the user knows the password is not enough.

To strengthen the question of Who are you?, another question has popped up as a trend in recent years - What do you have?

This question is an example of two-factor authentication. It requires the user to provide a second secret they possess, like a mobile phone or a security key. While this method might be sufficient in some cases, vulnerabilities such as MFA fatigue and others in recent cyber attacks have proven the second authentication factor could be exploited as well.

To solve that, we try today to answer those two questions, but also the overall question of Who are you? with a third factor - something that proves that the user is indeed who they claim to be. This can be achieved by using biometric data in the form of Passkeys.

mfa.jpeg

Authorization - What You Can Do?

After we manage to identify the user and confirm that they are indeed who they claim to be, the next step is to decide what can they do within our application.

authz.jpeg

Previously, this question was answered with a simple list of roles and a list of permissions for each role. Today, in a world of distributed applications, we are just using multiple sources for gathering the data we need to streamline our permissions model into a simple list of roles. Not only that, in the world of SaaS, applications depend on 3rd party products (such as Salesforce, for example), creating more factors for policy decisions.

To address this need, it is crucial to apply a more fine-grained approach. Instead of using roles, we define a more complex policy based on attributes (ABAC) and relationships between entities (ReBAC). This way, we can declare policy rules that calculate multiple vectors of data from many factors and decide whether a user can perform an operation or not.

While many developers find themselves mixing this permissions model with their application code, the best practice is to separate the authorization logic from the application code. Two methods we can implement to achieve this are Policy as Code and Policy as Graph , where the policy is defined in a separate file and the data saved in a dedicated Graph DB. This gives developers the ability to define granular and generic permission models that are easily enforced by the application.

The Demo Application

To better demonstrate these methods, we will build a simple note-taking application using two tools - Hanko and Permit.io.

App Screenshot.jpg

As you can see, the functionality of the application is very simple. An authenticated user can create notes, and each note has a title and a body. We also want to configure the notes in a way that only the user who created a note (or an admin) could delete it.

Instead of building these passkeys and fine-grained authorization from scratch, we will use the following tools:

Hanko - a cloud service and open-source tool that allows us to implement passkey authentication.

Permit.io - a cloud service and open-source tool that allows us to implement fine-grained authorization without having to build it from scratch ourselves.

The application code is built with Next.js, and the code is available on GitHub.

Setup the Application

In the following sections, we will go step-by-step through the implementation of passkeys and fine-grained authorization in our application. We highly recommend cloning the sample application so you can follow the steps more easily.

If you prefer to read the article without cloning the application, you can skip this section and continue to the next one.

Clone the application:

git clone git@github.com:permitio/permit-hanko.git
Enter fullscreen mode Exit fullscreen mode

Install the dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Run the application:

npm run dev
Enter fullscreen mode Exit fullscreen mode

At this point, the application will fail to run as we need to set up the Hanko and Permit.io services.

Hanko - App error.jpg

Let's set up the Hanko account so we can log in to the application.

Use Hanko for Passkey Authentication

To use Hanko for passkey authentication, you can either run it locally in your environment or use the cloud service. In this article, we will use the cloud service as it is easier to set up and use.

  1. Visit the Hanko webapp, create a new organization, and give it the name you want. Hanko - New organization.jpg
  2. In the main dashboard, create a new project - assign http://localhost:3000 as the App URL. Hanko - New Project.jpg
  3. From the Settings > General section of the project, copy the API URL. Hanko API URL.jpg
  4. Paste it to a new file called .env.local, in the root directory of the application.
  NEXT_PUBLIC_HANKO_API_URL=https://a0ae8d5d-9505-415f-ad70-51839c285726.hanko.io
Enter fullscreen mode Exit fullscreen mode

Now, when we run the application again, we can see that the error is gone, and we can see the login page.

Hanko - Login.jpg

To add this authentication window, we just use the Hanko SDK for JavaScript. You can see the element that implemented the login flow in the app/auth/login/page.tsx file.

<Paper sx={{ p: 2 }}>
  <HankoAuth />
</Paper>
Enter fullscreen mode Exit fullscreen mode

We also added a middleware logic in the middleware.ts file that will redirect the user to the login page if they are not authenticated.

const authenticateUser = async (req: NextRequest): Promise<string> => {
 if (!hankoApiUrl) {
  return "";
 }

 // Get Hanko token from cookie
 const hanko = req.cookies.get("hanko")?.value;
...
// Authenticate user using Hanko
const user = await authenticateUser(req);

// Redirect to login page if user is not authenticated
if (!user) {
 urlToRedirect.pathname = LOGIN_URL;
 return NextResponse.rewrite(urlToRedirect);
}
Enter fullscreen mode Exit fullscreen mode

With this authentication configuration and flow configured, we are ready to continue with the implementation of Permit.io for authorization in our application.

Use Permit.io for Basic RBAC Authorization

Now, that we are done with the authentication, it is time to set up our permission layer. For the first phase, we will use simple roles to determine the actions that the user can do.

In the /app/api/notes/route.ts, you'll find four functions, GET, POST, PUT, and DELETE - responsible for the logic of getting, creating, updating, and deleting notes, respectively.

Traditionally, developers would check the user's role in the application code, and decide if the user can perform the operation or not. For example, in our GET function, they used code that looks like this:

if (user.admin !== true) {
 return;
}
Enter fullscreen mode Exit fullscreen mode

This code might be simple, but it is not scalable. If we want to change the role permissions, we need to change the code and redeploy the application. Also, if we would like to make this "flat" permission more fine-grained, we need to add more code and make it more complex.

If you look at the route.ts file, you'll not find any of those checks. Instead, we are using the generic permit.check middleware.ts file.

const response = await fetch(`${pdpUrl}/allowed`, {
 method: "POST",
 headers: {
  Authorization: `Bearer ${permitApiKey}`,
  "Content-Type": "application/json",
  Accept: "application/json",
 },
 body: JSON.stringify({
  user: user,
  action,
  resource: resource,
  context: {},
 }),
});
Enter fullscreen mode Exit fullscreen mode

This code is a generic permit.check function that checks the permissions configured for the application using three factors:

User - the entity that attempts to perform the operation (in our case, a user authenticated with Hanko).

Action - the operation that the user will attempt to perform (in our case, GET).

Resource - the entity that the user will attempt to perform the operation on (in our case, the note).

At this point, since we haven't configured the Permit SDK key in the app, any user can perform any operation. Setting up Permit.io will fix this.

Now, we can simply configure these permissions in Permit.io, and change them for our needs without changing the application code.

  1. Create a free account on Permit.io website, then create a new organization, and give it the name you want. Skip the rest of the Onboarding wizard

  2. In the left sidebar, click on Policy and then go to the Roles tab, create the following roles:
    Permit Roles.jpg

  3. In the Resources tab, create a new resource called notes, with the following four actions and an owner attribute:
    Hanko Permit - Resources.jpg

  4. Back in the Policy tab, let's allow all users to GET and POST notes, and only users with the admin role to PUT, and DELETE notes.
    Hanko Permit - RBAC.jpg

Next, we need to place the Permit.io API credentials in our application config.

  1. To use Permit.io in our application, we need first to get the API key from Permit.io dashboard.

  2. In the env.local file, add the following key

    PERMIT_API_KEY=<YOUR_COPIED_API_KEY>
    
  3. We will also want to configure the API endpoint of Permit.io to check the permissions, we will use now in the cloud service.

    PERMIT_PDP_URL=https://cloudpdp.api.permit.io
    

To make sure all environment variables are in place, restart the application.

Now, as we configured the permissions in Permit.io and the app, it is time to sign up with a user and try it out.

Check the Permissions

In localhost:3000 page, login with a user of your choice, and make sure you have the right passkey configured.

In the first screen, let's try to create a note - we can see this note is now on the list.

Permit - Hanko create note.jpg

In the Permit.io dashboard, let's go to the Audit tab in the left sidebar, and we can see that the POST action is logged. Opening the audit log row, we can see detailed information about this decision, and that the decision happened because this user has the User role.

Permit - Audit log.jpg

The way this user got the user role is a sync function we created for every user in our system as the least privileged role.

const response = await permit.api.syncUser({
 key,
 email,
 attributes: {
  roles: ["user"],
 },
});

await permit.api.roleAssignments.assign({
 role: "user",
 tenant: "default",
 user: key,
});
Enter fullscreen mode Exit fullscreen mode

Now, let's try to delete the note - and we can see that the delete button is returning an error

Screenshot Template.jpg

To change this situation, let's go to the Permit.io dashboard, in the Users screen edit our user and add an admin role to it.

Screenshot Template-1.jpg

Returning to the application, we can see that the delete action is now working.

By this simple configuration change, we can see how we can change the permissions of our application without changing the code.

Now that we are done with the RBAC model in our application, let's go to the next phase and make our permissions more fine-grained with no need to change the application code.

Use Permit.io for Fine-Grained ABAC Authorization

Note: For this section to run properly, you need to run the Permit.io PDP as a sidecar, You can read how to do that here.

As stated before, the delete action in our app is supposed to be limited to the same user who created the role.

To achieve that, we can easily configure this policy in Permit.io and see its immediate effect in the application.

  1. In the Permit.io dashboard, go to ABAC Rules on the Policy page, and enable the ABAC option. Permit enable ABAC.jpg
  2. Create a new Resource Set rule - this set will create a condition that will be true only if the user is the owner of the note. Permit ABAC rule.jpg
  3. Back in the Policy tab, create a new policy that will allow PUT, and DELETE only for the owner of the note. Mind that we leave the privileged admin role to do all actions. Permit Hanko ABAC policy.jpg

At this point, let's add another user to the application. Let's log out with the current user and sign up with a new user. Let's now try to remove the note that we created with the first user - and we can see that the delete button is returning an error.

ABAC no matching.jpg

Let's now create a new note from this user, to see how we combine the RBAC and ABAC permissions together in our application.

  1. Create a new note with the second user.

  2. Log out from the second user and log in with the first user.

  3. Try to delete the note that we created with the second user - and we can see that delete is successful as the first user is an admin.

If we compare this simple configuration change to the traditional way of implementing permissions, we can see how much time and effort we saved. We also created a more generic and fine-grained permissions model that can be easily changed and configured without changing the application code.

Use Permit.io for Fine-Grained ReBAC Authorization

Another approach for fine-grained authorization that we can use in our application is ReBAC - Relationship-Based Access Control. Assuming a note app, we might want to create workspaces, organizations, and folders for our notes. In this case, we might do not have a dedicated owner field in the note entity, but we have a relationship between the note and the workspace.

As Permit.io also supports the configuration of ReBAC policies, we can easily implement it in our application without changing the permit.check or any enforcement code in the app. To read more about ReBAC and modeling such fine-grained permissions, read here.

Next Steps

In this article, we demonstrated how to implement passkey authentication and fine-grained authorization in a simple note-taking application. We used https://hanko.io and https://www.permit.io/rebac to implement those trends in our application, and we can see how easy it is to implement them with no need to change the application code.

Access control is a very important part of our application, and we must make sure that we implement it in the right way. As next steps, we recommend you to read more on the following topics:

We would also be happy to see you in our Slack community, where you can ask questions and get help from our team and other developers. Join us here.

💖 💪 🙅 🚩
gemanor
Gabriel L. Manor

Posted on October 27, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related