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

Allow the TypeORM adapter's Better Auth transactions to join an application-owned transaction
returned by `getManager`. This keeps auth mutations atomic with audit and outbox writes made
through the same scoped manager, while retaining `dataSource.transaction()` as the fallback.
7 changes: 7 additions & 0 deletions .changeset/nest-api-invocation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@nestm/better-auth": minor
---

Add a plugin-aware `BetterAuthService.invokeApi()` boundary for application-owned Nest
controllers. It normalizes Node request headers to Web Headers, preserves endpoint result types,
and translates Better Auth API errors into stable Nest HTTP exceptions.
7 changes: 7 additions & 0 deletions .changeset/portable-typeorm-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@nestm/better-auth": patch
---

Make the TypeORM adapter accept validated structural DataSource, metadata, and manager capabilities
so linked-workspace consumers do not need casts when TypeORM is installed at multiple physical
paths.
8 changes: 8 additions & 0 deletions .changeset/safe-session-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@nestm/better-auth": minor
---

Add `BetterAuthSessionService`, an injectable application-facing session facade with token-free
summaries, authoritative current-session detection, strict caller-owned id revocation, and bulk
revocation helpers. Add an opt-in `BetterAuthSessionManagementRoutePolicy` that blocks Better
Auth's raw token-bearing HTTP session endpoints once an application facade is mounted.
9 changes: 9 additions & 0 deletions .changeset/stock-organization-control-plane.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@nestm/better-auth": minor
---

Add a stock-Better-Auth-compatible organization control plane with normalized member and
invitation results, ID-bound invitation resend, serialized lifecycle mutations, and raw-route
policy enforcement. Add a PostgreSQL TypeORM coordinator that shares one application-owned
transaction and organization advisory lock with the Better Auth adapter. Active-organization
guards now bypass cookie caches and verify live membership before authorizing a request.
5 changes: 5 additions & 0 deletions .changeset/strict-mutation-origins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nestm/better-auth": minor
---

Add a reusable, fail-closed Nest `MutationOriginGuard` with strict trusted-origin canonicalization and Fetch Metadata fallback for state-changing controller routes.
231 changes: 206 additions & 25 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"@nestjs/core": "^12.0.0-alpha.5",
"@nestjs/graphql": "^13.0.0 || ^14.0.0",
"@nestjs/websockets": "^12.0.0-alpha.5",
"better-auth": ">=1.6.0 <2.0.0",
"better-auth": ">=1.6.26 <1.7.0-0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"typeorm": "^1.1.0"
Expand Down
6 changes: 6 additions & 0 deletions src/better-auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
} from "./better-auth.tokens.ts";
import { betterAuthInstanceProvider } from "./providers/auth-instance.provider.ts";
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 { 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 @@ -72,6 +74,8 @@ function assertRoutePolicyClass(candidate: Type<unknown>): void {
resolveAuthBasePath(auth, options.basePath),
},
BetterAuthService,
BetterAuthSessionService,
BetterAuthOrganizationService,
BetterAuthGuard,
BetterAuthHookRegistry,
BetterAuthDatabaseHookRegistry,
Expand All @@ -85,6 +89,8 @@ function assertRoutePolicyClass(candidate: Type<unknown>): void {
BETTER_AUTH_MODULE_OPTIONS,
BETTER_AUTH_BASE_PATH,
BetterAuthService,
BetterAuthSessionService,
BetterAuthOrganizationService,
BetterAuthGuard,
BetterAuthHookRegistry,
BetterAuthDatabaseHookRegistry,
Expand Down
2 changes: 1 addition & 1 deletion src/decorators/access-control.decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const Roles = Reflector.createDecorator<string | readonly string[], strin
transform: toRoleArray,
});

