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
499 changes: 499 additions & 0 deletions docs/oauth-setup.md

Large diffs are not rendered by default.

1,752 changes: 1,471 additions & 281 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions packages/db/drizzle/0032_verify_existing_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Mark all existing users with credentials as verified
-- This ensures users who registered before email verification was required can still log in
-- We use created_at as the verification timestamp for existing users

UPDATE users
SET email_verified = created_at
WHERE email_verified IS NULL
AND id IN (
SELECT user_id FROM user_credentials
);
7 changes: 7 additions & 0 deletions packages/db/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@
"when": 1767667200000,
"tag": "0031_drop_climb_stats_fk",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1767753600000,
"tag": "0032_verify_existing_users",
"breakpoints": true
}
]
}
13 changes: 13 additions & 0 deletions packages/web/app/api/auth/providers-config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";

/**
* Returns which OAuth providers are configured.
* This allows the client to show/hide social login buttons appropriately.
*/
export async function GET() {
return NextResponse.json({
google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
apple: !!(process.env.APPLE_ID && process.env.APPLE_SECRET),
facebook: !!(process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET),
});
}
113 changes: 93 additions & 20 deletions packages/web/app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import * as schema from "@/app/lib/db/schema";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { sendVerificationEmail } from "@/app/lib/email/email-service";
import { checkRateLimit, getClientIp } from "@/app/lib/auth/rate-limiter";

