Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,20 @@ REFERRAL_RATE_LIMIT_MAX_ATTEMPTS=10
REFERRAL_ENABLE_BOT_DETECTION=true
# Enable VPN/Proxy detection (requires external service)
REFERRAL_ENABLE_VPN_DETECTION=false


# OAuth Configuration
AUTH_OAUTH_ENABLED=true
OAUTH_REDIRECT_URI=http://localhost:3000/auth/oauth/callback

# Google OAuth
OAUTH_GOOGLE_CLIENT_ID=your_google_client_id
OAUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret

# GitHub OAuth
OAUTH_GITHUB_CLIENT_ID=your_github_client_id
OAUTH_GITHUB_CLIENT_SECRET=your_github_client_secret

# Twitter/X OAuth
OAUTH_TWITTER_CLIENT_ID=your_twitter_client_id
OAUTH_TWITTER_CLIENT_SECRET=your_twitter_client_secret
7 changes: 6 additions & 1 deletion src/core/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { SocialAccount } from "./entities/social-account.entity";
import { OAuthController } from "./oauth.controller";
import { AuditLogService } from "src/infrastructure/audit/audit-log.service";
import { Module, OnModuleInit } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
Expand Down Expand Up @@ -74,10 +77,11 @@ import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity";
Wallet,
RefreshToken,
TwoFactorAuth,
SocialAccount,
]),
AuditModule,
],
controllers: [AuthController],
controllers: [AuthController, OAuthController],
providers: [
// Legacy services (for backward compatibility)
AuthService,
Expand All @@ -102,6 +106,7 @@ import { RefreshToken, TwoFactorAuth } from "./entities/auth.entity";
ApiKeyStrategy,
StrategyAuthGuard,
AdminTwoFactorGuard,
AuditLogService,
],
exports: [
// Legacy exports
Expand Down
1 change: 1 addition & 0 deletions src/core/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("AuthService", () => {
referredById: null,
referredBy: null,
referrals: [],
socialAccounts: [],
};

const mockJwtService = {
Expand Down
26 changes: 26 additions & 0 deletions src/core/auth/dto/oauth.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsNotEmpty, IsOptional } from "class-validator";

export class OAuthCallbackDto {
@ApiProperty({ description: "Authorization code from OAuth provider" })
@IsString()
@IsNotEmpty()
code: string;

@ApiProperty({ description: "State parameter for CSRF protection", required: false })
@IsString()
@IsOptional()
state?: string;
}

export class OAuthLinkDto {
@ApiProperty({ description: "Authorization code from OAuth provider" })
@IsString()
@IsNotEmpty()
code: string;

@ApiProperty({ description: "State parameter", required: false })
@IsString()
@IsOptional()
state?: string;
}
57 changes: 57 additions & 0 deletions src/core/auth/entities/social-account.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
Unique,
} from "typeorm";
import { User } from "src/core/user/entities/user.entity";

export enum SocialProvider {
GOOGLE = "google",
GITHUB = "github",
TWITTER = "twitter",
}

@Entity("social_accounts")
@Unique(["provider", "providerUserId"])
export class SocialAccount {
@PrimaryGeneratedColumn("uuid")
id: string;

@Column({ type: "uuid" })
@Index()
userId: string;

@ManyToOne(() => User, (user) => user.socialAccounts, { onDelete: "CASCADE" })
@JoinColumn({ name: "userId" })
user: User;

@Column({ type: "varchar" })
provider: SocialProvider;

@Column()
providerUserId: string;

@Column({ nullable: true })
email: string | null;

@Column({ nullable: true })
displayName: string | null;

@Column({ nullable: true })
avatarUrl: string | null;

@Column({ default: false })
emailVerified: boolean;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
99 changes: 99 additions & 0 deletions src/core/auth/oauth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
Controller,
Post,
Get,
Delete,
Param,
Body,
UseGuards,
Request,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from "@nestjs/swagger";
import { OAuthStrategy } from "./strategies/oauth/oauth.strategy";
import { JwtAuthGuard } from "./jwt.guard";
import { Public } from "src/common/decorators/public.decorator";
import { OAuthCallbackDto, OAuthLinkDto } from "./dto/oauth.dto";
import { v4 as uuidv4 } from "uuid";

@ApiTags("OAuth / Social Auth")
@Controller("auth/oauth")
export class OAuthController {
constructor(private readonly oauthStrategy: OAuthStrategy) {}

@Public()
@Get(":provider")
@ApiOperation({ summary: "Get OAuth authorization URL for a provider" })
@ApiParam({ name: "provider", enum: ["google", "github", "twitter"] })
@ApiResponse({ status: 200, description: "Returns the authorization URL" })
getAuthorizationUrl(@Param("provider") provider: string) {
const state = uuidv4();
const url = this.oauthStrategy.getAuthorizationUrl(provider, state);
return { url, state };
}

@Public()
@Post(":provider/callback")
@ApiOperation({ summary: "Handle OAuth callback and issue JWT" })
@ApiParam({ name: "provider", enum: ["google", "github", "twitter"] })
@ApiResponse({ status: 200, description: "Returns JWT token and user info" })
@ApiResponse({ status: 401, description: "OAuth authentication failed" })
async handleCallback(
@Param("provider") provider: string,
@Body() dto: OAuthCallbackDto,
) {
return this.oauthStrategy.authenticate({ provider, code: dto.code, state: dto.state });
}

@Post(":provider/link")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Link a social provider to your existing account" })
@ApiParam({ name: "provider", enum: ["google", "github", "twitter"] })
@ApiResponse({ status: 200, description: "Social account linked successfully" })
async linkProvider(
@Param("provider") provider: string,
@Body() dto: OAuthLinkDto,
@Request() req,
) {
const userId = req.user.sub || req.user.id;
return this.oauthStrategy.linkProvider(userId, provider, dto.code);
}

@Delete(":provider/unlink")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Unlink a social provider from your account" })
@ApiParam({ name: "provider", enum: ["google", "github", "twitter"] })
@ApiResponse({ status: 200, description: "Social account unlinked successfully" })
async unlinkProvider(
@Param("provider") provider: string,
@Request() req,
) {
const userId = req.user.sub || req.user.id;
return this.oauthStrategy.unlinkProvider(userId, provider);
}

@Get("providers/linked")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Get all linked social providers for current user" })
@ApiResponse({ status: 200, description: "List of linked providers" })
async getLinkedProviders(@Request() req) {
const userId = req.user.sub || req.user.id;
return this.oauthStrategy.getLinkedProviders(userId);
}

@Public()
@Get("providers/available")
@ApiOperation({ summary: "Get list of available OAuth providers" })
@ApiResponse({ status: 200, description: "List of enabled providers" })
getAvailableProviders() {
return { providers: this.oauthStrategy.getAvailableProviders() };
}
}
Loading
Loading