@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.
Files changed (62) hide show
  1. package/.claude/rules/configurable-features.md +3 -1
  2. package/.claude/rules/package-management.md +1 -1
  3. package/.claude/rules/role-system.md +31 -3
  4. package/.claude/rules/testing.md +4 -4
  5. package/CLAUDE.md +7 -1
  6. package/FRAMEWORK-API.md +2 -1
  7. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
  8. package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
  9. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
  10. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
  11. package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
  12. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
  13. package/dist/core/common/interfaces/server-options.interface.d.ts +9 -0
  14. package/dist/core/common/services/core-body-parser.initializer.d.ts +26 -0
  15. package/dist/core/common/services/core-body-parser.initializer.js +136 -0
  16. package/dist/core/common/services/core-body-parser.initializer.js.map +1 -0
  17. package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
  18. package/dist/core/modules/auth/services/core-auth.service.js +19 -0
  19. package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
  20. package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
  21. package/dist/core/modules/better-auth/better-auth.config.js +27 -1
  22. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  23. package/dist/core/modules/better-auth/core-better-auth.module.js +7 -3
  24. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  25. package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
  26. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  27. package/dist/core/modules/error-code/error-codes.d.ts +9 -0
  28. package/dist/core/modules/error-code/error-codes.js +8 -0
  29. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  30. package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
  31. package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
  32. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  33. package/dist/core/modules/user/core-user.service.js +11 -6
  34. package/dist/core/modules/user/core-user.service.js.map +1 -1
  35. package/dist/core.module.d.ts +3 -0
  36. package/dist/core.module.js +102 -42
  37. package/dist/core.module.js.map +1 -1
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/server/modules/error-code/error-codes.d.ts +1 -0
  42. package/dist/tsconfig.build.tsbuildinfo +1 -1
  43. package/docs/REQUEST-LIFECYCLE.md +29 -0
  44. package/docs/error-codes.md +16 -0
  45. package/docs/security-overrides.md +18 -2
  46. package/migration-guides/11.42.2-to-11.42.3.md +145 -0
  47. package/migration-guides/11.42.3-to-11.42.4.md +185 -0
  48. package/package.json +3 -3
  49. package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
  50. package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
  51. package/src/core/common/interfaces/server-options.interface.ts +121 -2
  52. package/src/core/common/services/core-body-parser.initializer.ts +232 -0
  53. package/src/core/modules/auth/services/core-auth.service.ts +37 -0
  54. package/src/core/modules/better-auth/better-auth.config.ts +46 -1
  55. package/src/core/modules/better-auth/core-better-auth.module.ts +24 -4
  56. package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
  57. package/src/core/modules/error-code/error-codes.ts +9 -0
  58. package/src/core/modules/tenant/README.md +8 -8
  59. package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
  60. package/src/core/modules/user/core-user.service.ts +19 -6
  61. package/src/core.module.ts +170 -55
  62. 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.tenantActiveCache.delete(tenantId);
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
- // Detect config changes (e.g., roleHierarchy modified in tests) and flush caches
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.tenantActiveCache.delete(parsed.tenantId);
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
- if (this.options?.betterAuthUserMapper && (oldUser as any)?.iamId) {
734
- const rolesChanged = 'roles' in (input as any);
735
- const verifiedChanged = 'verified' in (input as any) || 'emailVerified' in (input as any);
736
- if (rolesChanged || verifiedChanged) {
737
- this.options.betterAuthUserMapper.invalidateUserCache((oldUser as any).iamId);
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;
@@ -1,5 +1,13 @@
1
1
  import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
2
- import { DynamicModule, Global, MiddlewareConsumer, Module, NestModule, UnauthorizedException } from '@nestjs/common';
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
- // Validate via BetterAuth session
669
- const { session, user: sessionUser } = await betterAuthService.getSession({
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('User not found');
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
- // Validate via BetterAuth session
701
- const { session, user: sessionUser } = await betterAuthService.getSession({
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('User not found');
716
+ throw new UnauthorizedException('Invalid or expired session');
713
717
  }
714
718
 
715
- return { headers: connectionParams, user };
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
- const { session, user: sessionUser } = await betterAuthService.getSession({
785
- headers: { authorization: `Bearer ${authToken}` },
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('User not found');
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
- const { session, user: sessionUser } = await betterAuthService.getSession({
822
- headers: { authorization: `Bearer ${authToken}` },
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('User not found');
839
+ throw new UnauthorizedException('Invalid or expired session');
832
840
  }
833
841
 
834
- return { headers: connectionParams, user };
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 payload = authService.decodeJwt(authToken);
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 payload = authService.decodeJwt(authToken);
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
- return { headers: connectionParams, user };
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';