Integration of external authorization server https://authorizer.dev into a full-stack application on NestJS and Angular
Publication date: 2024-11-08
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
$ ./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.
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
$ ./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
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
$ 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.
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',
},
}),
]}
);
5. We are starting the generation of additional code for the infrastructure
Commands
npm run docs:infrastructure
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;
}
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);
}
}
}
}
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);
}
}
}
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 };
},
});
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/core/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;
}
}
Export the new service from the module and the prism module it uses.
Update the file libs/core/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);
}
}
},
});
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;
},
};
},
}),
//...
],
//...
},
//...
});