Skip to main content
Version: Next (2.x)

Authorization

@baeta/auth is a scope-based authorization system. You declare the scopes your app cares about (isLoggedIn, hasRole('admin'), ownsResource(id)), Baeta loads them lazily per request, and resolvers gate themselves with auth(scope.X) or auth(rule.or(scope.X, scope.Y)). Failures throw typed errors; successes are cached for the rest of the request.

At a glance

  • Typed scope accessor and rule combinators.
  • Default scopes for Query / Mutation / Subscription so unprotected operations can't slip through.
  • Pre- and post-resolution checks (auth, authAfter).
  • Field-, type-, and module-level rules.
  • Grant propagation: attach permissions to resolver results so descendants can consume them without a re-check.
  • Per-request scope caching with optional custom key functions.
  • Sync and async scope loaders.
  • Independent control over a subscription's subscribe and resolve legs.

Installation

yarn add @baeta/auth

Basic setup

1. Define scopes and create auth

createAuth returns:

  • auth: chains onto $use to attach pre-resolution scope rules.
  • authAfter: same shape, runs after the resolver (handy when the decision depends on the resolved value).
  • authAppPlugin: the app plugin you register on createApplication.
  • scope: typed accessor for individual rules (scope.isLoggedIn, scope.hasAccess('admin'), scope.$granted('readUserPhotos')).
  • rule: combinators that group rules (rule.and, rule.or, rule.chain, rule.race).
src/lib/auth.ts
import { createAuth } from "@baeta/auth";
import { UnauthenticatedError } from "@baeta/errors";
import type { Context } from "../types/context.ts";

export type Scopes = {
isPublic: boolean;
isLoggedIn: boolean;
hasAccess: "guest" | "user" | "moderator" | "admin";
};

export type Grants = "readUserPhotos";

export const { auth, authAfter, authAppPlugin, scope, rule } = createAuth<
Context,
Scopes,
Grants
>(async (ctx) => {
const accessList = new Set(["guest", "user"]);
return {
// Boolean loaders may be a plain value or a lazy function
isPublic: true,
isLoggedIn: async () => {
// Loaders run lazily, only when a rule asks for them
if (!ctx.userId) {
throw new UnauthenticatedError();
}
return true;
},
// Parameterized loaders receive the value supplied at the call site
hasAccess: (access) => accessList.has(access),
};
});

Scope types determine how you call the accessor:

  • A boolean scope is read as a property, like scope.isLoggedIn.
  • A parameterized scope is called with its argument, like scope.hasAccess('admin'). The argument type comes from the Scopes map (a string, a literal union, an object, anything you choose).

2. Register the app plugin

src/app.ts
import { createApplication } from "@baeta/core";
import { authAppPlugin } from "./lib/auth.ts";
import modules from "./modules/index.ts";

const baeta = createApplication({
modules,
plugins: [authAppPlugin],
});
note

The authAppPlugin must be passed to createApplication. If a module uses auth(...) or authAfter(...) but the plugin isn't registered, Baeta throws at schema build time.

tip

Register authAppPlugin before plugins that perform per-field work (complexity, caching, logging). Middlewares attached by earlier plugins wrap those attached by later plugins, so auth-first means unauthenticated requests bounce immediately without exposing internals. See App Plugins for the contract.

Composing rules

The scope accessor

Use scope to produce a single rule:

FormMeaning
scope.isLoggedInBoolean scope, required to resolve true
scope.hasAccess('admin')Parameterized scope with a typed argument
scope.$granted('readUserPhotos')Granted permission check (see Grants)

The rule combinators

Combine rules with one of four combinators. Each takes two or more rules:

CombinatorSemantics
rule.andAll rules must succeed (parallel)
rule.orAny rule may succeed (parallel; aggregates errors when all fail)
rule.chainRules evaluated in order; stops on first failure
rule.raceRules evaluated in order; stops on first success

Combinators nest:

auth(
rule.and(
scope.isLoggedIn,
rule.or(scope.isPublic, scope.hasAccess("admin")),
),
);

Static rules

Pass a scope (or combined rule) directly to auth():

import { auth, rule, scope } from "../../lib/auth.ts";

