Authentication
JORVEL ships the auth primitives, not a framework lock-in: a signed-cookie session (getSession / requireUser), a middleware gate, and OAuth 2.0 + PKCE helpers with presets for GitHub, Google, and Microsoft. All live in @jorvel/security and are runtime-agnostic (Web Crypto — edge, Node, Workers).
Sessions
Sessions are stateless: the payload is JSON, base64url-encoded, and signed with HMAC-SHA256. The cookie is the session — tamper-evident, no server store. For revocation or large payloads, store a DB session id as the payload instead.
import { SessionManager } from '@jorvel/security';
const sessions = new SessionManager<{ id: string; role: string }>({
secret: process.env.SESSION_SECRET!, // long random; rotate via verifySecrets: [...old]
maxAge: 7 * 24 * 60 * 60, // 7 days; embedded as exp for expiry checks
});
// after a successful login:
const setCookie = await sessions.seal({ id: user.id, role: user.role });
return new Response(null, { status: 302, headers: { location: '/', 'set-cookie': setCookie } });
// reading (returns null when absent / expired / tampered):
const user = await sessions.read(request); // pass a Request or a Cookie header string
// logout:
return new Response(null, { headers: { 'set-cookie': sessions.destroy() } });Cookies default to HttpOnly + Secure + SameSite=Lax. Rotate the secret without logging everyone out by listing the previous secret in verifySecrets — old cookies still verify, new ones are signed with the new key.
requireUser
requireUser throws SessionRequiredError (carrying status: 401) when there is no valid session — catch it at your route/adapter boundary to redirect to login.
import { requireUser, SessionRequiredError } from '@jorvel/security';
try {
const user = await requireUser(request, { secret: process.env.SESSION_SECRET! });
// … render the protected route with `user`
} catch (e) {
if (e instanceof SessionRequiredError) {
return Response.redirect('/login', 302);
}
throw e;
}Middleware route gating
Combine the session with route middleware to gate whole path prefixes in one place:
import { defineMiddleware, redirect, next } from '@jorvel/runtime';
import { getSession } from '@jorvel/security';
export default defineMiddleware(async (ctx) => {
if (!ctx.pathname.startsWith('/dashboard')) return next();
const user = await getSession(ctx.request ?? '', { secret: process.env.SESSION_SECRET! });
if (!user) return redirect('/login?from=' + encodeURIComponent(ctx.pathname));
ctx.state.user = user; // downstream middlewares + loaders can read it
return next();
});OAuth (PKCE) with provider presets
buildAuthorizeUrl / exchangeCodeForTokensimplement OAuth 2.0 with PKCE. Provider presets supply the endpoints + default scopes so you don't hand-copy URLs. Below: a GitHub login round-trip.
import {
OAUTH_PROVIDERS, generatePkceChallenge, buildAuthorizeUrl,
parseAuthorizationResponse, exchangeCodeForTokens, fetchUserInfo,
} from '@jorvel/security';
const gh = OAUTH_PROVIDERS.github;
// 1. /login/github — redirect to GitHub
const { verifier, challenge } = await generatePkceChallenge();
const state = crypto.randomUUID();
// persist { verifier, state } in a short-lived signed cookie, then:
const url = buildAuthorizeUrl({
authorizationEndpoint: gh.authorizationEndpoint,
clientId: process.env.GITHUB_CLIENT_ID!,
redirectUri: 'https://app.example.com/callback/github',
scope: gh.defaultScope,
state,
codeChallenge: challenge,
});
// 2. /callback/github — exchange code → tokens → profile → session
const { code } = parseAuthorizationResponse(callbackUrl, savedState);
const tokens = await exchangeCodeForTokens({
tokenEndpoint: gh.tokenEndpoint,
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
code,
redirectUri: 'https://app.example.com/callback/github',
codeVerifier: savedVerifier,
});
const profile = await fetchUserInfo('github', tokens.access_token);
const setCookie = await sessions.seal({ id: String(profile.id), role: 'user' });Never put secrets in the bundle
SESSION_SECRET and OAuth client secrets are server-only env vars. Read them in middleware / adapters / server routes — never in code that ships to the browser.RBAC — roles & permissions
Gate on permissions, not just login. createRbac maps roles → permission strings (supports * and posts:* wildcards) and pairs with the session.
import { createRbac } from '@jorvel/security';
const rbac = createRbac({
roles: {
admin: ['*'],
editor: ['posts:*', 'media:read'],
viewer: ['posts:read'],
},
});
const user = await requireUser(request, { secret });
rbac.can(user.roles, 'posts:write'); // editor → true
rbac.requirePermission(user.roles, 'users:delete'); // throws ForbiddenError (403) unless admin
rbac.hasRole(user.roles, 'admin');Pairs with CSRF
