@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
|
@@ -16,6 +16,11 @@ import { Model } from 'mongoose';
|
|
|
16
16
|
|
|
17
17
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
18
18
|
import { resolveGuardRequest } from '../../common/helpers/execution-context-request.helper';
|
|
19
|
+
import {
|
|
20
|
+
revalidateAllWsConnections,
|
|
21
|
+
revalidateWsConnectionsForTenant,
|
|
22
|
+
revalidateWsConnectionsOf,
|
|
23
|
+
} from '../../common/helpers/graphql-ws-connection.helper';
|
|
19
24
|
import { ConfigService } from '../../common/services/config.service';
|
|
20
25
|
import { ResolvedTenantContext, setTenantContextResolver } from '../../common/services/core-tenant-context.registry';
|
|
21
26
|
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
@@ -208,6 +213,9 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
208
213
|
if (!config || config.enabled === false) {
|
|
209
214
|
return {};
|
|
210
215
|
}
|
|
216
|
+
// The WebSocket path reaches this without passing canActivate(), so it has to pick up a changed
|
|
217
|
+
// `cacheTtlMs` itself — otherwise an API whose traffic is all WebSocket keeps the 30 s default.
|
|
218
|
+
this.syncConfig(config);
|
|
211
219
|
|
|
212
220
|
const trimmed =
|
|
213
221
|
headerTenantId && typeof headerTenantId === 'string' && headerTenantId.length <= 128
|
|
@@ -301,7 +309,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
301
309
|
* `cacheTtlMs`. Broadcast to every replica when Redis is enabled.
|
|
302
310
|
*/
|
|
303
311
|
invalidateTenant(tenantId: string): void {
|
|
304
|
-
this.
|
|
312
|
+
this.clearTenant(tenantId);
|
|
305
313
|
this.publishInvalidation({ scope: 'tenant', tenantId });
|
|
306
314
|
}
|
|
307
315
|
|
|
@@ -339,16 +347,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
339
347
|
return true;
|
|
340
348
|
}
|
|
341
349
|
|
|
342
|
-
|
|
343
|
-
if (this.lastSeenConfig !== config) {
|
|
344
|
-
this.lastSeenConfig = config;
|
|
345
|
-
// Default 30s in production, 0 (disabled) in test environments to avoid stale data between test cases
|
|
346
|
-
const isTestEnv =
|
|
347
|
-
process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
|
|
348
|
-
this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
|
|
349
|
-
// Local-only flush: every replica detects the config change itself, no broadcast needed
|
|
350
|
-
this.clearAll();
|
|
351
|
-
}
|
|
350
|
+
this.syncConfig(config);
|
|
352
351
|
|
|
353
352
|
const request = this.getRequest(context);
|
|
354
353
|
if (!request) {
|
|
@@ -711,7 +710,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
711
710
|
} else if (parsed?.scope === 'user' && parsed.userId) {
|
|
712
711
|
this.clearUser(parsed.userId);
|
|
713
712
|
} else if (parsed?.scope === 'tenant' && parsed.tenantId) {
|
|
714
|
-
this.
|
|
713
|
+
this.clearTenant(parsed.tenantId);
|
|
715
714
|
}
|
|
716
715
|
}
|
|
717
716
|
|
|
@@ -722,6 +721,17 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
722
721
|
this.membershipCache.clear();
|
|
723
722
|
this.tenantIdsCache.clear();
|
|
724
723
|
this.tenantActiveCache.clear();
|
|
724
|
+
// Open WebSocket connections were authorized against what was just thrown away.
|
|
725
|
+
revalidateAllWsConnections();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Forget a tenant's cached `isTenantActive` answer locally (no broadcast), and re-check the open
|
|
730
|
+
* WebSocket connections scoped to it.
|
|
731
|
+
*/
|
|
732
|
+
protected clearTenant(tenantId: string): void {
|
|
733
|
+
this.tenantActiveCache.delete(tenantId);
|
|
734
|
+
revalidateWsConnectionsForTenant(tenantId);
|
|
725
735
|
}
|
|
726
736
|
|
|
727
737
|
/**
|
|
@@ -738,6 +748,25 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
738
748
|
this.tenantIdsCache.delete(key);
|
|
739
749
|
}
|
|
740
750
|
}
|
|
751
|
+
// A membership change reaches the user's OPEN WebSocket connections too, not just new requests.
|
|
752
|
+
// After the caches above, so the re-check reads the membership as it is now.
|
|
753
|
+
revalidateWsConnectionsOf({ userId });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Pick up a changed `multiTenancy` config: the cache TTL, and a flush of everything cached under
|
|
758
|
+
* the old one. Local-only — every replica detects the change itself, no broadcast needed.
|
|
759
|
+
*/
|
|
760
|
+
private syncConfig(config: object & { cacheTtlMs?: number }): void {
|
|
761
|
+
if (this.lastSeenConfig === config) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
this.lastSeenConfig = config;
|
|
765
|
+
// Default 30s in production, 0 (disabled) in test environments to avoid stale data between test cases
|
|
766
|
+
const isTestEnv =
|
|
767
|
+
process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
|
|
768
|
+
this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
|
|
769
|
+
this.clearAll();
|
|
741
770
|
}
|
|
742
771
|
|
|
743
772
|
/**
|
|
@@ -7,6 +7,7 @@ import { Document, Model } from 'mongoose';
|
|
|
7
7
|
import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
|
|
8
8
|
import { accessDeniedException } from '../../common/exceptions/access-denied.exception';
|
|
9
9
|
import { resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
|
|
10
|
+
import { revalidateWsConnectionsOf } from '../../common/helpers/graphql-ws-connection.helper';
|
|
10
11
|
import { maskEmail } from '../../common/helpers/logging.helper';
|
|
11
12
|
import { assignPlain, isQueryableString, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
12
13
|
import { ServiceOptions } from '../../common/interfaces/service-options.interface';
|
|
@@ -377,6 +378,10 @@ export abstract class CoreUserService<
|
|
|
377
378
|
);
|
|
378
379
|
}
|
|
379
380
|
|
|
381
|
+
// The same holds for the user's open GraphQL WebSockets: their re-check finds the device's
|
|
382
|
+
// refresh token gone and closes them, instead of letting them run until the next interval.
|
|
383
|
+
revalidateWsConnectionsOf({ email: dbObject.email, userId: dbObject.id });
|
|
384
|
+
|
|
380
385
|
return updatedUser;
|
|
381
386
|
},
|
|
382
387
|
{ dbObject, serviceOptions },
|
|
@@ -648,6 +653,10 @@ export abstract class CoreUserService<
|
|
|
648
653
|
this.options.betterAuthUserMapper.invalidateUserCache((user as any).iamId);
|
|
649
654
|
}
|
|
650
655
|
|
|
656
|
+
// ... including on the user's OPEN GraphQL WebSockets, which were authorized with the old
|
|
657
|
+
// roles. After the cache above, so the re-check reads the new ones.
|
|
658
|
+
revalidateWsConnectionsOf({ userId: String(userId) });
|
|
659
|
+
|
|
651
660
|
return user;
|
|
652
661
|
},
|
|
653
662
|
{ serviceOptions },
|
|
@@ -730,12 +739,16 @@ export abstract class CoreUserService<
|
|
|
730
739
|
}
|
|
731
740
|
|
|
732
741
|
// Invalidate BetterAuth user cache when roles or verified status may have changed
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
742
|
+
const rightsMayHaveChanged =
|
|
743
|
+
!!input &&
|
|
744
|
+
typeof input === 'object' &&
|
|
745
|
+
('roles' in (input as any) || 'verified' in (input as any) || 'emailVerified' in (input as any));
|
|
746
|
+
if (rightsMayHaveChanged && this.options?.betterAuthUserMapper && (oldUser as any)?.iamId) {
|
|
747
|
+
this.options.betterAuthUserMapper.invalidateUserCache((oldUser as any).iamId);
|
|
748
|
+
}
|
|
749
|
+
// ... and re-check the user's OPEN GraphQL WebSockets, which were authorized with the old values.
|
|
750
|
+
if (rightsMayHaveChanged) {
|
|
751
|
+
revalidateWsConnectionsOf({ userId: String(id) });
|
|
739
752
|
}
|
|
740
753
|
|
|
741
754
|
return updatedUser;
|
package/src/core.module.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DynamicModule,
|
|
4
|
+
Global,
|
|
5
|
+
Logger,
|
|
6
|
+
MiddlewareConsumer,
|
|
7
|
+
Module,
|
|
8
|
+
NestModule,
|
|
9
|
+
UnauthorizedException,
|
|
10
|
+
} from '@nestjs/common';
|
|
3
11
|
import { APP_INTERCEPTOR, APP_PIPE, DiscoveryModule } from '@nestjs/core';
|
|
4
12
|
import { GraphQLModule } from '@nestjs/graphql';
|
|
5
13
|
import { MongooseModule } from '@nestjs/mongoose';
|
|
@@ -8,6 +16,7 @@ import graphqlUploadExpress = require('graphql-upload/graphqlUploadExpress.js');
|
|
|
8
16
|
import mongoose from 'mongoose';
|
|
9
17
|
|
|
10
18
|
import { merge } from './core/common/helpers/config.helper';
|
|
19
|
+
import { registerWsConnection } from './core/common/helpers/graphql-ws-connection.helper';
|
|
11
20
|
import {
|
|
12
21
|
buildRequestContextAwareExecute,
|
|
13
22
|
buildRequestContextAwareSubscribe,
|
|
@@ -39,6 +48,7 @@ import { mongooseRoleGuardPlugin } from './core/common/plugins/mongoose-role-gua
|
|
|
39
48
|
import { mongooseSystemRolePlugin } from './core/common/plugins/mongoose-system-role.plugin';
|
|
40
49
|
import { mongooseTenantPlugin } from './core/common/plugins/mongoose-tenant.plugin';
|
|
41
50
|
import { ConfigService } from './core/common/services/config.service';
|
|
51
|
+
import { CoreBodyParserInitializer } from './core/common/services/core-body-parser.initializer';
|
|
42
52
|
import { CoreCronJobsInitializer } from './core/common/services/core-cron-jobs.initializer';
|
|
43
53
|
import { CoreRedisService } from './core/common/services/core-redis.service';
|
|
44
54
|
import { CoreS3Service } from './core/common/services/core-s3.service';
|
|
@@ -48,6 +58,7 @@ import { MailjetService } from './core/common/services/mailjet.service';
|
|
|
48
58
|
import { ModelDocService } from './core/common/services/model-doc.service';
|
|
49
59
|
import { TemplateService } from './core/common/services/template.service';
|
|
50
60
|
import { CoreAiModule } from './core/modules/ai/core-ai.module';
|
|
61
|
+
import { isLegacyJwt } from './core/modules/better-auth/core-better-auth-token.helper';
|
|
51
62
|
import { CoreBetterAuthUserMapper } from './core/modules/better-auth/core-better-auth-user.mapper';
|
|
52
63
|
import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-auth.module';
|
|
53
64
|
import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
|
|
@@ -314,6 +325,9 @@ export class CoreModule implements NestModule {
|
|
|
314
325
|
},
|
|
315
326
|
|
|
316
327
|
// Core Services
|
|
328
|
+
// Applies `bodyParser` limits and answers a body-parser 413 with an error code. Swaps the
|
|
329
|
+
// parser layers NestJS registered in place, during module init — see the initializer.
|
|
330
|
+
CoreBodyParserInitializer,
|
|
317
331
|
CoreCronJobsInitializer,
|
|
318
332
|
// Audits the file endpoints the project actually REGISTERED. applyFileRoles() above can only
|
|
319
333
|
// reach the base-class members; a subclass that re-declares one keeps its own @Roles() and the
|
|
@@ -665,23 +679,22 @@ export class CoreModule implements NestModule {
|
|
|
665
679
|
connectionParams?.Authorization?.split(' ')[1] ?? headers.Authorization?.split(' ')[1];
|
|
666
680
|
|
|
667
681
|
if (authToken) {
|
|
668
|
-
//
|
|
669
|
-
const
|
|
670
|
-
headers: { authorization: `Bearer ${authToken}` },
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
if (!session || !sessionUser) {
|
|
674
|
-
throw new UnauthorizedException('Invalid or expired session');
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// Map to full user with roles
|
|
678
|
-
const user = await userMapper.mapSessionUser(sessionUser);
|
|
682
|
+
// Resolved like an HTTP request with the same header — see authenticateIamWsToken().
|
|
683
|
+
const user = await CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken);
|
|
679
684
|
if (!user) {
|
|
680
|
-
throw new UnauthorizedException('
|
|
685
|
+
throw new UnauthorizedException('Invalid or expired session');
|
|
681
686
|
}
|
|
682
687
|
|
|
683
688
|
extra.user = user;
|
|
684
689
|
extra.headers = connectionParams ?? headers;
|
|
690
|
+
// Re-checked for the life of the socket — see graphql-ws-connection.helper.ts.
|
|
691
|
+
registerWsConnection({
|
|
692
|
+
carrier: extra,
|
|
693
|
+
reauthenticate: () =>
|
|
694
|
+
CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken),
|
|
695
|
+
socket: extra.socket,
|
|
696
|
+
user,
|
|
697
|
+
});
|
|
685
698
|
return extra;
|
|
686
699
|
}
|
|
687
700
|
|
|
@@ -690,29 +703,30 @@ export class CoreModule implements NestModule {
|
|
|
690
703
|
},
|
|
691
704
|
},
|
|
692
705
|
'subscriptions-transport-ws': {
|
|
693
|
-
onConnect: async (connectionParams) => {
|
|
706
|
+
onConnect: async (connectionParams, webSocket) => {
|
|
694
707
|
const enableAuth = graphQlOpts?.enableSubscriptionAuth ?? true;
|
|
695
708
|
|
|
696
709
|
if (enableAuth) {
|
|
697
710
|
const authToken: string = connectionParams?.Authorization?.split(' ')[1];
|
|
698
711
|
|
|
699
712
|
if (authToken) {
|
|
700
|
-
//
|
|
701
|
-
const
|
|
702
|
-
headers: { authorization: `Bearer ${authToken}` },
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
if (!session || !sessionUser) {
|
|
706
|
-
throw new UnauthorizedException('Invalid or expired session');
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Map to full user with roles
|
|
710
|
-
const user = await userMapper.mapSessionUser(sessionUser);
|
|
713
|
+
// Resolved like an HTTP request with the same header — see authenticateIamWsToken().
|
|
714
|
+
const user = await CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken);
|
|
711
715
|
if (!user) {
|
|
712
|
-
throw new UnauthorizedException('
|
|
716
|
+
throw new UnauthorizedException('Invalid or expired session');
|
|
713
717
|
}
|
|
714
718
|
|
|
715
|
-
|
|
719
|
+
// subscriptions-transport-ws hands each operation a copy of this object; the
|
|
720
|
+
// registration survives that copy — see graphql-ws-connection.helper.ts.
|
|
721
|
+
const connectionContext = { headers: connectionParams, user };
|
|
722
|
+
registerWsConnection({
|
|
723
|
+
carrier: connectionContext,
|
|
724
|
+
reauthenticate: () =>
|
|
725
|
+
CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken),
|
|
726
|
+
socket: webSocket,
|
|
727
|
+
user,
|
|
728
|
+
});
|
|
729
|
+
return connectionContext;
|
|
716
730
|
}
|
|
717
731
|
|
|
718
732
|
throw new UnauthorizedException('Missing authentication token');
|
|
@@ -781,21 +795,22 @@ export class CoreModule implements NestModule {
|
|
|
781
795
|
connectionParams?.Authorization?.split(' ')[1] ?? headers.Authorization?.split(' ')[1];
|
|
782
796
|
|
|
783
797
|
if (authToken) {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
});
|
|
787
|
-
|
|
788
|
-
if (!session || !sessionUser) {
|
|
789
|
-
throw new UnauthorizedException('Invalid or expired session');
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
const user = await userMapper.mapSessionUser(sessionUser);
|
|
798
|
+
// Resolved like an HTTP request with the same header — see authenticateIamWsToken().
|
|
799
|
+
const user = await CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken);
|
|
793
800
|
if (!user) {
|
|
794
|
-
throw new UnauthorizedException('
|
|
801
|
+
throw new UnauthorizedException('Invalid or expired session');
|
|
795
802
|
}
|
|
796
803
|
|
|
797
804
|
extra.user = user;
|
|
798
805
|
extra.headers = connectionParams ?? headers;
|
|
806
|
+
// Re-checked for the life of the socket — see graphql-ws-connection.helper.ts.
|
|
807
|
+
registerWsConnection({
|
|
808
|
+
carrier: extra,
|
|
809
|
+
reauthenticate: () =>
|
|
810
|
+
CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken),
|
|
811
|
+
socket: extra.socket,
|
|
812
|
+
user,
|
|
813
|
+
});
|
|
799
814
|
return extra;
|
|
800
815
|
}
|
|
801
816
|
|
|
@@ -804,7 +819,7 @@ export class CoreModule implements NestModule {
|
|
|
804
819
|
},
|
|
805
820
|
},
|
|
806
821
|
'subscriptions-transport-ws': {
|
|
807
|
-
onConnect: async (connectionParams) => {
|
|
822
|
+
onConnect: async (connectionParams, webSocket) => {
|
|
808
823
|
const enableAuth = graphQlOpts?.enableSubscriptionAuth ?? true;
|
|
809
824
|
|
|
810
825
|
if (enableAuth) {
|
|
@@ -818,20 +833,23 @@ export class CoreModule implements NestModule {
|
|
|
818
833
|
const authToken: string = connectionParams?.Authorization?.split(' ')[1];
|
|
819
834
|
|
|
820
835
|
if (authToken) {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
if (!session || !sessionUser) {
|
|
826
|
-
throw new UnauthorizedException('Invalid or expired session');
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
const user = await userMapper.mapSessionUser(sessionUser);
|
|
836
|
+
// Resolved like an HTTP request with the same header — see authenticateIamWsToken().
|
|
837
|
+
const user = await CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken);
|
|
830
838
|
if (!user) {
|
|
831
|
-
throw new UnauthorizedException('
|
|
839
|
+
throw new UnauthorizedException('Invalid or expired session');
|
|
832
840
|
}
|
|
833
841
|
|
|
834
|
-
|
|
842
|
+
// subscriptions-transport-ws hands each operation a copy of this object; the
|
|
843
|
+
// registration survives that copy — see graphql-ws-connection.helper.ts.
|
|
844
|
+
const connectionContext = { headers: connectionParams, user };
|
|
845
|
+
registerWsConnection({
|
|
846
|
+
carrier: connectionContext,
|
|
847
|
+
reauthenticate: () =>
|
|
848
|
+
CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken),
|
|
849
|
+
socket: webSocket,
|
|
850
|
+
user,
|
|
851
|
+
});
|
|
852
|
+
return connectionContext;
|
|
835
853
|
}
|
|
836
854
|
|
|
837
855
|
throw new UnauthorizedException('Missing authentication token');
|
|
@@ -900,14 +918,20 @@ export class CoreModule implements NestModule {
|
|
|
900
918
|
connectionParams?.Authorization?.split(' ')[1] ?? headers.Authorization?.split(' ')[1];
|
|
901
919
|
if (authToken) {
|
|
902
920
|
// verify authToken/getJwtPayLoad
|
|
903
|
-
const
|
|
904
|
-
const user = await authService.validateUser(payload);
|
|
921
|
+
const user = await CoreModule.authenticateLegacyWsToken(authService, authToken);
|
|
905
922
|
if (!user) {
|
|
906
923
|
throw new UnauthorizedException('No user found for token');
|
|
907
924
|
}
|
|
908
925
|
// the user/jwtPayload object found will be available as context.currentUser/jwtPayload in your GraphQL resolvers
|
|
909
926
|
extra.user = user;
|
|
910
927
|
extra.headers = connectionParams ?? headers;
|
|
928
|
+
// Re-checked for the life of the socket — see graphql-ws-connection.helper.ts.
|
|
929
|
+
registerWsConnection({
|
|
930
|
+
carrier: extra,
|
|
931
|
+
reauthenticate: () => CoreModule.authenticateLegacyWsToken(authService, authToken),
|
|
932
|
+
socket: extra.socket,
|
|
933
|
+
user,
|
|
934
|
+
});
|
|
911
935
|
return extra;
|
|
912
936
|
}
|
|
913
937
|
|
|
@@ -916,20 +940,28 @@ export class CoreModule implements NestModule {
|
|
|
916
940
|
},
|
|
917
941
|
},
|
|
918
942
|
'subscriptions-transport-ws': {
|
|
919
|
-
onConnect: async (connectionParams) => {
|
|
943
|
+
onConnect: async (connectionParams, webSocket) => {
|
|
920
944
|
if (enableSubscriptionAuth) {
|
|
921
945
|
// get authToken from authorization header
|
|
922
946
|
const authToken: string = connectionParams?.Authorization?.split(' ')[1];
|
|
923
947
|
|
|
924
948
|
if (authToken) {
|
|
925
949
|
// verify authToken/getJwtPayLoad
|
|
926
|
-
const
|
|
927
|
-
const user = await authService.validateUser(payload);
|
|
950
|
+
const user = await CoreModule.authenticateLegacyWsToken(authService, authToken);
|
|
928
951
|
if (!user) {
|
|
929
952
|
throw new UnauthorizedException('No user found for token');
|
|
930
953
|
}
|
|
931
954
|
// the user/jwtPayload object found will be available as context.currentUser/jwtPayload in your GraphQL resolvers
|
|
932
|
-
|
|
955
|
+
// subscriptions-transport-ws hands each operation a copy of this object; the
|
|
956
|
+
// registration survives that copy — see graphql-ws-connection.helper.ts.
|
|
957
|
+
const connectionContext = { headers: connectionParams, user };
|
|
958
|
+
registerWsConnection({
|
|
959
|
+
carrier: connectionContext,
|
|
960
|
+
reauthenticate: () => CoreModule.authenticateLegacyWsToken(authService, authToken),
|
|
961
|
+
socket: webSocket,
|
|
962
|
+
user,
|
|
963
|
+
});
|
|
964
|
+
return connectionContext;
|
|
933
965
|
}
|
|
934
966
|
|
|
935
967
|
throw new UnauthorizedException('Missing authentication token');
|
|
@@ -943,6 +975,89 @@ export class CoreModule implements NestModule {
|
|
|
943
975
|
};
|
|
944
976
|
}
|
|
945
977
|
|
|
978
|
+
/**
|
|
979
|
+
* Resolve the bearer token of an IAM WebSocket handshake to a user — and re-resolve it for a
|
|
980
|
+
* connection that is already open (the `reauthenticate` of `registerWsConnection()`). Answers
|
|
981
|
+
* `null` when the token does not authenticate (any more).
|
|
982
|
+
*
|
|
983
|
+
* Mirrors Strategy 1 of `CoreBetterAuthMiddleware` — a Better-Auth JWT first, then the session
|
|
984
|
+
* store — because that is what an HTTP request carrying the same header gets. Until 11.42.3 the
|
|
985
|
+
* handshake asked `getSession()` with an `authorization` header, which Better-Auth reads only
|
|
986
|
+
* with its `bearer` plugin, and the framework does not install that plugin: every IAM handshake
|
|
987
|
+
* with a bearer token was refused as "Invalid or expired session". `getSession()` stays as the
|
|
988
|
+
* last resort, so a project that added the bearer plugin itself resolves exactly as before.
|
|
989
|
+
*
|
|
990
|
+
* A JWT is checked statelessly (signature, expiry), as on HTTP: signing out ends the session
|
|
991
|
+
* behind it, not the JWT, which lapses on its own (`betterAuth.jwt.expiresIn`, 15 min by default).
|
|
992
|
+
*/
|
|
993
|
+
private static async authenticateIamWsToken(
|
|
994
|
+
betterAuthService: Pick<
|
|
995
|
+
CoreBetterAuthService,
|
|
996
|
+
'getSession' | 'getSessionByToken' | 'isJwtEnabled' | 'verifyJwtToken'
|
|
997
|
+
>,
|
|
998
|
+
userMapper: Pick<CoreBetterAuthUserMapper, 'mapSessionUser'>,
|
|
999
|
+
authToken: string,
|
|
1000
|
+
): Promise<any> {
|
|
1001
|
+
if (!authToken) {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
if (authToken.split('.').length === 3 && !isLegacyJwt(authToken) && betterAuthService.isJwtEnabled()) {
|
|
1005
|
+
const payload = await betterAuthService.verifyJwtToken(authToken);
|
|
1006
|
+
if (payload?.sub) {
|
|
1007
|
+
return userMapper.mapSessionUser({
|
|
1008
|
+
email: payload.email || '',
|
|
1009
|
+
emailVerified: payload.emailVerified,
|
|
1010
|
+
id: payload.sub,
|
|
1011
|
+
name: payload.name,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
const stored = await betterAuthService.getSessionByToken(authToken);
|
|
1016
|
+
if (stored?.session && stored?.user) {
|
|
1017
|
+
return userMapper.mapSessionUser(stored.user);
|
|
1018
|
+
}
|
|
1019
|
+
const { session, user: sessionUser } = await betterAuthService.getSession({
|
|
1020
|
+
headers: { authorization: `Bearer ${authToken}` },
|
|
1021
|
+
});
|
|
1022
|
+
return session && sessionUser ? userMapper.mapSessionUser(sessionUser) : null;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* The legacy counterpart of `authenticateIamWsToken()`: the token's signature and expiry are verified
|
|
1027
|
+
* exactly as passport-jwt does on HTTP (`CoreAuthService.verifyJwt()`), then `validateUser()` ties it
|
|
1028
|
+
* to its device's live refresh token, so a logout, a password reset or a token refresh ends it.
|
|
1029
|
+
*
|
|
1030
|
+
* Until 11.42.3 this read the token with `decodeJwt()`, which checks neither signature nor expiry:
|
|
1031
|
+
* an expired token — or a copy re-signed with any key — opened sockets for as long as its `tokenId`
|
|
1032
|
+
* matched the device. A token that is not a JWT at all decoded to `null`, which `validateUser()` then
|
|
1033
|
+
* dereferenced: the handshake closed with 4500 and the TypeError as its reason.
|
|
1034
|
+
*
|
|
1035
|
+
* An AuthService that does not extend `CoreAuthService` may lack `verifyJwt()`. It keeps the
|
|
1036
|
+
* unverified read rather than losing its WebSockets on upgrade, and is warned about once.
|
|
1037
|
+
*/
|
|
1038
|
+
private static async authenticateLegacyWsToken(authService: any, authToken: string): Promise<any> {
|
|
1039
|
+
if (!authToken) {
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
let payload: any;
|
|
1043
|
+
if (typeof authService?.verifyJwt === 'function') {
|
|
1044
|
+
payload = await authService.verifyJwt(authToken);
|
|
1045
|
+
} else {
|
|
1046
|
+
if (!CoreModule.unverifiedLegacyWsTokenWarned) {
|
|
1047
|
+
CoreModule.unverifiedLegacyWsTokenWarned = true;
|
|
1048
|
+
new Logger('CoreModule').warn(
|
|
1049
|
+
'The AuthService passed to CoreModule.forRoot() has no verifyJwt(): legacy WebSocket tokens are ' +
|
|
1050
|
+
'read WITHOUT checking signature or expiry. Extend CoreAuthService to close that.',
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
payload = authService.decodeJwt(authToken);
|
|
1054
|
+
}
|
|
1055
|
+
return payload ? authService.validateUser(payload) : null;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Whether the "no verifyJwt()" warning was already logged in this process. */
|
|
1059
|
+
private static unverifiedLegacyWsTokenWarned = false;
|
|
1060
|
+
|
|
946
1061
|
/**
|
|
947
1062
|
* @deprecated Use `isCookiesEnabled` from `core/common/helpers/cookies.helper` instead.
|
|
948
1063
|
*/
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export * from './core/common/helpers/execution-context-request.helper';
|
|
|
40
40
|
export * from './core/common/helpers/file.helper';
|
|
41
41
|
export * from './core/common/helpers/filter.helper';
|
|
42
42
|
export * from './core/common/helpers/graphql.helper';
|
|
43
|
+
export * from './core/common/helpers/graphql-ws-connection.helper';
|
|
43
44
|
export * from './core/common/helpers/graphql-ws-context.helper';
|
|
44
45
|
export * from './core/common/helpers/interceptor.helper';
|
|
45
46
|
export * from './core/common/helpers/gridfs.helper';
|