From d906e39b0fadd8f8ee4220c61a906d3bbda24756 Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Fri, 14 Aug 2026 11:12:27 -0300 Subject: [PATCH 1/3] feat: harden auth composition boundaries --- .changeset/portable-typeorm-capabilities.md | 7 + .changeset/strict-mutation-origins.md | 5 + README.md | 55 +++++- src/guards/mutation-origin.guard.ts | 185 ++++++++++++++++++++ src/index.ts | 8 + src/typeorm/adapter.ts | 36 ++-- src/typeorm/capabilities.ts | 73 ++++++++ src/typeorm/dialect.ts | 4 +- src/typeorm/index.ts | 10 +- src/typeorm/registry.ts | Bin 6688 -> 6703 bytes src/typeorm/sql.ts | 4 +- src/typeorm/types.ts | 49 +++++- tests/e2e/mutation-origin-guard.e2e.test.ts | 91 ++++++++++ tests/unit/exports.test.ts | 4 + tests/unit/mutation-origin-guard.test.ts | 50 ++++++ tests/unit/typeorm-portability.test.ts | 52 ++++++ 16 files changed, 607 insertions(+), 26 deletions(-) create mode 100644 .changeset/portable-typeorm-capabilities.md create mode 100644 .changeset/strict-mutation-origins.md create mode 100644 src/guards/mutation-origin.guard.ts create mode 100644 src/typeorm/capabilities.ts create mode 100644 tests/e2e/mutation-origin-guard.e2e.test.ts create mode 100644 tests/unit/mutation-origin-guard.test.ts create mode 100644 tests/unit/typeorm-portability.test.ts diff --git a/.changeset/portable-typeorm-capabilities.md b/.changeset/portable-typeorm-capabilities.md new file mode 100644 index 0000000..8c485c1 --- /dev/null +++ b/.changeset/portable-typeorm-capabilities.md @@ -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. diff --git a/.changeset/strict-mutation-origins.md b/.changeset/strict-mutation-origins.md new file mode 100644 index 0000000..b22ac05 --- /dev/null +++ b/.changeset/strict-mutation-origins.md @@ -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. diff --git a/README.md b/README.md index ef866a0..92d5915 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,15 @@ Notes: `@Session()` populated. - `@Roles` and `@OrgRoles` are deliberately separate domains: an organization owner does not pass `@Roles('admin')`. +- `session.activeOrganizationId` is a tenant selector, not proof of current membership or a + database-isolation boundary; Better Auth guards, hooks, and route policies do not scope domain + queries. When composing with + [`@nestm/tenant`](https://github.com/nestm-dev/tenant#secure-quick-start), keep the adapter in the + application: set `disableGlobalGuard: true` and `disableAutomaticGuard: true`, then explicitly + run `BetterAuthGuard` → `TenantGuard` → permissions (or use one composite guard), resolve only + from the guard-populated session with `CallbackTenantResolver`—without a client header + fallback—and re-check `(organizationId, userId)` membership in `TenantAccessPolicy` on every + request. - Authorization is fail-closed: a class-level `@AllowAnonymous`/`@OptionalAuth` is ignored on handlers that declare their own `@Roles`/`@OrgRoles`/`@RequireActiveOrg`/permission requirements (a handler-level `@AllowAnonymous` still wins). @@ -167,6 +176,40 @@ Notes: not cover gateways). The guard understands http, ws, and rpc contexts; GraphQL is wired but currently **experimental** (the `@nestjs/graphql` v12-compatible stack is not yet stable). +### State-changing controller origins + +Cookie-authenticated controller routes also need a CSRF boundary. Register the exported +`MutationOriginGuard` as an application guard with an exact origin allowlist: + +```ts +import { Module } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import { MUTATION_ORIGIN_GUARD_OPTIONS, MutationOriginGuard } from "@nestm/better-auth"; + +@Module({ + providers: [ + { + provide: MUTATION_ORIGIN_GUARD_OPTIONS, + useValue: { trustedOrigins: ["https://studio.example.com"] }, + }, + MutationOriginGuard, + { provide: APP_GUARD, useExisting: MutationOriginGuard }, + ], +}) +export class SecurityModule {} +``` + +For `POST`, `PUT`, `PATCH`, `DELETE`, and other non-safe HTTP methods, the guard requires either +an exact trusted canonical `Origin` or `Sec-Fetch-Site: same-origin`. Invalid, repeated, opaque +(`null`), non-HTTP, or untrusted origins fail with `403`; malformed Fetch Metadata also fails +closed. `GET`, `HEAD`, and `OPTIONS` are unaffected. An explicitly trusted cross-site origin wins +over `Sec-Fetch-Site: cross-site`, which permits a deliberately separate browser frontend. + +Trusted origins must use HTTPS. Local development may opt into plain HTTP with +`allowLoopbackHttp: true`; this accepts only `localhost`, `*.localhost`, `127.0.0.0/8`, and +`[::1]`. The guard affects Nest HTTP controller routes, while mounted Better Auth endpoints keep +Better Auth's own origin validation. + ## Hooks with NestJS DI Hook classes are regular providers — inject anything. They are discovered anywhere in your @@ -389,7 +432,7 @@ import { typeormAdapter } from "@nestm/better-auth/typeorm"; BetterAuthModule.forRootAsync({ inject: [DataSource], useFactory: (dataSource: DataSource) => ({ - options: { database: typeormAdapter(dataSource) }, + options: { database: typeormAdapter(dataSource, { transaction: true }) }, }), }); ``` @@ -397,6 +440,12 @@ BetterAuthModule.forRootAsync({ `typeorm` is an **optional** peer and the built entry imports it only as a type — nothing is loaded at runtime, so installing this package without TypeORM stays free. +The adapter's public boundary is a library-owned structural capability contract rather than +TypeORM's nominal `DataSource` class. A linked workspace can therefore pass its own compatible +`DataSource` directly even when the package manager resolves TypeORM at a second physical path. +The adapter validates the metadata and manager capabilities it consumes at runtime; no consumer +cast or shared-module-path workaround is required. + ### Requirements - PostgreSQL. The adapter emits SQL directly and is verified against TypeORM's `postgres`, @@ -432,6 +481,10 @@ typeormAdapter(dataSource, { entities: { rateLimit: ThrottleBucket } }); | `usePlural` | `false` | Appends `s` to model names during resolution. | | `debugLogs` | `false` | Forwarded to the adapter factory. | +Enable `transaction` in production so Better Auth's multi-step user/account/session writes are +atomic. It remains opt-in for compatibility with applications that already own the transaction +through `getManager`. + `getManager` is resolved **per statement**, not once at construction — an adapter is built at application boot, long before any request context exists: diff --git a/src/guards/mutation-origin.guard.ts b/src/guards/mutation-origin.guard.ts new file mode 100644 index 0000000..c8a4776 --- /dev/null +++ b/src/guards/mutation-origin.guard.ts @@ -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>; +} + +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; + + 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(); + 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."); + } +} diff --git a/src/index.ts b/src/index.ts index f3caa9b..29be4bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,14 @@ export { export { BetterAuthService } from "./services/better-auth.service.ts"; export { BetterAuthGuard } from "./guards/better-auth.guard.ts"; export { createAuthError, type AuthErrorStatus } from "./guards/auth-errors.ts"; +export { + MUTATION_ORIGIN_GUARD_OPTIONS, + MutationOriginGuard, + canonicalizeTrustedMutationOrigin, + canonicalizeTrustedMutationOrigins, + type MutationOriginCanonicalizationOptions, + type MutationOriginGuardOptions, +} from "./guards/mutation-origin.guard.ts"; // Access-control decorators export { diff --git a/src/typeorm/adapter.ts b/src/typeorm/adapter.ts index 9e8281e..6293680 100644 --- a/src/typeorm/adapter.ts +++ b/src/typeorm/adapter.ts @@ -8,9 +8,8 @@ import type { CustomAdapter, } from "better-auth/adapters"; import type { BetterAuthOptions } from "better-auth/types"; -import type { DataSource, EntityManager } from "typeorm"; - import { resolveDialect } from "./dialect.ts"; +import { executeQuery, requireEntityManager } from "./capabilities.ts"; import { TypeormModelRegistry } from "./registry.ts"; import { boundedInteger, @@ -21,7 +20,7 @@ import { tableRef, } from "./sql.ts"; import type { SqlContext } from "./sql.ts"; -import type { TypeormAdapterConfig } from "./types.ts"; +import type { TypeormAdapterConfig, TypeormDataSource, TypeormEntityManager } from "./types.ts"; type Row = Record; @@ -75,7 +74,7 @@ interface StatementResult { * ``` */ export function typeormAdapter( - dataSource: DataSource, + dataSource: TypeormDataSource, config: TypeormAdapterConfig = {}, ): AdapterFactory { const dialect = resolveDialect(dataSource); @@ -87,7 +86,7 @@ export function typeormAdapter( let lazyOptions: BetterAuthOptions | null = null; const createCustomAdapter = - (resolveManager: () => EntityManager): AdapterFactoryOptions["adapter"] => + (resolveManager: () => TypeormEntityManager): AdapterFactoryOptions["adapter"] => ({ schema, getFieldName, getDefaultModelName, getFieldAttributes }) => { /** * TypeORM's Postgres query runner returns `raw.rows` for `SELECT`/`INSERT` but the @@ -101,7 +100,7 @@ export function typeormAdapter( parameters: unknown[], shape: "rows" | "rowsWithCount", ): Promise => { - const raw: unknown = await resolveManager().query(sql, parameters); + const raw = await executeQuery(resolveManager(), sql, parameters); if (shape === "rows") { if (!Array.isArray(raw)) return { rows: [], affected: 0 }; const rows = raw as Row[]; @@ -447,17 +446,20 @@ export function typeormAdapter( transaction: config.transaction ? (callback) => - dataSource.transaction((manager) => - callback( - createAdapterFactory({ - // The inner adapter is pinned to the transactional manager, and - // `getManager` is deliberately not consulted: a statement that resolved - // its own manager here would run outside the transaction it was handed. - adapter: createCustomAdapter(() => manager), - config: { ...adapterConfig, transaction: false }, - })(lazyOptions ?? {}), - ), - ) + Reflect.apply(dataSource.transaction, dataSource, [ + (manager: unknown) => { + const transactionalManager = requireEntityManager(manager); + return callback( + createAdapterFactory({ + // The inner adapter is pinned to the transactional manager, and + // `getManager` is deliberately not consulted: a statement that resolved + // its own manager here would run outside the transaction it was handed. + adapter: createCustomAdapter(() => transactionalManager), + config: { ...adapterConfig, transaction: false }, + })(lazyOptions ?? {}), + ); + }, + ]) : false, }; diff --git a/src/typeorm/capabilities.ts b/src/typeorm/capabilities.ts new file mode 100644 index 0000000..c539076 --- /dev/null +++ b/src/typeorm/capabilities.ts @@ -0,0 +1,73 @@ +import { BetterAuthError } from "better-auth"; + +import type { + TypeormColumnMetadata, + TypeormDataSource, + TypeormEntityManager, + TypeormEntityMetadata, + TypeormEntityTarget, +} from "./types.ts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function requireColumn(value: unknown): TypeormColumnMetadata { + if ( + !isRecord(value) || + typeof value.propertyName !== "string" || + typeof value.databaseName !== "string" + ) { + throw new BetterAuthError("[TypeORM Adapter] DataSource returned malformed column metadata."); + } + return { + propertyName: value.propertyName, + databaseName: value.databaseName, + type: value.type, + }; +} + +export function requireEntityMetadata(value: unknown): TypeormEntityMetadata { + if ( + !isRecord(value) || + typeof value.targetName !== "string" || + typeof value.tableName !== "string" || + (value.schema !== undefined && typeof value.schema !== "string") || + !Array.isArray(value.columns) + ) { + throw new BetterAuthError("[TypeORM Adapter] DataSource returned malformed entity metadata."); + } + return { + targetName: value.targetName, + tableName: value.tableName, + ...(typeof value.schema === "string" ? { schema: value.schema } : {}), + columns: value.columns.map(requireColumn), + }; +} + +export function getEntityMetadata( + dataSource: TypeormDataSource, + target: TypeormEntityTarget, +): TypeormEntityMetadata { + return requireEntityMetadata(Reflect.apply(dataSource.getMetadata, dataSource, [target])); +} + +export function requireEntityManager(value: unknown): TypeormEntityManager { + if (!isRecord(value) || typeof value.query !== "function") { + throw new BetterAuthError( + "[TypeORM Adapter] DataSource transaction returned an invalid EntityManager.", + ); + } + const query = value.query; + return { + query: (...parameters: never[]) => Reflect.apply(query, value, parameters), + }; +} + +export async function executeQuery( + manager: TypeormEntityManager, + sql: string, + parameters: readonly unknown[], +): Promise { + return await Reflect.apply(manager.query, manager, [sql, [...parameters]]); +} diff --git a/src/typeorm/dialect.ts b/src/typeorm/dialect.ts index 8601808..1fcda6f 100644 --- a/src/typeorm/dialect.ts +++ b/src/typeorm/dialect.ts @@ -1,5 +1,5 @@ import { BetterAuthError } from "better-auth"; -import type { DataSource } from "typeorm"; +import type { TypeormDataSource } from "./types.ts"; /** * The TypeORM driver types this adapter emits verified SQL for. @@ -43,7 +43,7 @@ export interface TypeormDialect { parameter(index: number): string; } -export function resolveDialect(dataSource: DataSource): TypeormDialect { +export function resolveDialect(dataSource: TypeormDataSource): TypeormDialect { const driverType = String(dataSource.options.type); if (!VERIFIED_DRIVER_TYPES.has(driverType)) { throw new BetterAuthError( diff --git a/src/typeorm/index.ts b/src/typeorm/index.ts index d216371..eec0d30 100644 --- a/src/typeorm/index.ts +++ b/src/typeorm/index.ts @@ -1,2 +1,10 @@ export { typeormAdapter } from "./adapter.ts"; -export type { TypeormAdapterConfig, TypeormEntityTarget } from "./types.ts"; +export type { + TypeormAdapterConfig, + TypeormColumnMetadata, + TypeormDataSource, + TypeormEntityManager, + TypeormEntityMetadata, + TypeormEntitySchema, + TypeormEntityTarget, +} from "./types.ts"; diff --git a/src/typeorm/registry.ts b/src/typeorm/registry.ts index bd4559771baa97f68e51f3ae5672f411c0e96961..76df8d9ba5d5a0efb15074e23219bd55f3ce37db 100644 GIT binary patch delta 363 zcmZ2rvfgBZemz%aZb5!gi9)qPdTNPlUP)$2rEh9UVoG93qK-mQYGG++Q7WpiLajnt zQGTw1lAeBYVnJe3W=>{FW@@osNwJbO%(Rlqf>ecSF3u1jonMseoS##gn+G=+F7A?O zQIZ&(Us{x$ssk59wGCZ7B(VtSP#vy`y^@=q86`N_p;k*qiORy( M&k5s9?vR`T01!8XkpKVy delta 336 zcmZ2)vcP15zE@^$L4Hw*LP=#oszSAbOJYf4aDHh~a;lDkYhFoaNu_UUNn#36RH0TO zttdZNK?$TXzbIG9nu`ln@5GD3^>9^s`XF`1dL_jmh5A}r3JIv@=_Tjql;-9YCn#hV zDsuwF1<>!|uq~?_rRVt+9rxq*Zfw&bQ$LlF* z>2rZSV1#Opf-RaSqLrYwD#b?UmF6a;7R7G-DaSEcjdg-YpFc)fU-p6alI9Z&(c=JU5|D2PjOU?iQ%XfHg diff --git a/src/typeorm/sql.ts b/src/typeorm/sql.ts index bb11f90..8442506 100644 --- a/src/typeorm/sql.ts +++ b/src/typeorm/sql.ts @@ -1,9 +1,9 @@ import { BetterAuthError } from "better-auth"; import type { CleanedWhere } from "better-auth/adapters"; -import type { EntityMetadata } from "typeorm"; import type { TypeormDialect } from "./dialect.ts"; import type { TypeormModelRegistry } from "./registry.ts"; +import type { TypeormEntityMetadata } from "./types.ts"; /** * Accumulates bound parameters so placeholder numbering stays correct across a whole @@ -56,7 +56,7 @@ export interface SqlContext { readonly isDateField: (fieldName: string) => boolean; } -export function tableRef(dialect: TypeormDialect, entity: EntityMetadata): string { +export function tableRef(dialect: TypeormDialect, entity: TypeormEntityMetadata): string { const table = dialect.escape(entity.tableName); return entity.schema ? `${dialect.escape(entity.schema)}.${table}` : table; } diff --git a/src/typeorm/types.ts b/src/typeorm/types.ts index a281c69..46c7a14 100644 --- a/src/typeorm/types.ts +++ b/src/typeorm/types.ts @@ -1,5 +1,4 @@ import type { AdapterFactoryConfig } from "better-auth/adapters"; -import type { EntityManager, EntitySchema } from "typeorm"; /** * Anything `DataSource.getMetadata()` accepts as an entity handle. @@ -8,7 +7,51 @@ import type { EntityManager, EntitySchema } from "typeorm"; * admits a `{ type, name }` form. */ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- TypeORM types a decorated entity class as `Function`. -export type TypeormEntityTarget = Function | EntitySchema | string; +export type TypeormEntityTarget = Function | TypeormEntitySchema | string; + +/** Structural EntitySchema identity used only as a DataSource lookup handle. */ +export interface TypeormEntitySchema { + readonly options: { readonly name: string }; +} + +export interface TypeormColumnMetadata { + readonly propertyName: string; + readonly databaseName: string; + readonly type: unknown; +} + +export interface TypeormEntityMetadata { + readonly targetName: string; + readonly tableName: string; + readonly schema?: string; + readonly columns: readonly TypeormColumnMetadata[]; +} + +export type TypeormCallableCapability = (...parameters: never[]) => unknown; + +/** Minimal, structurally portable manager capability used by the adapter. */ +export interface TypeormEntityManager { + readonly query: TypeormCallableCapability; +} + +/** + * Minimal DataSource capability required by the adapter. + * + * Using TypeORM's full DataSource class here leaks its private nominal brand + * into consumers. That makes identical peer versions installed at two linked + * workspace paths fail assignability even though the runtime API is the same. + */ +export interface TypeormDataSource { + readonly options: { readonly type: unknown }; + readonly driver: { + escape(identifier: string): string; + createParameter(parameterName: string, index: number): string; + }; + readonly entityMetadatas: readonly unknown[]; + readonly manager: TypeormEntityManager; + readonly getMetadata: TypeormCallableCapability; + readonly transaction: TypeormCallableCapability; +} export interface TypeormAdapterConfig { /** @@ -44,7 +87,7 @@ export interface TypeormAdapterConfig { * Statements issued inside `transaction()` ignore this hook and use the transactional * manager, because a callback that escaped its own transaction would defeat the point. */ - getManager?: (() => EntityManager | undefined) | undefined; + getManager?: (() => TypeormEntityManager | undefined) | undefined; /** * Enable Better Auth's `transaction()` support, backed by `dataSource.transaction()`. diff --git a/tests/e2e/mutation-origin-guard.e2e.test.ts b/tests/e2e/mutation-origin-guard.e2e.test.ts new file mode 100644 index 0000000..ecce6a4 --- /dev/null +++ b/tests/e2e/mutation-origin-guard.e2e.test.ts @@ -0,0 +1,91 @@ +import { Controller, Get, Post } from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import request from "supertest"; +import { afterEach, describe, expect, it } from "vitest"; +import type { INestApplication } from "@nestjs/common"; +import { MUTATION_ORIGIN_GUARD_OPTIONS, MutationOriginGuard } from "../../src/index.ts"; +import { createTestApp } from "../shared/test-app.ts"; +import { createTestAuth } from "../shared/test-auth.ts"; +import { testHttpAdapter } from "../shared/http-adapter.ts"; + +const TRUSTED_ORIGIN = "https://studio.example.com"; + +@Controller("mutation-origin") +class MutationOriginController { + @Get() + read(): { readonly ok: true } { + return { ok: true }; + } + + @Post() + write(): { readonly ok: true } { + return { ok: true }; + } +} + +describe(`MutationOriginGuard (${testHttpAdapter})`, () => { + let app: INestApplication; + + afterEach(async () => { + await app?.close(); + }); + + async function createApp(): Promise { + return createTestApp({ + forRoot: { auth: createTestAuth(), disableGlobalGuard: true }, + metadata: { + controllers: [MutationOriginController], + providers: [ + { + provide: MUTATION_ORIGIN_GUARD_OPTIONS, + useValue: { trustedOrigins: [TRUSTED_ORIGIN] }, + }, + MutationOriginGuard, + { provide: APP_GUARD, useExisting: MutationOriginGuard }, + ], + }, + }); + } + + it("does not affect safe methods", async () => { + app = await createApp(); + await request(app.getHttpServer()).get("/mutation-origin").expect(200, { ok: true }); + }); + + it("accepts an exact trusted Origin", async () => { + app = await createApp(); + await request(app.getHttpServer()) + .post("/mutation-origin") + .set("Origin", TRUSTED_ORIGIN) + .set("Sec-Fetch-Site", "cross-site") + .expect(201, { ok: true }); + }); + + it("accepts same-origin Fetch Metadata when Origin is absent", async () => { + app = await createApp(); + await request(app.getHttpServer()) + .post("/mutation-origin") + .set("Sec-Fetch-Site", "same-origin") + .expect(201, { ok: true }); + }); + + it.each([ + { label: "both signals are absent", headers: {} }, + { label: "Origin is null", headers: { Origin: "null" } }, + { label: "Origin is not HTTP", headers: { Origin: "file://local" } }, + { label: "Origin is not trusted", headers: { Origin: "https://evil.example.com" } }, + { label: "Origin is not canonical", headers: { Origin: `${TRUSTED_ORIGIN}/` } }, + { + label: "Fetch Metadata is cross-site without Origin", + headers: { "Sec-Fetch-Site": "cross-site" }, + }, + { label: "Fetch Metadata is malformed", headers: { "Sec-Fetch-Site": "unknown" } }, + ])("rejects when $label", async ({ headers }) => { + app = await createApp(); + const response = await request(app.getHttpServer()) + .post("/mutation-origin") + .set(headers) + .expect(403); + expect(response.body.message).toBe("Mutation request origin could not be verified."); + }); +}); diff --git a/tests/unit/exports.test.ts b/tests/unit/exports.test.ts index a939613..e53ed6d 100644 --- a/tests/unit/exports.test.ts +++ b/tests/unit/exports.test.ts @@ -28,6 +28,10 @@ const EXPECTED_VALUE_EXPORTS = [ // services & guard "BetterAuthService", "BetterAuthGuard", + "MutationOriginGuard", + "MUTATION_ORIGIN_GUARD_OPTIONS", + "canonicalizeTrustedMutationOrigin", + "canonicalizeTrustedMutationOrigins", "BetterAuthHookRegistry", "BetterAuthDatabaseHookRegistry", // decorators diff --git a/tests/unit/mutation-origin-guard.test.ts b/tests/unit/mutation-origin-guard.test.ts new file mode 100644 index 0000000..029e250 --- /dev/null +++ b/tests/unit/mutation-origin-guard.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalizeTrustedMutationOrigin, + canonicalizeTrustedMutationOrigins, +} from "../../src/index.ts"; + +describe("trusted mutation origins", () => { + it("canonicalizes and de-duplicates exact HTTPS origins", () => { + expect( + canonicalizeTrustedMutationOrigins([ + "https://Studio.EXAMPLE.com:443/", + "https://studio.example.com", + "https://studio.example.com:8443", + ]), + ).toEqual(["https://studio.example.com", "https://studio.example.com:8443"]); + }); + + it.each([ + "null", + "ftp://studio.example.com", + "https://user:secret@studio.example.com", + "https://studio.example.com/path", + "https://studio.example.com?token=value", + "https://studio.example.com#fragment", + " https://studio.example.com", + ])("rejects a non-origin value: %s", (origin) => { + expect(() => canonicalizeTrustedMutationOrigin(origin)).toThrow(); + }); + + it("rejects HTTP by default and permits only explicit loopback HTTP", () => { + expect(() => canonicalizeTrustedMutationOrigin("http://127.0.0.1:5173")).toThrow(); + expect( + canonicalizeTrustedMutationOrigins( + ["http://localhost:5173", "http://127.27.4.9:5173", "http://[::1]:5173"], + { allowLoopbackHttp: true }, + ), + ).toEqual(["http://localhost:5173", "http://127.27.4.9:5173", "http://[::1]:5173"]); + expect(() => + canonicalizeTrustedMutationOrigin("http://studio.example.com", { + allowLoopbackHttp: true, + }), + ).toThrow(); + }); + + it("requires a non-empty trust set", () => { + expect(() => canonicalizeTrustedMutationOrigins([])).toThrow( + "requires at least one trusted origin", + ); + }); +}); diff --git a/tests/unit/typeorm-portability.test.ts b/tests/unit/typeorm-portability.test.ts new file mode 100644 index 0000000..f1bfd7d --- /dev/null +++ b/tests/unit/typeorm-portability.test.ts @@ -0,0 +1,52 @@ +import { DataSource } from "typeorm"; +import { describe, expect, it } from "vitest"; + +import { typeormAdapter } from "../../src/typeorm/adapter.ts"; +import type { + TypeormCallableCapability, + TypeormDataSource, + TypeormEntityManager, +} from "../../src/typeorm/types.ts"; + +/** Simulates an equivalent DataSource class arriving from another peer path. */ +class LinkedWorkspaceDataSource implements TypeormDataSource { + readonly #nominalBrand = true; + readonly getMetadata: TypeormCallableCapability; + readonly transaction: TypeormCallableCapability; + + constructor(private readonly source: DataSource) { + this.getMetadata = source.getMetadata.bind(source); + this.transaction = source.transaction.bind(source); + } + + get hasPrivateBrand(): boolean { + return this.#nominalBrand; + } + + get options(): TypeormDataSource["options"] { + return this.source.options; + } + + get driver(): TypeormDataSource["driver"] { + return this.source.driver; + } + + get entityMetadatas(): TypeormDataSource["entityMetadatas"] { + return this.source.entityMetadatas; + } + + get manager(): TypeormEntityManager { + return this.source.manager; + } +} + +describe("TypeORM adapter portability", () => { + it("accepts a privately branded consumer DataSource through structural capabilities", () => { + const source = new LinkedWorkspaceDataSource( + new DataSource({ type: "postgres", entities: [] }), + ); + + expect(source.hasPrivateBrand).toBe(true); + expect(typeormAdapter(source)).toBeTypeOf("function"); + }); +}); From 32f4c44f02bd9da1fbd63afd6f1e2ec5d3806449 Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Sat, 22 Aug 2026 03:52:31 -0300 Subject: [PATCH 2/3] feat: add safe auth control-plane services --- .changeset/join-application-transactions.md | 7 + .changeset/nest-api-invocation.md | 7 + .changeset/safe-session-management.md | 8 + README.md | 89 +++++- src/better-auth.module.ts | 3 + src/index.ts | 17 ++ .../session-management-route-policy.ts | 27 ++ src/services/better-auth-api-invocation.ts | 53 ++++ src/services/better-auth-session.service.ts | 283 ++++++++++++++++++ src/services/better-auth.service.ts | 32 +- src/typeorm/adapter.ts | 36 ++- src/typeorm/types.ts | 9 +- tests/e2e/api-invocation.e2e.test.ts | 81 +++++ tests/e2e/session-service.e2e.test.ts | 233 ++++++++++++++ tests/packed-types/consumer.ts | 43 +++ tests/postgres/adapter-options.spec.ts | 34 +++ tests/unit/api-invocation-type-assertions.ts | 33 ++ tests/unit/api-invocation.test.ts | 128 ++++++++ tests/unit/exports.test.ts | 5 + tests/unit/session-route-policy.test.ts | 30 ++ tests/unit/session-service-type-assertions.ts | 38 +++ tests/unit/session-service.test.ts | 215 +++++++++++++ 22 files changed, 1379 insertions(+), 32 deletions(-) create mode 100644 .changeset/join-application-transactions.md create mode 100644 .changeset/nest-api-invocation.md create mode 100644 .changeset/safe-session-management.md create mode 100644 src/policies/session-management-route-policy.ts create mode 100644 src/services/better-auth-api-invocation.ts create mode 100644 src/services/better-auth-session.service.ts create mode 100644 tests/e2e/api-invocation.e2e.test.ts create mode 100644 tests/e2e/session-service.e2e.test.ts create mode 100644 tests/unit/api-invocation-type-assertions.ts create mode 100644 tests/unit/api-invocation.test.ts create mode 100644 tests/unit/session-route-policy.test.ts create mode 100644 tests/unit/session-service-type-assertions.ts create mode 100644 tests/unit/session-service.test.ts diff --git a/.changeset/join-application-transactions.md b/.changeset/join-application-transactions.md new file mode 100644 index 0000000..fb7c2b9 --- /dev/null +++ b/.changeset/join-application-transactions.md @@ -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. diff --git a/.changeset/nest-api-invocation.md b/.changeset/nest-api-invocation.md new file mode 100644 index 0000000..ca231ed --- /dev/null +++ b/.changeset/nest-api-invocation.md @@ -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. diff --git a/.changeset/safe-session-management.md b/.changeset/safe-session-management.md new file mode 100644 index 0000000..a43b0fc --- /dev/null +++ b/.changeset/safe-session-management.md @@ -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. diff --git a/README.md b/README.md index 92d5915..4613f89 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,68 @@ materialized. `.getSession(headers)`. The raw instance is injectable via `@InjectBetterAuth()` or the `BETTER_AUTH_INSTANCE` token; the resolved mount path via `BETTER_AUTH_BASE_PATH`. +For application-owned controller facades, use `invokeApi()` instead of converting Nest request +headers and mapping Better Auth errors in every service: + +```ts +import { Headers as RequestHeaders } from "@nestjs/common"; +import type { IncomingHttpHeaders } from "node:http"; + +async invite( + @RequestHeaders() requestHeaders: IncomingHttpHeaders, + body: InviteMemberDto, +) { + return this.auth.invokeApi(requestHeaders, (api, headers) => + api.createInvitation({ body, headers }), + ); +} +``` + +The callback receives the plugin-aware `auth.api` and a Web `Headers` copy. Its exact return type +is preserved. Better Auth `APIError`s become Nest `HttpException`s with +`{ statusCode, code, message }`; arbitrary body fields such as `cause` are not exposed, and +non-Better-Auth failures continue through the application's exception pipeline unchanged. + +`invokeApi()` does not sanitize successful endpoint payloads. For session management, inject +`BetterAuthSessionService` instead. Its `list()` result contains only the session `id`, dates, +nullable IP address and user agent, plus an authoritative `current` flag. Better Auth's bearer +tokens and user ids never cross the service boundary: + +```ts +@Controller("account/sessions") +export class AccountSessionsController { + constructor(private readonly sessions: BetterAuthSessionService) {} + + @Get() + list(@RequestHeaders() headers: IncomingHttpHeaders) { + return this.sessions.list(headers); + } + + @Delete(":sessionId") + revoke(@RequestHeaders() headers: IncomingHttpHeaders, @Param("sessionId") sessionId: string) { + return this.sessions.revokeById(headers, sessionId); + } +} +``` + +`revokeById(headers, sessionId)` accepts only a session owned by the authenticated caller and +returns the same `SESSION_NOT_FOUND` response for missing and foreign ids. `revokeOthers(headers)` +keeps the current session; `revokeAll(headers)` includes it. All four methods accept Web `Headers` +or Nest/Node request headers and translate Better Auth API errors through `invokeApi()`. + +Once the application facade is mounted, opt in to the supplied route policy so clients cannot +reach Better Auth's token-bearing session routes directly: + +```ts +BetterAuthModule.forFeature({ + routePolicies: [BetterAuthSessionManagementRoutePolicy], +}); +``` + +This blocks `/list-sessions`, `/revoke-session`, `/revoke-other-sessions`, and +`/revoke-sessions` at the Better Auth HTTP mount. Server-side calls made by +`BetterAuthSessionService` remain available. + HTTP adapters and application request augmentations can extend `BetterAuthRequestState` instead of recreating Better Auth's plugin-aware `session` and `user` fields. Its resolved-session marker uses the global symbol registry so guards and decorators remain compatible across duplicate package @@ -473,17 +535,18 @@ typeormAdapter(dataSource, { entities: { rateLimit: ThrottleBucket } }); ### Options -| Option | Default | Purpose | -| ------------- | -------------------- | ----------------------------------------------------------------------------------------------- | -| `entities` | `{}` | Explicit model → entity mapping. | -| `getManager` | `dataSource.manager` | Supplies the `EntityManager` per statement, so auth writes can join a surrounding unit of work. | -| `transaction` | `false` | Enables Better Auth's `transaction()`, backed by `dataSource.transaction()`. | -| `usePlural` | `false` | Appends `s` to model names during resolution. | -| `debugLogs` | `false` | Forwarded to the adapter factory. | +| Option | Default | Purpose | +| ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `entities` | `{}` | Explicit model → entity mapping. | +| `getManager` | `dataSource.manager` | Supplies a scoped `EntityManager`; a defined value also lets Better Auth join the application's active transaction. | +| `transaction` | `false` | Enables Better Auth's `transaction()`; joins `getManager()` or opens `dataSource.transaction()`. | +| `usePlural` | `false` | Appends `s` to model names during resolution. | +| `debugLogs` | `false` | Forwarded to the adapter factory. | Enable `transaction` in production so Better Auth's multi-step user/account/session writes are -atomic. It remains opt-in for compatibility with applications that already own the transaction -through `getManager`. +atomic. When the application already owns a transaction through `getManager`, the adapter joins +it instead of opening a competing transaction. That allows an application audit or outbox write +using the same scoped manager to commit or roll back with the Better Auth mutation. `getManager` is resolved **per statement**, not once at construction — an adapter is built at application boot, long before any request context exists: @@ -492,9 +555,11 @@ application boot, long before any request context exists: typeormAdapter(dataSource, { getManager: () => unitOfWork.getStore()?.manager }); ``` -Returning `undefined` falls back to `dataSource.manager`, so it is safe to call outside a -scoped context. Statements inside `transaction()` ignore the hook and use the transactional -manager — a callback that escaped its own transaction would defeat the point. +Returning `undefined` falls back to `dataSource.manager` for ordinary statements and makes +`transaction()` open `dataSource.transaction()`. Returning a manager while `transaction` is +enabled explicitly means that manager already belongs to the application's active unit of work; +the Better Auth callback pins it for every inner statement. Do not return a non-transactional +manager from the hook merely as a permanent replacement for `dataSource.manager`. ### Timezones — this adapter owns the concern diff --git a/src/better-auth.module.ts b/src/better-auth.module.ts index b477a1d..ef560b4 100644 --- a/src/better-auth.module.ts +++ b/src/better-auth.module.ts @@ -20,6 +20,7 @@ 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 { BetterAuthGuard } from "./guards/better-auth.guard.ts"; import { BetterAuthHookRegistry } from "./hooks/hook-registry.service.ts"; import { BetterAuthDatabaseHookRegistry } from "./hooks/database-hook-registry.service.ts"; @@ -72,6 +73,7 @@ function assertRoutePolicyClass(candidate: Type): void { resolveAuthBasePath(auth, options.basePath), }, BetterAuthService, + BetterAuthSessionService, BetterAuthGuard, BetterAuthHookRegistry, BetterAuthDatabaseHookRegistry, @@ -85,6 +87,7 @@ function assertRoutePolicyClass(candidate: Type): void { BETTER_AUTH_MODULE_OPTIONS, BETTER_AUTH_BASE_PATH, BetterAuthService, + BetterAuthSessionService, BetterAuthGuard, BetterAuthHookRegistry, BetterAuthDatabaseHookRegistry, diff --git a/src/index.ts b/src/index.ts index 29be4bd..e262284 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,7 +64,24 @@ export { // Service & guard export { BetterAuthService } from "./services/better-auth.service.ts"; +export type { BetterAuthApiInvocation } from "./services/better-auth.service.ts"; +export { + BetterAuthSessionService, + type BetterAuthSessionBulkRevocationResult, + type BetterAuthSessionRevocationResult, + type BetterAuthSessionSummary, +} from "./services/better-auth-session.service.ts"; +export { + mapBetterAuthApiError, + normalizeBetterAuthHeaders, + type BetterAuthApiErrorResponse, + type BetterAuthApiHeaders, +} from "./services/better-auth-api-invocation.ts"; export { BetterAuthGuard } from "./guards/better-auth.guard.ts"; +export { + BETTER_AUTH_SESSION_MANAGEMENT_PATHS, + BetterAuthSessionManagementRoutePolicy, +} from "./policies/session-management-route-policy.ts"; export { createAuthError, type AuthErrorStatus } from "./guards/auth-errors.ts"; export { MUTATION_ORIGIN_GUARD_OPTIONS, diff --git a/src/policies/session-management-route-policy.ts b/src/policies/session-management-route-policy.ts new file mode 100644 index 0000000..f49411f --- /dev/null +++ b/src/policies/session-management-route-policy.ts @@ -0,0 +1,27 @@ +import { HttpStatus, Injectable } from "@nestjs/common"; +import { AuthRoutePolicy } from "../decorators/route-policy.decorator.ts"; +import { deny, type BetterAuthRoutePolicyHandler } from "./route-policy.ts"; + +/** Raw Better Auth routes superseded by {@link BetterAuthSessionService}. */ +export const BETTER_AUTH_SESSION_MANAGEMENT_PATHS = [ + "/list-sessions", + "/revoke-session", + "/revoke-other-sessions", + "/revoke-sessions", +] as const; + +/** + * Opt-in policy that closes Better Auth's token-oriented HTTP session routes. + * Server-side `BetterAuthSessionService` calls remain available. + */ +@AuthRoutePolicy({ path: BETTER_AUTH_SESSION_MANAGEMENT_PATHS, order: -100 }) +@Injectable() +export class BetterAuthSessionManagementRoutePolicy implements BetterAuthRoutePolicyHandler { + evaluate() { + return deny(HttpStatus.FORBIDDEN, { + statusCode: HttpStatus.FORBIDDEN, + code: "SESSION_MANAGEMENT_FACADE_REQUIRED", + message: "Use the application's session-management endpoints.", + }); + } +} diff --git a/src/services/better-auth-api-invocation.ts b/src/services/better-auth-api-invocation.ts new file mode 100644 index 0000000..4ef7162 --- /dev/null +++ b/src/services/better-auth-api-invocation.ts @@ -0,0 +1,53 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; +import { isAPIError } from "better-auth/api"; +import { fromNodeHeaders } from "better-auth/node"; +import type { IncomingHttpHeaders } from "node:http"; + +/** Header shapes accepted by Better Auth server-side API invocations. */ +export type BetterAuthApiHeaders = Headers | IncomingHttpHeaders; + +/** Stable Nest response body produced for a Better Auth API error. */ +export interface BetterAuthApiErrorResponse { + readonly statusCode: number; + readonly code: string; + readonly message: string; +} + +/** Convert Node/Nest request headers to the Web Headers shape Better Auth requires. */ +export function normalizeBetterAuthHeaders(headers: BetterAuthApiHeaders): Headers { + return headers instanceof Headers ? new Headers(headers) : fromNodeHeaders(headers); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function httpErrorStatus(statusCode: unknown): number { + return typeof statusCode === "number" && + Number.isInteger(statusCode) && + statusCode >= 100 && + statusCode <= 599 + ? statusCode + : HttpStatus.INTERNAL_SERVER_ERROR; +} + +/** + * Translate a Better Auth `APIError` into a Nest exception without exposing its + * arbitrary body fields (notably `cause`). Non-Better-Auth errors are left for + * the application's own exception pipeline. + */ +export function mapBetterAuthApiError(error: unknown): HttpException | undefined { + if (!isAPIError(error)) return undefined; + + const statusCode = httpErrorStatus(error.statusCode); + const body = error.body; + const code = + nonEmptyString(body?.code) ?? + nonEmptyString(error.status) ?? + (statusCode === 500 ? "INTERNAL_SERVER_ERROR" : "BETTER_AUTH_ERROR"); + const message = + nonEmptyString(body?.message) ?? nonEmptyString(error.message) ?? "Better Auth request failed."; + const response: BetterAuthApiErrorResponse = { statusCode, code, message }; + + return new HttpException(response, statusCode, { cause: error }); +} diff --git a/src/services/better-auth-session.service.ts b/src/services/better-auth-session.service.ts new file mode 100644 index 0000000..276453c --- /dev/null +++ b/src/services/better-auth-session.service.ts @@ -0,0 +1,283 @@ +import { HttpException, HttpStatus, Injectable } from "@nestjs/common"; +import { APIError } from "better-auth/api"; +import type { AnyAuth, RegisteredAuth } from "../types/auth.types.ts"; +import type { BetterAuthApiHeaders } from "./better-auth-api-invocation.ts"; +import { BetterAuthService } from "./better-auth.service.ts"; + +/** Token-free session information safe to return from an application API. */ +export interface BetterAuthSessionSummary { + readonly id: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly expiresAt: Date; + readonly ipAddress: string | null; + readonly userAgent: string | null; + readonly current: boolean; +} + +/** Result of revoking one caller-owned session by its public-safe identifier. */ +export interface BetterAuthSessionRevocationResult { + readonly status: boolean; + readonly revokedSessionId: string; + readonly revokedCurrentSession: boolean; +} + +/** Result of a bulk session revocation operation. */ +export interface BetterAuthSessionBulkRevocationResult { + readonly status: boolean; +} + +interface CoreSessionApi { + getSession(input: { + headers: Headers; + query: { disableCookieCache: boolean; disableRefresh: boolean }; + }): unknown; + listSessions(input: { headers: Headers }): unknown; + revokeSession(input: { body: { token: string }; headers: Headers }): unknown; + revokeOtherSessions(input: { headers: Headers }): unknown; + revokeSessions(input: { headers: Headers }): unknown; +} + +interface PrivateSessionRecord { + readonly id: string; + readonly token: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly expiresAt: Date; + readonly ipAddress: string | null; + readonly userAgent: string | null; +} + +interface OwnedSessions { + readonly currentSessionId: string; + readonly sessions: readonly PrivateSessionRecord[]; +} + +const CORE_SESSION_API_METHODS = [ + "getSession", + "listSessions", + "revokeSession", + "revokeOtherSessions", + "revokeSessions", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function coreSessionApi(value: unknown): CoreSessionApi { + if (!isRecord(value)) { + throw new TypeError("The Better Auth server API is unavailable."); + } + for (const method of CORE_SESSION_API_METHODS) { + if (typeof value[method] !== "function") { + throw new TypeError(`The Better Auth server API does not provide '${method}'.`); + } + } + + const invoke = (method: (typeof CORE_SESSION_API_METHODS)[number], input: unknown): unknown => { + const candidate = value[method]; + if (typeof candidate !== "function") { + // Re-check in case a mutable plugin API changes after the initial validation. + throw new TypeError(`The Better Auth server API does not provide '${method}'.`); + } + const result: unknown = Reflect.apply(candidate, value, [input]); + return result; + }; + + return { + getSession: (input) => invoke("getSession", input), + listSessions: (input) => invoke("listSessions", input), + revokeSession: (input) => invoke("revokeSession", input), + revokeOtherSessions: (input) => invoke("revokeOtherSessions", input), + revokeSessions: (input) => invoke("revokeSessions", input), + }; +} + +function requiredString(record: Record, field: string): string { + const value = record[field]; + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`Better Auth returned an invalid session '${field}'.`); + } + return value; +} + +function optionalString(record: Record, field: string): string | null { + const value = record[field]; + if (value === null || value === undefined) return null; + if (typeof value !== "string") { + throw new TypeError(`Better Auth returned an invalid session '${field}'.`); + } + return value; +} + +function requiredDate(record: Record, field: string): Date { + const value = record[field]; + let date: Date; + if (value instanceof Date) date = new Date(value.getTime()); + else if (typeof value === "string") date = new Date(value); + else if (typeof value === "number") date = new Date(value); + else throw new TypeError(`Better Auth returned an invalid session '${field}'.`); + if (Number.isNaN(date.getTime())) { + throw new TypeError(`Better Auth returned an invalid session '${field}'.`); + } + return date; +} + +function privateSession(value: unknown): PrivateSessionRecord { + if (!isRecord(value)) throw new TypeError("Better Auth returned an invalid session."); + return { + id: requiredString(value, "id"), + token: requiredString(value, "token"), + createdAt: requiredDate(value, "createdAt"), + updatedAt: requiredDate(value, "updatedAt"), + expiresAt: requiredDate(value, "expiresAt"), + ipAddress: optionalString(value, "ipAddress"), + userAgent: optionalString(value, "userAgent"), + }; +} + +function currentSessionId(value: unknown): string | undefined { + if (value === null) return undefined; + if (!isRecord(value) || !isRecord(value.session)) { + throw new TypeError("Better Auth returned an invalid current session."); + } + return requiredString(value.session, "id"); +} + +function privateSessions(value: unknown): readonly PrivateSessionRecord[] { + if (!Array.isArray(value)) { + throw new TypeError("Better Auth returned an invalid session list."); + } + return value.map(privateSession); +} + +function revocationStatus(value: unknown): boolean { + if (!isRecord(value) || typeof value.status !== "boolean") { + throw new TypeError("Better Auth returned an invalid session revocation result."); + } + return value.status; +} + +function sessionNotFound(): HttpException { + return new HttpException( + { + statusCode: HttpStatus.NOT_FOUND, + code: "SESSION_NOT_FOUND", + message: "Session not found.", + }, + HttpStatus.NOT_FOUND, + ); +} + +function invalidSessionId(): HttpException { + return new HttpException( + { + statusCode: HttpStatus.BAD_REQUEST, + code: "INVALID_SESSION_ID", + message: "Session id must be a non-empty string.", + }, + HttpStatus.BAD_REQUEST, + ); +} + +/** + * Application-facing session management over Better Auth's token-oriented API. + * Tokens remain private to this service and are never present in its results. + */ +@Injectable() +export class BetterAuthSessionService { + constructor(private readonly betterAuth: BetterAuthService) {} + + /** List the caller's active sessions without exposing bearer tokens or user ids. */ + async list(headers: BetterAuthApiHeaders): Promise { + const owned = await this.readOwnedSessions(headers); + return owned.sessions.map((session) => ({ + id: session.id, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + expiresAt: session.expiresAt, + ipAddress: session.ipAddress, + userAgent: session.userAgent, + current: session.id === owned.currentSessionId, + })); + } + + /** + * Revoke one of the caller's sessions by id. Unknown and foreign ids share + * the same response so this method does not become a session-id oracle. + */ + async revokeById( + headers: BetterAuthApiHeaders, + sessionId: string, + ): Promise { + if (typeof sessionId !== "string" || sessionId.trim().length === 0) { + throw invalidSessionId(); + } + + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = coreSessionApi(untypedApi); + const owned = await this.readOwnedSessionsWithApi(api, normalizedHeaders); + const target = owned.sessions.find((session) => session.id === sessionId); + if (!target) throw sessionNotFound(); + + const result = await api.revokeSession({ + body: { token: target.token }, + headers: normalizedHeaders, + }); + return { + status: revocationStatus(result), + revokedSessionId: target.id, + revokedCurrentSession: target.id === owned.currentSessionId, + }; + }); + } + + /** Revoke every caller-owned session except the session making this request. */ + async revokeOthers( + headers: BetterAuthApiHeaders, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const result = await coreSessionApi(untypedApi).revokeOtherSessions({ + headers: normalizedHeaders, + }); + return { status: revocationStatus(result) }; + }); + } + + /** Revoke every caller-owned session, including the session making this request. */ + async revokeAll(headers: BetterAuthApiHeaders): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const result = await coreSessionApi(untypedApi).revokeSessions({ + headers: normalizedHeaders, + }); + return { status: revocationStatus(result) }; + }); + } + + private readOwnedSessions(headers: BetterAuthApiHeaders): Promise { + return this.betterAuth.invokeApi(headers, (untypedApi: unknown, normalizedHeaders) => + this.readOwnedSessionsWithApi(coreSessionApi(untypedApi), normalizedHeaders), + ); + } + + private async readOwnedSessionsWithApi( + api: CoreSessionApi, + headers: Headers, + ): Promise { + const currentResult = await api.getSession({ + headers, + query: { disableCookieCache: true, disableRefresh: true }, + }); + const currentId = currentSessionId(currentResult); + if (!currentId) { + throw new APIError("UNAUTHORIZED", { + code: "UNAUTHORIZED", + message: "Unauthorized.", + }); + } + + const sessions = privateSessions(await api.listSessions({ headers })); + return { currentSessionId: currentId, sessions }; + } +} diff --git a/src/services/better-auth.service.ts b/src/services/better-auth.service.ts index 98f3817..c73d8b9 100644 --- a/src/services/better-auth.service.ts +++ b/src/services/better-auth.service.ts @@ -1,8 +1,18 @@ import { Inject, Injectable } from "@nestjs/common"; -import { fromNodeHeaders } from "better-auth/node"; import type { IncomingHttpHeaders } from "node:http"; import { BETTER_AUTH_INSTANCE } from "../better-auth.tokens.ts"; import type { AnyAuth, AuthContextOf, RegisteredAuth, UserSession } from "../types/auth.types.ts"; +import { + mapBetterAuthApiError, + normalizeBetterAuthHeaders, + type BetterAuthApiHeaders, +} from "./better-auth-api-invocation.ts"; + +/** A plugin-aware Better Auth server API operation. */ +export type BetterAuthApiInvocation = ( + api: TAuth["api"], + headers: Headers, +) => TResult; /** * Typed accessor for the better-auth instance. Inject it anywhere; for @@ -29,8 +39,26 @@ export class BetterAuthService { return this.auth.$context as Promise>; } + /** + * Invoke a plugin-aware Better Auth server endpoint from a Nest service or + * controller. Node request headers are normalized to Web Headers and Better + * Auth API errors are translated to stable Nest HTTP exceptions. + */ + async invokeApi( + headers: BetterAuthApiHeaders, + invoke: BetterAuthApiInvocation, + ): Promise> { + try { + return await invoke(this.auth.api, normalizeBetterAuthHeaders(headers)); + } catch (error: unknown) { + const mapped = mapBetterAuthApiError(error); + if (mapped) throw mapped; + throw error; + } + } + async getSession(headers: Headers | IncomingHttpHeaders): Promise | null> { - const webHeaders = headers instanceof Headers ? headers : fromNodeHeaders(headers); + const webHeaders = normalizeBetterAuthHeaders(headers); const session = await (this.auth as AnyAuth).api.getSession({ headers: webHeaders }); return (session ?? null) as UserSession | null; } diff --git a/src/typeorm/adapter.ts b/src/typeorm/adapter.ts index 6293680..bbcee3c 100644 --- a/src/typeorm/adapter.ts +++ b/src/typeorm/adapter.ts @@ -445,21 +445,27 @@ export function typeormAdapter( }, transaction: config.transaction - ? (callback) => - Reflect.apply(dataSource.transaction, dataSource, [ - (manager: unknown) => { - const transactionalManager = requireEntityManager(manager); - return callback( - createAdapterFactory({ - // The inner adapter is pinned to the transactional manager, and - // `getManager` is deliberately not consulted: a statement that resolved - // its own manager here would run outside the transaction it was handed. - adapter: createCustomAdapter(() => transactionalManager), - config: { ...adapterConfig, transaction: false }, - })(lazyOptions ?? {}), - ); - }, - ]) + ? (callback) => { + const runWithManager = (manager: unknown) => { + const transactionalManager = requireEntityManager(manager); + return callback( + createAdapterFactory({ + // Pin every inner statement to one manager. Resolving the hook again from + // inside the callback could let a context change split one logical unit of work. + adapter: createCustomAdapter(() => transactionalManager), + config: { ...adapterConfig, transaction: false }, + })(lazyOptions ?? {}), + ); + }; + + // A defined scoped manager means the application already owns the transaction + // (typically through AsyncLocalStorage). Join it so Better Auth writes can be + // committed or rolled back atomically with application audit/outbox records. + const scopedManager = config.getManager?.(); + if (scopedManager !== undefined) return runWithManager(scopedManager); + + return Reflect.apply(dataSource.transaction, dataSource, [runWithManager]); + } : false, }; diff --git a/src/typeorm/types.ts b/src/typeorm/types.ts index 46c7a14..6ce469a 100644 --- a/src/typeorm/types.ts +++ b/src/typeorm/types.ts @@ -84,13 +84,16 @@ export interface TypeormAdapterConfig { * construction: an adapter is built at application boot, long before any request context * exists. * - * Statements issued inside `transaction()` ignore this hook and use the transactional - * manager, because a callback that escaped its own transaction would defeat the point. + * When `transaction` is enabled, a defined manager also signals that the application already + * owns the transaction. Better Auth's transaction callback joins and pins that manager rather + * than opening an independent `dataSource.transaction()`. Return `undefined` whenever no + * application transaction is active. */ getManager?: (() => TypeormEntityManager | undefined) | undefined; /** - * Enable Better Auth's `transaction()` support, backed by `dataSource.transaction()`. + * Enable Better Auth's `transaction()` support. It joins a manager returned by `getManager`, + * or opens a `dataSource.transaction()` when the hook is absent or returns `undefined`. * * @default false */ diff --git a/tests/e2e/api-invocation.e2e.test.ts b/tests/e2e/api-invocation.e2e.test.ts new file mode 100644 index 0000000..abbafc1 --- /dev/null +++ b/tests/e2e/api-invocation.e2e.test.ts @@ -0,0 +1,81 @@ +import { Controller, Get, Headers as RequestHeaders, Post } from "@nestjs/common"; +import { betterAuth } from "better-auth"; +import { bearer as bearerPlugin, organization } from "better-auth/plugins"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { INestApplication } from "@nestjs/common"; +import { BetterAuthService } from "../../src/index.ts"; +import { signUpUser, bearer } from "../shared/auth-client.ts"; +import { createTestApp } from "../shared/test-app.ts"; +import { TEST_BASE_URL, TEST_SECRET } from "../shared/test-auth.ts"; +import { testHttpAdapter } from "../shared/http-adapter.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +const auth = betterAuth({ + baseURL: TEST_BASE_URL, + secret: TEST_SECRET, + emailAndPassword: { enabled: true }, + telemetry: { enabled: false }, + plugins: [bearerPlugin(), organization()], +}); + +@Controller("auth-facade") +class AuthFacadeController { + constructor(private readonly authService: BetterAuthService) {} + + @Get("session") + getSession(@RequestHeaders() requestHeaders: IncomingHttpHeaders) { + return this.authService.invokeApi(requestHeaders, (api, headers) => + api.getSession({ headers }), + ); + } + + @Post("missing-invitation") + acceptMissingInvitation(@RequestHeaders() requestHeaders: IncomingHttpHeaders) { + return this.authService.invokeApi(requestHeaders, (api, headers) => + api.acceptInvitation({ + body: { invitationId: "missing-invitation" }, + headers, + }), + ); + } +} + +describe(`BetterAuthService.invokeApi (${testHttpAdapter})`, () => { + let app: INestApplication; + let token: string; + + beforeAll(async () => { + app = await createTestApp({ + forRoot: { auth }, + metadata: { controllers: [AuthFacadeController] }, + }); + token = (await signUpUser(app)).token; + }); + + afterAll(async () => { + await app.close(); + }); + + it("authenticates a server API call with normalized request headers", async () => { + const response = await request(app.getHttpServer()) + .get("/auth-facade/session") + .set(bearer(token)); + + expect(response.status).toBe(200); + expect(response.body.session.token).toBe(token); + }); + + it("returns a stable Nest error for a Better Auth API failure", async () => { + const response = await request(app.getHttpServer()) + .post("/auth-facade/missing-invitation") + .set(bearer(token)); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + statusCode: 400, + code: "INVITATION_NOT_FOUND", + }); + expect(response.body.message).toEqual(expect.any(String)); + }); +}); diff --git a/tests/e2e/session-service.e2e.test.ts b/tests/e2e/session-service.e2e.test.ts new file mode 100644 index 0000000..7fe5cd8 --- /dev/null +++ b/tests/e2e/session-service.e2e.test.ts @@ -0,0 +1,233 @@ +import { + Controller, + Get, + Headers as RequestHeaders, + HttpCode, + HttpStatus, + Param, + Post, +} from "@nestjs/common"; +import { betterAuth } from "better-auth"; +import { bearer as bearerPlugin } from "better-auth/plugins"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { INestApplication } from "@nestjs/common"; +import { + BetterAuthModule, + BetterAuthSessionManagementRoutePolicy, + BetterAuthSessionService, +} from "../../src/index.ts"; +import { bearer, signUpUser, type SignedUpUser } from "../shared/auth-client.ts"; +import { createTestApp } from "../shared/test-app.ts"; +import { TEST_BASE_URL, TEST_SECRET } from "../shared/test-auth.ts"; +import { testHttpAdapter } from "../shared/http-adapter.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +const auth = betterAuth({ + baseURL: TEST_BASE_URL, + secret: TEST_SECRET, + emailAndPassword: { enabled: true }, + telemetry: { enabled: false }, + plugins: [bearerPlugin()], +}); + +@Controller("account/sessions") +class SessionFacadeController { + constructor(private readonly sessions: BetterAuthSessionService) {} + + @Get() + list(@RequestHeaders() headers: IncomingHttpHeaders) { + return this.sessions.list(headers); + } + + @Post("revoke-others") + @HttpCode(HttpStatus.OK) + revokeOthers(@RequestHeaders() headers: IncomingHttpHeaders) { + return this.sessions.revokeOthers(headers); + } + + @Post("revoke-all") + @HttpCode(HttpStatus.OK) + revokeAll(@RequestHeaders() headers: IncomingHttpHeaders) { + return this.sessions.revokeAll(headers); + } + + @Post(":sessionId/revoke") + @HttpCode(HttpStatus.OK) + revokeById( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("sessionId") sessionId: string, + ) { + return this.sessions.revokeById(headers, sessionId); + } +} + +async function signInAgain(app: INestApplication, user: SignedUpUser): Promise { + const response = await request(app.getHttpServer()) + .post("/api/auth/sign-in/email") + .send({ email: user.email, password: user.password }); + if (response.status !== 200 || typeof response.body?.token !== "string") { + throw new Error(`sign-in/email failed: ${response.status} ${JSON.stringify(response.body)}`); + } + return response.body.token; +} + +async function sessionForToken(app: INestApplication, token: string): Promise { + const response = await request(app.getHttpServer()) + .get("/api/auth/get-session") + .set(bearer(token)); + if (response.status !== 200) { + throw new Error(`get-session failed: ${response.status} ${JSON.stringify(response.body)}`); + } + return response.body; +} + +describe(`BetterAuthSessionService (${testHttpAdapter})`, () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp({ + forRoot: { auth }, + metadata: { + controllers: [SessionFacadeController], + imports: [ + BetterAuthModule.forFeature({ + routePolicies: [BetterAuthSessionManagementRoutePolicy], + }), + ], + }, + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("lists safe summaries and identifies the requesting session without exposing tokens", async () => { + const user = await signUpUser(app); + const currentToken = await signInAgain(app, user); + + const response = await request(app.getHttpServer()) + .get("/account/sessions") + .set(bearer(currentToken)); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body.filter((entry: { current: boolean }) => entry.current)).toHaveLength(1); + for (const summary of response.body) { + expect(summary).toEqual({ + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + expiresAt: expect.any(String), + ipAddress: expect.toBeOneOf([expect.any(String), null]), + userAgent: expect.toBeOneOf([expect.any(String), null]), + current: expect.any(Boolean), + }); + } + const serialized = JSON.stringify(response.body); + expect(serialized).not.toContain("token"); + expect(serialized).not.toContain("userId"); + expect(serialized).not.toContain(user.token); + expect(serialized).not.toContain(currentToken); + }); + + it("revokes a caller-owned session by id while preserving the current session", async () => { + const user = await signUpUser(app); + const currentToken = await signInAgain(app, user); + const listed = await request(app.getHttpServer()) + .get("/account/sessions") + .set(bearer(currentToken)); + const other = listed.body.find((entry: { current: boolean }) => !entry.current); + + const revoked = await request(app.getHttpServer()) + .post(`/account/sessions/${other.id}/revoke`) + .set(bearer(currentToken)); + + expect(revoked.status).toBe(200); + expect(revoked.body).toEqual({ + status: true, + revokedSessionId: other.id, + revokedCurrentSession: false, + }); + expect(JSON.stringify(revoked.body)).not.toContain(user.token); + expect(await sessionForToken(app, user.token)).toBeNull(); + expect(await sessionForToken(app, currentToken)).toMatchObject({ + session: { id: expect.any(String) }, + }); + }); + + it("does not reveal or revoke another user's session by id", async () => { + const caller = await signUpUser(app); + const otherUser = await signUpUser(app); + const otherList = await request(app.getHttpServer()) + .get("/account/sessions") + .set(bearer(otherUser.token)); + const foreignSessionId: string = otherList.body[0].id; + + const response = await request(app.getHttpServer()) + .post(`/account/sessions/${foreignSessionId}/revoke`) + .set(bearer(caller.token)); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + statusCode: 404, + code: "SESSION_NOT_FOUND", + message: "Session not found.", + }); + expect(await sessionForToken(app, otherUser.token)).toMatchObject({ + session: { id: foreignSessionId }, + }); + }); + + it("revokes other sessions and then the current session through bulk operations", async () => { + const user = await signUpUser(app); + const secondToken = await signInAgain(app, user); + const currentToken = await signInAgain(app, user); + + const others = await request(app.getHttpServer()) + .post("/account/sessions/revoke-others") + .set(bearer(currentToken)); + + expect(others.status).toBe(200); + expect(others.body).toEqual({ status: true }); + expect(await sessionForToken(app, user.token)).toBeNull(); + expect(await sessionForToken(app, secondToken)).toBeNull(); + expect(await sessionForToken(app, currentToken)).not.toBeNull(); + + const all = await request(app.getHttpServer()) + .post("/account/sessions/revoke-all") + .set(bearer(currentToken)); + + expect(all.status).toBe(200); + expect(all.body).toEqual({ status: true }); + expect(await sessionForToken(app, currentToken)).toBeNull(); + }); + + it("blocks Better Auth's raw token-bearing routes while keeping the server facade usable", async () => { + const user = await signUpUser(app); + + const rawList = await request(app.getHttpServer()) + .get("/api/auth/list-sessions") + .set(bearer(user.token)); + const rawRevoke = await request(app.getHttpServer()) + .post("/api/auth/revoke-session") + .set(bearer(user.token)) + .send({ token: user.token }); + const facadeList = await request(app.getHttpServer()) + .get("/account/sessions") + .set(bearer(user.token)); + + for (const response of [rawList, rawRevoke]) { + expect(response.status).toBe(403); + expect(response.body).toEqual({ + statusCode: 403, + code: "SESSION_MANAGEMENT_FACADE_REQUIRED", + message: "Use the application's session-management endpoints.", + }); + } + expect(facadeList.status).toBe(200); + expect(facadeList.body).toHaveLength(1); + expect(JSON.stringify(facadeList.body)).not.toContain(user.token); + }); +}); diff --git a/tests/packed-types/consumer.ts b/tests/packed-types/consumer.ts index 9410047..3fd6f4e 100644 --- a/tests/packed-types/consumer.ts +++ b/tests/packed-types/consumer.ts @@ -1,6 +1,9 @@ import { BetterAuthGuard, BetterAuthModule, + BetterAuthService, + BetterAuthSessionManagementRoutePolicy, + BetterAuthSessionService, AuthRoutePolicy, deny, type AnyAuth, @@ -8,15 +11,46 @@ import { type BetterAuthRoutePolicy, type BetterAuthRoutePolicyContext, type BetterAuthRoutePolicyHandler, + type BetterAuthSessionBulkRevocationResult, + type BetterAuthSessionRevocationResult, + type BetterAuthSessionSummary, } from "@nestm/better-auth"; import { typeormAdapter, type TypeormAdapterConfig } from "@nestm/better-auth/typeorm"; import type { Reflector } from "@nestjs/core"; +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; +import type { IncomingHttpHeaders } from "node:http"; import type { DataSource, EntityManager } from "typeorm"; declare const reflector: Reflector; declare const auth: AnyAuth; declare const dataSource: DataSource; declare const scopedManager: EntityManager | undefined; +declare const requestHeaders: IncomingHttpHeaders; + +const pluginAuth = betterAuth({ plugins: [organization()] }); +declare const pluginService: BetterAuthService; +declare const sessionService: BetterAuthSessionService; +const invitationCall = pluginService.invokeApi(requestHeaders, (api, headers) => + api.createInvitation({ + body: { + email: "packed@example.com", + role: "member", + organizationId: "packed-organization", + }, + headers, + }), +); +const sessionList: Promise = + sessionService.list(requestHeaders); +const sessionRevocation: Promise = sessionService.revokeById( + requestHeaders, + "session-id", +); +const otherSessionRevocation: Promise = + sessionService.revokeOthers(requestHeaders); +const allSessionRevocation: Promise = + sessionService.revokeAll(requestHeaders); const functionalRoutePolicy = (({ authPath }) => authPath === "/functional-policy-test" @@ -53,6 +87,9 @@ class PackedRoutePolicy implements BetterAuthRoutePolicyHandler { } AuthRoutePolicy({ path: "/sign-up/*", methods: ["POST"], order: -10 })(PackedRoutePolicy); const policyFeature = BetterAuthModule.forFeature({ routePolicies: [PackedRoutePolicy] }); +const sessionPolicyFeature = BetterAuthModule.forFeature({ + routePolicies: [BetterAuthSessionManagementRoutePolicy], +}); // The `./typeorm` subpath ships its own entry, so it needs its own coverage here: without a // consumer import it would be published untested against its rolled-up declarations. @@ -73,8 +110,14 @@ export { databaseAdapter, defaultedAdapter, interop, + invitationCall, + sessionList, + sessionRevocation, + otherSessionRevocation, + allSessionRevocation, manuallyConstructedGuard, moduleWithTypeormDatabase, policyFeature, + sessionPolicyFeature, synchronousModule, }; diff --git a/tests/postgres/adapter-options.spec.ts b/tests/postgres/adapter-options.spec.ts index 4339da7..0cfe31c 100644 --- a/tests/postgres/adapter-options.spec.ts +++ b/tests/postgres/adapter-options.spec.ts @@ -129,6 +129,40 @@ describe("adapter options", () => { }); describe("transaction", () => { + test("joins an application-owned transaction supplied by getManager", async () => { + const dataSource = context.dataSource!; + let scoped: EntityManager | undefined; + let managerResolutions = 0; + const db = typeormAdapter(dataSource, { + transaction: true, + getManager: () => { + managerResolutions++; + return scoped; + }, + })(MINIMAL_OPTIONS); + + await expect( + dataSource.transaction(async (manager) => { + scoped = manager; + await db.transaction(async (trx) => { + await trx.create({ + model: "rateLimit", + data: { key: "joined-tx", count: 1, lastRequest: 1 }, + }); + }); + // The inner adapter is pinned: the hook identifies the outer transaction once, + // rather than being re-resolved for every statement in Better Auth's callback. + expect(managerResolutions).toBe(1); + throw new Error("roll back application unit of work"); + }), + ).rejects.toThrow("roll back application unit of work"); + + scoped = undefined; + expect( + await db.findOne({ model: "rateLimit", where: [{ field: "key", value: "joined-tx" }] }), + ).toBeNull(); + }); + test("commits when the callback resolves and rolls back when it throws", async () => { const dataSource = context.dataSource!; const db = typeormAdapter(dataSource, { transaction: true })(MINIMAL_OPTIONS); diff --git a/tests/unit/api-invocation-type-assertions.ts b/tests/unit/api-invocation-type-assertions.ts new file mode 100644 index 0000000..361a438 --- /dev/null +++ b/tests/unit/api-invocation-type-assertions.ts @@ -0,0 +1,33 @@ +/** Compile-time coverage for plugin-aware server API invocation inference. */ +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; +import { BetterAuthService } from "../../src/index.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +const auth = betterAuth({ plugins: [organization()] }); +declare const service: BetterAuthService; +declare const requestHeaders: IncomingHttpHeaders; + +const invitation = service.invokeApi(requestHeaders, (api, headers) => + api.createInvitation({ + body: { + email: "invitee@example.com", + role: "member", + organizationId: "organization-id", + }, + headers, + }), +); + +const sessions = service.invokeApi(new Headers(), (api, headers) => api.listSessions({ headers })); + +async function assertInferredResults(): Promise { + const created = await invitation; + const invitationId: string = created.id; + const activeSessions = await sessions; + const sessionId: string | undefined = activeSessions[0]?.id; + void invitationId; + void sessionId; +} + +export { assertInferredResults, invitation, sessions }; diff --git a/tests/unit/api-invocation.test.ts b/tests/unit/api-invocation.test.ts new file mode 100644 index 0000000..caa765f --- /dev/null +++ b/tests/unit/api-invocation.test.ts @@ -0,0 +1,128 @@ +import { HttpException } from "@nestjs/common"; +import { APIError } from "better-auth/api"; +import { describe, expect, it, vi } from "vitest"; +import { + BetterAuthService, + mapBetterAuthApiError, + normalizeBetterAuthHeaders, + type AnyAuth, +} from "../../src/index.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +function createAuth>(api: TApi) { + return { + handler: async (_request: Request) => new Response(), + api, + options: {}, + $context: Promise.resolve({}), + $Infer: { Session: {} }, + $ERROR_CODES: {}, + } satisfies AnyAuth; +} + +describe("normalizeBetterAuthHeaders", () => { + it("converts Node headers without dropping repeated values", () => { + const source: IncomingHttpHeaders = { + cookie: "session=abc", + "x-forwarded-for": ["192.0.2.1", "192.0.2.2"], + }; + + const headers = normalizeBetterAuthHeaders(source); + + expect(headers).toBeInstanceOf(Headers); + expect(headers.get("cookie")).toBe("session=abc"); + expect(headers.get("x-forwarded-for")).toBe("192.0.2.1, 192.0.2.2"); + }); + + it("copies an existing Web Headers object", () => { + const source = new Headers({ authorization: "Bearer token" }); + + const headers = normalizeBetterAuthHeaders(source); + source.set("authorization", "Bearer changed"); + + expect(headers).not.toBe(source); + expect(headers.get("authorization")).toBe("Bearer token"); + }); +}); + +describe("mapBetterAuthApiError", () => { + it("preserves status, code, and message but strips arbitrary error body fields", () => { + const error = new APIError("FORBIDDEN", { + code: "MEMBER_ACCESS_DENIED", + message: "Member access denied.", + cause: new Error("database details"), + internal: "not-for-the-response", + }); + + const mapped = mapBetterAuthApiError(error); + + expect(mapped).toBeInstanceOf(HttpException); + expect(mapped?.getStatus()).toBe(403); + expect(mapped?.getResponse()).toEqual({ + statusCode: 403, + code: "MEMBER_ACCESS_DENIED", + message: "Member access denied.", + }); + expect(mapped?.cause).toBe(error); + }); + + it("uses the Better Auth status name when the body has no code", () => { + const mapped = mapBetterAuthApiError(new APIError("UNAUTHORIZED", { message: "Sign in." })); + + expect(mapped?.getResponse()).toEqual({ + statusCode: 401, + code: "UNAUTHORIZED", + message: "Sign in.", + }); + }); + + it("does not reinterpret application errors", () => { + expect(mapBetterAuthApiError(new Error("application failure"))).toBeUndefined(); + }); +}); + +describe("BetterAuthService.invokeApi", () => { + it("passes plugin API and normalized headers to the operation", async () => { + const inspect = vi.fn((headers: Headers) => headers.get("authorization")); + const auth = createAuth({ inspect }); + const service = new BetterAuthService(auth); + + const result = await service.invokeApi({ authorization: "Bearer caller" }, (api, headers) => + api.inspect(headers), + ); + + expect(result).toBe("Bearer caller"); + expect(inspect).toHaveBeenCalledOnce(); + }); + + it("maps Better Auth API errors thrown asynchronously", async () => { + const service = new BetterAuthService(createAuth({})); + + const promise = service.invokeApi({}, async () => { + throw new APIError("CONFLICT", { + code: "INVITATION_ALREADY_EXISTS", + message: "Invitation already exists.", + }); + }); + + await expect(promise).rejects.toMatchObject({ + status: 409, + response: { + statusCode: 409, + code: "INVITATION_ALREADY_EXISTS", + message: "Invitation already exists.", + }, + }); + }); + + it("rethrows non-Better-Auth failures unchanged", async () => { + const service = new BetterAuthService(createAuth({})); + const failure = new Error("audit store unavailable"); + + const promise = service.invokeApi({}, () => { + throw failure; + }); + + await expect(promise).rejects.toBe(failure); + }); +}); diff --git a/tests/unit/exports.test.ts b/tests/unit/exports.test.ts index e53ed6d..95914cc 100644 --- a/tests/unit/exports.test.ts +++ b/tests/unit/exports.test.ts @@ -22,11 +22,16 @@ const EXPECTED_VALUE_EXPORTS = [ "mergeHookContext", "resolveAuthBasePath", "normalizeBasePath", + "normalizeBetterAuthHeaders", + "mapBetterAuthApiError", "deny", "getRequestFromContext", "resolveContextKind", // services & guard "BetterAuthService", + "BetterAuthSessionService", + "BetterAuthSessionManagementRoutePolicy", + "BETTER_AUTH_SESSION_MANAGEMENT_PATHS", "BetterAuthGuard", "MutationOriginGuard", "MUTATION_ORIGIN_GUARD_OPTIONS", diff --git a/tests/unit/session-route-policy.test.ts b/tests/unit/session-route-policy.test.ts new file mode 100644 index 0000000..16633ac --- /dev/null +++ b/tests/unit/session-route-policy.test.ts @@ -0,0 +1,30 @@ +import { HttpStatus } from "@nestjs/common"; +import { describe, expect, it } from "vitest"; +import { + BETTER_AUTH_SESSION_MANAGEMENT_PATHS, + BetterAuthSessionManagementRoutePolicy, +} from "../../src/index.ts"; + +describe("BetterAuthSessionManagementRoutePolicy", () => { + it("covers every token-oriented Better Auth session-management route", () => { + expect(BETTER_AUTH_SESSION_MANAGEMENT_PATHS).toEqual([ + "/list-sessions", + "/revoke-session", + "/revoke-other-sessions", + "/revoke-sessions", + ]); + }); + + it("returns a stable opt-in denial", () => { + expect(new BetterAuthSessionManagementRoutePolicy().evaluate()).toEqual({ + effect: "deny", + status: HttpStatus.FORBIDDEN, + body: { + statusCode: HttpStatus.FORBIDDEN, + code: "SESSION_MANAGEMENT_FACADE_REQUIRED", + message: "Use the application's session-management endpoints.", + }, + headers: undefined, + }); + }); +}); diff --git a/tests/unit/session-service-type-assertions.ts b/tests/unit/session-service-type-assertions.ts new file mode 100644 index 0000000..7b54861 --- /dev/null +++ b/tests/unit/session-service-type-assertions.ts @@ -0,0 +1,38 @@ +/** Compile-time coverage for the stable, token-free session facade. */ +import { + BetterAuthSessionService, + type BetterAuthSessionBulkRevocationResult, + type BetterAuthSessionRevocationResult, + type BetterAuthSessionSummary, +} from "../../src/index.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +declare const service: BetterAuthSessionService; +declare const requestHeaders: IncomingHttpHeaders; + +const listed: Promise = service.list(requestHeaders); +const revoked: Promise = service.revokeById( + requestHeaders, + "session-id", +); +const revokedOthers: Promise = + service.revokeOthers(requestHeaders); +const revokedAll: Promise = service.revokeAll(new Headers()); + +async function assertSafeSurface(): Promise { + const summary = (await listed)[0]; + if (summary) { + const id: string = summary.id; + const current: boolean = summary.current; + // @ts-expect-error Tokens must never appear on public summaries. + const token = summary.token; + // @ts-expect-error User ids must never appear on public summaries. + const userId = summary.userId; + void id; + void current; + void token; + void userId; + } +} + +export { assertSafeSurface, listed, revoked, revokedAll, revokedOthers }; diff --git a/tests/unit/session-service.test.ts b/tests/unit/session-service.test.ts new file mode 100644 index 0000000..d38a480 --- /dev/null +++ b/tests/unit/session-service.test.ts @@ -0,0 +1,215 @@ +import { HttpException } from "@nestjs/common"; +import { APIError } from "better-auth/api"; +import { describe, expect, it, vi } from "vitest"; +import { BetterAuthService, BetterAuthSessionService, type AnyAuth } from "../../src/index.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +const CREATED_AT = new Date("2026-01-01T10:00:00.000Z"); +const UPDATED_AT = new Date("2026-01-02T10:00:00.000Z"); +const EXPIRES_AT = new Date("2027-01-01T10:00:00.000Z"); + +function session(id: string, token: string, overrides: Record = {}) { + return { + id, + token, + userId: "caller-user-id", + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + expiresAt: EXPIRES_AT, + ipAddress: "192.0.2.10", + userAgent: "Test Browser", + ...overrides, + }; +} + +function createApi(overrides: Record = {}) { + return { + getSession: vi.fn( + async (_input: { + headers: Headers; + query: { disableCookieCache: boolean; disableRefresh: boolean }; + }) => ({ + session: session("current-id", "current-secret"), + user: { id: "caller-user-id" }, + }), + ), + listSessions: vi.fn(async (_input: { headers: Headers }) => [ + session("other-id", "other-secret", { ipAddress: undefined, userAgent: null }), + session("current-id", "current-secret"), + ]), + revokeSession: vi.fn(async (_input: { body: { token: string }; headers: Headers }) => ({ + status: true, + })), + revokeOtherSessions: vi.fn(async (_input: { headers: Headers }) => ({ status: true })), + revokeSessions: vi.fn(async (_input: { headers: Headers }) => ({ status: true })), + ...overrides, + }; +} + +function createService(api = createApi()) { + const auth = { + handler: async (_request: Request) => new Response(), + api, + options: {}, + $context: Promise.resolve({}), + $Infer: { Session: {} }, + $ERROR_CODES: {}, + } satisfies AnyAuth; + return { api, service: new BetterAuthSessionService(new BetterAuthService(auth)) }; +} + +describe("BetterAuthSessionService", () => { + it("returns token-free summaries and marks the authoritative current session", async () => { + const { api, service } = createService(); + const nodeHeaders: IncomingHttpHeaders = { + authorization: "Bearer current-secret", + "x-forwarded-for": ["192.0.2.10", "192.0.2.11"], + }; + + const result = await service.list(nodeHeaders); + + expect(result).toEqual([ + { + id: "other-id", + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + expiresAt: EXPIRES_AT, + ipAddress: null, + userAgent: null, + current: false, + }, + { + id: "current-id", + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + expiresAt: EXPIRES_AT, + ipAddress: "192.0.2.10", + userAgent: "Test Browser", + current: true, + }, + ]); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("token"); + expect(serialized).not.toContain("userId"); + expect(serialized).not.toContain("current-secret"); + expect(serialized).not.toContain("other-secret"); + + const currentCall = api.getSession.mock.calls[0]?.[0]; + expect(currentCall?.headers).toBeInstanceOf(Headers); + expect(currentCall?.headers.get("authorization")).toBe("Bearer current-secret"); + expect(currentCall?.headers.get("x-forwarded-for")).toBe("192.0.2.10, 192.0.2.11"); + expect(currentCall?.query).toEqual({ disableCookieCache: true, disableRefresh: true }); + }); + + it("uses a caller-owned token internally when revoking by safe id", async () => { + const { api, service } = createService(); + + const result = await service.revokeById(new Headers({ cookie: "session=caller" }), "other-id"); + + expect(api.revokeSession).toHaveBeenCalledWith({ + body: { token: "other-secret" }, + headers: expect.any(Headers), + }); + expect(result).toEqual({ + status: true, + revokedSessionId: "other-id", + revokedCurrentSession: false, + }); + expect(JSON.stringify(result)).not.toContain("other-secret"); + }); + + it("reports when the revoked id belongs to the current session", async () => { + const { service } = createService(); + + await expect(service.revokeById({}, "current-id")).resolves.toEqual({ + status: true, + revokedSessionId: "current-id", + revokedCurrentSession: true, + }); + }); + + it.each(["unknown-id", "foreign-id"])( + "uses the same not-found response for an absent or unowned id (%s)", + async (sessionId) => { + const { api, service } = createService(); + + const promise = service.revokeById({}, sessionId); + + await expect(promise).rejects.toMatchObject({ + status: 404, + response: { + statusCode: 404, + code: "SESSION_NOT_FOUND", + message: "Session not found.", + }, + }); + expect(api.revokeSession).not.toHaveBeenCalled(); + }, + ); + + it("rejects an empty session id before calling Better Auth", async () => { + const { api, service } = createService(); + + await expect(service.revokeById({}, " ")).rejects.toMatchObject({ + status: 400, + response: { + statusCode: 400, + code: "INVALID_SESSION_ID", + message: "Session id must be a non-empty string.", + }, + }); + expect(api.getSession).not.toHaveBeenCalled(); + }); + + it("maps Better Auth errors through the shared invocation boundary", async () => { + const { service } = createService( + createApi({ + listSessions: vi.fn(async () => { + throw new APIError("FORBIDDEN", { + code: "SESSION_NOT_FRESH", + message: "A fresh session is required.", + cause: new Error("private details"), + }); + }), + }), + ); + + const error = await service.list({}).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(HttpException); + if (!(error instanceof HttpException)) throw error; + expect(error.getResponse()).toEqual({ + statusCode: 403, + code: "SESSION_NOT_FRESH", + message: "A fresh session is required.", + }); + }); + + it("maps an absent authoritative current session to a stable unauthorized error", async () => { + const api = createApi({ getSession: vi.fn(async () => null) }); + const { service } = createService(api); + + await expect(service.list({})).rejects.toMatchObject({ + status: 401, + response: { + statusCode: 401, + code: "UNAUTHORIZED", + message: "Unauthorized.", + }, + }); + expect(api.listSessions).not.toHaveBeenCalled(); + }); + + it("delegates bulk revocations without exposing Better Auth payloads", async () => { + const { api, service } = createService(); + + await expect(service.revokeOthers({ authorization: "Bearer caller" })).resolves.toEqual({ + status: true, + }); + await expect(service.revokeAll({ authorization: "Bearer caller" })).resolves.toEqual({ + status: true, + }); + expect(api.revokeOtherSessions).toHaveBeenCalledOnce(); + expect(api.revokeSessions).toHaveBeenCalledOnce(); + }); +}); From 6a14f6ac106c244777efb6291e8ceba94551f53d Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Sat, 22 Aug 2026 12:10:25 -0300 Subject: [PATCH 3/3] feat: add organization control plane --- .../stock-organization-control-plane.md | 9 + README.md | 91 +- package.json | 2 +- src/better-auth.module.ts | 3 + src/decorators/access-control.decorators.ts | 2 +- src/guards/better-auth.guard.ts | 50 +- src/index.ts | 16 + .../better-auth-module-options.interface.ts | 6 + ...r-auth-organization-lifecycle.interface.ts | 12 + ...organization-control-plane-route-policy.ts | 42 + .../better-auth-organization.service.ts | 790 ++++++++++++++++++ src/typeorm/index.ts | 4 + src/typeorm/organization-lifecycle.ts | 130 +++ tests/e2e/organization-roles.e2e.test.ts | 35 + tests/e2e/organization-service.e2e.test.ts | 350 ++++++++ tests/packed-types/consumer.ts | 56 +- tests/postgres/flows.spec.ts | 12 +- tests/postgres/organization-lifecycle.spec.ts | 115 +++ tests/unit/exports.test.ts | 3 + tests/unit/organization-guard.test.ts | 122 +++ tests/unit/organization-route-policy.test.ts | 41 + .../organization-service-type-assertions.ts | 91 ++ tests/unit/organization-service.test.ts | 461 ++++++++++ .../typeorm-organization-lifecycle.test.ts | 116 +++ 24 files changed, 2530 insertions(+), 29 deletions(-) create mode 100644 .changeset/stock-organization-control-plane.md create mode 100644 src/interfaces/better-auth-organization-lifecycle.interface.ts create mode 100644 src/policies/organization-control-plane-route-policy.ts create mode 100644 src/services/better-auth-organization.service.ts create mode 100644 src/typeorm/organization-lifecycle.ts create mode 100644 tests/e2e/organization-service.e2e.test.ts create mode 100644 tests/postgres/organization-lifecycle.spec.ts create mode 100644 tests/unit/organization-guard.test.ts create mode 100644 tests/unit/organization-route-policy.test.ts create mode 100644 tests/unit/organization-service-type-assertions.ts create mode 100644 tests/unit/organization-service.test.ts create mode 100644 tests/unit/typeorm-organization-lifecycle.test.ts diff --git a/.changeset/stock-organization-control-plane.md b/.changeset/stock-organization-control-plane.md new file mode 100644 index 0000000..5cdca49 --- /dev/null +++ b/.changeset/stock-organization-control-plane.md @@ -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. diff --git a/README.md b/README.md index 4613f89..907ae63 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - **NestJS 12** (`^12.0.0-alpha.5`, on the `next` npm tag) — this package is ESM-only, matching Nest 12's ESM-first direction - **Node >= 22.13** (raised from 22.12 by the optional `typeorm` peer, which declares `^20.19 || ^22.13 || >=24.11`) -- **better-auth >= 1.6 < 2** +- **better-auth >= 1.6.26 < 1.7.0-0** (the conformance suite runs against stock `1.6.26`) > **Nest 12 alpha peer-dependency note:** the current `12.0.0-alpha.*` packages still declare > `^11.0.0` peers on their own siblings, so plain `npm install` fails with `ERESOLVE`. @@ -34,7 +34,7 @@ ## Install ```bash -pnpm add @nestm/better-auth@alpha better-auth +pnpm add @nestm/better-auth@alpha better-auth@1.6.26 ``` ## Quick start @@ -103,18 +103,19 @@ BetterAuthModule.forRootAsync({ ### Module options -| Option | Mode | Description | -| ---------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth` | option | Pre-built `betterAuth()` instance (best type inference). | -| `options` | option | Raw `BetterAuthOptions`; the module calls `betterAuth()` itself and pre-seeds `hooks`/`databaseHooks`. | -| `basePath` | option | Override the mount path only (for edge cases like proxy rewrites) — better-auth's router still uses its own config, so to actually move the endpoints set better-auth's `basePath`/`baseURL`. Default mirrors better-auth: path inside `baseURL` → (`BETTER_AUTH_URL` when no `baseURL`) → `basePath` → `/api/auth`. | -| `cors` | option | `false` to disable, or `{ origin, credentials, methods, allowedHeaders, maxAge }`. Defaults to array `trustedOrigins`. | -| `routePolicy` | option | Functional HTTP policy. It runs after auth-route CORS/body recovery and before DI route policies, `middleware`, or better-auth. Return a Web `Response` to short-circuit. | -| `routePolicyBodyLimit` | option | Maximum bytes buffered from an untouched request stream for policy body inspection. Default `1_048_576` (1 MiB); oversized requests receive `413 PAYLOAD_TOO_LARGE`. | -| `middleware` | option | `(req, res, run) => …` wrapper around the auth handler — for MikroORM `RequestContext` / AsyncLocalStorage setups. | -| `interop.publicKeys` | option | Metadata keys from other guards that mean public. Their presence skips session lookup with the same handler-level authorization override as `@AllowAnonymous()`. | -| `isGlobal` | extra | Default `true`. | -| `disableGlobalGuard` | extra | Skip the automatic `APP_GUARD` registration. | +| Option | Mode | Description | +| ----------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth` | option | Pre-built `betterAuth()` instance (best type inference). | +| `options` | option | Raw `BetterAuthOptions`; the module calls `betterAuth()` itself and pre-seeds `hooks`/`databaseHooks`. | +| `basePath` | option | Override the mount path only (for edge cases like proxy rewrites) — better-auth's router still uses its own config, so to actually move the endpoints set better-auth's `basePath`/`baseURL`. Default mirrors better-auth: path inside `baseURL` → (`BETTER_AUTH_URL` when no `baseURL`) → `basePath` → `/api/auth`. | +| `cors` | option | `false` to disable, or `{ origin, credentials, methods, allowedHeaders, maxAge }`. Defaults to array `trustedOrigins`. | +| `routePolicy` | option | Functional HTTP policy. It runs after auth-route CORS/body recovery and before DI route policies, `middleware`, or better-auth. Return a Web `Response` to short-circuit. | +| `routePolicyBodyLimit` | option | Maximum bytes buffered from an untouched request stream for policy body inspection. Default `1_048_576` (1 MiB); oversized requests receive `413 PAYLOAD_TOO_LARGE`. | +| `middleware` | option | `(req, res, run) => …` wrapper around the auth handler — for MikroORM `RequestContext` / AsyncLocalStorage setups. | +| `interop.publicKeys` | option | Metadata keys from other guards that mean public. Their presence skips session lookup with the same handler-level authorization override as `@AllowAnonymous()`. | +| `organizationLifecycle` | option | Optional organization-scoped serialization boundary used by `BetterAuthOrganizationService` mutations. Without it the service still validates and normalizes stock Better Auth results, but does not serialize concurrent lifecycle changes. | +| `isGlobal` | extra | Default `true`. | +| `disableGlobalGuard` | extra | Skip the automatic `APP_GUARD` registration. | ## Guard & decorators @@ -362,6 +363,68 @@ This blocks `/list-sessions`, `/revoke-session`, `/revoke-other-sessions`, and `/revoke-sessions` at the Better Auth HTTP mount. Server-side calls made by `BetterAuthSessionService` remain available. +### Organization control plane + +`BetterAuthOrganizationService` is the application-facing lifecycle facade for the stock +Better Auth organization plugin. It lists, updates, removes, and leaves memberships; lists, +creates, resends by invitation id, and cancels organization invitations; and lists, previews, +accepts, or rejects the authenticated account's invitations. Returned members always include a +validated public user projection, and returned invitations are runtime-validated before crossing +the service boundary. In particular, `updateMemberRole()` re-reads the joined member because stock +Better Auth 1.6.26 returns a bare member at runtime despite its joined-user response type. + +Every lifecycle mutation passes through the optional `organizationLifecycle` coordinator. The +service by itself is a compatibility and normalization layer; without a coordinator it does not +serialize concurrent requests. For cross-process PostgreSQL serialization and database atomicity, +use the supplied TypeORM coordinator and give its exact `getManager` function to the Better Auth +adapter so both execute inside the same transaction and organization advisory lock: + +```ts +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; +import { + createTypeormBetterAuthOrganizationLifecycleCoordinator, + typeormAdapter, +} from "@nestm/better-auth/typeorm"; + +const organizationLifecycle = createTypeormBetterAuthOrganizationLifecycleCoordinator(dataSource); + +const auth = betterAuth({ + database: typeormAdapter(dataSource, { + transaction: true, + getManager: organizationLifecycle.getManager, + }), + plugins: [organization()], +}); + +BetterAuthModule.forRoot({ auth, organizationLifecycle }); +``` + +After the application's organization and account facade controllers are mounted, opt in to the +raw-route policy: + +```ts +BetterAuthModule.forFeature({ + routePolicies: [BetterAuthOrganizationControlPlaneRoutePolicy], +}); +``` + +That policy closes the corresponding raw organization/member/invitation HTTP paths, including +the reserved `/organization/resend-invitation` path. It does not affect server-side calls. +Calling `BetterAuthService`, the injected Better Auth instance, or `auth.api.*` directly bypasses +the lifecycle coordinator, so code that needs the guarantee must use +`BetterAuthOrganizationService`. Cross-process atomicity therefore requires all three pieces: the +PostgreSQL coordinator, the adapter wired to that same coordinator's `getManager`, and the opt-in +raw-route policy preventing clients from taking an uncoordinated HTTP path for those lifecycle +operations. + +The transaction covers database mutations only. Invitation email delivery, application/Better +Auth hook side effects, secondary storage, and client cookie caches cannot be committed or rolled +back atomically with PostgreSQL. After remove/leave, matching database or secondary-storage +session selectors are cleared best-effort after commit; a cleanup failure does not turn an already +committed membership mutation into an apparent failure, and an already-issued signed cookie cache +may remain stale until refreshed. + HTTP adapters and application request augmentations can extend `BetterAuthRequestState` instead of recreating Better Auth's plugin-aware `session` and `user` fields. Its resolved-session marker uses the global symbol registry so guards and decorators remain compatible across duplicate package diff --git a/package.json b/package.json index f9e7b34..05feea8 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/better-auth.module.ts b/src/better-auth.module.ts index ef560b4..39fec07 100644 --- a/src/better-auth.module.ts +++ b/src/better-auth.module.ts @@ -21,6 +21,7 @@ import { 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"; @@ -74,6 +75,7 @@ function assertRoutePolicyClass(candidate: Type): void { }, BetterAuthService, BetterAuthSessionService, + BetterAuthOrganizationService, BetterAuthGuard, BetterAuthHookRegistry, BetterAuthDatabaseHookRegistry, @@ -88,6 +90,7 @@ function assertRoutePolicyClass(candidate: Type): void { BETTER_AUTH_BASE_PATH, BetterAuthService, BetterAuthSessionService, + BetterAuthOrganizationService, BetterAuthGuard, BetterAuthHookRegistry, BetterAuthDatabaseHookRegistry, diff --git a/src/decorators/access-control.decorators.ts b/src/decorators/access-control.decorators.ts index a2d8f43..08a321c 100644 --- a/src/decorators/access-control.decorators.ts +++ b/src/decorators/access-control.decorators.ts @@ -46,7 +46,7 @@ export const Roles = Reflector.createDecorator({ key: METADATA_KEY.requireActiveOrg, transform: () => true, diff --git a/src/guards/better-auth.guard.ts b/src/guards/better-auth.guard.ts index 9c84a13..8808cea 100644 --- a/src/guards/better-auth.guard.ts +++ b/src/guards/better-auth.guard.ts @@ -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; @@ -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); @@ -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"); } } @@ -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; @@ -188,19 +204,25 @@ export class BetterAuthGuard implements CanActivate { this.logger.error(message); } - private async getActiveMemberRole(headers: Headers): Promise { + private async getActiveMemberRole( + headers: Headers, + organizationId: string, + ): Promise { const api = this.api(); try { if (typeof api.getActiveMemberRole === "function") { const result = (await (api.getActiveMemberRole as (input: unknown) => Promise)({ 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)({ 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( @@ -222,6 +244,7 @@ export class BetterAuthGuard implements CanActivate { headers: Headers, options: PermissionCheckOptions, endpoint: "userHasPermission" | "hasPermission", + organizationId?: string, ): Promise { const api = this.api(); const fn = api[endpoint]; @@ -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); // 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)(input)) as { success?: boolean; } | null; diff --git a/src/index.ts b/src/index.ts index e262284..c53f005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ 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 { BetterAuthOrganizationLifecycleCoordinator } from "./interfaces/better-auth-organization-lifecycle.interface.ts"; export { deny, type BetterAuthRoutePolicy, @@ -71,6 +72,17 @@ export { type BetterAuthSessionRevocationResult, type BetterAuthSessionSummary, } from "./services/better-auth-session.service.ts"; +export { + BetterAuthOrganizationService, + type BetterAuthOrganizationInvitation, + type BetterAuthOrganizationInvitationAcceptance, + type BetterAuthOrganizationInvitationPreview, + type BetterAuthOrganizationMember, + type BetterAuthOrganizationMemberList, + type BetterAuthOrganizationMemberListOptions, + type BetterAuthOrganizationRequestHeaders, + type BetterAuthReceivedOrganizationInvitation, +} from "./services/better-auth-organization.service.ts"; export { mapBetterAuthApiError, normalizeBetterAuthHeaders, @@ -82,6 +94,10 @@ export { BETTER_AUTH_SESSION_MANAGEMENT_PATHS, BetterAuthSessionManagementRoutePolicy, } from "./policies/session-management-route-policy.ts"; +export { + BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS, + BetterAuthOrganizationControlPlaneRoutePolicy, +} from "./policies/organization-control-plane-route-policy.ts"; export { createAuthError, type AuthErrorStatus } from "./guards/auth-errors.ts"; export { MUTATION_ORIGIN_GUARD_OPTIONS, diff --git a/src/interfaces/better-auth-module-options.interface.ts b/src/interfaces/better-auth-module-options.interface.ts index 01b78ae..5fc33e3 100644 --- a/src/interfaces/better-auth-module-options.interface.ts +++ b/src/interfaces/better-auth-module-options.interface.ts @@ -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 { BetterAuthOrganizationLifecycleCoordinator } from "./better-auth-organization-lifecycle.interface.ts"; /** * CORS configuration for the mounted better-auth routes. When omitted, @@ -54,6 +55,11 @@ interface BetterAuthModuleCommonOptions { /** Maximum bytes buffered from an untouched stream for route-policy body inspection. Default 1 MiB. */ routePolicyBodyLimit?: number; interop?: BetterAuthInteropOptions; + /** + * Optional serialization boundary for organization membership and invitation + * mutations made through `BetterAuthOrganizationService`. + */ + organizationLifecycle?: BetterAuthOrganizationLifecycleCoordinator; } /** diff --git a/src/interfaces/better-auth-organization-lifecycle.interface.ts b/src/interfaces/better-auth-organization-lifecycle.interface.ts new file mode 100644 index 0000000..15cfefa --- /dev/null +++ b/src/interfaces/better-auth-organization-lifecycle.interface.ts @@ -0,0 +1,12 @@ +/** + * Coordinates organization mutations that must observe one serialized view of + * membership and invitation state. + * + * Implementations must keep the callback inside the same transaction/context + * used by the configured Better Auth database adapter. The TypeORM subpath + * provides a PostgreSQL implementation backed by transaction-scoped advisory + * locks. + */ +export interface BetterAuthOrganizationLifecycleCoordinator { + run(organizationId: string, operation: () => Promise): Promise; +} diff --git a/src/policies/organization-control-plane-route-policy.ts b/src/policies/organization-control-plane-route-policy.ts new file mode 100644 index 0000000..fed4167 --- /dev/null +++ b/src/policies/organization-control-plane-route-policy.ts @@ -0,0 +1,42 @@ +import { HttpStatus, Injectable } from "@nestjs/common"; +import { AuthRoutePolicy } from "../decorators/route-policy.decorator.ts"; +import { deny, type BetterAuthRoutePolicyHandler } from "./route-policy.ts"; + +/** + * Raw Better Auth organization routes superseded by an application control + * plane backed by `BetterAuthOrganizationService`. + */ +export const BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS = [ + "/organization/update", + "/organization/get-full-organization", + "/organization/has-permission", + "/organization/invite-member", + "/organization/resend-invitation", + "/organization/cancel-invitation", + "/organization/list-invitations", + "/organization/list-members", + "/organization/remove-member", + "/organization/update-member-role", + "/organization/leave", + "/organization/accept-invitation", + "/organization/reject-invitation", + "/organization/get-invitation", + "/organization/list-user-invitations", +] as const; + +/** + * Opt-in policy that closes Better Auth's raw organization control-plane + * routes. Register it with `BetterAuthModule.forFeature({ routePolicies: [...] })` + * after exposing the corresponding application facade endpoints. + */ +@AuthRoutePolicy({ path: BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS, order: -100 }) +@Injectable() +export class BetterAuthOrganizationControlPlaneRoutePolicy implements BetterAuthRoutePolicyHandler { + evaluate() { + return deny(HttpStatus.FORBIDDEN, { + statusCode: HttpStatus.FORBIDDEN, + code: "ORGANIZATION_CONTROL_PLANE_FACADE_REQUIRED", + message: "Use the application's organization control-plane endpoints.", + }); + } +} diff --git a/src/services/better-auth-organization.service.ts b/src/services/better-auth-organization.service.ts new file mode 100644 index 0000000..49b63be --- /dev/null +++ b/src/services/better-auth-organization.service.ts @@ -0,0 +1,790 @@ +import { HttpException, HttpStatus, Inject, Injectable, Logger } from "@nestjs/common"; +import { isAPIError } from "better-auth/api"; +import type { IncomingHttpHeaders } from "node:http"; +import { BETTER_AUTH_MODULE_OPTIONS } from "../better-auth.tokens.ts"; +import type { BetterAuthModuleOptions } from "../interfaces/better-auth-module-options.interface.ts"; +import type { AnyAuth, RegisteredAuth } from "../types/auth.types.ts"; +import type { BetterAuthApiHeaders } from "./better-auth-api-invocation.ts"; +import { BetterAuthService } from "./better-auth.service.ts"; + +const ORGANIZATION_API_METHODS = [ + "getSession", + "listMembers", + "updateMemberRole", + "removeMember", + "leaveOrganization", + "listInvitations", + "createInvitation", + "cancelInvitation", + "listUserInvitations", + "getInvitation", + "acceptInvitation", + "rejectInvitation", +] as const; + +type OrganizationApiMethod = (typeof ORGANIZATION_API_METHODS)[number]; + +interface OrganizationApi { + call(method: OrganizationApiMethod, input: unknown): Promise; +} + +interface InternalSessionAdapter { + listSessions(userId: string): Promise; + updateSession(token: string, update: Record): Promise; +} + +type OrganizationApiOperation = (input: unknown) => Promise; + +/** Public, normalized organization member returned by the lifecycle facade. */ +export interface BetterAuthOrganizationMember { + readonly id: string; + readonly userId: string; + readonly organizationId: string; + readonly role: string; + readonly createdAt: Date; + readonly user: { + readonly id: string; + readonly name: string; + readonly email: string; + readonly image: string | null; + }; +} + +/** Pagination and sorting accepted by {@link BetterAuthOrganizationService.listMembers}. */ +export interface BetterAuthOrganizationMemberListOptions { + readonly limit?: number | undefined; + readonly offset?: number | undefined; + readonly sortBy?: string | undefined; + readonly sortDirection?: "asc" | "desc" | undefined; +} + +export interface BetterAuthOrganizationMemberList { + readonly members: readonly BetterAuthOrganizationMember[]; + readonly total: number; +} + +/** Public, normalized Better Auth organization invitation. */ +export interface BetterAuthOrganizationInvitation { + readonly id: string; + readonly email: string; + readonly role: string; + readonly organizationId: string; + readonly inviterId: string; + readonly status: "pending" | "accepted" | "rejected" | "canceled"; + readonly expiresAt: Date; + readonly createdAt: Date; +} + +export interface BetterAuthReceivedOrganizationInvitation extends BetterAuthOrganizationInvitation { + readonly organizationName: string; +} + +export interface BetterAuthOrganizationInvitationPreview extends BetterAuthOrganizationInvitation { + readonly organizationName: string; + readonly organizationSlug: string; + readonly inviterEmail: string; +} + +export interface BetterAuthOrganizationInvitationAcceptance { + readonly invitation: BetterAuthOrganizationInvitation; + readonly member: BetterAuthOrganizationMember; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isOrganizationApiOperation(value: unknown): value is OrganizationApiOperation { + return typeof value === "function"; +} + +function isInternalSessionAdapter(value: unknown): value is InternalSessionAdapter { + return ( + isRecord(value) && + typeof value.listSessions === "function" && + typeof value.updateSession === "function" + ); +} + +function requiredRecord(value: unknown, label: string): Record { + if (!isRecord(value)) throw invalidResponse(`Better Auth returned an invalid ${label}.`); + return value; +} + +function requiredString(record: Record, field: string, label: string): string { + const value = record[field]; + if (typeof value !== "string" || value.length === 0) { + throw invalidResponse(`Better Auth returned an invalid ${label}.${field}.`); + } + return value; +} + +function optionalString( + record: Record, + field: string, + label: string, +): string | null { + const value = record[field]; + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + throw invalidResponse(`Better Auth returned an invalid ${label}.${field}.`); + } + return value; +} + +function requiredDate(record: Record, field: string, label: string): Date { + const value = record[field]; + const date = + value instanceof Date + ? new Date(value.getTime()) + : typeof value === "string" || typeof value === "number" + ? new Date(value) + : undefined; + if (!date || Number.isNaN(date.getTime())) { + throw invalidResponse(`Better Auth returned an invalid ${label}.${field}.`); + } + return date; +} + +function requiredNonNegativeInteger( + record: Record, + field: string, + label: string, +): number { + const value = record[field]; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw invalidResponse(`Better Auth returned an invalid ${label}.${field}.`); + } + return value; +} + +function requiredArray(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) throw invalidResponse(`Better Auth returned an invalid ${label}.`); + return value; +} + +function basicMember(value: unknown): Omit { + const member = requiredRecord(value, "organization member"); + return { + id: requiredString(member, "id", "organization member"), + userId: requiredString(member, "userId", "organization member"), + organizationId: requiredString(member, "organizationId", "organization member"), + role: requiredString(member, "role", "organization member"), + createdAt: requiredDate(member, "createdAt", "organization member"), + }; +} + +function publicMember(value: unknown): BetterAuthOrganizationMember { + const member = requiredRecord(value, "organization member"); + const normalized = basicMember(member); + const user = requiredRecord(member.user, "organization member.user"); + return { + ...normalized, + user: { + id: requiredString(user, "id", "organization member.user"), + name: requiredString(user, "name", "organization member.user"), + email: requiredString(user, "email", "organization member.user"), + image: optionalString(user, "image", "organization member.user"), + }, + }; +} + +function invitationStatus(value: unknown): BetterAuthOrganizationInvitation["status"] { + switch (value) { + case "pending": + case "accepted": + case "rejected": + case "canceled": + return value; + } + throw invalidResponse("Better Auth returned an invalid organization invitation.status."); +} + +function publicInvitation(value: unknown): BetterAuthOrganizationInvitation { + const invitation = requiredRecord(value, "organization invitation"); + return { + id: requiredString(invitation, "id", "organization invitation"), + email: requiredString(invitation, "email", "organization invitation"), + role: requiredString(invitation, "role", "organization invitation"), + organizationId: requiredString(invitation, "organizationId", "organization invitation"), + inviterId: requiredString(invitation, "inviterId", "organization invitation"), + status: invitationStatus(invitation.status), + expiresAt: requiredDate(invitation, "expiresAt", "organization invitation"), + createdAt: requiredDate(invitation, "createdAt", "organization invitation"), + }; +} + +function receivedInvitation(value: unknown): BetterAuthReceivedOrganizationInvitation { + const invitation = requiredRecord(value, "received organization invitation"); + return { + ...publicInvitation(invitation), + organizationName: requiredString( + invitation, + "organizationName", + "received organization invitation", + ), + }; +} + +function invitationPreview(value: unknown): BetterAuthOrganizationInvitationPreview { + const invitation = requiredRecord(value, "organization invitation preview"); + return { + ...publicInvitation(invitation), + organizationName: requiredString( + invitation, + "organizationName", + "organization invitation preview", + ), + organizationSlug: requiredString( + invitation, + "organizationSlug", + "organization invitation preview", + ), + inviterEmail: requiredString(invitation, "inviterEmail", "organization invitation preview"), + }; +} + +function organizationApi(value: unknown): OrganizationApi { + const record = requiredRecord(value, "organization server API"); + for (const method of ORGANIZATION_API_METHODS) { + if (typeof record[method] !== "function") { + throw new TypeError(`The Better Auth organization API does not provide '${method}'.`); + } + } + return { + call: async (method, input) => { + const operation = record[method]; + if (!isOrganizationApiOperation(operation)) { + throw new TypeError(`The Better Auth organization API does not provide '${method}'.`); + } + return operation(input); + }, + }; +} + +function invalidResponse(message: string): HttpException { + return new HttpException( + { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, code: "INVALID_BETTER_AUTH_RESPONSE", message }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); +} + +function invitationNotFound(): HttpException { + return new HttpException( + { + statusCode: HttpStatus.NOT_FOUND, + code: "INVITATION_NOT_FOUND", + message: "Invitation not found.", + }, + HttpStatus.NOT_FOUND, + ); +} + +function accountInvitationNotFound(): HttpException { + return new HttpException( + { + statusCode: HttpStatus.BAD_REQUEST, + code: "INVITATION_NOT_FOUND", + message: "Invitation not found.", + }, + HttpStatus.BAD_REQUEST, + ); +} + +function isStockInvitationNotFound(error: unknown): boolean { + if (!isAPIError(error) || error.statusCode !== 400) return false; + const body: unknown = error.body; + const code = isRecord(body) ? body.code : undefined; + if (code === "INVITATION_NOT_FOUND") return true; + const bodyMessage = isRecord(body) ? body.message : undefined; + const message = typeof bodyMessage === "string" ? bodyMessage : error.message; + return ( + message + .trim() + .toLowerCase() + .replace(/[.!]+$/, "") === "invitation not found" + ); +} + +function memberNotFound(): HttpException { + return new HttpException( + { statusCode: HttpStatus.NOT_FOUND, code: "MEMBER_NOT_FOUND", message: "Member not found." }, + HttpStatus.NOT_FOUND, + ); +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +/** + * Reusable Nest-facing organization control plane over Better Auth's stock + * organization plugin. Every mutation is coordinated by organization, while + * all public results are runtime-validated and normalized. + */ +@Injectable() +export class BetterAuthOrganizationService { + private readonly logger = new Logger(BetterAuthOrganizationService.name); + + constructor( + private readonly betterAuth: BetterAuthService, + @Inject(BETTER_AUTH_MODULE_OPTIONS) + private readonly moduleOptions: BetterAuthModuleOptions, + ) {} + + async listMembers( + headers: BetterAuthApiHeaders, + organizationId: string, + options: BetterAuthOrganizationMemberListOptions = {}, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const result = requiredRecord( + await organizationApi(untypedApi).call("listMembers", { + headers: normalizedHeaders, + query: { organizationId, ...options }, + }), + "organization member list", + ); + return { + members: requiredArray(result.members, "organization member list.members").map( + publicMember, + ), + total: requiredNonNegativeInteger(result, "total", "organization member list"), + }; + }); + } + + async updateMemberRole( + headers: BetterAuthApiHeaders, + organizationId: string, + memberId: string, + role: string | readonly string[], + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = organizationApi(untypedApi); + return this.runMutation(organizationId, async () => { + await api.call("updateMemberRole", { + headers: normalizedHeaders, + body: { organizationId, memberId, role: Array.isArray(role) ? [...role] : role }, + }); + return this.readJoinedMember(api, normalizedHeaders, organizationId, memberId); + }); + }); + } + + async removeMember( + headers: BetterAuthApiHeaders, + organizationId: string, + memberId: string, + ): Promise { + const member = await this.betterAuth.invokeApi( + headers, + async (untypedApi: unknown, normalizedHeaders) => { + const api = organizationApi(untypedApi); + return this.runMutation(organizationId, async () => { + const existing = await this.readJoinedMember( + api, + normalizedHeaders, + organizationId, + memberId, + ); + await api.call("removeMember", { + headers: normalizedHeaders, + body: { organizationId, memberIdOrEmail: memberId }, + }); + return existing; + }); + }, + ); + await this.clearOrganizationSelections(member.userId, organizationId); + return member; + } + + async leave( + headers: BetterAuthApiHeaders, + organizationId: string, + ): Promise { + const member = await this.betterAuth.invokeApi( + headers, + async (untypedApi: unknown, normalizedHeaders) => { + const api = organizationApi(untypedApi); + return this.runMutation(organizationId, async () => { + const session = requiredRecord( + await api.call("getSession", { + headers: normalizedHeaders, + query: { disableCookieCache: true, disableRefresh: true }, + }), + "session", + ); + const user = requiredRecord(session.user, "session.user"); + const userId = requiredString(user, "id", "session.user"); + const existing = await this.readJoinedMemberByUserId( + api, + normalizedHeaders, + organizationId, + userId, + ); + await api.call("leaveOrganization", { + headers: normalizedHeaders, + body: { organizationId }, + }); + return existing; + }); + }, + ); + await this.clearOrganizationSelections(member.userId, organizationId); + return member; + } + + async listInvitations( + headers: BetterAuthApiHeaders, + organizationId: string, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => + this.readOrganizationInvitations( + organizationApi(untypedApi), + normalizedHeaders, + organizationId, + ), + ); + } + + async invite( + headers: BetterAuthApiHeaders, + organizationId: string, + email: string, + role: string | readonly string[], + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = organizationApi(untypedApi); + return this.runMutation(organizationId, async () => { + const normalizedEmail = normalizeEmail(email); + const now = Date.now(); + const expiredPendingInvitations = ( + await this.readOrganizationInvitations(api, normalizedHeaders, organizationId) + ).filter( + (candidate) => + candidate.status === "pending" && + candidate.expiresAt.getTime() <= now && + normalizeEmail(candidate.email) === normalizedEmail, + ); + for (const invitation of expiredPendingInvitations) { + await this.cancelPendingInvitation(api, normalizedHeaders, invitation); + } + return publicInvitation( + await api.call("createInvitation", { + headers: normalizedHeaders, + body: { + email: normalizedEmail, + organizationId, + role: Array.isArray(role) ? [...role] : role, + }, + }), + ); + }); + }); + } + + async resendInvitation( + headers: BetterAuthApiHeaders, + organizationId: string, + invitationId: string, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = organizationApi(untypedApi); + return this.runMutation(organizationId, async () => { + const invitations = await this.readOrganizationInvitations( + api, + normalizedHeaders, + organizationId, + ); + const invitation = invitations.find((candidate) => candidate.id === invitationId); + if (!invitation || invitation.status !== "pending") throw invitationNotFound(); + + const body = { + email: invitation.email, + organizationId, + role: invitation.role, + }; + const now = Date.now(); + if (invitation.expiresAt.getTime() <= now) { + await this.cancelPendingInvitation(api, normalizedHeaders, invitation); + return publicInvitation( + await api.call("createInvitation", { headers: normalizedHeaders, body }), + ); + } + + const matchingLiveInvitations = invitations.filter( + (candidate) => + candidate.status === "pending" && + candidate.expiresAt.getTime() > now && + normalizeEmail(candidate.email) === normalizeEmail(invitation.email), + ); + if (matchingLiveInvitations.length !== 1) { + throw invalidResponse( + "Better Auth returned ambiguous pending invitations for the requested recipient.", + ); + } + const resent = publicInvitation( + await api.call("createInvitation", { + headers: normalizedHeaders, + body: { ...body, resend: true }, + }), + ); + if (resent.id !== invitationId) { + throw invalidResponse("Better Auth resent a different invitation than requested."); + } + return resent; + }); + }); + } + + async cancelInvitation( + headers: BetterAuthApiHeaders, + organizationId: string, + invitationId: string, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = organizationApi(untypedApi); + return this.runMutation(organizationId, async () => { + const invitation = ( + await this.readOrganizationInvitations(api, normalizedHeaders, organizationId) + ).find((candidate) => candidate.id === invitationId); + if (!invitation || invitation.status !== "pending") throw invitationNotFound(); + return this.cancelPendingInvitation(api, normalizedHeaders, invitation); + }); + }); + } + + async listUserInvitations( + headers: BetterAuthApiHeaders, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => + requiredArray( + await organizationApi(untypedApi).call("listUserInvitations", { + headers: normalizedHeaders, + }), + "received organization invitation list", + ).map(receivedInvitation), + ); + } + + async getInvitation( + headers: BetterAuthApiHeaders, + invitationId: string, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => + invitationPreview( + await organizationApi(untypedApi).call("getInvitation", { + headers: normalizedHeaders, + query: { id: invitationId }, + }), + ), + ); + } + + async acceptInvitation( + headers: BetterAuthApiHeaders, + invitationId: string, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = organizationApi(untypedApi); + const preview = await this.readMutationInvitationPreview( + api, + normalizedHeaders, + invitationId, + ); + return this.runMutation(preview.organizationId, async () => { + const lockedPreview = await this.readMutationInvitationPreview( + api, + normalizedHeaders, + invitationId, + ); + if (lockedPreview.organizationId !== preview.organizationId) { + throw accountInvitationNotFound(); + } + const accepted = requiredRecord( + await api.call("acceptInvitation", { + headers: normalizedHeaders, + body: { invitationId }, + }), + "organization invitation acceptance", + ); + const invitation = publicInvitation(accepted.invitation); + const member = basicMember(accepted.member); + return { + invitation, + member: await this.readJoinedMember( + api, + normalizedHeaders, + invitation.organizationId, + member.id, + ), + }; + }); + }); + } + + async rejectInvitation( + headers: BetterAuthApiHeaders, + invitationId: string, + ): Promise { + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = organizationApi(untypedApi); + const preview = await this.readMutationInvitationPreview( + api, + normalizedHeaders, + invitationId, + ); + return this.runMutation(preview.organizationId, async () => { + const lockedPreview = await this.readMutationInvitationPreview( + api, + normalizedHeaders, + invitationId, + ); + if (lockedPreview.organizationId !== preview.organizationId) { + throw accountInvitationNotFound(); + } + const rejected = requiredRecord( + await api.call("rejectInvitation", { + headers: normalizedHeaders, + body: { invitationId }, + }), + "organization invitation rejection", + ); + return publicInvitation(rejected.invitation); + }); + }); + } + + private runMutation(organizationId: string, operation: () => Promise): Promise { + return this.moduleOptions.organizationLifecycle?.run(organizationId, operation) ?? operation(); + } + + private async readOrganizationInvitations( + api: OrganizationApi, + headers: Headers, + organizationId: string, + ): Promise { + return requiredArray( + await api.call("listInvitations", { headers, query: { organizationId } }), + "organization invitation list", + ).map(publicInvitation); + } + + private async readMutationInvitationPreview( + api: OrganizationApi, + headers: Headers, + invitationId: string, + ): Promise { + try { + return invitationPreview( + await api.call("getInvitation", { + headers, + query: { id: invitationId }, + }), + ); + } catch (error: unknown) { + if (isStockInvitationNotFound(error)) throw accountInvitationNotFound(); + throw error; + } + } + + private async cancelPendingInvitation( + api: OrganizationApi, + headers: Headers, + invitation: BetterAuthOrganizationInvitation, + ): Promise { + const canceled = publicInvitation( + await api.call("cancelInvitation", { + headers, + body: { invitationId: invitation.id }, + }), + ); + if ( + canceled.id !== invitation.id || + canceled.organizationId !== invitation.organizationId || + canceled.status !== "canceled" + ) { + throw invalidResponse("Better Auth returned an invalid canceled invitation."); + } + return canceled; + } + + private async readJoinedMember( + api: OrganizationApi, + headers: Headers, + organizationId: string, + memberId: string, + ): Promise { + return this.readSingleJoinedMember(api, headers, organizationId, "id", memberId); + } + + private async readJoinedMemberByUserId( + api: OrganizationApi, + headers: Headers, + organizationId: string, + userId: string, + ): Promise { + return this.readSingleJoinedMember(api, headers, organizationId, "userId", userId); + } + + private async readSingleJoinedMember( + api: OrganizationApi, + headers: Headers, + organizationId: string, + filterField: "id" | "userId", + filterValue: string, + ): Promise { + const result = requiredRecord( + await api.call("listMembers", { + headers, + query: { organizationId, limit: 1, offset: 0, filterField, filterValue }, + }), + "organization member list", + ); + const members = requiredArray(result.members, "organization member list.members"); + if (members.length !== 1) throw memberNotFound(); + return publicMember(members[0]); + } + + private async clearOrganizationSelections(userId: string, organizationId: string): Promise { + try { + const adapter = await this.internalSessionAdapter(); + const sessions = requiredArray(await adapter.listSessions(userId), "internal session list"); + const matchingTokens = sessions.flatMap((value) => { + if (!isRecord(value) || value.activeOrganizationId !== organizationId) return []; + const token = value.token; + return typeof token === "string" && token.length > 0 ? [token] : []; + }); + const settled = await Promise.allSettled( + matchingTokens.map((token) => adapter.updateSession(token, { activeOrganizationId: null })), + ); + const failures = settled.filter((result) => result.status === "rejected").length; + if (failures > 0) { + this.logger.warn( + `Organization membership committed, but ${failures} session selection(s) could not be cleared.`, + ); + } + } catch (error: unknown) { + this.logger.warn( + `Organization membership committed, but session selections could not be reconciled: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + private async internalSessionAdapter(): Promise { + const context: unknown = await this.betterAuth.context(); + const contextRecord = requiredRecord(context, "auth context"); + const adapter: unknown = contextRecord.internalAdapter; + if (!isInternalSessionAdapter(adapter)) { + throw new TypeError("The Better Auth internal session adapter is unavailable."); + } + return adapter; + } +} + +/** Node/Nest header alias retained for discoverable controller signatures. */ +export type BetterAuthOrganizationRequestHeaders = Headers | IncomingHttpHeaders; diff --git a/src/typeorm/index.ts b/src/typeorm/index.ts index eec0d30..8619824 100644 --- a/src/typeorm/index.ts +++ b/src/typeorm/index.ts @@ -1,4 +1,8 @@ export { typeormAdapter } from "./adapter.ts"; +export { + createTypeormBetterAuthOrganizationLifecycleCoordinator, + type TypeormBetterAuthOrganizationLifecycleCoordinator, +} from "./organization-lifecycle.ts"; export type { TypeormAdapterConfig, TypeormColumnMetadata, diff --git a/src/typeorm/organization-lifecycle.ts b/src/typeorm/organization-lifecycle.ts new file mode 100644 index 0000000..e47502c --- /dev/null +++ b/src/typeorm/organization-lifecycle.ts @@ -0,0 +1,130 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { BetterAuthError } from "better-auth"; + +import type { BetterAuthOrganizationLifecycleCoordinator } from "../interfaces/better-auth-organization-lifecycle.interface.ts"; +import { executeQuery, requireEntityManager } from "./capabilities.ts"; +import { resolveDialect } from "./dialect.ts"; +import type { TypeormDataSource, TypeormEntityManager } from "./types.ts"; + +const ADVISORY_LOCK_SQL = "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))"; + +const ADVISORY_LOCK_DRIVER_TYPES: ReadonlySet = new Set(["postgres", "aurora-postgres"]); + +interface OrganizationLifecycleState { + active: boolean; + readonly manager: TypeormEntityManager; + readonly locks: Map>; +} + +/** + * A TypeORM organization-lifecycle coordinator whose manager can also be used + * by {@link typeormAdapter} to join the same transaction. + */ +export interface TypeormBetterAuthOrganizationLifecycleCoordinator extends BetterAuthOrganizationLifecycleCoordinator { + /** The active lifecycle transaction manager, or `undefined` outside `run`. */ + readonly getManager: () => TypeormEntityManager | undefined; +} + +function invalidCoordinatorInput(message: string): BetterAuthError { + return new BetterAuthError(`[TypeORM Organization Lifecycle] ${message}`); +} + +function requireOrganizationId(organizationId: string): string { + if (typeof organizationId !== "string" || organizationId.trim().length === 0) { + throw invalidCoordinatorInput("organizationId must be a non-empty string."); + } + return organizationId; +} + +function requireOperation(operation: () => Promise): () => Promise { + if (typeof operation !== "function") { + throw invalidCoordinatorInput("operation must be a function."); + } + return operation; +} + +/** + * Creates a PostgreSQL transaction and advisory-lock boundary for Better Auth + * organization lifecycle mutations. + * + * Pass `coordinator.getManager` to {@link typeormAdapter} with + * `transaction: true`. Better Auth will then join the transaction opened by + * {@link TypeormBetterAuthOrganizationLifecycleCoordinator.run} instead of + * opening a second transaction on another pooled connection. + */ +export function createTypeormBetterAuthOrganizationLifecycleCoordinator( + dataSource: TypeormDataSource, +): TypeormBetterAuthOrganizationLifecycleCoordinator { + if ( + typeof dataSource !== "object" || + dataSource === null || + typeof dataSource.transaction !== "function" + ) { + throw invalidCoordinatorInput("A DataSource with transaction support is required."); + } + + const { driverType } = resolveDialect(dataSource); + if (!ADVISORY_LOCK_DRIVER_TYPES.has(driverType)) { + throw invalidCoordinatorInput( + `Driver "${driverType}" does not provide the PostgreSQL transaction-scoped advisory locks required by this coordinator.`, + ); + } + + const storage = new AsyncLocalStorage(); + + const getManager = (): TypeormEntityManager | undefined => { + const state = storage.getStore(); + return state?.active ? state.manager : undefined; + }; + + const acquireLock = async ( + state: OrganizationLifecycleState, + organizationId: string, + ): Promise => { + const existing = state.locks.get(organizationId); + if (existing) return existing; + + const pending = executeQuery(state.manager, ADVISORY_LOCK_SQL, [organizationId]).then( + () => undefined, + ); + state.locks.set(organizationId, pending); + return pending; + }; + + const run = async ( + organizationIdInput: string, + operationInput: () => Promise, + ): Promise => { + const organizationId = requireOrganizationId(organizationIdInput); + const operation = requireOperation(operationInput); + const current = storage.getStore(); + + if (current?.active) { + await acquireLock(current, organizationId); + return await operation(); + } + + const transaction = dataSource.transaction; + return await Reflect.apply(transaction, dataSource, [ + async (managerInput: unknown) => { + const state: OrganizationLifecycleState = { + active: true, + manager: requireEntityManager(managerInput), + locks: new Map(), + }; + + return await storage.run(state, async () => { + try { + await acquireLock(state, organizationId); + return await operation(); + } finally { + state.active = false; + } + }); + }, + ]); + }; + + return { getManager, run }; +} diff --git a/tests/e2e/organization-roles.e2e.test.ts b/tests/e2e/organization-roles.e2e.test.ts index 21fd321..09d5743 100644 --- a/tests/e2e/organization-roles.e2e.test.ts +++ b/tests/e2e/organization-roles.e2e.test.ts @@ -86,6 +86,41 @@ describe(`organization roles (${testHttpAdapter})`, () => { expect(response.status).toBe(200); }); + it("@RequireActiveOrg rejects a stale organization selector after remote member removal", async () => { + const owner = await signUpUser(app); + const organizationId = await createActiveOrganization(app, owner, "revoked"); + const member = await signUpUser(app); + const invited = await request(app.getHttpServer()) + .post("/api/auth/organization/invite-member") + .set(bearer(owner.token)) + .send({ email: member.email, organizationId, role: "member" }); + expect(invited.status).toBe(200); + + const accepted = await request(app.getHttpServer()) + .post("/api/auth/organization/accept-invitation") + .set(bearer(member.token)) + .send({ invitationId: invited.body.id }); + expect(accepted.status).toBe(200); + const memberId: string = accepted.body.member.id; + + const beforeRemoval = await request(app.getHttpServer()) + .get("/org/requires-active") + .set(bearer(member.token)); + expect(beforeRemoval.status).toBe(200); + + const removed = await request(app.getHttpServer()) + .post("/api/auth/organization/remove-member") + .set(bearer(owner.token)) + .send({ memberIdOrEmail: memberId, organizationId }); + expect(removed.status).toBe(200); + + const afterRemoval = await request(app.getHttpServer()) + .get("/org/requires-active") + .set(bearer(member.token)); + expect(afterRemoval.status).toBe(403); + expect(afterRemoval.body.message).toBe("Active organization membership is required"); + }); + it("@OrgRoles allows the organization owner", async () => { const user = await signUpUser(app); await createActiveOrganization(app, user, "owner"); diff --git a/tests/e2e/organization-service.e2e.test.ts b/tests/e2e/organization-service.e2e.test.ts new file mode 100644 index 0000000..d236d58 --- /dev/null +++ b/tests/e2e/organization-service.e2e.test.ts @@ -0,0 +1,350 @@ +import { + Body, + Controller, + Delete, + Get, + Headers as RequestHeaders, + HttpCode, + HttpStatus, + Param, + Patch, + Post, +} from "@nestjs/common"; +import { betterAuth } from "better-auth"; +import { bearer as bearerPlugin, organization } from "better-auth/plugins"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { INestApplication } from "@nestjs/common"; +import { + BetterAuthModule, + BetterAuthOrganizationControlPlaneRoutePolicy, + BetterAuthOrganizationService, +} from "../../src/index.ts"; +import { bearer, signUpUser, type SignedUpUser } from "../shared/auth-client.ts"; +import { createTestApp } from "../shared/test-app.ts"; +import { TEST_BASE_URL, TEST_SECRET } from "../shared/test-auth.ts"; +import { testHttpAdapter } from "../shared/http-adapter.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +const auth = betterAuth({ + baseURL: TEST_BASE_URL, + secret: TEST_SECRET, + emailAndPassword: { enabled: true }, + telemetry: { enabled: false }, + plugins: [bearerPlugin(), organization({ requireEmailVerificationOnInvitation: false })], +}); + +interface InvitationBody { + readonly email: string; + readonly role: string | readonly string[]; +} + +interface MemberRoleBody { + readonly role: string | readonly string[]; +} + +@Controller() +class OrganizationFacadeController { + constructor(private readonly organizations: BetterAuthOrganizationService) {} + + @Get("organizations/:organizationId/members") + listMembers( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("organizationId") organizationId: string, + ) { + return this.organizations.listMembers(headers, organizationId); + } + + @Patch("organizations/:organizationId/members/:memberId/role") + updateMemberRole( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("organizationId") organizationId: string, + @Param("memberId") memberId: string, + @Body() body: MemberRoleBody, + ) { + return this.organizations.updateMemberRole(headers, organizationId, memberId, body.role); + } + + @Delete("organizations/:organizationId/members/:memberId") + removeMember( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("organizationId") organizationId: string, + @Param("memberId") memberId: string, + ) { + return this.organizations.removeMember(headers, organizationId, memberId); + } + + @Post("organizations/:organizationId/invitations") + @HttpCode(HttpStatus.OK) + invite( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("organizationId") organizationId: string, + @Body() body: InvitationBody, + ) { + return this.organizations.invite(headers, organizationId, body.email, body.role); + } + + @Get("organizations/:organizationId/invitations") + listInvitations( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("organizationId") organizationId: string, + ) { + return this.organizations.listInvitations(headers, organizationId); + } + + @Post("organizations/:organizationId/invitations/:invitationId/resend") + @HttpCode(HttpStatus.OK) + resendInvitation( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("organizationId") organizationId: string, + @Param("invitationId") invitationId: string, + ) { + return this.organizations.resendInvitation(headers, organizationId, invitationId); + } + + @Post("organizations/:organizationId/invitations/:invitationId/cancel") + @HttpCode(HttpStatus.OK) + cancelInvitation( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("organizationId") organizationId: string, + @Param("invitationId") invitationId: string, + ) { + return this.organizations.cancelInvitation(headers, organizationId, invitationId); + } + + @Get("account/organization-invitations") + listUserInvitations(@RequestHeaders() headers: IncomingHttpHeaders) { + return this.organizations.listUserInvitations(headers); + } + + @Get("account/organization-invitations/:invitationId") + getInvitation( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("invitationId") invitationId: string, + ) { + return this.organizations.getInvitation(headers, invitationId); + } + + @Post("account/organization-invitations/:invitationId/accept") + @HttpCode(HttpStatus.OK) + acceptInvitation( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("invitationId") invitationId: string, + ) { + return this.organizations.acceptInvitation(headers, invitationId); + } + + @Post("account/organization-invitations/:invitationId/reject") + @HttpCode(HttpStatus.OK) + rejectInvitation( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("invitationId") invitationId: string, + ) { + return this.organizations.rejectInvitation(headers, invitationId); + } +} + +async function createOrganization( + app: INestApplication, + owner: SignedUpUser, + suffix: string, +): Promise { + const response = await request(app.getHttpServer()) + .post("/api/auth/organization/create") + .set(bearer(owner.token)) + .send({ name: `Organization ${suffix}`, slug: `organization-${suffix}-${owner.userId}` }); + if (response.status !== 200 || typeof response.body?.id !== "string") { + throw new Error( + `organization/create failed: ${response.status} ${JSON.stringify(response.body)}`, + ); + } + return response.body.id; +} + +async function verifyEmail(user: SignedUpUser): Promise { + const context = await auth.$context; + await context.internalAdapter.updateUser(user.userId, { emailVerified: true }); +} + +async function expireInvitation(invitationId: string): Promise { + const context = await auth.$context; + await context.adapter.update({ + model: "invitation", + where: [{ field: "id", value: invitationId }], + update: { expiresAt: new Date(0) }, + }); +} + +async function inviteThroughFacade( + app: INestApplication, + owner: SignedUpUser, + organizationId: string, + invitee: SignedUpUser, +) { + return request(app.getHttpServer()) + .post(`/organizations/${organizationId}/invitations`) + .set(bearer(owner.token)) + .send({ email: invitee.email, role: "member" }); +} + +describe(`BetterAuthOrganizationService (${testHttpAdapter})`, () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp({ + forRoot: { auth }, + metadata: { + controllers: [OrganizationFacadeController], + imports: [ + BetterAuthModule.forFeature({ + routePolicies: [BetterAuthOrganizationControlPlaneRoutePolicy], + }), + ], + }, + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("hydrates role updates and reconciles every active session after removal", async () => { + const owner = await signUpUser(app); + const invitee = await signUpUser(app); + await verifyEmail(invitee); + const organizationId = await createOrganization(app, owner, "member-lifecycle"); + + const invited = await inviteThroughFacade(app, owner, organizationId, invitee); + expect(invited.status).toBe(200); + + const resent = await request(app.getHttpServer()) + .post(`/organizations/${organizationId}/invitations/${invited.body.id}/resend`) + .set(bearer(owner.token)); + expect(resent.status).toBe(200); + expect(resent.body.id).toBe(invited.body.id); + + const accepted = await request(app.getHttpServer()) + .post(`/account/organization-invitations/${invited.body.id}/accept`) + .set(bearer(invitee.token)); + expect(accepted.status).toBe(200); + expect(accepted.body.member.user).toMatchObject({ + id: invitee.userId, + email: invitee.email, + }); + + const updated = await request(app.getHttpServer()) + .patch(`/organizations/${organizationId}/members/${accepted.body.member.id}/role`) + .set(bearer(owner.token)) + .send({ role: "admin" }); + expect(updated.status).toBe(200); + expect(updated.body).toMatchObject({ + id: accepted.body.member.id, + role: "admin", + user: { id: invitee.userId, email: invitee.email }, + }); + + const removed = await request(app.getHttpServer()) + .delete(`/organizations/${organizationId}/members/${accepted.body.member.id}`) + .set(bearer(owner.token)); + expect(removed.status).toBe(200); + expect(removed.body.user.email).toBe(invitee.email); + + const session = await request(app.getHttpServer()) + .get("/api/auth/get-session") + .set(bearer(invitee.token)); + expect(session.status).toBe(200); + expect(session.body.session.activeOrganizationId).toBeNull(); + }); + + it("replaces expired resends and expired same-email re-invites on stock 1.6.26", async () => { + const owner = await signUpUser(app); + const firstInvitee = await signUpUser(app); + const secondInvitee = await signUpUser(app); + const organizationId = await createOrganization(app, owner, "expired-invitations"); + + const first = await inviteThroughFacade(app, owner, organizationId, firstInvitee); + expect(first.status).toBe(200); + await expireInvitation(first.body.id); + + const replacement = await request(app.getHttpServer()) + .post(`/organizations/${organizationId}/invitations/${first.body.id}/resend`) + .set(bearer(owner.token)); + expect(replacement.status).toBe(200); + expect(replacement.body.id).not.toBe(first.body.id); + expect(replacement.body.email).toBe(firstInvitee.email); + + const second = await inviteThroughFacade(app, owner, organizationId, secondInvitee); + expect(second.status).toBe(200); + await expireInvitation(second.body.id); + + const reinvited = await inviteThroughFacade(app, owner, organizationId, secondInvitee); + expect(reinvited.status).toBe(200); + expect(reinvited.body.id).not.toBe(second.body.id); + const listed = await request(app.getHttpServer()) + .get(`/organizations/${organizationId}/invitations`) + .set(bearer(owner.token)); + expect(listed.status).toBe(200); + expect( + listed.body.find((candidate: { id: string }) => candidate.id === second.body.id)?.status, + ).toBe("canceled"); + expect( + listed.body.find((candidate: { id: string }) => candidate.id === reinvited.body.id)?.status, + ).toBe("pending"); + }); + + it("keeps terminal invitation transitions terminal and blocks raw control-plane routes", async () => { + const owner = await signUpUser(app); + const rejecter = await signUpUser(app); + const cancelTarget = await signUpUser(app); + await verifyEmail(rejecter); + const organizationId = await createOrganization(app, owner, "terminal-invitations"); + + const rejectable = await inviteThroughFacade(app, owner, organizationId, rejecter); + const rejected = await request(app.getHttpServer()) + .post(`/account/organization-invitations/${rejectable.body.id}/reject`) + .set(bearer(rejecter.token)); + expect(rejected.status).toBe(200); + expect(rejected.body.status).toBe("rejected"); + const rejectedAgain = await request(app.getHttpServer()) + .post(`/account/organization-invitations/${rejectable.body.id}/reject`) + .set(bearer(rejecter.token)); + expect(rejectedAgain.status).toBe(400); + expect(rejectedAgain.body).toEqual({ + statusCode: 400, + code: "INVITATION_NOT_FOUND", + message: "Invitation not found.", + }); + + const cancelable = await inviteThroughFacade(app, owner, organizationId, cancelTarget); + const canceled = await request(app.getHttpServer()) + .post(`/organizations/${organizationId}/invitations/${cancelable.body.id}/cancel`) + .set(bearer(owner.token)); + expect(canceled.status).toBe(200); + expect(canceled.body.status).toBe("canceled"); + const canceledAgain = await request(app.getHttpServer()) + .post(`/organizations/${organizationId}/invitations/${cancelable.body.id}/cancel`) + .set(bearer(owner.token)); + expect(canceledAgain.status).toBe(404); + + const rawOrganization = await request(app.getHttpServer()) + .get(`/api/auth/organization/list-members?organizationId=${organizationId}`) + .set(bearer(owner.token)); + const rawAccount = await request(app.getHttpServer()) + .post("/api/auth/organization/accept-invitation") + .set(bearer(rejecter.token)) + .send({ invitationId: rejectable.body.id }); + const futureRawResend = await request(app.getHttpServer()) + .post("/api/auth/organization/resend-invitation") + .set(bearer(owner.token)) + .send({ invitationId: "future-id" }); + + for (const response of [rawOrganization, rawAccount, futureRawResend]) { + expect(response.status).toBe(403); + expect(response.body).toEqual({ + statusCode: 403, + code: "ORGANIZATION_CONTROL_PLANE_FACADE_REQUIRED", + message: "Use the application's organization control-plane endpoints.", + }); + } + }); +}); diff --git a/tests/packed-types/consumer.ts b/tests/packed-types/consumer.ts index 3fd6f4e..dd08973 100644 --- a/tests/packed-types/consumer.ts +++ b/tests/packed-types/consumer.ts @@ -1,6 +1,8 @@ import { BetterAuthGuard, BetterAuthModule, + BetterAuthOrganizationControlPlaneRoutePolicy, + BetterAuthOrganizationService, BetterAuthService, BetterAuthSessionManagementRoutePolicy, BetterAuthSessionService, @@ -8,6 +10,12 @@ import { deny, type AnyAuth, type BetterAuthInteropOptions, + type BetterAuthOrganizationInvitation, + type BetterAuthOrganizationInvitationAcceptance, + type BetterAuthOrganizationInvitationPreview, + type BetterAuthOrganizationMember, + type BetterAuthOrganizationMemberList, + type BetterAuthReceivedOrganizationInvitation, type BetterAuthRoutePolicy, type BetterAuthRoutePolicyContext, type BetterAuthRoutePolicyHandler, @@ -15,7 +23,12 @@ import { type BetterAuthSessionRevocationResult, type BetterAuthSessionSummary, } from "@nestm/better-auth"; -import { typeormAdapter, type TypeormAdapterConfig } from "@nestm/better-auth/typeorm"; +import { + createTypeormBetterAuthOrganizationLifecycleCoordinator, + typeormAdapter, + type TypeormAdapterConfig, + type TypeormBetterAuthOrganizationLifecycleCoordinator, +} from "@nestm/better-auth/typeorm"; import type { Reflector } from "@nestjs/core"; import { betterAuth } from "better-auth"; import { organization } from "better-auth/plugins"; @@ -31,6 +44,7 @@ declare const requestHeaders: IncomingHttpHeaders; const pluginAuth = betterAuth({ plugins: [organization()] }); declare const pluginService: BetterAuthService; declare const sessionService: BetterAuthSessionService; +declare const organizationService: BetterAuthOrganizationService; const invitationCall = pluginService.invokeApi(requestHeaders, (api, headers) => api.createInvitation({ body: { @@ -51,6 +65,21 @@ const otherSessionRevocation: Promise = sessionService.revokeOthers(requestHeaders); const allSessionRevocation: Promise = sessionService.revokeAll(requestHeaders); +const organizationMembers: Promise = + organizationService.listMembers(requestHeaders, "packed-organization"); +const updatedOrganizationMember: Promise = + organizationService.updateMemberRole(requestHeaders, "packed-organization", "packed-member", [ + "admin", + ]); +const sentOrganizationInvitation: Promise = + organizationService.invite(requestHeaders, "packed-organization", "packed@example.com", "member"); +const receivedOrganizationInvitations: Promise< + readonly BetterAuthReceivedOrganizationInvitation[] +> = organizationService.listUserInvitations(requestHeaders); +const organizationInvitationPreview: Promise = + organizationService.getInvitation(requestHeaders, "packed-invitation"); +const organizationInvitationAcceptance: Promise = + organizationService.acceptInvitation(requestHeaders, "packed-invitation"); const functionalRoutePolicy = (({ authPath }) => authPath === "/functional-policy-test" @@ -90,6 +119,9 @@ const policyFeature = BetterAuthModule.forFeature({ routePolicies: [PackedRouteP const sessionPolicyFeature = BetterAuthModule.forFeature({ routePolicies: [BetterAuthSessionManagementRoutePolicy], }); +const organizationPolicyFeature = BetterAuthModule.forFeature({ + routePolicies: [BetterAuthOrganizationControlPlaneRoutePolicy], +}); // The `./typeorm` subpath ships its own entry, so it needs its own coverage here: without a // consumer import it would be published untested against its rolled-up declarations. @@ -100,6 +132,17 @@ const typeormConfig: TypeormAdapterConfig = { }; const databaseAdapter = typeormAdapter(dataSource, typeormConfig); const defaultedAdapter = typeormAdapter(dataSource); +const organizationLifecycle = createTypeormBetterAuthOrganizationLifecycleCoordinator(dataSource); +const typedOrganizationLifecycle: TypeormBetterAuthOrganizationLifecycleCoordinator = + organizationLifecycle; +const coordinatedDatabaseAdapter = typeormAdapter(dataSource, { + getManager: organizationLifecycle.getManager, + transaction: true, +}); +const coordinatedInvitationCall = organizationLifecycle.run( + "packed-organization", + () => invitationCall, +); const moduleWithTypeormDatabase = BetterAuthModule.forRoot({ options: { database: databaseAdapter }, @@ -108,16 +151,27 @@ const moduleWithTypeormDatabase = BetterAuthModule.forRoot({ export { asynchronousModule, databaseAdapter, + coordinatedDatabaseAdapter, + coordinatedInvitationCall, defaultedAdapter, interop, invitationCall, + organizationInvitationAcceptance, + organizationInvitationPreview, + organizationMembers, + organizationPolicyFeature, + receivedOrganizationInvitations, + sentOrganizationInvitation, sessionList, sessionRevocation, otherSessionRevocation, + organizationLifecycle, allSessionRevocation, manuallyConstructedGuard, moduleWithTypeormDatabase, policyFeature, sessionPolicyFeature, synchronousModule, + typedOrganizationLifecycle, + updatedOrganizationMember, }; diff --git a/tests/postgres/flows.spec.ts b/tests/postgres/flows.spec.ts index 9a7b106..89516b0 100644 --- a/tests/postgres/flows.spec.ts +++ b/tests/postgres/flows.spec.ts @@ -48,11 +48,19 @@ describe.each(ARMS)("flows on the %s adapter", (arm: Arm) => { expect(result.memberOrganizationIds).toEqual([result.organizationId]); }); - test("counts, sorts, limits and offsets", () => { + test("counts, sorts, limits and offsets", async () => { expect(result.memberCount).toBe(2); expect(result.fullOrganizationMemberCount).toBe(2); expect(result.sortedMemberUserIds).toHaveLength(2); - expect([...result.sortedMemberUserIds].sort()).toEqual(result.sortedMemberUserIds); + const reverseSortedMembers = await context.auth.db.findMany<{ userId: string }>({ + model: "member", + where: [{ field: "organizationId", value: result.organizationId }], + sortBy: { field: "userId", direction: "desc" }, + limit: 10, + }); + expect(reverseSortedMembers.map((member) => member.userId)).toEqual( + result.sortedMemberUserIds.toReversed(), + ); expect(result.pagedOrganizationSlugs).toEqual([ORG_SLUG]); expect(result.pagedPastEndIsEmpty).toBe(true); }); diff --git a/tests/postgres/organization-lifecycle.spec.ts b/tests/postgres/organization-lifecycle.spec.ts new file mode 100644 index 0000000..b4c7f21 --- /dev/null +++ b/tests/postgres/organization-lifecycle.spec.ts @@ -0,0 +1,115 @@ +import type { BetterAuthOptions } from "better-auth/types"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { + createTypeormBetterAuthOrganizationLifecycleCoordinator, + typeormAdapter, +} from "../../src/typeorm/index.ts"; +import { createArm } from "./harness.ts"; +import type { ArmContext } from "./harness.ts"; + +const MINIMAL_OPTIONS = { + rateLimit: { storage: "database" }, +} as unknown as BetterAuthOptions; + +const TRY_ADVISORY_LOCK_SQL = + "SELECT pg_try_advisory_xact_lock(hashtextextended($1, 0)) AS acquired"; + +function advisoryLockResult(value: unknown): boolean { + if (!Array.isArray(value)) { + throw new TypeError("PostgreSQL returned an invalid advisory-lock result."); + } + const row: unknown = value[0]; + if ( + typeof row !== "object" || + row === null || + !("acquired" in row) || + typeof row.acquired !== "boolean" + ) { + throw new TypeError("PostgreSQL returned an invalid advisory-lock result."); + } + return row.acquired; +} + +describe("TypeORM organization lifecycle coordinator", () => { + let context: ArmContext; + + beforeAll(async () => { + context = await createArm("typeorm"); + }); + + afterAll(async () => { + await context?.dispose(); + }); + + test("makes Better Auth adapter transactions join its commit and rollback", async () => { + const dataSource = context.dataSource!; + const coordinator = createTypeormBetterAuthOrganizationLifecycleCoordinator(dataSource); + const db = typeormAdapter(dataSource, { + getManager: coordinator.getManager, + transaction: true, + })(MINIMAL_OPTIONS); + + await coordinator.run("org-commit", async () => { + await db.transaction(async (trx) => { + await trx.create({ + model: "rateLimit", + data: { key: "lifecycle-commit", count: 1, lastRequest: 1 }, + }); + }); + }); + expect( + await db.findOne({ + model: "rateLimit", + where: [{ field: "key", value: "lifecycle-commit" }], + }), + ).not.toBeNull(); + + await expect( + coordinator.run("org-rollback", async () => { + await db.transaction(async (trx) => { + await trx.create({ + model: "rateLimit", + data: { + key: "lifecycle-rollback", + count: 1, + lastRequest: 1, + }, + }); + }); + throw new Error("roll back lifecycle mutation"); + }), + ).rejects.toThrow("roll back lifecycle mutation"); + expect( + await db.findOne({ + model: "rateLimit", + where: [{ field: "key", value: "lifecycle-rollback" }], + }), + ).toBeNull(); + }); + + test("holds an organization-keyed advisory lock until its transaction ends", async () => { + const dataSource = context.dataSource!; + const coordinator = createTypeormBetterAuthOrganizationLifecycleCoordinator(dataSource); + const organizationId = "organization-lock-target"; + + await coordinator.run(organizationId, async () => { + const [sameOrganization, differentOrganization] = await dataSource.transaction( + async (manager) => { + const same = await manager.query(TRY_ADVISORY_LOCK_SQL, [organizationId]); + const different = await manager.query(TRY_ADVISORY_LOCK_SQL, ["different-organization"]); + return [advisoryLockResult(same), advisoryLockResult(different)] as const; + }, + ); + + expect(sameOrganization).toBe(false); + expect(differentOrganization).toBe(true); + }); + + const released = await dataSource.transaction(async (manager) => { + const rows = await manager.query(TRY_ADVISORY_LOCK_SQL, [organizationId]); + return advisoryLockResult(rows); + }); + expect(released).toBe(true); + }); +}); diff --git a/tests/unit/exports.test.ts b/tests/unit/exports.test.ts index 95914cc..db5b592 100644 --- a/tests/unit/exports.test.ts +++ b/tests/unit/exports.test.ts @@ -30,8 +30,11 @@ const EXPECTED_VALUE_EXPORTS = [ // services & guard "BetterAuthService", "BetterAuthSessionService", + "BetterAuthOrganizationService", "BetterAuthSessionManagementRoutePolicy", "BETTER_AUTH_SESSION_MANAGEMENT_PATHS", + "BetterAuthOrganizationControlPlaneRoutePolicy", + "BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS", "BetterAuthGuard", "MutationOriginGuard", "MUTATION_ORIGIN_GUARD_OPTIONS", diff --git a/tests/unit/organization-guard.test.ts b/tests/unit/organization-guard.test.ts new file mode 100644 index 0000000..a2f41dd --- /dev/null +++ b/tests/unit/organization-guard.test.ts @@ -0,0 +1,122 @@ +import { Reflector } from "@nestjs/core"; +import { ExecutionContextHost } from "@nestjs/core/helpers/execution-context-host"; +import { describe, expect, it, vi } from "vitest"; +import { + BetterAuthGuard, + MemberHasPermission, + RequireActiveOrg, + type AnyAuth, +} from "../../src/index.ts"; + +class RequiresActiveOrganizationController { + @RequireActiveOrg() + read(this: void): void {} + + @MemberHasPermission({ permissions: { organization: ["update"] } }) + update(this: void): void {} +} + +function executionContext( + handler = RequiresActiveOrganizationController.prototype.read, +): ExecutionContextHost { + const context = new ExecutionContextHost( + [ + { + headers: { + authorization: "Bearer authoritative-session", + cookie: "better-auth.session_data=stale-cookie-selector", + }, + }, + ], + RequiresActiveOrganizationController, + handler, + ); + context.setType("http"); + return context; +} + +function authWithOrganizationApi(organizationApi: Record): AnyAuth { + return { + handler: async (_request: Request) => new Response(), + api: { + getSession: vi.fn(async (_input: unknown) => ({ + session: { activeOrganizationId: "authoritative-organization" }, + user: { id: "user-id" }, + })), + ...organizationApi, + }, + options: {}, + $context: Promise.resolve({}), + $Infer: { Session: {} }, + $ERROR_CODES: {}, + } satisfies AnyAuth; +} + +describe("BetterAuthGuard organization membership lookup", () => { + it("passes the authoritative session organization to getActiveMemberRole", async () => { + const getActiveMemberRole = vi.fn(async (_input: unknown) => ({ role: "member" })); + const auth = authWithOrganizationApi({ getActiveMemberRole }); + const guard = new BetterAuthGuard(new Reflector(), auth); + + await expect(guard.canActivate(executionContext())).resolves.toBe(true); + + expect(auth.api.getSession).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { disableCookieCache: true }, + }); + expect(getActiveMemberRole).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { organizationId: "authoritative-organization" }, + }); + }); + + it("passes the same authoritative organization to the getActiveMember fallback", async () => { + const getActiveMember = vi.fn(async (_input: unknown) => ({ + organizationId: "authoritative-organization", + role: "member", + })); + const guard = new BetterAuthGuard( + new Reflector(), + authWithOrganizationApi({ getActiveMember }), + ); + + await expect(guard.canActivate(executionContext())).resolves.toBe(true); + + expect(getActiveMember).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { organizationId: "authoritative-organization" }, + }); + }); + + it("fails closed when the fallback resolves a member from a stale organization", async () => { + const getActiveMember = vi.fn(async (_input: unknown) => ({ + organizationId: "stale-cookie-organization", + role: "owner", + })); + const guard = new BetterAuthGuard( + new Reflector(), + authWithOrganizationApi({ getActiveMember }), + ); + + await expect(guard.canActivate(executionContext())).rejects.toMatchObject({ + response: { message: "Active organization membership is required" }, + }); + }); + + it("pins MemberHasPermission to the authoritative selector instead of the signed cookie", async () => { + const hasPermission = vi.fn(async (_input: unknown) => ({ success: true })); + const guard = new BetterAuthGuard(new Reflector(), authWithOrganizationApi({ hasPermission })); + + await expect( + guard.canActivate(executionContext(RequiresActiveOrganizationController.prototype.update)), + ).resolves.toBe(true); + + expect(hasPermission).toHaveBeenCalledWith({ + body: { + permissions: { organization: ["update"] }, + organizationId: "authoritative-organization", + }, + headers: expect.any(Headers), + }); + }); +}); diff --git a/tests/unit/organization-route-policy.test.ts b/tests/unit/organization-route-policy.test.ts new file mode 100644 index 0000000..ce265a2 --- /dev/null +++ b/tests/unit/organization-route-policy.test.ts @@ -0,0 +1,41 @@ +import { HttpStatus } from "@nestjs/common"; +import { describe, expect, it } from "vitest"; +import { + BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS, + BetterAuthOrganizationControlPlaneRoutePolicy, +} from "../../src/index.ts"; + +describe("BetterAuthOrganizationControlPlaneRoutePolicy", () => { + it("covers the application organization and account lifecycle routes", () => { + expect(BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS).toEqual([ + "/organization/update", + "/organization/get-full-organization", + "/organization/has-permission", + "/organization/invite-member", + "/organization/resend-invitation", + "/organization/cancel-invitation", + "/organization/list-invitations", + "/organization/list-members", + "/organization/remove-member", + "/organization/update-member-role", + "/organization/leave", + "/organization/accept-invitation", + "/organization/reject-invitation", + "/organization/get-invitation", + "/organization/list-user-invitations", + ]); + }); + + it("returns a stable opt-in denial", () => { + expect(new BetterAuthOrganizationControlPlaneRoutePolicy().evaluate()).toEqual({ + effect: "deny", + status: HttpStatus.FORBIDDEN, + body: { + statusCode: HttpStatus.FORBIDDEN, + code: "ORGANIZATION_CONTROL_PLANE_FACADE_REQUIRED", + message: "Use the application's organization control-plane endpoints.", + }, + headers: undefined, + }); + }); +}); diff --git a/tests/unit/organization-service-type-assertions.ts b/tests/unit/organization-service-type-assertions.ts new file mode 100644 index 0000000..3db8ca8 --- /dev/null +++ b/tests/unit/organization-service-type-assertions.ts @@ -0,0 +1,91 @@ +/** Compile-time coverage for the normalized organization lifecycle facade. */ +import { + BetterAuthOrganizationService, + type BetterAuthOrganizationInvitation, + type BetterAuthOrganizationInvitationAcceptance, + type BetterAuthOrganizationInvitationPreview, + type BetterAuthOrganizationMember, + type BetterAuthOrganizationMemberList, + type BetterAuthReceivedOrganizationInvitation, +} from "../../src/index.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +declare const service: BetterAuthOrganizationService; +declare const headers: IncomingHttpHeaders; + +const members: Promise = service.listMembers( + headers, + "organization-id", + { limit: 20, offset: 0, sortDirection: "asc" }, +); +const updated: Promise = service.updateMemberRole( + headers, + "organization-id", + "member-id", + ["admin"], +); +const removed: Promise = service.removeMember( + headers, + "organization-id", + "member-id", +); +const left: Promise = service.leave(headers, "organization-id"); +const sent: Promise = service.invite( + headers, + "organization-id", + "invitee@example.com", + "member", +); +const resent: Promise = service.resendInvitation( + headers, + "organization-id", + "invitation-id", +); +const canceled: Promise = service.cancelInvitation( + headers, + "organization-id", + "invitation-id", +); +const received: Promise = + service.listUserInvitations(headers); +const preview: Promise = service.getInvitation( + headers, + "invitation-id", +); +const accepted: Promise = service.acceptInvitation( + headers, + "invitation-id", +); +const rejected: Promise = service.rejectInvitation( + headers, + "invitation-id", +); + +async function assertSafeOrganizationSurface(): Promise { + const firstMember = (await members).members[0]; + if (firstMember) { + const email: string = firstMember.user.email; + // @ts-expect-error Public member users never expose password material. + const password = firstMember.user.password; + void email; + void password; + } + const invitation = await sent; + // @ts-expect-error Public invitations never expose a session token. + const token = invitation.token; + void token; +} + +export { + accepted, + assertSafeOrganizationSurface, + canceled, + left, + preview, + received, + rejected, + removed, + resent, + sent, + updated, +}; diff --git a/tests/unit/organization-service.test.ts b/tests/unit/organization-service.test.ts new file mode 100644 index 0000000..f872b7a --- /dev/null +++ b/tests/unit/organization-service.test.ts @@ -0,0 +1,461 @@ +import { HttpException, Logger } from "@nestjs/common"; +import { APIError } from "better-auth/api"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BetterAuthOrganizationService, + BetterAuthService, + type AnyAuth, + type BetterAuthModuleOptions, + type BetterAuthOrganizationLifecycleCoordinator, +} from "../../src/index.ts"; + +const CREATED_AT = new Date("2026-01-01T10:00:00.000Z"); +const EXPIRES_AT = new Date("2099-09-01T10:00:00.000Z"); +const EXPIRED_AT = new Date("2000-01-02T10:00:00.000Z"); +const ORGANIZATION_ID = "organization-id"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function member(overrides: Record = {}) { + return { + id: "member-id", + userId: "member-user-id", + organizationId: ORGANIZATION_ID, + role: "member", + createdAt: CREATED_AT, + user: { + id: "member-user-id", + name: "Member User", + email: "member@example.com", + image: null, + }, + ...overrides, + }; +} + +function invitation(overrides: Record = {}) { + return { + id: "invitation-id", + email: "invitee@example.com", + role: "member", + organizationId: ORGANIZATION_ID, + inviterId: "owner-user-id", + status: "pending", + expiresAt: EXPIRES_AT, + createdAt: CREATED_AT, + ...overrides, + }; +} + +function invitationPreview(overrides: Record = {}) { + return { + ...invitation(), + organizationName: "Organization", + organizationSlug: "organization", + inviterEmail: "owner@example.com", + ...overrides, + }; +} + +function receivedInvitation(overrides: Record = {}) { + return { + ...invitation(), + organizationName: "Organization", + ...overrides, + }; +} + +function createApi(overrides: Record = {}) { + return { + getSession: vi.fn(async (_input: unknown) => ({ + session: { id: "session-id" }, + user: { id: "member-user-id" }, + })), + listMembers: vi.fn(async (_input: unknown) => ({ members: [member()], total: 1 })), + updateMemberRole: vi.fn(async (_input: unknown) => ({ + id: "member-id", + userId: "member-user-id", + organizationId: ORGANIZATION_ID, + role: "admin", + createdAt: CREATED_AT, + })), + removeMember: vi.fn(async (_input: unknown) => ({ member: member() })), + leaveOrganization: vi.fn(async (_input: unknown) => member()), + listInvitations: vi.fn(async (_input: unknown) => [invitation()]), + createInvitation: vi.fn(async (_input: unknown) => invitation()), + cancelInvitation: vi.fn(async (_input: unknown) => invitation({ status: "canceled" })), + listUserInvitations: vi.fn(async (_input: unknown) => [receivedInvitation()]), + getInvitation: vi.fn(async (_input: unknown) => invitationPreview()), + acceptInvitation: vi.fn(async (_input: unknown) => ({ + invitation: invitation({ status: "accepted" }), + member: { + id: "member-id", + userId: "member-user-id", + organizationId: ORGANIZATION_ID, + role: "member", + createdAt: CREATED_AT, + }, + })), + rejectInvitation: vi.fn(async (_input: unknown) => ({ + invitation: invitation({ status: "rejected" }), + member: null, + })), + ...overrides, + }; +} + +class RecordingCoordinator implements BetterAuthOrganizationLifecycleCoordinator { + readonly organizationIds: string[] = []; + + async run(organizationId: string, operation: () => Promise): Promise { + this.organizationIds.push(organizationId); + return operation(); + } +} + +function createService(api = createApi(), contextOverrides: Record = {}) { + const internalAdapter = { + listSessions: vi.fn(async (_userId: string) => []), + updateSession: vi.fn(async (_token: string, _update: Record) => ({ + status: true, + })), + ...contextOverrides, + }; + const auth = { + handler: async (_request: Request) => new Response(), + api, + options: {}, + $context: Promise.resolve({ internalAdapter }), + $Infer: { Session: {} }, + $ERROR_CODES: {}, + } satisfies AnyAuth; + const coordinator = new RecordingCoordinator(); + const options = { auth, organizationLifecycle: coordinator } satisfies BetterAuthModuleOptions; + return { + api, + coordinator, + internalAdapter, + service: new BetterAuthOrganizationService(new BetterAuthService(auth), options), + }; +} + +describe("BetterAuthOrganizationService", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("re-reads the joined member after a stock updateMemberRole response", async () => { + const updatedMember = member({ role: "admin" }); + const api = createApi({ + listMembers: vi.fn(async (_input: unknown) => ({ members: [updatedMember], total: 1 })), + }); + const { coordinator, service } = createService(api); + + const result = await service.updateMemberRole({}, ORGANIZATION_ID, "member-id", ["admin"]); + + expect(result).toEqual(updatedMember); + expect(result.user.email).toBe("member@example.com"); + expect(api.updateMemberRole).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { + organizationId: ORGANIZATION_ID, + memberId: "member-id", + role: ["admin"], + }, + }); + expect(api.listMembers).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { + organizationId: ORGANIZATION_ID, + limit: 1, + offset: 0, + filterField: "id", + filterValue: "member-id", + }, + }); + expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID]); + }); + + it("cancels expired pending invitations for the normalized email before inviting", async () => { + const events: string[] = []; + const api = createApi({ + listInvitations: vi.fn(async (_input: unknown) => [ + invitation({ id: "expired-id", email: "Invitee@Example.com", expiresAt: EXPIRED_AT }), + invitation({ id: "other-id", email: "other@example.com", expiresAt: EXPIRED_AT }), + ]), + cancelInvitation: vi.fn(async (input: unknown) => { + events.push("cancel"); + if (!isRecord(input) || !isRecord(input.body)) throw new TypeError("invalid input"); + const invitationId = input.body.invitationId; + if (typeof invitationId !== "string") throw new TypeError("invalid invitation id"); + return invitation({ id: invitationId, status: "canceled" }); + }), + createInvitation: vi.fn(async (_input: unknown) => { + events.push("create"); + return invitation({ id: "fresh-id", email: "invitee@example.com" }); + }), + }); + const { coordinator, service } = createService(api); + + const result = await service.invite({}, ORGANIZATION_ID, " Invitee@Example.com ", "member"); + + expect(result.id).toBe("fresh-id"); + expect(events).toEqual(["cancel", "create"]); + expect(api.cancelInvitation).toHaveBeenCalledOnce(); + expect(api.cancelInvitation).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { invitationId: "expired-id" }, + }); + expect(api.createInvitation).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { + email: "invitee@example.com", + organizationId: ORGANIZATION_ID, + role: "member", + }, + }); + expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID]); + }); + + it("resends one live invitation through stock createInvitation({ resend: true })", async () => { + const api = createApi({ + createInvitation: vi.fn(async (_input: unknown) => + invitation({ expiresAt: new Date("2099-10-01T10:00:00.000Z") }), + ), + }); + const { coordinator, service } = createService(api); + + const result = await service.resendInvitation({}, ORGANIZATION_ID, "invitation-id"); + + expect(result.id).toBe("invitation-id"); + expect(api.createInvitation).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { + email: "invitee@example.com", + organizationId: ORGANIZATION_ID, + role: "member", + resend: true, + }, + }); + expect(api.cancelInvitation).not.toHaveBeenCalled(); + expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID]); + }); + + it("cancels and replaces an expired pending invitation during resend", async () => { + const events: string[] = []; + const api = createApi({ + listInvitations: vi.fn(async (_input: unknown) => [invitation({ expiresAt: EXPIRED_AT })]), + cancelInvitation: vi.fn(async (_input: unknown) => { + events.push("cancel"); + return invitation({ expiresAt: EXPIRED_AT, status: "canceled" }); + }), + createInvitation: vi.fn(async (_input: unknown) => { + events.push("create"); + return invitation({ id: "replacement-id" }); + }), + }); + const { service } = createService(api); + + const result = await service.resendInvitation({}, ORGANIZATION_ID, "invitation-id"); + + expect(result.id).toBe("replacement-id"); + expect(events).toEqual(["cancel", "create"]); + expect(api.createInvitation).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { + email: "invitee@example.com", + organizationId: ORGANIZATION_ID, + role: "member", + }, + }); + }); + + it("rechecks account invitations inside the organization coordinator", async () => { + const api = createApi(); + const { coordinator, service } = createService(api); + + const accepted = await service.acceptInvitation({}, "invitation-id"); + const rejected = await service.rejectInvitation({}, "invitation-id"); + + expect(accepted.invitation.status).toBe("accepted"); + expect(accepted.member.user.email).toBe("member@example.com"); + expect(rejected.status).toBe("rejected"); + expect(api.getInvitation).toHaveBeenCalledTimes(4); + expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID, ORGANIZATION_ID]); + }); + + it("normalizes stock terminal invitation preflights to a stable facade error", async () => { + const api = createApi({ + getInvitation: vi.fn(async (_input: unknown) => { + throw new APIError("BAD_REQUEST", { message: "Invitation not found!" }); + }), + }); + const { service } = createService(api); + + await expect(service.rejectInvitation({}, "terminal-id")).rejects.toMatchObject({ + status: 400, + response: { + statusCode: 400, + code: "INVITATION_NOT_FOUND", + message: "Invitation not found.", + }, + }); + expect(api.rejectInvitation).not.toHaveBeenCalled(); + }); + + it("normalizes an invitation that becomes terminal after entering the coordinator", async () => { + let previewCalls = 0; + const api = createApi({ + getInvitation: vi.fn(async (_input: unknown) => { + previewCalls += 1; + if (previewCalls === 1) return invitationPreview(); + throw new APIError("BAD_REQUEST", { message: "Invitation not found!" }); + }), + }); + const { coordinator, service } = createService(api); + + await expect(service.acceptInvitation({}, "raced-id")).rejects.toMatchObject({ + status: 400, + response: { code: "INVITATION_NOT_FOUND" }, + }); + expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID]); + expect(api.acceptInvitation).not.toHaveBeenCalled(); + }); + + it("preserves stock recipient authorization failures from invitation preflight", async () => { + const api = createApi({ + getInvitation: vi.fn(async (_input: unknown) => { + throw new APIError("FORBIDDEN", { + code: "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION", + message: "You are not the recipient of the invitation.", + }); + }), + }); + const { service } = createService(api); + + await expect(service.acceptInvitation({}, "foreign-id")).rejects.toMatchObject({ + status: 403, + response: { + code: "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION", + message: "You are not the recipient of the invitation.", + }, + }); + expect(api.acceptInvitation).not.toHaveBeenCalled(); + }); + + it("rejects canceling a non-pending or cross-organization invitation before mutation", async () => { + const api = createApi({ + listInvitations: vi.fn(async (_input: unknown) => [ + invitation({ id: "accepted-id", status: "accepted" }), + ]), + }); + const { service } = createService(api); + + await expect( + service.cancelInvitation({}, ORGANIZATION_ID, "accepted-id"), + ).rejects.toMatchObject({ + status: 404, + response: { code: "INVITATION_NOT_FOUND" }, + }); + expect(api.cancelInvitation).not.toHaveBeenCalled(); + }); + + it("clears every matching session selector after remove without failing the mutation", async () => { + vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + const api = createApi(); + const listSessions = vi.fn(async (_userId: string) => [ + { token: "matching-one", activeOrganizationId: ORGANIZATION_ID }, + { token: "unrelated", activeOrganizationId: "other-organization" }, + { token: "matching-two", activeOrganizationId: ORGANIZATION_ID }, + ]); + const updateSession = vi.fn(async (token: string) => { + if (token === "matching-two") throw new Error("secondary store unavailable"); + return { status: true }; + }); + const { coordinator, service } = createService(api, { listSessions, updateSession }); + + await expect(service.removeMember({}, ORGANIZATION_ID, "member-id")).resolves.toMatchObject({ + id: "member-id", + user: { id: "member-user-id" }, + }); + + expect(api.removeMember).toHaveBeenCalledOnce(); + expect(listSessions).toHaveBeenCalledWith("member-user-id"); + expect(updateSession).toHaveBeenCalledTimes(2); + expect(updateSession).toHaveBeenCalledWith("matching-one", { + activeOrganizationId: null, + }); + expect(updateSession).toHaveBeenCalledWith("matching-two", { + activeOrganizationId: null, + }); + expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID]); + }); + + it("coordinates leave by the authoritative session user and clears its selectors", async () => { + const api = createApi(); + const listSessions = vi.fn(async (_userId: string) => [ + { token: "leaving-session", activeOrganizationId: ORGANIZATION_ID }, + ]); + const updateSession = vi.fn(async (_token: string) => ({ status: true })); + const { coordinator, service } = createService(api, { listSessions, updateSession }); + + const result = await service.leave({}, ORGANIZATION_ID); + + expect(result.userId).toBe("member-user-id"); + expect(api.getSession).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { disableCookieCache: true, disableRefresh: true }, + }); + expect(api.listMembers).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { + organizationId: ORGANIZATION_ID, + limit: 1, + offset: 0, + filterField: "userId", + filterValue: "member-user-id", + }, + }); + expect(api.leaveOrganization).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { organizationId: ORGANIZATION_ID }, + }); + expect(updateSession).toHaveBeenCalledWith("leaving-session", { + activeOrganizationId: null, + }); + expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID]); + }); + + it("returns normalized account lists and previews", async () => { + const { service } = createService(); + + const listed = await service.listUserInvitations({}); + const preview = await service.getInvitation({}, "invitation-id"); + + expect(listed).toEqual([receivedInvitation()]); + expect(preview).toEqual(invitationPreview()); + expect(listed[0]?.createdAt).toBeInstanceOf(Date); + expect(preview.expiresAt).toBeInstanceOf(Date); + }); + + it("rejects malformed public member data instead of leaking a partial result", async () => { + const api = createApi({ + listMembers: vi.fn(async (_input: unknown) => ({ + members: [member({ user: { id: "member-user-id" } })], + total: 1, + })), + }); + const { service } = createService(api); + + const error = await service + .listMembers({}, ORGANIZATION_ID) + .catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(HttpException); + if (!(error instanceof HttpException)) throw error; + expect(error.getStatus()).toBe(500); + expect(error.getResponse()).toMatchObject({ code: "INVALID_BETTER_AUTH_RESPONSE" }); + }); +}); diff --git a/tests/unit/typeorm-organization-lifecycle.test.ts b/tests/unit/typeorm-organization-lifecycle.test.ts new file mode 100644 index 0000000..171ca62 --- /dev/null +++ b/tests/unit/typeorm-organization-lifecycle.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTypeormBetterAuthOrganizationLifecycleCoordinator } from "../../src/typeorm/organization-lifecycle.ts"; +import type { + TypeormCallableCapability, + TypeormDataSource, + TypeormEntityManager, +} from "../../src/typeorm/types.ts"; + +interface QueryCall { + readonly parameters: readonly unknown[]; + readonly sql: string; +} + +function createDataSource(driverType = "postgres") { + const queries: QueryCall[] = []; + let transactionCalls = 0; + const query = vi.fn(async (sql: string, parameters: readonly unknown[]) => { + queries.push({ sql, parameters }); + return []; + }); + const manager: TypeormEntityManager = { + query, + }; + const transaction = (async ( + callback: (transactionManager: TypeormEntityManager) => Promise, + ) => { + transactionCalls += 1; + return await callback(manager); + }) as TypeormCallableCapability; + const dataSource: TypeormDataSource = { + options: { type: driverType }, + driver: { + escape: (identifier) => `"${identifier}"`, + createParameter: (_name, index) => `$${index + 1}`, + }, + entityMetadatas: [], + manager, + getMetadata: vi.fn() as TypeormCallableCapability, + transaction, + }; + + return { + dataSource, + queries, + getTransactionCalls: () => transactionCalls, + }; +} + +describe("TypeORM organization lifecycle coordinator", () => { + it("opens one transaction, exposes its manager, and deduplicates nested locks", async () => { + const harness = createDataSource(); + const coordinator = createTypeormBetterAuthOrganizationLifecycleCoordinator(harness.dataSource); + const getManager = coordinator.getManager; + const organizationId = `org-1'); SELECT pg_sleep(10); --`; + + expect(getManager()).toBeUndefined(); + await expect( + coordinator.run(organizationId, async () => { + expect(getManager()).toBeDefined(); + await Promise.all([ + coordinator.run(organizationId, async () => "first nested result"), + coordinator.run(organizationId, async () => "second nested result"), + ]); + await coordinator.run("org-2", async () => undefined); + return "result"; + }), + ).resolves.toBe("result"); + + expect(getManager()).toBeUndefined(); + expect(harness.getTransactionCalls()).toBe(1); + expect(harness.queries).toHaveLength(2); + expect(harness.queries[0]).toEqual({ + sql: "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", + parameters: [organizationId], + }); + expect(harness.queries[0]!.sql).not.toContain(organizationId); + expect(harness.queries[1]).toEqual({ + sql: "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", + parameters: ["org-2"], + }); + }); + + it("clears the exposed manager and propagates operation failures", async () => { + const harness = createDataSource(); + const coordinator = createTypeormBetterAuthOrganizationLifecycleCoordinator(harness.dataSource); + const operationError = new Error("operation failed"); + + await expect( + coordinator.run("org-1", async () => { + throw operationError; + }), + ).rejects.toBe(operationError); + + expect(coordinator.getManager()).toBeUndefined(); + expect(harness.getTransactionCalls()).toBe(1); + }); + + it("rejects invalid runtime inputs before opening a transaction", async () => { + const harness = createDataSource(); + const coordinator = createTypeormBetterAuthOrganizationLifecycleCoordinator(harness.dataSource); + + await expect(coordinator.run(" ", async () => undefined)).rejects.toThrow( + /organizationId must be a non-empty string/, + ); + expect(harness.getTransactionCalls()).toBe(0); + }); + + it("rejects PostgreSQL-compatible dialects without advisory-lock support", () => { + const harness = createDataSource("cockroachdb"); + + expect(() => + createTypeormBetterAuthOrganizationLifecycleCoordinator(harness.dataSource), + ).toThrow(/does not provide the PostgreSQL transaction-scoped advisory locks/); + }); +});