diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index adb7a8b09..2188d2d08 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -34,6 +34,14 @@ import { MailerModule } from '@tractr/nestjs-mailer'; AuthenticationModule.registerAsync({ useFactory: (defaultOptions) => ({ ...defaultOptions, + userConfig: { + idField: 'id', + loginField: 'email', + passwordField: 'password', + emailField: 'email', + customSelect: getSelectPrismaUserQuery(), + formatUser: ({ ...user }) => user, + }, userService: USER_SERVICE, }), }), @@ -58,7 +66,6 @@ import { MailerModule } from '@tractr/nestjs-mailer'; }), CaslModule.register({ rolePermissions, - getSelectPrismaUserQuery, }), ConsoleModule, LoggerModule, diff --git a/libs/nestjs/authentication/src/authentication.module.spec.ts b/libs/nestjs/authentication/src/authentication.module.spec.ts index 907ce4ad9..9bea72538 100644 --- a/libs/nestjs/authentication/src/authentication.module.spec.ts +++ b/libs/nestjs/authentication/src/authentication.module.spec.ts @@ -22,6 +22,15 @@ const AUTHENTICATION_MOCK_USER_SERVICE = 'AUTHENTICATION_MOCK_USER_SERVICE'; describe('Authentication Module with async options', () => { let app: INestApplication; let mockUserService: MockProxy; + const mockUserConfig = { + emailField: 'email', + passwordField: 'password', + loginField: 'email', + idField: 'id', + customSelect: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formatUser: (user: Record) => user, + }; beforeEach(async () => { mockUserService = mockDeep(); @@ -44,6 +53,7 @@ describe('Authentication Module with async options', () => { useFactory: (defaultValue) => Promise.resolve({ ...defaultValue, + userConfig: mockUserConfig, jwtModuleOptions: { secret: 'integration-tests', }, @@ -97,12 +107,7 @@ describe('Authentication Module with async options', () => { passReqToCallback: true, }, }, - userConfig: { - emailField: 'email', - passwordField: 'password', - loginField: 'email', - idField: 'id', - }, + userConfig: mockUserConfig, userService: AUTHENTICATION_MOCK_USER_SERVICE, }); }); diff --git a/libs/nestjs/authentication/src/controllers/login.controller.spec.ts b/libs/nestjs/authentication/src/controllers/login.controller.spec.ts index a152d7149..ff28b5ae6 100644 --- a/libs/nestjs/authentication/src/controllers/login.controller.spec.ts +++ b/libs/nestjs/authentication/src/controllers/login.controller.spec.ts @@ -20,6 +20,7 @@ describe('Authentication Module', () => { let app: INestApplication; let mockUserService: MockProxy; let mockUser: UserType; + const mockFormatUser = jest.fn(); beforeAll(async () => { mockUserService = mockDeep(); @@ -45,6 +46,13 @@ describe('Authentication Module', () => { imports: [ LoggerModule, AuthenticationModule.register({ + userConfig: { + idField: 'id', + emailField: 'email', + loginField: 'email', + passwordField: 'password', + formatUser: mockFormatUser, + }, jwtModuleOptions: { secret: 'integration-tests', }, @@ -53,7 +61,6 @@ describe('Authentication Module', () => { ], }).compile(); - mockUserService.findUnique.mockReset(); mockUserService.findUnique.mockReturnValue(Promise.resolve(mockUser)); app = moduleFixture.createNestApplication(); @@ -61,7 +68,14 @@ describe('Authentication Module', () => { await app.init(); }); + beforeEach(() => { + mockFormatUser.mockImplementation((user) => user); + }); + afterEach(async () => { + mockFormatUser.mockReset(); + mockUserService.findUnique.mockReset(); + // mockFormatUser.mockReset(); if (app) await app.close(); }); @@ -107,6 +121,9 @@ describe('Authentication Module', () => { accessToken: await authenticationService.createUserJWT(mockUser), user: expectedUser, }); + + expect(mockFormatUser).toHaveBeenCalledTimes(1); + expect(mockFormatUser).toBeCalledWith(expectedUser); }); it('/me get the user information back and use the jwt auth strategy', async () => { const authenticationService = app.get( @@ -122,6 +139,9 @@ describe('Authentication Module', () => { expect(response.status).toBe(200); expect(response.body).toEqual(JSON.parse(JSON.stringify(mockUser))); + + expect(mockFormatUser).toHaveBeenCalledTimes(1); + expect(mockFormatUser).toBeCalledWith(mockUser); }); it('/me get the user information back and use the query param auth strategy', async () => { const authenticationService = app.get( @@ -137,6 +157,9 @@ describe('Authentication Module', () => { expect(response.status).toBe(200); expect(response.body).toEqual(JSON.parse(JSON.stringify(mockUser))); + + expect(mockFormatUser).toHaveBeenCalledTimes(1); + expect(mockFormatUser).toBeCalledWith(mockUser); }); it('/logout should set the cookie to undefined to logout the browser', async () => { diff --git a/libs/nestjs/authentication/src/controllers/login.controller.ts b/libs/nestjs/authentication/src/controllers/login.controller.ts index b93dcb983..90ed983c5 100644 --- a/libs/nestjs/authentication/src/controllers/login.controller.ts +++ b/libs/nestjs/authentication/src/controllers/login.controller.ts @@ -35,15 +35,21 @@ export class LoginController { ): Promise { const user = this.throwIfNoUser(req); const token = await this.authenticationService.login(user); + const { options: cookieOptions } = this.authenticationOptions.cookies; + const { formatUser } = this.authenticationOptions.userConfig; res.cookie( this.authenticationOptions.cookies.cookieName, token.accessToken, { signed: !!req.secret, - ...this.authenticationOptions.cookies.options, + ...cookieOptions, }, ); - return { ...token, user }; + return { + ...token, + // Format user with filter provided by the module consumer + user: formatUser(user), + }; } @Get('logout') @@ -70,7 +76,9 @@ export class LoginController { @Get('me') me(@Req() req: Request, @CurrentUser() user: UserType): UserType { this.throwIfNoUser(req); - return user; + const { formatUser } = this.authenticationOptions.userConfig; + // Format user with filter provided by the module consumer + return formatUser(user); } throwIfNoUser(req: Request): UserType { diff --git a/libs/nestjs/authentication/src/dtos/authentication-options-user.dto.ts b/libs/nestjs/authentication/src/dtos/authentication-options-user.dto.ts index aca5bd541..631af46c4 100644 --- a/libs/nestjs/authentication/src/dtos/authentication-options-user.dto.ts +++ b/libs/nestjs/authentication/src/dtos/authentication-options-user.dto.ts @@ -1,4 +1,4 @@ -import { IsString } from 'class-validator'; +import { IsObject, IsOptional, IsString } from 'class-validator'; import { DEFAULT_EMAIL_FIELD, @@ -8,15 +8,44 @@ import { } from '../constants'; export class AuthenticationOptionsUser { + /** + * Specify the id field of the user entity + */ @IsString() idField: string = DEFAULT_ID_FIELD; + /** + * Specify the login field of the user entity + */ @IsString() loginField: string = DEFAULT_LOGIN_FIELD; + /** + * Specify the password field of the user entity + */ @IsString() passwordField: string = DEFAULT_PASSWORD_FIELD; + /** + * Specify the email field of the user entity + */ @IsString() emailField: string = DEFAULT_EMAIL_FIELD; + + /** + * Allows to specify custom select rules to populate user field + * during authentication. + * Those fields will be accessible by the authorization layer. + */ + @IsObject() + @IsOptional() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + customSelect?: Record; + + /** + * Allows to specify a filter function to remove some fields + * from the user before returning it to the frontend */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formatUser: (user: Record) => Record = (user) => + user; } diff --git a/libs/nestjs/authentication/src/services/authentication.service.spec.ts b/libs/nestjs/authentication/src/services/authentication.service.spec.ts index 3d8a44a4f..855cae2a8 100644 --- a/libs/nestjs/authentication/src/services/authentication.service.spec.ts +++ b/libs/nestjs/authentication/src/services/authentication.service.spec.ts @@ -129,6 +129,7 @@ describe('AuthService', () => { describe('authenticateLoginCredentials', () => { it('should throw a UserNotFoundError if no user has been found by the login field', async () => { mockAuthenticationOptions.userConfig = { + ...mockAuthenticationOptions.userConfig, loginField: 'loginFieldTest', emailField: 'emailFieldTest', passwordField: 'passwordFieldTest', diff --git a/libs/nestjs/authentication/src/services/authentication.service.ts b/libs/nestjs/authentication/src/services/authentication.service.ts index e8dd9f999..575f68b6f 100644 --- a/libs/nestjs/authentication/src/services/authentication.service.ts +++ b/libs/nestjs/authentication/src/services/authentication.service.ts @@ -36,9 +36,11 @@ export class AuthenticationService { login: string, password: string, ): Promise { - const { passwordField } = this.authenticationOptions.userConfig; + const { passwordField, customSelect } = + this.authenticationOptions.userConfig; - const user = await this.findUserByLogin(login); + // Fetch user and use select clause provided by the module consumer + const user = await this.findUserByLogin(login, customSelect); if (!user) throw new UserNotFoundError(); const { [passwordField]: passwordBcrypt } = diff --git a/libs/nestjs/authentication/src/strategies/jwt.strategy.ts b/libs/nestjs/authentication/src/strategies/jwt.strategy.ts index cb5e415c8..55c64b833 100644 --- a/libs/nestjs/authentication/src/strategies/jwt.strategy.ts +++ b/libs/nestjs/authentication/src/strategies/jwt.strategy.ts @@ -2,8 +2,11 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-jwt'; -import { AUTHENTICATION_USER_SERVICE } from '../constants'; -import { JwtTokenPayload } from '../dtos'; +import { + AUTHENTICATION_MODULE_OPTIONS, + AUTHENTICATION_USER_SERVICE, +} from '../constants'; +import { AuthenticationOptions, JwtTokenPayload } from '../dtos'; import { UserService, UserType } from '../interfaces'; import { StrategyOptionsService } from '../services'; @@ -12,6 +15,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) { constructor( @Inject(AUTHENTICATION_USER_SERVICE) private readonly userService: UserService, + @Inject(AUTHENTICATION_MODULE_OPTIONS) + private readonly authenticationOptions: AuthenticationOptions, protected readonly strategyOptionsService: StrategyOptionsService, ) { super(strategyOptionsService.createJwtStrategyOptions()); @@ -20,6 +25,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) { async validate(payload: JwtTokenPayload): Promise { const user = await this.userService.findUnique({ where: { id: payload.sub }, + // Use select clause provided by the module consumer + select: this.authenticationOptions.userConfig.customSelect, }); if (!user) { diff --git a/libs/nestjs/casl/src/guards/policies-guard.ts b/libs/nestjs/casl/src/guards/policies-guard.ts index bdb9e321e..4d3988f94 100644 --- a/libs/nestjs/casl/src/guards/policies-guard.ts +++ b/libs/nestjs/casl/src/guards/policies-guard.ts @@ -15,7 +15,6 @@ import { isClass, PolicyHandlerType } from '@tractr/common'; import { AUTHENTICATION_USER_SERVICE, AuthenticationUserService, - UserSelect, } from '@tractr/nestjs-authentication'; import { IS_PUBLIC_KEY, Logger, POLICIES_KEY } from '@tractr/nestjs-core'; @@ -50,16 +49,9 @@ export class PoliciesGuard implements CanActivate { const req = context.switchToHttp().getRequest(); - let { user } = req; - - if (user && this.caslOptions.getSelectPrismaUserQuery) - user = { - ...user, - ...(await this.userService.findUnique({ - where: { id: user.id }, - select: this.caslOptions.getSelectPrismaUserQuery() as UserSelect, - })), - }; + // Get user from the request object. + // User should have been fetched and populated by the authentication layer + const { user } = req; if (user && (!user.roles || !Array.isArray(user.roles))) { this.logger.error( diff --git a/libs/nestjs/casl/src/interfaces/casl.interface.ts b/libs/nestjs/casl/src/interfaces/casl.interface.ts index 730415171..ed323fa53 100644 --- a/libs/nestjs/casl/src/interfaces/casl.interface.ts +++ b/libs/nestjs/casl/src/interfaces/casl.interface.ts @@ -8,5 +8,4 @@ export interface CaslOptions< A extends AnyAbility = AnyAbility, > { rolePermissions: RolePermissions; - getSelectPrismaUserQuery?: () => Record; } diff --git a/libs/nestjs/core/src/helpers/module-options.ts b/libs/nestjs/core/src/helpers/module-options.ts index 9cbe80303..81e0c685c 100644 --- a/libs/nestjs/core/src/helpers/module-options.ts +++ b/libs/nestjs/core/src/helpers/module-options.ts @@ -55,7 +55,7 @@ export type AsyncOptions< useFactory?: ( defaultOptions: DefaultOptions, ...args: any[] - ) => Promise | InternalOptions | undefined; + ) => Promise | PublicOptions | undefined; inject?: FactoryProvider['inject']; }; diff --git a/package-lock.json b/package-lock.json index ecf1cede1..66fc57feb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stack", - "version": "1.31.0", + "version": "1.32.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "stack", - "version": "1.31.0", + "version": "1.32.0", "hasInstallScript": true, "license": "MIT", "dependencies": {