diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 0b1acf6..f9f79cc 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -96,17 +96,30 @@ 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 /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. + // Generate our own random token and store it directly in the DB. + // This bypasses NextAuth's internal token hashing which causes + // Verification_Failed errors in our deployment environment. + const { randomBytes } = await import("crypto"); + const customToken = randomBytes(32).toString("hex"); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + // Dynamically import DB to store our custom token + const { db, verificationTokens } = await import("@query/db"); + if (db) { + await db.insert(verificationTokens).values({ + identifier, + token: `custom:${customToken}`, + expires, + }); + } + + // Build /verify URL with our custom token const verifyUrl = new URL("/verify", parsedUrl.origin); - verifyUrl.searchParams.set("token", token); + verifyUrl.searchParams.set("token", customToken); verifyUrl.searchParams.set("email", identifier); verifyUrl.searchParams.set("callbackUrl", callbackUrl); const safeUrl = verifyUrl.toString(); diff --git a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts index 8882f3c..44ee338 100644 --- a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts +++ b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts @@ -3,42 +3,39 @@ import { db, verificationTokens, users, sessions } from "@query/db"; import { eq, and } from "drizzle-orm"; /** - * Custom email verification endpoint that bypasses NextAuth's callback. + * Custom email verification endpoint. + * + * Our sendVerificationRequest stores a separate token that we control. + * This endpoint looks up that token directly — no dependency on NextAuth's + * internal hashing mechanism. */ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - const rawToken = searchParams.get("token"); + const tokenParam = searchParams.get("token"); const email = searchParams.get("email"); const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"; - // Use NEXTAUTH_URL for redirects (request.url resolves to internal host on Cloud Run) const baseUrl = process.env.NEXTAUTH_URL || process.env.AUTH_URL || "https://datasciencegt.org"; - if (!rawToken || !email) { + if (!tokenParam || !email) { return NextResponse.redirect(`${baseUrl}/auth/error?error=Configuration`); } - 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(""); - if (!db) { return NextResponse.redirect(`${baseUrl}/auth/error?error=Configuration`); } - // Look up and delete the token (one-time use) + // Look up the token directly — our sendVerificationRequest stores + // the token value as-is (no hashing) with a "custom:" prefix + const customTokenValue = `custom:${tokenParam}`; + const result = await db .delete(verificationTokens) .where( and( eq(verificationTokens.identifier, email), - eq(verificationTokens.token, hashedToken) + eq(verificationTokens.token, customTokenValue) ) ) .returning();