Skip to content

Commit b53e618

Browse files
authored
Add Supabase Authorization on backend (#5)
- Add `signIn`, `signOut`, `signUp`, `me` endpoints - Add `AuthGuard` to guard endpoints where users should be authenticated. To use, just add this `@UseGuards(AuthGuard('supabase'))` - Created profiles database with following columns: <img width="972" alt="image" src="https://github.com/user-attachments/assets/fba00cd9-32cf-4b7e-aac6-9432b3daf486"> - Added Row Level Security for selecting, inserting and modifying - Uses Supabase generated types and added a `update-types` command (can automate in workflow in future)
1 parent c109833 commit b53e618

File tree

14 files changed

+706
-34
lines changed

14 files changed

+706
-34
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
SUPABASE_URL=your-supabase-url
2+
SUPABASE_KEY=your-supabase-key

project/apps/api-gateway/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,32 @@
1818
"test:watch": "jest --watch",
1919
"test:cov": "jest --coverage",
2020
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
21-
"test:e2e": "jest --config ./test/jest-e2e.json"
21+
"test:e2e": "jest --config ./test/jest-e2e.json",
22+
"update-types": "npx supabase gen types --lang=typescript --project-id kamxbsekjfdzemvoevgz > src/supabase/database.types.ts"
2223
},
2324
"dependencies": {
2425
"@repo/pipes": "workspace:*",
2526
"@repo/dtos": "workspace:*",
2627
"@repo/eslint-config": "workspace:*",
2728
"@nestjs/common": "^10.0.0",
29+
"@nestjs/config": "^3.2.3",
2830
"@nestjs/core": "^10.0.0",
2931
"@nestjs/microservices": "^10.4.3",
3032
"@nestjs/platform-express": "^10.0.0",
33+
"@repo/dtos": "workspace:*",
34+
"@repo/pipes": "workspace:*",
35+
"@supabase/supabase-js": "^2.45.4",
36+
"cookie-parser": "^1.4.6",
37+
"nestjs-pino": "^4.1.0",
38+
"pino-pretty": "^11.2.2",
3139
"reflect-metadata": "^0.2.0",
3240
"rxjs": "^7.8.1"
3341
},
3442
"devDependencies": {
3543
"@nestjs/cli": "^10.0.0",
3644
"@nestjs/schematics": "^10.0.0",
3745
"@nestjs/testing": "^10.0.0",
46+
"@types/cookie-parser": "^1.4.7",
3847
"@types/express": "^4.17.17",
3948
"@types/jest": "^29.5.2",
4049
"@types/node": "^20.3.1",

project/apps/api-gateway/src/api-gateway.module.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
import { Module } from '@nestjs/common';
22
import { ClientsModule, Transport } from '@nestjs/microservices';
33
import { QuestionsController } from './questions/questions.controller';
4+
import { SupabaseService } from './supabase/supabase.service';
5+
import { ConfigModule } from '@nestjs/config';
6+
import { AuthController } from './auth/auth.controller';
7+
import { AuthService } from './auth/auth.service';
8+
import { LoggerModule } from 'nestjs-pino';
49

510
@Module({
611
imports: [
12+
ConfigModule.forRoot({
13+
isGlobal: true,
14+
}),
15+
LoggerModule.forRoot({
16+
pinoHttp: {
17+
transport: {
18+
target: 'pino-pretty',
19+
},
20+
},
21+
}),
722
// Client for Questions Service
823
ClientsModule.register([
924
{
@@ -15,6 +30,7 @@ import { QuestionsController } from './questions/questions.controller';
1530
},
1631
]),
1732
],
18-
controllers: [QuestionsController],
33+
controllers: [QuestionsController, AuthController],
34+
providers: [SupabaseService, AuthService],
1935
})
2036
export class ApiGatewayModule {}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
Body,
3+
Controller,
4+
Get,
5+
HttpStatus,
6+
Post,
7+
Req,
8+
Res,
9+
UsePipes,
10+
} from '@nestjs/common';
11+
12+
import {
13+
SignInDto,
14+
signInSchema,
15+
SignUpDto,
16+
signUpSchema,
17+
} from '@repo/dtos/auth';
18+
import { ZodValidationPipe } from '@repo/pipes/zod-validation-pipe.pipe';
19+
import { Request, Response } from 'express';
20+
import { AuthService } from './auth.service';
21+
22+
@Controller('auth')
23+
export class AuthController {
24+
constructor(private readonly authService: AuthService) {}
25+
26+
@Post('signup')
27+
@UsePipes(new ZodValidationPipe(signUpSchema))
28+
async signUp(@Body() body: SignUpDto, @Res() res: Response) {
29+
const { userData, session } = await this.authService.signUp(body);
30+
res.cookie('token', session.access_token, {
31+
httpOnly: true,
32+
secure: process.env.NODE_ENV === 'production',
33+
sameSite: 'lax',
34+
maxAge: 60 * 60 * 24 * 7 * 1000,
35+
});
36+
37+
return res.status(HttpStatus.OK).json({ userData });
38+
}
39+
40+
@Post('signin')
41+
@UsePipes(new ZodValidationPipe(signInSchema))
42+
async signIn(@Body() body: SignInDto, @Res() res: Response) {
43+
const { userData, session } = await this.authService.signIn(body);
44+
res.cookie('token', session.access_token, {
45+
httpOnly: true,
46+
secure: process.env.NODE_ENV === 'production',
47+
sameSite: 'lax',
48+
maxAge: 60 * 60 * 24 * 7 * 1000,
49+
});
50+
51+
return res.status(HttpStatus.OK).json({ userData });
52+
}
53+
54+
@Post('signout')
55+
async signOut(@Res() res: Response) {
56+
res.clearCookie('token');
57+
return res
58+
.status(HttpStatus.OK)
59+
.json({ message: 'Signed out successfully' });
60+
}
61+
62+
@Get('me')
63+
async me(@Req() request: Request, @Res() res: Response) {
64+
const token = request.cookies['token'];
65+
if (!token) {
66+
return res.status(HttpStatus.UNAUTHORIZED).json({ user: null });
67+
}
68+
const { userData } = await this.authService.me(token);
69+
return res.status(HttpStatus.OK).json({ userData });
70+
}
71+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
Injectable,
3+
CanActivate,
4+
ExecutionContext,
5+
UnauthorizedException,
6+
} from '@nestjs/common';
7+
import { SupabaseService } from '../supabase/supabase.service';
8+
9+
@Injectable()
10+
export class AuthGuard implements CanActivate {
11+
constructor(private readonly supabaseService: SupabaseService) {}
12+
13+
async canActivate(context: ExecutionContext): Promise<boolean> {
14+
const request = context.switchToHttp().getRequest();
15+
const token = request.cookies['token'];
16+
17+
if (!token) {
18+
throw new UnauthorizedException('No token found');
19+
}
20+
21+
const { data, error } = await this.supabaseService
22+
.getClient()
23+
.auth.getUser(token);
24+
25+
if (error || !data) {
26+
throw new UnauthorizedException('Invalid token');
27+
}
28+
29+
request.user = data;
30+
return true;
31+
}
32+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
Injectable,
3+
BadRequestException,
4+
UnauthorizedException,
5+
} from '@nestjs/common';
6+
import { SupabaseService } from '../supabase/supabase.service';
7+
import { SignInDto, SignUpDto } from '@repo/dtos/auth';
8+
import { UserDetails } from 'src/supabase/collection';
9+
10+
@Injectable()
11+
export class AuthService {
12+
constructor(private readonly supabaseService: SupabaseService) {}
13+
private readonly PROFILES_TABLE = 'profiles';
14+
15+
async me(token: string) {
16+
const { data, error } = await this.supabaseService
17+
.getClient()
18+
.auth.getUser(token);
19+
20+
if (error) {
21+
throw new UnauthorizedException(error.message);
22+
}
23+
const { user } = data;
24+
if (!user || !data) {
25+
throw new BadRequestException('Unexpected sign-in response.');
26+
}
27+
const { data: userData, error: profileError } = await this.supabaseService
28+
.getClient()
29+
.from(this.PROFILES_TABLE)
30+
.select(`id, email, username`)
31+
.eq('id', user.id)
32+
.returns<UserDetails[]>()
33+
.single();
34+
if (profileError) {
35+
throw new BadRequestException(profileError.message);
36+
}
37+
return { userData };
38+
}
39+
40+
async signUp(signUpDto: SignUpDto) {
41+
const { email, password, username } = signUpDto;
42+
43+
// Step 1: Create user in Supabase Auth
44+
const { data, error } = await this.supabaseService.getClient().auth.signUp({
45+
email,
46+
password,
47+
options: {
48+
data: {
49+
username,
50+
},
51+
},
52+
});
53+
if (error) {
54+
throw new BadRequestException(error.message);
55+
}
56+
const { user, session } = data;
57+
58+
if (!user || !session) {
59+
throw new BadRequestException('Unexpected error occured');
60+
}
61+
62+
// Step 2: Insert profile data into profiles table
63+
const { data: userData, error: profileError } = await this.supabaseService
64+
.getClient()
65+
.from(this.PROFILES_TABLE)
66+
.insert([
67+
{
68+
id: user.id,
69+
username,
70+
email,
71+
},
72+
])
73+
.returns<UserDetails[]>()
74+
.single();
75+
76+
if (profileError) {
77+
// Delete the created user if profile creation fails
78+
await this.supabaseService.getClient().auth.admin.deleteUser(user.id);
79+
throw new BadRequestException(profileError.message);
80+
}
81+
82+
// Return user and session information
83+
return { userData, session };
84+
}
85+
86+
async signIn(signInDto: SignInDto) {
87+
const { email, password } = signInDto;
88+
const { data, error } = await this.supabaseService
89+
.getClient()
90+
.auth.signInWithPassword({ email, password });
91+
92+
if (error) {
93+
throw new BadRequestException(error.message);
94+
}
95+
const { user, session } = data;
96+
if (!user || !data) {
97+
throw new BadRequestException('Unexpected sign-in response.');
98+
}
99+
100+
const { data: userData, error: profileError } = await this.supabaseService
101+
.getClient()
102+
.from(this.PROFILES_TABLE)
103+
.select(`id, email, username`)
104+
.eq('id', user.id)
105+
.returns<UserDetails[]>()
106+
.single();
107+
108+
if (profileError) {
109+
throw new BadRequestException(profileError.message);
110+
}
111+
112+
return { userData, session };
113+
}
114+
}

