Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/public-metadata-interop.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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).
Expand Down
26 changes: 24 additions & 2 deletions src/guards/better-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,20 +22,23 @@
} 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 {
user?: { role?: string | string[] } & Record<string, unknown>;
session?: { activeOrganizationId?: string } & Record<string, unknown>;
}

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

function matchesRequiredRole(
role: string | readonly string[] | null | undefined,
required: readonly string[],
): boolean {
if (!role) return false;
const actual = Array.isArray(role)
? (role as string[])

Check warning on line 41 in src/guards/better-auth.guard.ts

View workflow job for this annotation

GitHub Actions / check

typescript(no-unsafe-type-assertion)

Unsafe assertion from `any` detected: consider using type guards or a safer assertion.
: String(role)
.split(",")
.map((r) => r.trim());
Expand All @@ -50,6 +53,9 @@
constructor(
private readonly reflector: Reflector,
@Inject(BETTER_AUTH_INSTANCE) private readonly auth: AnyAuth,
@Optional()
@Inject(BETTER_AUTH_MODULE_OPTIONS)
private readonly options?: BetterAuthModuleOptions,
) {}

/**
Expand Down Expand Up @@ -84,6 +90,9 @@
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
Expand All @@ -107,9 +116,9 @@
let session: GuardSession | null;
if (cacheable && request?.[SESSION_RESOLVED]) {
// Idempotency: APP_GUARD + @UseGuards on the same route must not double-fetch.
session = (request.session ?? null) as GuardSession | null;

Check warning on line 119 in src/guards/better-auth.guard.ts

View workflow job for this annotation

GitHub Actions / check

typescript(no-unsafe-type-assertion)

Unsafe assertion from `any` detected: consider using type guards or a safer assertion.
} else {
session = ((await this.auth.api.getSession({ headers })) ?? null) as GuardSession | null;

Check warning on line 121 in src/guards/better-auth.guard.ts

View workflow job for this annotation

GitHub Actions / check

typescript(no-unsafe-type-assertion)

Unsafe assertion from `any` detected: consider using type guards or a safer assertion.
if (request) {
request.session = session;
request.user = session?.user ?? null;
Expand All @@ -126,7 +135,7 @@

const orgRoles = this.reflector.getAllAndOverride(OrgRoles, targets);
const requireActiveOrg =
this.reflector.getAllAndOverride(RequireActiveOrg, targets) === true || !!orgRoles;

Check warning on line 138 in src/guards/better-auth.guard.ts

View workflow job for this annotation

GitHub Actions / check

typescript(no-unnecessary-boolean-literal-compare)

This expression unnecessarily compares a boolean value to a boolean instead of using it directly.
if (requireActiveOrg && !session.session?.activeOrganizationId) {
throw await createAuthError(kind, "FORBIDDEN", "Active organization is required");
}
Expand Down Expand Up @@ -156,6 +165,19 @@
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<unknown>(key, handler) !== undefined,
);
if (declaredOnHandler) return true;
if (this.handlerDeclaresAuthorization(handler)) return false;
return publicKeys.some(
(key) => this.reflector.getAllAndOverride<unknown>(key, targets) !== undefined,
);
}

private api(): Record<string, unknown> {
return this.auth.api as unknown as Record<string, unknown>;
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type {
BetterAuthOptionsModeOptions,
BetterAuthModuleExtras,
BetterAuthCorsOptions,
BetterAuthInteropOptions,
BetterAuthRequestMiddleware,
BetterAuthRoutePolicy,
BetterAuthRoutePolicyContext,
Expand Down
11 changes: 11 additions & 0 deletions src/interfaces/better-auth-module-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ export type BetterAuthRoutePolicy = (
context: BetterAuthRoutePolicyContext,
) => Promise<Response | void> | 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
Expand All @@ -74,6 +84,7 @@ interface BetterAuthModuleCommonOptions {
cors?: false | BetterAuthCorsOptions;
middleware?: BetterAuthRequestMiddleware;
routePolicy?: BetterAuthRoutePolicy;
interop?: BetterAuthInteropOptions;
}

/**
Expand Down
102 changes: 102 additions & 0 deletions tests/e2e/interop.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createTestAuth>;

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();
});
});
17 changes: 15 additions & 2 deletions tests/packed-types/consumer.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -15,4 +28,4 @@ const asynchronousModule = BetterAuthModule.forRootAsync({
}),
});

export { asynchronousModule, synchronousModule };
export { asynchronousModule, interop, manuallyConstructedGuard, synchronousModule };
Loading