// Public or authenticated access
const userQuery = Query.user
.$use(auth(rule.or(scope.isPublic, scope.isLoggedIn)))
.resolve(({ args }) => db.user.findFirst({ where: args.where }));

// Admin-only
const createUserMutation = Mutation.createUser
.$use(auth(scope.hasAccess("admin")))
.resolve(({ args }) => db.user.create({ data: args.data }));

Dynamic rules

Pass a function when the rule depends on request data. Return true to allow, false to deny, or any ScopeRules value to delegate to scope/rule evaluation:

const updateUserMutation = Mutation.updateUser
.$use(
auth(async ({ args, ctx }) => {
const user = await db.user.findFirst({ where: args.where });
if (user && user.id === ctx.userId) {
return true; // Owner, allow
}
return scope.hasAccess("admin");
}),
)
.resolve(/* ... */);

Post-resolution authorization

When the decision depends on the resolved value, use authAfter. It runs after the resolver and avoids the duplicate fetch a pre-resolution dynamic rule would need:

const userQuery = Query.user
.$use(
authAfter((params, result) => {
if (result && result.id === params.ctx.userId) {
return true;
}
return scope.hasAccess("admin");
}),
)
.resolve(({ args }) => db.user.findFirst({ where: args.where }));

Type-wide rules

Chain auth() before $fields to apply rules to every field of a type:

// All Query fields require auth
export default Query.$use(auth(scope.isLoggedIn)).$fields({
user: userQuery,
users: usersQuery,
});

// All User fields require auth
export default User.$use(auth(scope.isLoggedIn)).$fields({
id: User.id.key("id"),
email: User.email.key("email"),
});

Subscriptions

A subscription has two phases (subscribe and resolve) and auth chains onto either or both. Each $use only protects the phase that follows it:

const userCreatedSubscription = Subscription.userCreated
.$use(auth(scope.isLoggedIn)) // protects subscribe
.subscribe(({ ctx }) => ctx.pubsub.subscribe("user-created"))
.$use(auth(scope.isLoggedIn)) // protects resolve
.resolve(({ source }) => source);

Default scopes

Default scopes apply to every operation of Query / Mutation / Subscription and combine with local rules under AND semantics. They're defined via a callback that hands you the typed scope and rule builders:

export const { auth, authAfter, authAppPlugin, scope, rule } = createAuth<
Context,
Scopes,
Grants
>(
async (ctx) => ({
/* loaders */
}),
{
defaultScopes: ({ scope, rule }) => ({
Query: scope.isLoggedIn,
Mutation: scope.isLoggedIn,
Subscription: scope.isLoggedIn,
}),
},
);

When defaultScopes is set, authAppPlugin attaches a fallback middleware to every operation field that doesn't carry an auth() / authAfter(). Unprotected operations can't slip through by accident.

Skipping defaults

skipDefaults opts a single operation out:

const publicContent = Query.publicContent
.$use(auth(scope.isPublic, { skipDefaults: true }))
.resolve(() => getPublicContent());

Grants

Grants propagate a permission from a parent resolver to its descendants, so a child resolver doesn't have to repeat work the parent already authorized.

The model:

  1. A resolver attaches grants to the result object it returns.
  2. A descendant resolver whose params.source is that same object reads the grant with scope.$granted(name).

Grants are stored in a WeakMap keyed by the result object, so they live exactly as long as the result and never leak across requests.

Attaching grants

Pass grants in the second argument of auth() or authAfter(). An array of grant names is the simplest form:

const userQuery = Query.user
.$use(
auth(rule.or(scope.isPublic, scope.isLoggedIn), {
grants: ["readUserPhotos"],
}),
)
.resolve(({ args }) => db.user.findFirst({ where: args.where }));

A function computes grants per result. It receives the resolver params plus the resolved value:

.$use(
auth(scope.isLoggedIn, {
grants: (params, user) => {
return user.id === params.ctx.userId
? ["readUserPhotos", "editProfile"]
: ["readUserPhotos"];
},
}),
);

Consuming grants

Read the grant downstream with scope.$granted:

const userPhotosResolver = User.photos
.$use(auth(scope.$granted("readUserPhotos")))
.resolve(({ source }) =>
db.userPhoto.findMany({ where: { userId: source.id } }),
);

