Security@rhythmjs/security
Authentication & authorization
Resolve the user once, derive it onto the context, and guard what follows: predicates, roles, and permissions with honest 401/403 semantics.
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.
bun add @rhythmjs/security @rhythmjs/rhythm @rhythmjs/routerAttach 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.
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.
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 checkTwo 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.
import { getBasicCredentials } from "@rhythmjs/security/authentication";
const credentials = getBasicCredentials(ctx.request);
// { username: "ada", password: "s3cret" } | nullAuthorize 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.
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).
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.