Skip to content

Commit 2f28cc4

Browse files
authored
Merge pull request #46 from Adrianmjim/feat/add-auth-guard
feat(auth): Add BaseSupabaseAuthGuard
2 parents 656854e + dd73a42 commit 2f28cc4

File tree

4 files changed

+269
-2
lines changed

4 files changed

+269
-2
lines changed

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,32 @@ export class CatService {
213213
}
214214
```
215215

216+
## Authentication
217+
218+
This library provides a base guard to authenticate requests using the Supabase auth client.
219+
220+
### Usage
221+
To use the `BaseSupabaseAuthGuard`, you need to create a subclass that implements the `extractTokenFromRequest` method. This implementation should define how to extract the token from the request, which might be in the headers, cookies, or query parameters.
222+
223+
```typescript
224+
import { Injectable, ExecutionContext } from '@nestjs/common';
225+
import { SupabaseClient } from '@supabase/supabase-js';
226+
import { BaseSupabaseAuthGuard } from 'nestjs-supabase-js';
227+
228+
@Injectable()
229+
export class MyAuthGuard extends BaseSupabaseAuthGuard {
230+
public constructor(supabaseClient: SupabaseClient) {
231+
super(supabaseClient);
232+
}
233+
234+
protected extractTokenFromRequest(request: Request): string | undefined {
235+
return request.headers.authorization;
236+
}
237+
}
238+
```
239+
240+
Then, you can bind the guard as described in the [NestJS documentation](https://docs.nestjs.com/guards#binding-guards).
241+
216242
## 🤝 Contributing [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/adrianmjim/nestjs-supabase-js/issues)
217243

218244
Contributions, issues and feature requests are welcome.
@@ -233,4 +259,4 @@ Please ⭐️ this repository if this project helped you!
233259

234260
Copyright © 2024 [Adrián Martínez Jiménez](https://github.com/adrianmjim).
235261

236-
This project is licensed under the MIT License - see the [LICENSE file](LICENSE) for details.
262+
This project is licensed under the MIT License - see the [LICENSE file](LICENSE) for details.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { afterAll, beforeAll, describe, expect, it, Mock, Mocked, vitest } from 'vitest';
2+
3+
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
4+
import { HttpArgumentsHost } from '@nestjs/common/interfaces';
5+
import { AuthError, SupabaseClient, User } from '@supabase/supabase-js';
6+
import { SupabaseAuthClient } from '@supabase/supabase-js/dist/module/lib/SupabaseAuthClient';
7+
8+
import { BaseSupabaseAuthGuard } from './BaseSupabaseAuthGuard';
9+
10+
class TestSupabaseAuthGuard extends BaseSupabaseAuthGuard {
11+
public constructor(
12+
private readonly getToken: () => string | undefined,
13+
supabaseClient: SupabaseClient,
14+
) {
15+
super(supabaseClient);
16+
}
17+
18+
protected extractTokenFromRequest(_request: Request): (string | undefined) | Promise<string | undefined> {
19+
return this.getToken();
20+
}
21+
}
22+
23+
describe(BaseSupabaseAuthGuard.name, () => {
24+
let testSupabaseAuthGuard: TestSupabaseAuthGuard;
25+
let supabaseAuthClientMock: Mocked<SupabaseAuthClient>;
26+
let supabaseClientMock: Mocked<SupabaseClient>;
27+
let getTokenMock: Mock<() => string | undefined>;
28+
29+
beforeAll(() => {
30+
supabaseAuthClientMock = {
31+
getUser: vitest.fn(),
32+
} as Partial<Mocked<SupabaseAuthClient>> as Mocked<SupabaseAuthClient>;
33+
34+
supabaseClientMock = {
35+
auth: supabaseAuthClientMock,
36+
} as Partial<Mocked<SupabaseClient>> as Mocked<SupabaseClient>;
37+
38+
getTokenMock = vitest.fn();
39+
40+
testSupabaseAuthGuard = new TestSupabaseAuthGuard(getTokenMock, supabaseClientMock);
41+
});
42+
43+
describe('canActivate', () => {
44+
describe('when called and getToken() returns undefined', () => {
45+
let switchToHttpMock: Mocked<HttpArgumentsHost>;
46+
let executionContextMock: Mocked<ExecutionContext>;
47+
let result: unknown;
48+
49+
beforeAll(async () => {
50+
switchToHttpMock = {
51+
getRequest: vitest.fn(),
52+
} as Partial<Mocked<HttpArgumentsHost>> as Mocked<HttpArgumentsHost>;
53+
54+
executionContextMock = {
55+
switchToHttp: vitest.fn().mockReturnValueOnce(switchToHttpMock),
56+
} as Partial<Mocked<ExecutionContext>> as Mocked<ExecutionContext>;
57+
58+
try {
59+
await testSupabaseAuthGuard.canActivate(executionContextMock);
60+
} catch (error: unknown) {
61+
result = error;
62+
}
63+
});
64+
65+
afterAll(() => {
66+
vitest.clearAllMocks();
67+
});
68+
69+
it('should call executionContext.switchToHttp()', () => {
70+
expect(executionContextMock.switchToHttp).toHaveBeenCalledTimes(1);
71+
expect(executionContextMock.switchToHttp).toHaveBeenCalledWith();
72+
});
73+
74+
it('should call executionContext.switchToHttp().getRequest()', () => {
75+
expect(switchToHttpMock.getRequest).toHaveBeenCalledTimes(1);
76+
expect(switchToHttpMock.getRequest).toHaveBeenCalledWith();
77+
});
78+
79+
it('should call getToken()', () => {
80+
expect(getTokenMock).toHaveBeenCalledTimes(1);
81+
expect(getTokenMock).toHaveBeenCalledWith();
82+
});
83+
84+
it('should throw a UnauthorizedException', () => {
85+
expect(result).toBeInstanceOf(UnauthorizedException);
86+
expect(result).toHaveProperty('message', 'Unauthorized');
87+
});
88+
});
89+
90+
describe('when called and userResponse.error is not null', () => {
91+
let switchToHttpMock: Mocked<HttpArgumentsHost>;
92+
let executionContextMock: Mocked<ExecutionContext>;
93+
let tokenFixture: string;
94+
let result: unknown;
95+
96+
beforeAll(async () => {
97+
switchToHttpMock = {
98+
getRequest: vitest.fn(),
99+
} as Partial<Mocked<HttpArgumentsHost>> as Mocked<HttpArgumentsHost>;
100+
101+
executionContextMock = {
102+
switchToHttp: vitest.fn().mockReturnValueOnce(switchToHttpMock),
103+
} as Partial<Mocked<ExecutionContext>> as Mocked<ExecutionContext>;
104+
105+
tokenFixture = 'token';
106+
107+
getTokenMock.mockReturnValueOnce(tokenFixture);
108+
109+
supabaseAuthClientMock.getUser.mockResolvedValueOnce({
110+
data: { user: null },
111+
error: {} as AuthError,
112+
});
113+
114+
try {
115+
await testSupabaseAuthGuard.canActivate(executionContextMock);
116+
} catch (error: unknown) {
117+
result = error;
118+
}
119+
});
120+
121+
afterAll(() => {
122+
vitest.clearAllMocks();
123+
});
124+
125+
it('should call executionContext.switchToHttp()', () => {
126+
expect(executionContextMock.switchToHttp).toHaveBeenCalledTimes(1);
127+
expect(executionContextMock.switchToHttp).toHaveBeenCalledWith();
128+
});
129+
130+
it('should call executionContext.switchToHttp().getRequest()', () => {
131+
expect(switchToHttpMock.getRequest).toHaveBeenCalledTimes(1);
132+
expect(switchToHttpMock.getRequest).toHaveBeenCalledWith();
133+
});
134+
135+
it('should call getToken()', () => {
136+
expect(getTokenMock).toHaveBeenCalledTimes(1);
137+
expect(getTokenMock).toHaveBeenCalledWith();
138+
});
139+
140+
it('should call supabaseClient.auth.getUser()', () => {
141+
expect(supabaseClientMock.auth.getUser).toHaveBeenCalledTimes(1);
142+
expect(supabaseClientMock.auth.getUser).toHaveBeenCalledWith(tokenFixture);
143+
});
144+
145+
it('should throw a UnauthorizedException', () => {
146+
expect(result).toBeInstanceOf(UnauthorizedException);
147+
expect(result).toHaveProperty('message', 'Unauthorized');
148+
});
149+
});
150+
151+
describe('when called and userResponse.data is not null', () => {
152+
let switchToHttpMock: Mocked<HttpArgumentsHost>;
153+
let executionContextMock: Mocked<ExecutionContext>;
154+
let tokenFixture: string;
155+
let requestFixture: unknown;
156+
let result: unknown;
157+
158+
beforeAll(async () => {
159+
switchToHttpMock = {
160+
getRequest: vitest.fn(),
161+
} as Partial<Mocked<HttpArgumentsHost>> as Mocked<HttpArgumentsHost>;
162+
163+
executionContextMock = {
164+
switchToHttp: vitest.fn().mockReturnValueOnce(switchToHttpMock),
165+
} as Partial<Mocked<ExecutionContext>> as Mocked<ExecutionContext>;
166+
167+
tokenFixture = 'token';
168+
requestFixture = {};
169+
170+
switchToHttpMock.getRequest.mockReturnValueOnce(requestFixture);
171+
172+
getTokenMock.mockReturnValueOnce(tokenFixture);
173+
174+
supabaseAuthClientMock.getUser.mockResolvedValueOnce({
175+
data: { user: {} as User },
176+
error: null,
177+
});
178+
179+
result = await testSupabaseAuthGuard.canActivate(executionContextMock);
180+
});
181+
182+
afterAll(() => {
183+
vitest.clearAllMocks();
184+
});
185+
186+
it('should call executionContext.switchToHttp()', () => {
187+
expect(executionContextMock.switchToHttp).toHaveBeenCalledTimes(1);
188+
expect(executionContextMock.switchToHttp).toHaveBeenCalledWith();
189+
});
190+
191+
it('should call executionContext.switchToHttp().getRequest()', () => {
192+
expect(switchToHttpMock.getRequest).toHaveBeenCalledTimes(1);
193+
expect(switchToHttpMock.getRequest).toHaveBeenCalledWith();
194+
});
195+
196+
it('should call getToken()', () => {
197+
expect(getTokenMock).toHaveBeenCalledTimes(1);
198+
expect(getTokenMock).toHaveBeenCalledWith();
199+
});
200+
201+
it('should call supabaseClient.auth.getUser()', () => {
202+
expect(supabaseClientMock.auth.getUser).toHaveBeenCalledTimes(1);
203+
expect(supabaseClientMock.auth.getUser).toHaveBeenCalledWith(tokenFixture);
204+
});
205+
206+
it('should return true', () => {
207+
expect(result).toBeTruthy();
208+
});
209+
});
210+
});
211+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
2+
import { SupabaseClient, User, UserResponse } from '@supabase/supabase-js';
3+
4+
@Injectable()
5+
export abstract class BaseSupabaseAuthGuard implements CanActivate {
6+
public constructor(protected readonly supabaseClient: SupabaseClient) {}
7+
8+
public async canActivate(context: ExecutionContext): Promise<boolean> {
9+
const request: unknown = context.switchToHttp().getRequest();
10+
11+
const token: string | undefined = await this.extractTokenFromRequest(request);
12+
13+
if (token === undefined) {
14+
throw new UnauthorizedException();
15+
}
16+
17+
const userResponse: UserResponse = await this.supabaseClient.auth.getUser(token);
18+
19+
if (userResponse.error !== null) {
20+
throw new UnauthorizedException();
21+
}
22+
23+
(request as { user: User }).user = userResponse.data.user;
24+
25+
return true;
26+
}
27+
28+
protected abstract extractTokenFromRequest(request: unknown): (string | undefined) | Promise<string | undefined>;
29+
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { BaseSupabaseAuthGuard } from './guards/BaseSupabaseAuthGuard';
12
export { InjectSupabaseClient } from './decorators/InjectSupabaseClient';
23
export { NestSupabaseConfig } from './models/NestSupabaseConfig';
34
export { NestSupabaseConfigAsync } from './models/NestSupabaseConfigAsync';
@@ -6,4 +7,4 @@ export { NestSupabaseConfigFactory } from './models/NestSupabaseConfigFactory';
67
export { NestSupabaseConfigFactoryAsyncOptions } from './models/NestSupabaseConfigFactoryAsyncOptions';
78
export { NameSupabaseConfigPair } from './models/NameSupabaseConfigPair';
89
export { SupabaseConfig } from './models/SupabaseConfig';
9-
export { SupabaseModule } from './modules/SupabaseModule';
10+
export { SupabaseModule } from './modules/SupabaseModule';

0 commit comments

Comments
 (0)