project init
This commit is contained in:
43
apps/api/src/auth/auth.controller.ts
Normal file
43
apps/api/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { User } from '@coursecraft/database';
|
||||
import { ExchangeTokenDto } from './dto/exchange-token.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('exchange')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Exchange Supabase token for API token' })
|
||||
async exchangeToken(@Body() dto: ExchangeTokenDto) {
|
||||
const user = await this.authService.validateSupabaseToken(dto.supabaseToken);
|
||||
return this.authService.generateTokens(user);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get current user' })
|
||||
async getCurrentUser(@CurrentUser() user: User) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
subscriptionTier: user.subscriptionTier,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
40
apps/api/src/auth/auth.module.ts
Normal file
40
apps/api/src/auth/auth.module.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SupabaseService } from './supabase.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: '7d',
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
SupabaseService,
|
||||
JwtAuthGuard,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SupabaseService, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
65
apps/api/src/auth/auth.service.ts
Normal file
65
apps/api/src/auth/auth.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SupabaseService } from './supabase.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { User } from '@coursecraft/database';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string; // user id
|
||||
email: string;
|
||||
supabaseId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private supabaseService: SupabaseService,
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async validateSupabaseToken(token: string): Promise<User> {
|
||||
const supabaseUser = await this.supabaseService.verifyToken(token);
|
||||
|
||||
if (!supabaseUser) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// Find or create user in our database
|
||||
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
|
||||
|
||||
if (!user) {
|
||||
user = await this.usersService.create({
|
||||
supabaseId: supabaseUser.id,
|
||||
email: supabaseUser.email!,
|
||||
name: supabaseUser.user_metadata?.full_name || supabaseUser.user_metadata?.name || null,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async generateTokens(user: User) {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
supabaseId: user.supabaseId,
|
||||
};
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
subscriptionTier: user.subscriptionTier,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async validateJwtPayload(payload: JwtPayload): Promise<User | null> {
|
||||
return this.usersService.findById(payload.sub);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
11
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { User } from '@coursecraft/database';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof User | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user as User;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
}
|
||||
);
|
||||
4
apps/api/src/auth/decorators/public.decorator.ts
Normal file
4
apps/api/src/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
12
apps/api/src/auth/dto/exchange-token.dto.ts
Normal file
12
apps/api/src/auth/dto/exchange-token.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ExchangeTokenDto {
|
||||
@ApiProperty({
|
||||
description: 'Supabase access token',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
supabaseToken: string;
|
||||
}
|
||||
83
apps/api/src/auth/guards/jwt-auth.guard.ts
Normal file
83
apps/api/src/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { SupabaseService } from '../supabase.service';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { JwtPayload } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private jwtService: JwtService,
|
||||
private authService: AuthService,
|
||||
private supabaseService: SupabaseService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing or invalid authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
|
||||
try {
|
||||
// 1) Try our own JWT (from POST /auth/exchange)
|
||||
try {
|
||||
const payload = this.jwtService.verify<JwtPayload>(token);
|
||||
const user = await this.authService.validateJwtPayload(payload);
|
||||
if (user) {
|
||||
request.user = user;
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Not our JWT or expired — try Supabase below
|
||||
}
|
||||
|
||||
// 2) Fallback: Supabase access_token (for backward compatibility)
|
||||
const supabaseUser = await this.supabaseService.verifyToken(token);
|
||||
if (!supabaseUser) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
|
||||
if (!user) {
|
||||
user = await this.usersService.create({
|
||||
supabaseId: supabaseUser.id,
|
||||
email: supabaseUser.email!,
|
||||
name:
|
||||
supabaseUser.user_metadata?.full_name ||
|
||||
supabaseUser.user_metadata?.name ||
|
||||
null,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
|
||||
});
|
||||
}
|
||||
|
||||
request.user = user;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) throw error;
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
72
apps/api/src/auth/guards/supabase-auth.guard.ts
Normal file
72
apps/api/src/auth/guards/supabase-auth.guard.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { SupabaseService } from '../supabase.service';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class SupabaseAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private supabaseService: SupabaseService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Check if route is public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing or invalid authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
|
||||
try {
|
||||
// Validate token with Supabase
|
||||
const supabaseUser = await this.supabaseService.verifyToken(token);
|
||||
|
||||
if (!supabaseUser) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// Find or create user in our database
|
||||
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
|
||||
|
||||
if (!user) {
|
||||
// Auto-create user on first API call
|
||||
user = await this.usersService.create({
|
||||
supabaseId: supabaseUser.id,
|
||||
email: supabaseUser.email!,
|
||||
name:
|
||||
supabaseUser.user_metadata?.full_name ||
|
||||
supabaseUser.user_metadata?.name ||
|
||||
null,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
request.user = user;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
62
apps/api/src/auth/strategies/jwt.strategy.ts
Normal file
62
apps/api/src/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SupabaseService } from '../supabase.service';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private supabaseService: SupabaseService,
|
||||
private usersService: UsersService
|
||||
) {
|
||||
// Use Supabase JWT secret for validation
|
||||
const supabaseUrl = configService.get<string>('NEXT_PUBLIC_SUPABASE_URL');
|
||||
const jwtSecret = configService.get<string>('SUPABASE_JWT_SECRET') ||
|
||||
configService.get<string>('JWT_SECRET');
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: jwtSecret,
|
||||
// Pass the request to validate method
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(req: any, payload: any) {
|
||||
// Extract token from header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
throw new UnauthorizedException('No authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
|
||||
// Validate with Supabase
|
||||
const supabaseUser = await this.supabaseService.verifyToken(token);
|
||||
|
||||
if (!supabaseUser) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// Find or create user in our database
|
||||
let user = await this.usersService.findBySupabaseId(supabaseUser.id);
|
||||
|
||||
if (!user) {
|
||||
// Auto-create user on first API call
|
||||
user = await this.usersService.create({
|
||||
supabaseId: supabaseUser.id,
|
||||
email: supabaseUser.email!,
|
||||
name: supabaseUser.user_metadata?.full_name ||
|
||||
supabaseUser.user_metadata?.name ||
|
||||
null,
|
||||
avatarUrl: supabaseUser.user_metadata?.avatar_url || null,
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
56
apps/api/src/auth/supabase.service.ts
Normal file
56
apps/api/src/auth/supabase.service.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createClient, SupabaseClient, User as SupabaseUser } from '@supabase/supabase-js';
|
||||
|
||||
@Injectable()
|
||||
export class SupabaseService {
|
||||
private supabase: SupabaseClient;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const supabaseUrl = this.configService.get<string>('NEXT_PUBLIC_SUPABASE_URL');
|
||||
const supabaseServiceKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
throw new Error('Supabase configuration is missing');
|
||||
}
|
||||
|
||||
this.supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getClient(): SupabaseClient {
|
||||
return this.supabase;
|
||||
}
|
||||
|
||||
async verifyToken(token: string): Promise<SupabaseUser | null> {
|
||||
try {
|
||||
const { data, error } = await this.supabase.auth.getUser(token);
|
||||
|
||||
if (error || !data.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserById(userId: string): Promise<SupabaseUser | null> {
|
||||
try {
|
||||
const { data, error } = await this.supabase.auth.admin.getUserById(userId);
|
||||
|
||||
if (error || !data.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user