@lenne.tech/nest-server 11.42.2 → 11.42.3

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 (46) hide show
  1. package/.claude/rules/configurable-features.md +2 -1
  2. package/.claude/rules/role-system.md +31 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +1 -1
  6. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
  7. package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
  8. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
  9. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
  10. package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
  11. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
  12. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  13. package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
  14. package/dist/core/modules/auth/services/core-auth.service.js +19 -0
  15. package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
  16. package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
  17. package/dist/core/modules/better-auth/better-auth.config.js +27 -1
  18. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
  20. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  21. package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
  22. package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
  23. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  24. package/dist/core/modules/user/core-user.service.js +11 -6
  25. package/dist/core/modules/user/core-user.service.js.map +1 -1
  26. package/dist/core.module.d.ts +3 -0
  27. package/dist/core.module.js +100 -42
  28. package/dist/core.module.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/tsconfig.build.tsbuildinfo +1 -1
  33. package/docs/REQUEST-LIFECYCLE.md +1 -0
  34. package/migration-guides/11.42.2-to-11.42.3.md +145 -0
  35. package/package.json +1 -1
  36. package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
  37. package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
  38. package/src/core/common/interfaces/server-options.interface.ts +32 -2
  39. package/src/core/modules/auth/services/core-auth.service.ts +37 -0
  40. package/src/core/modules/better-auth/better-auth.config.ts +46 -1
  41. package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
  42. package/src/core/modules/tenant/README.md +8 -8
  43. package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
  44. package/src/core/modules/user/core-user.service.ts +19 -6
  45. package/src/core.module.ts +166 -55
  46. package/src/index.ts +1 -0
@@ -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,
@@ -48,6 +57,7 @@ import { MailjetService } from './core/common/services/mailjet.service';
48
57
  import { ModelDocService } from './core/common/services/model-doc.service';
49
58
  import { TemplateService } from './core/common/services/template.service';
50
59
  import { CoreAiModule } from './core/modules/ai/core-ai.module';
60
+ import { isLegacyJwt } from './core/modules/better-auth/core-better-auth-token.helper';
51
61
  import { CoreBetterAuthUserMapper } from './core/modules/better-auth/core-better-auth-user.mapper';
52
62
  import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-auth.module';
53
63
  import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
@@ -665,23 +675,22 @@ export class CoreModule implements NestModule {
665
675
  connectionParams?.Authorization?.split(' ')[1] ?? headers.Authorization?.split(' ')[1];
666
676
 
667
677
  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);
678
+ // Resolved like an HTTP request with the same header — see authenticateIamWsToken().
679
+ const user = await CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken);
679
680
  if (!user) {
680
- throw new UnauthorizedException('User not found');
681
+ throw new UnauthorizedException('Invalid or expired session');
681
682
  }
682
683
 
683
684
  extra.user = user;
684
685
  extra.headers = connectionParams ?? headers;
686
+ // Re-checked for the life of the socket — see graphql-ws-connection.helper.ts.
687
+ registerWsConnection({
688
+ carrier: extra,
689
+ reauthenticate: () =>
690
+ CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken),
691
+ socket: extra.socket,
692
+ user,
693
+ });
685
694
  return extra;
686
695
  }
687
696
 
@@ -690,29 +699,30 @@ export class CoreModule implements NestModule {
690
699
  },
691
700
  },
692
701
  'subscriptions-transport-ws': {
693
- onConnect: async (connectionParams) => {
702
+ onConnect: async (connectionParams, webSocket) => {
694
703
  const enableAuth = graphQlOpts?.enableSubscriptionAuth ?? true;
695
704
 
696
705
  if (enableAuth) {
697
706
  const authToken: string = connectionParams?.Authorization?.split(' ')[1];
698
707
 
699
708
  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);
709
+ // Resolved like an HTTP request with the same header — see authenticateIamWsToken().
710
+ const user = await CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken);
711
711
  if (!user) {
712
- throw new UnauthorizedException('User not found');
712
+ throw new UnauthorizedException('Invalid or expired session');
713
713
  }
714
714
 
715
- return { headers: connectionParams, user };
715
+ // subscriptions-transport-ws hands each operation a copy of this object; the
716
+ // registration survives that copy — see graphql-ws-connection.helper.ts.
717
+ const connectionContext = { headers: connectionParams, user };
718
+ registerWsConnection({
719
+ carrier: connectionContext,
720
+ reauthenticate: () =>
721
+ CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken),
722
+ socket: webSocket,
723
+ user,
724
+ });
725
+ return connectionContext;
716
726
  }
717
727
 
718
728
  throw new UnauthorizedException('Missing authentication token');
@@ -781,21 +791,22 @@ export class CoreModule implements NestModule {
781
791
  connectionParams?.Authorization?.split(' ')[1] ?? headers.Authorization?.split(' ')[1];
782
792
 
783
793
  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);
