rhythmjs

Search documentation

Search guides, the tutorial and every package.

On this page

Install#

@rhythmjs/security ships six independent middleware modules, each on its own subpath export; there is no root barrel. This page covers /authentication and /authorization; CORS & CSRF and rate limiting & secure headers have their own pages.

Shell
bun add @rhythmjs/security @rhythmjs/rhythm @rhythmjs/router

Attach the user#

attachUser(resolve) runs your resolver once per request and derives ctx.user onto the context (UserContext<TUser>). The resolver receives the full RhythmHttpContext, may be async, and whatever it returns is normalized with ?? null: returning undefined and returning null both mean anonymous. Identity stays in your hands: a session lookup, a token verification, a database call; the middleware only owns the plumbing.

TypeScript
import { attachUser, getBearerToken } from "@rhythmjs/security/authentication";

const withUser = attachUser(async (ctx) => {
  const token = getBearerToken(ctx.request);
  return token === null ? null : await users.byToken(token);
});

new RhythmRouter().use(withUser).get("/me", (ctx) => {
  ctx.json(ctx.user); // TUser | null
});

Guard with requireAuthentication#

requireAuthentication() stops anonymous requests and narrows ctx.user to non-null for everything downstream. The rejection is configurable: by default it answers 401 (status and message override it); redirectTo answers a 302 to your login page instead; challenge sets a WWW-Authenticate header (for example "Bearer" or "Basic realm=admin") alongside the 401.

TypeScript
import { attachUser, requireAuthentication } from "@rhythmjs/security/authentication";

new RhythmRouter()
  .use(attachUser((ctx) => sessionUser(ctx)))
  .use(requireAuthentication({ challenge: "Bearer" }))
  .get("/me", (ctx) => ctx.json(ctx.user)); // ctx.user: TUser, narrowed, no null check

Two companions cover the remaining flows: isAuthenticated(ctx) is the underlying type-guard predicate, usable in your own branches, and redirectIfAuthenticated(to) is the login-page inverse: an already-signed-in user is redirected away, everyone else falls through.

Parse Authorization headers#

Two parsers read the Authorization header without touching the context. getBearerToken(request) matches Bearer case-insensitively, trims the token, and returns null for a missing header, a different scheme, or an empty token. getBasicCredentials(request) base64-decodes Basic credentials byte-safely (UTF-8 usernames and passwords survive), splits on the first :, and returns { username, password }, or null for malformed input rather than throwing.

TypeScript
import { getBasicCredentials } from "@rhythmjs/security/authentication";

const credentials = getBasicCredentials(ctx.request);
// { username: "ada", password: "s3cret" } | null

Authorize with predicates#

@rhythmjs/security/authorization builds on ctx.user. The primitive is authorize(check): any sync or async predicate over the context; a refusal answers 403 (override with status / message) and the chain stops.

TypeScript
import { authorize } from "@rhythmjs/security/authorization";

documentsRouter.use(
  authorize(async (ctx) => (await loadDocument(ctx)).ownerId === ctx.user.id, {
    message: "Not your document",
  }),
);

Roles and permissions#

requireRoles(required) and requirePermissions(required) are the common cases packaged. They read user.roles and user.permissions by default; override with the roles / permissions extractor options when your user shape differs. The match defaults differ on purpose: roles match "any" (one qualifying role suffices), permissions match "all" (every listed permission is required).

TypeScript
import { requireRoles, requirePermissions } from "@rhythmjs/security/authorization";

adminRouter.use(requireRoles(["admin", "ops"])); // any of the two
reportsRouter.use(requirePermissions(["reports.read", "reports.export"])); // all of them
legacyRouter.use(requireRoles(["editor"], { roles: (user) => user.groups }));

401 versus 403#

The two modules split the status codes the way HTTP intends. Missing identity is 401: requireAuthentication answers it, and so do requireRoles / requirePermissions when ctx.user is null: an anonymous request is never told it lacks a role. Present-but-insufficient identity is 403: authorize refusals and failed role or permission checks. Rejections use the router's ctx.error(), so your exception filters and error mapping see them like any other error response.