DEV Community

ILshat Khamitov
ILshat Khamitov

Posted on

Integration of external authorization server https://authorizer.dev into a full-stack application on NestJS and Angular

In this article, I will connect an external authorization server https://authorizer.dev to the project and write additional backend and frontend modules for integration with it.

The code will be compiled for running via Docker Compose and Kubernetes.

1. Create an Angular library for authorization

Create an empty Angular library to store components with authorization and registration forms, as well as various services and Guard.

Commands

# Create Angular library
./node_modules/.bin/nx g @nx/angular:library --name=auth-angular --buildable --publishable --directory=libs/core/auth-angular --simpleName=true --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/auth-angular

# Change file with test options
rm -rf libs/core/auth-angular/src/test-setup.ts
cp apps/client/src/test-setup.ts libs/core/auth-angular/src/test-setup.ts
Enter fullscreen mode Exit fullscreen mode

Console output
$ ./node_modules/.bin/nx g @nx/angular:library --name=auth-angular --buildable --publishable --directory=libs/core/auth-angular --simpleName=true --strict=true --prefix= --standalone=true --selector= --changeDetection=OnPush --importPath=@nestjs-mod-fullstack/auth-angular

 NX  Generating @nx/angular:library

CREATE libs/core/auth-angular/project.json
CREATE libs/core/auth-angular/README.md
CREATE libs/core/auth-angular/ng-package.json
CREATE libs/core/auth-angular/package.json
CREATE libs/core/auth-angular/tsconfig.json
CREATE libs/core/auth-angular/tsconfig.lib.json
CREATE libs/core/auth-angular/tsconfig.lib.prod.json
CREATE libs/core/auth-angular/src/index.ts
CREATE libs/core/auth-angular/jest.config.ts
CREATE libs/core/auth-angular/src/test-setup.ts
CREATE libs/core/auth-angular/tsconfig.spec.json
CREATE libs/core/auth-angular/src/lib/auth-angular/auth-angular.component.css
CREATE libs/core/auth-angular/src/lib/auth-angular/auth-angular.component.html
CREATE libs/core/auth-angular/src/lib/auth-angular/auth-angular.component.spec.ts
CREATE libs/core/auth-angular/src/lib/auth-angular/auth-angular.component.ts
CREATE libs/core/auth-angular/.eslintrc.json
UPDATE tsconfig.base.json

 NX   👀 View Details of auth-angular

Run "nx show project auth-angular" to view details about this project.
Enter fullscreen mode Exit fullscreen mode

2. Create a NestJS library for authorization

Create an empty NestJS library.

Commands

./node_modules/.bin/nx g @nestjs-mod/schematics:library auth --buildable --publishable --directory=libs/core/auth --simpleName=true --projectNameAndRootFormat=as-provided --strict=true
Enter fullscreen mode Exit fullscreen mode

Console output
$ ./node_modules/.bin/nx g @nestjs-mod/schematics:library auth --buildable --publishable --directory=libs/core/auth --simpleName=true --projectNameAndRootFormat=as-provided --strict=true

 NX  Generating @nestjs-mod/schematics:library

CREATE libs/core/auth/tsconfig.json
CREATE libs/core/auth/src/index.ts
CREATE libs/core/auth/tsconfig.lib.json
CREATE libs/core/auth/README.md
CREATE libs/core/auth/package.json
CREATE libs/core/auth/project.json
CREATE libs/core/auth/.eslintrc.json
CREATE libs/core/auth/jest.config.ts
CREATE libs/core/auth/tsconfig.spec.json
UPDATE tsconfig.base.json
CREATE libs/core/auth/src/lib/auth.configuration.ts
CREATE libs/core/auth/src/lib/auth.constants.ts
CREATE libs/core/auth/src/lib/auth.environments.ts
CREATE libs/core/auth/src/lib/auth.module.ts
Enter fullscreen mode Exit fullscreen mode

3. Install additional libraries

Install JS-client and NestJS-module for working with authorizer server from frontend and backend.
In tests we often use random data, for fast generation of such data we install package @faker-js/faker.

Commands

npm install --save @nestjs-mod/authorizer @authorizerdev/authorizer-js @faker-js/faker
Enter fullscreen mode Exit fullscreen mode

Console output
$ npm install --save @nestjs-mod/authorizer @authorizerdev/authorizer-js @faker-js/faker

added 3 packages, removed 371 packages, and audited 2787 packages in 18s

344 packages are looking for funding
  run `npm fund` for details

34 vulnerabilities (3 low, 12 moderate, 19 high)

To address issues that do not require attention, run:
  npm audit fix

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.
Enter fullscreen mode Exit fullscreen mode

4. Connecting new modules to the backend

apps/server/src/main.ts


import {
  AuthorizerModule,
  AuthorizerUser,
  CheckAccessOptions,
  defaultAuthorizerCheckAccessValidator,AUTHORIZER_ENV_PREFIX
} from '@nestjs-mod/authorizer';
// ...
import {
  DOCKER_COMPOSE_FILE,
  DockerCompose,
  DockerComposeAuthorizer,
  DockerComposePostgreSQL,
} from '@nestjs-mod/docker-compose';
// ...