794
+ // Resolved like an HTTP request with the same header — see authenticateIamWsToken().
795
+ const user = await CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken);
793
796
  if (!user) {
794
- throw new UnauthorizedException('User not found');
797
+ throw new UnauthorizedException('Invalid or expired session');
795
798
  }
796
799
 
797
800
  extra.user = user;
798
801
  extra.headers = connectionParams ?? headers;
802
+ // Re-checked for the life of the socket — see graphql-ws-connection.helper.ts.
803
+ registerWsConnection({
804
+ carrier: extra,
805
+ reauthenticate: () =>
806
+ CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken),
807
+ socket: extra.socket,
808
+ user,
809
+ });
799
810
  return extra;
800
811
  }
801
812
 
@@ -804,7 +815,7 @@ export class CoreModule implements NestModule {
804
815
  },
805
816
  },
806
817
  'subscriptions-transport-ws': {
807
- onConnect: async (connectionParams) => {
818
+ onConnect: async (connectionParams, webSocket) => {
808
819
  const enableAuth = graphQlOpts?.enableSubscriptionAuth ?? true;
809
820
 
810
821
  if (enableAuth) {
@@ -818,20 +829,23 @@ export class CoreModule implements NestModule {
818
829
  const authToken: string = connectionParams?.Authorization?.split(' ')[1];
819
830
 
820
831
  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);
832
+ // Resolved like an HTTP request with the same header — see authenticateIamWsToken().
833
+ const user = await CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken);
830
834
  if (!user) {
831
- throw new UnauthorizedException('User not found');
835
+ throw new UnauthorizedException('Invalid or expired session');
832
836
  }
833
837
 
834
- return { headers: connectionParams, user };
838
+ // subscriptions-transport-ws hands each operation a copy of this object; the
839
+ // registration survives that copy — see graphql-ws-connection.helper.ts.
840
+ const connectionContext = { headers: connectionParams, user };
841
+ registerWsConnection({
842
+ carrier: connectionContext,
843
+ reauthenticate: () =>
844
+ CoreModule.authenticateIamWsToken(betterAuthService, userMapper, authToken),
845
+ socket: webSocket,
846
+ user,
847
+ });
848
+ return connectionContext;
835
849
  }
836
850
 
837
851
  throw new UnauthorizedException('Missing authentication token');
@@ -900,14 +914,20 @@ export class CoreModule implements NestModule {
900
914
  connectionParams?.Authorization?.split(' ')[1] ?? headers.Authorization?.split(' ')[1];
901
915
  if (authToken) {
902
916
  // verify authToken/getJwtPayLoad
903
- const payload = authService.decodeJwt(authToken);
904
- const user = await authService.validateUser(payload);
917
+ const user = await CoreModule.authenticateLegacyWsToken(authService, authToken);
905
918
  if (!user) {
906
919
  throw new UnauthorizedException('No user found for token');
907
920
  }
908
921
  // the user/jwtPayload object found will be available as context.currentUser/jwtPayload in your GraphQL resolvers
909
922
  extra.user = user;
910
923
  extra.headers = connectionParams ?? headers;
924
+ // Re-checked for the life of the socket — see graphql-ws-connection.helper.ts.
925
+ registerWsConnection({
926
+ carrier: extra,
927
+ reauthenticate: () => CoreModule.authenticateLegacyWsToken(authService, authToken),
928
+ socket: extra.socket,
929
+ user,
930
+ });
911
931
  return extra;
912
932
  }
913
933
 
@@ -916,20 +936,28 @@ export class CoreModule implements NestModule {
916
936
  },
917
937
  },
918
938
  'subscriptions-transport-ws': {
919
- onConnect: async (connectionParams) => {
939
+ onConnect: async (connectionParams, webSocket) => {
920
940
  if (enableSubscriptionAuth) {
921
941
  // get authToken from authorization header
922
942
  const authToken: string = connectionParams?.Authorization?.split(' ')[1];
923
943
 
924
944
  if (authToken) {
925
945
  // verify authToken/getJwtPayLoad
926
- const payload = authService.decodeJwt(authToken);
927
- const user = await authService.validateUser(payload);
946
+ const user = await CoreModule.authenticateLegacyWsToken(authService, authToken);
928
947
  if (!user) {
929
948
  throw new UnauthorizedException('No user found for token');
930
949
  }
931
950
  // the user/jwtPayload object found will be available as context.currentUser/jwtPayload in your GraphQL resolvers
932
- return { headers: connectionParams, user };
951
+ // subscriptions-transport-ws hands each operation a copy of this object; the
952
+ // registration survives that copy — see graphql-ws-connection.helper.ts.
953
+ const connectionContext = { headers: connectionParams, user };
954
+ registerWsConnection({
955
+ carrier: connectionContext,
956
+ reauthenticate: () => CoreModule.authenticateLegacyWsToken(authService, authToken),
957
+ socket: webSocket,
958
+ user,
959
+ });
960
+ return connectionContext;
933
961
  }
934
962
 
935
963
  throw new UnauthorizedException('Missing authentication token');
@@ -943,6 +971,89 @@ export class CoreModule implements NestModule {
943
971
  };
944
972
  }
945
973
 
974
+ /**
975
+ * Resolve the bearer token of an IAM WebSocket handshake to a user — and re-resolve it for a
976
+ * connection that is already open (the `reauthenticate` of `registerWsConnection()`). Answers
977
+ * `null` when the token does not authenticate (any more).
978
+ *
979
+ * Mirrors Strategy 1 of `CoreBetterAuthMiddleware` — a Better-Auth JWT first, then the session
980
+ * store — because that is what an HTTP request carrying the same header gets. Until 11.42.3 the
981
+ * handshake asked `getSession()` with an `authorization` header, which Better-Auth reads only
982
+ * with its `bearer` plugin, and the framework does not install that plugin: every IAM handshake
983
+ * with a bearer token was refused as "Invalid or expired session". `getSession()` stays as the
984
+ * last resort, so a project that added the bearer plugin itself resolves exactly as before.
985
+ *
986
+ * A JWT is checked statelessly (signature, expiry), as on HTTP: signing out ends the session
987
+ * behind it, not the JWT, which lapses on its own (`betterAuth.jwt.expiresIn`, 15 min by default).
988
+ */
989
+ private static async authenticateIamWsToken(
990
+ betterAuthService: Pick<
991
+ CoreBetterAuthService,
992
+ 'getSession' | 'getSessionByToken' | 'isJwtEnabled' | 'verifyJwtToken'
993
+ >,
994
+ userMapper: Pick<CoreBetterAuthUserMapper, 'mapSessionUser'>,
995
+ authToken: string,
996
+ ): Promise<any> {
997
+ if (!authToken) {
998
+ return null;
999
+ }
1000
+ if (authToken.split('.').length === 3 && !isLegacyJwt(authToken) && betterAuthService.isJwtEnabled()) {
1001
+ const payload = await betterAuthService.verifyJwtToken(authToken);
1002
+ if (payload?.sub) {
1003
+ return userMapper.mapSessionUser({
1004
+ email: payload.email || '',
1005
+ emailVerified: payload.emailVerified,
1006
+ id: payload.sub,
1007
+ name: payload.name,
1008
+ });
1009
+ }
1010
+ }
1011
+ const stored = await betterAuthService.getSessionByToken(authToken);
1012
+ if (stored?.session && stored?.user) {
1013
+ return userMapper.mapSessionUser(stored.user);
1014
+ }
1015
+ const { session, user: sessionUser } = await betterAuthService.getSession({
1016
+ headers: { authorization: `Bearer ${authToken}` },
1017
+ });
1018
+ return session && sessionUser ? userMapper.mapSessionUser(sessionUser) : null;
1019
+ }
1020
+
1021
+ /**
1022
+ * The legacy counterpart of `authenticateIamWsToken()`: the token's signature and expiry are verified
1023
+ * exactly as passport-jwt does on HTTP (`CoreAuthService.verifyJwt()`), then `validateUser()` ties it
1024
+ * to its device's live refresh token, so a logout, a password reset or a token refresh ends it.
1025
+ *
1026
+ * Until 11.42.3 this read the token with `decodeJwt()`, which checks neither signature nor expiry:
1027
+ * an expired token — or a copy re-signed with any key — opened sockets for as long as its `tokenId`
1028
+ * matched the device. A token that is not a JWT at all decoded to `null`, which `validateUser()` then
1029
+ * dereferenced: the handshake closed with 4500 and the TypeError as its reason.
1030
+ *
1031
+ * An AuthService that does not extend `CoreAuthService` may lack `verifyJwt()`. It keeps the
1032
+ * unverified read rather than losing its WebSockets on upgrade, and is warned about once.
1033
+ */
1034
+ private static async authenticateLegacyWsToken(authService: any, authToken: string): Promise<any> {
1035
+ if (!authToken) {
1036
+ return null;
1037
+ }
1038
+ let payload: any;
1039
+ if (typeof authService?.verifyJwt === 'function') {
1040
+ payload = await authService.verifyJwt(authToken);
1041
+ } else {
1042
+ if (!CoreModule.unverifiedLegacyWsTokenWarned) {
1043
+ CoreModule.unverifiedLegacyWsTokenWarned = true;
1044
+ new Logger('CoreModule').warn(
1045
+ 'The AuthService passed to CoreModule.forRoot() has no verifyJwt(): legacy WebSocket tokens are ' +
1046
+ 'read WITHOUT checking signature or expiry. Extend CoreAuthService to close that.',
1047
+ );
1048
+ }
1049
+ payload = authService.decodeJwt(authToken);
1050
+ }
1051
+ return payload ? authService.validateUser(payload) : null;
1052
+ }
1053
+
1054
+ /** Whether the "no verifyJwt()" warning was already logged in this process. */
1055
+ private static unverifiedLegacyWsTokenWarned = false;
1056
+
946
1057
  /**
947
1058
  * @deprecated Use `isCookiesEnabled` from `core/common/helpers/cookies.helper` instead.
948
1059
  */
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';