Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rbac.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@thexjs/auth": minor
---

Add role-based access control. Sessions now carry `roles`/`permissions` (from the provider's user or a new `resolveRoles` hook on `defineAuth`, snapshotted at session creation). New pure helpers (`hasRole`, `hasAnyRole`, `hasPermission`, `hasAllPermissions`), fail-closed guards (`requireRole`, `requirePermission`, `requireAuth`), and middleware adapters (`toMiddleware`, plus `auth.requireRole(...)` / `auth.requirePermission(...)` / `auth.requireAuth()` / `auth.guard(...)`) that plug into the framework's route middleware. Signed out → 401, authenticated but unauthorized → 403, optional `redirectTo` for signed-out users.
6 changes: 4 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ tracking list, not a commitment schedule — each item gets its own design first
emission (request counts, latency histograms, build/runtime errors) is not
wired up.
2. **Authorization**: role-based access control / permissions layer. Sessions
exist (`@thexjs/auth`), but there is no framework-level concept of roles,
scopes, or per-route authorization guards.
carry roles/permissions (via `resolveRoles`), and `requireRole`/
`requirePermission`/`requireAuth` guards plug into route middleware.
Shipped in `@thexjs/auth`; keep tracking until an example app exercises a
full roles + scopes flow end-to-end.
3. **Scale validation**: load and concurrency testing. No published
benchmarks or soak tests for SSR throughput, the rate limiter under load,
or shared-store behavior across replicas.
Expand Down
64 changes: 62 additions & 2 deletions packages/auth/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHmac } from "node:crypto";
import { checkCsrf } from "@thexjs/core";
import type { MiddlewareFn } from "@thexjs/core";
import { readCookie } from "./cookies";
import {
type CredentialsProvider,
Expand All @@ -11,6 +12,14 @@ import {
fetchUserInfo,
toOAuth2,
} from "./providers";
import {
type GuardMiddlewareOptions,
type SessionGuard,
requireAuth as requireAuthGuard,
requirePermission as requirePermissionGuard,
requireRole as requireRoleGuard,
toMiddleware,
} from "./rbac";
import type { SessionStore } from "./session";
import type { AuthUser, Session } from "./types";

Expand Down Expand Up @@ -38,6 +47,14 @@ export interface AuthConfig {
successRedirect?: string;
/** Where to redirect the browser after sign-out. Default: `/`. */
signOutRedirect?: string;
/**
* Resolves the roles/permissions granted to a user at session creation.
* The result is snapshotted into the session's `user` object, so RBAC
* guards (`auth.requireRole`, `auth.requirePermission`) read it without a
* per-request lookup. When omitted, roles come from the provider's
* `authorize`/`profile` result (its `roles`/`permissions` fields).
*/
resolveRoles?: (user: AuthUser) => Promise<Partial<Pick<AuthUser, "roles" | "permissions">>>;
}

export interface ResolvedAuthConfig {
Expand Down Expand Up @@ -74,6 +91,27 @@ export interface Auth {
setSessionCookie(res: Response, user: AuthUser, provider: string): Promise<Response>;
/** Clears the session cookie from `res` and revokes the session, if any. */
clearSessionCookie(res: Response, req?: Request): Promise<Response>;
/**
* Route guard middleware: requires a signed-in session for the route.
* Returns a core `MiddlewareFn` for `export const middleware` / `export const auth`.
*/
requireAuth(options?: GuardMiddlewareOptions): MiddlewareFn;
/**
* Route guard middleware: requires the session's user to have any of the
* given roles (a single role string or an array). Fail-closed: signed out →
* 401, signed in but unauthorized → 403. Pass `redirectTo` to redirect
* signed-out users instead of returning 401.
*/
requireRole(roles: string | string[], options?: GuardMiddlewareOptions): MiddlewareFn;
/**
* Route guard middleware: requires the session's user to have every one of
* the given permissions (a single string or an array). Fail-closed: signed
* out → 401, signed in but unauthorized → 403. Pass `redirectTo` to
* redirect signed-out users instead of returning 401.
*/
requirePermission(permissions: string | string[], options?: GuardMiddlewareOptions): MiddlewareFn;
/** Low-level guard combinator: `auth.guard(requireRole("admin"), { redirectTo: "/login" })`. */
guard(guard: SessionGuard, options?: GuardMiddlewareOptions): MiddlewareFn;
}

/** Resolves `config` against defaults and returns the auth helper. */
Expand Down Expand Up @@ -130,10 +168,18 @@ export function defineAuth(config: AuthConfig): Auth {
return res;
};

const snapshotUser = (user: AuthUser): AuthUser => {
const snapshotUser = async (user: AuthUser): Promise<AuthUser> => {
const snapshot: AuthUser = { id: user.id };
if (typeof user.name === "string") snapshot.name = user.name;
if (typeof user.email === "string") snapshot.email = user.email;
if (config.resolveRoles) {
const granted = await config.resolveRoles(user);
if (Array.isArray(granted.roles)) snapshot.roles = [...granted.roles];
if (Array.isArray(granted.permissions)) snapshot.permissions = [...granted.permissions];
} else {
if (Array.isArray(user.roles)) snapshot.roles = [...user.roles];
if (Array.isArray(user.permissions)) snapshot.permissions = [...user.permissions];
}
return snapshot;
};

Expand All @@ -144,7 +190,7 @@ export function defineAuth(config: AuthConfig): Auth {
token: await hash(token),
userId: user.id,
provider,
user: snapshotUser(user),
user: await snapshotUser(user),
expiresAt: now + resolved.sessionMaxAge * 1000,
createdAt: now,
};
Expand Down Expand Up @@ -313,5 +359,19 @@ export function defineAuth(config: AuthConfig): Auth {
getSession,
setSessionCookie,
clearSessionCookie,
requireAuth(options) {
return toMiddleware(getSession, requireAuthGuard(), options);
},
requireRole(roles, options) {
const list = Array.isArray(roles) ? roles : [roles];
return toMiddleware(getSession, requireRoleGuard(...list), options);
},
requirePermission(permissions, options) {
const list = Array.isArray(permissions) ? permissions : [permissions];
return toMiddleware(getSession, requirePermissionGuard(...list), options);
},
guard(guard, options) {
return toMiddleware(getSession, guard, options);
},
};
}
15 changes: 15 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,19 @@ export type {
GitHubProviderConfig,
OAuthTokens,
} from "./providers";
export {
hasRole,
hasAnyRole,
hasPermission,
hasAllPermissions,
requireAuth,
requireRole,
requirePermission,
toMiddleware,
} from "./rbac";
export type {
AuthGuardResult,
SessionGuard,
GuardMiddlewareOptions,
} from "./rbac";
export type { AuthUser, Session } from "./types";
Loading
Loading