import { ExecutionContext } from '@nestjs/common';
// ...
bootstrapNestApplication({
  modules: {
   // ...

    core: [
      AuthorizerModule.forRoot({
        staticConfiguration: {
          extraHeaders: {
            'x-authorizer-url': `http://localhost:${process.env.SERVER_AUTHORIZER_EXTERNAL_CLIENT_PORT}`,
          },
          checkAccessValidator: async (
            authorizerUser?: AuthorizerUser,
            options?: CheckAccessOptions,
            ctx?: ExecutionContext
          ) => {
            if (
              typeof ctx?.getClass === 'function' &&
              typeof ctx?.getHandler === 'function' &&
              ctx?.getClass().name === 'TerminusHealthCheckController' &&
              ctx?.getHandler().name === 'check'
            ) {
              return true;
            }

            return defaultAuthorizerCheckAccessValidator(
              authorizerUser,
              options
            );
          },
        },
      }),
    ],
    infrastructure: [
      DockerComposePostgreSQL.forFeature({
        featureModuleName: AUTHORIZER_ENV_PREFIX,
      }),
      DockerComposeAuthorizer.forRoot({
        staticEnvironments: {
          databaseUrl: '%SERVER_AUTHORIZER_INTERNAL_DATABASE_URL%',
        },
        staticConfiguration: {
          image: 'lakhansamani/authorizer:1.4.4',
          disableStrongPassword: 'true',
          disableEmailVerification: 'true',
          featureName: AUTHORIZER_ENV_PREFIX,
          organizationName: 'NestJSModFullstack',
          dependsOnServiceNames: {
            'postgre-sql': 'service_healthy',
            redis: 'service_healthy',
          },
          isEmailServiceEnabled: 'true',
          isSmsServiceEnabled: 'false',
          env: 'development',
        },
      }),
    ]}
    );
Enter fullscreen mode Exit fullscreen mode

5. We are starting the generation of additional code for the infrastructure

Commands

npm run docs:infrastructure
Enter fullscreen mode Exit fullscreen mode

6. Add all the necessary code to the AuthModule module (NestJS library)

When the application is launched, the module can create a default administrator, his email and password must be passed through environment variables, if not passed, the default administrator will not be created.

Update the file libs/core/auth/src/lib/auth.environments.ts

import { EnvModel, EnvModelProperty } from '@nestjs-mod/common';
import { IsNotEmpty } from 'class-validator';

@EnvModel()
export class AuthEnvironments {
  @EnvModelProperty({
    description: 'Global admin username',
    default: 'admin@example.com',
  })
  adminEmail?: string;

  @EnvModelProperty({
    description: 'Global admin username',
    default: 'admin',
  })
  @IsNotEmpty()
  adminUsername?: string;

  @EnvModelProperty({
    description: 'Global admin password',
  })
  adminPassword?: string;
}
Enter fullscreen mode Exit fullscreen mode

We create a service for calling the authorization server's admin methods, add a method for creating an admin, this method will be called when the application starts and create a default system admin.

We create the file libs/core/auth/src/lib/services/auth-authorizer.service.ts

import { AuthorizerService } from '@nestjs-mod/authorizer';
import { Injectable, Logger } from '@nestjs/common';
import { AuthError } from '../auth.errors';

@Injectable()
export class AuthAuthorizerService {
  private logger = new Logger(AuthAuthorizerService.name);

  constructor(private readonly authorizerService: AuthorizerService) {}

  authorizerClientID() {
    return this.authorizerService.config.clientID;
  }

  async createAdmin(user: { username?: string; password: string; email: string }) {
    const signupUserResult = await this.authorizerService.signup({
      nickname: user.username,
      password: user.password,
      confirm_password: user.password,
      email: user.email.toLowerCase(),
      roles: ['admin'],
    });
    if (signupUserResult.errors.length > 0) {
      this.logger.error(signupUserResult.errors[0].message, signupUserResult.errors[0].stack);
      if (!signupUserResult.errors[0].message.includes('has already signed up')) {
        throw new AuthError(signupUserResult.errors[0].message);
      }
    } else {
      if (!signupUserResult.data?.user) {
        throw new AuthError('Failed to create a user');
      }

      await this.verifyUser({
        externalUserId: signupUserResult.data.user.id,
        email: signupUserResult.data.user.email,
      });

      this.logger.debug(`Admin with email: ${signupUserResult.data.user.email} successfully created!`);
    }
  }

  async verifyUser({ externalUserId, email }: { externalUserId: string; email: string }) {
    await this.updateUser(externalUserId, { email_verified: true, email });
    return this;
  }

  async updateUser(
    externalUserId: string,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    params: Partial<Record<string, any>>
  ) {
    if (Object.keys(params).length > 0) {
      const paramsForUpdate = Object.entries(params)
        .map(([key, value]) => (typeof value === 'boolean' ? `${key}: ${value}` : `${key}: "${value}"`))
        .join(',');
      const updateUserResult = await this.authorizerService.graphqlQuery({
        query: `mutation {
  _update_user(params: { 
      id: "${externalUserId}", ${paramsForUpdate} }) {
    id
  }
}`,
      });

      if (updateUserResult.errors.length > 0) {
        this.logger.error(updateUserResult.errors[0].message, updateUserResult.errors[0].stack);
        throw new AuthError(updateUserResult.errors[0].message);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Create a service with an OnModuleInit hook in which, when the module starts, we start the process of creating a default admin if it does not exist.

Create a file libs/core/auth/src/lib/services/auth-authorizer-bootstrap.service.ts

import { isInfrastructureMode } from '@nestjs-mod/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { AuthAuthorizerService } from './auth-authorizer.service';
import { AuthEnvironments } from '../auth.environments';

@Injectable()
export class AuthAuthorizerBootstrapService implements OnModuleInit {
  private logger = new Logger(AuthAuthorizerBootstrapService.name);

  constructor(private readonly authAuthorizerService: AuthAuthorizerService, private readonly authEnvironments: AuthEnvironments) {}

  async onModuleInit() {
    this.logger.debug('onModuleInit');
    if (!isInfrastructureMode()) {
      try {
        await this.createAdmin();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
      } catch (err: any) {
        this.logger.error(err, err.stack);
      }
    }
  }

  private async createAdmin() {
    try {
      if (this.authEnvironments.adminEmail && this.authEnvironments.adminPassword) {
        await this.authAuthorizerService.createAdmin({
          username: this.authEnvironments.adminUsername,
          password: this.authEnvironments.adminPassword,
          email: this.authEnvironments.adminEmail,
        });
      }
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
    } catch (err: any) {
      this.logger.error(err, err.stack);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Add the created services to AuthModule, in this module we connect the global Guard for constant checking of the presence of an authorization token when calling any backend methods, and also connect a filter for transforming authorization errors.

The environment variables for this module will have the AUTH_ prefix, to enable this prefix you need to override the propertyNameFormatters option.

The names of the environment variables: SERVER_AUTH_ADMIN_EMAIL, SERVER_AUTH_ADMIN_USERNAME, SERVER_AUTH_ADMIN_PASSWORD.

Update the file libs/core/auth/src/lib/auth.module.ts

import { AuthorizerGuard, AuthorizerModule } from '@nestjs-mod/authorizer';
import { createNestModule, getFeatureDotEnvPropertyNameFormatter, NestModuleCategory } from '@nestjs-mod/common';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { AUTH_FEATURE, AUTH_MODULE } from './auth.constants';
import { AuthEnvironments } from './auth.environments';
import { AuthExceptionsFilter } from './auth.filter';
import { AuthorizerController } from './controllers/authorizer.controller';
import { AuthAuthorizerBootstrapService } from './services/auth-authorizer-bootstrap.service';
import { AuthAuthorizerService } from './services/auth-authorizer.service';

export const { AuthModule } = createNestModule({
  moduleName: AUTH_MODULE,
  moduleCategory: NestModuleCategory.feature,
  staticEnvironmentsModel: AuthEnvironments,
  imports: [
    AuthorizerModule.forFeature({
      featureModuleName: AUTH_FEATURE,
    }),
  ],
  controllers: [AuthorizerController],
  providers: [{ provide: APP_GUARD, useClass: AuthorizerGuard }, { provide: APP_FILTER, useClass: AuthExceptionsFilter }, AuthAuthorizerService, AuthAuthorizerBootstrapService],
  wrapForRootAsync: (asyncModuleOptions) => {
    if (!asyncModuleOptions) {
      asyncModuleOptions = {};
    }
    const FomatterClass = getFeatureDotEnvPropertyNameFormatter(AUTH_FEATURE);
    Object.assign(asyncModuleOptions, {
      environmentsOptions: {
        propertyNameFormatters: [new FomatterClass()],
        name: AUTH_FEATURE,
      },
    });

    return { asyncModuleOptions };
  },
});
Enter fullscreen mode Exit fullscreen mode

7. Adding logic for automatic user creation for the WebhookModule

Since the authorization guard is triggered automatically when calling any methods, including methods of the WebhookModule module, we can create a new user for the WebhookModule module at the time of authorization token validation.

We will move the method for creating a new user to a separate service, which will be available when importing the module as a feature WebhookModule.forFeature().

Create a file libs/feature/webhook/src/lib/services/webhook-users.service.ts

import { InjectPrismaClient } from '@nestjs-mod/prisma';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/webhook-client';
import { omit } from 'lodash/fp';
import { randomUUID } from 'node:crypto';
import { CreateWebhookUserArgs, WebhookUserObject } from '../types/webhook-user-object';
import { WEBHOOK_FEATURE } from '../webhook.constants';

@Injectable()
export class WebhookUsersService {
  constructor(
    @InjectPrismaClient(WEBHOOK_FEATURE)
    private readonly prismaClient: PrismaClient
  ) {}

  async createUser(user: Omit<CreateWebhookUserArgs, 'id'>) {
    const data = {
      externalTenantId: randomUUID(),
      userRole: 'User',
      ...omit(['id', 'createdAt', 'updatedAt', 'Webhook_Webhook_createdByToWebhookUser', 'Webhook_Webhook_updatedByToWebhookUser'], user),
    } as WebhookUserObject;
    const existsUser = await this.prismaClient.webhookUser.findFirst({
      where: {
        externalTenantId: user.externalTenantId,
        externalUserId: user.externalUserId,
      },
    });
    if (!existsUser) {
      return await this.prismaClient.webhookUser.create({
        data,
      });
    }
    return existsUser;
  }
}
Enter fullscreen mode Exit fullscreen mode

Export the new service from the module and the prism module it uses.

Update the file libs/feature/webhook/src/lib/webhook.module.ts

import { PrismaToolsModule } from '@nestjs-mod-fullstack/prisma-tools';
import { createNestModule, getFeatureDotEnvPropertyNameFormatter, NestModuleCategory } from '@nestjs-mod/common';
import { PrismaModule } from '@nestjs-mod/prisma';
import { HttpModule } from '@nestjs/axios';
import { UseFilters, UseGuards } from '@nestjs/common';
import { ApiHeaders } from '@nestjs/swagger';
import { WebhookUsersController } from './controllers/webhook-users.controller';
import { WebhookController } from './controllers/webhook.controller';
import { WebhookServiceBootstrap } from './services/webhook-bootstrap.service';
import { WebhookToolsService } from './services/webhook-tools.service';
import { WebhookUsersService } from './services/webhook-users.service';
import { WebhookService } from './services/webhook.service';
import { WebhookConfiguration, WebhookStaticConfiguration } from './webhook.configuration';
import { WEBHOOK_FEATURE, WEBHOOK_MODULE } from './webhook.constants';
import { WebhookEnvironments } from './webhook.environments';
import { WebhookExceptionsFilter } from './webhook.filter';
import { WebhookGuard } from './webhook.guard';

export const { WebhookModule } = createNestModule({
  moduleName: WEBHOOK_MODULE,
  moduleCategory: NestModuleCategory.feature,
  staticEnvironmentsModel: WebhookEnvironments,
  staticConfigurationModel: WebhookStaticConfiguration,
  configurationModel: WebhookConfiguration,
  imports: [
    HttpModule,
    PrismaModule.forFeature({
      contextName: WEBHOOK_FEATURE,
      featureModuleName: WEBHOOK_FEATURE,
    }),
    PrismaToolsModule.forFeature({
      featureModuleName: WEBHOOK_FEATURE,
    }),
  ],
  sharedImports: [
    PrismaModule.forFeature({
      contextName: WEBHOOK_FEATURE,
      featureModuleName: WEBHOOK_FEATURE,
    }),
  ],
  providers: [WebhookToolsService, WebhookServiceBootstrap],
  controllers: [WebhookUsersController, WebhookController],
  sharedProviders: [WebhookService, WebhookUsersService],
  wrapForRootAsync: (asyncModuleOptions) => {
    if (!asyncModuleOptions) {
      asyncModuleOptions = {};
    }
    const FomatterClass = getFeatureDotEnvPropertyNameFormatter(WEBHOOK_FEATURE);
    Object.assign(asyncModuleOptions, {
      environmentsOptions: {
        propertyNameFormatters: [new FomatterClass()],
        name: WEBHOOK_FEATURE,
      },
    });

    return { asyncModuleOptions };
  },
  preWrapApplication: async ({ current }) => {
    const staticEnvironments = current.staticEnvironments as WebhookEnvironments;
    const staticConfiguration = current.staticConfiguration as WebhookStaticConfiguration;

    for (const ctrl of [WebhookController, WebhookUsersController]) {
      if (staticEnvironments.useFilters) {
        UseFilters(WebhookExceptionsFilter)(ctrl);
      }
      if (staticEnvironments.useGuards) {
        UseGuards(WebhookGuard)(ctrl);
      }
      if (staticConfiguration.externalUserIdHeaderName && staticConfiguration.externalTenantIdHeaderName) {
        ApiHeaders([
          {
            name: staticConfiguration.externalUserIdHeaderName,
            allowEmptyValue: true,
          },
          {
            name: staticConfiguration.externalTenantIdHeaderName,
            allowEmptyValue: true,
          },
        ])(ctrl);
      }
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

We update the function for creating the configuration of the module AuthorizerModule, add the use of the service from the module WebhookModule.

We update the file apps/server/src/main.ts

//...

bootstrapNestApplication({
  modules: {
    //...
    core: [
      AuthorizerModule.forRootAsync({
        imports: [WebhookModule.forFeature({ featureModuleName: AUTH_FEATURE })],
        inject: [WebhookUsersService],
        configurationFactory: (webhookUsersService: WebhookUsersService) => {
          return {
            extraHeaders: {
              'x-authorizer-url': `http://localhost:${process.env.SERVER_AUTHORIZER_EXTERNAL_CLIENT_PORT}`,
            },
            checkAccessValidator: async (authorizerUser?: AuthorizerUser, options?: CheckAccessOptions, ctx?: ExecutionContext) => {
              if (typeof ctx?.getClass === 'function' && typeof ctx?.getHandler === 'function' && ctx?.getClass().name === 'TerminusHealthCheckController' && ctx?.getHandler().name === 'check') {
                return true;
              }

              const result = await defaultAuthorizerCheckAccessValidator(authorizerUser, options);

              if (ctx && authorizerUser?.id) {
                const webhookUser = await webhookUsersService.createUser({
                  externalUserId: authorizerUser?.id,
                  externalTenantId: authorizerUser?.id,
                  userRole: authorizerUser.roles?.includes('admin') ? 'Admin' : 'User',
                });
                const req: WebhookRequest = getRequestFromExecutionContext(ctx);
                req.externalTenantId = webhookUser.externalTenantId;
              }

              return result;
            },
          };
        },
      }),
      //...
    ],
    //...
  },
  //...
});
Enter fullscreen mode Exit fullscreen mode

8. Add all the necessary code to the Angular library for authorization

Create an instance of the authorization server client using DI from Angular.

Create the file libs/core/auth-angular/src/lib/services/authorizer.service.ts

import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Authorizer, ConfigType } from '@authorizerdev/authorizer-js';

export const AUTHORIZER_URL = new InjectionToken<string>('AuthorizerURL');

@Injectable({ providedIn: 'root' })
export class AuthorizerService extends Authorizer {
  constructor(
    @Inject(AUTHORIZER_URL)
    private readonly authorizerURL: string
  ) {
    super({
      authorizerURL:
        // need for override from e2e-tests
        localStorage.getItem('authorizerURL') ||
        // use from environments
        authorizerURL,
      clientID: '',
      redirectURL: window.location.origin,
    } as ConfigType);
  }
}
Enter fullscreen mode Exit fullscreen mode

All additional methods for working with the authorization server are added to AuthService.

Create the file libs/core/auth-angular/src/lib/services/auth.service.ts

import { Injectable } from '@angular/core';
import { AuthToken, LoginInput, SignupInput, User } from '@authorizerdev/authorizer-js';
import { mapGraphqlErrors } from '@nestjs-mod-fullstack/common-angular';
import { BehaviorSubject, catchError, from, map, of, tap } from 'rxjs';
import { AuthorizerService } from './authorizer.service';

@Injectable({ providedIn: 'root' })
export class AuthService {
  profile$ = new BehaviorSubject<User | undefined>(undefined);
  tokens$ = new BehaviorSubject<AuthToken | undefined>(undefined);

  constructor(private readonly authorizerService: AuthorizerService) {}

  getAuthorizerClientID() {
    return this.authorizerService.config.clientID;
  }

  setAuthorizerClientID(clientID: string) {
    this.authorizerService.config.clientID = clientID;
  }

  signUp(data: SignupInput) {
    return from(
      this.authorizerService.signup({
        ...data,
        email: data.email?.toLowerCase(),
      })
    ).pipe(
      mapGraphqlErrors(),
      map((result) => {
        this.setProfileAndTokens(result);
        return {
          profile: result?.user,
          tokens: this.tokens$.value,
        };
      })
    );
  }

  signIn(data: LoginInput) {
    return from(
      this.authorizerService.login({
        ...data,
        email: data.email?.toLowerCase(),
      })
    ).pipe(
      mapGraphqlErrors(),
      map((result) => {
        this.setProfileAndTokens(result);
        return {
          profile: result?.user,
          tokens: this.tokens$.value,
        };
      })
    );
  }

  signOut() {
    return from(this.authorizerService.logout(this.getAuthorizationHeaders())).pipe(
      mapGraphqlErrors(),
      tap(() => {
        this.clearProfileAndTokens();
      })
    );
  }

  refreshToken() {
    return from(this.authorizerService.browserLogin()).pipe(
      mapGraphqlErrors(),
      tap((result) => {
        this.setProfileAndTokens(result);
      }),
      catchError((err) => {
        console.error(err);
        this.clearProfileAndTokens();
        return of(null);
      })
    );
  }

  clearProfileAndTokens() {
    this.setProfileAndTokens({} as AuthToken);
  }

  setProfileAndTokens(result: AuthToken | undefined) {
    this.tokens$.next(result as AuthToken);
    this.profile$.next(result?.user);
  }

  getAuthorizationHeaders() {
    if (!this.tokens$.value?.access_token) {
      return undefined;
    }
    return {
      Authorization: `Bearer ${this.tokens$.value.access_token}`,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Some pages have role restrictions, to activate this feature we need to create Guard.

Create the file libs/core/auth-angular/src/lib/services/auth-guard.service.ts

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import { of } from 'rxjs';
import { AuthService } from './auth.service';
export const AUTH_GUARD_DATA_ROUTE_KEY = 'authGuardData';

export class AuthGuardData {
  roles?: string[];

  constructor(options?: AuthGuardData) {
    Object.assign(this, options);
  }
}

@Injectable({ providedIn: 'root' })
export class AuthGuardService implements CanActivate {
  constructor(private readonly authAuthService: AuthService) {}
  canActivate(route: ActivatedRouteSnapshot) {
    if (route.data[AUTH_GUARD_DATA_ROUTE_KEY] instanceof AuthGuardData) {
      const authGuardData = route.data[AUTH_GUARD_DATA_ROUTE_KEY];
      const authUser = this.authAuthService.profile$.value;
      const authGuardDataRoles = (authGuardData.roles || []).map((role) => role.toLowerCase());
      return of(Boolean((authUser && authGuardDataRoles.length > 0 && authGuardDataRoles.some((r) => authUser.roles?.includes(r))) || (authGuardDataRoles.length === 0 && !authUser?.roles)));
    }
    return of(true);
  }
}
Enter fullscreen mode Exit fullscreen mode

Add the registration form component libs/core/auth-angular/src/lib/forms/auth-sign-up-form/auth-sign-up-form.component.ts

import { AsyncPipe, NgIf } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Optional, Output } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { AuthToken, SignupInput } from '@authorizerdev/authorizer-js';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzMessageService } from 'ng-zorro-antd/message';
import { NZ_MODAL_DATA } from 'ng-zorro-antd/modal';
import { BehaviorSubject, catchError, of, tap } from 'rxjs';
import { AuthService } from '../../services/auth.service';

@UntilDestroy()
@Component({
  standalone: true,
  imports: [FormlyModule, NzFormModule, NzInputModule, NzButtonModule, FormsModule, ReactiveFormsModule, AsyncPipe, NgIf, RouterModule],
  selector: 'auth-sign-up-form',
  templateUrl: './auth-sign-up-form.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AuthSignUpFormComponent implements OnInit {
  @Input()
  hideButtons?: boolean;

  @Output()
  afterSignUp = new EventEmitter<AuthToken>();

  form = new UntypedFormGroup({});
  formlyModel$ = new BehaviorSubject<object | null>(null);
  formlyFields$ = new BehaviorSubject<FormlyFieldConfig[] | null>(null);

  constructor(
    @Optional()
    @Inject(NZ_MODAL_DATA)
    private readonly nzModalData: AuthSignUpFormComponent,
    private readonly authService: AuthService,
    private readonly nzMessageService: NzMessageService
  ) {}

  ngOnInit(): void {
    Object.assign(this, this.nzModalData);
    this.setFieldsAndModel({ password: '', confirm_password: '' });
  }

  setFieldsAndModel(data: SignupInput = { password: '', confirm_password: '' }) {
    this.formlyFields$.next([
      {
        key: 'email',
        type: 'input',
        validation: {
          show: true,
        },
        props: {
          label: `auth.form.email`,
          placeholder: 'email',
          required: true,
        },
      },
      {
        key: 'password',
        type: 'input',
        validation: {
          show: true,
        },
        props: {
          label: `auth.form.password`,
          placeholder: 'password',
          required: true,
          type: 'password',
        },
      },
      {
        key: 'confirm_password',
        type: 'input',
        validation: {
          show: true,
        },
        props: {
          label: `auth.form.confirm_password`,
          placeholder: 'confirm_password',
          required: true,
          type: 'password',
        },
      },
    ]);
    this.formlyModel$.next(this.toModel(data));
  }

  submitForm(): void {
    if (this.form.valid) {
      const value = this.toJson(this.form.value);
      this.authService
        .signUp({ ...value })
        .pipe(
          tap((result) => {
            if (result.tokens) {
              this.afterSignUp.next(result.tokens);
              this.nzMessageService.success('Success');
            }
          }),
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          catchError((err: any) => {
            console.error(err);
            this.nzMessageService.error(err.message);
            return of(null);
          }),
          untilDestroyed(this)
        )
        .subscribe();
    } else {
      console.log(this.form.controls);
      this.nzMessageService.warning('Validation errors');
    }
  }

  private toModel(data: SignupInput): object | null {
    return {
      email: data['email'],
      password: data['password'],
      confirm_password: data['confirm_password'],
    };
  }

  private toJson(data: SignupInput) {
    return {
      email: data['email'],
      password: data['password'],
      confirm_password: data['confirm_password'],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Add the registration form template libs/core/auth-angular/src/lib/forms/auth-sign-up-form/auth-sign-up-form.component.html

@if (formlyFields$ | async; as formlyFields) {
<form nz-form [formGroup]="form" (ngSubmit)="submitForm()">
  <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form>
  @if (!hideButtons) {
  <nz-form-control>
    <div class="flex justify-between">
      <div>
        <button nz-button nzType="default" type="button" [routerLink]="'/sign-in'">Sign-in</button>
      </div>
      <button nz-button nzType="primary" type="submit" [disabled]="!form.valid">Sign-up</button>
    </div>
  </nz-form-control>
  }
</form>
}
Enter fullscreen mode Exit fullscreen mode

Adding a component to the authorization form libs/core/auth-angular/src/lib/forms/auth-sign-in-form/auth-sign-in-form.component.ts

import { AsyncPipe, NgIf } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Optional, Output } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { AuthToken, LoginInput } from '@authorizerdev/authorizer-js';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzMessageService } from 'ng-zorro-antd/message';
import { NZ_MODAL_DATA } from 'ng-zorro-antd/modal';
import { BehaviorSubject, catchError, of, tap } from 'rxjs';
import { AuthService } from '../../services/auth.service';

@UntilDestroy()
@Component({
  standalone: true,
  imports: [FormlyModule, NzFormModule, NzInputModule, NzButtonModule, FormsModule, ReactiveFormsModule, AsyncPipe, NgIf, RouterModule],
  selector: 'auth-sign-in-form',
  templateUrl: './auth-sign-in-form.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AuthSignInFormComponent implements OnInit {
  @Input()
  hideButtons?: boolean;

  @Output()
  afterSignIn = new EventEmitter<AuthToken>();

  form = new UntypedFormGroup({});
  formlyModel$ = new BehaviorSubject<object | null>(null);
  formlyFields$ = new BehaviorSubject<FormlyFieldConfig[] | null>(null);

  constructor(
    @Optional()
    @Inject(NZ_MODAL_DATA)
    private readonly nzModalData: AuthSignInFormComponent,
    private readonly authService: AuthService,
    private readonly nzMessageService: NzMessageService
  ) {}

  ngOnInit(): void {
    Object.assign(this, this.nzModalData);
    this.setFieldsAndModel({ password: '' });
  }

  setFieldsAndModel(data: LoginInput = { password: '' }) {
    this.formlyFields$.next([
      {
        key: 'email',
        type: 'input',
        validation: {
          show: true,
        },
        props: {
          label: `auth.form.email`,
          placeholder: 'email',
          required: true,
        },
      },
      {
        key: 'password',
        type: 'input',
        validation: {
          show: true,
        },
        props: {
          label: `auth.form.password`,
          placeholder: 'password',
          required: true,
          type: 'password',
        },
      },
    ]);
    this.formlyModel$.next(this.toModel(data));
  }

  submitForm(): void {
    if (this.form.valid) {
      const value = this.toJson(this.form.value);
      this.authService
        .signIn(value)
        .pipe(
          tap((result) => {
            if (result.tokens) {
              this.afterSignIn.next(result.tokens);
              this.nzMessageService.success('Success');
            }
          }),
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          catchError((err: any) => {
            console.error(err);
            this.nzMessageService.error(err.message);
            return of(null);
          }),
          untilDestroyed(this)
        )
        .subscribe();
    } else {
      console.log(this.form.controls);
      this.nzMessageService.warning('Validation errors');
    }
  }

  private toModel(data: LoginInput): object | null {
    return {
      email: data['email'],
      password: data['password'],
    };
  }

  private toJson(data: LoginInput) {
    return {
      email: data['email'],
      password: data['password'],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Adding a login form template libs/core/auth-angular/src/lib/forms/auth-sign-in-form/auth-sign-in-form.component.html

@if (formlyFields$ | async; as formlyFields) {
<form nz-form [formGroup]="form" (ngSubmit)="submitForm()">
  <formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form"> </formly-form>
  @if (!hideButtons) {
  <nz-form-control>
    <div class="flex justify-between">
      <div>
        <button nz-button nzType="default" type="button" [routerLink]="'/sign-up'">Sign-up</button>
      </div>
      <button nz-button nzType="primary" type="submit" [disabled]="!form.valid">Sign-in</button>
    </div>
  </nz-form-control>
  }
</form>
}
Enter fullscreen mode Exit fullscreen mode

9. Adding an initialization service to an Angular application

In this service, we try to refresh the authorization token, and also subscribe to receive a token during registration, authorization and refresh, and setup the received token in the sdk to work with the backend.

Create the file apps/client/src/app/app-initializer.ts

import { HttpHeaders } from '@angular/common/http';
import { DefaultRestService, WebhookRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk';
import { AuthService } from '@nestjs-mod-fullstack/auth-angular';
import { catchError, map, mergeMap, of, Subscription, tap, throwError } from 'rxjs';

export class AppInitializer {
  private subscribeToTokenUpdatesSubscription?: Subscription;

  constructor(private readonly defaultRestService: DefaultRestService, private readonly webhookRestService: WebhookRestService, private readonly authService: AuthService) {}

  resolve() {
    this.subscribeToTokenUpdates();
    return (
      this.authService.getAuthorizerClientID()
        ? of(null)
        : this.defaultRestService.authorizerControllerGetAuthorizerClientID().pipe(
            map(({ clientID }) => {
              this.authService.setAuthorizerClientID(clientID);
              return null;
            })
          )
    ).pipe(
      mergeMap(() => this.authService.refreshToken()),
      catchError((err) => {
        console.error(err);
        return throwError(() => err);
      })
    );
  }

  private subscribeToTokenUpdates() {
    if (this.subscribeToTokenUpdatesSubscription) {
      this.subscribeToTokenUpdatesSubscription.unsubscribe();
      this.subscribeToTokenUpdatesSubscription = undefined;
    }
    this.subscribeToTokenUpdatesSubscription = this.authService.tokens$
      .pipe(
        tap(() => {
          const authorizationHeaders = this.authService.getAuthorizationHeaders();
          if (authorizationHeaders) {
            this.defaultRestService.defaultHeaders = new HttpHeaders(authorizationHeaders);
            this.webhookRestService.defaultHeaders = new HttpHeaders(authorizationHeaders);
          }
        })
      )
      .subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

10. Updating the Angular application configuration

Updating the file apps/client/src/app/app.config.ts

import { provideHttpClient } from '@angular/common/http';
import { APP_INITIALIZER, ApplicationConfig, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { provideRouter } from '@angular/router';
import { DefaultRestService, RestClientApiModule, RestClientConfiguration, WebhookRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk';
import { AUTHORIZER_URL, AuthService } from '@nestjs-mod-fullstack/auth-angular';
import { WEBHOOK_CONFIGURATION_TOKEN, WebhookConfiguration } from '@nestjs-mod-fullstack/webhook-angular';
import { FormlyModule } from '@ngx-formly/core';
import { FormlyNgZorroAntdModule } from '@ngx-formly/ng-zorro-antd';
import { en_US, provideNzI18n } from 'ng-zorro-antd/i18n';
import { serverUrl, webhookSuperAdminExternalUserId } from '../environments/environment';
import { AppInitializer } from './app-initializer';
import { AppErrorHandler } from './app.error-handler';
import { appRoutes } from './app.routes';

export const appConfig = ({ authorizerURL }: { authorizerURL?: string }): ApplicationConfig => {
  return {
    providers: [
      provideClientHydration(),
      provideZoneChangeDetection({ eventCoalescing: true }),
      provideRouter(appRoutes),
      provideHttpClient(),
      provideNzI18n(en_US),
      {
        provide: WEBHOOK_CONFIGURATION_TOKEN,
        useValue: new WebhookConfiguration({ webhookSuperAdminExternalUserId }),
      },
      importProvidersFrom(
        BrowserAnimationsModule,
        RestClientApiModule.forRoot(
          () =>
            new RestClientConfiguration({
              basePath: serverUrl,
            })
        ),
        FormlyModule.forRoot(),
        FormlyNgZorroAntdModule
      ),
      { provide: ErrorHandler, useClass: AppErrorHandler },
      {
        provide: AUTHORIZER_URL,
        useValue: authorizerURL,
      },
      {
        provide: APP_INITIALIZER,
        useFactory: (defaultRestService: DefaultRestService, webhookRestService: WebhookRestService, authService: AuthService) => () => new AppInitializer(defaultRestService, webhookRestService, authService).resolve(),
        multi: true,
        deps: [DefaultRestService, WebhookRestService, AuthService],
      },
    ],
  };
};
Enter fullscreen mode Exit fullscreen mode

11. Update files and add new ones to run docker-compose and kubernetes

I will not fully describe the changes in all files, you can see them in the commit with changes for the current post, below I will simply add the updated docker-compose-full.yml and its file with environment variables.

Update the file .docker/docker-compose-full.yml

version: '3'
networks:
  nestjs-mod-fullstack-network:
    driver: 'bridge'
services:
  nestjs-mod-fullstack-postgre-sql:
    image: 'bitnami/postgresql:15.5.0'
    container_name: 'nestjs-mod-fullstack-postgre-sql'
    networks:
      - 'nestjs-mod-fullstack-network'
    healthcheck:
      test:
        - 'CMD-SHELL'
        - 'pg_isready -U postgres'
      interval: '5s'
      timeout: '5s'
      retries: 5
    tty: true
    restart: 'always'
    environment:
      POSTGRESQL_USERNAME: '${SERVER_POSTGRE_SQL_POSTGRESQL_USERNAME}'
      POSTGRESQL_PASSWORD: '${SERVER_POSTGRE_SQL_POSTGRESQL_PASSWORD}'
      POSTGRESQL_DATABASE: '${SERVER_POSTGRE_SQL_POSTGRESQL_DATABASE}'
    volumes:
      - 'nestjs-mod-fullstack-postgre-sql-volume:/bitnami/postgresql'
  nestjs-mod-fullstack-authorizer:
    image: 'lakhansamani/authorizer:1.4.4'
    container_name: 'nestjs-mod-fullstack-authorizer'
    ports:
      - '8000:8080'
    networks:
      - 'nestjs-mod-fullstack-network'
    environment:
      DATABASE_URL: '${SERVER_AUTHORIZER_DATABASE_URL}'
      DATABASE_TYPE: '${SERVER_AUTHORIZER_DATABASE_TYPE}'
      DATABASE_NAME: '${SERVER_AUTHORIZER_DATABASE_NAME}'
      ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}'
      PORT: '${SERVER_AUTHORIZER_PORT}'
      AUTHORIZER_URL: '${SERVER_AUTHORIZER_URL}'
      COOKIE_NAME: '${SERVER_AUTHORIZER_COOKIE_NAME}'
      SMTP_HOST: '${SERVER_AUTHORIZER_SMTP_HOST}'
      SMTP_PORT: '${SERVER_AUTHORIZER_SMTP_PORT}'
      SMTP_USERNAME: '${SERVER_AUTHORIZER_SMTP_USERNAME}'
      SMTP_PASSWORD: '${SERVER_AUTHORIZER_SMTP_PASSWORD}'
      SENDER_EMAIL: '${SERVER_AUTHORIZER_SENDER_EMAIL}'
      SENDER_NAME: '${SERVER_AUTHORIZER_SENDER_NAME}'
      DISABLE_PLAYGROUND: '${SERVER_AUTHORIZER_DISABLE_PLAYGROUND}'
      ACCESS_TOKEN_EXPIRY_TIME: '${SERVER_AUTHORIZER_ACCESS_TOKEN_EXPIRY_TIME}'
      DISABLE_STRONG_PASSWORD: '${SERVER_AUTHORIZER_DISABLE_STRONG_PASSWORD}'
      DISABLE_EMAIL_VERIFICATION: '${SERVER_AUTHORIZER_DISABLE_EMAIL_VERIFICATION}'
      ORGANIZATION_NAME: '${SERVER_AUTHORIZER_ORGANIZATION_NAME}'
      IS_SMS_SERVICE_ENABLED: '${SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED}'
      IS_EMAIL_SERVICE_ENABLED: '${SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED}'
      ENV: '${SERVER_AUTHORIZER_ENV}'
      RESET_PASSWORD_URL: '${SERVER_AUTHORIZER_RESET_PASSWORD_URL}'
      ROLES: '${SERVER_AUTHORIZER_ROLES}'
      DEFAULT_ROLES: '${SERVER_AUTHORIZER_DEFAULT_ROLES}'
      JWT_ROLE_CLAIM: '${SERVER_AUTHORIZER_JWT_ROLE_CLAIM}'
      ORGANIZATION_LOGO: '${SERVER_AUTHORIZER_ORGANIZATION_LOGO}'
    tty: true
    restart: 'always'
    depends_on:
      nestjs-mod-fullstack-postgre-sql:
        condition: service_healthy
      nestjs-mod-fullstack-postgre-sql-migrations:
        condition: service_completed_successfully
  nestjs-mod-fullstack-postgre-sql-migrations:
    image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-migrations:${ROOT_VERSION}'
    container_name: 'nestjs-mod-fullstack-postgre-sql-migrations'
    networks:
      - 'nestjs-mod-fullstack-network'
    tty: true
    environment:
      NX_SKIP_NX_CACHE: 'true'
      SERVER_ROOT_DATABASE_URL: '${SERVER_ROOT_DATABASE_URL}'
      SERVER_APP_DATABASE_URL: '${SERVER_APP_DATABASE_URL}'
      SERVER_WEBHOOK_DATABASE_URL: '${SERVER_WEBHOOK_DATABASE_URL}'
      SERVER_AUTHORIZER_DATABASE_URL: '${SERVER_AUTHORIZER_DATABASE_URL}'
    depends_on:
      nestjs-mod-fullstack-postgre-sql:
        condition: 'service_healthy'
    working_dir: '/usr/src/app'
    volumes:
      - './../apps:/usr/src/app/apps'
      - './../libs:/usr/src/app/libs'
  nestjs-mod-fullstack-server:
    image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-server:${SERVER_VERSION}'
    container_name: 'nestjs-mod-fullstack-server'
    networks:
      - 'nestjs-mod-fullstack-network'
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    healthcheck:
      test: ['CMD-SHELL', 'npx -y wait-on --timeout= --interval=1000 --window --verbose --log http://localhost:${SERVER_PORT}/api/health']
      interval: 30s
      timeout: 10s
      retries: 10
    tty: true
    environment:
      SERVER_APP_DATABASE_URL: '${SERVER_APP_DATABASE_URL}'
      SERVER_PORT: '${SERVER_PORT}'
      SERVER_WEBHOOK_DATABASE_URL: '${SERVER_WEBHOOK_DATABASE_URL}'
      SERVER_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID: '${SERVER_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID}'
      SERVER_AUTH_ADMIN_EMAIL: '${SERVER_AUTH_ADMIN_EMAIL}'
      SERVER_AUTH_ADMIN_USERNAME: '${SERVER_AUTH_ADMIN_USERNAME}'
      SERVER_AUTH_ADMIN_PASSWORD: '${SERVER_AUTH_ADMIN_PASSWORD}'
      NODE_TLS_REJECT_UNAUTHORIZED: '0'
      SERVER_AUTHORIZER_URL: '${SERVER_AUTHORIZER_URL}'
      SERVER_AUTHORIZER_REDIRECT_URL: '${SERVER_AUTHORIZER_REDIRECT_URL}'
      SERVER_AUTHORIZER_AUTHORIZER_URL: '${SERVER_AUTHORIZER_AUTHORIZER_URL}'
      SERVER_AUTHORIZER_ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}'
    restart: 'always'
    depends_on:
      nestjs-mod-fullstack-postgre-sql:
        condition: service_healthy
      nestjs-mod-fullstack-postgre-sql-migrations:
        condition: service_completed_successfully
  nestjs-mod-fullstack-nginx:
    image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-nginx:${CLIENT_VERSION}'
    container_name: 'nestjs-mod-fullstack-nginx'
    networks:
      - 'nestjs-mod-fullstack-network'
    healthcheck:
      test: ['CMD-SHELL', 'curl -so /dev/null http://localhost:${NGINX_PORT} || exit 1']
      interval: 30s
      timeout: 10s
      retries: 10
    environment:
      SERVER_PORT: '${SERVER_PORT}'
      NGINX_PORT: '${NGINX_PORT}'
      CLIENT_AUTHORIZER_URL: '${CLIENT_AUTHORIZER_URL}'
      CLIENT_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID: '${CLIENT_WEBHOOK_SUPER_ADMIN_EXTERNAL_USER_ID}'
    restart: 'always'
    depends_on:
      nestjs-mod-fullstack-server:
        condition: service_healthy
    ports:
      - '${NGINX_PORT}:${NGINX_PORT}'
  nestjs-mod-fullstack-e2e-tests:
    image: 'ghcr.io/nestjs-mod/nestjs-mod-fullstack-e2e-tests:${ROOT_VERSION}'
    container_name: 'nestjs-mod-fullstack-e2e-tests'
    networks:
      - 'nestjs-mod-fullstack-network'
    environment:
      IS_DOCKER_COMPOSE: 'true'
      BASE_URL: 'http://nestjs-mod-fullstack-nginx:${NGINX_PORT}'
      SERVER_AUTHORIZER_URL: 'http://nestjs-mod-fullstack-authorizer:8080'
      SERVER_URL: 'http://nestjs-mod-fullstack-server:8080'
      SERVER_AUTH_ADMIN_EMAIL: '${SERVER_AUTH_ADMIN_EMAIL}'
      SERVER_AUTH_ADMIN_USERNAME: '${SERVER_AUTH_ADMIN_USERNAME}'
      SERVER_AUTH_ADMIN_PASSWORD: '${SERVER_AUTH_ADMIN_PASSWORD}'
      SERVER_AUTHORIZER_ADMIN_SECRET: '${SERVER_AUTHORIZER_ADMIN_SECRET}'
    depends_on:
      nestjs-mod-fullstack-nginx:
        condition: service_healthy
    working_dir: '/usr/src/app'
    volumes:
      - './../apps:/usr/src/app/apps'
      - './../libs:/usr/src/app/libs'
  nestjs-mod-fullstack-https-portal:
    image: steveltn/https-portal:1
    container_name: 'nestjs-mod-fullstack-https-portal'
    networks:
      - 'nestjs-mod-fullstack-network'
    ports:
      - '80:80'
      - '443:443'
    links:
      - nestjs-mod-fullstack-nginx
    restart: always
    environment:
      STAGE: '${HTTPS_PORTAL_STAGE}'
      DOMAINS: '${SERVER_DOMAIN} -> http://nestjs-mod-fullstack-nginx:${NGINX_PORT}'
    depends_on:
      nestjs-mod-fullstack-nginx:
        condition: service_healthy
    volumes:
      - nestjs-mod-fullstack-https-portal-volume:/var/lib/https-portal
volumes:
  nestjs-mod-fullstack-postgre-sql-volume:
    name: 'nestjs-mod-fullstack-postgre-sql-volume'
  nestjs-mod-fullstack-https-portal-volume:
    name: 'nestjs-mod-fullstack-https-portal-volume'
Enter fullscreen mode Exit fullscreen mode

Update the file .docker/docker-compose-full.env

SERVER_PORT=9090
NGINX_PORT=8080
SERVER_ROOT_DATABASE_URL=postgres://postgres:postgres_password@nestjs-mod-fullstack-postgre-sql:5432/postgres?schema=public
SERVER_APP_DATABASE_URL=postgres://app:app_password@nestjs-mod-fullstack-postgre-sql:5432/app?schema=public
SERVER_WEBHOOK_DATABASE_URL=postgres://webhook:webhook_password@nestjs-mod-fullstack-postgre-sql:5432/webhook?schema=public
SERVER_POSTGRE_SQL_POSTGRESQL_USERNAME=postgres
SERVER_POSTGRE_SQL_POSTGRESQL_PASSWORD=postgres_password
SERVER_POSTGRE_SQL_POSTGRESQL_DATABASE=postgres
SERVER_DOMAIN=example.com
HTTPS_PORTAL_STAGE=local # local|stage|production

CLIENT_AUTHORIZER_URL=http://localhost:8000
SERVER_AUTHORIZER_REDIRECT_URL=http://localhost:8080
SERVER_AUTH_ADMIN_EMAIL=nestjs-mod-fullstack@site15.ru
SERVER_AUTH_ADMIN_USERNAME=admin
SERVER_AUTH_ADMIN_PASSWORD=SbxcbII7RUvCOe9TDXnKhfRrLJW5cGDA
SERVER_URL=http://localhost:9090/api
SERVER_AUTHORIZER_URL=http://localhost:8000
SERVER_AUTHORIZER_ADMIN_SECRET=VfKSfPPljhHBXCEohnitursmgDxfAyiD
SERVER_AUTHORIZER_DATABASE_TYPE=postgres
SERVER_AUTHORIZER_DATABASE_URL=postgres://Yk42KA4sOb:B7Ep2MwlRR6fAx0frXGWVTGP850qAxM6@nestjs-mod-fullstack-postgre-sql:5432/authorizer
SERVER_AUTHORIZER_DATABASE_NAME=authorizer
SERVER_AUTHORIZER_PORT=8080
SERVER_AUTHORIZER_AUTHORIZER_URL=http://nestjs-mod-fullstack-authorizer:8080
SERVER_AUTHORIZER_COOKIE_NAME=authorizer
SERVER_AUTHORIZER_DISABLE_PLAYGROUND=true
SERVER_AUTHORIZER_ACCESS_TOKEN_EXPIRY_TIME=30m
SERVER_AUTHORIZER_DISABLE_STRONG_PASSWORD=true
SERVER_AUTHORIZER_DISABLE_EMAIL_VERIFICATION=true
SERVER_AUTHORIZER_ORGANIZATION_NAME=NestJSModFullstack
SERVER_AUTHORIZER_IS_EMAIL_SERVICE_ENABLED=true
SERVER_AUTHORIZER_IS_SMS_SERVICE_ENABLED=false
SERVER_AUTHORIZER_RESET_PASSWORD_URL=/reset-password
SERVER_AUTHORIZER_ROLES=user,admin
SERVER_AUTHORIZER_DEFAULT_ROLES=user
SERVER_AUTHORIZER_JWT_ROLE_CLAIM=role
Enter fullscreen mode Exit fullscreen mode

12. Updating E2E tests

When writing and running E2E tests, a lot of code is duplicated, in order to remove duplication, we create a test utility.

Create the file libs/testing/src/lib/utils/rest-client-helper.ts

import { AuthToken, Authorizer } from '@authorizerdev/authorizer-js';
import { Configuration, DefaultApi, WebhookApi } from '@nestjs-mod-fullstack/app-rest-sdk';
import axios, { AxiosInstance } from 'axios';
import { get } from 'env-var';
import { GenerateRandomUserResult, generateRandomUser } from './generate-random-user';
import { getUrls } from './get-urls';

export class RestClientHelper {
  private authorizerClientID!: string;

  authorizationTokens?: AuthToken;

  private webhookApi?: WebhookApi;
  private defaultApi?: DefaultApi;
  private authorizer?: Authorizer;

  private defaultApiAxios?: AxiosInstance;
  private webhookApiAxios?: AxiosInstance;

  randomUser?: GenerateRandomUserResult;

  constructor(
    private readonly options?: {
      isAdmin?: boolean;
      serverUrl?: string;
      authorizerURL?: string;
      randomUser?: GenerateRandomUserResult;
    }
  ) {
    this.randomUser = options?.randomUser;
    this.createApiClients();
  }

  getGeneratedRandomUser(): Required<GenerateRandomUserResult> {
    if (!this.randomUser) {
      throw new Error('this.randomUser not set');
    }
    return this.randomUser as Required<GenerateRandomUserResult>;
  }

  getWebhookApi() {
    if (!this.webhookApi) {
      throw new Error('webhookApi not set');
    }
    return this.webhookApi;
  }

  getDefaultApi() {
    if (!this.defaultApi) {
      throw new Error('defaultApi not set');
    }
    return this.defaultApi;
  }

  async getAuthorizerClient() {
    if (!this.authorizerClientID && this.defaultApi) {
      this.authorizerClientID = (await this.defaultApi.authorizerControllerGetAuthorizerClientID()).data.clientID;
      if (!this.options?.isAdmin) {
        this.authorizer = new Authorizer({
          authorizerURL: this.getAuthorizerUrl(),
          clientID: this.authorizerClientID,
          redirectURL: this.getServerUrl(),
        });
      } else {
        this.authorizer = new Authorizer({
          authorizerURL: this.getAuthorizerUrl(),
          clientID: this.authorizerClientID,
          redirectURL: this.getServerUrl(),
          extraHeaders: {
            'x-authorizer-admin-secret': get('SERVER_AUTHORIZER_ADMIN_SECRET').required().asString(),
          },
        });
      }
    }
    return this.authorizer as Authorizer;
  }

  async setRoles(roles: string[]) {
    const _updateUserResult = await (
      await this.getAuthorizerClient()
    ).graphqlQuery({
      query: `mutation {
  _update_user(
    params: { id: "${this.authorizationTokens?.user?.id}", roles: ${JSON.stringify(roles)} }
  ) {
    id
    roles
  }
}`,
    });
    if (_updateUserResult.errors.length > 0) {
      console.error(_updateUserResult.errors);
      throw new Error(_updateUserResult.errors[0].message);
    }
    await this.login();

    return this;
  }

  async createAndLoginAsUser(options?: Pick<GenerateRandomUserResult, 'email' | 'password'>) {
    await this.generateRandomUser(options);
    await this.reg();
    await this.login(options);

    if (this.options?.isAdmin) {
      await this.setRoles(['admin', 'user']);
    }

    return this;
  }

  async generateRandomUser(options?: Pick<GenerateRandomUserResult, 'email' | 'password'> | undefined) {
    if (!this.randomUser || options) {
      this.randomUser = await generateRandomUser(undefined, options);
    }
    return this;
  }

  async reg() {
    if (!this.randomUser) {
      this.randomUser = await generateRandomUser();
    }
    await (
      await this.getAuthorizerClient()
    ).signup({
      email: this.randomUser.email,
      confirm_password: this.randomUser.password,
      password: this.randomUser.password,
    });
    return this;
  }

  async login(options?: Partial<Pick<GenerateRandomUserResult, 'email' | 'password'>>) {
    if (!this.randomUser) {
      this.randomUser = await generateRandomUser();
    }
    const loginOptions = {
      email: options?.email || this.randomUser.email,
      password: options?.password || this.randomUser.password,
    };
    const loginResult = await (await this.getAuthorizerClient()).login(loginOptions);

    if (loginResult.errors.length) {
      throw new Error(loginResult.errors[0].message);
    }

    this.authorizationTokens = loginResult.data;

    if (this.webhookApiAxios) {
      Object.assign(this.webhookApiAxios.defaults.headers.common, this.getAuthorizationHeaders());
    }
    if (this.defaultApiAxios) {
      Object.assign(this.defaultApiAxios.defaults.headers.common, this.getAuthorizationHeaders());
    }

    return this;
  }

  async logout() {
    await (await this.getAuthorizerClient()).logout(this.getAuthorizationHeaders());
    return this;
  }

  getAuthorizationHeaders() {
    return {
      Authorization: `Bearer ${this.authorizationTokens?.access_token}`,
    };
  }

  private createApiClients() {
    this.webhookApiAxios = axios.create();
    this.defaultApiAxios = axios.create();

    this.webhookApi = new WebhookApi(
      new Configuration({
        basePath: this.getServerUrl(),
      }),
      undefined,
      this.webhookApiAxios
    );
    this.defaultApi = new DefaultApi(
      new Configuration({
        basePath: this.getServerUrl(),
      }),
      undefined,
      this.defaultApiAxios
    );
  }

  private getAuthorizerUrl(): string {
    return this.options?.authorizerURL || getUrls().authorizerUrl;
  }

  private getServerUrl(): string {
    return this.options?.serverUrl || getUrls().serverUrl;
  }
}
Enter fullscreen mode Exit fullscreen mode

I will not describe the changes in all the files with tests, I will add only one where users with different roles are used.

Update the file apps/server-e2e/src/server/webhook-crud-as-admin.spec.ts

import { RestClientHelper } from '@nestjs-mod-fullstack/testing';
import { get } from 'env-var';

describe('CRUD operations with Webhook as "Admin" role', () => {
  const user1 = new RestClientHelper();
  const admin = new RestClientHelper({
    isAdmin: true,
  });

  let createEventName: string;

  beforeAll(async () => {
    await user1.createAndLoginAsUser();
    await admin.login({
      email: get('SERVER_AUTH_ADMIN_EMAIL').required().asString(),
      password: get('SERVER_AUTH_ADMIN_PASSWORD').required().asString(),
    });

    const { data: events } = await user1.getWebhookApi().webhookControllerEvents();
    createEventName = events.find((e) => e.eventName.includes('create'))?.eventName || 'create';
    expect(events.map((e) => e.eventName)).toEqual(['app-demo.create', 'app-demo.update', 'app-demo.delete']);
  });

  afterAll(async () => {
    const { data: manyWebhooks } = await user1.getWebhookApi().webhookControllerFindMany();
    for (const webhook of manyWebhooks.webhooks) {
      if (webhook.endpoint.startsWith(user1.getGeneratedRandomUser().site)) {
        await user1.getWebhookApi().webhookControllerUpdateOne(webhook.id, {
          enabled: false,
        });
      }
    }
    //

    const { data: manyWebhooks2 } = await admin.getWebhookApi().webhookControllerFindMany();
    for (const webhook of manyWebhooks2.webhooks) {
      if (webhook.endpoint.startsWith(admin.getGeneratedRandomUser().site)) {
        await admin.getWebhookApi().webhookControllerUpdateOne(webhook.id, {
          enabled: false,
        });
      }
    }
  });

  it('should create new webhook as user1', async () => {
    const { data: newWebhook } = await user1.getWebhookApi().webhookControllerCreateOne({
      enabled: false,
      endpoint: user1.getGeneratedRandomUser().site,
      eventName: createEventName,
    });
    expect(newWebhook).toMatchObject({
      enabled: false,
      endpoint: user1.getGeneratedRandomUser().site,
      eventName: createEventName,
    });
  });

  it('should create new webhook as admin', async () => {
    const { data: newWebhook } = await admin.getWebhookApi().webhookControllerCreateOne({
      enabled: false,
      endpoint: admin.getGeneratedRandomUser().site,
      eventName: createEventName,
    });
    expect(newWebhook).toMatchObject({
      enabled: false,
      endpoint: admin.getGeneratedRandomUser().site,
      eventName: createEventName,
    });
  });

  it('should read one webhooks as user', async () => {
    const { data: manyWebhooks } = await user1.getWebhookApi().webhookControllerFindMany();
    expect(manyWebhooks).toMatchObject({
      meta: { curPage: 1, perPage: 5, totalResults: 1 },
      webhooks: [
        {
          enabled: false,
          endpoint: user1.getGeneratedRandomUser().site,
          eventName: createEventName,
        },
      ],
    });
  });

  it('should read all webhooks as admin', async () => {
    const { data: manyWebhooks } = await admin.getWebhookApi().webhookControllerFindMany();
    expect(manyWebhooks.meta.totalResults).toBeGreaterThan(1);
    expect(manyWebhooks).toMatchObject({
      meta: { curPage: 1, perPage: 5 },
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this post, https://authorizer.dev was chosen as the authorization server, but the principle of working with other authorization servers is approximately the same and the additional code that was written on the frontend and backend will not differ much.

The authorization server chosen for this project is very easy to implement in the project, but it does not support mutable tenancy, so if you need this option, it is better to choose another authorization server or write your own.

Auto refresh of the token in case of error 401 is not provided in this version of the project, it will be implemented in future posts.

Plans

The login server user has a picture field, but the login server does not have a method to upload a photo. In the next post, I will include https://min.io/ in the project and configure the integration with NestJS and Angular...

Links

https://nestjs.com - the official website of the framework
https://nestjs-mod.com - the official website of additional utilities
https://fullstack.nestjs-mod.com - website from the post
https://github.com/nestjs-mod/nestjs-mod-fullstack - the project from the post
https://github.com/nestjs-mod/nestjs-mod-fullstack/compare/414980df21e585cb798e1ff756300c4547e68a42..2e4639867c55e350f0c52dee4cb581fc624b5f9d - current changes
https://github.com/nestjs-mod/nestjs-mod-fullstack/actions/runs/11729520686/artifacts/2159651164 - video of the tests

Top comments (0)