Skip to content

Commit c7c14c2

Browse files
committed
feat(auth): initial draft of @nestjs-cls/auth plugin
1 parent 1fae846 commit c7c14c2

14 files changed

+3659
-6096
lines changed

packages/auth/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# @nestjs-cls/auth
2+
3+
An "Auth" plugin for `nestjs-cls` that simplifies verifying permissions by storing the authorization object in the CLS context.
4+
5+
The authorization object can be retrieved in any other service and used to verify permissions without having to pass it around. It can also be queried using the handy `RequirePermission` decorator.
6+
7+
### ➡️ [Go to the documentation website](https://papooch.github.io/nestjs-cls/plugins/available-plugins/auth) 📖

packages/auth/jest.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = {
2+
moduleFileExtensions: ['js', 'json', 'ts'],
3+
rootDir: '.',
4+
testRegex: '.*\\.spec\\.ts$',
5+
preset: 'ts-jest',
6+
collectCoverageFrom: ['src/**/*.ts'],
7+
coverageDirectory: '../coverage',
8+
testEnvironment: 'node',
9+
};

packages/auth/package.json

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"name": "@nestjs-cls/auth",
3+
"version": "0.0.1",
4+
"description": "A nestjs-cls plugin for authorization",
5+
"author": "papooch",
6+
"license": "MIT",
7+
"engines": {
8+
"node": ">=18"
9+
},
10+
"publishConfig": {
11+
"access": "public"
12+
},
13+
"repository": {
14+
"type": "git",
15+
"url": "git+https://github.com/Papooch/nestjs-cls.git"
16+
},
17+
"homepage": "https://papooch.github.io/nestjs-cls/",
18+
"keywords": [
19+
"nest",
20+
"nestjs",
21+
"cls",
22+
"continuation-local-storage",
23+
"als",
24+
"AsyncLocalStorage",
25+
"async_hooks",
26+
"request context",
27+
"async context",
28+
"auth",
29+
"authorization"
30+
],
31+
"main": "dist/src/index.js",
32+
"types": "dist/src/index.d.ts",
33+
"files": [
34+
"dist/src/**/!(*.spec).d.ts",
35+
"dist/src/**/!(*.spec).js"
36+
],
37+
"scripts": {
38+
"prepack": "cp ../../../LICENSE ./LICENSE",
39+
"prebuild": "rimraf dist",
40+
"build": "tsc",
41+
"test": "jest",
42+
"test:watch": "jest --watch",
43+
"test:cov": "jest --coverage"
44+
},
45+
"peerDependencies": {
46+
"@nestjs/common": ">= 10 < 12",
47+
"@nestjs/core": ">= 10 < 12",
48+
"nestjs-cls": "workspace:^5.4.3",
49+
"reflect-metadata": "*",
50+
"rxjs": ">= 7"
51+
},
52+
"devDependencies": {
53+
"@nestjs/cli": "^11.0.7",
54+
"@nestjs/common": "^11.1.0",
55+
"@nestjs/core": "^11.1.0",
56+
"@nestjs/testing": "^11.1.0",
57+
"@types/jest": "^29.5.14",
58+
"@types/node": "^22.15.12",
59+
"jest": "^29.7.0",
60+
"nestjs-cls": "workspace:^5.4.3",
61+
"reflect-metadata": "^0.2.2",
62+
"rimraf": "^6.0.1",
63+
"rxjs": "^7.8.1",
64+
"ts-jest": "^29.3.1",
65+
"ts-loader": "^9.5.2",
66+
"ts-node": "^10.9.2",
67+
"tsconfig-paths": "^4.2.0",
68+
"typescript": "5.8.2"
69+
}
70+
}

packages/auth/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './lib/auth-host';
2+
export * from './lib/plugin-auth';
3+
export * from './lib/require-permission.decorator';
4+
export * from './lib/permission-denied.exception';

