diff --git a/packages/auth/src/adapter.ts b/packages/auth/src/adapter.ts index 88922e2..61d74a9 100644 --- a/packages/auth/src/adapter.ts +++ b/packages/auth/src/adapter.ts @@ -11,49 +11,12 @@ function createAdapter(): Adapter | undefined { } try { - const baseAdapter = DrizzleAdapter(db, { + return DrizzleAdapter(db, { usersTable: users, accountsTable: accounts, sessionsTable: sessions, verificationTokensTable: verificationTokens, }); - - // DEBUG: Wrap token methods with logging - const wrappedAdapter: Adapter = { - ...baseAdapter, - async createVerificationToken(data) { - console.log("[AUTH-DEBUG] createVerificationToken called:", { - identifier: data.identifier, - tokenHash: data.token?.substring(0, 20) + "...", - expires: data.expires, - }); - const result = await baseAdapter.createVerificationToken!(data); - console.log("[AUTH-DEBUG] createVerificationToken result:", result ? "SUCCESS" : "NULL"); - return result; - }, - async useVerificationToken(params) { - console.log("[AUTH-DEBUG] useVerificationToken called:", { - identifier: params.identifier, - tokenHash: params.token?.substring(0, 20) + "...", - }); - const result = await baseAdapter.useVerificationToken!(params); - console.log("[AUTH-DEBUG] useVerificationToken result:", result ? "FOUND" : "NOT_FOUND (THIS CAUSES VERIFICATION_FAILED)"); - if (!result) { - // Extra debug: check if any tokens exist for this identifier - const { eq } = await import("drizzle-orm"); - const existing = await db.select().from(verificationTokens).where(eq(verificationTokens.identifier, params.identifier)); - console.log("[AUTH-DEBUG] tokens for this identifier:", existing.length); - if (existing.length > 0) { - console.log("[AUTH-DEBUG] stored token starts with:", existing[0].token?.substring(0, 20)); - console.log("[AUTH-DEBUG] lookup token starts with:", params.token?.substring(0, 20)); - console.log("[AUTH-DEBUG] tokens match:", existing[0].token === params.token); - } - } - return result; - }, - }; - - return wrappedAdapter; } catch (error) { console.error("Auth adapter: Failed to create Drizzle adapter:", error); return undefined; diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index a3fd79e..0b1acf6 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -96,16 +96,19 @@ export const authConfig: NextAuthConfig = { const { createTransport } = await import("nodemailer"); const transport = createTransport(provider.server); + // Extract the raw token from NextAuth's callback URL const parsedUrl = new URL(url); const host = parsedUrl.host; + const token = parsedUrl.searchParams.get("token") || ""; + const callbackUrl = parsedUrl.searchParams.get("callbackUrl") || "/dashboard"; - // Build an intermediate /verify URL that prevents email scanners - // from consuming the one-time token via pre-fetch GET requests. - // We pass the ENTIRE original callback URL encoded to avoid - // dropping any parameters NextAuth needs internally. + // Build /verify URL — user clicks a button on this page to complete sign-in. + // This prevents email scanners from consuming the one-time token. + // The verify page redirects to our custom /api/auth/verify-email endpoint. const verifyUrl = new URL("/verify", parsedUrl.origin); - verifyUrl.searchParams.set("callback", Buffer.from(url).toString("base64")); + verifyUrl.searchParams.set("token", token); verifyUrl.searchParams.set("email", identifier); + verifyUrl.searchParams.set("callbackUrl", callbackUrl); const safeUrl = verifyUrl.toString(); const result = await transport.sendMail({ diff --git a/packages/db/scripts/diagnose-auth.ts b/packages/db/scripts/diagnose-auth.ts deleted file mode 100644 index 6b2ff51..0000000 --- a/packages/db/scripts/diagnose-auth.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; -import { createHash } from "crypto"; -import * as dotenv from "dotenv"; -import path from "path"; -import { eq, and } from "drizzle-orm"; - -dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); - -import { verificationTokens } from "../src/schemas"; - -const DATABASE_URL = process.env.DATABASE_URL; -const AUTH_SECRET = process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET; - -if (!DATABASE_URL) throw new Error("DATABASE_URL is not defined"); -if (!AUTH_SECRET) throw new Error("AUTH_SECRET/NEXTAUTH_SECRET is not defined"); - -const pool = new Pool({ connectionString: DATABASE_URL }); -const db = drizzle(pool); - -async function diagnose() { - console.log("=== NextAuth Email Verification Diagnostic ===\n"); - console.log(`AUTH_SECRET present: ${!!AUTH_SECRET}`); - console.log(`AUTH_SECRET (first 10 chars): ${AUTH_SECRET!.substring(0, 10)}...`); - console.log(`DATABASE_URL present: ${!!DATABASE_URL}\n`); - - // Step 1: Check existing tokens - console.log("--- Step 1: Checking existing verification tokens ---"); - const existingTokens = await db.select().from(verificationTokens); - console.log(`Found ${existingTokens.length} existing token(s).`); - for (const t of existingTokens) { - const isExpired = new Date(t.expires) < new Date(); - console.log(` identifier: ${t.identifier}, expired: ${isExpired}, token_hash_start: ${t.token.substring(0, 20)}...`); - } - - // Step 2: Simulate creating a verification token - console.log("\n--- Step 2: Simulating token creation ---"); - const rawToken = "test_diagnostic_token_12345"; - const testEmail = "diagnostic@test.com"; - const hashedToken = createHash("sha256") - .update(`${rawToken}${AUTH_SECRET}`) - .digest("hex"); - const expires = new Date(Date.now() + 86400 * 1000); // 24 hours - - console.log(`Raw token: ${rawToken}`); - console.log(`Hashed token: ${hashedToken}`); - console.log(`Identifier: ${testEmail}`); - console.log(`Expires: ${expires.toISOString()}`); - - try { - await db.insert(verificationTokens).values({ - identifier: testEmail, - token: hashedToken, - expires, - }); - console.log("✅ Token inserted successfully"); - } catch (err: any) { - console.log(`❌ Failed to insert token: ${err.message}`); - await pool.end(); - return; - } - - // Step 3: Simulate verification (lookup + delete) - console.log("\n--- Step 3: Simulating token verification ---"); - const reHashedToken = createHash("sha256") - .update(`${rawToken}${AUTH_SECRET}`) - .digest("hex"); - - console.log(`Re-hashed token: ${reHashedToken}`); - console.log(`Hashes match: ${hashedToken === reHashedToken}`); - - try { - const result = await db - .delete(verificationTokens) - .where( - and( - eq(verificationTokens.identifier, testEmail), - eq(verificationTokens.token, reHashedToken) - ) - ) - .returning(); - - if (result.length > 0) { - console.log("✅ Token found and deleted successfully — verification WOULD work!"); - } else { - console.log("❌ Token NOT found — verification WOULD fail!"); - console.log(" This means the hash comparison is failing in the DB"); - } - } catch (err: any) { - console.log(`❌ Error during verification: ${err.message}`); - } - - // Step 4: Re-check if the token was cleaned up - console.log("\n--- Step 4: Post-cleanup check ---"); - const remaining = await db.select().from(verificationTokens); - console.log(`Remaining tokens: ${remaining.length}`); - - console.log("\n=== Diagnostic complete ==="); - await pool.end(); -} - -diagnose().catch(console.error); diff --git a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts new file mode 100644 index 0000000..f1e1852 --- /dev/null +++ b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, verificationTokens, users, sessions } from "@query/db"; +import { eq, and } from "drizzle-orm"; + +/** + * Custom email verification endpoint that bypasses NextAuth's callback. + * + * NextAuth's built-in email callback fails silently in this deployment. + * This endpoint replicates the exact same logic: + * 1. Hash the raw token with AUTH_SECRET (Web Crypto SHA-256) + * 2. Look up the hashed token + identifier in the DB + * 3. Delete the token (one-time use) + * 4. Create a database session for the user + * 5. Set the session cookie and redirect to dashboard + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const rawToken = searchParams.get("token"); + const email = searchParams.get("email"); + const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"; + + if (!rawToken || !email) { + return NextResponse.redirect(new URL("/auth/error?error=Configuration", request.url)); + } + + const secret = process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || ""; + + try { + // Hash the token the same way @auth/core does (Web Crypto SHA-256) + const data = new TextEncoder().encode(`${rawToken}${secret}`); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashedToken = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + // Look up and delete the token (one-time use) + if (!db) { + return NextResponse.redirect(new URL("/auth/error?error=Configuration", request.url)); + } + + const result = await db + .delete(verificationTokens) + .where( + and( + eq(verificationTokens.identifier, email), + eq(verificationTokens.token, hashedToken) + ) + ) + .returning(); + + if (result.length === 0) { + // Token not found — expired, already used, or hash mismatch + return NextResponse.redirect(new URL("/auth/error?error=Verification", request.url)); + } + + const invite = result[0]; + + // Check expiry + if (new Date(invite.expires) < new Date()) { + return NextResponse.redirect(new URL("/auth/error?error=Verification", request.url)); + } + + // Find or create user + let user = await db + .select() + .from(users) + .where(eq(users.email, email)) + .then((r) => r[0] ?? null); + + if (!user) { + // Create new user + const newId = crypto.randomUUID(); + const inserted = await db + .insert(users) + .values({ id: newId, email, emailVerified: new Date() }) + .returning(); + user = inserted[0]; + } else if (!user.emailVerified) { + // Mark email as verified + await db + .update(users) + .set({ emailVerified: new Date() }) + .where(eq(users.id, user.id)); + } + + // Create a database session + const sessionToken = crypto.randomUUID(); + const sessionExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + + await db.insert(sessions).values({ + sessionToken, + userId: user.id, + expires: sessionExpires, + }); + + // Build redirect response with session cookie + const redirectUrl = new URL(callbackUrl, request.url); + const response = NextResponse.redirect(redirectUrl); + + // Set the session cookie (same name NextAuth uses) + const isProduction = process.env.NODE_ENV === "production"; + const cookieName = isProduction + ? "__Secure-authjs.session-token" + : "authjs.session-token"; + + response.cookies.set(cookieName, sessionToken, { + httpOnly: true, + sameSite: "lax", + secure: isProduction, + path: "/", + expires: sessionExpires, + }); + + return response; + } catch (error) { + console.error("[verify-email] Error:", error); + return NextResponse.redirect(new URL("/auth/error?error=Verification", request.url)); + } +} diff --git a/sites/mainweb/app/(portal)/api/debug-auth/route.ts b/sites/mainweb/app/(portal)/api/debug-auth/route.ts deleted file mode 100644 index 13643a8..0000000 --- a/sites/mainweb/app/(portal)/api/debug-auth/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { NextResponse } from "next/server"; -import { db, verificationTokens } from "@query/db"; -import { eq, and } from "drizzle-orm"; - -// Temporary debug endpoint — tests the complete token flow -// DELETE THIS AFTER DEBUGGING -export async function GET() { - const logs: string[] = []; - - try { - // Step 1: Generate a test token the same way NextAuth does (Web Crypto) - const rawToken = "debug_test_" + Date.now(); - const secret = process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || ""; - const testEmail = "debug_test@test.com"; - - logs.push(`Raw token: ${rawToken}`); - logs.push(`Secret first 5: ${secret.substring(0, 5)}`); - - // Hash using Web Crypto (same as @auth/core createHash) - const data = new TextEncoder().encode(`${rawToken}${secret}`); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashedToken = Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - - logs.push(`Hashed token: ${hashedToken.substring(0, 30)}...`); - - // Step 2: Insert token into DB - try { - await db.insert(verificationTokens).values({ - identifier: testEmail, - token: hashedToken, - expires: new Date(Date.now() + 86400000), - }); - logs.push("✅ Insert: SUCCESS"); - } catch (e: any) { - logs.push(`❌ Insert: FAILED — ${e.message}`); - return NextResponse.json({ logs, status: "INSERT_FAILED" }); - } - - // Step 3: Look up token (same as useVerificationToken) - try { - const result = await db - .delete(verificationTokens) - .where( - and( - eq(verificationTokens.identifier, testEmail), - eq(verificationTokens.token, hashedToken) - ) - ) - .returning(); - - if (result.length > 0) { - logs.push("✅ Lookup: FOUND AND DELETED — token flow WORKS"); - } else { - logs.push("❌ Lookup: NOT FOUND — this is the bug!"); - } - } catch (e: any) { - logs.push(`❌ Lookup: ERROR — ${e.message}`); - } - - // Step 4: Check if there are ANY tokens currently in the DB - const allTokens = await db.select().from(verificationTokens); - logs.push(`\nAll tokens in DB: ${allTokens.length}`); - for (const t of allTokens) { - const expired = new Date(t.expires) < new Date(); - logs.push(` - ${t.identifier} | hash: ${t.token.substring(0, 16)}... | expired: ${expired}`); - } - - return NextResponse.json({ logs, status: "COMPLETE" }); - - } catch (e: any) { - logs.push(`Fatal error: ${e.message}`); - return NextResponse.json({ logs, status: "ERROR" }); - } -} diff --git a/sites/mainweb/app/(portal)/verify/page.tsx b/sites/mainweb/app/(portal)/verify/page.tsx index b64e07c..f3db0db 100644 --- a/sites/mainweb/app/(portal)/verify/page.tsx +++ b/sites/mainweb/app/(portal)/verify/page.tsx @@ -8,22 +8,16 @@ function VerifyContent() { const searchParams = useSearchParams(); const [verifying, setVerifying] = useState(false); - // The full NextAuth callback URL is base64-encoded in the 'callback' param - const encodedCallback = searchParams?.get('callback') || ''; + const token = searchParams?.get('token') || ''; const email = searchParams?.get('email') || ''; - - let callbackUrl = ''; - try { - callbackUrl = atob(encodedCallback); - } catch { - // invalid base64 - } + const callbackUrl = searchParams?.get('callbackUrl') || '/dashboard'; const handleVerify = () => { - if (!callbackUrl) return; + if (!token) return; setVerifying(true); - // Redirect to the exact original NextAuth callback URL - window.location.href = callbackUrl; + // Redirect to our custom verification endpoint + const params = new URLSearchParams({ token, email, callbackUrl }); + window.location.href = `/api/auth/verify-email?${params.toString()}`; }; return ( @@ -57,14 +51,14 @@ function VerifyContent() {
- {!callbackUrl && ( + {!token && (

Error: Invalid or missing verification link. Please request a new sign-in link.