const registerSchema = z.object({
email: z.string().email("Invalid email address"),
Expand All @@ -16,6 +18,22 @@ const registerSchema = z.object({

export async function POST(request: NextRequest) {
try {
// Rate limiting - 10 requests per minute per IP for registration
const clientIp = getClientIp(request);
const rateLimitResult = checkRateLimit(`register:${clientIp}`, 10, 60_000);

if (rateLimitResult.limited) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{
status: 429,
headers: {
"Retry-After": String(rateLimitResult.retryAfterSeconds),
},
}
);
}

const body = await request.json();

// Validate input
Expand Down Expand Up @@ -54,42 +72,97 @@ export async function POST(request: NextRequest) {

// User exists but has no credentials (e.g., OAuth user)
// They can add a password to their existing account
// OAuth users are pre-verified by their provider, so no email verification needed
const passwordHash = await bcrypt.hash(password, 12);
await db.insert(schema.userCredentials).values({
userId: existingUser[0].id,
passwordHash,

await db.transaction(async (tx) => {
await tx.insert(schema.userCredentials).values({
userId: existingUser[0].id,
passwordHash,
});

// Ensure user is marked as verified (OAuth provider already verified their email)
if (!existingUser[0].emailVerified) {
await tx
.update(schema.users)
.set({ emailVerified: new Date() })
.where(eq(schema.users.id, existingUser[0].id));
}
});

return NextResponse.json(
{ message: "Password added to existing account", userId: existingUser[0].id },
{ message: "Password added to existing account" },
{ status: 200 }
);
}

// Create new user
const userId = crypto.randomUUID();
const passwordHash = await bcrypt.hash(password, 12);
const verificationToken = crypto.randomUUID();
const tokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours

// Insert user
await db.insert(schema.users).values({
id: userId,
email,
name: name || email.split("@")[0],
});
// Use transaction to ensure user, credentials, profile, and token are created atomically
// If any insert fails, all changes are rolled back
try {
await db.transaction(async (tx) => {
// Insert user (emailVerified is null for unverified accounts)
await tx.insert(schema.users).values({
id: userId,
email,
name: name || email.split("@")[0],
emailVerified: null,
});

// Insert credentials
await db.insert(schema.userCredentials).values({
userId,
passwordHash,
});
// Insert credentials
await tx.insert(schema.userCredentials).values({
userId,
passwordHash,
});

// Create empty profile (user can customize later)
await db.insert(schema.userProfiles).values({
userId,
});
// Create empty profile (user can customize later)
await tx.insert(schema.userProfiles).values({
userId,
});

// Insert verification token
await tx.insert(schema.verificationTokens).values({
identifier: email,
token: verificationToken,
expires: tokenExpires,
});
});
} catch (insertError) {
// Handle race condition: another request created this user between our check and insert
// PostgreSQL unique constraint violation code is '23505'
if (insertError && typeof insertError === 'object' && 'code' in insertError && insertError.code === '23505') {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
);
}
throw insertError;
}

// Send verification email outside transaction (don't fail registration if email fails)
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
let emailSent = false;
try {
await sendVerificationEmail(email, verificationToken, baseUrl);
emailSent = true;
} catch (emailError) {
console.error("Failed to send verification email:", emailError);
// User is created, they can use resend functionality
}

return NextResponse.json(
{ message: "Account created successfully", userId },
{
message: emailSent
? "Account created. Please check your email to verify your account."
: "Account created. Please request a new verification email.",
requiresVerification: true,
emailSent,
},
{ status: 201 }
);
} catch (error) {
Expand Down
114 changes: 114 additions & 0 deletions packages/web/app/api/auth/resend-verification/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/app/lib/db/db";
import * as schema from "@/app/lib/db/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { sendVerificationEmail } from "@/app/lib/email/email-service";
import { checkRateLimit, getClientIp } from "@/app/lib/auth/rate-limiter";

// Zod schema for email validation
const resendVerificationSchema = z.object({
email: z.string().email("Invalid email address"),
});

// Minimum response time to prevent timing attacks
// Balances security (covering typical email send variance) with UX
const MIN_RESPONSE_TIME_MS = 1500;

// Helper to introduce consistent delay to prevent timing attacks
async function consistentDelay(startTime: number): Promise<void> {
const elapsed = Date.now() - startTime;
const remaining = MIN_RESPONSE_TIME_MS - elapsed;
if (remaining > 0) {
await new Promise((resolve) => setTimeout(resolve, remaining));
}
}

export async function POST(request: NextRequest) {
const startTime = Date.now();
const genericMessage = "If an account exists and needs verification, a verification email will be sent";

try {
// Rate limiting - 5 requests per minute per IP
const clientIp = getClientIp(request);
const rateLimitResult = checkRateLimit(`resend-verification:${clientIp}`, 5, 60_000);

if (rateLimitResult.limited) {
await consistentDelay(startTime);
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{
status: 429,
headers: {
"Retry-After": String(rateLimitResult.retryAfterSeconds),
},
}
);
}

const body = await request.json();

// Validate input with Zod
const validationResult = resendVerificationSchema.safeParse(body);
if (!validationResult.success) {
await consistentDelay(startTime);
return NextResponse.json(
{ error: validationResult.error.issues[0].message },
{ status: 400 }
);
}

const { email } = validationResult.data;
const db = getDb();

// Check if user exists and is unverified
const user = await db
.select()
.from(schema.users)
.where(eq(schema.users.email, email))
.limit(1);

// Don't reveal user status - return same message for all cases
// Use consistent delay for all paths to prevent timing attacks
if (user.length === 0 || user[0].emailVerified) {
await consistentDelay(startTime);
return NextResponse.json(
{ message: genericMessage },
{ status: 200 }
);
}

// Generate new token
const token = crypto.randomUUID();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours

// Delete existing tokens and create new one atomically
await db.transaction(async (tx) => {
await tx
.delete(schema.verificationTokens)
.where(eq(schema.verificationTokens.identifier, email));

await tx.insert(schema.verificationTokens).values({
identifier: email,
token,
expires,
});
});

const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
await sendVerificationEmail(email, token, baseUrl);

await consistentDelay(startTime);
return NextResponse.json(
{ message: genericMessage },
{ status: 200 }
);
} catch (error) {
console.error("Resend verification error:", error);
await consistentDelay(startTime);
return NextResponse.json(
{ error: "Failed to send verification email" },
{ status: 500 }
);
}
}
Loading
Loading