Because grants key off the result object's identity, User.photos only finds the grant when its source is the same User object that Query.user returned.

Array results

When a resolver returns an array, the grant attaches to each element. Child resolvers iterating per item see it:

const usersQuery = Query.users
.$use(
auth(scope.isLoggedIn, {
grants: ["readUserPhotos"], // attached to each returned user
}),
)
.resolve(() => db.user.findMany());

Custom targets

Sometimes the grant belongs to a nested value rather than the resolver's direct return: a wrapper, a nested entity, an object derived from the result. Pass a GrantConfig with a target function:

const meQuery = Query.me
.$use(
auth(scope.isLoggedIn, {
grants: {
grant: "readUserPhotos",
target: (entry) => entry.user, // attach to the nested user, not the wrapper
},
}),
)
.resolve(({ ctx }) => ({
user: db.user.findUniqueOrThrow({ where: { id: ctx.userId } }),
session: ctx.session,
}));

For array results, target runs per entry:

auth(scope.isLoggedIn, {
grants: {
grant: "readUserPhotos",
target: (entry) => entry.user, // each list item's nested user
},
});

target must return a non-primitive; the WeakMap can only key off objects. A primitive return logs a warning and drops the grant for that entry.

You can also pass an array of grant configs to attach different grants to different sub-objects of the same result.

Scope caching

Within a single request, each scope-with-argument combination runs at most once. The cache is per-context and gone when the request finishes.

How keys are derived

For each lookup, Baeta picks a key for the scope's argument in this order:

  1. Custom key function: if cacheKeyMap has an entry for the scope, its return value is the key.
  2. Auto-serialize: for primitives, plain objects, and arrays of those (recursively), Baeta serializes to a stable JSON-shaped string.
  3. Reference identity: anything else (class instances, Date, Map, Set) is used as a Map key by reference. Two structurally-equal but distinct objects won't share a cache entry.

Boolean scopes don't take an argument, so they skip the cache-key path entirely. ScopeCacheKeyMap rejects them at the type level.

When to provide a custom key

The auto-serializer only walks plain objects. If a scope receives a class instance, a Date, or any object with non-enumerable state, the fallback is reference identity, which means a fresh value built per call never hits the cache.

A cacheKeyMap entry fixes that with a stable key:

type Scopes = {
hasRole: string;
ownsResource: { ownerId: string; resourceId: string };
canAccessProject: Project; // class instance
};

createAuth<Context, Scopes, Grants>(
async (ctx) => ({
/* loaders */
}),
{
cacheKeyMap: {
// String args are already serializable, entry is optional.
hasRole: (role) => `role:${role}`,
// Plain objects are serializable, but you can shorten the key if
// many fields are irrelevant to the decision.
ownsResource: ({ ownerId, resourceId }) => `${ownerId}:${resourceId}`,
// Class instances need a custom key; otherwise the cache falls back
// to reference identity and new Project(...) calls never share results.
canAccessProject: (project) => project.id,
},
},
);

Return a stable value (equal inputs produce equal keys). Strings are easiest; any value that compares equal as a Map key works, including a stable object reference.

Concurrency and errors

Values are stored as the in-flight Promise<boolean>, so concurrent rule evaluations all await the same promise instead of re-invoking the loader. If the loader throws, the rejection is cached too: subsequent callers in the same request see the same error instead of retrying.

Custom error handling

createAuth accepts an errorResolver that maps any error thrown during authorization. Defaults:

  • A single GraphQLError passes through unchanged.
  • An AggregateError (from rule.or) collapses into an AggregateGraphQLError, with HTTP status reconciled to 401 if any constituent is unauthenticated.
  • Anything else is logged and rethrown wrapped in InternalServerError.

Override with a (err) => unknown:

import { createAuth } from "@baeta/auth";
import { ForbiddenError } from "@baeta/errors";

createAuth<Context, Scopes, Grants>(loader, {
errorResolver: (err) => {
if (err instanceof Error && err.message === "BANNED") {
return new ForbiddenError("Account suspended");
}
return err; // fall back to default behavior
},
});

Return an Error to throw it. Return any non-error value to rethrow the original.

Reference

For a runnable end-to-end setup, see examples/auth. The full API reference for @baeta/auth lives at API → @baeta/auth.