Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
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