project/apps/api-gateway/src/main.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@ import { NestFactory } from '@nestjs/core';
22
import { ApiGatewayModule } from './api-gateway.module';
33
import { RpcExceptionFilter } from './filters/rpc-exception.filter';
44
import { RpcExceptionInterceptor } from './interceptors/rpc-exception.interceptor';
5+
import * as cookieParser from 'cookie-parser';
6+
import { Logger } from 'nestjs-pino';
57

68
async function bootstrap() {
7-
const app = await NestFactory.create(ApiGatewayModule);
9+
const app = await NestFactory.create(ApiGatewayModule, { bufferLogs: true });
10+
app.use(cookieParser());
11+
app.useLogger(app.get(Logger));
12+
app.enableCors({
13+
origin: 'http://localhost:3000',
14+
credentials: true,
15+
});
816
app.useGlobalFilters(new RpcExceptionFilter());
917
app.useGlobalInterceptors(new RpcExceptionInterceptor());
1018
await app.listen(4000);

project/apps/api-gateway/src/questions/questions.controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ import {
77
Inject,
88
Body,
99
Post,
10+
UseGuards,
1011
Put,
1112
Delete,
1213
Query,
1314
} from '@nestjs/common';
1415
import { ClientProxy } from '@nestjs/microservices';
15-
16+
import { AuthGuard } from 'src/auth/auth.guard';
1617
import { CreateQuestionDto, UpdateQuestionDto } from '@repo/dtos/questions';
1718

1819
@Controller('questions')
20+
@UseGuards(AuthGuard) // Can comment out if we dw auth for now
1921
export class QuestionsController {
2022
constructor(
2123
@Inject('QUESTIONS_SERVICE')
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import { Tables } from './database.types';
2+
export type UserDetails = Tables<'profiles'>;

0 commit comments

Comments
 (0)