Skip to content

Commit 447b7eb

Browse files
committed
fix: add auth callback route to complete email verification PKCE flow
- Add /auth/callback route handler to exchange the PKCE code for a session when users click their verification email link - Fix emailRedirectTo to point to /auth/callback instead of /login - Add /auth/callback to middleware public routes so unauthenticated users arriving from verification emails are not redirected away - Show ?error= query param on login page for failed verification - Fix backend /auth/signup crash when response.session is None (returns 202 with requires_email_confirmation flag instead)
1 parent 3c04168 commit 447b7eb

File tree

5 files changed

+87
-12
lines changed

5 files changed

+87
-12
lines changed

backend/routes/auth.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from fastapi import APIRouter, HTTPException, status, Depends, Request
2+
from fastapi.responses import JSONResponse
23
from pydantic import BaseModel, EmailStr, Field
34
from config.supabase_client import get_supabase
45
from utils.auth import verify_user
@@ -84,15 +85,32 @@ async def signup(request: SignUpRequest, req: Request):
8485
status_code=status.HTTP_400_BAD_REQUEST,
8586
detail="Failed to create user account"
8687
)
87-
88+
89+
# When Supabase email confirmations are enabled, session is None until
90+
# the user clicks the verification link. Return 202 to indicate that the
91+
# account was created but is pending email confirmation.
92+
if response.session is None:
93+
return JSONResponse(
94+
status_code=status.HTTP_202_ACCEPTED,
95+
content={
96+
"message": "Account created. Please check your email to confirm your account.",
97+
"requires_email_confirmation": True,
98+
"user": {
99+
"id": str(response.user.id),
100+
"email": response.user.email,
101+
"created_at": str(response.user.created_at),
102+
},
103+
},
104+
)
105+
88106
return {
89107
"access_token": response.session.access_token,
90108
"token_type": "bearer",
91109
"user": {
92110
"id": response.user.id,
93111
"email": response.user.email,
94-
"created_at": response.user.created_at
95-
}
112+
"created_at": str(response.user.created_at),
113+
},
96114
}
97115

98116
except HTTPException:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createServerClient, type CookieOptions } from "@supabase/ssr";
2+
import { NextRequest, NextResponse } from "next/server";
3+
4+
export async function GET(request: NextRequest) {
5+
const requestUrl = new URL(request.url);
6+
const code = requestUrl.searchParams.get("code");
7+
8+
if (code) {
9+
// Build the response first so we can attach cookies to it
10+
const response = NextResponse.redirect(
11+
new URL("/dashboard", requestUrl.origin)
12+
);
13+
14+
const supabase = createServerClient(
15+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
16+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
17+
{
18+
cookies: {
19+
get(name: string) {
20+
return request.cookies.get(name)?.value;
21+
},
22+
set(name: string, value: string, options: CookieOptions) {
23+
response.cookies.set({ name, value, ...options });
24+
},
25+
remove(name: string, options: CookieOptions) {
26+
response.cookies.set({ name, value: "", ...options });
27+
},
28+
},
29+
}
30+
);
31+
32+
const { error } = await supabase.auth.exchangeCodeForSession(code);
33+
34+
if (!error) {
35+
return response;
36+
}
37+
}
38+
39+
// Verification failed or no code present — send user back to login with an error
40+
return NextResponse.redirect(
41+
new URL("/login?error=Email+verification+failed.+Please+try+again.", requestUrl.origin)
42+
);
43+
}

frontend/app/login/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useState, useEffect } from "react";
44
import { useAuth } from "@/lib/useAuth";
55
import { useRouter } from "next/navigation";
66
import { motion } from "framer-motion";
@@ -16,6 +16,15 @@ export default function LoginPage() {
1616
const { signIn } = useAuth();
1717
const router = useRouter();
1818

19+
// Show any error passed via query param (e.g. from the auth callback route)
20+
useEffect(() => {
21+
const params = new URLSearchParams(window.location.search);
22+
const errorParam = params.get("error");
23+
if (errorParam) {
24+
setError(errorParam);
25+
}
26+
}, []);
27+
1928
const handleLogin = async (e: React.FormEvent) => {
2029
e.preventDefault();
2130
setLoading(true);

frontend/lib/useAuth.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
5252
};
5353

5454
const signUp = async (email: string, password: string, username: string) => {
55-
// Get the current origin for redirect URL
56-
const redirectUrl = typeof window !== 'undefined'
57-
? `${window.location.origin}/login`
58-
: 'https://study-quest-mohi-devhubs-projects.vercel.app/login';
55+
// Get the current origin for redirect URL — must point to the auth callback
56+
// handler so the PKCE code can be exchanged for a session.
57+
const redirectUrl = typeof window !== 'undefined'
58+
? `${window.location.origin}/auth/callback`
59+
: 'https://study-quest-mohi-devhubs-projects.vercel.app/auth/callback';
5960

6061
const { data, error } = await supabase.auth.signUp({
6162
email,

frontend/middleware.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,14 @@ export async function middleware(req: NextRequest) {
6666
);
6767

6868
// Public routes that don't require authentication (includes root for landing page)
69-
const publicRoutes = ["/", "/landing"];
70-
const isPublicRoute =
71-
req.nextUrl.pathname === "/" ||
72-
publicRoutes.some((route) => req.nextUrl.pathname.startsWith(route + "/"));
69+
// /auth/callback must be public so the email-verification redirect can reach it
70+
// before a session exists.
71+
const publicRoutes = ["/", "/landing", "/auth/callback"];
72+
const isPublicRoute =
73+
req.nextUrl.pathname === "/" ||
74+
publicRoutes.some((route) =>
75+
req.nextUrl.pathname === route || req.nextUrl.pathname.startsWith(route + "/")
76+
);
7377

7478
// If user is not authenticated and trying to access protected route
7579
if (!session && !isPublicRoute && !isAuthRoute) {

0 commit comments

Comments
 (0)