Skip to content

Commit d0fbf19

Browse files
authored
Merge pull request #51 from DouglasNeuroInformatics/dev
add ability to pass metadata not included in token payload to `defineAbility` function
2 parents adb4019 + 3a47e6c commit d0fbf19

File tree

9 files changed

+77
-34
lines changed

9 files changed

+77
-34
lines changed

example/app.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
2+
13
import { z } from 'zod';
24

35
import { $BaseEnv, AppFactory, AuthModule, CryptoService } from '../src/index.js';
46
import { AppController } from './app.controller.js';
57
import { CatsModule } from './cats/cats.module.js';
68

7-
import type { UserQueryResult } from '../src/index.js';
8-
99
export default AppFactory.create({
1010
controllers: [AppController],
1111
docs: {
@@ -18,7 +18,7 @@ export default AppFactory.create({
1818
inject: [CryptoService],
1919
useFactory: (cryptoService: CryptoService) => {
2020
return {
21-
defineAbility: (ability, payload): void => {
21+
defineAbility: (ability, payload) => {
2222
if (payload.isAdmin) {
2323
ability.can('manage', 'all');
2424
}
@@ -32,7 +32,7 @@ export default AppFactory.create({
3232
isAdmin: z.boolean()
3333
})
3434
},
35-
userQuery: async ({ username }): Promise<null | UserQueryResult<{ isAdmin: true }>> => {
35+
userQuery: async ({ username }) => {
3636
if (username !== 'admin') {
3737
return null;
3838
}

src/modules/auth/__tests__/ability.factory.spec.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Test } from '@nestjs/testing';
22
import { beforeEach, describe, expect, it, vi } from 'vitest';
33
import type { Mock } from 'vitest';
44

5+
import { MockFactory } from '../../../testing/index.js';
6+
import { LoggingService } from '../../logging/logging.service.js';
57
import { AbilityFactory } from '../ability.factory.js';
68
import { DEFINE_ABILITY_TOKEN } from '../auth.config.js';
79

@@ -19,22 +21,23 @@ describe('AbilityFactory', () => {
1921
{
2022
provide: DEFINE_ABILITY_TOKEN,
2123
useValue: defineAbility
22-
}
24+
},
25+
MockFactory.createForService(LoggingService)
2326
]
2427
}).compile();
2528
abilityFactory = moduleRef.get(AbilityFactory);
2629
});
2730

2831
describe('createForPayload', () => {
2932
it('should return an empty ruleset, if defineAbility is undefined', () => {
30-
const ability = abilityFactory.createForPayload({});
33+
const ability = abilityFactory.createForPayload({}, null);
3134
expect(ability.rules).toStrictEqual([]);
3235
});
3336
it('should add the correct ruleset for the ability', () => {
3437
defineAbility.mockImplementationOnce((ability) => {
3538
ability.can('manage', 'all');
3639
});
37-
const ability = abilityFactory.createForPayload({});
40+
const ability = abilityFactory.createForPayload({}, null);
3841
expect(ability.rules).toStrictEqual([
3942
{
4043
action: 'manage',
@@ -47,7 +50,7 @@ describe('AbilityFactory', () => {
4750
defineAbility.mockImplementationOnce((ability) => {
4851
ability.can('manage', 'Cat', { id: { in: [0, 1] } });
4952
});
50-
const ability = abilityFactory.createForPayload({});
53+
const ability = abilityFactory.createForPayload({}, null);
5154
expect(ability.rules).toStrictEqual([
5255
{
5356
action: 'manage',

src/modules/auth/__tests__/auth.module.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,15 @@ describe('AuthModule', () => {
118118

119119
it('should return status code 401 if the hashed password is incorrect', async () => {
120120
const comparePassword = vi.spyOn(CryptoService.prototype, 'comparePassword');
121-
userQuery.mockResolvedValueOnce({ hashedPassword: '123$123', tokenPayload });
121+
userQuery.mockResolvedValueOnce({ hashedPassword: '123$123', metadata: null, tokenPayload });
122122
const response = await request(server).post('/auth/login').send(loginCredentials);
123123
expect(response.status).toBe(401);
124124
expect(userQuery).toHaveBeenCalledExactlyOnceWith(loginCredentials);
125125
expect(comparePassword).toHaveBeenCalledExactlyOnceWith(loginCredentials.password, '123$123');
126126
});
127127

128128
it('should return status code 200 and an access token if the credentials are correct', async () => {
129-
userQuery.mockResolvedValueOnce({ hashedPassword, tokenPayload });
129+
userQuery.mockResolvedValueOnce({ hashedPassword, metadata: null, tokenPayload });
130130
const response = await request(server).post('/auth/login').send(loginCredentials);
131131
expect(response.status).toBe(200);
132132
expect(response.body).toStrictEqual({
@@ -152,7 +152,7 @@ describe('AuthModule', () => {
152152
});
153153
it('should reject queries with a valid access token, but insufficient permissions', async () => {
154154
const signAsync = vi.spyOn(jwtService, 'signAsync');
155-
userQuery.mockResolvedValueOnce({ hashedPassword, tokenPayload });
155+
userQuery.mockResolvedValueOnce({ hashedPassword, metadata: null, tokenPayload });
156156
defineAbility.mockImplementationOnce((ability) => {
157157
ability.can('delete', 'Cat');
158158
});
@@ -177,7 +177,7 @@ describe('AuthModule', () => {
177177
let accessToken: string;
178178

179179
beforeAll(async () => {
180-
userQuery.mockResolvedValueOnce({ hashedPassword, tokenPayload });
180+
userQuery.mockResolvedValueOnce({ hashedPassword, metadata: null, tokenPayload });
181181
defineAbility.mockImplementationOnce((ability) => {
182182
ability.can('manage', 'Cat');
183183
});

src/modules/auth/__tests__/auth.service.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Mock } from 'vitest';
66

77
import { MockFactory } from '../../../testing/index.js';
88
import { CryptoService } from '../../crypto/crypto.service.js';
9+
import { LoggingService } from '../../logging/logging.service.js';
910
import { AbilityFactory } from '../ability.factory.js';
1011
import { USER_QUERY_TOKEN } from '../auth.config.js';
1112
import { AuthService } from '../auth.service.js';
@@ -31,7 +32,8 @@ describe('AuthService', () => {
3132
AuthService,
3233
MockFactory.createForService(AbilityFactory),
3334
MockFactory.createForService(CryptoService),
34-
MockFactory.createForService(JwtService)
35+
MockFactory.createForService(JwtService),
36+
MockFactory.createForService(LoggingService)
3537
]
3638
}).compile();
3739
abilityFactory = moduleRef.get(AbilityFactory);

src/modules/auth/ability.factory.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AbilityBuilder, detectSubjectType } from '@casl/ability';
22
import { createPrismaAbility } from '@casl/prisma';
33
import { Inject, Injectable } from '@nestjs/common';
44

5+
import { LoggingService } from '../logging/logging.service.js';
56
import { DEFINE_ABILITY_TOKEN } from './auth.config.js';
67

78
import type { AppAbilities, AppAbility, DefineAbility, Permission } from './auth.config.js';
@@ -15,19 +16,31 @@ export class AbilityFactory {
1516
return detectSubjectType(obj);
1617
};
1718

18-
constructor(@Inject(DEFINE_ABILITY_TOKEN) private readonly defineAbility?: DefineAbility) {}
19+
constructor(
20+
private readonly loggingService: LoggingService,
21+
@Inject(DEFINE_ABILITY_TOKEN) private readonly defineAbility?: DefineAbility
22+
) {}
1923

20-
createForPayload(tokenPayload: { [key: string]: any }): AppAbility {
24+
createForPayload(payload: { [key: string]: any }, metadata: unknown): AppAbility {
25+
this.loggingService.verbose({
26+
message: 'Creating Ability From Payload',
27+
metadata,
28+
payload
29+
});
2130
const abilityBuilder = new AbilityBuilder<AppAbility>(createPrismaAbility);
2231
if (this.defineAbility) {
23-
this.defineAbility(abilityBuilder, tokenPayload);
32+
this.defineAbility(abilityBuilder, payload, metadata);
2433
}
2534
return abilityBuilder.build({
2635
detectSubjectType: this.detectSubjectType
2736
});
2837
}
2938

3039
createForPermissions(permissions: Permission[]): AppAbility {
40+
this.loggingService.verbose({
41+
message: 'Creating Ability From Permissions',
42+
permissions
43+
});
3144
return createPrismaAbility<AppAbilities>(permissions, {
3245
detectSubjectType: this.detectSubjectType
3346
});

src/modules/auth/auth.config.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { FallbackIfNever } from '@douglasneuroinformatics/libjs';
55
import { ConfigurableModuleBuilder } from '@nestjs/common';
66
import { Prisma } from '@prisma/client';
77
import type { DefaultSelection } from '@prisma/client/runtime/library';
8+
import type { IfNever } from 'type-fest';
89
import type { z } from 'zod';
910

1011
import { defineToken } from '../../utils/token.utils.js';
@@ -33,9 +34,10 @@ type AppConditions = FallbackIfNever<PrismaQuery, unknown>;
3334

3435
type AppAbility = PureAbility<AppAbilities, AppConditions>;
3536

36-
type DefineAbility<TPayload extends { [key: string]: unknown } = { [key: string]: unknown }> = (
37+
type DefineAbility<TPayload extends { [key: string]: unknown } = { [key: string]: unknown }, TMetadata = any> = (
3738
ability: AbilityBuilder<AppAbility>,
38-
tokenPayload: TPayload
39+
tokenPayload: TPayload,
40+
metadata: TMetadata
3941
) => void;
4042

4143
type Permission = RawRuleOf<PureAbility<[AppAction, AppSubjectName]>>;
@@ -51,34 +53,44 @@ interface JwtPayload {
5153
permissions: Permission[];
5254
}
5355

54-
type UserQueryResult<TPayload extends { [key: string]: unknown } = { [key: string]: unknown }> = {
56+
type UserQueryResult<
57+
TPayload extends { [key: string]: unknown } = { [key: string]: unknown },
58+
TMetadata = never
59+
> = IfNever<TMetadata, {}, { metadata: TMetadata }> & {
5560
hashedPassword: string;
5661
tokenPayload: TPayload;
5762
};
5863

5964
type UserQuery<
6065
TLoginCredentials extends BaseLoginCredentials = BaseLoginCredentials,
61-
TPayload extends { [key: string]: unknown } = { [key: string]: unknown }
62-
> = (credentials: TLoginCredentials) => Promise<null | UserQueryResult<TPayload>>;
66+
TPayload extends { [key: string]: unknown } = { [key: string]: unknown },
67+
TMetadata = any
68+
> = (credentials: TLoginCredentials) => Promise<null | UserQueryResult<TPayload, TMetadata>>;
6369

6470
type LoginResponseBody = {
6571
accessToken: string;
6672
};
6773

6874
type AuthModuleOptions<
6975
TLoginCredentialsSchema extends BaseLoginCredentialsSchema = BaseLoginCredentialsSchema,
70-
TPayloadSchema extends z.ZodType<{ [key: string]: unknown }> = z.ZodType<{ [key: string]: unknown }>
76+
TPayloadSchema extends z.ZodType<{ [key: string]: unknown }> = z.ZodType<{ [key: string]: unknown }>,
77+
TMetadataSchema extends z.ZodTypeAny = z.ZodNever
7178
> = {
72-
defineAbility: (ability: AbilityBuilder<AppAbility>, tokenPayload: z.TypeOf<TPayloadSchema>) => any;
79+
defineAbility: (
80+
ability: AbilityBuilder<AppAbility>,
81+
tokenPayload: z.TypeOf<TPayloadSchema>,
82+
metadata: z.TypeOf<TMetadataSchema>
83+
) => any;
7384
schemas: {
7485
loginCredentials: TLoginCredentialsSchema;
86+
metadata?: TMetadataSchema;
7587
tokenPayload: TPayloadSchema;
7688
};
77-
userQuery: UserQuery<z.TypeOf<TLoginCredentialsSchema>, z.TypeOf<TPayloadSchema>>;
89+
userQuery: UserQuery<z.TypeOf<TLoginCredentialsSchema>, z.TypeOf<TPayloadSchema>, z.TypeOf<TMetadataSchema>>;
7890
};
7991

8092
export const { ConfigurableModuleClass: ConfigurableAuthModule, MODULE_OPTIONS_TOKEN: AUTH_MODULE_OPTIONS_TOKEN } =
81-
new ConfigurableModuleBuilder<AuthModuleOptions<any, any>>()
93+
new ConfigurableModuleBuilder<AuthModuleOptions<any, any, any>>()
8294
.setClassMethodName('forRoot')
8395
.setExtras({}, (definition) => ({
8496
...definition,

src/modules/auth/auth.module.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,20 @@ export class AuthModule extends ConfigurableAuthModule implements OnModuleInit {
6565

6666
static forRoot<
6767
TLoginCredentialsSchema extends BaseLoginCredentialsSchema,
68-
TPayloadSchema extends z.ZodType<{ [key: string]: unknown }>
69-
>(options: AuthModuleOptions<TLoginCredentialsSchema, TPayloadSchema>): DynamicModule {
68+
TPayloadSchema extends z.ZodType<{ [key: string]: unknown }>,
69+
TMetadataSchema extends z.ZodTypeAny = z.ZodNever
70+
>(options: AuthModuleOptions<TLoginCredentialsSchema, TPayloadSchema, TMetadataSchema>): DynamicModule {
7071
return super.forRoot(options);
7172
}
7273
static forRootAsync<
7374
TLoginCredentialsSchema extends BaseLoginCredentialsSchema,
74-
TPayloadSchema extends z.ZodType<{ [key: string]: unknown }>
75+
TPayloadSchema extends z.ZodType<{ [key: string]: unknown }>,
76+
TMetadataSchema extends z.ZodTypeAny = z.ZodNever
7577
>(
76-
options: ConfigurableModuleAsyncOptions<AuthModuleOptions<TLoginCredentialsSchema, TPayloadSchema>, 'create'>
78+
options: ConfigurableModuleAsyncOptions<
79+
AuthModuleOptions<TLoginCredentialsSchema, TPayloadSchema, TMetadataSchema>,
80+
'create'
81+
>
7782
): DynamicModule {
7883
return super.forRootAsync(options);
7984
}

src/modules/auth/auth.service.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
22
import { JwtService } from '@nestjs/jwt';
33

44
import { CryptoService } from '../crypto/crypto.service.js';
5+
import { LoggingService } from '../logging/logging.service.js';
56
import { AbilityFactory } from './ability.factory.js';
67
import { USER_QUERY_TOKEN } from './auth.config.js';
78

@@ -13,7 +14,8 @@ export class AuthService {
1314
@Inject(USER_QUERY_TOKEN) private readonly userQuery: UserQuery,
1415
private readonly abilityFactory: AbilityFactory,
1516
private readonly cryptoService: CryptoService,
16-
private readonly jwtService: JwtService
17+
private readonly jwtService: JwtService,
18+
private readonly loggingService: LoggingService
1719
) {}
1820

1921
async login(credentials: BaseLoginCredentials): Promise<LoginResponseBody> {
@@ -26,12 +28,16 @@ export class AuthService {
2628
throw new UnauthorizedException('Invalid Credentials');
2729
}
2830

29-
const ability = this.abilityFactory.createForPayload(user.tokenPayload);
31+
const ability = this.abilityFactory.createForPayload(user.tokenPayload, user.metadata);
3032

3133
return { accessToken: await this.signToken({ ...user.tokenPayload, permissions: ability.rules }) };
3234
}
3335

3436
private async signToken(payload: JwtPayload): Promise<string> {
37+
this.loggingService.verbose({
38+
message: 'Signing JWT',
39+
payload
40+
});
3541
return this.jwtService.signAsync(payload, {
3642
expiresIn: '1d'
3743
});

src/modules/auth/guards/__tests__/jwt-auth.guard.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock('@nestjs/passport', () => ({ AuthGuard: () => BaseConstructor }));
2424
describe('JwtAuthGuard', () => {
2525
let guard: JwtAuthGuard;
2626

27+
let abilityFactory: AbilityFactory;
2728
let loggingService: MockedInstance<LoggingService>;
2829
let reflector: MockedInstance<Reflector>;
2930

@@ -39,6 +40,7 @@ describe('JwtAuthGuard', () => {
3940
loggingService = MockFactory.createMock(LoggingService);
4041
reflector = MockFactory.createMock(Reflector);
4142
guard = new JwtAuthGuard(loggingService as any, reflector);
43+
abilityFactory = new AbilityFactory(loggingService as unknown as LoggingService);
4244
});
4345

4446
it('should extend BaseConstructor', () => {
@@ -94,7 +96,7 @@ describe('JwtAuthGuard', () => {
9496
subject: 'all'
9597
} satisfies RouteAccessType);
9698
BaseConstructor.prototype.canActivate.mockResolvedValueOnce(true);
97-
const ability = AbilityFactory.prototype.createForPermissions([{ action: 'manage', subject: 'Cat' }]);
99+
const ability = abilityFactory.createForPermissions([{ action: 'manage', subject: 'Cat' }]);
98100
getRequest.mockReturnValueOnce({ url: 'http://localhost:5500', user: { ability } });
99101
await expect(guard.canActivate(context)).resolves.toBe(false);
100102
});
@@ -111,7 +113,7 @@ describe('JwtAuthGuard', () => {
111113
}
112114
] satisfies RouteAccessType);
113115
BaseConstructor.prototype.canActivate.mockResolvedValueOnce(true);
114-
const ability = AbilityFactory.prototype.createForPermissions([{ action: 'read', subject: 'Cat' }]);
116+
const ability = abilityFactory.createForPermissions([{ action: 'read', subject: 'Cat' }]);
115117
getRequest.mockReturnValueOnce({ url: 'http://localhost:5500', user: { ability } });
116118
await expect(guard.canActivate(context)).resolves.toBe(false);
117119
});
@@ -122,7 +124,7 @@ describe('JwtAuthGuard', () => {
122124
subject: 'all'
123125
} satisfies RouteAccessType);
124126
BaseConstructor.prototype.canActivate.mockResolvedValueOnce(true);
125-
const ability = AbilityFactory.prototype.createForPermissions([{ action: 'manage', subject: 'all' }]);
127+
const ability = abilityFactory.createForPermissions([{ action: 'manage', subject: 'all' }]);
126128
getRequest.mockReturnValueOnce({ url: 'http://localhost:5500', user: { ability } });
127129
await expect(guard.canActivate(context)).resolves.toBe(true);
128130
});

0 commit comments

Comments
 (0)