project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View 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,
};
}
}

View 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 {}

View 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);
}
}

View 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;
}
);

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View 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;
}

View 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');
}
}
}

View 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');
}
}
}

View 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;
}
}

View 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;
}
}
}