Nuxt Going Full-Stack: How to Handle Authorization?
When developing an application, it is essential to grant or restrict access to specific areas or data based on a user's role or permissions. This concept is known as authorization.
Authorization is a critical aspect of any application for security purposes. It is imperative to protect sensitive data, such as a user's email address or password, and prevent the disclosure of private information that could compromise user privacy and your business integrity.
To clarify: authentication involves verifying a user's identity, whereas authorization involves permitting or denying access to resources based on the user's role or permissions.
While building Orion, I confronted this challenge and experimented with various approaches. In this article, I'll share several strategies to manage authorization in a NuxtHub application, ranging from the simplest to the most sophisticated methods. It is crucial to understand that the most complex method is not necessarily the best for every application.
Orion serves as a community-driven collection of templates for your projects, from landing pages to complete web applications. NuxtHub functions as a deployment and management platform for Nuxt, powered by Cloudflare.
Create a Full-Stack Nuxt Application: A Twitch JourneyThe Context
Consider this endpoint in your Nuxt application:
export default defineEventHandler(async (event) => {
const id = getRouteParam(event, 'id')
const publication = await db.query.publications.findOne({
where: { id },
columns: {
title: true,
content: true,
status: true,
authorId: true,
},
})
return publication
})
You can access this endpoint by making a GET /api/publication/<id>
request. This endpoint returns a publication. At first glance, there is no issue with this endpoint. However, a closer examination reveals that a publication has a status that can be draft
, published
, or deleted
.
The concern is that anyone can access this endpoint and view a draft publication or a deleted publication. Some might be able to view the content of a publication before it is published, which could contain vital information such as a press release or a product launch. Such a scenario could significantly harm your business.
This is a security issue that necessitates restricting access to this endpoint, utilizing the principle of least privilege. Security is not merely a feature but should be ingrained in the system's design.
Authenticated or Not
The following section is premised on the utilization of the Nuxt nuxt-auth-utils package. Nevertheless, its usage is not essential.
The initial step in securing this endpoint is verifying if the user is authenticated. If the user lacks authentication, it should return a 401 Unauthorized
error.
Upon exploring the nuxt-auth-utils package, I discovered a server utility termed requireUserSession
. This utility can be employed at an endpoint's inception to attempt retrieving the user's session and triggering a 401 Unauthorized
error if there is no session, indicating the user is unauthenticated.
export default defineEventHandler(async (event) => {
await requireUserSession(event)
const publication = {} // ...
return publication
})
This is the preliminary step towards safeguarding the endpoint against unauthenticated users. However, if anyone can register an account, they may still access the content of a draft publication. Further action is necessary.
Only Admins
Building on requireUserSession
, I can readily devise a new utility named requireAdminSession
. This session will verify if the user is authenticated and has admin rights. If the user is not an admin, a 403 Forbidden
error should be returned. Unauthorized relates to authentication, while Forbidden pertains to authorization.
export async function requireAdminSession(event: H3Event, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
const userSession = await requireUserSession(event)
const { user } = userSession
if (user.roleType !== 'admin') {
throw createError({
statusCode: opts.statusCode || 403,
message: opts.message || 'Unauthorized',
})
}
return userSession as UserSessionRequired
}
Subsequently, requireUserSession
can be replaced with requireAdminSession
in the endpoint.
export default defineEventHandler(async (event) => {
await requireAdminSession(event)
const publication = {} // ...
return publication
})
Now, only admins, users assigned the admin
role, can access a publication's content.
However, this approach is not entirely effective, as if the publication is published, I wish to permit all users to access it. Currently, only admins can view the content of a publication, irrespective of the publication's status.
Issues with These Approaches
The aforementioned strategies lack sufficient flexibility for a detailed authorization system. They cannot accommodate situations where the author of the publication should have access, even if the publication is unpublished.
To address this, I could draft another utility termed requirePublicationAccess
:
export async function requirePublicationAccess(event: H3Event, publication: Publication, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
const userSession = await getUserSession(event)
const { user } = userSession
if (publication.status === 'published')
return userSession
if (!userSession) {
throw createError({
statusCode: opts.statusCode || 401,
message: opts.message || 'Unauthorized',
})
}
if (publication.authorId === user.id)
return userSession
if (user.roleType === 'admin')
return userSession
throw createError({
statusCode: opts.statusCode || 403,
message: opts.message || 'Forbidden',
})
}
This solution is adequate until I need to manage additional endpoints. This approach will result in excessive code duplication concerning error handling. Additionally, the code becomes more challenging to test due to the necessity of managing an entire H3 event.
Without a superior approach, I continue developing Orion but soon encountered another challenge: I need to implement the same authorization logic on the client side. I do not want to show an edit button to a user who is neither the author of the publication nor an admin, and I do not wish to hide this edit button from the admin if I decide to permit publication editing on the server. Similar to forms validation, duplicating logic can result in substantial inconsistencies between the client and the server, leading to customer frustration. The utilities I have created thus far are solely server-side due to the use of requireUserSession
and getUserSession
.
Let's take a step back to comprehend how this issue might be resolved. Authorization consists of three components:
- allow access to a resource
- deny access to a resource
- authorize access to a resource
The first two are straightforward conditions yielding a boolean response to questions like "Can I access this publication? Yes or No". The developer must manually handle this response. The third one grants access to the resource. For the question "Can I access this publication?", there is neither a "yes" nor "no"; nothing happens if the answer is "yes", but an error is raised if it is "no". The authorize
sends an error automatically.
These elements hold no direct relation to the client, server, or the framework in use or the authentication system. Armed with this knowledge, I began working on a local module for Orion to manage authorization more flexibly. After several days of effort, I am proud to introduce nuxt-authorization.
Initially, I aimed to develop both a Nitro and a Nuxt package, but Nitro modules are not ready yet. Nonetheless, I submitted some pull requests to the Nitro repository to enhance the module system. One of these is the module author guide.
Introducing nuxt-authorization
This module offers a simple yet powerful method for managing authorization in a Nuxt application, applicable to both client and server. It is authentication system agnostic but can be seamlessly integrated with nuxt-auth-utils.
For instructions on its usage, visit the GitHub repository: nuxt-authorization. To delve into the design and implementation, continue reading.
The module remains in its early stages of development, and if you have any feedback or suggestions for improvement, feel free to open an issue on the GitHub repository or contact me on X.
Problems I Want to Solve
Before describing what has been accomplished with nuxt-authorization, let's examine the problems I intended to solve.
Much like forms validation, I aim to maintain a single source of truth and a single API for both the app and server. This is crucial to avoid discrepancies between the app and server.
I also seek the ability to define authorization logic simply and grouped by resource. This approach enhances readability, maintainability, and consistency across various application aspects, such as forms validation.
The authorization logic must be adaptable enough to accommodate a varied range of use cases, from allow
or deny
conditions to more complex authorize
conditions. The errors produced by the authorize
condition should be customizable to enhance user experience. Not all unauthorized errors need to be a 403 Forbidden
.
Finally, I desire components that ease integrating authorization logic into templates. For instance, I want the ability to show or hide a button based on authorization logic.
Design
The module comprises two components: the ability and the bouncer.
Abilities constitute the rules that embody the authorization logic. They take a user and one or more resources and yield a deny or an allow condition. While they can be grouped by resource, they remain independent of one another.
export const editBook = defineAbility((user: User, book: Book) => {
return user.id === book.authorId
})
In the above example, only the author of a book is allowed to edit it. By default, abilities are executed only if the user is authenticated, but it is possible to permit guest access to certain resources.
export const listBooks = defineAbility((user: User | null) => {
return true
})
In this instance, everyone, including unauthenticated users, is permitted to list books.
The defineAbility
function acts as a factory that creates an ability but nothing beyond that. It needs to be used with bouncer functions.
In a server endpoint, the authorize
function can be employed to grant access to a resource based on the abilities.
export default defineEventHandler(async (event) => {
await authorize(event, listBooks)
const books = await db.query.books.findMany()
return books
})
The authorize
function will trigger a 403 Forbidden
error if the user is not permitted to list books. More granular control can be achieved using the allows
or denies
functions.
export default defineEventHandler(async (event) => {
if (await denies(event, listBooks, book))
return []
const books = await db.query.books.findMany()
return books
})
Instead of triggering an error, an empty array is returned if the user is not allowed to list books. The appropriate method for handling unauthorized access will depend on your business logic.
For example, returning a 404 Not Found
error if the user is not authorized to access a specific resource can preserve data privacy. Disclosing the existence of the resource could pose a security risk.
This customization is attainable within the abilities through the allow
and deny
functions.
export const viewBook = defineAbility((user: User, book: Book) => {
if (book.status === 'draft') {
return deny({
statusCode: 404,
message: 'Not Found',
})
}
return allow()
})
Now, the authorize
function will throw a 404 Not Found
error if the user is not permitted to view a draft book, rather than the default 403 Forbidden
.
On the client side, the allows
, denies
, and authorize
functions can also be utilized to administer the authorization logic.
In addition, these functions offer two components: Can
and Cannot
. These components enable portions of a template to be shown or hidden based on the authorization logic.
<template>
<Can :ability="editBook" :args="[book]">
<button>Edit</button>
</Can>
</template>
Consult the GitHub repository for more information: nuxt-authorization.
Final Thoughts
Both the code and the design of this package draw significant inspiration from the Adonis Bouncer. It is well-crafted, and reinventing concepts unnecessarily is often futile.
I am already implementing this package in Orion, and the experience has been quite enjoyable. I am confident there is still potential for enhancement, but this module represents an initial step in empowering Nuxt developers and advancing Nuxt's full-stack capabilities. Contributions and suggestions for new features or improvements are welcome.
Orion is a community-driven collection of templates for your projects, spanning from landing pages to comprehensive web applications.
I hope this article and module assist you in managing authorization within your Nuxt application, thereby preventing and restricting unauthorized access to your data. Security is imperative.
Happy coding!
Thanks for reading! My name is Estéban, and I love to write about web development.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!