Authorization
Policy-based authorization as a Nuxt module: what an already-identified user may do. Authentication stays in Auth. The design follows nuxt-authorization: the app defines policies as functions and the current user is injected into them by a resolver (one for the app, one for the server).
@nuxthub/core, @frigear-nu/module-kit, @frigear-nu/auth and
@frigear-nu/users; all are installed automatically.Quick setup
pnpm add @frigear-nu/authorization
export default defineNuxtConfig({
modules: ['@nuxthub/core', '@frigear-nu/users', '@frigear-nu/authorization'],
routeRules: {
'/admin/**': { roles: ['admin'] },
},
authorization: {
redirectTo: '/login', // unauthenticated page visits; omit to show a 401 error
},
})
// shared/utils/abilities.ts - auto-imported in the app and the server
export const editPost = defineAbility((user, post: Post) => user.id === post.userId || user.role === 'admin')
// server/api/posts/[id].put.ts
export default defineEventHandler(async (event) => {
const post = await findPost(getRouterParam(event, 'id'))
await authorize(event, editPost, post) // throws 403 when denied
})
<Can :ability="editPost" :args="[post]">
<button>Edit</button>
</Can>
role column to users (see Database). That only changes the generated Drizzle schema,
so generate and commit a migration in your app and in every fixture that uses the module. See
Module Kit.Options
| Option | Type | Default | Purpose |
|---|---|---|---|
abilities | string | shared/utils/abilities.ts, else shared/abilities.ts | Path to the abilities file. Needed to refer to abilities by name in route rules and page meta. |
resolvers.server | string | session or bearer user | Path to a default-export (event: H3Event) => User | null replacing the server resolver. |
resolvers.client | string | session user | Path to a default-export () => User | null replacing the app resolver. |
redirectTo | string | - | Redirect target for unauthenticated page navigation. Without it a 401 error is shown. |
Defining abilities
defineAbility, allow and deny are auto-imported in app and server. The first argument of a policy is the resolved
user, the rest are the arguments passed when checking.
// shared/utils/abilities.ts
export const isAdmin = defineAbility(user => user.role === 'admin')
export const editPost = defineAbility((user, post: Post) =>
user.id === post.userId || user.role === 'admin' || deny({ message: 'Not your post' }))
// Let guests reach the policy (it receives `null`).
export const viewPost = defineAbility({ allowGuest: true }, (user, post: Post) => post.published || user?.id === post.userId)
Without allowGuest, guests are denied before the policy runs.
type AuthorizationResponse = { authorized: boolean, statusCode?: number, message?: string }
type AuthorizerResponse = boolean | AuthorizationResponse | Promise<boolean | AuthorizationResponse>
type Authorizer<User = any> = (user: User, ...args: any[]) => AuthorizerResponse
interface Ability<User = any> {
allowGuest: boolean
original: Authorizer<User>
execute: (user: User | null, ...args: any[]) => AuthorizerResponse
}
allow() // { authorized: true }
deny({ statusCode?, message? }) // statusCode defaults to 403
Server
The server helpers take the event first and are auto-imported.
await authorize(event, editPost, post) // throws an h3 error when denied (403 by default)
if (await allows(event, isAdmin)) { /* ... */ }
if (await denies(event, isAdmin)) { /* ... */ }
const user = await resolveAuthorizationUser(event) // the configured server resolver
Roles in sessions and tokens
The default server resolver uses getAuth, so route rules and abilities apply to
cookie sessions, user API tokens and OAuth access tokens alike. A bearer request loads the user from the database, so
role is always current. A cookie session carries the user stored at login, so a role change reaches it after the next
sign-in (or when your code calls setUserSession). The default app resolver reads the same session user.
Policy hooks of other modules (events:authorize, forms:authorize, users:admin:authorize, oauth:authorize)
receive ctx.userId. Identify the caller from it, not from the session on ctx.event: the session is empty for bearer
requests.
Pure helpers
@frigear-nu/authorization/utils exports framework-agnostic functions, used by the helpers above and useful in tests:
import {
check, allows, denies, authorize, // (ability, user, ...args), no event
matchRoute, resolveRule, evaluateRule, normalizeAuthorizeMeta,
roleIsHigher, roleIsHigherOrEqual, canManageRole,
AuthorizationError, createAuthorizationError, isAbility, ROUTE_RULE_KEYS,
} from '@frigear-nu/authorization/utils'
check(ability, user, ...args) // Promise<AuthorizationResponse>
matchRoute('/admin/**', '/admin/users') // true: '/x/**' prefix, '*' one segment, or an exact path
For apps with ranked roles, the role helpers take a hierarchy from highest to lowest rank; unknown roles never outrank or equal anything:
const roles = ['admin', 'manager', 'member', 'user'] as const
roleIsHigher(roles, 'manager', 'member') // true
roleIsHigherOrEqual(roles, 'member', 'member') // true
canManageRole(roles, 'manager', 'manager') // false: only strictly lower roles
canManageRole(roles, 'admin', 'admin', { equalAllowedFor: ['admin'] }) // true
App
<Can :ability="editPost" :args="[post]">
<button>Edit</button>
<template #fallback><span>Read only</span></template>
</Can>
<Cannot :ability="isAdmin">You are not an admin.</Cannot>
Can accepts one ability, or an array that must all pass (then args is an array of argument arrays).
const { allows, denies, authorize } = useAuthorization() // each is also auto-imported on its own
if (await allows(editPost, post)) { /* ... */ }
authorize(event, ...) or route rules.Route rules
Rules are keys on Nuxt's native routeRules.
Patterns follow route rules (/admin/** prefix, * one segment, or an exact path). Every matching pattern contributes
and keys of more specific patterns override less specific ones. Pages are gated by a global route middleware (also
during SSR); rules for /api/** are also enforced by a Nitro middleware.
export default defineNuxtConfig({
routeRules: {
'/admin/**': { roles: ['admin'], ssr: false },
'/api/admin/**': { roles: ['admin'] },
'/admin/login': { authenticated: false }, // opens a sub-route of a protected pattern
'/billing': { ability: 'isAdmin' }, // export name from the abilities file; no arguments
'/members/**': { authenticated: true },
'/pricing': { guest: true },
},
})
| Key | Meaning |
|---|---|
roles | Allowed user.role values ([] clears an inherited restriction). |
ability | Name of an exported ability that must allow the user. |
guest | Let unauthenticated visitors reach the checks (they still apply). |
authenticated | true requires a logged-in user; false disables authorization for the path. |
Guests get 401; a wrong role or a denied ability gets 403. Per page, definePageMeta replaces the matching route
rules:
<script setup lang="ts">
definePageMeta({ authorize: isAdmin }) // an ability, an ability name, or { roles, ability, args, guest }
</script>
interface RouteRule {
roles?: string[]
ability?: string | Ability
args?: unknown[] // page meta only
guest?: boolean
authenticated?: boolean
}
type AuthorizeMeta = string | Ability | RouteRule
routeRules and nitro.routeRules at build time. Rules added at runtime are not picked up.Database
The module extends the users table through extendDatabaseTable with one column:
role (text, not null, default 'user').