From 56b446717131cb72a9e4db91ca86e92e5260b9bb Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Sat, 22 Aug 2026 16:41:21 -0300 Subject: [PATCH] feat: add safe platform user management --- .changeset/safe-platform-user-management.md | 16 + README.md | 169 +++- src/better-auth.module.ts | 3 + src/guards/auth-errors.ts | 13 +- src/guards/better-auth.guard.ts | 18 + src/index.ts | 28 + ...-auth-control-plane-lifecycle.interface.ts | 18 + .../better-auth-module-options.interface.ts | 10 + src/mount/mount.service.ts | 30 +- src/mount/request-utils.ts | 33 +- src/mount/route-policy.ts | 13 +- src/policies/user-management-route-policy.ts | 26 + .../better-auth-organization.service.ts | 111 +- src/services/better-auth-session.service.ts | 66 +- .../better-auth-user-management.service.ts | 945 ++++++++++++++++++ src/typeorm/control-plane-lifecycle.ts | 153 +++ src/typeorm/index.ts | 4 + src/typeorm/organization-lifecycle.ts | 126 +-- tests/e2e/organization-service.e2e.test.ts | 76 ++ tests/e2e/rest-auth.e2e.test.ts | 20 + tests/e2e/session-service.e2e.test.ts | 47 + tests/e2e/user-management-service.e2e.test.ts | 542 ++++++++++ tests/packed-types/consumer.ts | 80 +- tests/postgres/organization-lifecycle.spec.ts | 55 +- tests/shared/raw-http.ts | 85 ++ tests/unit/banned-user-guard.test.ts | 94 ++ tests/unit/exports.test.ts | 3 + .../organization-service-type-assertions.ts | 8 +- tests/unit/organization-service.test.ts | 90 +- tests/unit/request-utils.test.ts | 39 + tests/unit/session-service.test.ts | 30 + .../typeorm-organization-lifecycle.test.ts | 62 +- .../unit/user-management-route-policy.test.ts | 26 + ...user-management-service-type-assertions.ts | 85 ++ tests/unit/user-management-service.test.ts | 708 +++++++++++++ 35 files changed, 3625 insertions(+), 207 deletions(-) create mode 100644 .changeset/safe-platform-user-management.md create mode 100644 src/interfaces/better-auth-control-plane-lifecycle.interface.ts create mode 100644 src/policies/user-management-route-policy.ts create mode 100644 src/services/better-auth-user-management.service.ts create mode 100644 src/typeorm/control-plane-lifecycle.ts create mode 100644 tests/e2e/user-management-service.e2e.test.ts create mode 100644 tests/shared/raw-http.ts create mode 100644 tests/unit/banned-user-guard.test.ts create mode 100644 tests/unit/request-utils.test.ts create mode 100644 tests/unit/user-management-route-policy.test.ts create mode 100644 tests/unit/user-management-service-type-assertions.ts create mode 100644 tests/unit/user-management-service.test.ts diff --git a/.changeset/safe-platform-user-management.md b/.changeset/safe-platform-user-management.md new file mode 100644 index 0000000..f635dfa --- /dev/null +++ b/.changeset/safe-platform-user-management.md @@ -0,0 +1,16 @@ +--- +"@nestm/better-auth": minor +--- + +Add a stock-Better-Auth-compatible platform user-management facade with bounded user queries and +profile/role/ban mutations, token-free active session summaries, safe owned-session-id revocation, +and an opt-in policy closing the raw admin HTTP namespace. Generalize the TypeORM organization +lifecycle coordinator into one namespaced organization/user/platform control-plane coordinator +while preserving the organization-only API. Canonicalize raw request targets before auth mount and +policy matching so encoded dot segments cannot bypass protected routes, and dual-acquire legacy +plus namespaced organization advisory locks for safe rolling upgrades. The guard now rejects +retained sessions for actively banned users while respecting valid expired bans, and expiry-omitted +re-bans no longer retain a previous temporary expiry. Stock-valid hostile profile and session +display fields are projected into explicit bounded/redacted outputs instead of blocking admin +enforcement or safe session revocation. Organization member identity fields use the same bounded, +explicit projection so hostile profile display data cannot block role changes or removals. diff --git a/README.md b/README.md index 907ae63..182ba34 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - `@Session()` / `@CurrentUser()` parameter decorators, `@InjectBetterAuth()` - Class-based hooks with full NestJS DI: `@Hook` + `@BeforeHook`/`@AfterHook`, `@DatabaseHook` + `@BeforeCreate`/`@AfterUpdate`/…, discovered anywhere in your module graph — **no `hooks: {}` pre-declaration needed** - Composable HTTP route policies with full NestJS DI: `@AuthRoutePolicy({ path, methods, order })` +- Safe application facades for account sessions, organization lifecycle, and platform user management - Works with every better-auth plugin; plugin types flow into `@Session()` and `BetterAuthService` ## Requirements @@ -113,7 +114,8 @@ BetterAuthModule.forRootAsync({ | `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. | +| `controlPlaneLifecycle` | option | Optional namespaced (`organization`, `user`, `platform`) serialization boundary shared by the organization and user-management facades. | +| `organizationLifecycle` | option | Deprecated organization-only serialization boundary retained for compatibility. `BetterAuthOrganizationService` prefers `controlPlaneLifecycle` when both are present. | | `isGlobal` | extra | Default `true`. | | `disableGlobalGuard` | extra | Skip the automatic `APP_GUARD` registration. | @@ -325,8 +327,11 @@ non-Better-Auth failures continue through the application's exception pipeline u `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: +nullable IP address and user agent, an authoritative `current` flag, and `redactedFields`. Better +Auth's bearer tokens and user ids never cross the service boundary. Untrusted IP address and user +agent display values are truncated to 255 and 1,024 code units; `redactedFields` lists +`"ipAddress"` then `"userAgent"` when projection occurs, so callers do not treat them as +authoritative: ```ts @Controller("account/sessions") @@ -363,6 +368,112 @@ 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. +### Platform user management + +`BetterAuthUserManagementService` is an application-facing facade over the stock Better Auth +admin plugin. It provides bounded user listing/search/filtering/pagination, reads one user, +updates only `name` and a syntactically valid `email`, assigns bounded role values, bans/unbans, +lists token-free active sessions, revokes a target-owned session by its public id, and revokes all +sessions after first verifying that the user exists. Successful Better Auth responses are +runtime-validated and length-bounded before they cross the service boundary. `emailVerified` is +never caller-controlled; changing to a distinct email resets it to `false` internally. A +same/case-only email update is omitted, so it cannot clear an existing verification while a name +change in the same request still applies. +The configured auth instance must include Better Auth's `admin()` plugin. + +Stock Better Auth accepts profile values wider than this safe facade, including empty/non-string +names, unbounded names/images, and email addresses outside the facade's bounded syntax. These +self-controlled display fields cannot make an account unmanageable. Every managed user includes a +deterministically ordered `redactedFields` array (`name`, `email`, `image`, `banReason`): empty or +non-string names become `name: null`, oversized names and ban reasons are truncated to 256 and +1,024 code units, unsafe emails become `email: null` and are never fabricated or truncated, and +unsafe images become `image: null`. An empty array means no projection occurred. User/session ids, +roles, and dates remain strict authoritative fields; malformed values there fail closed. + +```ts +@Controller("platform/users") +export class PlatformUsersController { + constructor(private readonly users: BetterAuthUserManagementService) {} + + @Get() + list(@RequestHeaders() headers: IncomingHttpHeaders) { + return this.users.list(headers, { limit: 50, offset: 0 }); + } + + @Delete(":userId/sessions/:sessionId") + revokeSession( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("userId") userId: string, + @Param("sessionId") sessionId: string, + ) { + return this.users.revokeSessionById(headers, userId, sessionId); + } +} +``` + +The list API permits one stock Better Auth exact filter at a time: +`{ field: "role", value: "platform_admin" }` or `{ field: "banned", value: true }`. Role values +are structurally bounded but deliberately not application-allowlisted; a controller that accepts +roles from clients must constrain them to its configured admin-plugin roles. Stock Better Auth +1.6.26 catches adapter failures inside `listUsers` and returns `{ users: [], total: 0 }`, so this +facade cannot distinguish that failure from a genuinely empty result. Applications that need an +availability signal must obtain it from database/adapter health monitoring, not from this page. + +Omitting `expiresInSeconds` from `ban()` delegates expiry to the admin plugin's +`defaultBanExpiresIn`. With stock Better Auth configuration (that option also omitted), the ban +has no expiry; a configured plugin default remains authoritative. `BetterAuthGuard` rejects +retained sessions for active bans with `403 BANNED_USER`, even though the stock admin plugin checks +bans only when creating a new session. A ban whose valid expiry is strictly in the past is no +longer enforced; malformed expiry data fails closed. The same denial applies when +`@AllowAnonymous({ resolveSession: true })` or `@OptionalAuth()` resolves an active banned session, +so a public handler never receives that identity as authenticated; a request with no session still +uses the decorators' normal anonymous behavior. + +Better Auth 1.6.26 can retain an old temporary `banExpires` when an expiry-omitted re-ban should +apply the plugin default. When the facade sees an existing expiry, it first uses the public +`adminUpdateUser` API to set `{ banned: true, banExpires: null }`, then calls `banUser` to apply the +requested/plugin-default reason and expiry, all inside one `run("user", ...)` lifecycle. If the +final call fails without a transaction, the account remains permanently banned rather than +failing open. The sequence is fully database-atomic only when the lifecycle coordinator and Better +Auth adapter share its transaction manager as shown below; without that wiring a stricter first +mutation can persist on failure, but there is no intermediate unbanned state. This stale-expiry +correction requires the stock role to grant both `user:ban` and `user:update`: a ban-only role can +perform an ordinary ban, but cannot convert an existing temporary ban to an expiry-omitted ban +through this facade. Roles used for this management surface should grant both permissions. + +Stock `listUserSessions` has no pagination and materializes the adapter's complete result before +the facade receives it. The facade rejects results over 1,000 entries rather than returning an +unbounded payload or silently making a later session id unreachable; `revokeSessionById` therefore +also fails closed above that threshold. `revokeAllSessions` does not enumerate sessions and remains +available. Database-level session retention is still required because this guard cannot prevent +the stock endpoint's initial allocation. + +Managed-user session summaries similarly project IP address and User-Agent to 255 and 1,024 code +units and expose deterministic `redactedFields` (`ipAddress`, then `userAgent`). The private token, +session/user ids, and dates remain strict, so oversized display metadata cannot block a safe-id +single-session revocation. + +Once facade controllers are mounted, close the entire raw admin HTTP namespace: + +```ts +BetterAuthModule.forFeature({ + routePolicies: [BetterAuthUserManagementRoutePolicy], +}); +``` + +The segment-safe `/admin/*` policy returns `403 USER_MANAGEMENT_FACADE_REQUIRED`. It prevents +clients from reaching token-bearing session responses and raw create/delete/password/ +impersonation endpoints; those higher-risk operations are intentionally absent from the facade. +The policy is opt-in and HTTP-only. Direct `auth.api.*`, `BetterAuthService`, or injected raw auth +calls bypass both it and the lifecycle coordinator. + +User mutations use `controlPlaneLifecycle.run("user", userId, operation)` when configured. That +serializes changes to one user, but it cannot by itself protect a global invariant such as “at +least one platform administrator remains”: an application enforcing that rule must re-read and +mutate under one shared `run("platform", "administrators", ...)` operation. Database transactions +also cannot atomically roll back secondary storage, hook side effects, or already-issued client +cookies. + ### Organization control plane `BetterAuthOrganizationService` is the application-facing lifecycle facade for the stock @@ -373,33 +484,51 @@ validated public user projection, and returned invitations are runtime-validated 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: +Stock profile fields cannot make a membership unmanageable. Each returned member's nested `user` +has `name: string | null`, `email: string | null`, `image: string | null`, and a deterministic +`redactedFields` array (`name`, `email`, then `image`). Empty/non-string names become `null`, names +over 256 code units are safely truncated, emails outside the facade's 320-code-unit syntax become +`null` and are never fabricated or truncated, and non-string/images over 4,096 code units become +`null`. The authoritative user/member/organization ids, role, and membership date remain strict, +so projected display data cannot block listing, role changes, removals, or leaving an organization. + +Every lifecycle mutation prefers the optional shared `controlPlaneLifecycle` coordinator and +falls back to the legacy `organizationLifecycle` option. 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 +control-plane coordinator and give its exact `getManager` function to the Better Auth adapter so +both execute inside the same transaction and namespaced advisory lock: ```ts import { betterAuth } from "better-auth"; import { organization } from "better-auth/plugins"; import { - createTypeormBetterAuthOrganizationLifecycleCoordinator, + createTypeormBetterAuthControlPlaneLifecycleCoordinator, typeormAdapter, } from "@nestm/better-auth/typeorm"; -const organizationLifecycle = createTypeormBetterAuthOrganizationLifecycleCoordinator(dataSource); +const controlPlaneLifecycle = createTypeormBetterAuthControlPlaneLifecycleCoordinator(dataSource); const auth = betterAuth({ database: typeormAdapter(dataSource, { transaction: true, - getManager: organizationLifecycle.getManager, + getManager: controlPlaneLifecycle.getManager, }), plugins: [organization()], }); -BetterAuthModule.forRoot({ auth, organizationLifecycle }); +BetterAuthModule.forRoot({ auth, controlPlaneLifecycle }); ``` +`createTypeormBetterAuthOrganizationLifecycleCoordinator()` and the `organizationLifecycle` +module option remain available for existing organization-only applications. + +During the scoped-key rolling-upgrade compatibility window, organization operations acquire both +the legacy raw organization id and `organization:` advisory-lock keys in deterministic sorted +order. This keeps new processes serialized with older processes that know only the raw key. It +temporarily adds one advisory lock per organization operation and may preserve legacy +over-serialization until the compatibility acquisition is removed in a later release. + After the application's organization and account facade controllers are mounted, opt in to the raw-route policy: @@ -412,11 +541,10 @@ BetterAuthModule.forFeature({ 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 lifecycle coordinator, so code that needs the guarantee must use the corresponding facade. +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 @@ -454,6 +582,13 @@ supported. Each policy receives a normalized context containing `method`, `url`, `pathname`, `authPath`, Web `headers`, parsed `body`, and byte-exact `rawBody` when it is recoverable. +The raw request target is parsed once with WHATWG URL semantics before base-path selection and +policy matching, and that canonical pathname is reused for both decisions. Encoded dot segments +such as `%2e%2e`, `.%2e`, and `%2e.` therefore cannot traverse into a protected auth route after a +policy has seen a different path. Malformed request targets are rejected with +`400 INVALID_REQUEST_TARGET`; the original target remains available as `context.url` and is passed +unchanged to Better Auth after policy evaluation. + ```ts @AuthRoutePolicy({ path: "/sign-up/*", order: -10 }) @Injectable() diff --git a/src/better-auth.module.ts b/src/better-auth.module.ts index 39fec07..60497a9 100644 --- a/src/better-auth.module.ts +++ b/src/better-auth.module.ts @@ -22,6 +22,7 @@ import { betterAuthInstanceProvider } from "./providers/auth-instance.provider.t import { BetterAuthService } from "./services/better-auth.service.ts"; import { BetterAuthSessionService } from "./services/better-auth-session.service.ts"; import { BetterAuthOrganizationService } from "./services/better-auth-organization.service.ts"; +import { BetterAuthUserManagementService } from "./services/better-auth-user-management.service.ts"; import { BetterAuthGuard } from "./guards/better-auth.guard.ts"; import { BetterAuthHookRegistry } from "./hooks/hook-registry.service.ts"; import { BetterAuthDatabaseHookRegistry } from "./hooks/database-hook-registry.service.ts"; @@ -76,6 +77,7 @@ function assertRoutePolicyClass(candidate: Type): void { BetterAuthService, BetterAuthSessionService, BetterAuthOrganizationService, + BetterAuthUserManagementService, BetterAuthGuard, BetterAuthHookRegistry, BetterAuthDatabaseHookRegistry, @@ -91,6 +93,7 @@ function assertRoutePolicyClass(candidate: Type): void { BetterAuthService, BetterAuthSessionService, BetterAuthOrganizationService, + BetterAuthUserManagementService, BetterAuthGuard, BetterAuthHookRegistry, BetterAuthDatabaseHookRegistry, diff --git a/src/guards/auth-errors.ts b/src/guards/auth-errors.ts index 01d5607..0bc40db 100644 --- a/src/guards/auth-errors.ts +++ b/src/guards/auth-errors.ts @@ -30,15 +30,20 @@ export async function createAuthError( kind: AuthContextKind, status: AuthErrorStatus, message?: string, + code?: string, ): Promise { + const statusCode = status === "UNAUTHORIZED" ? 401 : 403; + const structuredError = code ? { statusCode, code, message: message ?? status } : undefined; if (kind === "ws") { const WsException = await getWsException(); - return new WsException(message ?? status); + return new WsException(structuredError ?? message ?? status); } if (kind === "rpc") { - return new Error(message ?? status); + return structuredError + ? Object.assign(new Error(structuredError.message), structuredError) + : new Error(message ?? status); } return status === "UNAUTHORIZED" - ? new UnauthorizedException(message) - : new ForbiddenException(message); + ? new UnauthorizedException(structuredError ?? message) + : new ForbiddenException(structuredError ?? message); } diff --git a/src/guards/better-auth.guard.ts b/src/guards/better-auth.guard.ts index 8808cea..d612fdc 100644 --- a/src/guards/better-auth.guard.ts +++ b/src/guards/better-auth.guard.ts @@ -32,6 +32,21 @@ interface GuardSession { type ReflectTarget = Parameters[1]; +function hasActiveBan(user: GuardSession["user"], now = Date.now()): boolean { + if (user?.banned !== true) return false; + const value = user.banExpires; + if (value === undefined || value === null) return true; + const expiration = + value instanceof Date + ? value.getTime() + : typeof value === "string" || typeof value === "number" + ? new Date(value).getTime() + : Number.NaN; + // Match Better Auth's strict `< Date.now()` expiry rule and fail closed for + // malformed adapter values instead of accidentally re-enabling the account. + return !Number.isFinite(expiration) || expiration >= now; +} + function matchesRequiredRole( role: string | readonly string[] | null | undefined, required: readonly string[], @@ -129,6 +144,9 @@ export class BetterAuthGuard implements CanActivate { } } + if (session && hasActiveBan(session.user)) { + throw await createAuthError(kind, "FORBIDDEN", "User is banned.", "BANNED_USER"); + } if (anonymous) return true; if (!session) { diff --git a/src/index.ts b/src/index.ts index c53f005..1fa7fd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,10 @@ export type { } from "./interfaces/better-auth-module-options.interface.ts"; export type { BetterAuthFeatureOptions } from "./interfaces/better-auth-feature-options.interface.ts"; export type { BetterAuthOptionsFactory } from "./interfaces/better-auth-options-factory.interface.ts"; +export type { + BetterAuthControlPlaneLifecycleCoordinator, + BetterAuthControlPlaneLifecycleScope, +} from "./interfaces/better-auth-control-plane-lifecycle.interface.ts"; export type { BetterAuthOrganizationLifecycleCoordinator } from "./interfaces/better-auth-organization-lifecycle.interface.ts"; export { deny, @@ -69,6 +73,7 @@ export type { BetterAuthApiInvocation } from "./services/better-auth.service.ts" export { BetterAuthSessionService, type BetterAuthSessionBulkRevocationResult, + type BetterAuthSessionRedactedField, type BetterAuthSessionRevocationResult, type BetterAuthSessionSummary, } from "./services/better-auth-session.service.ts"; @@ -80,9 +85,28 @@ export { type BetterAuthOrganizationMember, type BetterAuthOrganizationMemberList, type BetterAuthOrganizationMemberListOptions, + type BetterAuthOrganizationMemberUserRedactedField, type BetterAuthOrganizationRequestHeaders, type BetterAuthReceivedOrganizationInvitation, } from "./services/better-auth-organization.service.ts"; +export { + BetterAuthUserManagementService, + type BetterAuthManagedUser, + type BetterAuthManagedUserBanOptions, + type BetterAuthManagedUserListFilter, + type BetterAuthManagedUserListOptions, + type BetterAuthManagedUserPage, + type BetterAuthManagedUserProfileUpdate, + type BetterAuthManagedUserRedactedField, + type BetterAuthManagedUserSearchField, + type BetterAuthManagedUserSearchOperator, + type BetterAuthManagedUserSession, + type BetterAuthManagedUserSessionBulkRevocationResult, + type BetterAuthManagedUserSessionRedactedField, + type BetterAuthManagedUserSessionRevocationResult, + type BetterAuthManagedUserSortDirection, + type BetterAuthManagedUserSortField, +} from "./services/better-auth-user-management.service.ts"; export { mapBetterAuthApiError, normalizeBetterAuthHeaders, @@ -98,6 +122,10 @@ export { BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS, BetterAuthOrganizationControlPlaneRoutePolicy, } from "./policies/organization-control-plane-route-policy.ts"; +export { + BETTER_AUTH_USER_MANAGEMENT_PATHS, + BetterAuthUserManagementRoutePolicy, +} from "./policies/user-management-route-policy.ts"; export { createAuthError, type AuthErrorStatus } from "./guards/auth-errors.ts"; export { MUTATION_ORIGIN_GUARD_OPTIONS, diff --git a/src/interfaces/better-auth-control-plane-lifecycle.interface.ts b/src/interfaces/better-auth-control-plane-lifecycle.interface.ts new file mode 100644 index 0000000..6a4738a --- /dev/null +++ b/src/interfaces/better-auth-control-plane-lifecycle.interface.ts @@ -0,0 +1,18 @@ +/** Namespaces serialized Better Auth control-plane mutations. */ +export type BetterAuthControlPlaneLifecycleScope = "organization" | "user" | "platform"; + +/** + * Coordinates control-plane mutations that must observe one serialized view + * of a resource's state. + * + * Implementations must keep the callback inside the same transaction/context + * used by the configured Better Auth database adapter. Scope is part of the + * resource identity so equal organization and user ids never share a lock. + */ +export interface BetterAuthControlPlaneLifecycleCoordinator { + run( + scope: BetterAuthControlPlaneLifecycleScope, + resourceId: string, + operation: () => Promise, + ): Promise; +} diff --git a/src/interfaces/better-auth-module-options.interface.ts b/src/interfaces/better-auth-module-options.interface.ts index 5fc33e3..b2b4f50 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 { BetterAuthControlPlaneLifecycleCoordinator } from "./better-auth-control-plane-lifecycle.interface.ts"; import type { BetterAuthOrganizationLifecycleCoordinator } from "./better-auth-organization-lifecycle.interface.ts"; /** @@ -55,9 +56,18 @@ interface BetterAuthModuleCommonOptions { /** Maximum bytes buffered from an untouched stream for route-policy body inspection. Default 1 MiB. */ routePolicyBodyLimit?: number; interop?: BetterAuthInteropOptions; + /** + * Optional, shared serialization boundary for organization and user + * control-plane mutations. Prefer this over the legacy organization-only + * coordinator when more than one control-plane service is enabled. + */ + controlPlaneLifecycle?: BetterAuthControlPlaneLifecycleCoordinator; /** * Optional serialization boundary for organization membership and invitation * mutations made through `BetterAuthOrganizationService`. + * + * @deprecated Prefer `controlPlaneLifecycle`, which uses one transaction + * context for every Better Auth control-plane service. */ organizationLifecycle?: BetterAuthOrganizationLifecycleCoordinator; } diff --git a/src/mount/mount.service.ts b/src/mount/mount.service.ts index 444a192..c132cdb 100644 --- a/src/mount/mount.service.ts +++ b/src/mount/mount.service.ts @@ -18,6 +18,7 @@ import { } from "./body-recovery.ts"; import { resolveCorsHandler } from "./cors.ts"; import { + canonicalizeRequestTarget, getNodeRequest, getNodeResponse, matchesBasePath, @@ -75,12 +76,28 @@ export class BetterAuthMountService { httpAdapter.use( (req: AdapterRequest, res: AdapterResponse, next: (error?: unknown) => void) => { - if (!matchesBasePath(req, basePath)) { + const nodeReq = getNodeRequest(req); + const nodeRes = getNodeResponse(res); + const requestTarget = canonicalizeRequestTarget(req); + if (!requestTarget) { + void writeRoutePolicyResponse( + Response.json( + { + statusCode: 400, + code: "INVALID_REQUEST_TARGET", + message: "Request target is invalid.", + }, + { status: 400 }, + ), + nodeRes, + (nodeReq.method ?? "GET").toUpperCase(), + ).catch(next); + return; + } + if (!matchesBasePath(requestTarget.pathname, basePath)) { next(); return; } - const nodeReq = getNodeRequest(req); - const nodeRes = getNodeResponse(res); if (cors?.(nodeReq, nodeRes)) return; const execute = async (): Promise => { if (routePolicy || this.routePolicies.size > 0) { @@ -99,7 +116,12 @@ export class BetterAuthMountService { ); return; } - const context = createRoutePolicyContext(req, nodeReq, basePath, recoveredBody); + const context = createRoutePolicyContext( + nodeReq, + basePath, + recoveredBody, + requestTarget, + ); const policyResponse = await this.routePolicies.run(context, routePolicy); if (policyResponse instanceof Response) { await writeRoutePolicyResponse(policyResponse, nodeRes, context.method); diff --git a/src/mount/request-utils.ts b/src/mount/request-utils.ts index f9abcaa..bd66f2a 100644 --- a/src/mount/request-utils.ts +++ b/src/mount/request-utils.ts @@ -6,19 +6,38 @@ export type AdapterRequest = any; // oxlint-disable-next-line typescript/no-explicit-any export type AdapterResponse = any; +/** One WHATWG-normalized view of the request target, reused by mount and policies. */ +export interface CanonicalRequestTarget { + /** The original request target, including its query string. */ + readonly url: string; + /** The WHATWG URL pathname seen by Better Auth's downstream Fetch router. */ + readonly pathname: string; +} + export function getRequestUrl(req: AdapterRequest): string { - return req?.originalUrl ?? req?.url ?? req?.raw?.url ?? ""; + return req?.raw?.url ?? req?.url ?? req?.originalUrl ?? ""; } -export function getRequestPath(req: AdapterRequest): string { +/** + * Apply the same WHATWG URL parsing that the downstream Node-to-Fetch bridge + * applies before Better Auth routes a request. In particular, encoded dot + * segments are removed here before any base-path or route-policy decision. + */ +export function canonicalizeRequestTarget(req: AdapterRequest): CanonicalRequestTarget | undefined { const url = getRequestUrl(req); - const queryIndex = url.indexOf("?"); - return queryIndex === -1 ? url : url.slice(0, queryIndex); + if (typeof url !== "string" || url.length === 0) return undefined; + try { + return { + url, + pathname: new URL(`http://better-auth.invalid${url}`).pathname, + }; + } catch { + return undefined; + } } -export function matchesBasePath(req: AdapterRequest, basePath: string): boolean { - const path = getRequestPath(req); - return path === basePath || path.startsWith(`${basePath}/`); +export function matchesBasePath(pathname: string, basePath: string): boolean { + return pathname === basePath || pathname.startsWith(`${basePath}/`); } /** Unwraps Fastify's `req.raw`; Express requests are already Node requests. */ diff --git a/src/mount/route-policy.ts b/src/mount/route-policy.ts index 4fb7223..fd105a8 100644 --- a/src/mount/route-policy.ts +++ b/src/mount/route-policy.ts @@ -1,7 +1,6 @@ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http"; import type { BetterAuthRoutePolicyContext } from "../policies/route-policy.ts"; -import type { AdapterRequest } from "./request-utils.ts"; -import { getRequestPath, getRequestUrl } from "./request-utils.ts"; +import type { CanonicalRequestTarget } from "./request-utils.ts"; import type { RecoveredBody } from "./body-recovery.ts"; function toWebHeaders(headers: IncomingHttpHeaders): Headers { @@ -23,18 +22,16 @@ function resolveAuthPath(pathname: string, basePath: string): string { } export function createRoutePolicyContext( - frameworkReq: AdapterRequest, nodeReq: IncomingMessage, basePath: string, recoveredBody: RecoveredBody, + target: CanonicalRequestTarget, ): BetterAuthRoutePolicyContext { - const url = getRequestUrl(frameworkReq); - const pathname = getRequestPath(frameworkReq); return { method: (nodeReq.method ?? "GET").toUpperCase(), - url, - pathname, - authPath: resolveAuthPath(pathname, basePath), + url: target.url, + pathname: target.pathname, + authPath: resolveAuthPath(target.pathname, basePath), headers: toWebHeaders(nodeReq.headers), body: recoveredBody.body, rawBody: recoveredBody.rawBody, diff --git a/src/policies/user-management-route-policy.ts b/src/policies/user-management-route-policy.ts new file mode 100644 index 0000000..7e6ea16 --- /dev/null +++ b/src/policies/user-management-route-policy.ts @@ -0,0 +1,26 @@ +import { HttpStatus, Injectable } from "@nestjs/common"; +import { AuthRoutePolicy } from "../decorators/route-policy.decorator.ts"; +import { deny, type BetterAuthRoutePolicyHandler } from "./route-policy.ts"; + +/** + * Segment-safe wildcard for every HTTP route owned by Better Auth's admin + * plugin, including routes introduced by a compatible future release. + */ +export const BETTER_AUTH_USER_MANAGEMENT_PATHS = ["/admin/*"] as const; + +/** + * Opt-in policy that closes Better Auth's raw admin HTTP namespace after an + * application exposes a user-management facade backed by + * {@link BetterAuthUserManagementService}. + */ +@AuthRoutePolicy({ path: BETTER_AUTH_USER_MANAGEMENT_PATHS, order: -100 }) +@Injectable() +export class BetterAuthUserManagementRoutePolicy implements BetterAuthRoutePolicyHandler { + evaluate() { + return deny(HttpStatus.FORBIDDEN, { + statusCode: HttpStatus.FORBIDDEN, + code: "USER_MANAGEMENT_FACADE_REQUIRED", + message: "Use the application's user-management endpoints.", + }); + } +} diff --git a/src/services/better-auth-organization.service.ts b/src/services/better-auth-organization.service.ts index 49b63be..14080b4 100644 --- a/src/services/better-auth-organization.service.ts +++ b/src/services/better-auth-organization.service.ts @@ -35,6 +35,12 @@ interface InternalSessionAdapter { type OrganizationApiOperation = (input: unknown) => Promise; +const MAX_MEMBER_NAME_LENGTH = 256; +const MAX_MEMBER_EMAIL_LENGTH = 320; +const MAX_MEMBER_IMAGE_LENGTH = 4_096; + +export type BetterAuthOrganizationMemberUserRedactedField = "name" | "email" | "image"; + /** Public, normalized organization member returned by the lifecycle facade. */ export interface BetterAuthOrganizationMember { readonly id: string; @@ -44,9 +50,11 @@ export interface BetterAuthOrganizationMember { readonly createdAt: Date; readonly user: { readonly id: string; - readonly name: string; - readonly email: string; + readonly name: string | null; + readonly email: string | null; readonly image: string | null; + /** Display fields projected or omitted to keep this response bounded. */ + readonly redactedFields: readonly BetterAuthOrganizationMemberUserRedactedField[]; }; } @@ -119,19 +127,6 @@ function requiredString(record: Record, field: string, label: s 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 = @@ -174,17 +169,91 @@ function basicMember(value: unknown): Omit }; } +function truncateDisplayString(value: string, maximumLength: number): string { + let result = value.slice(0, maximumLength); + const finalCodeUnit = result.charCodeAt(result.length - 1); + const nextCodeUnit = value.charCodeAt(result.length); + if ( + finalCodeUnit >= 0xd800 && + finalCodeUnit <= 0xdbff && + nextCodeUnit >= 0xdc00 && + nextCodeUnit <= 0xdfff + ) { + result = result.slice(0, -1); + } + return result; +} + +function isFacadeEmail(value: string): boolean { + const separator = value.indexOf("@"); + if (separator <= 0 || separator !== value.lastIndexOf("@")) return false; + + const localPart = value.slice(0, separator); + const domain = value.slice(separator + 1); + if ( + localPart.length > 64 || + domain.length === 0 || + domain.length > 253 || + localPart.startsWith(".") || + localPart.endsWith(".") || + localPart.includes("..") || + !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(localPart) + ) { + return false; + } + + return domain + .split(".") + .every( + (label) => + label.length > 0 && + label.length <= 63 && + /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label), + ); +} + function publicMember(value: unknown): BetterAuthOrganizationMember { const member = requiredRecord(value, "organization member"); const normalized = basicMember(member); const user = requiredRecord(member.user, "organization member.user"); + const redactedFields: BetterAuthOrganizationMemberUserRedactedField[] = []; + const sourceName = user.name; + let name: string | null = typeof sourceName === "string" ? sourceName : null; + if (typeof sourceName !== "string" || sourceName.length === 0) { + redactedFields.push("name"); + name = null; + } else if (sourceName.length > MAX_MEMBER_NAME_LENGTH) { + redactedFields.push("name"); + name = truncateDisplayString(sourceName, MAX_MEMBER_NAME_LENGTH); + } + const sourceEmail = user.email; + let email: string | null = typeof sourceEmail === "string" ? sourceEmail : null; + if ( + typeof sourceEmail !== "string" || + sourceEmail.length === 0 || + sourceEmail.length > MAX_MEMBER_EMAIL_LENGTH || + !isFacadeEmail(sourceEmail) + ) { + redactedFields.push("email"); + email = null; + } + const sourceImage = user.image; + let image: string | null = typeof sourceImage === "string" ? sourceImage : null; + if (sourceImage !== undefined && sourceImage !== null && typeof sourceImage !== "string") { + redactedFields.push("image"); + image = null; + } else if (typeof sourceImage === "string" && sourceImage.length > MAX_MEMBER_IMAGE_LENGTH) { + redactedFields.push("image"); + image = null; + } 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"), + name, + email, + image, + redactedFields, }, }; } @@ -658,7 +727,11 @@ export class BetterAuthOrganizationService(organizationId: string, operation: () => Promise): Promise { - return this.moduleOptions.organizationLifecycle?.run(organizationId, operation) ?? operation(); + return ( + this.moduleOptions.controlPlaneLifecycle?.run("organization", organizationId, operation) ?? + this.moduleOptions.organizationLifecycle?.run(organizationId, operation) ?? + operation() + ); } private async readOrganizationInvitations( diff --git a/src/services/better-auth-session.service.ts b/src/services/better-auth-session.service.ts index 276453c..8bd6a24 100644 --- a/src/services/better-auth-session.service.ts +++ b/src/services/better-auth-session.service.ts @@ -4,6 +4,13 @@ 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 MAX_SESSION_IDENTIFIER_LENGTH = 1_024; +const MAX_SESSION_TOKEN_LENGTH = 4_096; +const MAX_IP_ADDRESS_LENGTH = 255; +const MAX_USER_AGENT_LENGTH = 1_024; + +export type BetterAuthSessionRedactedField = "ipAddress" | "userAgent"; + /** Token-free session information safe to return from an application API. */ export interface BetterAuthSessionSummary { readonly id: string; @@ -13,6 +20,8 @@ export interface BetterAuthSessionSummary { readonly ipAddress: string | null; readonly userAgent: string | null; readonly current: boolean; + /** Display metadata truncated to keep this response bounded. */ + readonly redactedFields: readonly BetterAuthSessionRedactedField[]; } /** Result of revoking one caller-owned session by its public-safe identifier. */ @@ -46,6 +55,7 @@ interface PrivateSessionRecord { readonly expiresAt: Date; readonly ipAddress: string | null; readonly userAgent: string | null; + readonly redactedFields: readonly BetterAuthSessionRedactedField[]; } interface OwnedSessions { @@ -94,21 +104,44 @@ function coreSessionApi(value: unknown): CoreSessionApi { }; } -function requiredString(record: Record, field: string): string { +function requiredString( + record: Record, + field: string, + maximumLength: number, +): string { const value = record[field]; - if (typeof value !== "string" || value.length === 0) { + if (typeof value !== "string" || value.length === 0 || value.length > maximumLength) { 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}'.`); +function truncateDisplayString(value: string, maximumLength: number): string { + let result = value.slice(0, maximumLength); + const finalCodeUnit = result.charCodeAt(result.length - 1); + const nextCodeUnit = value.charCodeAt(result.length); + if ( + finalCodeUnit >= 0xd800 && + finalCodeUnit <= 0xdbff && + nextCodeUnit >= 0xdc00 && + nextCodeUnit <= 0xdfff + ) { + result = result.slice(0, -1); } - return value; + return result; +} + +function projectedOptionalString( + record: Record, + field: string, + maximumLength: number, +): { readonly redacted: boolean; readonly value: string | null } { + const value = record[field]; + if (value === null || value === undefined) return { redacted: false, value: null }; + if (typeof value !== "string") return { redacted: true, value: null }; + return value.length > maximumLength + ? { redacted: true, value: truncateDisplayString(value, maximumLength) } + : { redacted: false, value }; } function requiredDate(record: Record, field: string): Date { @@ -126,14 +159,20 @@ function requiredDate(record: Record, field: string): Date { function privateSession(value: unknown): PrivateSessionRecord { if (!isRecord(value)) throw new TypeError("Better Auth returned an invalid session."); + const redactedFields: BetterAuthSessionRedactedField[] = []; + const ipAddress = projectedOptionalString(value, "ipAddress", MAX_IP_ADDRESS_LENGTH); + if (ipAddress.redacted) redactedFields.push("ipAddress"); + const userAgent = projectedOptionalString(value, "userAgent", MAX_USER_AGENT_LENGTH); + if (userAgent.redacted) redactedFields.push("userAgent"); return { - id: requiredString(value, "id"), - token: requiredString(value, "token"), + id: requiredString(value, "id", MAX_SESSION_IDENTIFIER_LENGTH), + token: requiredString(value, "token", MAX_SESSION_TOKEN_LENGTH), createdAt: requiredDate(value, "createdAt"), updatedAt: requiredDate(value, "updatedAt"), expiresAt: requiredDate(value, "expiresAt"), - ipAddress: optionalString(value, "ipAddress"), - userAgent: optionalString(value, "userAgent"), + ipAddress: ipAddress.value, + userAgent: userAgent.value, + redactedFields, }; } @@ -142,7 +181,7 @@ function currentSessionId(value: unknown): string | undefined { if (!isRecord(value) || !isRecord(value.session)) { throw new TypeError("Better Auth returned an invalid current session."); } - return requiredString(value.session, "id"); + return requiredString(value.session, "id", MAX_SESSION_IDENTIFIER_LENGTH); } function privateSessions(value: unknown): readonly PrivateSessionRecord[] { @@ -200,6 +239,7 @@ export class BetterAuthSessionService { ipAddress: session.ipAddress, userAgent: session.userAgent, current: session.id === owned.currentSessionId, + redactedFields: session.redactedFields, })); } diff --git a/src/services/better-auth-user-management.service.ts b/src/services/better-auth-user-management.service.ts new file mode 100644 index 0000000..60ee119 --- /dev/null +++ b/src/services/better-auth-user-management.service.ts @@ -0,0 +1,945 @@ +import { HttpException, HttpStatus, Inject, Injectable } from "@nestjs/common"; +import type { BetterAuthModuleOptions } from "../interfaces/better-auth-module-options.interface.ts"; +import type { AnyAuth, RegisteredAuth } from "../types/auth.types.ts"; +import { BETTER_AUTH_MODULE_OPTIONS } from "../better-auth.tokens.ts"; +import type { BetterAuthApiHeaders } from "./better-auth-api-invocation.ts"; +import { BetterAuthService } from "./better-auth.service.ts"; + +const DEFAULT_LIST_LIMIT = 50; +const MAX_LIST_LIMIT = 100; +const MAX_LIST_OFFSET = 1_000_000; +const MAX_IDENTIFIER_LENGTH = 1_024; +const MAX_SEARCH_LENGTH = 256; +const MAX_NAME_LENGTH = 256; +const MAX_EMAIL_LENGTH = 320; +const MAX_ROLE_LENGTH = 128; +const MAX_ROLES = 16; +const MAX_BAN_REASON_LENGTH = 1_024; +const MAX_IMAGE_LENGTH = 4_096; +const MAX_SESSION_TOKEN_LENGTH = 4_096; +const MAX_IP_ADDRESS_LENGTH = 255; +const MAX_USER_AGENT_LENGTH = 1_024; +const MAX_SESSION_RESULTS = 1_000; +const MAX_BAN_SECONDS = 365 * 24 * 60 * 60; + +const USER_MANAGEMENT_API_METHODS = [ + "getUser", + "listUsers", + "adminUpdateUser", + "setRole", + "banUser", + "unbanUser", + "listUserSessions", + "revokeUserSession", + "revokeUserSessions", +] as const; + +type UserManagementApiMethod = (typeof USER_MANAGEMENT_API_METHODS)[number]; +type UserManagementApiOperation = (input: unknown) => unknown; + +interface UserManagementApi { + call(method: UserManagementApiMethod, input: unknown): Promise; +} + +export type BetterAuthManagedUserRedactedField = "name" | "email" | "image" | "banReason"; + +/** Public, normalized user returned by the Better Auth admin plugin facade. */ +export interface BetterAuthManagedUser { + readonly id: string; + readonly name: string | null; + readonly email: string | null; + readonly emailVerified: boolean; + readonly image: string | null; + readonly roles: readonly string[]; + readonly banned: boolean; + readonly banReason: string | null; + readonly banExpiresAt: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; + /** Display fields projected or omitted to keep this response bounded. */ + readonly redactedFields: readonly BetterAuthManagedUserRedactedField[]; +} + +export type BetterAuthManagedUserSearchField = "email" | "name"; +export type BetterAuthManagedUserSearchOperator = "contains" | "starts_with" | "ends_with"; +export type BetterAuthManagedUserSortField = "email" | "name" | "createdAt" | "updatedAt"; +export type BetterAuthManagedUserSortDirection = "asc" | "desc"; + +/** One safe, exact filter supported by stock Better Auth's user list API. */ +export type BetterAuthManagedUserListFilter = + | { readonly field: "role"; readonly value: string } + | { readonly field: "banned"; readonly value: boolean }; + +/** Bounded search, filter, sort, and offset pagination for {@link BetterAuthUserManagementService.list}. */ +export interface BetterAuthManagedUserListOptions { + readonly limit?: number | undefined; + readonly offset?: number | undefined; + readonly search?: string | undefined; + readonly searchField?: BetterAuthManagedUserSearchField | undefined; + readonly searchOperator?: BetterAuthManagedUserSearchOperator | undefined; + readonly filter?: BetterAuthManagedUserListFilter | undefined; + readonly sortBy?: BetterAuthManagedUserSortField | undefined; + readonly sortDirection?: BetterAuthManagedUserSortDirection | undefined; +} + +export interface BetterAuthManagedUserPage { + readonly users: readonly BetterAuthManagedUser[]; + readonly total: number; + readonly limit: number; + readonly offset: number; +} + +/** Profile fields intentionally safe for an ordinary platform user-management screen. */ +export interface BetterAuthManagedUserProfileUpdate { + readonly name?: string | undefined; + readonly email?: string | undefined; +} + +export interface BetterAuthManagedUserBanOptions { + readonly reason?: string | undefined; + /** + * Positive seconds, bounded to one year. Omit to let the Better Auth admin + * plugin apply its configured `defaultBanExpiresIn` (no expiry by stock default). + */ + readonly expiresInSeconds?: number | undefined; +} + +export type BetterAuthManagedUserSessionRedactedField = "ipAddress" | "userAgent"; + +/** Token-free active session information for a managed user. */ +export interface BetterAuthManagedUserSession { + readonly id: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly expiresAt: Date; + readonly ipAddress: string | null; + readonly userAgent: string | null; + readonly impersonated: boolean; + /** Display metadata truncated to keep this response bounded. */ + readonly redactedFields: readonly BetterAuthManagedUserSessionRedactedField[]; +} + +export interface BetterAuthManagedUserSessionRevocationResult { + readonly success: boolean; + readonly revokedSessionId: string; +} + +export interface BetterAuthManagedUserSessionBulkRevocationResult { + readonly success: boolean; +} + +interface PrivateManagedUserSession extends BetterAuthManagedUserSession { + readonly token: string; +} + +interface NormalizedListOptions { + readonly limit: number; + readonly offset: number; + readonly query: Record; +} + +interface NormalizedProfileUpdate { + readonly name?: string; + readonly email?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isInputRecord(value: unknown): value is Record { + return isRecord(value) && !Array.isArray(value) && !(value instanceof Date); +} + +function containsControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 31 || code === 127) return true; + } + return false; +} + +function invalidInput(code: string, message: string): HttpException { + return new HttpException( + { statusCode: HttpStatus.BAD_REQUEST, code, message }, + HttpStatus.BAD_REQUEST, + ); +} + +function invalidResponse(message: string): HttpException { + return new HttpException( + { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + code: "INVALID_BETTER_AUTH_RESPONSE", + message, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); +} + +function sessionNotFound(): HttpException { + return new HttpException( + { statusCode: HttpStatus.NOT_FOUND, code: "SESSION_NOT_FOUND", message: "Session not found." }, + HttpStatus.NOT_FOUND, + ); +} + +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, + maximumLength: number, +): string { + const value = record[field]; + if (typeof value !== "string" || value.length === 0 || value.length > maximumLength) { + throw invalidResponse(`Better Auth returned an invalid ${label}.${field}.`); + } + return value; +} + +function truncateDisplayString(value: string, maximumLength: number): string { + let result = value.slice(0, maximumLength); + const finalCodeUnit = result.charCodeAt(result.length - 1); + const nextCodeUnit = value.charCodeAt(result.length); + if ( + finalCodeUnit >= 0xd800 && + finalCodeUnit <= 0xdbff && + nextCodeUnit >= 0xdc00 && + nextCodeUnit <= 0xdfff + ) { + result = result.slice(0, -1); + } + return result; +} + +function projectedOptionalString( + record: Record, + field: string, + maximumLength: number, +): { readonly redacted: boolean; readonly value: string | null } { + const value = record[field]; + if (value === undefined || value === null) return { redacted: false, value: null }; + if (typeof value !== "string") return { redacted: true, value: null }; + return value.length > maximumLength + ? { redacted: true, value: truncateDisplayString(value, maximumLength) } + : { redacted: false, value }; +} + +function requiredBoolean(record: Record, field: string, label: string): boolean { + const value = record[field]; + if (typeof value !== "boolean") { + 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 optionalDate(record: Record, field: string, label: string): Date | null { + const value = record[field]; + if (value === undefined || value === null) return null; + return requiredDate(record, field, label); +} + +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 requireIdentifier(value: string, label: "user" | "session"): string { + if ( + typeof value !== "string" || + value.trim().length === 0 || + value.length > MAX_IDENTIFIER_LENGTH + ) { + throw invalidInput( + label === "user" ? "INVALID_USER_ID" : "INVALID_SESSION_ID", + `${label === "user" ? "User" : "Session"} id must be a non-empty string no longer than ${MAX_IDENTIFIER_LENGTH} characters.`, + ); + } + return value; +} + +function storedRoles(value: string): readonly string[] { + const roles = value.split(",").map((role) => role.trim()); + if ( + roles.length === 0 || + roles.length > MAX_ROLES || + roles.some( + (role) => + role.length === 0 || role.length > MAX_ROLE_LENGTH || containsControlCharacter(role), + ) + ) { + throw invalidResponse("Better Auth returned an invalid managed user.role."); + } + return [...new Set(roles)]; +} + +function inputRoles(value: string | readonly string[]): readonly string[] { + const candidates = typeof value === "string" ? [value] : value; + if (!Array.isArray(candidates) || candidates.length === 0 || candidates.length > MAX_ROLES) { + throw invalidInput("INVALID_ROLES", `Roles must contain between 1 and ${MAX_ROLES} values.`); + } + const roles = candidates.map((candidate) => { + if (typeof candidate !== "string") { + throw invalidInput("INVALID_ROLES", "Every role must be a string."); + } + const role = candidate.trim(); + if ( + role.length === 0 || + role.length > MAX_ROLE_LENGTH || + role.includes(",") || + containsControlCharacter(role) + ) { + throw invalidInput( + "INVALID_ROLES", + `Every role must be non-empty, comma/control-character-free, and no longer than ${MAX_ROLE_LENGTH} characters.`, + ); + } + return role; + }); + return [...new Set(roles)]; +} + +function isFacadeEmail(value: string): boolean { + const separator = value.indexOf("@"); + if (separator <= 0 || separator !== value.lastIndexOf("@")) return false; + + const localPart = value.slice(0, separator); + const domain = value.slice(separator + 1); + if ( + localPart.length > 64 || + domain.length === 0 || + domain.length > 253 || + localPart.startsWith(".") || + localPart.endsWith(".") || + localPart.includes("..") || + !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(localPart) + ) { + return false; + } + + return domain + .split(".") + .every( + (label) => + label.length > 0 && + label.length <= 63 && + /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label), + ); +} + +function publicUser(value: unknown): BetterAuthManagedUser { + const user = requiredRecord(value, "managed user"); + const redactedFields: BetterAuthManagedUserRedactedField[] = []; + const bannedValue = user.banned; + if (typeof bannedValue !== "boolean" && bannedValue !== null) { + throw invalidResponse("Better Auth returned an invalid managed user.banned."); + } + const sourceName = user.name; + let name: string | null = typeof sourceName === "string" ? sourceName : null; + if (typeof sourceName !== "string" || sourceName.length === 0) { + redactedFields.push("name"); + name = null; + } else if (sourceName.length > MAX_NAME_LENGTH) { + redactedFields.push("name"); + name = truncateDisplayString(sourceName, MAX_NAME_LENGTH); + } + const sourceEmail = user.email; + let email: string | null = typeof sourceEmail === "string" ? sourceEmail : null; + if ( + typeof sourceEmail !== "string" || + sourceEmail.length === 0 || + sourceEmail.length > MAX_EMAIL_LENGTH || + !isFacadeEmail(sourceEmail) + ) { + redactedFields.push("email"); + email = null; + } + const image = projectedOptionalString(user, "image", MAX_IMAGE_LENGTH); + if (image.redacted) redactedFields.push("image"); + const banReason = projectedOptionalString(user, "banReason", MAX_BAN_REASON_LENGTH); + if (banReason.redacted) redactedFields.push("banReason"); + return { + id: requiredString(user, "id", "managed user", MAX_IDENTIFIER_LENGTH), + name, + email, + emailVerified: requiredBoolean(user, "emailVerified", "managed user"), + image: image.redacted ? null : image.value, + roles: storedRoles( + requiredString(user, "role", "managed user", MAX_ROLES * (MAX_ROLE_LENGTH + 1)), + ), + banned: bannedValue === true, + banReason: banReason.value, + banExpiresAt: optionalDate(user, "banExpires", "managed user"), + createdAt: requiredDate(user, "createdAt", "managed user"), + updatedAt: requiredDate(user, "updatedAt", "managed user"), + redactedFields, + }; +} + +function privateSession(value: unknown, expectedUserId: string): PrivateManagedUserSession { + const session = requiredRecord(value, "managed user session"); + const redactedFields: BetterAuthManagedUserSessionRedactedField[] = []; + const userId = requiredString(session, "userId", "managed user session", MAX_IDENTIFIER_LENGTH); + if (userId !== expectedUserId) { + throw invalidResponse("Better Auth returned a session for the wrong managed user."); + } + const impersonatedBy = session.impersonatedBy; + if ( + impersonatedBy !== undefined && + impersonatedBy !== null && + (typeof impersonatedBy !== "string" || + impersonatedBy.length === 0 || + impersonatedBy.length > MAX_IDENTIFIER_LENGTH) + ) { + throw invalidResponse("Better Auth returned an invalid managed user session.impersonatedBy."); + } + const ipAddress = projectedOptionalString(session, "ipAddress", MAX_IP_ADDRESS_LENGTH); + if (ipAddress.redacted) redactedFields.push("ipAddress"); + const userAgent = projectedOptionalString(session, "userAgent", MAX_USER_AGENT_LENGTH); + if (userAgent.redacted) redactedFields.push("userAgent"); + return { + id: requiredString(session, "id", "managed user session", MAX_IDENTIFIER_LENGTH), + token: requiredString(session, "token", "managed user session", MAX_SESSION_TOKEN_LENGTH), + createdAt: requiredDate(session, "createdAt", "managed user session"), + updatedAt: requiredDate(session, "updatedAt", "managed user session"), + expiresAt: requiredDate(session, "expiresAt", "managed user session"), + ipAddress: ipAddress.value, + userAgent: userAgent.value, + impersonated: typeof impersonatedBy === "string", + redactedFields, + }; +} + +function publicSession(session: PrivateManagedUserSession): BetterAuthManagedUserSession { + return { + id: session.id, + createdAt: new Date(session.createdAt.getTime()), + updatedAt: new Date(session.updatedAt.getTime()), + expiresAt: new Date(session.expiresAt.getTime()), + ipAddress: session.ipAddress, + userAgent: session.userAgent, + impersonated: session.impersonated, + redactedFields: session.redactedFields, + }; +} + +function successResult(value: unknown, label: string): boolean { + const result = requiredRecord(value, label); + if (typeof result.success !== "boolean") { + throw invalidResponse(`Better Auth returned an invalid ${label}.success.`); + } + return result.success; +} + +function userManagementApi(value: unknown): UserManagementApi { + const record = requiredRecord(value, "user-management server API"); + for (const method of USER_MANAGEMENT_API_METHODS) { + if (typeof record[method] !== "function") { + throw new TypeError(`The Better Auth admin API does not provide '${method}'.`); + } + } + return { + call: async (method, input) => { + const operation = record[method]; + if (typeof operation !== "function") { + throw new TypeError(`The Better Auth admin API does not provide '${method}'.`); + } + return await Reflect.apply(operation as UserManagementApiOperation, record, [input]); + }, + }; +} + +function boundedInteger( + value: number | undefined, + defaultValue: number, + minimum: number, + maximum: number, + field: string, +): number { + const normalized = value ?? defaultValue; + if (!Number.isSafeInteger(normalized) || normalized < minimum || normalized > maximum) { + throw invalidInput( + "INVALID_USER_LIST_OPTIONS", + `${field} must be an integer between ${minimum} and ${maximum}.`, + ); + } + return normalized; +} + +function normalizedListOptions(options: BetterAuthManagedUserListOptions): NormalizedListOptions { + const input = options; + if (!isInputRecord(options)) { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "User list options must be an object."); + } + const allowedKeys = new Set([ + "limit", + "offset", + "search", + "searchField", + "searchOperator", + "filter", + "sortBy", + "sortDirection", + ]); + if (Object.keys(options).some((key) => !allowedKeys.has(key))) { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "User list options contain an unknown field."); + } + + const limit = boundedInteger(input.limit, DEFAULT_LIST_LIMIT, 1, MAX_LIST_LIMIT, "limit"); + const offset = boundedInteger(input.offset, 0, 0, MAX_LIST_OFFSET, "offset"); + const sortBy = input.sortBy ?? "email"; + const sortDirection = input.sortDirection ?? "asc"; + if (!["email", "name", "createdAt", "updatedAt"].includes(sortBy)) { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "sortBy is unsupported."); + } + if (sortDirection !== "asc" && sortDirection !== "desc") { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "sortDirection must be 'asc' or 'desc'."); + } + + const query: Record = { limit, offset, sortBy, sortDirection }; + if (input.search !== undefined) { + if (typeof input.search !== "string") { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "search must be a string."); + } + const search = input.search.trim(); + if (search.length === 0 || search.length > MAX_SEARCH_LENGTH) { + throw invalidInput( + "INVALID_USER_LIST_OPTIONS", + `search must be non-empty and no longer than ${MAX_SEARCH_LENGTH} characters.`, + ); + } + const searchField = input.searchField ?? "email"; + const searchOperator = input.searchOperator ?? "contains"; + if (searchField !== "email" && searchField !== "name") { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "searchField is unsupported."); + } + if (!["contains", "starts_with", "ends_with"].includes(searchOperator)) { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "searchOperator is unsupported."); + } + query.searchValue = searchField === "email" ? search.toLowerCase() : search; + query.searchField = searchField; + query.searchOperator = searchOperator; + } else if (input.searchField !== undefined || input.searchOperator !== undefined) { + throw invalidInput( + "INVALID_USER_LIST_OPTIONS", + "searchField and searchOperator require search.", + ); + } + + if (input.filter !== undefined) { + const filter = input.filter; + if (!isInputRecord(filter)) { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "filter must be an object."); + } + if (Object.keys(filter).some((key) => key !== "field" && key !== "value")) { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "filter contains an unknown field."); + } + if (filter.field === "role") { + if (typeof filter.value !== "string") { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "A role filter requires a string value."); + } + const [role] = inputRoles(filter.value); + query.filterField = "role"; + query.filterValue = role; + } else if (filter.field === "banned") { + if (typeof filter.value !== "boolean") { + throw invalidInput( + "INVALID_USER_LIST_OPTIONS", + "A banned filter requires a boolean value.", + ); + } + query.filterField = "banned"; + query.filterValue = filter.value; + } else { + throw invalidInput("INVALID_USER_LIST_OPTIONS", "filter.field is unsupported."); + } + query.filterOperator = "eq"; + } + + return { limit, offset, query }; +} + +function normalizedProfileUpdate( + update: BetterAuthManagedUserProfileUpdate, +): NormalizedProfileUpdate { + const input = update; + if (!isInputRecord(update)) { + throw invalidInput("INVALID_USER_PROFILE_UPDATE", "Profile update must be an object."); + } + if (Object.keys(update).some((key) => key !== "name" && key !== "email")) { + throw invalidInput("INVALID_USER_PROFILE_UPDATE", "Profile update contains an unknown field."); + } + const data: { name?: string; email?: string } = {}; + if (input.name !== undefined) { + if (typeof input.name !== "string") { + throw invalidInput("INVALID_USER_PROFILE_UPDATE", "name must be a string."); + } + const name = input.name.trim(); + if (name.length === 0 || name.length > MAX_NAME_LENGTH) { + throw invalidInput( + "INVALID_USER_PROFILE_UPDATE", + `name must be non-empty and no longer than ${MAX_NAME_LENGTH} characters.`, + ); + } + data.name = name; + } + if (input.email !== undefined) { + if (typeof input.email !== "string") { + throw invalidInput("INVALID_USER_PROFILE_UPDATE", "email must be a string."); + } + const email = input.email.trim().toLowerCase(); + if (email.length === 0 || email.length > MAX_EMAIL_LENGTH || !isFacadeEmail(email)) { + throw invalidInput( + "INVALID_USER_PROFILE_UPDATE", + `email must use a valid address syntax and be no longer than ${MAX_EMAIL_LENGTH} characters.`, + ); + } + data.email = email; + } + if (Object.keys(data).length === 0) { + throw invalidInput( + "INVALID_USER_PROFILE_UPDATE", + "At least one profile field must be provided.", + ); + } + return data; +} + +function normalizedBanOptions(options: BetterAuthManagedUserBanOptions): { + readonly reason?: string; + readonly expiresInSeconds?: number; +} { + const input = options; + if (!isInputRecord(options)) { + throw invalidInput("INVALID_BAN_OPTIONS", "Ban options must be an object."); + } + if (Object.keys(options).some((key) => key !== "reason" && key !== "expiresInSeconds")) { + throw invalidInput("INVALID_BAN_OPTIONS", "Ban options contain an unknown field."); + } + let reason: string | undefined; + if (input.reason !== undefined) { + if (typeof input.reason !== "string") { + throw invalidInput("INVALID_BAN_OPTIONS", "Ban reason must be a string."); + } + reason = input.reason.trim(); + if (reason.length === 0 || reason.length > MAX_BAN_REASON_LENGTH) { + throw invalidInput( + "INVALID_BAN_OPTIONS", + `Ban reason must be non-empty and no longer than ${MAX_BAN_REASON_LENGTH} characters.`, + ); + } + } + let expiresInSeconds: number | undefined; + if (input.expiresInSeconds !== undefined) { + expiresInSeconds = input.expiresInSeconds; + if ( + !Number.isSafeInteger(expiresInSeconds) || + expiresInSeconds <= 0 || + expiresInSeconds > MAX_BAN_SECONDS + ) { + throw invalidInput( + "INVALID_BAN_OPTIONS", + `Ban expiry must be an integer between 1 and ${MAX_BAN_SECONDS} seconds.`, + ); + } + } + return { + ...(reason === undefined ? {} : { reason }), + ...(expiresInSeconds === undefined ? {} : { expiresInSeconds }), + }; +} + +/** + * Application-facing platform user management over Better Auth's stock admin + * plugin. Public results are runtime-validated, list inputs are bounded, and + * bearer session tokens never cross this service boundary. + */ +@Injectable() +export class BetterAuthUserManagementService { + constructor( + private readonly betterAuth: BetterAuthService, + @Inject(BETTER_AUTH_MODULE_OPTIONS) + private readonly moduleOptions: BetterAuthModuleOptions, + ) {} + + async get(headers: BetterAuthApiHeaders, userIdInput: string): Promise { + const userId = requireIdentifier(userIdInput, "user"); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => + this.readUser(userManagementApi(untypedApi), normalizedHeaders, userId), + ); + } + + async list( + headers: BetterAuthApiHeaders, + options: BetterAuthManagedUserListOptions = {}, + ): Promise { + const normalized = normalizedListOptions(options); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const result = requiredRecord( + await userManagementApi(untypedApi).call("listUsers", { + headers: normalizedHeaders, + query: normalized.query, + }), + "managed user list", + ); + const users = requiredArray(result.users, "managed user list.users"); + if (users.length > normalized.limit) { + throw invalidResponse("Better Auth returned more managed users than the requested limit."); + } + return { + users: users.map(publicUser), + total: requiredNonNegativeInteger(result, "total", "managed user list"), + limit: normalized.limit, + offset: normalized.offset, + }; + }); + } + + async updateProfile( + headers: BetterAuthApiHeaders, + userIdInput: string, + update: BetterAuthManagedUserProfileUpdate, + ): Promise { + const userId = requireIdentifier(userIdInput, "user"); + const data = normalizedProfileUpdate(update); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = userManagementApi(untypedApi); + return this.runUserMutation(userId, async () => { + const current = await this.readUser(api, normalizedHeaders, userId); + const changedData: Record = {}; + if ( + data.name !== undefined && + (current.name === null || + current.redactedFields.includes("name") || + data.name !== current.name) + ) { + changedData.name = data.name; + } + if ( + data.email !== undefined && + (current.email === null || data.email !== current.email.toLowerCase()) + ) { + changedData.email = data.email; + // Better Auth's stock adminUpdateUser does not reset this when the + // email changes, so the safe facade must do so atomically. + changedData.emailVerified = false; + } + if (Object.keys(changedData).length === 0) return current; + return publicUser( + await api.call("adminUpdateUser", { + headers: normalizedHeaders, + body: { userId, data: changedData }, + }), + ); + }); + }); + } + + async setRoles( + headers: BetterAuthApiHeaders, + userIdInput: string, + rolesInput: string | readonly string[], + ): Promise { + const userId = requireIdentifier(userIdInput, "user"); + const roles = inputRoles(rolesInput); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = userManagementApi(untypedApi); + return this.runUserMutation(userId, async () => { + const result = requiredRecord( + await api.call("setRole", { + headers: normalizedHeaders, + body: { userId, role: roles.length === 1 ? roles[0] : [...roles] }, + }), + "managed user role update", + ); + return publicUser(result.user); + }); + }); + } + + async ban( + headers: BetterAuthApiHeaders, + userIdInput: string, + options: BetterAuthManagedUserBanOptions = {}, + ): Promise { + const userId = requireIdentifier(userIdInput, "user"); + const normalized = normalizedBanOptions(options); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = userManagementApi(untypedApi); + return this.runUserMutation(userId, async () => { + const current = await this.readUser(api, normalizedHeaders, userId); + const body = { + userId, + ...(normalized.reason === undefined ? {} : { banReason: normalized.reason }), + ...(normalized.expiresInSeconds === undefined + ? {} + : { banExpiresIn: normalized.expiresInSeconds }), + }; + const callBan = async (): Promise> => + requiredRecord( + await api.call("banUser", { + headers: normalizedHeaders, + body, + }), + "managed user ban", + ); + + if (normalized.expiresInSeconds === undefined && current.banExpiresAt !== null) { + // Stock Better Auth 1.6.26 preserves a previous banExpires when + // banUser omits it. Clear it while keeping the target banned first: + // if the final ban fails, the account remains fail-closed. + await api.call("adminUpdateUser", { + headers: normalizedHeaders, + body: { userId, data: { banned: true, banExpires: null } }, + }); + } + const result = await callBan(); + return publicUser(result.user); + }); + }); + } + + async unban(headers: BetterAuthApiHeaders, userIdInput: string): Promise { + const userId = requireIdentifier(userIdInput, "user"); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = userManagementApi(untypedApi); + return this.runUserMutation(userId, async () => { + const result = requiredRecord( + await api.call("unbanUser", { + headers: normalizedHeaders, + body: { userId }, + }), + "managed user unban", + ); + return publicUser(result.user); + }); + }); + } + + async listSessions( + headers: BetterAuthApiHeaders, + userIdInput: string, + ): Promise { + const userId = requireIdentifier(userIdInput, "user"); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = userManagementApi(untypedApi); + await this.readUser(api, normalizedHeaders, userId); + const now = Date.now(); + return (await this.readPrivateSessions(api, normalizedHeaders, userId)) + .filter((session) => session.expiresAt.getTime() > now) + .toSorted( + (left, right) => + right.updatedAt.getTime() - left.updatedAt.getTime() || left.id.localeCompare(right.id), + ) + .map(publicSession); + }); + } + + async revokeSessionById( + headers: BetterAuthApiHeaders, + userIdInput: string, + sessionIdInput: string, + ): Promise { + const userId = requireIdentifier(userIdInput, "user"); + const sessionId = requireIdentifier(sessionIdInput, "session"); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = userManagementApi(untypedApi); + return this.runUserMutation(userId, async () => { + const now = Date.now(); + const target = (await this.readPrivateSessions(api, normalizedHeaders, userId)).find( + (session) => session.id === sessionId && session.expiresAt.getTime() > now, + ); + if (!target) throw sessionNotFound(); + const result = await api.call("revokeUserSession", { + headers: normalizedHeaders, + body: { sessionToken: target.token }, + }); + return { + success: successResult(result, "managed user session revocation"), + revokedSessionId: target.id, + }; + }); + }); + } + + async revokeAllSessions( + headers: BetterAuthApiHeaders, + userIdInput: string, + ): Promise { + const userId = requireIdentifier(userIdInput, "user"); + return this.betterAuth.invokeApi(headers, async (untypedApi: unknown, normalizedHeaders) => { + const api = userManagementApi(untypedApi); + return this.runUserMutation(userId, async () => { + await this.readUser(api, normalizedHeaders, userId); + const result = await api.call("revokeUserSessions", { + headers: normalizedHeaders, + body: { userId }, + }); + return { success: successResult(result, "managed user session bulk revocation") }; + }); + }); + } + + private readUser( + api: UserManagementApi, + headers: Headers, + userId: string, + ): Promise { + return api + .call("getUser", { headers, query: { id: userId } }) + .then((value) => publicUser(value)); + } + + private async readPrivateSessions( + api: UserManagementApi, + headers: Headers, + userId: string, + ): Promise { + const result = requiredRecord( + await api.call("listUserSessions", { headers, body: { userId } }), + "managed user session list", + ); + const sessions = requiredArray(result.sessions, "managed user session list.sessions"); + if (sessions.length > MAX_SESSION_RESULTS) { + throw invalidResponse( + `Better Auth returned more than ${MAX_SESSION_RESULTS} managed user sessions.`, + ); + } + return sessions.map((session) => privateSession(session, userId)); + } + + private runUserMutation(userId: string, operation: () => Promise): Promise { + return this.moduleOptions.controlPlaneLifecycle?.run("user", userId, operation) ?? operation(); + } +} diff --git a/src/typeorm/control-plane-lifecycle.ts b/src/typeorm/control-plane-lifecycle.ts new file mode 100644 index 0000000..41f75a6 --- /dev/null +++ b/src/typeorm/control-plane-lifecycle.ts @@ -0,0 +1,153 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { BetterAuthError } from "better-auth"; + +import type { + BetterAuthControlPlaneLifecycleCoordinator, + BetterAuthControlPlaneLifecycleScope, +} from "../interfaces/better-auth-control-plane-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"]); +const CONTROL_PLANE_SCOPES: ReadonlySet = new Set(["organization", "user", "platform"]); + +interface ControlPlaneLifecycleState { + active: boolean; + readonly manager: TypeormEntityManager; + readonly locks: Map>; +} + +/** + * A TypeORM control-plane coordinator whose manager can also be used by + * {@link typeormAdapter} to join the same transaction. + */ +export interface TypeormBetterAuthControlPlaneLifecycleCoordinator extends BetterAuthControlPlaneLifecycleCoordinator { + /** The active lifecycle transaction manager, or `undefined` outside `run`. */ + readonly getManager: () => TypeormEntityManager | undefined; +} + +function invalidCoordinatorInput(message: string): BetterAuthError { + return new BetterAuthError(`[TypeORM Control Plane Lifecycle] ${message}`); +} + +function requireScope( + scope: BetterAuthControlPlaneLifecycleScope, +): BetterAuthControlPlaneLifecycleScope { + if (!CONTROL_PLANE_SCOPES.has(scope)) { + throw invalidCoordinatorInput( + `scope must be one of "organization", "user", or "platform"; received ${String(scope)}.`, + ); + } + return scope; +} + +function requireResourceId(resourceId: string): string { + if (typeof resourceId !== "string" || resourceId.trim().length === 0) { + throw invalidCoordinatorInput("resourceId must be a non-empty string."); + } + return resourceId; +} + +function requireOperation(operation: () => Promise): () => Promise { + if (typeof operation !== "function") { + throw invalidCoordinatorInput("operation must be a function."); + } + return operation; +} + +function scopedLockKeys( + scope: BetterAuthControlPlaneLifecycleScope, + resourceId: string, +): readonly string[] { + const namespaced = `${scope}:${resourceId}`; + // Organization coordination predates scoped keys. Acquire both during the + // compatibility window so a rolling deployment still serializes against an + // older process that holds only the raw organization id. Sorting the pair is + // mandatory: every new process must request the two locks in the same order. + return scope === "organization" ? [resourceId, namespaced].toSorted() : [namespaced]; +} + +/** + * Creates one PostgreSQL transaction/context and namespaced advisory-lock + * boundary shared by every Better Auth control-plane service. + * + * Pass `coordinator.getManager` to {@link typeormAdapter} with + * `transaction: true`, and pass the coordinator itself as the Nest module's + * `controlPlaneLifecycle` option. + */ +export function createTypeormBetterAuthControlPlaneLifecycleCoordinator( + dataSource: TypeormDataSource, +): TypeormBetterAuthControlPlaneLifecycleCoordinator { + 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: ControlPlaneLifecycleState, lockKey: string): Promise => { + const existing = state.locks.get(lockKey); + if (existing) return existing; + + const pending = executeQuery(state.manager, ADVISORY_LOCK_SQL, [lockKey]).then(() => undefined); + state.locks.set(lockKey, pending); + return pending; + }; + + const run = async ( + scopeInput: BetterAuthControlPlaneLifecycleScope, + resourceIdInput: string, + operationInput: () => Promise, + ): Promise => { + const scope = requireScope(scopeInput); + const resourceId = requireResourceId(resourceIdInput); + const operation = requireOperation(operationInput); + const lockKeys = scopedLockKeys(scope, resourceId); + const current = storage.getStore(); + + if (current?.active) { + for (const lockKey of lockKeys) await acquireLock(current, lockKey); + return await operation(); + } + + const transaction = dataSource.transaction; + return await Reflect.apply(transaction, dataSource, [ + async (managerInput: unknown) => { + const state: ControlPlaneLifecycleState = { + active: true, + manager: requireEntityManager(managerInput), + locks: new Map(), + }; + + return await storage.run(state, async () => { + try { + for (const lockKey of lockKeys) await acquireLock(state, lockKey); + return await operation(); + } finally { + state.active = false; + } + }); + }, + ]); + }; + + return { getManager, run }; +} diff --git a/src/typeorm/index.ts b/src/typeorm/index.ts index 8619824..eabeca2 100644 --- a/src/typeorm/index.ts +++ b/src/typeorm/index.ts @@ -1,4 +1,8 @@ export { typeormAdapter } from "./adapter.ts"; +export { + createTypeormBetterAuthControlPlaneLifecycleCoordinator, + type TypeormBetterAuthControlPlaneLifecycleCoordinator, +} from "./control-plane-lifecycle.ts"; export { createTypeormBetterAuthOrganizationLifecycleCoordinator, type TypeormBetterAuthOrganizationLifecycleCoordinator, diff --git a/src/typeorm/organization-lifecycle.ts b/src/typeorm/organization-lifecycle.ts index e47502c..bfa1afe 100644 --- a/src/typeorm/organization-lifecycle.ts +++ b/src/typeorm/organization-lifecycle.ts @@ -1,130 +1,42 @@ -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>; -} +import { + createTypeormBetterAuthControlPlaneLifecycleCoordinator, + type TypeormBetterAuthControlPlaneLifecycleCoordinator, +} from "./control-plane-lifecycle.ts"; +import type { TypeormDataSource } from "./types.ts"; /** - * A TypeORM organization-lifecycle coordinator whose manager can also be used - * by {@link typeormAdapter} to join the same transaction. + * Backwards-compatible organization-scoped view of the shared TypeORM + * control-plane coordinator. */ 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}`); + readonly getManager: TypeormBetterAuthControlPlaneLifecycleCoordinator["getManager"]; } function requireOrganizationId(organizationId: string): string { if (typeof organizationId !== "string" || organizationId.trim().length === 0) { - throw invalidCoordinatorInput("organizationId must be a non-empty string."); + throw new BetterAuthError( + "[TypeORM Organization Lifecycle] 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. + * Creates the legacy organization-only API over the generalized control-plane + * coordinator. New applications with user management should create one + * control-plane coordinator and pass it to every service instead. */ 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 coordinator = createTypeormBetterAuthControlPlaneLifecycleCoordinator(dataSource); + return { + getManager: coordinator.getManager, + run: async (organizationId, operation) => + await coordinator.run("organization", requireOrganizationId(organizationId), operation), }; - - 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-service.e2e.test.ts b/tests/e2e/organization-service.e2e.test.ts index d236d58..438ce46 100644 --- a/tests/e2e/organization-service.e2e.test.ts +++ b/tests/e2e/organization-service.e2e.test.ts @@ -292,6 +292,82 @@ describe(`BetterAuthOrganizationService (${testHttpAdapter})`, () => { ).toBe("pending"); }); + it("keeps hostile stock-valid member profiles role-changeable and removable", async () => { + const owner = await signUpUser(app); + const organizationId = await createOrganization(app, owner, "hostile-member-profile"); + const email = `${"e".repeat(400)}-${process.pid}-${Date.now()}@example.com`; + const password = "super-secure-password"; + const signedUp = await request(app.getHttpServer()).post("/api/auth/sign-up/email").send({ + email, + password, + name: "", + }); + expect(signedUp.status).toBe(200); + const member: SignedUpUser = { + email, + password, + name: "", + token: signedUp.body.token, + userId: signedUp.body.user.id, + }; + await verifyEmail(member); + + const invited = await inviteThroughFacade(app, owner, organizationId, member); + expect(invited.status).toBe(200); + const accepted = await request(app.getHttpServer()) + .post(`/account/organization-invitations/${invited.body.id}/accept`) + .set(bearer(member.token)); + expect(accepted.status).toBe(200); + expect(accepted.body.member.user).toEqual({ + id: member.userId, + name: null, + email: null, + image: null, + redactedFields: ["name", "email"], + }); + + const oversizedName = "n".repeat(300); + const oversizedImage = `https://example.com/${"i".repeat(4_100)}`; + const updatedProfile = await request(app.getHttpServer()) + .post("/api/auth/update-user") + .set(bearer(member.token)) + .send({ name: oversizedName, image: oversizedImage }); + expect(updatedProfile.status).toBe(200); + + const updatedRole = await request(app.getHttpServer()) + .patch(`/organizations/${organizationId}/members/${accepted.body.member.id}/role`) + .set(bearer(owner.token)) + .send({ role: "admin" }); + expect(updatedRole.status).toBe(200); + expect(updatedRole.body).toMatchObject({ + id: accepted.body.member.id, + role: "admin", + user: { + id: member.userId, + name: "n".repeat(256), + email: null, + image: null, + redactedFields: ["name", "email", "image"], + }, + }); + + 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).toEqual(updatedRole.body.user); + + const listed = await request(app.getHttpServer()) + .get(`/organizations/${organizationId}/members`) + .set(bearer(owner.token)); + expect(listed.status).toBe(200); + expect( + listed.body.members.some( + (candidate: { readonly userId?: unknown }) => candidate.userId === member.userId, + ), + ).toBe(false); + }); + it("keeps terminal invitation transitions terminal and blocks raw control-plane routes", async () => { const owner = await signUpUser(app); const rejecter = await signUpUser(app); diff --git a/tests/e2e/rest-auth.e2e.test.ts b/tests/e2e/rest-auth.e2e.test.ts index ef8db1f..4c34410 100644 --- a/tests/e2e/rest-auth.e2e.test.ts +++ b/tests/e2e/rest-auth.e2e.test.ts @@ -78,6 +78,26 @@ describe(`rest auth (${testHttpAdapter})`, () => { expect(anonymous.body).toEqual({ authenticated: false }); }); + it("does not expose a retained actively banned identity on a resolve-session public route", async () => { + const user = await signUpUser(app); + const context = await auth.$context; + await context.internalAdapter.updateUser(user.userId, { + banned: true, + banExpires: new Date(Date.now() + 60_000), + }); + + const response = await request(app.getHttpServer()) + .get("/test/public-with-session") + .set(bearer(user.token)); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + statusCode: 403, + code: "BANNED_USER", + message: "User is banned.", + }); + }); + it("@OptionalAuth allows both authenticated and anonymous access", async () => { const anonymous = await request(app.getHttpServer()).get("/test/optional"); expect(anonymous.status).toBe(200); diff --git a/tests/e2e/session-service.e2e.test.ts b/tests/e2e/session-service.e2e.test.ts index 7fe5cd8..413370a 100644 --- a/tests/e2e/session-service.e2e.test.ts +++ b/tests/e2e/session-service.e2e.test.ts @@ -21,6 +21,7 @@ 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 { sendRawHttpRequest } from "../shared/raw-http.ts"; import type { IncomingHttpHeaders } from "node:http"; const auth = betterAuth({ @@ -97,6 +98,7 @@ describe(`BetterAuthSessionService (${testHttpAdapter})`, () => { ], }, }); + await app.listen(0, "127.0.0.1"); }); afterAll(async () => { @@ -123,6 +125,7 @@ describe(`BetterAuthSessionService (${testHttpAdapter})`, () => { ipAddress: expect.toBeOneOf([expect.any(String), null]), userAgent: expect.toBeOneOf([expect.any(String), null]), current: expect.any(Boolean), + redactedFields: expect.any(Array), }); } const serialized = JSON.stringify(response.body); @@ -157,6 +160,36 @@ describe(`BetterAuthSessionService (${testHttpAdapter})`, () => { }); }); + it("bounds an oversized stock User-Agent without blocking safe-id revocation", async () => { + const user = await signUpUser(app); + const oversizedUserAgent = "u".repeat(2_048); + const response = await request(app.getHttpServer()) + .post("/api/auth/sign-in/email") + .set("User-Agent", oversizedUserAgent) + .send({ email: user.email, password: user.password }); + expect(response.status).toBe(200); + const oversizedAgentToken: string = response.body.token; + + const listed = await request(app.getHttpServer()) + .get("/account/sessions") + .set(bearer(oversizedAgentToken)); + expect(listed.status).toBe(200); + const projected = listed.body.find( + (entry: { redactedFields?: readonly string[] }) => + entry.redactedFields?.includes("userAgent") === true, + ); + expect(projected).toMatchObject({ + userAgent: "u".repeat(1_024), + redactedFields: ["userAgent"], + }); + + const revoked = await request(app.getHttpServer()) + .post(`/account/sessions/${projected.id}/revoke`) + .set(bearer(user.token)); + expect(revoked.status).toBe(200); + expect(await sessionForToken(app, oversizedAgentToken)).toBeNull(); + }); + it("does not reveal or revoke another user's session by id", async () => { const caller = await signUpUser(app); const otherUser = await signUpUser(app); @@ -226,6 +259,20 @@ describe(`BetterAuthSessionService (${testHttpAdapter})`, () => { message: "Use the application's session-management endpoints.", }); } + for (const target of [ + "/api/auth/decoy/%2e%2e/list-sessions", + "/api/auth/decoy/.%2e/list-sessions", + "/api/auth/decoy/%2e./list-sessions", + "/api/decoy/%2e%2e/auth/decoy/%2e%2e/list-sessions", + ]) { + const encodedRawList = await sendRawHttpRequest(app, "GET", target, bearer(user.token)); + expect(encodedRawList.status, target).toBe(403); + expect(encodedRawList.body, target).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/e2e/user-management-service.e2e.test.ts b/tests/e2e/user-management-service.e2e.test.ts new file mode 100644 index 0000000..cfd5edc --- /dev/null +++ b/tests/e2e/user-management-service.e2e.test.ts @@ -0,0 +1,542 @@ +import { + Body, + Controller, + Delete, + Get, + Headers as RequestHeaders, + HttpCode, + HttpStatus, + Param, + Patch, + Post, +} from "@nestjs/common"; +import { betterAuth } from "better-auth"; +import { admin as adminPlugin, bearer as bearerPlugin } from "better-auth/plugins"; +import { adminAc, userAc } from "better-auth/plugins/admin/access"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { INestApplication } from "@nestjs/common"; +import type { IncomingHttpHeaders } from "node:http"; + +import { + BetterAuthModule, + BetterAuthUserManagementRoutePolicy, + BetterAuthUserManagementService, +} 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 { sendRawHttpRequest } from "../shared/raw-http.ts"; + +const auth = betterAuth({ + baseURL: TEST_BASE_URL, + secret: TEST_SECRET, + emailAndPassword: { enabled: true }, + telemetry: { enabled: false }, + plugins: [ + bearerPlugin(), + adminPlugin({ + defaultRole: "user", + adminRoles: ["platform_admin"], + roles: { user: userAc, platform_admin: adminAc }, + }), + ], +}); + +const STOCK_ADMIN_HTTP_ROUTES = [ + { method: "post", path: "/admin/set-role" }, + { method: "get", path: "/admin/get-user" }, + { method: "post", path: "/admin/create-user" }, + { method: "post", path: "/admin/update-user" }, + { method: "get", path: "/admin/list-users" }, + { method: "post", path: "/admin/list-user-sessions" }, + { method: "post", path: "/admin/unban-user" }, + { method: "post", path: "/admin/ban-user" }, + { method: "post", path: "/admin/impersonate-user" }, + { method: "post", path: "/admin/stop-impersonating" }, + { method: "post", path: "/admin/revoke-user-session" }, + { method: "post", path: "/admin/revoke-user-sessions" }, + { method: "post", path: "/admin/remove-user" }, + { method: "post", path: "/admin/set-user-password" }, + { method: "post", path: "/admin/has-permission" }, +] as const; + +interface ProfileBody { + readonly name?: string; + readonly email?: string; +} + +interface RolesBody { + readonly roles: string | readonly string[]; +} + +interface BanBody { + readonly reason?: string; + readonly expiresInSeconds?: number; +} + +@Controller("platform/users") +class UserManagementFacadeController { + constructor(private readonly users: BetterAuthUserManagementService) {} + + @Get() + list(@RequestHeaders() headers: IncomingHttpHeaders) { + return this.users.list(headers); + } + + @Get(":userId") + get(@RequestHeaders() headers: IncomingHttpHeaders, @Param("userId") userId: string) { + return this.users.get(headers, userId); + } + + @Patch(":userId/profile") + updateProfile( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("userId") userId: string, + @Body() body: ProfileBody, + ) { + return this.users.updateProfile(headers, userId, body); + } + + @Patch(":userId/roles") + setRoles( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("userId") userId: string, + @Body() body: RolesBody, + ) { + return this.users.setRoles(headers, userId, body.roles); + } + + @Post(":userId/ban") + @HttpCode(HttpStatus.OK) + ban( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("userId") userId: string, + @Body() body: BanBody, + ) { + return this.users.ban(headers, userId, body); + } + + @Post(":userId/unban") + @HttpCode(HttpStatus.OK) + unban(@RequestHeaders() headers: IncomingHttpHeaders, @Param("userId") userId: string) { + return this.users.unban(headers, userId); + } + + @Get(":userId/sessions") + listSessions(@RequestHeaders() headers: IncomingHttpHeaders, @Param("userId") userId: string) { + return this.users.listSessions(headers, userId); + } + + @Delete(":userId/sessions/:sessionId") + revokeSession( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("userId") userId: string, + @Param("sessionId") sessionId: string, + ) { + return this.users.revokeSessionById(headers, userId, sessionId); + } + + @Delete(":userId/sessions") + revokeAllSessions( + @RequestHeaders() headers: IncomingHttpHeaders, + @Param("userId") userId: string, + ) { + return this.users.revokeAllSessions(headers, userId); + } +} + +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 promoteToPlatformAdmin(app: INestApplication, user: SignedUpUser): Promise { + const context = await auth.$context; + await context.internalAdapter.updateUser(user.userId, { role: "platform_admin" }); + return signInAgain(app, user); +} + +async function getSession(app: INestApplication, token: string) { + return request(app.getHttpServer()).get("/api/auth/get-session").set(bearer(token)); +} + +describe(`BetterAuthUserManagementService (${testHttpAdapter})`, () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp({ + forRoot: { auth }, + metadata: { + controllers: [UserManagementFacadeController], + imports: [ + BetterAuthModule.forFeature({ + routePolicies: [BetterAuthUserManagementRoutePolicy], + }), + ], + }, + }); + await app.listen(0, "127.0.0.1"); + }); + + afterAll(async () => { + await app.close(); + }); + + it("lists, reads, profiles, and assigns platform roles through stock admin APIs", async () => { + const admin = await signUpUser(app); + const adminToken = await promoteToPlatformAdmin(app, admin); + const target = await signUpUser(app); + + const listed = await request(app.getHttpServer()) + .get("/platform/users") + .set(bearer(adminToken)); + expect(listed.status).toBe(200); + expect(listed.body).toMatchObject({ + users: expect.arrayContaining([ + expect.objectContaining({ id: target.userId, roles: ["user"], banned: false }), + ]), + total: expect.any(Number), + limit: 50, + offset: 0, + }); + + const read = await request(app.getHttpServer()) + .get(`/platform/users/${target.userId}`) + .set(bearer(adminToken)); + expect(read.status).toBe(200); + expect(read.body).toMatchObject({ + id: target.userId, + email: target.email, + emailVerified: false, + roles: ["user"], + }); + + const context = await auth.$context; + await context.internalAdapter.updateUser(target.userId, { emailVerified: true }); + const caseOnly = await request(app.getHttpServer()) + .patch(`/platform/users/${target.userId}/profile`) + .set(bearer(adminToken)) + .send({ name: " Same Email Rename ", email: target.email.toUpperCase() }); + expect(caseOnly.status).toBe(200); + expect(caseOnly.body).toMatchObject({ + name: "Same Email Rename", + email: target.email, + emailVerified: true, + }); + const updatedEmail = `managed-${target.email}`; + const profiled = await request(app.getHttpServer()) + .patch(`/platform/users/${target.userId}/profile`) + .set(bearer(adminToken)) + .send({ name: " Managed Target ", email: updatedEmail.toUpperCase() }); + expect(profiled.status).toBe(200); + expect(profiled.body.name).toBe("Managed Target"); + expect(profiled.body.email).toBe(updatedEmail); + expect(profiled.body.emailVerified).toBe(false); + + const promoted = await request(app.getHttpServer()) + .patch(`/platform/users/${target.userId}/roles`) + .set(bearer(adminToken)) + .send({ roles: ["platform_admin"] }); + expect(promoted.status).toBe(200); + expect(promoted.body.roles).toEqual(["platform_admin"]); + + const deniedCaller = await signUpUser(app); + const denied = await request(app.getHttpServer()) + .get("/platform/users") + .set(bearer(deniedCaller.token)); + expect(denied.status).toBe(403); + + for (const route of [ + ...STOCK_ADMIN_HTTP_ROUTES, + { method: "post", path: "/admin/future/nested-route" } as const, + ]) { + const rawRequest = + route.method === "get" + ? request(app.getHttpServer()).get(`/api/auth${route.path}`) + : request(app.getHttpServer()).post(`/api/auth${route.path}`).send({}); + const raw = await rawRequest.set(bearer(adminToken)); + expect(raw.status, `${route.method.toUpperCase()} ${route.path}`).toBe(403); + expect(raw.body).toEqual({ + statusCode: 403, + code: "USER_MANAGEMENT_FACADE_REQUIRED", + message: "Use the application's user-management endpoints.", + }); + } + for (const encodedTarget of [ + "/api/auth/decoy/%2e%2e/admin/list-users", + "/api/auth/decoy/.%2e/admin/list-users", + "/api/auth/decoy/%2e./admin/list-users", + "/api/decoy/%2e%2e/auth/decoy/%2e%2e/admin/list-users", + ]) { + const raw = await sendRawHttpRequest(app, "GET", encodedTarget, bearer(adminToken)); + expect(raw.status, encodedTarget).toBe(403); + expect(raw.body, encodedTarget).toEqual({ + statusCode: 403, + code: "USER_MANAGEMENT_FACADE_REQUIRED", + message: "Use the application's user-management endpoints.", + }); + } + + const adjacentPath = await request(app.getHttpServer()) + .get("/api/auth/administrator") + .set(bearer(adminToken)); + expect(adjacentPath.status).toBe(404); + expect(adjacentPath.body.code).not.toBe("USER_MANAGEMENT_FACADE_REQUIRED"); + }); + + it("round-trips ban state and relies on stock session enforcement", async () => { + const admin = await signUpUser(app); + const adminToken = await promoteToPlatformAdmin(app, admin); + const target = await signUpUser(app); + + const banned = await request(app.getHttpServer()) + .post(`/platform/users/${target.userId}/ban`) + .set(bearer(adminToken)) + .send({ reason: "Policy violation", expiresInSeconds: 3_600 }); + expect(banned.status).toBe(200); + expect(banned.body).toMatchObject({ + id: target.userId, + banned: true, + banReason: "Policy violation", + banExpiresAt: expect.any(String), + }); + + const rejectedSignIn = await request(app.getHttpServer()) + .post("/api/auth/sign-in/email") + .send({ email: target.email, password: target.password }); + expect(rejectedSignIn.status).toBe(403); + expect(rejectedSignIn.body.code).toBe("BANNED_USER"); + + const permanentlyRebanned = await request(app.getHttpServer()) + .post(`/platform/users/${target.userId}/ban`) + .set(bearer(adminToken)) + .send({ reason: "Permanent policy ban" }); + expect(permanentlyRebanned.status).toBe(200); + expect(permanentlyRebanned.body).toMatchObject({ + id: target.userId, + banned: true, + banReason: "Permanent policy ban", + banExpiresAt: null, + }); + + const unbanned = await request(app.getHttpServer()) + .post(`/platform/users/${target.userId}/unban`) + .set(bearer(adminToken)); + expect(unbanned.status).toBe(200); + expect(unbanned.body).toMatchObject({ + id: target.userId, + banned: false, + banReason: null, + banExpiresAt: null, + }); + await expect(signInAgain(app, target)).resolves.toEqual(expect.any(String)); + }); + + it("rejects a retained session for an active ban and allows it after expiry", async () => { + const admin = await signUpUser(app); + const adminToken = await promoteToPlatformAdmin(app, admin); + const context = await auth.$context; + + await context.internalAdapter.updateUser(admin.userId, { + banned: true, + banExpires: new Date(Date.now() + 60_000), + }); + const denied = await request(app.getHttpServer()) + .get("/platform/users") + .set(bearer(adminToken)); + expect(denied.status).toBe(403); + expect(denied.body).toEqual({ + statusCode: 403, + code: "BANNED_USER", + message: "User is banned.", + }); + + await context.internalAdapter.updateUser(admin.userId, { + banExpires: new Date(Date.now() - 60_000), + }); + const allowed = await request(app.getHttpServer()) + .get("/platform/users") + .set(bearer(adminToken)); + expect(allowed.status).toBe(200); + }); + + it("lists token-free active sessions and safely revokes one or all by user-owned ids", async () => { + const admin = await signUpUser(app); + const adminToken = await promoteToPlatformAdmin(app, admin); + const target = await signUpUser(app); + const secondToken = await signInAgain(app, target); + const thirdToken = await signInAgain(app, target); + const secondSession = await getSession(app, secondToken); + const secondSessionId: string = secondSession.body.session.id; + + const listed = await request(app.getHttpServer()) + .get(`/platform/users/${target.userId}/sessions`) + .set(bearer(adminToken)); + expect(listed.status).toBe(200); + expect(listed.body).toHaveLength(3); + expect(listed.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: secondSessionId, impersonated: false }), + ]), + ); + const serialized = JSON.stringify(listed.body); + expect(serialized).not.toContain("token"); + expect(serialized).not.toContain(target.token); + expect(serialized).not.toContain(secondToken); + expect(serialized).not.toContain(thirdToken); + + const revoked = await request(app.getHttpServer()) + .delete(`/platform/users/${target.userId}/sessions/${secondSessionId}`) + .set(bearer(adminToken)); + expect(revoked.status).toBe(200); + expect(revoked.body).toEqual({ success: true, revokedSessionId: secondSessionId }); + expect((await getSession(app, secondToken)).body).toBeNull(); + expect((await getSession(app, thirdToken)).body).toMatchObject({ + session: { id: expect.any(String) }, + }); + + const unknown = await request(app.getHttpServer()) + .delete(`/platform/users/${target.userId}/sessions/not-a-session`) + .set(bearer(adminToken)); + expect(unknown.status).toBe(404); + expect(unknown.body).toEqual({ + statusCode: 404, + code: "SESSION_NOT_FOUND", + message: "Session not found.", + }); + + const revokedAll = await request(app.getHttpServer()) + .delete(`/platform/users/${target.userId}/sessions`) + .set(bearer(adminToken)); + expect(revokedAll.status).toBe(200); + expect(revokedAll.body).toEqual({ success: true }); + expect((await getSession(app, target.token)).body).toBeNull(); + expect((await getSession(app, thirdToken)).body).toBeNull(); + }); + + it("keeps stock-valid hostile display fields manageable through bounded projections", async () => { + const admin = await signUpUser(app); + const adminToken = await promoteToPlatformAdmin(app, admin); + const longEmail = `${"e".repeat(400)}-${process.pid}-${Date.now()}@example.com`; + const password = "super-secure-password"; + const signedUp = await request(app.getHttpServer()).post("/api/auth/sign-up/email").send({ + email: longEmail, + password, + name: "", + }); + expect(signedUp.status).toBe(200); + const targetUserId: string = signedUp.body.user.id; + const targetToken: string = signedUp.body.token; + + const emptyProfile = await request(app.getHttpServer()) + .get(`/platform/users/${targetUserId}`) + .set(bearer(adminToken)); + expect(emptyProfile.status).toBe(200); + expect(emptyProfile.body).toMatchObject({ + id: targetUserId, + name: null, + email: null, + redactedFields: ["name", "email"], + }); + + const nonStringProfile = await request(app.getHttpServer()) + .post("/api/auth/update-user") + .set(bearer(targetToken)) + .send({ name: { attackerControlled: true } }); + expect(nonStringProfile.status).toBe(200); + const nonStringRead = await request(app.getHttpServer()) + .get(`/platform/users/${targetUserId}`) + .set(bearer(adminToken)); + expect(nonStringRead.status).toBe(200); + expect(nonStringRead.body).toMatchObject({ + name: null, + email: null, + redactedFields: ["name", "email"], + }); + + const oversizedName = "n".repeat(300); + const oversizedImage = `https://example.com/${"i".repeat(4_100)}`; + const oversizedProfile = await request(app.getHttpServer()) + .post("/api/auth/update-user") + .set(bearer(targetToken)) + .send({ name: oversizedName, image: oversizedImage }); + expect(oversizedProfile.status).toBe(200); + + const projected = await request(app.getHttpServer()) + .get(`/platform/users/${targetUserId}`) + .set(bearer(adminToken)); + expect(projected.status).toBe(200); + expect(projected.body).toMatchObject({ + name: "n".repeat(256), + email: null, + image: null, + redactedFields: ["name", "email", "image"], + }); + const listed = await request(app.getHttpServer()) + .get("/platform/users") + .set(bearer(adminToken)); + expect(listed.status).toBe(200); + expect(listed.body.users).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: targetUserId, + name: "n".repeat(256), + email: null, + redactedFields: ["name", "email", "image"], + }), + ]), + ); + + const oversizedUserAgent = "u".repeat(2_048); + const signedIn = await request(app.getHttpServer()) + .post("/api/auth/sign-in/email") + .set("User-Agent", oversizedUserAgent) + .send({ email: longEmail, password }); + expect(signedIn.status).toBe(200); + const oversizedAgentToken: string = signedIn.body.token; + const sessions = await request(app.getHttpServer()) + .get(`/platform/users/${targetUserId}/sessions`) + .set(bearer(adminToken)); + expect(sessions.status).toBe(200); + const projectedSession = sessions.body.find( + (entry: { redactedFields?: readonly string[] }) => + entry.redactedFields?.includes("userAgent") === true, + ); + expect(projectedSession).toMatchObject({ + userAgent: "u".repeat(1_024), + redactedFields: ["userAgent"], + }); + expect(JSON.stringify(sessions.body)).not.toContain(oversizedAgentToken); + + const revoked = await request(app.getHttpServer()) + .delete(`/platform/users/${targetUserId}/sessions/${projectedSession.id}`) + .set(bearer(adminToken)); + expect(revoked.status).toBe(200); + expect((await getSession(app, oversizedAgentToken)).body).toBeNull(); + + const roleUpdated = await request(app.getHttpServer()) + .patch(`/platform/users/${targetUserId}/roles`) + .set(bearer(adminToken)) + .send({ roles: ["user"] }); + expect(roleUpdated.status).toBe(200); + expect(roleUpdated.body.redactedFields).toEqual(["name", "email", "image"]); + + const banned = await request(app.getHttpServer()) + .post(`/platform/users/${targetUserId}/ban`) + .set(bearer(adminToken)) + .send({ reason: "Hostile profile enforcement", expiresInSeconds: 3_600 }); + expect(banned.status).toBe(200); + expect(banned.body).toMatchObject({ + id: targetUserId, + banned: true, + redactedFields: ["name", "email", "image"], + }); + }); +}); diff --git a/tests/packed-types/consumer.ts b/tests/packed-types/consumer.ts index dd08973..ee5a3a2 100644 --- a/tests/packed-types/consumer.ts +++ b/tests/packed-types/consumer.ts @@ -6,27 +6,40 @@ import { BetterAuthService, BetterAuthSessionManagementRoutePolicy, BetterAuthSessionService, + BetterAuthUserManagementRoutePolicy, + BetterAuthUserManagementService, AuthRoutePolicy, deny, type AnyAuth, type BetterAuthInteropOptions, + type BetterAuthManagedUser, + type BetterAuthManagedUserRedactedField, + type BetterAuthManagedUserPage, + type BetterAuthManagedUserSession, + type BetterAuthManagedUserSessionRedactedField, + type BetterAuthManagedUserSessionBulkRevocationResult, + type BetterAuthManagedUserSessionRevocationResult, type BetterAuthOrganizationInvitation, type BetterAuthOrganizationInvitationAcceptance, type BetterAuthOrganizationInvitationPreview, type BetterAuthOrganizationMember, type BetterAuthOrganizationMemberList, + type BetterAuthOrganizationMemberUserRedactedField, type BetterAuthReceivedOrganizationInvitation, type BetterAuthRoutePolicy, type BetterAuthRoutePolicyContext, type BetterAuthRoutePolicyHandler, type BetterAuthSessionBulkRevocationResult, type BetterAuthSessionRevocationResult, + type BetterAuthSessionRedactedField, type BetterAuthSessionSummary, } from "@nestm/better-auth"; import { + createTypeormBetterAuthControlPlaneLifecycleCoordinator, createTypeormBetterAuthOrganizationLifecycleCoordinator, typeormAdapter, type TypeormAdapterConfig, + type TypeormBetterAuthControlPlaneLifecycleCoordinator, type TypeormBetterAuthOrganizationLifecycleCoordinator, } from "@nestm/better-auth/typeorm"; import type { Reflector } from "@nestjs/core"; @@ -45,6 +58,7 @@ const pluginAuth = betterAuth({ plugins: [organization()] }); declare const pluginService: BetterAuthService; declare const sessionService: BetterAuthSessionService; declare const organizationService: BetterAuthOrganizationService; +declare const userManagementService: BetterAuthUserManagementService; const invitationCall = pluginService.invokeApi(requestHeaders, (api, headers) => api.createInvitation({ body: { @@ -71,6 +85,11 @@ const updatedOrganizationMember: Promise = organizationService.updateMemberRole(requestHeaders, "packed-organization", "packed-member", [ "admin", ]); +declare const organizationMember: BetterAuthOrganizationMember; +const organizationMemberName: string | null = organizationMember.user.name; +const organizationMemberEmail: string | null = organizationMember.user.email; +const organizationMemberRedactions: readonly BetterAuthOrganizationMemberUserRedactedField[] = + organizationMember.user.redactedFields; const sentOrganizationInvitation: Promise = organizationService.invite(requestHeaders, "packed-organization", "packed@example.com", "member"); const receivedOrganizationInvitations: Promise< @@ -80,6 +99,34 @@ const organizationInvitationPreview: Promise = organizationService.acceptInvitation(requestHeaders, "packed-invitation"); +const managedUsers: Promise = userManagementService.list( + requestHeaders, + { + limit: 25, + filter: { field: "role", value: "platform_admin" }, + }, +); +const managedUser: Promise = userManagementService.get( + requestHeaders, + "packed-user", +); +const managedSessions: Promise = + userManagementService.listSessions(requestHeaders, "packed-user"); +const managedSessionRevocation: Promise = + userManagementService.revokeSessionById(requestHeaders, "packed-user", "packed-session"); +const managedSessionBulkRevocation: Promise = + userManagementService.revokeAllSessions(requestHeaders, "packed-user"); +declare const managedUserProjection: BetterAuthManagedUser; +const managedName: string | null = managedUserProjection.name; +const managedEmail: string | null = managedUserProjection.email; +const managedRedactions: readonly BetterAuthManagedUserRedactedField[] = + managedUserProjection.redactedFields; +declare const managedSessionProjection: BetterAuthManagedUserSession; +const managedSessionRedactions: readonly BetterAuthManagedUserSessionRedactedField[] = + managedSessionProjection.redactedFields; +declare const selfSessionProjection: BetterAuthSessionSummary; +const selfSessionRedactions: readonly BetterAuthSessionRedactedField[] = + selfSessionProjection.redactedFields; const functionalRoutePolicy = (({ authPath }) => authPath === "/functional-policy-test" @@ -122,6 +169,9 @@ const sessionPolicyFeature = BetterAuthModule.forFeature({ const organizationPolicyFeature = BetterAuthModule.forFeature({ routePolicies: [BetterAuthOrganizationControlPlaneRoutePolicy], }); +const userManagementPolicyFeature = BetterAuthModule.forFeature({ + routePolicies: [BetterAuthUserManagementRoutePolicy], +}); // 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. @@ -143,9 +193,19 @@ const coordinatedInvitationCall = organizationLifecycle.run( "packed-organization", () => invitationCall, ); +const controlPlaneLifecycle = createTypeormBetterAuthControlPlaneLifecycleCoordinator(dataSource); +const typedControlPlaneLifecycle: TypeormBetterAuthControlPlaneLifecycleCoordinator = + controlPlaneLifecycle; +const coordinatedManagedUser = controlPlaneLifecycle.run("user", "packed-user", () => managedUser); const moduleWithTypeormDatabase = BetterAuthModule.forRoot({ - options: { database: databaseAdapter }, + options: { + database: typeormAdapter(dataSource, { + getManager: controlPlaneLifecycle.getManager, + transaction: true, + }), + }, + controlPlaneLifecycle, }); export { @@ -153,17 +213,33 @@ export { databaseAdapter, coordinatedDatabaseAdapter, coordinatedInvitationCall, + coordinatedManagedUser, + controlPlaneLifecycle, defaultedAdapter, interop, invitationCall, + managedSessionBulkRevocation, + managedSessionRedactions, + managedSessionRevocation, + managedSessions, + managedEmail, + managedName, + managedRedactions, + managedUser, + managedUsers, organizationInvitationAcceptance, organizationInvitationPreview, + organizationMember, + organizationMemberEmail, + organizationMemberName, + organizationMemberRedactions, organizationMembers, organizationPolicyFeature, receivedOrganizationInvitations, sentOrganizationInvitation, sessionList, sessionRevocation, + selfSessionRedactions, otherSessionRevocation, organizationLifecycle, allSessionRevocation, @@ -173,5 +249,7 @@ export { sessionPolicyFeature, synchronousModule, typedOrganizationLifecycle, + typedControlPlaneLifecycle, updatedOrganizationMember, + userManagementPolicyFeature, }; diff --git a/tests/postgres/organization-lifecycle.spec.ts b/tests/postgres/organization-lifecycle.spec.ts index b4c7f21..c2ccb8a 100644 --- a/tests/postgres/organization-lifecycle.spec.ts +++ b/tests/postgres/organization-lifecycle.spec.ts @@ -2,6 +2,7 @@ import type { BetterAuthOptions } from "better-auth/types"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { + createTypeormBetterAuthControlPlaneLifecycleCoordinator, createTypeormBetterAuthOrganizationLifecycleCoordinator, typeormAdapter, } from "../../src/typeorm/index.ts"; @@ -88,28 +89,60 @@ describe("TypeORM organization lifecycle coordinator", () => { ).toBeNull(); }); - test("holds an organization-keyed advisory lock until its transaction ends", async () => { + test("dual-locks legacy and namespaced organization keys during rolling upgrades", async () => { const dataSource = context.dataSource!; - const coordinator = createTypeormBetterAuthOrganizationLifecycleCoordinator(dataSource); + const coordinator = createTypeormBetterAuthControlPlaneLifecycleCoordinator(dataSource); const organizationId = "organization-lock-target"; - await coordinator.run(organizationId, async () => { - const [sameOrganization, differentOrganization] = await dataSource.transaction( + await coordinator.run("organization", organizationId, async () => { + const [legacyProcessKey, namespacedKey, 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; + const legacy = await manager.query(TRY_ADVISORY_LOCK_SQL, [organizationId]); + const namespaced = await manager.query(TRY_ADVISORY_LOCK_SQL, [ + `organization:${organizationId}`, + ]); + const different = await manager.query(TRY_ADVISORY_LOCK_SQL, [ + "organization:different-organization", + ]); + return [ + advisoryLockResult(legacy), + advisoryLockResult(namespaced), + advisoryLockResult(different), + ] as const; }, ); - expect(sameOrganization).toBe(false); + expect(legacyProcessKey).toBe(false); + expect(namespacedKey).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); + const legacy = await manager.query(TRY_ADVISORY_LOCK_SQL, [organizationId]); + const namespaced = await manager.query(TRY_ADVISORY_LOCK_SQL, [ + `organization:${organizationId}`, + ]); + return [advisoryLockResult(legacy), advisoryLockResult(namespaced)] as const; + }); + expect(released).toEqual([true, true]); + }); + + test("namespaces equal user and organization ids while sharing the adapter transaction", async () => { + const dataSource = context.dataSource!; + const coordinator = createTypeormBetterAuthControlPlaneLifecycleCoordinator(dataSource); + const resourceId = "shared-resource-id"; + + await coordinator.run("user", resourceId, async () => { + const [sameUser, sameIdOrganization] = await dataSource.transaction(async (manager) => { + const same = await manager.query(TRY_ADVISORY_LOCK_SQL, [`user:${resourceId}`]); + const otherScope = await manager.query(TRY_ADVISORY_LOCK_SQL, [ + `organization:${resourceId}`, + ]); + return [advisoryLockResult(same), advisoryLockResult(otherScope)] as const; + }); + + expect(sameUser).toBe(false); + expect(sameIdOrganization).toBe(true); }); - expect(released).toBe(true); }); }); diff --git a/tests/shared/raw-http.ts b/tests/shared/raw-http.ts new file mode 100644 index 0000000..b857f6e --- /dev/null +++ b/tests/shared/raw-http.ts @@ -0,0 +1,85 @@ +import { createConnection } from "node:net"; +import type { INestApplication } from "@nestjs/common"; + +export interface RawHttpResponse { + readonly body: unknown; + readonly status: number; + readonly text: string; +} + +function listeningPort(app: INestApplication): number { + const server: unknown = app.getHttpServer(); + if (typeof server !== "object" || server === null) { + throw new TypeError("The Nest HTTP server is unavailable."); + } + const addressOperation = Reflect.get(server, "address"); + if (typeof addressOperation !== "function") { + throw new TypeError("The Nest HTTP server does not expose address()."); + } + const address: unknown = Reflect.apply(addressOperation, server, []); + if ( + typeof address !== "object" || + address === null || + !("port" in address) || + typeof address.port !== "number" + ) { + throw new TypeError("The Nest HTTP server is not listening on a TCP port."); + } + return address.port; +} + +/** Sends a literal origin-form request target without client URL normalization. */ +export function sendRawHttpRequest( + app: INestApplication, + method: string, + target: string, + headers: Readonly> = {}, +): Promise { + const port = listeningPort(app); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const socket = createConnection({ host: "127.0.0.1", port }, () => { + const headerLines = Object.entries(headers).map(([name, value]) => `${name}: ${value}`); + socket.write( + [ + `${method.toUpperCase()} ${target} HTTP/1.1`, + "Host: 127.0.0.1", + "Connection: close", + ...headerLines, + "", + "", + ].join("\r\n"), + ); + }); + socket.setTimeout(5_000, () => { + socket.destroy(new Error("Timed out waiting for the raw HTTP response.")); + }); + socket.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + socket.on("error", reject); + socket.on("end", () => { + const response = Buffer.concat(chunks).toString("utf8"); + const boundary = response.indexOf("\r\n\r\n"); + if (boundary < 0) { + reject(new TypeError("The raw HTTP response has no header boundary.")); + return; + } + const head = response.slice(0, boundary); + const text = response.slice(boundary + 4); + const statusText = head.split("\r\n", 1)[0]?.split(" ", 3)[1]; + const status = Number(statusText); + if (!Number.isInteger(status)) { + reject(new TypeError("The raw HTTP response has an invalid status line.")); + return; + } + let body: unknown = text; + if (text.length > 0) { + try { + body = JSON.parse(text) as unknown; + } catch { + // Keep non-JSON responses as text. + } + } + resolve({ body, status, text }); + }); + }); +} diff --git a/tests/unit/banned-user-guard.test.ts b/tests/unit/banned-user-guard.test.ts new file mode 100644 index 0000000..e13c3bc --- /dev/null +++ b/tests/unit/banned-user-guard.test.ts @@ -0,0 +1,94 @@ +import { Reflector } from "@nestjs/core"; +import { ExecutionContextHost } from "@nestjs/core/helpers/execution-context-host"; +import { describe, expect, it, vi } from "vitest"; + +import { AllowAnonymous, BetterAuthGuard, type AnyAuth } from "../../src/index.ts"; + +class ProtectedController { + read(this: void): void {} + + @AllowAnonymous({ resolveSession: true }) + publicWithSession(this: void): void {} +} + +function executionContext(handler = ProtectedController.prototype.read): ExecutionContextHost { + const context = new ExecutionContextHost( + [{ headers: { authorization: "Bearer retained-session" } }], + ProtectedController, + handler, + ); + context.setType("http"); + return context; +} + +function authWithUser(user: Record): AnyAuth { + return { + handler: async (_request: Request) => new Response(), + api: { + getSession: vi.fn(async (_input: unknown) => ({ + session: { id: "retained-session" }, + user: { id: "user-id", ...user }, + })), + }, + options: {}, + $context: Promise.resolve({}), + $Infer: { Session: {} }, + $ERROR_CODES: {}, + } satisfies AnyAuth; +} + +describe("BetterAuthGuard active-ban enforcement", () => { + it.each([ + { label: "no expiry", banExpires: null }, + { label: "a future expiry", banExpires: new Date(Date.now() + 60_000) }, + { label: "an invalid expiry", banExpires: "not-a-date" }, + ])("rejects a retained session for a banned user with $label", async ({ banExpires }) => { + const auth = authWithUser({ banned: true, banExpires }); + const guard = new BetterAuthGuard(new Reflector(), auth); + + await expect(guard.canActivate(executionContext())).rejects.toMatchObject({ + status: 403, + response: { + statusCode: 403, + code: "BANNED_USER", + message: "User is banned.", + }, + }); + expect(auth.api.getSession).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { disableCookieCache: true }, + }); + }); + + it("allows a retained session once its ban expiry is strictly in the past", async () => { + const guard = new BetterAuthGuard( + new Reflector(), + authWithUser({ banned: true, banExpires: new Date(Date.now() - 60_000) }), + ); + + await expect(guard.canActivate(executionContext())).resolves.toBe(true); + }); + + it("allows an unbanned user even when a stale ban expiry remains", async () => { + const guard = new BetterAuthGuard( + new Reflector(), + authWithUser({ banned: false, banExpires: new Date(Date.now() + 60_000) }), + ); + + await expect(guard.canActivate(executionContext())).resolves.toBe(true); + }); + + it("denies an active banned identity on @AllowAnonymous({ resolveSession: true })", async () => { + const guard = new BetterAuthGuard( + new Reflector(), + authWithUser({ banned: true, banExpires: new Date(Date.now() + 60_000) }), + ); + + await expect( + guard.canActivate(executionContext(ProtectedController.prototype.publicWithSession)), + ).rejects.toMatchObject({ + status: 403, + response: { code: "BANNED_USER" }, + }); + }); +}); diff --git a/tests/unit/exports.test.ts b/tests/unit/exports.test.ts index db5b592..8f00fd1 100644 --- a/tests/unit/exports.test.ts +++ b/tests/unit/exports.test.ts @@ -31,10 +31,13 @@ const EXPECTED_VALUE_EXPORTS = [ "BetterAuthService", "BetterAuthSessionService", "BetterAuthOrganizationService", + "BetterAuthUserManagementService", "BetterAuthSessionManagementRoutePolicy", "BETTER_AUTH_SESSION_MANAGEMENT_PATHS", "BetterAuthOrganizationControlPlaneRoutePolicy", "BETTER_AUTH_ORGANIZATION_CONTROL_PLANE_PATHS", + "BetterAuthUserManagementRoutePolicy", + "BETTER_AUTH_USER_MANAGEMENT_PATHS", "BetterAuthGuard", "MutationOriginGuard", "MUTATION_ORIGIN_GUARD_OPTIONS", diff --git a/tests/unit/organization-service-type-assertions.ts b/tests/unit/organization-service-type-assertions.ts index 3db8ca8..aa79170 100644 --- a/tests/unit/organization-service-type-assertions.ts +++ b/tests/unit/organization-service-type-assertions.ts @@ -6,6 +6,7 @@ import { type BetterAuthOrganizationInvitationPreview, type BetterAuthOrganizationMember, type BetterAuthOrganizationMemberList, + type BetterAuthOrganizationMemberUserRedactedField, type BetterAuthReceivedOrganizationInvitation, } from "../../src/index.ts"; import type { IncomingHttpHeaders } from "node:http"; @@ -64,10 +65,15 @@ const rejected: Promise = service.rejectInvita async function assertSafeOrganizationSurface(): Promise { const firstMember = (await members).members[0]; if (firstMember) { - const email: string = firstMember.user.email; + const name: string | null = firstMember.user.name; + const email: string | null = firstMember.user.email; + const redactedFields: readonly BetterAuthOrganizationMemberUserRedactedField[] = + firstMember.user.redactedFields; // @ts-expect-error Public member users never expose password material. const password = firstMember.user.password; + void name; void email; + void redactedFields; void password; } const invitation = await sent; diff --git a/tests/unit/organization-service.test.ts b/tests/unit/organization-service.test.ts index f872b7a..62c3950 100644 --- a/tests/unit/organization-service.test.ts +++ b/tests/unit/organization-service.test.ts @@ -5,6 +5,8 @@ import { BetterAuthOrganizationService, BetterAuthService, type AnyAuth, + type BetterAuthControlPlaneLifecycleCoordinator, + type BetterAuthControlPlaneLifecycleScope, type BetterAuthModuleOptions, type BetterAuthOrganizationLifecycleCoordinator, } from "../../src/index.ts"; @@ -115,7 +117,27 @@ class RecordingCoordinator implements BetterAuthOrganizationLifecycleCoordinator } } -function createService(api = createApi(), contextOverrides: Record = {}) { +class RecordingControlPlaneCoordinator implements BetterAuthControlPlaneLifecycleCoordinator { + readonly calls: Array<{ + readonly scope: BetterAuthControlPlaneLifecycleScope; + readonly resourceId: string; + }> = []; + + async run( + scope: BetterAuthControlPlaneLifecycleScope, + resourceId: string, + operation: () => Promise, + ): Promise { + this.calls.push({ scope, resourceId }); + return operation(); + } +} + +function createService( + api = createApi(), + contextOverrides: Record = {}, + controlPlaneLifecycle?: BetterAuthControlPlaneLifecycleCoordinator, +) { const internalAdapter = { listSessions: vi.fn(async (_userId: string) => []), updateSession: vi.fn(async (_token: string, _update: Record) => ({ @@ -132,7 +154,11 @@ function createService(api = createApi(), contextOverrides: Record { vi.restoreAllMocks(); }); + it("prefers the namespaced control-plane coordinator over the legacy organization one", async () => { + const genericCoordinator = new RecordingControlPlaneCoordinator(); + const { coordinator, service } = createService(createApi(), {}, genericCoordinator); + + await service.updateMemberRole({}, ORGANIZATION_ID, "member-id", "admin"); + + expect(genericCoordinator.calls).toEqual([ + { scope: "organization", resourceId: ORGANIZATION_ID }, + ]); + expect(coordinator.organizationIds).toEqual([]); + }); + it("re-reads the joined member after a stock updateMemberRole response", async () => { const updatedMember = member({ role: "admin" }); const api = createApi({ @@ -155,7 +193,10 @@ describe("BetterAuthOrganizationService", () => { const result = await service.updateMemberRole({}, ORGANIZATION_ID, "member-id", ["admin"]); - expect(result).toEqual(updatedMember); + expect(result).toEqual({ + ...updatedMember, + user: { ...updatedMember.user, redactedFields: [] }, + }); expect(result.user.email).toBe("member@example.com"); expect(api.updateMemberRole).toHaveBeenCalledWith({ headers: expect.any(Headers), @@ -178,6 +219,43 @@ describe("BetterAuthOrganizationService", () => { expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID]); }); + it("keeps role updates and removals operable through bounded member identity projections", async () => { + const oversizedName = "n".repeat(300); + const api = createApi({ + listMembers: vi.fn(async (_input: unknown) => ({ + members: [ + member({ + user: { + id: "member-user-id", + name: oversizedName, + email: `${"e".repeat(400)}@example.com`, + image: "i".repeat(4_097), + }, + }), + ], + total: 1, + })), + }); + const { coordinator, service } = createService(api); + + const listed = await service.listMembers({}, ORGANIZATION_ID); + const updated = await service.updateMemberRole({}, ORGANIZATION_ID, "member-id", "admin"); + const removed = await service.removeMember({}, ORGANIZATION_ID, "member-id"); + + for (const projected of [listed.members[0], updated, removed]) { + expect(projected?.user).toEqual({ + id: "member-user-id", + name: "n".repeat(256), + email: null, + image: null, + redactedFields: ["name", "email", "image"], + }); + } + expect(api.updateMemberRole).toHaveBeenCalledOnce(); + expect(api.removeMember).toHaveBeenCalledOnce(); + expect(coordinator.organizationIds).toEqual([ORGANIZATION_ID, ORGANIZATION_ID]); + }); + it("cancels expired pending invitations for the normalized email before inviting", async () => { const events: string[] = []; const api = createApi({ @@ -440,10 +518,12 @@ describe("BetterAuthOrganizationService", () => { expect(preview.expiresAt).toBeInstanceOf(Date); }); - it("rejects malformed public member data instead of leaking a partial result", async () => { + it("still rejects malformed authoritative member identity data", async () => { const api = createApi({ listMembers: vi.fn(async (_input: unknown) => ({ - members: [member({ user: { id: "member-user-id" } })], + members: [ + member({ user: { name: "Member User", email: "member@example.com", image: null } }), + ], total: 1, })), }); diff --git a/tests/unit/request-utils.test.ts b/tests/unit/request-utils.test.ts new file mode 100644 index 0000000..761d7b3 --- /dev/null +++ b/tests/unit/request-utils.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { canonicalizeRequestTarget, matchesBasePath } from "../../src/mount/request-utils.ts"; + +describe("request target canonicalization", () => { + it.each(["%2e%2e", ".%2e", "%2e."])( + "normalizes the encoded dot segment %s with WHATWG URL semantics", + (segment) => { + const target = canonicalizeRequestTarget({ + url: `/api/auth/decoy/${segment}/admin/list-users?limit=1`, + }); + + expect(target).toEqual({ + url: `/api/auth/decoy/${segment}/admin/list-users?limit=1`, + pathname: "/api/auth/admin/list-users", + }); + expect(matchesBasePath(target?.pathname ?? "", "/api/auth")).toBe(true); + }, + ); + + it("uses the Node request URL that the downstream bridge receives", () => { + expect( + canonicalizeRequestTarget({ + url: "/api/auth/sign-in/email", + originalUrl: "/stale/original-url", + }), + ).toMatchObject({ pathname: "/api/auth/sign-in/email" }); + }); + + it.each([{}, { url: "" }, { url: 42 }])("rejects an unusable request target: %o", (request) => { + expect(canonicalizeRequestTarget(request)).toBeUndefined(); + }); + + it("keeps base-path matching segment-safe after normalization", () => { + expect(matchesBasePath("/api/auth", "/api/auth")).toBe(true); + expect(matchesBasePath("/api/auth/admin/list-users", "/api/auth")).toBe(true); + expect(matchesBasePath("/api/authentication", "/api/auth")).toBe(false); + }); +}); diff --git a/tests/unit/session-service.test.ts b/tests/unit/session-service.test.ts index d38a480..37be7f1 100644 --- a/tests/unit/session-service.test.ts +++ b/tests/unit/session-service.test.ts @@ -77,6 +77,7 @@ describe("BetterAuthSessionService", () => { ipAddress: null, userAgent: null, current: false, + redactedFields: [], }, { id: "current-id", @@ -86,6 +87,7 @@ describe("BetterAuthSessionService", () => { ipAddress: "192.0.2.10", userAgent: "Test Browser", current: true, + redactedFields: [], }, ]); const serialized = JSON.stringify(result); @@ -118,6 +120,34 @@ describe("BetterAuthSessionService", () => { expect(JSON.stringify(result)).not.toContain("other-secret"); }); + it("projects oversized display metadata without blocking safe-id revocation", async () => { + const oversizedIpAddress = "1".repeat(256); + const oversizedUserAgent = "u".repeat(1_025); + const api = createApi({ + listSessions: vi.fn(async (_input: { headers: Headers }) => [ + session("other-id", "other-secret", { + ipAddress: oversizedIpAddress, + userAgent: oversizedUserAgent, + }), + session("current-id", "current-secret"), + ]), + }); + const { service } = createService(api); + + const listed = await service.list({}); + await expect(service.revokeById({}, "other-id")).resolves.toMatchObject({ status: true }); + + expect(listed[0]).toMatchObject({ + ipAddress: "1".repeat(255), + userAgent: "u".repeat(1_024), + redactedFields: ["ipAddress", "userAgent"], + }); + expect(api.revokeSession).toHaveBeenCalledWith({ + body: { token: "other-secret" }, + headers: expect.any(Headers), + }); + }); + it("reports when the revoked id belongs to the current session", async () => { const { service } = createService(); diff --git a/tests/unit/typeorm-organization-lifecycle.test.ts b/tests/unit/typeorm-organization-lifecycle.test.ts index 171ca62..2964ece 100644 --- a/tests/unit/typeorm-organization-lifecycle.test.ts +++ b/tests/unit/typeorm-organization-lifecycle.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { createTypeormBetterAuthControlPlaneLifecycleCoordinator } from "../../src/typeorm/control-plane-lifecycle.ts"; import { createTypeormBetterAuthOrganizationLifecycleCoordinator } from "../../src/typeorm/organization-lifecycle.ts"; import type { TypeormCallableCapability, @@ -69,16 +70,12 @@ describe("TypeORM organization lifecycle coordinator", () => { 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"], - }); + expect(harness.queries).toHaveLength(4); + expect(harness.queries.map(({ parameters }) => parameters)).toEqual([ + ...[organizationId, `organization:${organizationId}`].toSorted().map((lockKey) => [lockKey]), + ...["org-2", "organization:org-2"].toSorted().map((lockKey) => [lockKey]), + ]); + for (const { sql } of harness.queries) expect(sql).not.toContain(organizationId); }); it("clears the exposed manager and propagates operation failures", async () => { @@ -114,3 +111,48 @@ describe("TypeORM organization lifecycle coordinator", () => { ).toThrow(/does not provide the PostgreSQL transaction-scoped advisory locks/); }); }); + +describe("TypeORM control-plane lifecycle coordinator", () => { + it("shares one context while namespacing equal ids across resource scopes", async () => { + const harness = createDataSource(); + const coordinator = createTypeormBetterAuthControlPlaneLifecycleCoordinator(harness.dataSource); + + await coordinator.run("platform", "global-administrators", async () => { + expect(coordinator.getManager()).toBeDefined(); + await coordinator.run("organization", "same-id", async () => undefined); + await coordinator.run("user", "same-id", async () => undefined); + await coordinator.run("user", "same-id", async () => undefined); + }); + + expect(coordinator.getManager()).toBeUndefined(); + expect(harness.getTransactionCalls()).toBe(1); + expect(harness.queries.map(({ parameters }) => parameters)).toEqual([ + ["platform:global-administrators"], + ["organization:same-id"], + ["same-id"], + ["user:same-id"], + ]); + }); + + it.each([ + ["invalid", "resource-id", /scope must be one of/], + ["user", " ", /resourceId must be a non-empty string/], + ] as const)( + "rejects invalid runtime scope/resource pairs", + async (scope, resourceId, expected) => { + const harness = createDataSource(); + const coordinator = createTypeormBetterAuthControlPlaneLifecycleCoordinator( + harness.dataSource, + ); + + await expect( + coordinator.run( + scope as Parameters[0], + resourceId, + async () => undefined, + ), + ).rejects.toThrow(expected); + expect(harness.getTransactionCalls()).toBe(0); + }, + ); +}); diff --git a/tests/unit/user-management-route-policy.test.ts b/tests/unit/user-management-route-policy.test.ts new file mode 100644 index 0000000..34d5599 --- /dev/null +++ b/tests/unit/user-management-route-policy.test.ts @@ -0,0 +1,26 @@ +import { HttpStatus } from "@nestjs/common"; +import { describe, expect, it } from "vitest"; + +import { + BETTER_AUTH_USER_MANAGEMENT_PATHS, + BetterAuthUserManagementRoutePolicy, +} from "../../src/index.ts"; + +describe("BetterAuthUserManagementRoutePolicy", () => { + it("uses one segment-safe wildcard for the complete admin namespace", () => { + expect(BETTER_AUTH_USER_MANAGEMENT_PATHS).toEqual(["/admin/*"]); + }); + + it("returns a stable opt-in denial", () => { + expect(new BetterAuthUserManagementRoutePolicy().evaluate()).toEqual({ + effect: "deny", + status: HttpStatus.FORBIDDEN, + body: { + statusCode: HttpStatus.FORBIDDEN, + code: "USER_MANAGEMENT_FACADE_REQUIRED", + message: "Use the application's user-management endpoints.", + }, + headers: undefined, + }); + }); +}); diff --git a/tests/unit/user-management-service-type-assertions.ts b/tests/unit/user-management-service-type-assertions.ts new file mode 100644 index 0000000..3d10906 --- /dev/null +++ b/tests/unit/user-management-service-type-assertions.ts @@ -0,0 +1,85 @@ +/** Compile-time coverage for the normalized platform user-management facade. */ +import { + BetterAuthUserManagementService, + type BetterAuthManagedUser, + type BetterAuthManagedUserPage, + type BetterAuthManagedUserSession, + type BetterAuthManagedUserSessionBulkRevocationResult, + type BetterAuthManagedUserSessionRevocationResult, +} from "../../src/index.ts"; +import type { IncomingHttpHeaders } from "node:http"; + +declare const service: BetterAuthUserManagementService; +declare const headers: IncomingHttpHeaders; + +const listed: Promise = service.list(headers, { + limit: 20, + offset: 40, + search: "user@example.com", + searchField: "email", + searchOperator: "contains", + filter: { field: "role", value: "platform_admin" }, + sortBy: "createdAt", + sortDirection: "desc", +}); +const read: Promise = service.get(headers, "user-id"); +const updated: Promise = service.updateProfile(headers, "user-id", { + name: "Updated User", + email: "updated@example.com", +}); +const roleUpdated: Promise = service.setRoles(headers, "user-id", [ + "platform_admin", +]); +const banned: Promise = service.ban(headers, "user-id", { + reason: "Policy violation", + expiresInSeconds: 3_600, +}); +const unbanned: Promise = service.unban(headers, "user-id"); +const sessions: Promise = service.listSessions( + headers, + "user-id", +); +const revoked: Promise = service.revokeSessionById( + headers, + "user-id", + "session-id", +); +const revokedAll: Promise = + service.revokeAllSessions(headers, "user-id"); + +async function assertSafeUserManagementSurface(): Promise { + const user = await read; + const roles: readonly string[] = user.roles; + const name: string | null = user.name; + const email: string | null = user.email; + const redactedFields: readonly ("name" | "email" | "image" | "banReason")[] = user.redactedFields; + // @ts-expect-error The safe profile mutation does not expose verification state. + await service.updateProfile(headers, user.id, { emailVerified: true }); + + const [session] = await sessions; + if (session) { + const sessionId: string = session.id; + const sessionRedactedFields: readonly ("ipAddress" | "userAgent")[] = session.redactedFields; + // @ts-expect-error Managed session tokens stay private to the facade. + const token = session.token; + void sessionId; + void sessionRedactedFields; + void token; + } + void roles; + void name; + void email; + void redactedFields; +} + +export { + assertSafeUserManagementSurface, + banned, + listed, + read, + revoked, + revokedAll, + roleUpdated, + unbanned, + updated, +}; diff --git a/tests/unit/user-management-service.test.ts b/tests/unit/user-management-service.test.ts new file mode 100644 index 0000000..5d89bd5 --- /dev/null +++ b/tests/unit/user-management-service.test.ts @@ -0,0 +1,708 @@ +import { HttpException } from "@nestjs/common"; +import { APIError } from "better-auth/api"; +import { describe, expect, it, vi } from "vitest"; + +import { + BetterAuthService, + BetterAuthUserManagementService, + type AnyAuth, + type BetterAuthControlPlaneLifecycleCoordinator, + type BetterAuthControlPlaneLifecycleScope, + type BetterAuthModuleOptions, +} from "../../src/index.ts"; + +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("2099-01-01T10:00:00.000Z"); +const EXPIRED_AT = new Date("2000-01-01T10:00:00.000Z"); +const USER_ID = "managed-user-id"; + +function managedUser(overrides: Record = {}) { + return { + id: USER_ID, + name: "Managed User", + email: "managed@example.com", + emailVerified: true, + image: null, + role: "user", + banned: false, + banReason: null, + banExpires: null, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + ...overrides, + }; +} + +function managedSession(id: string, token: string, overrides: Record = {}) { + return { + id, + token, + userId: USER_ID, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + expiresAt: EXPIRES_AT, + ipAddress: "192.0.2.20", + userAgent: "Test Browser", + impersonatedBy: null, + ...overrides, + }; +} + +function createApi(overrides: Record = {}) { + return { + getSession: vi.fn(async (_input: unknown) => null), + getUser: vi.fn(async (_input: unknown) => managedUser()), + listUsers: vi.fn(async (_input: unknown) => ({ users: [managedUser()], total: 1 })), + adminUpdateUser: vi.fn(async (_input: unknown) => managedUser()), + setRole: vi.fn(async (_input: unknown) => ({ + user: managedUser({ role: "platform_admin,user" }), + })), + banUser: vi.fn(async (_input: unknown) => ({ + user: managedUser({ + banned: true, + banReason: "Policy violation", + banExpires: EXPIRES_AT, + }), + })), + unbanUser: vi.fn(async (_input: unknown) => ({ user: managedUser() })), + listUserSessions: vi.fn(async (_input: unknown) => ({ + sessions: [ + managedSession("active-id", "active-secret"), + managedSession("expired-id", "expired-secret", { expiresAt: EXPIRED_AT }), + ], + })), + revokeUserSession: vi.fn(async (_input: unknown) => ({ success: true })), + revokeUserSessions: vi.fn(async (_input: unknown) => ({ success: true })), + ...overrides, + }; +} + +interface LifecycleCall { + readonly scope: BetterAuthControlPlaneLifecycleScope; + readonly resourceId: string; +} + +class RecordingControlPlaneCoordinator implements BetterAuthControlPlaneLifecycleCoordinator { + readonly calls: LifecycleCall[] = []; + + async run( + scope: BetterAuthControlPlaneLifecycleScope, + resourceId: string, + operation: () => Promise, + ): Promise { + this.calls.push({ scope, resourceId }); + return operation(); + } +} + +function createService(api = createApi()) { + const auth = { + handler: async (_request: Request) => new Response(), + api, + options: {}, + $context: Promise.resolve({}), + $Infer: { Session: {} }, + $ERROR_CODES: {}, + } satisfies AnyAuth; + const coordinator = new RecordingControlPlaneCoordinator(); + const options = { auth, controlPlaneLifecycle: coordinator } satisfies BetterAuthModuleOptions; + return { + api, + coordinator, + service: new BetterAuthUserManagementService(new BetterAuthService(auth), options), + }; +} + +describe("BetterAuthUserManagementService", () => { + it("normalizes bounded list pagination without trusting Better Auth output", async () => { + const { api, service } = createService(); + + const result = await service.list({ authorization: "Bearer admin" }); + + expect(api.listUsers).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { + limit: 50, + offset: 0, + sortBy: "email", + sortDirection: "asc", + }, + }); + expect(result).toEqual({ + users: [ + { + id: USER_ID, + name: "Managed User", + email: "managed@example.com", + emailVerified: true, + image: null, + roles: ["user"], + banned: false, + banReason: null, + banExpiresAt: null, + createdAt: CREATED_AT, + updatedAt: UPDATED_AT, + redactedFields: [], + }, + ], + total: 1, + limit: 50, + offset: 0, + }); + }); + + it("maps only the stock safe search, exact filter, sort, and pagination inputs", async () => { + const { api, service } = createService(); + + await service.list( + {}, + { + limit: 20, + offset: 40, + search: " USER@Example.COM ", + searchField: "email", + searchOperator: "starts_with", + filter: { field: "role", value: " platform_admin " }, + sortBy: "updatedAt", + sortDirection: "desc", + }, + ); + + expect(api.listUsers).toHaveBeenCalledWith({ + headers: expect.any(Headers), + query: { + limit: 20, + offset: 40, + searchValue: "user@example.com", + searchField: "email", + searchOperator: "starts_with", + filterField: "role", + filterValue: "platform_admin", + filterOperator: "eq", + sortBy: "updatedAt", + sortDirection: "desc", + }, + }); + }); + + it("rejects a list response larger than the requested bound", async () => { + const { service } = createService( + createApi({ + listUsers: vi.fn(async (_input: unknown) => ({ + users: [managedUser(), managedUser({ id: "second-user" })], + total: 2, + })), + }), + ); + + await expect(service.list({}, { limit: 1 })).rejects.toMatchObject({ + status: 500, + response: { code: "INVALID_BETTER_AUTH_RESPONSE" }, + }); + }); + + it.each([ + { limit: 0 }, + { limit: 101 }, + { offset: -1 }, + { searchField: "email" }, + { filter: { field: "banned", value: "true" } }, + { filter: { field: "role", value: "user", extra: true } }, + { unknown: true }, + ])("rejects an unsafe list shape before calling Better Auth: %o", async (options) => { + const { api, service } = createService(); + + await expect( + service.list({}, options as unknown as Parameters[1]), + ).rejects.toMatchObject({ status: 400 }); + expect(api.listUsers).not.toHaveBeenCalled(); + }); + + it("updates only a normalized name and syntactically valid email", async () => { + const api = createApi({ + adminUpdateUser: vi.fn(async (_input: unknown) => + managedUser({ + name: "Renamed", + email: "renamed@example.com", + emailVerified: false, + }), + ), + }); + const { coordinator, service } = createService(api); + + const result = await service.updateProfile({}, USER_ID, { + name: " Renamed ", + email: " RENAMED@Example.COM ", + }); + + expect(api.adminUpdateUser).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { + userId: USER_ID, + data: { + name: "Renamed", + email: "renamed@example.com", + emailVerified: false, + }, + }, + }); + expect(result.email).toBe("renamed@example.com"); + expect(result.emailVerified).toBe(false); + expect(coordinator.calls).toEqual([{ scope: "user", resourceId: USER_ID }]); + + await expect( + service.updateProfile({}, USER_ID, { email: "not-an-email" }), + ).rejects.toMatchObject({ + status: 400, + }); + await expect( + service.updateProfile({}, USER_ID, { emailVerified: true } as unknown as Parameters< + typeof service.updateProfile + >[2]), + ).rejects.toMatchObject({ status: 400 }); + expect(api.adminUpdateUser).toHaveBeenCalledOnce(); + }); + + it("preserves verification for a same/case-only email while applying a name update", async () => { + const api = createApi({ + getUser: vi.fn(async (_input: unknown) => + managedUser({ email: "Managed@Example.com", emailVerified: true }), + ), + adminUpdateUser: vi.fn(async (_input: unknown) => + managedUser({ + name: "Renamed", + email: "Managed@Example.com", + emailVerified: true, + }), + ), + }); + const { service } = createService(api); + + const result = await service.updateProfile({}, USER_ID, { + name: " Renamed ", + email: " managed@example.COM ", + }); + + expect(api.adminUpdateUser).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID, data: { name: "Renamed" } }, + }); + expect(result).toMatchObject({ + name: "Renamed", + email: "Managed@Example.com", + emailVerified: true, + }); + }); + + it("returns the authoritative user without calling adminUpdateUser for an email-only no-op", async () => { + const api = createApi({ + getUser: vi.fn(async (_input: unknown) => + managedUser({ email: "Managed@Example.com", emailVerified: true }), + ), + }); + const { coordinator, service } = createService(api); + + const result = await service.updateProfile({}, USER_ID, { + email: "managed@example.com", + }); + + expect(api.adminUpdateUser).not.toHaveBeenCalled(); + expect(result.email).toBe("Managed@Example.com"); + expect(result.emailVerified).toBe(true); + expect(coordinator.calls).toEqual([{ scope: "user", resourceId: USER_ID }]); + }); + + it("serializes roles and ban state through the namespaced user lifecycle", async () => { + const { api, coordinator, service } = createService(); + + const roleResult = await service.setRoles({}, USER_ID, [" platform_admin ", "user"]); + const banned = await service.ban({}, USER_ID, { + reason: " Policy violation ", + expiresInSeconds: 3_600, + }); + const unbanned = await service.unban({}, USER_ID); + + expect(api.setRole).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID, role: ["platform_admin", "user"] }, + }); + expect(api.banUser).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID, banReason: "Policy violation", banExpiresIn: 3_600 }, + }); + expect(api.unbanUser).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID }, + }); + expect(roleResult.roles).toEqual(["platform_admin", "user"]); + expect(banned).toMatchObject({ banned: true, banReason: "Policy violation" }); + expect(unbanned.banned).toBe(false); + expect(coordinator.calls).toEqual([ + { scope: "user", resourceId: USER_ID }, + { scope: "user", resourceId: USER_ID }, + { scope: "user", resourceId: USER_ID }, + ]); + }); + + it("omits ban fields so the stock admin plugin can apply its configured defaults", async () => { + const { api, service } = createService(); + + await service.ban({}, USER_ID); + + expect(api.banUser).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID }, + }); + }); + + it("clears a previous temporary expiry before applying an expiry-omitted re-ban", async () => { + const banUser = vi.fn(async (_input: unknown) => ({ + user: managedUser({ banned: true, banExpires: null }), + })); + const api = createApi({ + getUser: vi.fn(async (_input: unknown) => + managedUser({ banned: true, banExpires: EXPIRES_AT }), + ), + banUser, + }); + const { coordinator, service } = createService(api); + + const result = await service.ban({}, USER_ID, { reason: "Permanent policy ban" }); + + expect(api.adminUpdateUser).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID, data: { banned: true, banExpires: null } }, + }); + expect(api.banUser).toHaveBeenCalledOnce(); + expect(api.banUser).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID, banReason: "Permanent policy ban" }, + }); + expect(api.adminUpdateUser.mock.invocationCallOrder[0]).toBeLessThan( + api.banUser.mock.invocationCallOrder[0] ?? 0, + ); + expect(api.unbanUser).not.toHaveBeenCalled(); + expect(result).toMatchObject({ banned: true, banExpiresAt: null }); + expect(coordinator.calls).toEqual([{ scope: "user", resourceId: USER_ID }]); + }); + + it("requires stock user:update permission for the fail-closed stale-expiry correction", async () => { + const api = createApi({ + getUser: vi.fn(async (_input: unknown) => + managedUser({ banned: true, banExpires: EXPIRES_AT }), + ), + adminUpdateUser: vi.fn(async (_input: unknown) => { + throw new APIError("FORBIDDEN", { + code: "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS", + message: "You are not allowed to change users.", + }); + }), + }); + const { service } = createService(api); + + await expect(service.ban({}, USER_ID)).rejects.toMatchObject({ status: 403 }); + + expect(api.adminUpdateUser).toHaveBeenCalledOnce(); + expect(api.banUser).not.toHaveBeenCalled(); + expect(api.unbanUser).not.toHaveBeenCalled(); + }); + + it("leaves a temporary target permanently banned when the final re-ban fails", async () => { + let persisted = managedUser({ banned: true, banExpires: EXPIRES_AT }); + const adminUpdateUser = vi.fn(async (_input: unknown) => { + persisted = managedUser({ banned: true, banExpires: null }); + return persisted; + }); + const api = createApi({ + getUser: vi.fn(async (_input: unknown) => persisted), + adminUpdateUser, + banUser: vi.fn(async (_input: unknown) => { + throw new Error("final ban failed"); + }), + }); + const { service } = createService(api); + + await expect(service.ban({}, USER_ID)).rejects.toThrow("final ban failed"); + + expect(persisted).toMatchObject({ banned: true, banExpires: null }); + expect(api.adminUpdateUser).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID, data: { banned: true, banExpires: null } }, + }); + expect(api.unbanUser).not.toHaveBeenCalled(); + }); + + it("rejects non-record mutation inputs before calling Better Auth", async () => { + const { api, service } = createService(); + + await expect( + service.ban({}, USER_ID, [] as unknown as Parameters[2]), + ).rejects.toMatchObject({ status: 400 }); + await expect( + service.updateProfile( + {}, + USER_ID, + new Date() as unknown as Parameters[2], + ), + ).rejects.toMatchObject({ status: 400 }); + expect(api.banUser).not.toHaveBeenCalled(); + expect(api.adminUpdateUser).not.toHaveBeenCalled(); + }); + + it("enforces identifier, role, and ban bounds before mutation", async () => { + const { api, service } = createService(); + + await expect(service.get({}, " ")).rejects.toMatchObject({ status: 400 }); + await expect(service.setRoles({}, USER_ID, [])).rejects.toMatchObject({ status: 400 }); + await expect(service.setRoles({}, USER_ID, "admin,user")).rejects.toMatchObject({ + status: 400, + }); + await expect(service.setRoles({}, USER_ID, "admin\nuser")).rejects.toMatchObject({ + status: 400, + }); + await expect(service.ban({}, USER_ID, { expiresInSeconds: 0 })).rejects.toMatchObject({ + status: 400, + }); + await expect( + service.ban({}, USER_ID, { expiresInSeconds: 365 * 24 * 60 * 60 + 1 }), + ).rejects.toMatchObject({ status: 400 }); + expect(api.getUser).not.toHaveBeenCalled(); + expect(api.setRole).not.toHaveBeenCalled(); + expect(api.banUser).not.toHaveBeenCalled(); + }); + + it("returns only active, token-free sessions after a managed-user preflight", async () => { + const api = createApi({ + listUserSessions: vi.fn(async (_input: unknown) => ({ + sessions: [ + managedSession("older", "older-secret", { + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ipAddress: undefined, + userAgent: null, + }), + managedSession("newer", "newer-secret", { + updatedAt: new Date("2026-01-03T00:00:00.000Z"), + impersonatedBy: "admin-id", + }), + managedSession("expired", "expired-secret", { expiresAt: EXPIRED_AT }), + ], + })), + }); + const { service } = createService(api); + + const result = await service.listSessions({}, USER_ID); + + expect(api.getUser).toHaveBeenCalledBefore(api.listUserSessions); + expect(result.map(({ id }) => id)).toEqual(["newer", "older"]); + expect(result[0]?.impersonated).toBe(true); + expect(result[1]).toMatchObject({ ipAddress: null, userAgent: null, impersonated: false }); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("token"); + expect(serialized).not.toContain("secret"); + expect(serialized).not.toContain("userId"); + }); + + it("resolves a target-owned active session id to its private token before revocation", async () => { + const { api, coordinator, service } = createService(); + + await expect(service.revokeSessionById({}, USER_ID, "active-id")).resolves.toEqual({ + success: true, + revokedSessionId: "active-id", + }); + expect(api.revokeUserSession).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { sessionToken: "active-secret" }, + }); + expect(coordinator.calls).toEqual([{ scope: "user", resourceId: USER_ID }]); + }); + + it("projects oversized session metadata without blocking safe-id revocation", async () => { + const api = createApi({ + listUserSessions: vi.fn(async (_input: unknown) => ({ + sessions: [ + managedSession("active-id", "active-secret", { + ipAddress: "1".repeat(256), + userAgent: "u".repeat(1_025), + }), + ], + })), + }); + const { service } = createService(api); + + const sessions = await service.listSessions({}, USER_ID); + await expect(service.revokeSessionById({}, USER_ID, "active-id")).resolves.toMatchObject({ + success: true, + }); + + expect(sessions[0]).toMatchObject({ + ipAddress: "1".repeat(255), + userAgent: "u".repeat(1_024), + redactedFields: ["ipAddress", "userAgent"], + }); + expect(api.revokeUserSession).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { sessionToken: "active-secret" }, + }); + }); + + it.each(["unknown-id", "expired-id"])( + "does not reveal or revoke an absent session id (%s)", + async (sessionId) => { + const { api, service } = createService(); + + await expect(service.revokeSessionById({}, USER_ID, sessionId)).rejects.toMatchObject({ + status: 404, + response: { + statusCode: 404, + code: "SESSION_NOT_FOUND", + message: "Session not found.", + }, + }); + expect(api.revokeUserSession).not.toHaveBeenCalled(); + }, + ); + + it("preflights the managed user before bulk revocation", async () => { + const { api, coordinator, service } = createService(); + + await expect(service.revokeAllSessions({}, USER_ID)).resolves.toEqual({ success: true }); + + expect(api.getUser).toHaveBeenCalledBefore(api.revokeUserSessions); + expect(api.revokeUserSessions).toHaveBeenCalledWith({ + headers: expect.any(Headers), + body: { userId: USER_ID }, + }); + expect(coordinator.calls).toEqual([{ scope: "user", resourceId: USER_ID }]); + }); + + it("rejects an oversized stock session list without disabling bulk revocation", async () => { + const api = createApi({ + listUserSessions: vi.fn(async (_input: unknown) => ({ + sessions: Array.from({ length: 1_001 }, (_, index) => + managedSession(`session-${index}`, `secret-${index}`), + ), + })), + }); + const { service } = createService(api); + + await expect(service.listSessions({}, USER_ID)).rejects.toMatchObject({ + status: 500, + response: { code: "INVALID_BETTER_AUTH_RESPONSE" }, + }); + await expect(service.revokeSessionById({}, USER_ID, "session-0")).rejects.toMatchObject({ + status: 500, + response: { code: "INVALID_BETTER_AUTH_RESPONSE" }, + }); + await expect(service.revokeAllSessions({}, USER_ID)).resolves.toEqual({ success: true }); + expect(api.revokeUserSession).not.toHaveBeenCalled(); + expect(api.revokeUserSessions).toHaveBeenCalledOnce(); + }); + + it("rejects malformed user and session payloads instead of leaking raw data", async () => { + const malformedUserApi = createApi({ + getUser: vi.fn(async (_input: unknown) => managedUser({ role: undefined })), + }); + const malformedSessionApi = createApi({ + listUserSessions: vi.fn(async (_input: unknown) => ({ + sessions: [managedSession("foreign", "foreign-secret", { userId: "other-user" })], + })), + }); + + await expect(createService(malformedUserApi).service.get({}, USER_ID)).rejects.toMatchObject({ + status: 500, + response: { code: "INVALID_BETTER_AUTH_RESPONSE" }, + }); + await expect( + createService(malformedSessionApi).service.listSessions({}, USER_ID), + ).rejects.toMatchObject({ + status: 500, + response: { code: "INVALID_BETTER_AUTH_RESPONSE" }, + }); + }); + + it("keeps stock-valid display overflow bounded and explicitly redacted", async () => { + const { service } = createService( + createApi({ + getUser: vi.fn(async (_input: unknown) => + managedUser({ + name: "n".repeat(257), + email: `${"e".repeat(400)}@example.com`, + image: "i".repeat(4_097), + banReason: "b".repeat(1_025), + }), + ), + }), + ); + + const result = await service.get({}, USER_ID); + + expect(result).toMatchObject({ + name: "n".repeat(256), + email: null, + image: null, + banReason: "b".repeat(1_024), + redactedFields: ["name", "email", "image", "banReason"], + }); + }); + + it.each(["", null, 42, { attackerControlled: true }])( + "projects an unusable stock profile name as unavailable: %o", + async (name) => { + const { service } = createService( + createApi({ getUser: vi.fn(async (_input: unknown) => managedUser({ name })) }), + ); + + await expect(service.get({}, USER_ID)).resolves.toMatchObject({ + name: null, + redactedFields: ["name"], + }); + }, + ); + + it("keeps oversized operational identifiers strict", async () => { + const { service } = createService( + createApi({ + getUser: vi.fn(async (_input: unknown) => managedUser({ id: "x".repeat(1_025) })), + }), + ); + + await expect(service.get({}, USER_ID)).rejects.toMatchObject({ + status: 500, + response: { code: "INVALID_BETTER_AUTH_RESPONSE" }, + }); + }); + + it("maps stock Better Auth authorization errors at the shared invocation boundary", async () => { + const { service } = createService( + createApi({ + getUser: vi.fn(async () => { + throw new APIError("FORBIDDEN", { + code: "YOU_ARE_NOT_ALLOWED_TO_PERFORM_THIS_ACTION", + message: "You are not allowed to perform this action.", + }); + }), + }), + ); + + const error = await service.get({}, USER_ID).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(HttpException); + if (!(error instanceof HttpException)) throw error; + expect(error.getResponse()).toEqual({ + statusCode: 403, + code: "YOU_ARE_NOT_ALLOWED_TO_PERFORM_THIS_ACTION", + message: "You are not allowed to perform this action.", + }); + }); + + it("fails clearly when the stock admin plugin API is absent", async () => { + const api = createApi(); + const { banUser: _missing, ...withoutBanUser } = api; + const { service } = createService(withoutBanUser as unknown as ReturnType); + + await expect(service.get({}, USER_ID)).rejects.toThrow( + "The Better Auth admin API does not provide 'banUser'.", + ); + }); +});