/** Requires an active organization on the session (organization plugin). */
/** Requires an active organization and a live membership in it (organization plugin). */
export const RequireActiveOrg = Reflector.createDecorator<void, true>({
key: METADATA_KEY.requireActiveOrg,
transform: () => true,
Expand Down
50 changes: 40 additions & 10 deletions src/guards/better-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ export class BetterAuthGuard implements CanActivate {
// Idempotency: APP_GUARD + @UseGuards on the same route must not double-fetch.
session = (request.session ?? null) as GuardSession | null;
} else {
session = ((await this.auth.api.getSession({ headers })) ?? null) as GuardSession | null;
session = ((await this.auth.api.getSession({
headers,
query: { disableCookieCache: true },
})) ?? null) as GuardSession | null;
if (request) {
request.session = session;
request.user = session?.user ?? null;
Expand All @@ -136,8 +139,16 @@ export class BetterAuthGuard implements CanActivate {
const orgRoles = this.reflector.getAllAndOverride(OrgRoles, targets);
const requireActiveOrg =
this.reflector.getAllAndOverride(RequireActiveOrg, targets) === true || !!orgRoles;
if (requireActiveOrg && !session.session?.activeOrganizationId) {
throw await createAuthError(kind, "FORBIDDEN", "Active organization is required");
const activeOrganizationId = session.session?.activeOrganizationId;
let activeMemberRole: string | string[] | null | undefined;
if (requireActiveOrg) {
if (!activeOrganizationId) {
throw await createAuthError(kind, "FORBIDDEN", "Active organization is required");
}
activeMemberRole = await this.getActiveMemberRole(headers, activeOrganizationId);
}
if (requireActiveOrg && activeMemberRole === null) {
throw await createAuthError(kind, "FORBIDDEN", "Active organization membership is required");
}

const roles = this.reflector.getAllAndOverride(Roles, targets);
Expand All @@ -146,8 +157,7 @@ export class BetterAuthGuard implements CanActivate {
}

if (orgRoles) {
const memberRole = await this.getActiveMemberRole(headers);
if (!matchesRequiredRole(memberRole, orgRoles)) {
if (!matchesRequiredRole(activeMemberRole, orgRoles)) {
throw await createAuthError(kind, "FORBIDDEN", "Insufficient organization permissions");
}
}
Expand All @@ -159,7 +169,13 @@ export class BetterAuthGuard implements CanActivate {

const memberPermission = this.reflector.getAllAndOverride(MemberHasPermission, targets);
if (memberPermission) {
await this.checkPermission(kind, headers, memberPermission, "hasPermission");
await this.checkPermission(
kind,
headers,
memberPermission,
"hasPermission",
session.session?.activeOrganizationId,
);
}

return true;
Expand Down Expand Up @@ -188,19 +204,25 @@ export class BetterAuthGuard implements CanActivate {
this.logger.error(message);
}

private async getActiveMemberRole(headers: Headers): Promise<string | string[] | null> {
private async getActiveMemberRole(
headers: Headers,
organizationId: string,
): Promise<string | string[] | null> {
const api = this.api();
try {
if (typeof api.getActiveMemberRole === "function") {
const result = (await (api.getActiveMemberRole as (input: unknown) => Promise<unknown>)({
headers,
query: { organizationId },
})) as { role?: string | string[] } | null;
return result?.role ?? null;
}
if (typeof api.getActiveMember === "function") {
const result = (await (api.getActiveMember as (input: unknown) => Promise<unknown>)({
headers,
})) as { role?: string | string[] } | null;
query: { organizationId },
})) as { organizationId?: string; role?: string | string[] } | null;
if (result?.organizationId !== organizationId) return null;
return result?.role ?? null;
}
this.logMisconfigurationOnce(
Expand All @@ -222,6 +244,7 @@ export class BetterAuthGuard implements CanActivate {
headers: Headers,
options: PermissionCheckOptions,
endpoint: "userHasPermission" | "hasPermission",
organizationId?: string,
): Promise<void> {
const api = this.api();
const fn = api[endpoint];
Expand All @@ -235,12 +258,19 @@ export class BetterAuthGuard implements CanActivate {
}
let success = false;
try {
if (endpoint === "hasPermission" && !organizationId) {
throw new Error("The authoritative session has no active organization.");
}
const organizationBody =
endpoint === "hasPermission" ? { organizationId } : ({} satisfies Record<string, never>);
// With an explicit `role`, omit the session headers: better-auth
// prefers the session user over `body.role`, which would silently
// evaluate the caller's own role instead of the requested one.
const input = options.role
? { body: { permissions: options.permissions, role: options.role } }
: { body: { permissions: options.permissions }, headers };
? {
body: { permissions: options.permissions, role: options.role, ...organizationBody },
}
: { body: { permissions: options.permissions, ...organizationBody }, headers };
const result = (await (fn as (input: unknown) => Promise<unknown>)(input)) as {
success?: boolean;
} | null;
Expand Down
185 changes: 185 additions & 0 deletions src/guards/mutation-origin.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { ForbiddenException, Inject, Injectable } from "@nestjs/common";
import { isIP } from "node:net";
import type { CanActivate, ExecutionContext } from "@nestjs/common";

/** Injection token consumed by {@link MutationOriginGuard}. */
export const MUTATION_ORIGIN_GUARD_OPTIONS = Symbol.for(
"@nestm/better-auth:mutation-origin-guard-options",
);

export interface MutationOriginGuardOptions {
/** Exact browser origins allowed to submit state-changing requests. */
readonly trustedOrigins: readonly string[];
/**
* Permit plain HTTP only for loopback hosts (`localhost`, `*.localhost`,
* `127.0.0.0/8`, and `[::1]`). Intended for explicitly configured local
* development origins. Defaults to `false`.
*/
readonly allowLoopbackHttp?: boolean;
}

export interface MutationOriginCanonicalizationOptions {
readonly allowLoopbackHttp?: boolean;
}

interface MutationHttpRequest {
readonly method?: string;
readonly headers?: Readonly<Record<string, string | readonly string[] | undefined>>;
}

type HeaderValue =
| { readonly kind: "missing" }
| { readonly kind: "invalid" }
| { readonly kind: "present"; readonly value: string };

const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const FETCH_SITES = new Set(["cross-site", "same-origin", "same-site", "none"]);

function isLoopbackHost(hostname: string): boolean {
const normalized =
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
const lowercase = normalized.toLowerCase();
if (lowercase === "localhost" || lowercase.endsWith(".localhost")) return true;

const ipVersion = isIP(lowercase);
if (ipVersion === 4) return lowercase.split(".")[0] === "127";
return ipVersion === 6 && lowercase === "::1";
}

function parseHttpOrigin(
origin: string,
options: MutationOriginCanonicalizationOptions,
requireCanonical: boolean,
): string {
if (origin.length === 0 || origin !== origin.trim() || origin.toLowerCase() === "null") {
throw new Error("Origin must be a non-empty HTTP(S) origin.");
}

let url: URL;
try {
url = new URL(origin);
} catch {
throw new Error("Origin must be a valid absolute URL.");
}

if (
(url.protocol !== "https:" && url.protocol !== "http:") ||
url.origin === "null" ||
url.username !== "" ||
url.password !== "" ||
url.pathname !== "/" ||
url.search !== "" ||
url.hash !== ""
) {
throw new Error(
"Origin must be an HTTP(S) origin without credentials, path, query, or fragment.",
);
}

if (
url.protocol === "http:" &&
(options.allowLoopbackHttp !== true || !isLoopbackHost(url.hostname))
) {
throw new Error(
"Origin must use HTTPS; plain HTTP is limited to explicitly allowed loopback origins.",
);
}

const canonical = url.origin;
if (requireCanonical && canonical !== origin) {
throw new Error("Origin header is not in canonical serialized-origin form.");
}
return canonical;
}

/**
* Validates and serializes one exact trusted origin. Host case, default ports,
* and a trailing root slash are normalized through the platform URL parser.
*/
export function canonicalizeTrustedMutationOrigin(
origin: string,
options: MutationOriginCanonicalizationOptions = {},
): string {
return parseHttpOrigin(origin, options, false);
}

/** Validates, canonicalizes, and de-duplicates an exact trusted-origin list. */
export function canonicalizeTrustedMutationOrigins(
origins: readonly string[],
options: MutationOriginCanonicalizationOptions = {},
): readonly string[] {
const canonical = [
...new Set(origins.map((origin) => canonicalizeTrustedMutationOrigin(origin, options))),
];
if (canonical.length === 0) {
throw new Error("MutationOriginGuard requires at least one trusted origin.");
}
return canonical;
}

function readHeader(request: MutationHttpRequest, name: string): HeaderValue {
const value = request.headers?.[name];
if (value === undefined) return { kind: "missing" };
if (typeof value !== "string" || value.length === 0) return { kind: "invalid" };
return { kind: "present", value };
}

/**
* Rejects unverifiable state-changing browser requests before controller code
* runs. Safe HTTP methods and non-HTTP Nest execution contexts are unaffected.
*/
@Injectable()
export class MutationOriginGuard implements CanActivate {
readonly #allowLoopbackHttp: boolean;
readonly #trustedOrigins: ReadonlySet<string>;

constructor(
@Inject(MUTATION_ORIGIN_GUARD_OPTIONS)
options: MutationOriginGuardOptions,
) {
this.#allowLoopbackHttp = options.allowLoopbackHttp === true;
this.#trustedOrigins = new Set(
canonicalizeTrustedMutationOrigins(options.trustedOrigins, {
allowLoopbackHttp: this.#allowLoopbackHttp,
}),
);
}

canActivate(context: ExecutionContext): boolean {
if (context.getType() !== "http") return true;

const request = context.switchToHttp().getRequest<MutationHttpRequest>();
const method = request.method;
if (method === undefined || method.length === 0) return this.reject();
if (SAFE_METHODS.has(method)) return true;

const origin = readHeader(request, "origin");
const fetchSite = readHeader(request, "sec-fetch-site");
if (origin.kind === "invalid" || fetchSite.kind === "invalid") return this.reject();
if (fetchSite.kind === "present" && !FETCH_SITES.has(fetchSite.value)) {
return this.reject();
}

if (origin.kind === "present") {
let canonical: string;
try {
canonical = parseHttpOrigin(
origin.value,
{ allowLoopbackHttp: this.#allowLoopbackHttp },
true,
);
} catch {
return this.reject();
}
if (!this.#trustedOrigins.has(canonical)) return this.reject();
return true;
}

if (fetchSite.kind === "present" && fetchSite.value === "same-origin") return true;
return this.reject();
}

private reject(): never {
throw new ForbiddenException("Mutation request origin could not be verified.");
}
}
Loading
Loading