DEV Community

ILshat Khamitov
ILshat Khamitov

Posted on • Edited 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)