Skip to content

Commit

Permalink
feat: add custom select to authentication module to populate user fie…
Browse files Browse the repository at this point in the history
…lds (#359)
  • Loading branch information
maxmousse committed Jan 20, 2022
1 parent 9dd407b commit 95a48d7
Show file tree
Hide file tree
Showing 12 changed files with 104 additions and 31 deletions.
9 changes: 8 additions & 1 deletion apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
}),
Expand All @@ -58,7 +66,6 @@ import { MailerModule } from '@tractr/nestjs-mailer';
}),
CaslModule.register({
rolePermissions,
getSelectPrismaUserQuery,
}),
ConsoleModule,
LoggerModule,
Expand Down
17 changes: 11 additions & 6 deletions libs/nestjs/authentication/src/authentication.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthenticationUserService>;
const mockUserConfig = {
emailField: 'email',
passwordField: 'password',
loginField: 'email',
idField: 'id',
customSelect: undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatUser: (user: Record<string, any>) => user,
};

beforeEach(async () => {
mockUserService = mockDeep<AuthenticationUserService>();
Expand All @@ -44,6 +53,7 @@ describe('Authentication Module with async options', () => {
useFactory: (defaultValue) =>
Promise.resolve({
...defaultValue,
userConfig: mockUserConfig,
jwtModuleOptions: {
secret: 'integration-tests',
},
Expand Down Expand Up @@ -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,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('Authentication Module', () => {
let app: INestApplication;
let mockUserService: MockProxy<AuthenticationUserService>;
let mockUser: UserType;
const mockFormatUser = jest.fn();

beforeAll(async () => {
mockUserService = mockDeep<AuthenticationUserService>();
Expand All @@ -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',
},
Expand All @@ -53,15 +61,21 @@ describe('Authentication Module', () => {
],
}).compile();

mockUserService.findUnique.mockReset();
mockUserService.findUnique.mockReturnValue(Promise.resolve(mockUser));

app = moduleFixture.createNestApplication();

await app.init();
});

beforeEach(() => {
mockFormatUser.mockImplementation((user) => user);
});

afterEach(async () => {
mockFormatUser.mockReset();
mockUserService.findUnique.mockReset();
// mockFormatUser.mockReset();
if (app) await app.close();
});

Expand Down Expand Up @@ -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<AuthenticationService>(
Expand All @@ -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<AuthenticationService>(
Expand All @@ -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 () => {
Expand Down
14 changes: 11 additions & 3 deletions libs/nestjs/authentication/src/controllers/login.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,21 @@ export class LoginController {
): Promise<AccessTokenDto & { user: UserType }> {
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')
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsString } from 'class-validator';
import { IsObject, IsOptional, IsString } from 'class-validator';

import {
DEFAULT_EMAIL_FIELD,
Expand All @@ -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<string, any>;

/**
* 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<string, any>) => Record<string, any> = (user) =>
user;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ export class AuthenticationService {
login: string,
password: string,
): Promise<UserType | null> {
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 } =
Expand Down
11 changes: 9 additions & 2 deletions libs/nestjs/authentication/src/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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());
Expand All @@ -20,6 +25,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
async validate(payload: JwtTokenPayload): Promise<UserType> {
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) {
Expand Down
14 changes: 3 additions & 11 deletions libs/nestjs/casl/src/guards/policies-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand Down
1 change: 0 additions & 1 deletion libs/nestjs/casl/src/interfaces/casl.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@ export interface CaslOptions<
A extends AnyAbility = AnyAbility,
> {
rolePermissions: RolePermissions<R, U, A>;
getSelectPrismaUserQuery?: () => Record<string, unknown>;
}
2 changes: 1 addition & 1 deletion libs/nestjs/core/src/helpers/module-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export type AsyncOptions<
useFactory?: (
defaultOptions: DefaultOptions,
...args: any[]
) => Promise<InternalOptions | undefined> | InternalOptions | undefined;
) => Promise<PublicOptions | undefined> | PublicOptions | undefined;
inject?: FactoryProvider['inject'];
};

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 95a48d7

Please sign in to comment.