JORVEL

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.

ts
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.

ts
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:

apps/shell/src/middleware.ts
ts
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.

ts
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.

ts
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

Cookie-based auth needs CSRF protection on state-changing requests. See the Forms & CSRF page for the double-submit cookie helper.