From 1ebb22a87dd06eb8612e02efd93c2738a51c5ba2 Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Tue, 4 Aug 2026 13:30:03 -0300 Subject: [PATCH] feat: honor foreign public-route metadata --- .changeset/public-metadata-interop.md | 7 ++ README.md | 4 + src/guards/better-auth.guard.ts | 26 ++++- src/index.ts | 1 + .../better-auth-module-options.interface.ts | 11 ++ tests/e2e/interop.e2e.test.ts | 102 ++++++++++++++++++ tests/packed-types/consumer.ts | 17 ++- 7 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 .changeset/public-metadata-interop.md create mode 100644 tests/e2e/interop.e2e.test.ts diff --git a/.changeset/public-metadata-interop.md b/.changeset/public-metadata-interop.md new file mode 100644 index 0000000..41ab293 --- /dev/null +++ b/.changeset/public-metadata-interop.md @@ -0,0 +1,7 @@ +--- +"@nestm/better-auth": patch +--- + +Let `BetterAuthGuard` honor foreign public-route metadata through +`interop.publicKeys`, removing the need for application wrapper guards when +another framework owns a public endpoint. diff --git a/README.md b/README.md index 5ee9584..f18f154 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ BetterAuthModule.forRootAsync({ | `cors` | option | `false` to disable, or `{ origin, credentials, methods, allowedHeaders, maxAge }`. Defaults to array `trustedOrigins`. | | `routePolicy` | option | Adapter-independent HTTP policy that runs after auth-route CORS/body recovery and before `middleware` or better-auth. Return a Web `Response` to short-circuit. | | `middleware` | option | `(req, res, run) => …` wrapper around the auth handler — for MikroORM `RequestContext` / AsyncLocalStorage setups. | +| `interop.publicKeys` | option | Metadata keys from other guards that mean public. Their presence skips session lookup with the same handler-level authorization override as `@AllowAnonymous()`. | | `isGlobal` | extra | Default `true`. | | `disableGlobalGuard` | extra | Skip the automatic `APP_GUARD` registration. | @@ -157,6 +158,9 @@ Notes: - Authorization is fail-closed: a class-level `@AllowAnonymous`/`@OptionalAuth` is ignored on handlers that declare their own `@Roles`/`@OrgRoles`/`@RequireActiveOrg`/permission requirements (a handler-level `@AllowAnonymous` still wins). +- `interop.publicKeys` lets one global guard honor another package's public-route decorator + without an application wrapper guard. Handler-level Better Auth requirements still override a + class-level foreign marker; a foreign marker placed on the handler itself is explicit and wins. - WebSocket gateways need `@UseGuards(BetterAuthGuard)` explicitly (Nest's `APP_GUARD` does not cover gateways). The guard understands http, ws, and rpc contexts; GraphQL is wired but currently **experimental** (the `@nestjs/graphql` v12-compatible stack is not yet stable). diff --git a/src/guards/better-auth.guard.ts b/src/guards/better-auth.guard.ts index 5c295ad..9c84a13 100644 --- a/src/guards/better-auth.guard.ts +++ b/src/guards/better-auth.guard.ts @@ -1,9 +1,9 @@ -import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Inject, Injectable, Logger, Optional } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { fromNodeHeaders } from "better-auth/node"; import type { CanActivate, ExecutionContext } from "@nestjs/common"; import { SESSION_RESOLVED } from "../better-auth.constants.ts"; -import { BETTER_AUTH_INSTANCE } from "../better-auth.tokens.ts"; +import { BETTER_AUTH_INSTANCE, BETTER_AUTH_MODULE_OPTIONS } from "../better-auth.tokens.ts"; import { AllowAnonymous, MemberHasPermission, @@ -22,6 +22,7 @@ import { } from "../utils/execution-context.util.ts"; import { createAuthError } from "./auth-errors.ts"; import type { AnyAuth } from "../types/auth.types.ts"; +import type { BetterAuthModuleOptions } from "../interfaces/better-auth-module-options.interface.ts"; /** Loosely-typed view of the session for guard-internal checks. */ interface GuardSession { @@ -29,6 +30,8 @@ interface GuardSession { session?: { activeOrganizationId?: string } & Record; } +type ReflectTarget = Parameters[1]; + function matchesRequiredRole( role: string | readonly string[] | null | undefined, required: readonly string[], @@ -50,6 +53,9 @@ export class BetterAuthGuard implements CanActivate { constructor( private readonly reflector: Reflector, @Inject(BETTER_AUTH_INSTANCE) private readonly auth: AnyAuth, + @Optional() + @Inject(BETTER_AUTH_MODULE_OPTIONS) + private readonly options?: BetterAuthModuleOptions, ) {} /** @@ -84,6 +90,9 @@ export class BetterAuthGuard implements CanActivate { anonymous = undefined; optional = undefined; } + if (anonymous === undefined && this.hasInteropPublicMarker(handler, targets)) { + anonymous = {}; + } const kind = resolveContextKind(context); const request = await getRequestFromContext(context); // A WS "request" is the long-lived socket client and an RPC context has @@ -156,6 +165,19 @@ export class BetterAuthGuard implements CanActivate { return true; } + /** Foreign public markers follow the same class-vs-handler precedence as our own decorators. */ + private hasInteropPublicMarker(handler: ReflectTarget, targets: ReflectTarget[]): boolean { + const publicKeys = this.options?.interop?.publicKeys ?? []; + const declaredOnHandler = publicKeys.some( + (key) => this.reflector.get(key, handler) !== undefined, + ); + if (declaredOnHandler) return true; + if (this.handlerDeclaresAuthorization(handler)) return false; + return publicKeys.some( + (key) => this.reflector.getAllAndOverride(key, targets) !== undefined, + ); + } + private api(): Record { return this.auth.api as unknown as Record; } diff --git a/src/index.ts b/src/index.ts index 12faabf..fa114ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export type { BetterAuthOptionsModeOptions, BetterAuthModuleExtras, BetterAuthCorsOptions, + BetterAuthInteropOptions, BetterAuthRequestMiddleware, BetterAuthRoutePolicy, BetterAuthRoutePolicyContext, diff --git a/src/interfaces/better-auth-module-options.interface.ts b/src/interfaces/better-auth-module-options.interface.ts index bc37c65..77b3cb3 100644 --- a/src/interfaces/better-auth-module-options.interface.ts +++ b/src/interfaces/better-auth-module-options.interface.ts @@ -64,6 +64,16 @@ export type BetterAuthRoutePolicy = ( context: BetterAuthRoutePolicyContext, ) => Promise | Response | void; +/** Metadata owned by another guard that {@link BetterAuthGuard} should honor. */ +export interface BetterAuthInteropOptions { + /** + * Foreign `@Public()`-equivalent metadata keys. Their presence skips session + * resolution, subject to the same handler-level authorization override as + * {@link AllowAnonymous}. + */ + readonly publicKeys?: readonly (string | symbol)[]; +} + interface BetterAuthModuleCommonOptions { /** * Overrides the mount path. When omitted it is resolved from the auth @@ -74,6 +84,7 @@ interface BetterAuthModuleCommonOptions { cors?: false | BetterAuthCorsOptions; middleware?: BetterAuthRequestMiddleware; routePolicy?: BetterAuthRoutePolicy; + interop?: BetterAuthInteropOptions; } /** diff --git a/tests/e2e/interop.e2e.test.ts b/tests/e2e/interop.e2e.test.ts new file mode 100644 index 0000000..6ea47e0 --- /dev/null +++ b/tests/e2e/interop.e2e.test.ts @@ -0,0 +1,102 @@ +import { Controller, Get, SetMetadata } from "@nestjs/common"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { INestApplication } from "@nestjs/common"; + +import { OptionalAuth, Roles } from "../../src/index.ts"; +import { createTestAuth } from "../shared/test-auth.ts"; +import { createTestApp } from "../shared/test-app.ts"; +import { testHttpAdapter } from "../shared/http-adapter.ts"; + +const FOREIGN_PUBLIC = "test:foreign-public"; +const SECOND_FOREIGN_PUBLIC = "test:second-foreign-public"; + +@Controller("interop") +@SetMetadata(FOREIGN_PUBLIC, true) +class InteropController { + @Get("open") + open(): { ok: true } { + return { ok: true }; + } + + @Get("guarded") + @Roles("admin") + guarded(): { ok: true } { + return { ok: true }; + } + + @Get("explicit-open") + @Roles("admin") + @SetMetadata(FOREIGN_PUBLIC, true) + explicitOpen(): { ok: true } { + return { ok: true }; + } + + @Get("explicit-open-second-key") + @Roles("admin") + @SetMetadata(SECOND_FOREIGN_PUBLIC, true) + explicitOpenSecondKey(): { ok: true } { + return { ok: true }; + } + + @Get("explicit-open-with-optional") + @OptionalAuth() + @SetMetadata(SECOND_FOREIGN_PUBLIC, true) + explicitOpenWithOptional(): { ok: true } { + return { ok: true }; + } +} + +describe(`public metadata interop (${testHttpAdapter})`, () => { + let app: INestApplication; + let auth: ReturnType; + + beforeAll(async () => { + auth = createTestAuth(); + app = await createTestApp({ + forRoot: { + auth, + interop: { publicKeys: [FOREIGN_PUBLIC, SECOND_FOREIGN_PUBLIC] }, + }, + metadata: { controllers: [InteropController] }, + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it("serves a foreign-public route without resolving a session", async () => { + const getSession = vi.spyOn(auth.api, "getSession"); + + await request(app.getHttpServer()).get("/interop/open").expect(200, { ok: true }); + + expect(getSession).not.toHaveBeenCalled(); + getSession.mockRestore(); + }); + + it("lets a handler-level auth requirement override a class-level foreign marker", async () => { + await request(app.getHttpServer()).get("/interop/guarded").expect(401); + }); + + it("lets a handler-level foreign marker explicitly override that requirement", async () => { + await request(app.getHttpServer()).get("/interop/explicit-open").expect(200, { ok: true }); + }); + + it("checks every key for a handler-level marker before inherited markers", async () => { + await request(app.getHttpServer()) + .get("/interop/explicit-open-second-key") + .expect(200, { ok: true }); + }); + + it("keeps an explicit foreign-public handler from resolving an optional session", async () => { + const getSession = vi.spyOn(auth.api, "getSession"); + + await request(app.getHttpServer()) + .get("/interop/explicit-open-with-optional") + .expect(200, { ok: true }); + + expect(getSession).not.toHaveBeenCalled(); + getSession.mockRestore(); + }); +}); diff --git a/tests/packed-types/consumer.ts b/tests/packed-types/consumer.ts index 6f848de..2348409 100644 --- a/tests/packed-types/consumer.ts +++ b/tests/packed-types/consumer.ts @@ -1,4 +1,17 @@ -import { BetterAuthModule } from "@nestm/better-auth"; +import { + BetterAuthGuard, + BetterAuthModule, + type AnyAuth, + type BetterAuthInteropOptions, +} from "@nestm/better-auth"; +import type { Reflector } from "@nestjs/core"; + +declare const reflector: Reflector; +declare const auth: AnyAuth; + +// Patch releases must preserve the guard's original two-argument constructor. +const manuallyConstructedGuard = new BetterAuthGuard(reflector, auth); +const interop: BetterAuthInteropOptions = { publicKeys: ["legacy:public", Symbol()] }; const synchronousModule = BetterAuthModule.forRoot({ options: { @@ -15,4 +28,4 @@ const asynchronousModule = BetterAuthModule.forRootAsync({ }), }); -export { asynchronousModule, synchronousModule }; +export { asynchronousModule, interop, manuallyConstructedGuard, synchronousModule };