Skip to content

Commit 9d33311

Browse files
authored
Merge pull request #13 from nestm-dev/codex/public-metadata-interop
feat: honor foreign public-route metadata
2 parents 8ad4ad0 + 1ebb22a commit 9d33311

7 files changed

Lines changed: 164 additions & 4 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@nestm/better-auth": patch
3+
---
4+
5+
Let `BetterAuthGuard` honor foreign public-route metadata through
6+
`interop.publicKeys`, removing the need for application wrapper guards when
7+
another framework owns a public endpoint.

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ BetterAuthModule.forRootAsync({
110110
| `cors` | option | `false` to disable, or `{ origin, credentials, methods, allowedHeaders, maxAge }`. Defaults to array `trustedOrigins`. |
111111
| `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. |
112112
| `middleware` | option | `(req, res, run) => …` wrapper around the auth handler — for MikroORM `RequestContext` / AsyncLocalStorage setups. |
113+
| `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()`. |
113114
| `isGlobal` | extra | Default `true`. |
114115
| `disableGlobalGuard` | extra | Skip the automatic `APP_GUARD` registration. |
115116
@@ -157,6 +158,9 @@ Notes:
157158
- Authorization is fail-closed: a class-level `@AllowAnonymous`/`@OptionalAuth` is ignored on
158159
handlers that declare their own `@Roles`/`@OrgRoles`/`@RequireActiveOrg`/permission
159160
requirements (a handler-level `@AllowAnonymous` still wins).
161+
- `interop.publicKeys` lets one global guard honor another package's public-route decorator
162+
without an application wrapper guard. Handler-level Better Auth requirements still override a
163+
class-level foreign marker; a foreign marker placed on the handler itself is explicit and wins.
160164
- WebSocket gateways need `@UseGuards(BetterAuthGuard)` explicitly (Nest's `APP_GUARD` does
161165
not cover gateways). The guard understands http, ws, and rpc contexts; GraphQL is wired but
162166
currently **experimental** (the `@nestjs/graphql` v12-compatible stack is not yet stable).

‎src/guards/better-auth.guard.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Inject, Injectable, Logger } from "@nestjs/common";
1+
import { Inject, Injectable, Logger, Optional } from "@nestjs/common";
22
import { Reflector } from "@nestjs/core";
33
import { fromNodeHeaders } from "better-auth/node";
44
import type { CanActivate, ExecutionContext } from "@nestjs/common";
55
import { SESSION_RESOLVED } from "../better-auth.constants.ts";
6-
import { BETTER_AUTH_INSTANCE } from "../better-auth.tokens.ts";
6+
import { BETTER_AUTH_INSTANCE, BETTER_AUTH_MODULE_OPTIONS } from "../better-auth.tokens.ts";
77
import {
88
AllowAnonymous,
99
MemberHasPermission,
@@ -22,13 +22,16 @@ import {
2222
} from "../utils/execution-context.util.ts";
2323
import { createAuthError } from "./auth-errors.ts";
2424
import type { AnyAuth } from "../types/auth.types.ts";
25+
import type { BetterAuthModuleOptions } from "../interfaces/better-auth-module-options.interface.ts";
2526

2627
/** Loosely-typed view of the session for guard-internal checks. */
2728
interface GuardSession {
2829
user?: { role?: string | string[] } & Record<string, unknown>;
2930
session?: { activeOrganizationId?: string } & Record<string, unknown>;
3031
}
3132

33+
type ReflectTarget = Parameters<Reflector["get"]>[1];
34+
3235
function matchesRequiredRole(
3336
role: string | readonly string[] | null | undefined,
3437
required: readonly string[],
@@ -50,6 +53,9 @@ export class BetterAuthGuard implements CanActivate {
5053
constructor(
5154
private readonly reflector: Reflector,
5255
@Inject(BETTER_AUTH_INSTANCE) private readonly auth: AnyAuth,
56+
@Optional()
57+
@Inject(BETTER_AUTH_MODULE_OPTIONS)
58+
private readonly options?: BetterAuthModuleOptions,
5359
) {}
5460

5561
/**
@@ -84,6 +90,9 @@ export class BetterAuthGuard implements CanActivate {
8490
anonymous = undefined;
8591
optional = undefined;
8692
}
93+
if (anonymous === undefined && this.hasInteropPublicMarker(handler, targets)) {
94+
anonymous = {};
95+
}
8796
const kind = resolveContextKind(context);
8897
const request = await getRequestFromContext(context);
8998
// A WS "request" is the long-lived socket client and an RPC context has
@@ -156,6 +165,19 @@ export class BetterAuthGuard implements CanActivate {
156165
return true;
157166
}
158167

168+
/** Foreign public markers follow the same class-vs-handler precedence as our own decorators. */
169+
private hasInteropPublicMarker(handler: ReflectTarget, targets: ReflectTarget[]): boolean {
170+
const publicKeys = this.options?.interop?.publicKeys ?? [];
171+
const declaredOnHandler = publicKeys.some(
172+
(key) => this.reflector.get<unknown>(key, handler) !== undefined,
173+
);
174+
if (declaredOnHandler) return true;
175+
if (this.handlerDeclaresAuthorization(handler)) return false;
176+
return publicKeys.some(
177+
(key) => this.reflector.getAllAndOverride<unknown>(key, targets) !== undefined,
178+
);
179+
}
180+
159181
private api(): Record<string, unknown> {
160182
return this.auth.api as unknown as Record<string, unknown>;
161183
}

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type {
3131
BetterAuthOptionsModeOptions,
3232
BetterAuthModuleExtras,
3333
BetterAuthCorsOptions,
34+
BetterAuthInteropOptions,
3435
BetterAuthRequestMiddleware,
3536
BetterAuthRoutePolicy,
3637
BetterAuthRoutePolicyContext,

‎src/interfaces/better-auth-module-options.interface.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ export type BetterAuthRoutePolicy = (
6464
context: BetterAuthRoutePolicyContext,
6565
) => Promise<Response | void> | Response | void;
6666

67+
/** Metadata owned by another guard that {@link BetterAuthGuard} should honor. */
68+
export interface BetterAuthInteropOptions {
69+
/**
70+
* Foreign `@Public()`-equivalent metadata keys. Their presence skips session
71+
* resolution, subject to the same handler-level authorization override as
72+
* {@link AllowAnonymous}.
73+
*/
74+
readonly publicKeys?: readonly (string | symbol)[];
75+
}
76+
6777
interface BetterAuthModuleCommonOptions {
6878
/**
6979
* Overrides the mount path. When omitted it is resolved from the auth
@@ -74,6 +84,7 @@ interface BetterAuthModuleCommonOptions {
7484
cors?: false | BetterAuthCorsOptions;
7585
middleware?: BetterAuthRequestMiddleware;
7686
routePolicy?: BetterAuthRoutePolicy;
87+
interop?: BetterAuthInteropOptions;
7788
}
7889

7990
/**

‎tests/e2e/interop.e2e.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { Controller, Get, SetMetadata } from "@nestjs/common";
2+
import request from "supertest";
3+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
4+
import type { INestApplication } from "@nestjs/common";
5+
6+
import { OptionalAuth, Roles } from "../../src/index.ts";
7+
import { createTestAuth } from "../shared/test-auth.ts";
8+
import { createTestApp } from "../shared/test-app.ts";
9+
import { testHttpAdapter } from "../shared/http-adapter.ts";
10+
11+
const FOREIGN_PUBLIC = "test:foreign-public";
12+
const SECOND_FOREIGN_PUBLIC = "test:second-foreign-public";
13+
14+
@Controller("interop")
15+
@SetMetadata(FOREIGN_PUBLIC, true)
16+
class InteropController {
17+
@Get("open")
18+
open(): { ok: true } {
19+
return { ok: true };
20+
}
21+
22+
@Get("guarded")
23+
@Roles("admin")
24+
guarded(): { ok: true } {
25+
return { ok: true };
26+
}
27+
28+
@Get("explicit-open")
29+
@Roles("admin")
30+
@SetMetadata(FOREIGN_PUBLIC, true)
31+
explicitOpen(): { ok: true } {
32+
return { ok: true };
33+
}
34+
35+
@Get("explicit-open-second-key")
36+
@Roles("admin")
37+
@SetMetadata(SECOND_FOREIGN_PUBLIC, true)
38+
explicitOpenSecondKey(): { ok: true } {
39+
return { ok: true };
40+
}
41+
42+
@Get("explicit-open-with-optional")
43+
@OptionalAuth()
44+
@SetMetadata(SECOND_FOREIGN_PUBLIC, true)
45+
explicitOpenWithOptional(): { ok: true } {
46+
return { ok: true };
47+
}
48+
}
49+
50+
describe(`public metadata interop (${testHttpAdapter})`, () => {
51+
let app: INestApplication;
52+
let auth: ReturnType<typeof createTestAuth>;
53+
54+
beforeAll(async () => {
55+
auth = createTestAuth();
56+
app = await createTestApp({
57+
forRoot: {
58+
auth,
59+
interop: { publicKeys: [FOREIGN_PUBLIC, SECOND_FOREIGN_PUBLIC] },
60+
},
61+
metadata: { controllers: [InteropController] },
62+
});
63+
});
64+
65+
afterAll(async () => {
66+
await app.close();
67+
});
68+
69+
it("serves a foreign-public route without resolving a session", async () => {
70+
const getSession = vi.spyOn(auth.api, "getSession");
71+
72+
await request(app.getHttpServer()).get("/interop/open").expect(200, { ok: true });
73+
74+
expect(getSession).not.toHaveBeenCalled();
75+
getSession.mockRestore();
76+
});
77+
78+
it("lets a handler-level auth requirement override a class-level foreign marker", async () => {
79+
await request(app.getHttpServer()).get("/interop/guarded").expect(401);
80+
});
81+
82+
it("lets a handler-level foreign marker explicitly override that requirement", async () => {
83+
await request(app.getHttpServer()).get("/interop/explicit-open").expect(200, { ok: true });
84+
});
85+
86+
it("checks every key for a handler-level marker before inherited markers", async () => {
87+
await request(app.getHttpServer())
88+
.get("/interop/explicit-open-second-key")
89+
.expect(200, { ok: true });
90+
});
91+
92+
it("keeps an explicit foreign-public handler from resolving an optional session", async () => {
93+
const getSession = vi.spyOn(auth.api, "getSession");
94+
95+
await request(app.getHttpServer())
96+
.get("/interop/explicit-open-with-optional")
97+
.expect(200, { ok: true });
98+
99+
expect(getSession).not.toHaveBeenCalled();
100+
getSession.mockRestore();
101+
});
102+
});

‎tests/packed-types/consumer.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
import { BetterAuthModule } from "@nestm/better-auth";
1+
import {
2+
BetterAuthGuard,
3+
BetterAuthModule,
4+
type AnyAuth,
5+
type BetterAuthInteropOptions,
6+
} from "@nestm/better-auth";
7+
import type { Reflector } from "@nestjs/core";
8+
9+
declare const reflector: Reflector;
10+
declare const auth: AnyAuth;
11+
12+
// Patch releases must preserve the guard's original two-argument constructor.
13+
const manuallyConstructedGuard = new BetterAuthGuard(reflector, auth);
14+
const interop: BetterAuthInteropOptions = { publicKeys: ["legacy:public", Symbol()] };
215

316
const synchronousModule = BetterAuthModule.forRoot({
417
options: {
@@ -15,4 +28,4 @@ const asynchronousModule = BetterAuthModule.forRootAsync({
1528
}),
1629
});
1730

18-
export { asynchronousModule, synchronousModule };
31+
export { asynchronousModule, interop, manuallyConstructedGuard, synchronousModule };

0 commit comments

Comments
 (0)