Skip to content

Commit f339a94

Browse files
authored
Merge pull request #2 from mayademcom/create-authorization-decorator
Create authorization decorator
2 parents b519dea + bfa820e commit f339a94

File tree

8 files changed

+270
-766
lines changed

8 files changed

+270
-766
lines changed

package-lock.json

Lines changed: 49 additions & 761 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,7 @@
2121
"test:e2e": "jest --config ./test/jest-e2e.json"
2222
},
2323
"peerDependencies": {
24-
"@nestjs/common": "^11.0.1",
25-
"@nestjs/core": "^11.0.1",
26-
"@nestjs/platform-express": "^11.0.1",
27-
"reflect-metadata": "^0.2.2",
28-
"rxjs": "^7.8.1"
24+
"@nestjs/common": "^11.x"
2925
},
3026
"devDependencies": {
3127
"@eslint/eslintrc": "^3.2.0",
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
2+
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants';
3+
import { Authorization } from './authorization.decorator';
4+
import Chance from 'chance';
5+
import { AuthorizationOptions } from '../models';
6+
7+
interface ParamMetadata {
8+
index: number;
9+
factory: (data: any, ctx: ExecutionContext) => string | null;
10+
}
11+
describe('Authorization Decorator', () => {
12+
const chance = new Chance();
13+
14+
function getParamDecoratorFactory(options?: AuthorizationOptions) {
15+
class TestController {
16+
public getDecoratorValue(@Authorization(options) value: string) {
17+
return value;
18+
}
19+
}
20+
21+
const args = Reflect.getMetadata(
22+
ROUTE_ARGS_METADATA,
23+
TestController,
24+
'getDecoratorValue',
25+
) as Record<string, ParamMetadata>;
26+
27+
return args[Object.keys(args)[0]].factory;
28+
}
29+
30+
let mockExecutionContext: ExecutionContext;
31+
32+
beforeEach(() => {
33+
mockExecutionContext = {
34+
switchToHttp: jest.fn().mockReturnValue({
35+
getRequest: jest.fn(),
36+
}),
37+
} as unknown as ExecutionContext;
38+
});
39+
40+
it('should extract token with Bearer prefix', () => {
41+
const token = chance.string({ length: 20 });
42+
const mockRequest = {
43+
headers: { authorization: `Bearer ${token}` },
44+
};
45+
46+
(
47+
mockExecutionContext.switchToHttp().getRequest as jest.Mock
48+
).mockReturnValue(mockRequest);
49+
50+
const factory = getParamDecoratorFactory();
51+
const actualToken = factory(undefined, mockExecutionContext);
52+
53+
expect(actualToken).toBe(token);
54+
});
55+
56+
it('should return null when no authorization header', () => {
57+
const mockRequest = { headers: {} };
58+
59+
(
60+
mockExecutionContext.switchToHttp().getRequest as jest.Mock
61+
).mockReturnValue(mockRequest);
62+
63+
const factory = getParamDecoratorFactory();
64+
const actualToken = factory(undefined, mockExecutionContext);
65+
66+
expect(actualToken).toBeNull();
67+
});
68+
69+
it('should extract token without prefix', () => {
70+
const token = chance.string({ length: 20 });
71+
const mockRequest = {
72+
headers: { authorization: token },
73+
};
74+
75+
(
76+
mockExecutionContext.switchToHttp().getRequest as jest.Mock
77+
).mockReturnValue(mockRequest);
78+
79+
const factory = getParamDecoratorFactory();
80+
const actualToken = factory(undefined, mockExecutionContext);
81+
82+
expect(actualToken).toBe(token);
83+
});
84+
85+
it('should throw when required and missing', () => {
86+
const mockRequest = { headers: {} };
87+
88+
(
89+
mockExecutionContext.switchToHttp().getRequest as jest.Mock
90+
).mockReturnValue(mockRequest);
91+
92+
const factory = getParamDecoratorFactory({ required: true });
93+
94+
expect(() => factory({ required: true }, mockExecutionContext)).toThrow(
95+
UnauthorizedException,
96+
);
97+
});
98+
99+
it('should handle custom prefix', () => {
100+
const token = chance.string({ length: 20 });
101+
const mockRequest = {
102+
headers: { authorization: `Token ${token}` },
103+
};
104+
105+
(
106+
mockExecutionContext.switchToHttp().getRequest as jest.Mock
107+
).mockReturnValue(mockRequest);
108+
109+
const factory = getParamDecoratorFactory({
110+
prefix: 'Token',
111+
});
112+
const actualToken = factory({ prefix: 'Token' }, mockExecutionContext);
113+
114+
expect(actualToken).toBe(token);
115+
});
116+
117+
it('should return token as-is when no prefix matches', () => {
118+
const token = chance.string({ length: 20 });
119+
const mockRequest = {
120+
headers: { authorization: `CustomPrefix ${token}` },
121+
};
122+
123+
(
124+
mockExecutionContext.switchToHttp().getRequest as jest.Mock
125+
).mockReturnValue(mockRequest);
126+
127+
const factory = getParamDecoratorFactory();
128+
const actualToken = factory(undefined, mockExecutionContext);
129+
130+
expect(actualToken).toBe(`CustomPrefix ${token}`);
131+
});
132+
133+
it('should handle empty prefix option', () => {
134+
const token = chance.string({ length: 20 });
135+
const mockRequest = {
136+
headers: { authorization: token },
137+
};
138+
139+
(
140+
mockExecutionContext.switchToHttp().getRequest as jest.Mock
141+
).mockReturnValue(mockRequest);
142+
143+
const factory = getParamDecoratorFactory({ prefix: '' });
144+
const actualToken = factory({ prefix: '' }, mockExecutionContext);
145+
146+
expect(actualToken).toBe(token);
147+
});
148+
149+
it('should return empty when prefix matches but token part is empty', () => {
150+
const mockRequest = {
151+
headers: { authorization: 'Bearer' },
152+
};
153+
154+
(
155+
mockExecutionContext.switchToHttp().getRequest as jest.Mock
156+
).mockReturnValue(mockRequest);
157+
158+
const factory = getParamDecoratorFactory();
159+
const actualToken = factory(undefined, mockExecutionContext);
160+
161+
expect(actualToken).toBe('');
162+
});
163+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// decorators/authorization.decorator.ts
2+
3+
import {
4+
ExecutionContext,
5+
UnauthorizedException,
6+
createParamDecorator,
7+
} from '@nestjs/common';
8+
9+
import { AuthorizationOptions } from '../models';
10+
11+
/**
12+
* Extract authorization token from request headers
13+
* @example
14+
* // Required token (throws if missing)
15+
* async webhook(@Authorization({ required: true }) token: string) {}
16+
*
17+
* // Optional token
18+
* async webhook(@Authorization() token: string | null) {}
19+
*
20+
* // Custom prefix
21+
* async webhook(@Authorization({ prefix: 'Token' }) token: string) {}
22+
*/
23+
export const Authorization = createParamDecorator(
24+
(
25+
data: AuthorizationOptions | undefined,
26+
ctx: ExecutionContext,
27+
): string | null => {
28+
const { required = false, prefix = 'Bearer' } = data || {};
29+
30+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
31+
const request = ctx.switchToHttp().getRequest();
32+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
33+
const authHeader = request.headers.authorization as string;
34+
if (required && !authHeader)
35+
throw new UnauthorizedException('Authorization header is required');
36+
37+
if (authHeader) return parseToken(authHeader, prefix);
38+
39+
return null;
40+
},
41+
);
42+
43+
function parseToken(authHeader: string, prefix?: string) {
44+
if (prefix && authHeader.startsWith(prefix))
45+
return authHeader.substring(prefix.length).trim();
46+
47+
return authHeader;
48+
}

src/decorators/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './authorization.decorator';

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
export * from './decorators/authorization.decorator';
12
export * from './hasura.module';
3+
export * from './hasura.service';
4+
export * from './models';
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface AuthorizationOptions {
2+
required?: boolean;
3+
prefix?: string;
4+
}

src/models/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './hasura-config.interface';
22
export * from './hasura-async-config.type';
33
export * from './hasura-request-builder.interface';
4+
export * from './authorization-options.interface';

0 commit comments

Comments
 (0)