Skip to content

Commit 6904517

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

14 files changed

+2019
-1571
lines changed

packages/authorization/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# @nestjs-cls/authorization
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/authorization) 📖
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+
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"name": "@nestjs-cls/authorization",
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:^6.0.1",
49+
"reflect-metadata": "*",
50+
"rxjs": ">= 7"
51+
},
52+
"devDependencies": {
53+
"@nestjs/cli": "^11.0.7",
54+
"@nestjs/common": "^11.1.3",
55+
"@nestjs/core": "^11.1.3",
56+
"@nestjs/testing": "^11.1.3",
57+
"@types/jest": "^29.5.14",
58+
"@types/node": "^22.15.12",
59+
"jest": "^29.7.0",
60+
"nestjs-cls": "workspace:6.0.1",
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+
}
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-authorization';
3+
export * from './lib/require-permission.decorator';
4+
export * from './lib/permission-denied.exception';
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Inject, Logger } from '@nestjs/common';
2+
import { ClsServiceManager } from 'nestjs-cls';
3+
import {
4+
AuthHostOptions,
5+
RequirePermissionOptions,
6+
} from './authorization.interfaces';
7+
import { AUTH_HOST_OPTIONS, getAuthClsKey } from './authorization.symbols';
8+
9+
export class AuthHost<TAuth = never> {
10+
private readonly cls = ClsServiceManager.getClsService();
11+
private readonly logger = new Logger(AuthHost.name);
12+
private readonly authInstanceSymbol: symbol;
13+
14+
private static _instanceMap = new Map<symbol, AuthHost<any>>();
15+
16+
static getInstance<TAuth = never>(authName?: string): AuthHost<TAuth> {
17+
const instanceSymbol = getAuthClsKey(authName);
18+
const instance = this._instanceMap.get(instanceSymbol);
19+
20+
if (!instance) {
21+
throw new Error(
22+
'AuthHost not initialized, Make sure that the `ClsPluginAuth` is properly registered and that the correct `authName` is used.',
23+
);
24+
}
25+
return instance;
26+
}
27+
28+
constructor(
29+
@Inject(AUTH_HOST_OPTIONS)
30+
private readonly options: AuthHostOptions<TAuth>,
31+
) {
32+
this.authInstanceSymbol = getAuthClsKey();
33+
AuthHost._instanceMap.set(this.authInstanceSymbol, this);
34+
}
35+
36+
get auth(): TAuth {
37+
if (!this.cls.isActive()) {
38+
throw new Error(
39+
'CLS context is not active, cannot retrieve auth instance.',
40+
);
41+
}
42+
return this.cls.get(this.authInstanceSymbol) as TAuth;
43+
}
44+
45+
setAuth(auth: TAuth): void {
46+
this.cls.set(this.authInstanceSymbol, auth);
47+
}
48+
49+
hasPermission(predicate: (auth: TAuth) => boolean): boolean;
50+
hasPermission(permission: any): boolean;
51+
hasPermission(
52+
predicateOrPermission: ((auth: TAuth) => boolean) | any,
53+
): boolean {
54+
if (typeof predicateOrPermission === 'function') {
55+
return predicateOrPermission(this.auth);
56+
} else {
57+
return this.options.permissionResolutionStrategy(
58+
this.auth,
59+
predicateOrPermission,
60+
);
61+
}
62+
}
63+
64+
requirePermission(
65+
permission: any,
66+
options?: RequirePermissionOptions,
67+
): void;
68+
requirePermission(
69+
predicate: (auth: TAuth) => boolean,
70+
options?: RequirePermissionOptions,
71+
): void;
72+
requirePermission(
73+
predicateOrPermission: ((auth: TAuth) => boolean) | any,
74+
options: RequirePermissionOptions = {},
75+
): void {
76+
const value =
77+
typeof predicateOrPermission === 'function'
78+
? undefined
79+
: predicateOrPermission;
80+
81+
if (!this.hasPermission(predicateOrPermission)) {
82+
throw this.options.exceptionFactory(options, value);
83+
}
84+
}
85+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface RequirePermissionOptions {
2+
exceptionMessage?: string;
3+
}
4+
5+
export interface AuthHostOptions<TAuth = any> {
6+
exceptionFactory: (
7+
options: RequirePermissionOptions,
8+
value?: any,
9+
) => Error | string;
10+
permissionResolutionStrategy: (authObject: TAuth, value: any) => boolean;
11+
}
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { ClsPluginBase, ClsService } from 'nestjs-cls';
2+
import { AuthHost } from './auth-host';
3+
import { AUTH_HOST_OPTIONS, getAuthClsKey } from './authorization.symbols';
4+
import {
5+
AuthHostOptions,
6+
RequirePermissionOptions,
7+
} from './authorization.interfaces';
8+
//import { UnauthorizedException } from '@nestjs/common';
9+
10+
const CLS_AUTHORIZATION_OPTIONS = Symbol('CLS_AUTHORIZATION_OPTIONS');
11+
12+
interface ClsAuthCallbacks<T> {
13+
authObjectFactory: (cls: ClsService) => T;
14+
permissionResolutionStrategy: (authObject: T, value: any) => boolean;
15+
exceptionFactory?: (
16+
options: RequirePermissionOptions,
17+
value?: any,
18+
) => Error | string;
19+
}
20+
21+
export interface ClsPluginAuthorizationOptions<T> {
22+
imports?: any[];
23+
inject?: any[];
24+
useFactory?: (...args: any[]) => ClsAuthCallbacks<T>;
25+
}
26+
27+
export class ClsPluginAuthorization extends ClsPluginBase {
28+
constructor(options: ClsPluginAuthorizationOptions<any>) {
29+
super('cls-plugin-authorization');
30+
this.imports.push(...(options.imports ?? []));
31+
this.providers = [
32+
{
33+
provide: CLS_AUTHORIZATION_OPTIONS,
34+
inject: options.inject,
35+
useFactory: options.useFactory ?? (() => ({})),
36+
},
37+
{
38+
provide: AUTH_HOST_OPTIONS,
39+
inject: [CLS_AUTHORIZATION_OPTIONS],
40+
useFactory: (callbacks: ClsAuthCallbacks<any>) =>
41+
({
42+
permissionResolutionStrategy:
43+
callbacks.permissionResolutionStrategy,
44+
exceptionFactory: (
45+
options: RequirePermissionOptions,
46+
value?: any,
47+
) => {
48+
const factory =
49+
callbacks.exceptionFactory ??
50+
this.defaultExceptionFactory;
51+
52+
const exception = factory(options, value);
53+
if (typeof exception === 'string') {
54+
return new Error(exception);
55+
}
56+
return exception;
57+
},
58+
}) satisfies AuthHostOptions,
59+
},
60+
{
61+
provide: AuthHost,
62+
useClass: AuthHost,
63+
},
64+
];
65+
66+
this.registerHooks({
67+
inject: [CLS_AUTHORIZATION_OPTIONS],
68+
useFactory: (options: ClsAuthCallbacks<any>) => ({
69+
afterSetup: (cls: ClsService) => {
70+
const authObject = options.authObjectFactory(cls);
71+
cls.setIfUndefined(getAuthClsKey(), authObject);
72+
},
73+
}),
74+
});
75+
76+
this.exports = [AuthHost];
77+
}
78+
79+
private defaultExceptionFactory(
80+
options: RequirePermissionOptions,
81+
value?: any,
82+
): Error | string {
83+
let message = options.exceptionMessage ?? 'Permission denied';
84+
if (value !== undefined) {
85+
message += ` (${JSON.stringify(value)})`;
86+
}
87+
return message;
88+
}
89+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { copyMethodMetadata } from 'nestjs-cls';
2+
import { AuthHost } from './auth-host';
3+
import { RequirePermissionOptions } from './authorization.interfaces';
4+
5+
export function RequirePermission<TAuth = any>(
6+
predicate: (auth: TAuth) => boolean,
7+
options?: RequirePermissionOptions,
8+
): MethodDecorator;
9+
10+
export function RequirePermission(
11+
permission: any,
12+
options?: RequirePermissionOptions,
13+
): MethodDecorator;
14+
15+
export function RequirePermission<TAuth = any>(
16+
authName: string,
17+
predicate: (auth: TAuth) => boolean,
18+
options: RequirePermissionOptions,
19+
): MethodDecorator;
20+
21+
export function RequirePermission(
22+
authName: string,
23+
permission: any,
24+
options: RequirePermissionOptions,
25+
): MethodDecorator;
26+
27+
export function RequirePermission<TAuth = any>(
28+
firstParam: any,
29+
secondParam?: any,
30+
thirdParam?: any,
31+
): MethodDecorator {
32+
let authName: string | undefined;
33+
let predicateOrPermission: ((auth: TAuth) => boolean) | any;
34+
let options: RequirePermissionOptions | undefined;
35+
36+
if (arguments.length === 3) {
37+
authName = firstParam;
38+
predicateOrPermission = secondParam;
39+
options = thirdParam;
40+
} else {
41+
authName = undefined;
42+
predicateOrPermission = firstParam;
43+
options = secondParam;
44+
}
45+
46+
options ??= {
47+
exceptionMessage: 'Permission denied',
48+
};
49+
50+
return ((
51+
_target: any,
52+
propertyKey: string | symbol,
53+
descriptor: TypedPropertyDescriptor<(...args: any) => any>,
54+
) => {
55+
const original = descriptor.value;
56+
if (typeof original !== 'function') {
57+
throw new Error(
58+
`The @RequirePermission decorator can be only used on functions, but ${propertyKey.toString()} is not a function.`,
59+
);
60+
}
61+
descriptor.value = new Proxy(original, {
62+
apply: async function (_, outerThis, args: any[]) {
63+
const authHost = AuthHost.getInstance(authName);
64+
65+
authHost.requirePermission(predicateOrPermission, options);
66+
67+
return await original.call(outerThis, ...args);
68+
},
69+
});
70+
copyMethodMetadata(original, descriptor.value);
71+
}) as MethodDecorator;
72+
}

0 commit comments

Comments
 (0)