@lenne.tech/nest-server 11.42.2 → 11.42.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/configurable-features.md +3 -1
- package/.claude/rules/package-management.md +1 -1
- package/.claude/rules/role-system.md +31 -3
- package/.claude/rules/testing.md +4 -4
- package/CLAUDE.md +7 -1
- package/FRAMEWORK-API.md +2 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
- package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +9 -0
- package/dist/core/common/services/core-body-parser.initializer.d.ts +26 -0
- package/dist/core/common/services/core-body-parser.initializer.js +136 -0
- package/dist/core/common/services/core-body-parser.initializer.js.map +1 -0
- package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
- package/dist/core/modules/auth/services/core-auth.service.js +19 -0
- package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
- package/dist/core/modules/better-auth/better-auth.config.js +27 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +7 -3
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +9 -0
- package/dist/core/modules/error-code/error-codes.js +8 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +11 -6
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core.module.d.ts +3 -0
- package/dist/core.module.js +102 -42
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +29 -0
- package/docs/error-codes.md +16 -0
- package/docs/security-overrides.md +18 -2
- package/migration-guides/11.42.2-to-11.42.3.md +145 -0
- package/migration-guides/11.42.3-to-11.42.4.md +185 -0
- package/package.json +3 -3
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
- package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
- package/src/core/common/interfaces/server-options.interface.ts +121 -2
- package/src/core/common/services/core-body-parser.initializer.ts +232 -0
- package/src/core/modules/auth/services/core-auth.service.ts +37 -0
- package/src/core/modules/better-auth/better-auth.config.ts +46 -1
- package/src/core/modules/better-auth/core-better-auth.module.ts +24 -4
- package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
- package/src/core/modules/error-code/error-codes.ts +9 -0
- package/src/core/modules/tenant/README.md +8 -8
- package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
- package/src/core/modules/user/core-user.service.ts +19 -6
- package/src/core.module.ts +170 -55
- package/src/index.ts +1 -0
|
@@ -1051,6 +1051,53 @@ export interface IBetterAuthUserField {
|
|
|
1051
1051
|
type: BetterAuthFieldType;
|
|
1052
1052
|
}
|
|
1053
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* Size limits for the two body parsers NestJS registers globally (`application/json` and
|
|
1056
|
+
* `application/x-www-form-urlencoded`).
|
|
1057
|
+
*
|
|
1058
|
+
* @see IServerOptions.bodyParser
|
|
1059
|
+
* @since 11.42.4
|
|
1060
|
+
*/
|
|
1061
|
+
export interface IBodyParserConfig {
|
|
1062
|
+
/**
|
|
1063
|
+
* Limit for `application/json` bodies — REST and GraphQL alike, since Apollo reads the body the
|
|
1064
|
+
* global parser produced.
|
|
1065
|
+
*
|
|
1066
|
+
* A number is bytes; a string is a size such as `'500kb'` or `'2mb'` (1024-based, units
|
|
1067
|
+
* `b` / `kb` / `mb` / `gb`, case-insensitive).
|
|
1068
|
+
*
|
|
1069
|
+
* @default '100kb' (body-parser's own default — unset leaves the parser untouched)
|
|
1070
|
+
*/
|
|
1071
|
+
json?: IBodyParserTypeConfig;
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Limit for `application/x-www-form-urlencoded` bodies. Same value format as `json`.
|
|
1075
|
+
*
|
|
1076
|
+
* Configured separately on purpose: form posts rarely need more, and raising one limit should not
|
|
1077
|
+
* silently raise the other.
|
|
1078
|
+
*
|
|
1079
|
+
* @default '100kb' (body-parser's own default — unset leaves the parser untouched)
|
|
1080
|
+
*/
|
|
1081
|
+
urlencoded?: IBodyParserTypeConfig;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Settings for one body parser.
|
|
1086
|
+
*
|
|
1087
|
+
* @see IBodyParserConfig
|
|
1088
|
+
* @since 11.42.4
|
|
1089
|
+
*/
|
|
1090
|
+
export interface IBodyParserTypeConfig {
|
|
1091
|
+
/**
|
|
1092
|
+
* Maximum body size: bytes as a number, or a size string such as `'2mb'`.
|
|
1093
|
+
*
|
|
1094
|
+
* A value that is not a positive size (`0`, negative, `NaN`, `'lots'`) is REJECTED with an error
|
|
1095
|
+
* log and the parser keeps its default — it never becomes "no limit". The parser runs before
|
|
1096
|
+
* authentication, so an unbounded limit would be an unbounded pre-auth allocation.
|
|
1097
|
+
*/
|
|
1098
|
+
limit?: number | string;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1054
1101
|
/**
|
|
1055
1102
|
* Interface for Error Code module configuration
|
|
1056
1103
|
*
|
|
@@ -1490,8 +1537,9 @@ export interface IMultiTenancy {
|
|
|
1490
1537
|
* - tenant API tokens: **403**
|
|
1491
1538
|
* - public (`S_EVERYONE`) routes: not blocked, but no tenant context is set
|
|
1492
1539
|
* - no header: the tenant is dropped from the user's tenant list, so its data stays out of reach
|
|
1493
|
-
* - GraphQL over WebSocket: no tenant is resolved for a NEW subscription; one already open
|
|
1494
|
-
*
|
|
1540
|
+
* - GraphQL over WebSocket: no tenant is resolved for a NEW subscription; one already open is
|
|
1541
|
+
* closed (4403) once `invalidateTenant()` is called, or at its next event after
|
|
1542
|
+
* `graphQl.subscriptionRevalidationMs` without it (since 11.42.3)
|
|
1495
1543
|
*
|
|
1496
1544
|
* A platform administrator under `adminBypass` still reaches the tenant — somebody has to be able to
|
|
1497
1545
|
* look into it and switch it back on.
|
|
@@ -2263,6 +2311,48 @@ export interface IServerOptions {
|
|
|
2263
2311
|
*/
|
|
2264
2312
|
betterAuth?: boolean | IBetterAuth;
|
|
2265
2313
|
|
|
2314
|
+
/**
|
|
2315
|
+
* Size limits for request bodies.
|
|
2316
|
+
*
|
|
2317
|
+
* NestJS registers a JSON and a URL-encoded body parser on every application, and neither has a
|
|
2318
|
+
* limit anybody chose: body-parser's default of **100 kB** applies. A request over it is answered
|
|
2319
|
+
* with 413 in middleware — before authentication, before routing — so the whole request fails,
|
|
2320
|
+
* every retry fails identically, and a document that grew past the limit can no longer be saved.
|
|
2321
|
+
*
|
|
2322
|
+
* Unset, both parsers stay exactly as NestJS registered them. Set a limit only where the data
|
|
2323
|
+
* model needs it, and keep it as small as that need allows: the parser runs before
|
|
2324
|
+
* authentication, and `JSON.parse` blocks the event loop for the whole body — measured on a
|
|
2325
|
+
* realistic document shape, about 0.5 ms for 100 kB, 16 ms for 1 MB and 55 ms for 2 MB per
|
|
2326
|
+
* request. That is why the default is not raised for everybody.
|
|
2327
|
+
*
|
|
2328
|
+
* Applied by `CoreModule` during module init, which replaces the global parsers NestJS registered
|
|
2329
|
+
* IN PLACE — same position in the middleware chain, same options otherwise (`extended: true` for
|
|
2330
|
+
* URL-encoded bodies, `req.rawBody` when the app was created with `NestFactory.create(…, {
|
|
2331
|
+
* rawBody: true })`). Because this happens in `app.init()`, it holds under
|
|
2332
|
+
* `Test.createTestingModule(...).createNestApplication()` too, which never runs `main.ts` — a
|
|
2333
|
+
* `useBodyParser()` call in `main.ts` does not reach the e2e suite, this does. A configured value
|
|
2334
|
+
* also replaces a global parser registered in `main.ts`; a path-scoped one
|
|
2335
|
+
* (`server.use('/upload', json({ limit }))`) is left alone, and stays the way to give ONE path a
|
|
2336
|
+
* different limit, since the parser runs before routing and GraphQL shares a single path anyway.
|
|
2337
|
+
*
|
|
2338
|
+
* One caveat: under the testing module the options passed to `createNestApplication()` are not
|
|
2339
|
+
* visible to providers, so a test app created with `{ rawBody: true }` gets no `req.rawBody` from
|
|
2340
|
+
* a CONFIGURED parser. Applications created with `NestFactory.create()` are not affected.
|
|
2341
|
+
*
|
|
2342
|
+
* Independent of this setting, a 413 from either parser is answered with
|
|
2343
|
+
* `#LTNS_0304: Request body too large [<size>, limit <limit>]` instead of a bare
|
|
2344
|
+
* "request entity too large", and logged as a warning naming the route and the config key.
|
|
2345
|
+
*
|
|
2346
|
+
* @since 11.42.4
|
|
2347
|
+
*
|
|
2348
|
+
* @example
|
|
2349
|
+
* ```typescript
|
|
2350
|
+
* // Documents carry HTML: allow 2 MB of JSON, keep form posts at the default
|
|
2351
|
+
* bodyParser: { json: { limit: '2mb' } },
|
|
2352
|
+
* ```
|
|
2353
|
+
*/
|
|
2354
|
+
bodyParser?: IBodyParserConfig;
|
|
2355
|
+
|
|
2266
2356
|
/**
|
|
2267
2357
|
* Configuration for Brevo
|
|
2268
2358
|
* See: https://developers.brevo.com/
|
|
@@ -2567,6 +2657,35 @@ export interface IServerOptions {
|
|
|
2567
2657
|
* Module options (forRootAsync)
|
|
2568
2658
|
*/
|
|
2569
2659
|
options?: GqlModuleAsyncOptions;
|
|
2660
|
+
|
|
2661
|
+
/**
|
|
2662
|
+
* How long an open GraphQL WebSocket connection may go without re-checking its authorization,
|
|
2663
|
+
* in milliseconds.
|
|
2664
|
+
*
|
|
2665
|
+
* A WebSocket is authorized at the handshake. Before 11.42.3 that was also the last check: a
|
|
2666
|
+
* removed membership, a deactivated tenant, a withdrawn role or a revoked session took effect
|
|
2667
|
+
* on every new request and on none of the sockets already open. Now a connection is re-checked
|
|
2668
|
+
* at once when an invalidation names it (`CoreTenantGuard.invalidateUser()` /
|
|
2669
|
+
* `invalidateTenant()` / `invalidateAll()`, a role change through `CoreUserService`, a
|
|
2670
|
+
* password reset), and before an event is delivered or an operation runs once its last check
|
|
2671
|
+
* is older than this value — the bound for a change nobody invalidated, such as a sign-out or a
|
|
2672
|
+
* membership edited directly in the database. A connection whose rights changed is closed with
|
|
2673
|
+
* 4403, which the graphql-ws client retries: it reconnects and continues with the current
|
|
2674
|
+
* rights, or is refused at the handshake.
|
|
2675
|
+
*
|
|
2676
|
+
* - `undefined` → 30000
|
|
2677
|
+
* - `0` → re-check on every operation and every event (one session lookup each)
|
|
2678
|
+
* - `false` → no time-based re-check; invalidations still apply
|
|
2679
|
+
* - anything else that is not a non-negative number → 30000, never "off"
|
|
2680
|
+
*
|
|
2681
|
+
* Applies to connections registered by `CoreModule`'s own `onConnect`. A project that replaces
|
|
2682
|
+
* `onConnect` through `graphQl.driver.subscriptions` registers its connections with
|
|
2683
|
+
* `registerWsConnection()` to keep this.
|
|
2684
|
+
*
|
|
2685
|
+
* @default 30000
|
|
2686
|
+
* @since 11.42.3
|
|
2687
|
+
*/
|
|
2688
|
+
subscriptionRevalidationMs?: false | number;
|
|
2570
2689
|
};
|
|
2571
2690
|
|
|
2572
2691
|
/**
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { Injectable, Logger, OnModuleInit, Optional, PayloadTooLargeException } from '@nestjs/common';
|
|
2
|
+
import { HttpAdapterHost, ModuleRef } from '@nestjs/core';
|
|
3
|
+
import { json, urlencoded } from 'express';
|
|
4
|
+
|
|
5
|
+
import { ErrorCode } from '../../modules/error-code/error-codes';
|
|
6
|
+
import { IBodyParserConfig } from '../interfaces/server-options.interface';
|
|
7
|
+
import { ConfigService } from './config.service';
|
|
8
|
+
|
|
9
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
10
|
+
|
|
11
|
+
/** The two parsers NestJS registers on every Express application */
|
|
12
|
+
export type CoreBodyParserType = 'json' | 'urlencoded';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The function names body-parser gives its middleware. NestJS identifies its own parser layers by
|
|
16
|
+
* exactly these names (`ExpressAdapter.isMiddlewareApplied()`), which is why a wrapper must keep them.
|
|
17
|
+
*/
|
|
18
|
+
const PARSER_NAMES: Record<CoreBodyParserType, string> = { json: 'jsonParser', urlencoded: 'urlencodedParser' };
|
|
19
|
+
|
|
20
|
+
/** 1024-based, like the `bytes` package body-parser itself parses limits with */
|
|
21
|
+
const UNIT_FACTORS: Record<string, number> = { b: 1, gb: 1024 ** 3, kb: 1024, mb: 1024 ** 2 };
|
|
22
|
+
|
|
23
|
+
/** The router layer fields this initializer reads and writes (Express 5 / `router` 2.x) */
|
|
24
|
+
interface ExpressLayer {
|
|
25
|
+
handle: RequestHandler;
|
|
26
|
+
slash?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert a configured body limit into bytes.
|
|
31
|
+
*
|
|
32
|
+
* Returns `undefined` for anything that is not a positive size, so the caller can refuse it. The
|
|
33
|
+
* value is turned into a number HERE rather than handed to body-parser as a string, so the limit that
|
|
34
|
+
* is logged and the limit that is enforced cannot differ.
|
|
35
|
+
*
|
|
36
|
+
* @example parseBodyLimit('2mb') // 2097152
|
|
37
|
+
* @example parseBodyLimit(500000) // 500000
|
|
38
|
+
* @example parseBodyLimit('lots') // undefined
|
|
39
|
+
*/
|
|
40
|
+
export function parseBodyLimit(value: unknown): number | undefined {
|
|
41
|
+
if (typeof value === 'number') {
|
|
42
|
+
return Number.isFinite(value) && value >= 1 ? Math.floor(value) : undefined;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== 'string') {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(value.trim());
|
|
48
|
+
if (!match) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const bytes = Math.floor(Number(match[1]) * UNIT_FACTORS[(match[2] || 'b').toLowerCase()]);
|
|
52
|
+
return bytes >= 1 ? bytes : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Applies `ServerOptions.bodyParser` to the body parsers NestJS registered, and turns their 413 into
|
|
57
|
+
* an answer a caller can act on.
|
|
58
|
+
*
|
|
59
|
+
* WHY it swaps layers instead of calling `useBodyParser()`: NestJS registers its parsers inside
|
|
60
|
+
* `app.init()`, BEFORE any `onModuleInit` runs, and a parser appended afterwards would sit behind
|
|
61
|
+
* the routes. So the layers NestJS created are replaced in place — same position in the middleware
|
|
62
|
+
* chain, same options otherwise. Doing it during module init (rather than in `main.ts`) is what
|
|
63
|
+
* makes the limit hold under `Test.createTestingModule()`, which never runs `main.ts`.
|
|
64
|
+
*
|
|
65
|
+
* Unconfigured, the parsers are not replaced, only wrapped: their own limit and options stay, and a
|
|
66
|
+
* 413 they raise is answered with `ErrorCode.REQUEST_BODY_TOO_LARGE` plus size and limit instead of
|
|
67
|
+
* a bare "request entity too large", and logged as a warning that names the route and the config
|
|
68
|
+
* key. Without that line the first symptom of a too-small limit is a user whose save keeps failing.
|
|
69
|
+
*
|
|
70
|
+
* Registered as a CoreModule provider; consumers never interact with it.
|
|
71
|
+
*/
|
|
72
|
+
@Injectable()
|
|
73
|
+
export class CoreBodyParserInitializer implements OnModuleInit {
|
|
74
|
+
protected readonly logger = new Logger(CoreBodyParserInitializer.name);
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
@Optional() protected readonly httpAdapterHost?: HttpAdapterHost,
|
|
78
|
+
@Optional() protected readonly moduleRef?: ModuleRef,
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
onModuleInit(): void {
|
|
82
|
+
const stack = this.getRouterStack();
|
|
83
|
+
if (!stack) {
|
|
84
|
+
// Fastify and non-HTTP contexts have no body-parser layers; there is nothing to adjust.
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const config = this.getConfig();
|
|
89
|
+
for (const type of ['json', 'urlencoded'] as const) {
|
|
90
|
+
const limit = this.resolveLimit(type, config?.[type]?.limit);
|
|
91
|
+
const layers = stack.filter((layer) => this.isGlobalParserLayer(layer, type));
|
|
92
|
+
|
|
93
|
+
if (limit !== undefined && !layers.length) {
|
|
94
|
+
this.logger.warn(
|
|
95
|
+
`bodyParser.${type}.limit is configured, but no global ${type} body parser is registered ` +
|
|
96
|
+
'(was the app created with `bodyParser: false`?) — the limit has no effect.',
|
|
97
|
+
);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const layer of layers) {
|
|
102
|
+
const parser = limit === undefined ? layer.handle : this.createParser(type, limit);
|
|
103
|
+
layer.handle = this.wrapParser(type, parser);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (limit !== undefined) {
|
|
107
|
+
this.logger.log(`Body parser: ${type} limit set to ${limit} bytes`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A parser equivalent to the one NestJS registers, with the configured limit.
|
|
114
|
+
*
|
|
115
|
+
* `extended: true` mirrors NestJS's URL-encoded default (nested objects via `qs`). `req.rawBody`
|
|
116
|
+
* is kept when the app was created with `rawBody: true`, because webhook signature checks read it
|
|
117
|
+
* and would otherwise fail on every request once a limit is configured.
|
|
118
|
+
*/
|
|
119
|
+
protected createParser(type: CoreBodyParserType, limit: number): RequestHandler {
|
|
120
|
+
const verify = this.isRawBodyEnabled() ? captureRawBody : undefined;
|
|
121
|
+
return type === 'json' ? json({ limit, verify }) : urlencoded({ extended: true, limit, verify });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The configured `bodyParser` block, or `undefined` when the project did not set one */
|
|
125
|
+
protected getConfig(): IBodyParserConfig | undefined {
|
|
126
|
+
return ConfigService.getFastButReadOnly<IBodyParserConfig>('bodyParser');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The Express router stack, or `undefined` for a non-Express adapter */
|
|
130
|
+
protected getRouterStack(): ExpressLayer[] | undefined {
|
|
131
|
+
const app = this.httpAdapterHost?.httpAdapter?.getInstance?.();
|
|
132
|
+
if (typeof app?.use !== 'function' || typeof app?.set !== 'function') {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const stack = app.router?.stack;
|
|
136
|
+
return Array.isArray(stack) ? stack : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a layer is a GLOBAL parser of this type.
|
|
141
|
+
*
|
|
142
|
+
* `slash` is how the Express 5 router marks `app.use(fn)` — mounted on `/` without an end. A
|
|
143
|
+
* path-scoped `app.use('/upload', json({ limit }))` is a project's deliberate exception for one
|
|
144
|
+
* path and is left alone, including its 413.
|
|
145
|
+
*/
|
|
146
|
+
protected isGlobalParserLayer(layer: ExpressLayer | undefined, type: CoreBodyParserType): boolean {
|
|
147
|
+
return layer?.slash === true && typeof layer.handle === 'function' && layer.handle.name === PARSER_NAMES[type];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether the application was created with `rawBody: true`.
|
|
152
|
+
*
|
|
153
|
+
* `NestFactory.create()` hands its options to the container and nothing public exposes them, so
|
|
154
|
+
* they are read from the container `ModuleRef` carries. Under `Test.createTestingModule()` the
|
|
155
|
+
* options passed to `createNestApplication()` never reach the container — that is the caveat
|
|
156
|
+
* documented on `IServerOptions.bodyParser`.
|
|
157
|
+
*/
|
|
158
|
+
protected isRawBodyEnabled(): boolean {
|
|
159
|
+
const container = (this.moduleRef as unknown as { container?: { contextOptions?: { rawBody?: boolean } } })
|
|
160
|
+
?.container;
|
|
161
|
+
return container?.contextOptions?.rawBody === true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The configured limit in bytes, or `undefined` when unset or invalid.
|
|
166
|
+
*
|
|
167
|
+
* An invalid value is refused rather than passed on: body-parser would throw at boot for some
|
|
168
|
+
* inputs and apply nonsense (a negative limit rejects every body) for others. The parser keeps the
|
|
169
|
+
* limit it has — never "no limit", since it runs before authentication.
|
|
170
|
+
*/
|
|
171
|
+
protected resolveLimit(type: CoreBodyParserType, value: unknown): number | undefined {
|
|
172
|
+
if (value === undefined || value === null) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const limit = parseBodyLimit(value);
|
|
176
|
+
if (limit === undefined) {
|
|
177
|
+
this.logger.error(
|
|
178
|
+
`bodyParser.${type}.limit ${JSON.stringify(value)} is not a positive size (bytes, or e.g. '500kb' / '2mb') ` +
|
|
179
|
+
`— ignored, the ${type} body parser keeps the limit it has (100 kB unless set elsewhere).`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return limit;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Answer a body-parser 413 with an error code, the size and the limit, and log it.
|
|
187
|
+
*
|
|
188
|
+
* Every other error (malformed JSON, unsupported charset, too many parameters) passes through
|
|
189
|
+
* unchanged, so NestJS answers it exactly as before.
|
|
190
|
+
*/
|
|
191
|
+
protected translateError(type: CoreBodyParserType, error: unknown, req: Request): unknown {
|
|
192
|
+
const details = error as undefined | { length?: unknown; limit?: unknown; type?: unknown };
|
|
193
|
+
if (details?.type !== 'entity.too.large') {
|
|
194
|
+
return error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// `length` is the declared Content-Length; a chunked body has none, and the byte count at which
|
|
198
|
+
// body-parser stopped reading says nothing about how large the body really was.
|
|
199
|
+
const length = typeof details.length === 'number' ? details.length : undefined;
|
|
200
|
+
const sizeText = length === undefined ? '' : `${length} bytes, `;
|
|
201
|
+
const path = String(req.originalUrl ?? req.url ?? '').split('?')[0];
|
|
202
|
+
|
|
203
|
+
this.logger.warn(
|
|
204
|
+
`413 ${req.method} ${path}: ${type} body ${length === undefined ? '' : `of ${length} bytes `}` +
|
|
205
|
+
`exceeds the limit of ${details.limit} bytes. If payloads of this size are legitimate here, ` +
|
|
206
|
+
`raise \`bodyParser.${type}.limit\` in the server options.`,
|
|
207
|
+
);
|
|
208
|
+
return new PayloadTooLargeException(
|
|
209
|
+
`${ErrorCode.REQUEST_BODY_TOO_LARGE} [${sizeText}limit ${details.limit} bytes]`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Wrap a parser so its 413 is translated, keeping the name NestJS identifies the layer by.
|
|
215
|
+
*
|
|
216
|
+
* Wrapping an already wrapped parser is harmless: the outer wrapper receives the translated
|
|
217
|
+
* `PayloadTooLargeException`, which carries no body-parser `type`, and passes it on unchanged.
|
|
218
|
+
*/
|
|
219
|
+
protected wrapParser(type: CoreBodyParserType, parser: RequestHandler): RequestHandler {
|
|
220
|
+
const wrapped = (req: Request, res: Response, next: NextFunction) =>
|
|
221
|
+
parser(req, res, (error?: unknown) => (error ? next(this.translateError(type, error, req)) : next()));
|
|
222
|
+
Object.defineProperty(wrapped, 'name', { value: PARSER_NAMES[type] });
|
|
223
|
+
return wrapped;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Same behaviour as the `verify` NestJS installs for `rawBody: true` */
|
|
228
|
+
function captureRawBody(req: Request & { rawBody?: Buffer }, _res: Response, buffer: Buffer): void {
|
|
229
|
+
if (Buffer.isBuffer(buffer)) {
|
|
230
|
+
req.rawBody = buffer;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -69,11 +69,48 @@ export class CoreAuthService {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Decode JWT
|
|
72
|
+
*
|
|
73
|
+
* Reads the payload WITHOUT checking signature or expiry — never decide access on its result.
|
|
74
|
+
* Use `verifyJwt()` for that.
|
|
72
75
|
*/
|
|
73
76
|
decodeJwt(token: string): JwtPayload {
|
|
74
77
|
return this.jwtService.decode(token) as JwtPayload;
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Verify an access token's signature and expiry and answer its payload, or `null` when it does not
|
|
82
|
+
* verify.
|
|
83
|
+
*
|
|
84
|
+
* The same check passport-jwt applies on the HTTP path (`JwtStrategy`), for transports that have no
|
|
85
|
+
* passport in front of them — today the GraphQL WebSocket. The key is resolved in passport's order:
|
|
86
|
+
* `jwt.secretOrKeyProvider` when configured, else `jwt.secretOrPrivateKey || jwt.secret`. Until
|
|
87
|
+
* 11.42.3 the WebSocket handshake used `decodeJwt()`, so an expired token, or a copy re-signed with
|
|
88
|
+
* any key, was accepted there as long as its `tokenId` still matched the device.
|
|
89
|
+
*
|
|
90
|
+
* @since 11.42.3
|
|
91
|
+
*/
|
|
92
|
+
async verifyJwt(token: string): Promise<JwtPayload | null> {
|
|
93
|
+
if (!token || typeof token !== 'string') {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const provider = this.configService.getFastButReadOnly('jwt.secretOrKeyProvider');
|
|
98
|
+
const secret =
|
|
99
|
+
typeof provider === 'function'
|
|
100
|
+
? await new Promise<string>((resolve, reject) =>
|
|
101
|
+
provider({}, token, (error: unknown, key: string) => (error ? reject(error) : resolve(key))),
|
|
102
|
+
)
|
|
103
|
+
: this.configService.getFastButReadOnly('jwt.secretOrPrivateKey') ||
|
|
104
|
+
this.configService.getFastButReadOnly('jwt.secret');
|
|
105
|
+
if (!secret) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return (await this.jwtService.verifyAsync(token, { secret })) as JwtPayload;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
77
114
|
/**
|
|
78
115
|
* Logout user (from device)
|
|
79
116
|
*/
|
|
@@ -8,6 +8,7 @@ import * as fs from 'fs';
|
|
|
8
8
|
import * as path from 'path';
|
|
9
9
|
|
|
10
10
|
import { resolveServerUrls, strippedApiHostname } from '../../common/helpers/cookies.helper';
|
|
11
|
+
import { revalidateWsConnectionsOf } from '../../common/helpers/graphql-ws-connection.helper';
|
|
11
12
|
import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
|
|
12
13
|
import { detectCookiePrefixDrift, resolveBetterAuthCookiePrefix } from './better-auth-cookie-prefix.helper';
|
|
13
14
|
|
|
@@ -753,6 +754,48 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
|
|
|
753
754
|
};
|
|
754
755
|
}
|
|
755
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Ends the GraphQL WebSocket connections that rode on a session the moment the session is deleted —
|
|
759
|
+
* a sign-out, a revoked session, a password reset with `revokeSessionsOnPasswordReset`, a deleted user.
|
|
760
|
+
*
|
|
761
|
+
* Without it such a connection stays open until `graphQl.subscriptionRevalidationMs` (30 s by
|
|
762
|
+
* default) catches it on its next event. Better-Auth's `onPasswordReset` cannot do this job: all
|
|
763
|
+
* three reset routes call it BEFORE they delete the sessions, so a re-check from there would still
|
|
764
|
+
* find them valid.
|
|
765
|
+
*
|
|
766
|
+
* A plugin rather than `databaseHooks` in the options, because Better-Auth collects a plugin's
|
|
767
|
+
* `init().options.databaseHooks` as a SEPARATE source: a project's own `options.databaseHooks`
|
|
768
|
+
* neither replaces this one nor is replaced by it — unlike everything spread from `options`.
|
|
769
|
+
*
|
|
770
|
+
* It only asks for a re-check of the user's connections. The re-check resolves each connection's
|
|
771
|
+
* own token again, so a connection on a DIFFERENT, still-valid session of the same user stays open.
|
|
772
|
+
* It never throws: a failure here must not fail the sign-out.
|
|
773
|
+
*/
|
|
774
|
+
export function wsSessionRevocationPlugin(): BetterAuthPlugin {
|
|
775
|
+
return {
|
|
776
|
+
id: 'lt-ws-session-revocation',
|
|
777
|
+
init: () => ({
|
|
778
|
+
options: {
|
|
779
|
+
databaseHooks: {
|
|
780
|
+
session: {
|
|
781
|
+
delete: {
|
|
782
|
+
after: async (session: { userId?: unknown }) => {
|
|
783
|
+
try {
|
|
784
|
+
if (session?.userId) {
|
|
785
|
+
revalidateWsConnectionsOf({ iamId: String(session.userId) });
|
|
786
|
+
}
|
|
787
|
+
} catch {
|
|
788
|
+
// Never fail the deletion over a re-check; the revalidation interval still applies.
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
}),
|
|
796
|
+
} as BetterAuthPlugin;
|
|
797
|
+
}
|
|
798
|
+
|
|
756
799
|
/**
|
|
757
800
|
* Formats a package name to a human-readable display name.
|
|
758
801
|
* Converts kebab-case and snake_case to Title Case.
|
|
@@ -873,7 +916,9 @@ function buildPlugins(
|
|
|
873
916
|
options: { passkeyNormalization: PasskeyNormalizationResult; serverEnv?: string },
|
|
874
917
|
): BetterAuthPlugin[] {
|
|
875
918
|
const { passkeyNormalization, serverEnv } = options;
|
|
876
|
-
|
|
919
|
+
// Always first and always on: it changes nothing about authentication itself, it only ends the
|
|
920
|
+
// WebSocket connections that rode on a session once the session is gone.
|
|
921
|
+
const plugins: BetterAuthPlugin[] = [wsSessionRevocationPlugin()];
|
|
877
922
|
|
|
878
923
|
// JWT Plugin for API client compatibility
|
|
879
924
|
// JWT is enabled by default unless explicitly disabled (jwt: false or jwt: { enabled: false })
|
|
@@ -226,6 +226,27 @@ function normalizeBetterAuthConfig(config: boolean | IBetterAuth | undefined): I
|
|
|
226
226
|
return config;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The secret Better-Auth actually signs with, for the NestJS layer (controller cookie helper,
|
|
231
|
+
* API middleware, CoreBetterAuthService) to sign its cookies with the same value.
|
|
232
|
+
*
|
|
233
|
+
* `createBetterAuthInstance` resolves it as `betterAuth.secret` → first fallback of at least 32
|
|
234
|
+
* characters → auto-generated. Only the instance knows the generated one; without it the cookie
|
|
235
|
+
* went out unsigned and every native Better-Auth endpoint (`/iam/token`, MCP authorize, passkey,
|
|
236
|
+
* 2FA) rejected the session with 401.
|
|
237
|
+
*/
|
|
238
|
+
function resolveSigningSecret(
|
|
239
|
+
authInstance: BetterAuthInstance | null,
|
|
240
|
+
config: IBetterAuth,
|
|
241
|
+
fallbackSecrets?: (string | undefined)[],
|
|
242
|
+
): string | undefined {
|
|
243
|
+
return (
|
|
244
|
+
(authInstance as null | { options?: { secret?: string } })?.options?.secret ||
|
|
245
|
+
config.secret ||
|
|
246
|
+
fallbackSecrets?.find((s) => s && s.length >= 32)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
229
250
|
/**
|
|
230
251
|
* CoreBetterAuthModule provides integration with the better-auth authentication framework.
|
|
231
252
|
*
|
|
@@ -698,8 +719,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
698
719
|
// Store a config copy with the resolved secret so that consumers
|
|
699
720
|
// (CoreBetterAuthService, CoreBetterAuthController) can sign cookies.
|
|
700
721
|
// The original config object may be frozen (from ConfigService), so we
|
|
701
|
-
// create a shallow copy with the resolved fallback secret applied.
|
|
702
|
-
const resolvedSecret =
|
|
722
|
+
// create a shallow copy with the resolved (fallback or generated) secret applied.
|
|
723
|
+
const resolvedSecret = resolveSigningSecret(this.authInstance, config, fallbackSecrets);
|
|
703
724
|
this.currentConfig =
|
|
704
725
|
resolvedSecret && resolvedSecret !== config.secret ? { ...config, secret: resolvedSecret } : config;
|
|
705
726
|
|
|
@@ -1068,8 +1089,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
1068
1089
|
this.resolvedCookieDomain = result?.cookieDomain;
|
|
1069
1090
|
|
|
1070
1091
|
// Store a config copy with the resolved secret (same as first forRoot variant)
|
|
1071
|
-
const
|
|
1072
|
-
const resolvedSecret2 = config.secret || fallbacks?.find((s) => s && s.length >= 32);
|
|
1092
|
+
const resolvedSecret2 = resolveSigningSecret(this.authInstance, config, options?.fallbackSecrets);
|
|
1073
1093
|
this.currentConfig =
|
|
1074
1094
|
resolvedSecret2 && resolvedSecret2 !== config.secret ? { ...config, secret: resolvedSecret2 } : config;
|
|
1075
1095
|
|
|
@@ -804,9 +804,17 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
804
804
|
}
|
|
805
805
|
|
|
806
806
|
try {
|
|
807
|
-
//
|
|
807
|
+
// Better-Auth's signOut finds the session through its SIGNED session cookie. An `Authorization`
|
|
808
|
+
// header is read only by its `bearer` plugin, which the framework does not install — so until
|
|
809
|
+
// 11.42.3 this call deleted nothing: the caller got `true`, the controller cleared the cookies,
|
|
810
|
+
// and the session stayed valid server-side, authenticating the same token until it expired.
|
|
811
|
+
// The header stays for projects that added the bearer plugin themselves.
|
|
808
812
|
const headers = new Headers();
|
|
809
813
|
headers.set('Authorization', `Bearer ${sessionToken}`);
|
|
814
|
+
const secret = this.config?.secret;
|
|
815
|
+
if (secret) {
|
|
816
|
+
headers.set('cookie', `${this.getSessionCookieName()}=${signCookieValueIfNeeded(sessionToken, secret, true)}`);
|
|
817
|
+
}
|
|
810
818
|
|
|
811
819
|
// Call Better-Auth's signOut endpoint
|
|
812
820
|
await api.signOut({ headers });
|
|
@@ -435,6 +435,15 @@ export const LtnsErrors = {
|
|
|
435
435
|
},
|
|
436
436
|
},
|
|
437
437
|
|
|
438
|
+
REQUEST_BODY_TOO_LARGE: {
|
|
439
|
+
code: 'LTNS_0304',
|
|
440
|
+
message: 'Request body too large',
|
|
441
|
+
translations: {
|
|
442
|
+
de: 'Die gesendeten Daten sind zu groß und wurden nicht verarbeitet.',
|
|
443
|
+
en: 'The submitted data is too large and was not processed.',
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
|
|
438
447
|
// =====================================================
|
|
439
448
|
// Resource Errors (LTNS_0400-LTNS_0499)
|
|
440
449
|
// =====================================================
|
|
@@ -236,14 +236,14 @@ multiTenancy: {
|
|
|
236
236
|
}
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
-
| Path | Deactivated tenant
|
|
240
|
-
| -------------------------------------------------------------- |
|
|
241
|
-
| Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `Tenant is inactive`
|
|
242
|
-
| Tenant API token | **403**
|
|
243
|
-
| `S_EVERYONE` with a header | not blocked, but no tenant context is set
|
|
244
|
-
| No header | dropped from the user's tenant list — its data stays out of reach
|
|
245
|
-
| GraphQL over WebSocket | no tenant resolved for a new subscription
|
|
246
|
-
| Admin under `adminBypass` | still allowed — somebody has to be able to switch it back on
|
|
239
|
+
| Path | Deactivated tenant |
|
|
240
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
241
|
+
| Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `Tenant is inactive` |
|
|
242
|
+
| Tenant API token | **403** |
|
|
243
|
+
| `S_EVERYONE` with a header | not blocked, but no tenant context is set |
|
|
244
|
+
| No header | dropped from the user's tenant list — its data stays out of reach |
|
|
245
|
+
| GraphQL over WebSocket | no tenant resolved for a new subscription; an open one is closed (4403) on `invalidateTenant()`, or at its next event once `graphQl.subscriptionRevalidationMs` has passed (since 11.42.3) |
|
|
246
|
+
| Admin under `adminBypass` | still allowed — somebody has to be able to switch it back on |
|
|
247
247
|
|
|
248
248
|
The hook is asked **after** the membership check, so a non-member learns nothing about a tenant's
|
|
249
249
|
state. Return `false` for inactive; anything else counts as active. Answers are cached for
|