packages/auth/src/lib/auth-host.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Logger } from '@nestjs/common';
2+
import { ClsServiceManager } from 'nestjs-cls';
3+
import { getAuthClsKey } from './auth.symbols';
4+
import { PermissionDeniedException } from './permission-denied.exception';
5+
import { RequirePermissionOptions } from './auth.interfaces';
6+
7+
export class AuthHost<TAuth = never> {
8+
private readonly cls = ClsServiceManager.getClsService();
9+
private readonly logger = new Logger(AuthHost.name);
10+
private readonly authInstanceSymbol: symbol;
11+
12+
private static _instanceMap = new Map<symbol, AuthHost<any>>();
13+
14+
static getInstance<TAuth = never>(authName?: string): AuthHost<TAuth> {
15+
const instanceSymbol = getAuthClsKey(authName);
16+
const instance = this._instanceMap.get(instanceSymbol);
17+
18+
if (!instance) {
19+
throw new Error(
20+
'AuthHost not initialized, Make sure that the `ClsPluginAuth` is properly registered and that the correct `authName` is used.',
21+
);
22+
}
23+
return instance;
24+
}
25+
26+
constructor() {
27+
this.authInstanceSymbol = getAuthClsKey();
28+
AuthHost._instanceMap.set(this.authInstanceSymbol, this);
29+
}
30+
31+
get auth(): TAuth {
32+
if (!this.cls.isActive()) {
33+
throw new Error(
34+
'CLS context is not active, cannot retrieve auth instance.',
35+
);
36+
}
37+
return this.cls.get(this.authInstanceSymbol) as TAuth;
38+
}
39+
40+
setAuth(auth: TAuth): void {
41+
this.cls.set(this.authInstanceSymbol, auth);
42+
}
43+
44+
hasPermission(predicate: (auth: TAuth) => boolean): boolean {
45+
if (!predicate(this.auth)) {
46+
return false;
47+
}
48+
return true;
49+
}
50+
51+
requirePermission(
52+
predicate: (auth: TAuth) => boolean,
53+
options?: RequirePermissionOptions,
54+
): void {
55+
if (!this.hasPermission(predicate)) {
56+
throw new PermissionDeniedException(
57+
options?.exceptionMessage ?? 'Permission denied',
58+
);
59+
}
60+
}
61+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface RequirePermissionOptions {
2+
exceptionMessage?: string;
3+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const AUTH_HOST_OPTIONS = Symbol('AUTH_HOST_OPTIONS');
2+
3+
const AUTH_CLS_KEY = Symbol('AUTH_CLS_KEY');
4+
5+
export const getAuthClsKey = (authName?: string) =>
6+
authName
7+
? Symbol.for(`${AUTH_CLS_KEY.description}_${authName}`)
8+
: AUTH_CLS_KEY;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class PermissionDeniedException extends Error {
2+
constructor(message: string) {
3+
super(message);
4+
this.name = 'PermissionDeniedException';
5+
}
6+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { ClsPluginBase, ClsService } from 'nestjs-cls';
2+
import { AuthHost } from './auth-host';
3+
import { getAuthClsKey } from './auth.symbols';
4+
5+
const CLS_AUTH_OPTIONS = Symbol('CLS_AUTH_OPTIONS');
6+
7+
interface ClsAuthCallbacks<T> {
8+
authObjectFactory: (cls: ClsService) => T;
9+
permissionResolutionStrategy?: (authObject: T, cls: ClsService) => boolean;
10+
}
11+
12+
export interface ClsPluginAuthOptions<T> {
13+
imports?: any[];
14+
inject?: any[];
15+
useFactory?: (...args: any[]) => ClsAuthCallbacks<T>;
16+
}
17+
18+
export class ClsPluginAuth extends ClsPluginBase {
19+
constructor(options: ClsPluginAuthOptions<any>) {
20+
super('cls-plugin-auth');
21+
this.imports.push(...(options.imports ?? []));
22+
this.providers = [
23+
{
24+
provide: CLS_AUTH_OPTIONS,
25+
inject: options.inject,
26+
useFactory: options.useFactory ?? (() => ({})),
27+
},
28+
{
29+
provide: AuthHost,
30+
useClass: AuthHost,
31+
},
32+
];
33+
34+
this.registerHooks({
35+
inject: [CLS_AUTH_OPTIONS],
36+
useFactory: (options: ClsAuthCallbacks<any>) => ({
37+
afterSetup: (cls: ClsService) => {
38+
const authObject = options.authObjectFactory(cls);
39+
cls.setIfUndefined(getAuthClsKey(), authObject);
40+
},
41+
}),
42+
});
43+
44+
this.exports = [AuthHost];
45+
}
46+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { copyMethodMetadata } from 'nestjs-cls';
2+
import { AuthHost } from './auth-host';
3+
import { RequirePermissionOptions } from './auth.interfaces';
4+
5+
export function RequirePermission<TAuth = any>(
6+
predicate: (auth: TAuth) => boolean,
7+
options?: RequirePermissionOptions,
8+
): MethodDecorator;
9+
10+
export function RequirePermission<TAuth = any>(
11+
authName: string,
12+
predicate: (auth: TAuth) => boolean,
13+
options?: RequirePermissionOptions,
14+
): MethodDecorator;
15+
16+
export function RequirePermission<TAuth = any>(
17+
firstParam: any,
18+
secondParam?: any,
19+
thirdParam?: any,
20+
): MethodDecorator {
21+
let authName: string | undefined;
22+
let predicate: (auth: TAuth) => boolean;
23+
let options: RequirePermissionOptions | undefined;
24+
25+
if (typeof firstParam === 'string') {
26+
authName = firstParam;
27+
predicate = secondParam as (auth: TAuth) => boolean;
28+
options = thirdParam;
29+
} else {
30+
authName = undefined;
31+
predicate = firstParam as (auth: TAuth) => boolean;
32+
options = secondParam;
33+
}
34+
options ??= {
35+
exceptionMessage: 'Permission denied',
36+
};
37+
38+
return ((
39+
_target: any,
40+
propertyKey: string | symbol,
41+
descriptor: TypedPropertyDescriptor<(...args: any) => any>,
42+
) => {
43+
const original = descriptor.value;
44+
if (typeof original !== 'function') {
45+
throw new Error(
46+
`The @RequirePermission decorator can be only used on functions, but ${propertyKey.toString()} is not a function.`,
47+
);
48+
}
49+
descriptor.value = new Proxy(original, {
50+
apply: function (_, outerThis, args: any[]) {
51+
const authHost = AuthHost.getInstance(authName);
52+
53+
authHost.requirePermission(predicate, options);
54+
55+
return original.call(outerThis, ...args);
56+
},
57+
});
58+
copyMethodMetadata(original, descriptor.value);
59+
}) as MethodDecorator;
60+
}

0 commit comments

Comments
 (0)