-
Notifications
You must be signed in to change notification settings - Fork 8
Add auth sign-in & sign-up for iiitl.ac.in domain #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4129789
eb2088c
002639c
36fa8ca
aa3a58f
5efc74a
a3a432b
74da903
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import { handlers } from "@/auth" | ||
|
|
||
| export const { GET, POST } = handlers |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,41 @@ | ||||||||||||||||||
| import { NextRequest, NextResponse } from "next/server"; | ||||||||||||||||||
| import clientPromise from "@/lib/mongodb"; | ||||||||||||||||||
| import { redisClient } from "@/lib/ratelimit"; | ||||||||||||||||||
|
|
||||||||||||||||||
| export async function GET(req: NextRequest) { | ||||||||||||||||||
| const token = req.nextUrl.searchParams.get("token"); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!token || !redisClient) { | ||||||||||||||||||
| return NextResponse.redirect(new URL("/login?error=InvalidToken", req.url)); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // 1. Retrieve pending data from Redis | ||||||||||||||||||
| const rawData = await redisClient.get(`verify_${token}`); | ||||||||||||||||||
| if (!rawData) { | ||||||||||||||||||
| return NextResponse.redirect(new URL("/login?error=TokenExpired", req.url)); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const pendingUser = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; | ||||||||||||||||||
|
|
||||||||||||||||||
| // 2. Atomic Save to MongoDB | ||||||||||||||||||
| const db = (await clientPromise).db(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Atomic upsert — safe under concurrent clicks / retries. | ||||||||||||||||||
| await db.collection("users").updateOne( | ||||||||||||||||||
| { email: pendingUser.email }, | ||||||||||||||||||
| { | ||||||||||||||||||
| $setOnInsert: { | ||||||||||||||||||
| ...pendingUser, | ||||||||||||||||||
| emailVerified: new Date(), | ||||||||||||||||||
| createdAt: new Date(), | ||||||||||||||||||
| }, | ||||||||||||||||||
| }, | ||||||||||||||||||
| { upsert: true } | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 3. Delete the token from Redis | ||||||||||||||||||
| await redisClient.del(`verify_${token}`); | ||||||||||||||||||
|
Comment on lines
+36
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make Redis token deletion best-effort. If Suggested fix- // 3. Delete the token from Redis
- await redisClient.del(`verify_${token}`);
+ // 3. Delete the token from Redis (best-effort cleanup)
+ try {
+ await redisClient.del(`verify_${token}`);
+ } catch (error) {
+ console.warn("Failed to delete verification token", error);
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| // 4. Redirect them to login | ||||||||||||||||||
| return NextResponse.redirect(new URL("/login?success=EmailVerified", req.url)); | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| "use server" | ||
|
|
||
| import { signIn } from "@/auth" | ||
| import { AuthError } from "next-auth" | ||
| import { redirect } from "next/navigation" | ||
| import { limit } from "@/lib/ratelimit" | ||
|
|
||
| export async function signInWithMagicLink(formData: FormData) { | ||
| const email = formData.get("email") as string; | ||
|
|
||
| if (!email || !/@iiitl\.ac\.in$/i.test(email)) { | ||
| redirect("/login?error=AccessDenied") | ||
| } | ||
|
|
||
| // Pre-check rate limits before calling Auth.js | ||
| const { success } = await limit(`magic_link_${email}`, { | ||
| maxRequests: 5, | ||
| window: '1h' | ||
| }); | ||
|
|
||
| if (!success) { | ||
| redirect("/login?error=AccessDenied") | ||
| } | ||
|
|
||
| try { | ||
| await signIn("email", { email, redirectTo: "/" }) | ||
| } catch (error) { | ||
| if (error instanceof AuthError) { | ||
| redirect("/login?error=AccessDenied") | ||
| } | ||
| throw error | ||
| } | ||
|
Comment on lines
+25
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In NextAuth.js (Auth.js) v5, throwing a custom AccessDenied or AuthError from the sendVerificationRequest function in an email provider is not directly supported for client-side propagation like in the Credentials provider's authorize callback. Throwing any Error from sendVerificationRequest will log a server-side error (typically [auth][error][SIGNIN_EMAIL_ERROR] or CallbackRouteError) but results in a client response of {error: "EmailSignin", status: 200, ok: true} instead of propagating the custom error to the client with ok: false. This is a known issue (open as of 2025). To throw custom errors: 1. Import { AuthError, AccessDenied } from "next-auth". 2. In your custom sendVerificationRequest: async sendVerificationRequest(params) { // Your validation logic if (someCondition) { throw new AccessDenied("Access denied: reason"); } // Send email... } However, due to the bug, the client receives generic "EmailSignin" instead of your custom message. Errors are caught server-side but not mapped to custom client responses for email providers. Workarounds: - Use server-side validation before calling signIn('email', { email }) and throw AuthError there (catchable client-side). - Custom error pages via pages: { error: '/custom-error' } handle generic errors. - For Credentials providers, extend specific errors like CredentialsSignin works better. Official docs confirm sendVerificationRequest expects Promise and throwing Error is standard for send failures, but client handling differs from authorize. Example config: providers: [{ id: "email", type: "email", async sendVerificationRequest({ identifier }) { if (!allowEmail(identifier)) { throw new AccessDenied("Email not allowed"); } // send email } }] Citations:
🏁 Script executed: find . -name "auth.ts" -o -name "auth.config.ts" | head -20Repository: iiitl/alumni Length of output: 65 🏁 Script executed: find . -name "actions.ts" | grep -i loginRepository: iiitl/alumni Length of output: 78 🏁 Script executed: cat -n ./auth.tsRepository: iiitl/alumni Length of output: 5392 🏁 Script executed: cat -n ./app/login/actions.tsRepository: iiitl/alumni Length of output: 1847 Magic-link errors are silently discarded by NextAuth.js v5 internally. The Validate the email and rate limit before calling 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| export async function signInWithCredentials(formData: FormData) { | ||
| const email = formData.get("email") as string; | ||
| const password = formData.get("password") as string; | ||
|
|
||
| if (!email || !password || !/@iiitl\.ac\.in$/i.test(email)) { | ||
| redirect("/login?error=InvalidCredentials") | ||
| } | ||
|
Comment on lines
+35
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normalize email to lowercase before lookup/sign-in. The credentials provider in - const email = formData.get("email") as string;
+ const email = (formData.get("email") as string | null)?.trim().toLowerCase() ?? "";Apply the same in 🤖 Prompt for AI Agents |
||
|
|
||
| try { | ||
| await signIn("credentials", { email, password, redirectTo: "/" }) | ||
| } catch (error) { | ||
| if (error instanceof AuthError) { | ||
| // AuthError translates to invalid credentials | ||
| redirect("/login?error=InvalidCredentials") | ||
| } | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| export async function signInWithGoogle() { | ||
| try { | ||
| await signIn("google", { redirectTo: "/" }) | ||
| } catch (error) { | ||
| if (error instanceof AuthError) { | ||
| redirect("/login?error=AccessDenied") | ||
| } | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| "use client"; | ||
|
|
||
| // app/register/RegisterForm.tsx | ||
| import { useActionState } from "react"; | ||
| import { handleRegistration, signUpWithGoogle } from "@/app/register/actions"; | ||
|
|
||
| export default function RegisterForm() { | ||
| // useActionState takes your action and an initial state object | ||
| const [state, formAction, isPending] = useActionState(handleRegistration, null); | ||
|
|
||
| return ( | ||
| <form action={formAction} className="mt-6 grid gap-4 sm:grid-cols-2"> | ||
|
|
||
| {/* Display the message from the server action here! */} | ||
| {state?.message && ( | ||
| <div className={`sm:col-span-2 p-3 rounded-md text-sm ${state.success ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}> | ||
| {state.message} | ||
| </div> | ||
| )} | ||
|
|
||
| <div className="sm:col-span-2"> | ||
| <label htmlFor="name" className="text-sm font-medium">Full name</label> | ||
| <input type="text" id="name" name="name" required className="mt-1 h-10 w-full rounded-md border border-border bg-background px-3 text-sm" /> | ||
| </div> | ||
| <div className="sm:col-span-2"> | ||
| <label htmlFor="email" className="text-sm font-medium">IIITL email or roll no.</label> | ||
| <input type="email" id="email" name="email" required className="mt-1 h-10 w-full rounded-md border border-border bg-background px-3 text-sm" /> | ||
|
Comment on lines
+26
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clarify the identifier field.
🤖 Prompt for AI Agents |
||
| </div> | ||
| <div className="sm:col-span-2"> | ||
| <label htmlFor="password" className="text-sm font-medium">Password</label> | ||
| <input type="password" id="password" name="password" minLength={8} required className="mt-1 h-10 w-full rounded-md border border-border bg-background px-3 text-sm" /> | ||
| </div> | ||
| <div> | ||
| <label htmlFor="branch" className="text-sm font-medium">Branch</label> | ||
| <select id="branch" name="branch" className="mt-1 h-10 w-full rounded-md border border-border bg-background px-3 text-sm"> | ||
| <option value="CSE">CSE</option> | ||
| <option value="IT">IT</option> | ||
| <option value="ECE">ECE</option> | ||
| </select> | ||
| </div> | ||
| <div> | ||
| <label htmlFor="graduationYear" className="text-sm font-medium">Graduation year</label> | ||
| <input type="number" id="graduationYear" name="graduationYear" required className="mt-1 h-10 w-full rounded-md border border-border bg-background px-3 text-sm" /> | ||
| </div> | ||
|
|
||
| <div className="sm:col-span-2"> | ||
| <button type="submit" disabled={isPending} className="inline-flex h-11 w-full items-center justify-center rounded-md bg-brand text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50"> | ||
| {isPending ? "Creating..." : "Create account"} | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="flex gap-2 items-center justify-center w-full sm:col-span-2"> | ||
| <div className="h-0.5 bg-background-700 w-full"></div> | ||
| <div>or</div> | ||
| <div className="h-0.5 bg-background-700 w-full"></div> | ||
| </div> | ||
| <div className="sm:col-span-2"> | ||
| <button formAction={signUpWithGoogle} formNoValidate type="submit" className="inline-flex h-11 w-full items-center justify-center rounded-md bg-background border border-border text-sm font-semibold text-white hover:bg-background-700"> | ||
| <span className="h-5 w-5 mr-1"><img src="/google.svg" alt="" className="w-full" /></span> | ||
|
Check warning on line 59 in app/register/RegistrationForm.tsx
|
||
| Sign-up with Google | ||
| </button> | ||
| </div> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| </form> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,109 @@ | ||||||||||||||||||||||||||||||||
| "use server" | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| import { signIn } from "@/auth" | ||||||||||||||||||||||||||||||||
| import { AuthError } from "next-auth" | ||||||||||||||||||||||||||||||||
| import { redirect } from "next/navigation" | ||||||||||||||||||||||||||||||||
| import { headers } from "next/headers" | ||||||||||||||||||||||||||||||||
| import bcrypt from "bcryptjs"; | ||||||||||||||||||||||||||||||||
| import crypto from "crypto"; | ||||||||||||||||||||||||||||||||
| import { redisClient, limit } from "@/lib/ratelimit"; | ||||||||||||||||||||||||||||||||
| import { sendEmail } from "@/lib/email"; | ||||||||||||||||||||||||||||||||
| import clientPromise from "@/lib/mongodb"; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export async function handleRegistration(prevState: unknown, formData: FormData) { | ||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||
| const authUrl = process.env.AUTH_URL; | ||||||||||||||||||||||||||||||||
| if (!authUrl) throw new Error("Server configuration error: AUTH_URL is missing."); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const rawEmail = formData.get("email"); | ||||||||||||||||||||||||||||||||
| const rawPassword = formData.get("password"); | ||||||||||||||||||||||||||||||||
| const rawBranch = formData.get("branch"); | ||||||||||||||||||||||||||||||||
| const rawGradYear = formData.get("graduationYear"); | ||||||||||||||||||||||||||||||||
| const rawName = formData.get("name"); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // 1. Explicitly narrow formData values to strings and reject empty/files | ||||||||||||||||||||||||||||||||
| if (typeof rawEmail !== "string" || !rawEmail.trim()) throw new Error("Invalid email"); | ||||||||||||||||||||||||||||||||
| if (typeof rawPassword !== "string") throw new Error("Invalid password"); | ||||||||||||||||||||||||||||||||
| if (typeof rawBranch !== "string" || !rawBranch.trim()) throw new Error("Invalid branch"); | ||||||||||||||||||||||||||||||||
| if (typeof rawGradYear !== "string" || !rawGradYear.trim()) throw new Error("Invalid graduation year"); | ||||||||||||||||||||||||||||||||
| if (typeof rawName !== "string" || !rawName.trim()) throw new Error("Invalid name"); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const email = rawEmail.trim(); | ||||||||||||||||||||||||||||||||
| const password = rawPassword; | ||||||||||||||||||||||||||||||||
| const branch = rawBranch.trim(); | ||||||||||||||||||||||||||||||||
| const graduationYearStr = rawGradYear.trim(); | ||||||||||||||||||||||||||||||||
| const name = rawName.trim(); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (!/@iiitl\.ac\.in$/i.test(email)) throw new Error("Invalid domain. Must end with @iiitl.ac.in"); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // 2. Validate password complexity and graduation year | ||||||||||||||||||||||||||||||||
| if (password.length < 8) throw new Error("Password must be at least 8 characters long."); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const yearParsed = parseInt(graduationYearStr, 10); | ||||||||||||||||||||||||||||||||
| const currentYear = new Date().getFullYear(); | ||||||||||||||||||||||||||||||||
| // Assuming IIITL started recently, graduation years from ~2018 to few years ahead | ||||||||||||||||||||||||||||||||
| if (isNaN(yearParsed) || yearParsed < 2018 || yearParsed > currentYear + 8) { | ||||||||||||||||||||||||||||||||
| throw new Error("Invalid graduation year."); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // 4. Rate Limiting by IP and Email | ||||||||||||||||||||||||||||||||
| const headerList = await headers(); | ||||||||||||||||||||||||||||||||
| const ip = headerList.get("x-forwarded-for") || "unknown_ip"; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Throttle by IP (prevent spam bots) | ||||||||||||||||||||||||||||||||
| const ipLimit = await limit(`verify_ip_${ip}`, { maxRequests: 5, window: "1h" }); | ||||||||||||||||||||||||||||||||
| if (!ipLimit.success) throw new Error("Too many registration attempts. Please try again later."); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Throttle by Email (prevent inbox bombing) | ||||||||||||||||||||||||||||||||
| const emailLimit = await limit(`verify_email_${email}`, { maxRequests: 3, window: "1h" }); | ||||||||||||||||||||||||||||||||
| if (!emailLimit.success) throw new Error("Verification emails are being sent too quickly. Please check your inbox or wait an hour."); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // 3. Check existing user in MongoDB | ||||||||||||||||||||||||||||||||
| const db = (await clientPromise).db(); | ||||||||||||||||||||||||||||||||
| const existingUser = await db.collection("users").findOne({ email }); | ||||||||||||||||||||||||||||||||
| if (existingUser) { | ||||||||||||||||||||||||||||||||
| throw new Error("An account is already registered with this email. Please sign in instead."); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // 5. Hash the password and create pending user | ||||||||||||||||||||||||||||||||
| const hashedPassword = await bcrypt.hash(password, 10); | ||||||||||||||||||||||||||||||||
| const token = crypto.randomUUID(); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const pendingUser = { | ||||||||||||||||||||||||||||||||
| email, | ||||||||||||||||||||||||||||||||
| name, | ||||||||||||||||||||||||||||||||
| branch, | ||||||||||||||||||||||||||||||||
| graduationYear: yearParsed, | ||||||||||||||||||||||||||||||||
| hashedPassword, | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| await redisClient?.set(`verify_${token}`, JSON.stringify(pendingUser), { ex: 24*60*60 }); | ||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent failure when Redis is unavailable.
+ if (!redisClient) {
+ throw new Error("Verification store unavailable");
+ }
- await redisClient?.set(`verify_${token}`, JSON.stringify(pendingUser), { ex: 24*60*60 });
+ await redisClient.set(`verify_${token}`, JSON.stringify(pendingUser), { ex: 24 * 60 * 60 });🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const verifyUrl = `${authUrl}/api/verify-email?token=${token}`; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| await sendEmail({ | ||||||||||||||||||||||||||||||||
| to: email, | ||||||||||||||||||||||||||||||||
| subject: "Verify your IIITL Alumni Account", | ||||||||||||||||||||||||||||||||
| text: `Click here to verify your account: ${verifyUrl}`, | ||||||||||||||||||||||||||||||||
| html: `<a href="${verifyUrl}">Verify Account</a>` | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return { success: true, message: "Check your email to verify your account." }; | ||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||
| if (error instanceof Error) { | ||||||||||||||||||||||||||||||||
| return { success: false, message: error.message }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| return { success: false, message: "Registration failed." }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+92
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not echo raw error messages to the client — violates issue Issue Return a generic message for all failures (including the domain check) and log the real error server-side. 🛡️ Proposed fix- } catch (error) {
- if (error instanceof Error) {
- return { success: false, message: error.message };
- }
- return { success: false, message: "Registration failed." };
- }
+ } catch (error) {
+ console.error({
+ severity: "WARNING",
+ event: "REGISTRATION_FAILED",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ // Always return a generic message to avoid leaking details / enabling enumeration.
+ return { success: false, message: "Unable to complete registration. Please try again." };
+ }Consider returning the same generic success-shaped message for the invalid-domain case as well so attackers can’t distinguish rejected domains from accepted ones via response timing/wording. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export async function signUpWithGoogle() { | ||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||
| await signIn("google", { redirectTo: "/" }) | ||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||
| if (error instanceof AuthError) { | ||||||||||||||||||||||||||||||||
| redirect("/login?error=AccessDenied") | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| throw error | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle Redis/JSON failures explicitly.
redisClient.get()andJSON.parse()can both throw here. A Redis outage or malformedverify_${token}payload will currently bubble up as a 500 and break the sign-up flow. Also validate the parsed payload before using it.Suggested fix
export async function GET(req: NextRequest) { const token = req.nextUrl.searchParams.get("token"); - if (!token || !redisClient) { + if (!token) { return NextResponse.redirect(new URL("/login?error=InvalidToken", req.url)); } + if (!redisClient) { + return NextResponse.redirect(new URL("/login?error=AuthUnavailable", req.url)); + } - // 1. Retrieve pending data from Redis - const rawData = await redisClient.get(`verify_${token}`); - if (!rawData) { - return NextResponse.redirect(new URL("/login?error=TokenExpired", req.url)); - } - - const pendingUser = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; + let pendingUser; + try { + const rawData = await redisClient.get(`verify_${token}`); + if (!rawData) { + return NextResponse.redirect(new URL("/login?error=TokenExpired", req.url)); + } + + pendingUser = typeof rawData === "string" ? JSON.parse(rawData) : rawData; + if (!pendingUser?.email || !pendingUser?.hashedPassword) { + return NextResponse.redirect(new URL("/login?error=InvalidToken", req.url)); + } + } catch (error) { + console.error("Failed to read or parse verification payload", error); + return NextResponse.redirect(new URL("/login?error=AuthUnavailable", req.url)); + }📝 Committable suggestion
🤖 Prompt for AI Agents