Authorization
Baeta provides a flexible and type-safe authorization system that allows you to define granular access controls at both operation and field levels. With support for default scopes, dynamic rules, and a permission granting system, you can implement complex authorization patterns while maintaining clean and maintainable code.
Key Features
- Type-safe authorization rules
- Default scopes with override capability
- Pre and post-resolution authorization
- Granular field-level permissions
- Permission grants between resolvers
- Scope caching for performance
- Async/sync authorization support
- Subscription-specific controls
- Type-wide authorization rules
Installation
- yarn
- npm
- pnpm
- bun
yarn add @baeta/extension-auth
npm install @baeta/extension-auth
pnpm add @baeta/extension-auth
bun add @baeta/extension-auth
Basic Setup
- Define Authorization Scopes
import { UnauthenticatedError } from "@baeta/errors";
import { authExtension } from "@baeta/extension-auth";
import type { Context } from "../types/context.ts";
declare global {
export namespace AuthExtension {
export interface Scopes {
isPublic: boolean;
isLoggedIn: boolean;
hasAccess: "guest" | "user" | "moderator" | "admin";
}
export interface GrantsMap {
readUserPhotos: boolean;
}
}
}
export const authExt = authExtension<Context>(async (ctx) => {
const accessList: string[] = ["guest", "user"];
// You can fetch data before the scopes are created
return {
isPublic: true,
isLoggedIn: async () => {
// But you can also use scope loaders, which will be resolved lazily, when needed
if (!ctx.userId) {
throw new UnauthenticatedError();
}
return true;
},
hasAccess: (access: string) => {
// Scope loaders are also required for non-boolean scopes
return ctx.user?.accessList.includes(access) ?? false;
},
};
});
- Register the Extension
Create src/modules/extensions.ts:
import { createExtensions } from "@baeta/core";
import { authExt } from "../extensions/auth-extension.ts";
export default createExtensions({
authExtension: authExt,
//... other extensions
});
Authorization checks should be registered first in your chain, as they determine if a request can proceed at all. This ensures unauthorized requests are rejected early in the process.
The only exception being the complexity extension.
Authorization Examples
Basic Static Rules
Auth rules are chained onto fields and the result is consumed:
// Public or authenticated access
const userQuery = Query.user
.$auth({
$or: {
isPublic: true,
isLoggedIn: true,
},
})
.resolve(({ args }) => {
return db.user.findFirst({ where: args.where });
});
// Admin-only access
const createUserMutation = Mutation.createUser
.$auth({
hasAccess: "admin",
})
.resolve(({ args }) => {
return db.user.create({ data: args.data });
});
Post-Resolution Authorization
// Post-auth checks permission after resolver execution
// Useful to avoid double database queries when you need the resource for permission checking
const userQuery = Query.user
.$postAuth((params, result) => {
if (result && result.id === params.ctx.userId) {
return true; // Allow access if user is requesting their own data
}
return { hasAccess: "admin" }; // Require admin access for other users' data
})
.resolve(({ args }) => {
return db.user.findFirst({ where: args.where });
});
// Compared to pre-resolution auth which might require an extra database query
const userQuery = Query.user
.$auth(async (params) => {
const user = await db.user.findFirst({
where: { id: params.args.id },
});
if (user && user.id === params.ctx.userId) return {};
return { hasAccess: "admin" };
})
.resolve(({ args }) => {
return db.user.findFirst({ where: args.where });
});
Subscription Rules
const userCreatedSubscription = Subscription.userCreated
.$auth({
isLoggedIn: true,
})
.subscribe(({ ctx }) => {
return ctx.pubsub.subscribe("user-created");
})
.resolve(({ source }) => {
return source;
});
Type-wide Rules
Type-wide rules are chained before $fields and apply to all fields of a type:
// Apply auth to all Query fields
const queryResolver = Query
.$auth({
isLoggedIn: true,
})
.$fields({
user: userQuery,
users: usersQuery,
});
// Apply auth to all User fields
const userResolver = User
.$auth({
isLoggedIn: true,
})
.$fields({
id: User.id.key("id"),
email: User.email.key("email"),
});
Grants System
// Grant permission when resolving user
const userQuery = Query.user
.$auth(
{
$or: { isPublic: true, isLoggedIn: true },
},
{
grants: ["readUserPhotos"],
},
)
.resolve(({ args }) => {
return db.user.findFirst({ where: args.where });
});
// Use granted permission on related field
const userPhotosResolver = User.photos
.$auth({
$granted: "readUserPhotos",
})
.resolve(({ source }) => {
return db.userPhoto.findMany({ where: { userId: source.id } });
});
Authorization Operators
$or: Any condition must be true$and: All conditions must be true$chain: Sequential evaluation$race: Parallel evaluation$granted: Check granted permissions
Default Scopes
Default scopes provide base authorization rules that apply to all operations. They are defined when creating the auth extension and are combined with local rules using an AND operator.
Defining Default Scopes
export const authExt = authExtension<Context>(
async (ctx) => {
// Define user scope values as explained above
return {...};
},
{
// All queries, mutations, and subscriptions will require the user to be logged in.
// You will still need to pay attention for relationships and nested queries.
defaultScopes: {
Query: {
isLoggedIn: true,
},
Mutation: {
isLoggedIn: true,
},
Subscription: {
isLoggedIn: true,
},
},
},
)
Skipping Default Scopes
You can bypass default scopes for specific operations using the skipDefaults option:
const publicContentQuery = Query.publicContent
.$auth(
{
isPublic: true,
},
{
skipDefaults: true,
},
)
.resolve(() => {
return getPublicContent();
});
For detailed examples, see the Baeta authorization example.