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
16 changes: 16 additions & 0 deletions .changeset/safe-platform-user-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@nestm/better-auth": minor
---

Add a stock-Better-Auth-compatible platform user-management facade with bounded user queries and
profile/role/ban mutations, token-free active session summaries, safe owned-session-id revocation,
and an opt-in policy closing the raw admin HTTP namespace. Generalize the TypeORM organization
lifecycle coordinator into one namespaced organization/user/platform control-plane coordinator
while preserving the organization-only API. Canonicalize raw request targets before auth mount and
policy matching so encoded dot segments cannot bypass protected routes, and dual-acquire legacy
plus namespaced organization advisory locks for safe rolling upgrades. The guard now rejects
retained sessions for actively banned users while respecting valid expired bans, and expiry-omitted
re-bans no longer retain a previous temporary expiry. Stock-valid hostile profile and session
display fields are projected into explicit bounded/redacted outputs instead of blocking admin
enforcement or safe session revocation. Organization member identity fields use the same bounded,
explicit projection so hostile profile display data cannot block role changes or removals.
169 changes: 152 additions & 17 deletions README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/better-auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { betterAuthInstanceProvider } from "./providers/auth-instance.provider.t
import { BetterAuthService } from "./services/better-auth.service.ts";
import { BetterAuthSessionService } from "./services/better-auth-session.service.ts";
import { BetterAuthOrganizationService } from "./services/better-auth-organization.service.ts";
import { BetterAuthUserManagementService } from "./services/better-auth-user-management.service.ts";
import { BetterAuthGuard } from "./guards/better-auth.guard.ts";
import { BetterAuthHookRegistry } from "./hooks/hook-registry.service.ts";
import { BetterAuthDatabaseHookRegistry } from "./hooks/database-hook-registry.service.ts";
Expand Down Expand Up @@ -76,6 +77,7 @@ function assertRoutePolicyClass(candidate: Type<unknown>): void {
BetterAuthService,
BetterAuthSessionService,
BetterAuthOrganizationService,
BetterAuthUserManagementService,
BetterAuthGuard,
BetterAuthHookRegistry,
BetterAuthDatabaseHookRegistry,
Expand All @@ -91,6 +93,7 @@ function assertRoutePolicyClass(candidate: Type<unknown>): void {
BetterAuthService,
BetterAuthSessionService,
BetterAuthOrganizationService,
BetterAuthUserManagementService,
BetterAuthGuard,
BetterAuthHookRegistry,
BetterAuthDatabaseHookRegistry,
Expand Down
13 changes: 9 additions & 4 deletions src/guards/auth-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
if (!wsExceptionCtor) {
try {
const mod = await loadOptionalModule("@nestjs/websockets");
wsExceptionCtor = mod.WsException as WsExceptionCtor;

Check warning on line 12 in src/guards/auth-errors.ts

View workflow job for this annotation

GitHub Actions / check

typescript(no-unsafe-type-assertion)

src/guards/auth-errors.ts:12:22: Unsafe type assertion: type 'WsExceptionCtor' is more narrow than the original type.
} catch {
throw new Error(
"@nestjs/websockets is required for WebSocket execution contexts. " +
Expand All @@ -30,15 +30,20 @@
kind: AuthContextKind,
status: AuthErrorStatus,
message?: string,
code?: string,
): Promise<Error> {
const statusCode = status === "UNAUTHORIZED" ? 401 : 403;
const structuredError = code ? { statusCode, code, message: message ?? status } : undefined;
if (kind === "ws") {
const WsException = await getWsException();
return new WsException(message ?? status);
return new WsException(structuredError ?? message ?? status);
}
if (kind === "rpc") {
return new Error(message ?? status);
return structuredError
? Object.assign(new Error(structuredError.message), structuredError)
: new Error(message ?? status);
}
return status === "UNAUTHORIZED"
? new UnauthorizedException(message)
: new ForbiddenException(message);
? new UnauthorizedException(structuredError ?? message)
: new ForbiddenException(structuredError ?? message);
}
18 changes: 18 additions & 0 deletions src/guards/better-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ interface GuardSession {

type ReflectTarget = Parameters<Reflector["get"]>[1];

function hasActiveBan(user: GuardSession["user"], now = Date.now()): boolean {
if (user?.banned !== true) return false;
const value = user.banExpires;
if (value === undefined || value === null) return true;
const expiration =
value instanceof Date
? value.getTime()
: typeof value === "string" || typeof value === "number"
? new Date(value).getTime()
: Number.NaN;
// Match Better Auth's strict `< Date.now()` expiry rule and fail closed for
// malformed adapter values instead of accidentally re-enabling the account.
return !Number.isFinite(expiration) || expiration >= now;
}

function matchesRequiredRole(
role: string | readonly string[] | null | undefined,
required: readonly string[],
Expand Down Expand Up @@ -129,6 +144,9 @@ export class BetterAuthGuard implements CanActivate {
}
}

if (session && hasActiveBan(session.user)) {
throw await createAuthError(kind, "FORBIDDEN", "User is banned.", "BANNED_USER");
}
if (anonymous) return true;

if (!session) {
Expand Down
28 changes: 28 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export type {
} from "./interfaces/better-auth-module-options.interface.ts";
export type { BetterAuthFeatureOptions } from "./interfaces/better-auth-feature-options.interface.ts";
export type { BetterAuthOptionsFactory } from "./interfaces/better-auth-options-factory.interface.ts";
export type {
BetterAuthControlPlaneLifecycleCoordinator,
BetterAuthControlPlaneLifecycleScope,
} from "./interfaces/better-auth-control-plane-lifecycle.interface.ts";
export type { BetterAuthOrganizationLifecycleCoordinator } from "./interfaces/better-auth-organization-lifecycle.interface.ts";
export {
deny,
Expand Down Expand Up @@ -69,6 +73,7 @@ export type { BetterAuthApiInvocation } from "./services/better-auth.service.ts"
export {
BetterAuthSessionService,
type BetterAuthSessionBulkRevocationResult,
type BetterAuthSessionRedactedField,
type BetterAuthSessionRevocationResult,
type BetterAuthSessionSummary,
} from "./services/better-auth-session.service.ts";
Expand All @@ -80,9 +85,28 @@ export {
type BetterAuthOrganizationMember,
type BetterAuthOrganizationMemberList,
type BetterAuthOrganizationMemberListOptions,
type BetterAuthOrganizationMemberUserRedactedField,
type BetterAuthOrganizationRequestHeaders,
type BetterAuthReceivedOrganizationInvitation,
} from "./services/better-auth-organization.service.ts";
export {
BetterAuthUserManagementService,
type BetterAuthManagedUser,
type BetterAuthManagedUserBanOptions,
type BetterAuthManagedUserListFilter,
type BetterAuthManagedUserListOptions,
type BetterAuthManagedUserPage,
type BetterAuthManagedUserProfileUpdate,
type BetterAuthManagedUserRedactedField,
type BetterAuthManagedUserSearchField,
type BetterAuthManagedUserSearchOperator,
type BetterAuthManagedUserSession,
type BetterAuthManagedUserSessionBulkRevocationResult,
type BetterAuthManagedUserSessionRedactedField,
type BetterAuthManagedUserSessionRevocationResult,
type BetterAuthManagedUserSortDirection,
type BetterAuthManagedUserSortField,
} from "./services/better-auth-user-management.service.ts";
export {
mapBetterAuthApiError,
normalizeBetterAuthHeaders,
Expand All @@ -98,6 +122,10 @@ export {
BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS,
BetterAuthOrganizationControlPlaneRoutePolicy,
} from "./policies/organization-control-plane-route-policy.ts";
export {
BETTER_AUTH_USER_MANAGEMENT_PATHS,
BetterAuthUserManagementRoutePolicy,
} from "./policies/user-management-route-policy.ts";
export { createAuthError, type AuthErrorStatus } from "./guards/auth-errors.ts";
export {
MUTATION_ORIGIN_GUARD_OPTIONS,
Expand Down
18 changes: 18 additions & 0 deletions src/interfaces/better-auth-control-plane-lifecycle.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Namespaces serialized Better Auth control-plane mutations. */
export type BetterAuthControlPlaneLifecycleScope = "organization" | "user" | "platform";

/**
* Coordinates control-plane mutations that must observe one serialized view
* of a resource's state.
*
* Implementations must keep the callback inside the same transaction/context
* used by the configured Better Auth database adapter. Scope is part of the
* resource identity so equal organization and user ids never share a lock.
*/
export interface BetterAuthControlPlaneLifecycleCoordinator {
run<T>(
scope: BetterAuthControlPlaneLifecycleScope,
resourceId: string,
operation: () => Promise<T>,
): Promise<T>;
}
10 changes: 10 additions & 0 deletions src/interfaces/better-auth-module-options.interface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BetterAuthOptions } from "better-auth";
import type { AnyAuth } from "../types/auth.types.ts";
import type { BetterAuthRoutePolicy } from "../policies/route-policy.ts";
import type { BetterAuthControlPlaneLifecycleCoordinator } from "./better-auth-control-plane-lifecycle.interface.ts";
import type { BetterAuthOrganizationLifecycleCoordinator } from "./better-auth-organization-lifecycle.interface.ts";

/**
Expand Down Expand Up @@ -55,9 +56,18 @@ interface BetterAuthModuleCommonOptions {
/** Maximum bytes buffered from an untouched stream for route-policy body inspection. Default 1 MiB. */
routePolicyBodyLimit?: number;
interop?: BetterAuthInteropOptions;
/**
* Optional, shared serialization boundary for organization and user
* control-plane mutations. Prefer this over the legacy organization-only
* coordinator when more than one control-plane service is enabled.
*/
controlPlaneLifecycle?: BetterAuthControlPlaneLifecycleCoordinator;
/**
* Optional serialization boundary for organization membership and invitation
* mutations made through `BetterAuthOrganizationService`.
*
* @deprecated Prefer `controlPlaneLifecycle`, which uses one transaction
* context for every Better Auth control-plane service.
*/
organizationLifecycle?: BetterAuthOrganizationLifecycleCoordinator;
}
Expand Down
30 changes: 26 additions & 4 deletions src/mount/mount.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "./body-recovery.ts";
import { resolveCorsHandler } from "./cors.ts";
import {
canonicalizeRequestTarget,
getNodeRequest,
getNodeResponse,
matchesBasePath,
Expand Down Expand Up @@ -75,12 +76,28 @@ export class BetterAuthMountService {

httpAdapter.use(
(req: AdapterRequest, res: AdapterResponse, next: (error?: unknown) => void) => {
if (!matchesBasePath(req, basePath)) {
const nodeReq = getNodeRequest(req);
const nodeRes = getNodeResponse(res);
const requestTarget = canonicalizeRequestTarget(req);
if (!requestTarget) {
void writeRoutePolicyResponse(
Response.json(
{
statusCode: 400,
code: "INVALID_REQUEST_TARGET",
message: "Request target is invalid.",
},
{ status: 400 },
),
nodeRes,
(nodeReq.method ?? "GET").toUpperCase(),
).catch(next);
return;
}
if (!matchesBasePath(requestTarget.pathname, basePath)) {
next();
return;
}
const nodeReq = getNodeRequest(req);
const nodeRes = getNodeResponse(res);
if (cors?.(nodeReq, nodeRes)) return;
const execute = async (): Promise<void> => {
if (routePolicy || this.routePolicies.size > 0) {
Expand All @@ -99,7 +116,12 @@ export class BetterAuthMountService {
);
return;
}
const context = createRoutePolicyContext(req, nodeReq, basePath, recoveredBody);
const context = createRoutePolicyContext(
nodeReq,
basePath,
recoveredBody,
requestTarget,
);
const policyResponse = await this.routePolicies.run(context, routePolicy);
if (policyResponse instanceof Response) {
await writeRoutePolicyResponse(policyResponse, nodeRes, context.method);
Expand Down
33 changes: 26 additions & 7 deletions src/mount/request-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,38 @@ export type AdapterRequest = any;
// oxlint-disable-next-line typescript/no-explicit-any
export type AdapterResponse = any;

/** One WHATWG-normalized view of the request target, reused by mount and policies. */
export interface CanonicalRequestTarget {
/** The original request target, including its query string. */
readonly url: string;
/** The WHATWG URL pathname seen by Better Auth's downstream Fetch router. */
readonly pathname: string;
}

export function getRequestUrl(req: AdapterRequest): string {
return req?.originalUrl ?? req?.url ?? req?.raw?.url ?? "";
return req?.raw?.url ?? req?.url ?? req?.originalUrl ?? "";
}

export function getRequestPath(req: AdapterRequest): string {
/**
* Apply the same WHATWG URL parsing that the downstream Node-to-Fetch bridge
* applies before Better Auth routes a request. In particular, encoded dot
* segments are removed here before any base-path or route-policy decision.
*/
export function canonicalizeRequestTarget(req: AdapterRequest): CanonicalRequestTarget | undefined {
const url = getRequestUrl(req);
const queryIndex = url.indexOf("?");
return queryIndex === -1 ? url : url.slice(0, queryIndex);
if (typeof url !== "string" || url.length === 0) return undefined;
try {
return {
url,
pathname: new URL(`http://better-auth.invalid${url}`).pathname,
};
} catch {
return undefined;
}
}

export function matchesBasePath(req: AdapterRequest, basePath: string): boolean {
const path = getRequestPath(req);
return path === basePath || path.startsWith(`${basePath}/`);
export function matchesBasePath(pathname: string, basePath: string): boolean {
return pathname === basePath || pathname.startsWith(`${basePath}/`);
}

/** Unwraps Fastify's `req.raw`; Express requests are already Node requests. */
Expand Down
13 changes: 5 additions & 8 deletions src/mount/route-policy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http";
import type { BetterAuthRoutePolicyContext } from "../policies/route-policy.ts";
import type { AdapterRequest } from "./request-utils.ts";
import { getRequestPath, getRequestUrl } from "./request-utils.ts";
import type { CanonicalRequestTarget } from "./request-utils.ts";
import type { RecoveredBody } from "./body-recovery.ts";

function toWebHeaders(headers: IncomingHttpHeaders): Headers {
Expand All @@ -23,18 +22,16 @@ function resolveAuthPath(pathname: string, basePath: string): string {
}

export function createRoutePolicyContext(
frameworkReq: AdapterRequest,
nodeReq: IncomingMessage,
basePath: string,
recoveredBody: RecoveredBody,
target: CanonicalRequestTarget,
): BetterAuthRoutePolicyContext {
const url = getRequestUrl(frameworkReq);
const pathname = getRequestPath(frameworkReq);
return {
method: (nodeReq.method ?? "GET").toUpperCase(),
url,
pathname,
authPath: resolveAuthPath(pathname, basePath),
url: target.url,
pathname: target.pathname,
authPath: resolveAuthPath(target.pathname, basePath),
headers: toWebHeaders(nodeReq.headers),
body: recoveredBody.body,
rawBody: recoveredBody.rawBody,
Expand Down
26 changes: 26 additions & 0 deletions src/policies/user-management-route-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { HttpStatus, Injectable } from "@nestjs/common";
import { AuthRoutePolicy } from "../decorators/route-policy.decorator.ts";
import { deny, type BetterAuthRoutePolicyHandler } from "./route-policy.ts";

/**
* Segment-safe wildcard for every HTTP route owned by Better Auth's admin
* plugin, including routes introduced by a compatible future release.
*/
export const BETTER_AUTH_USER_MANAGEMENT_PATHS = ["/admin/*"] as const;

/**
* Opt-in policy that closes Better Auth's raw admin HTTP namespace after an
* application exposes a user-management facade backed by
* {@link BetterAuthUserManagementService}.
*/
@AuthRoutePolicy({ path: BETTER_AUTH_USER_MANAGEMENT_PATHS, order: -100 })
@Injectable()
export class BetterAuthUserManagementRoutePolicy implements BetterAuthRoutePolicyHandler {
evaluate() {
return deny(HttpStatus.FORBIDDEN, {
statusCode: HttpStatus.FORBIDDEN,
code: "USER_MANAGEMENT_FACADE_REQUIRED",
message: "Use the application's user-management endpoints.",
});
}
}
Loading
Loading