diff --git a/README.md b/README.md index 5ceb8dc06..357a04bad 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,19 @@ This project requires a Redis-compatible key-value store. You'll need to: - Go to Authentication > Providers - Enable the providers you want to use (GitHub, Google, E-Mail) - Configure each provider with the appropriate credentials +6. Configure e-mail templates: + - Navigate to **Authentication → Templates** in the Supabase dashboard + - Update the URLs in the **Reset Password** and **Confirm Sign-Up** templates so that the CTA links point back to the dashboard's confirmation endpoint: + + **Reset Password** + ``` + {{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}&confirmation_url={{ .ConfirmationURL }} + ``` + + **Confirm Sign-Up** + ``` + {{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}&confirmation_url={{ .ConfirmationURL }} + ``` #### c. Database Setup 1. Apply the database migrations manually: diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index a9c04939c..482a0f20e 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -1,8 +1,9 @@ import { createClient } from '@/lib/clients/supabase/server' import { redirect } from 'next/navigation' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { logError, logInfo } from '@/lib/clients/logger' +import { logInfo } from '@/lib/clients/logger' import { ERROR_CODES } from '@/configs/logs' +import { encodedRedirect } from '@/lib/utils/auth' export async function GET(request: Request) { // The `/auth/callback` route is required for the server-side auth flow implemented @@ -26,11 +27,12 @@ export async function GET(request: Request) { const { data, error } = await supabase.auth.exchangeCodeForSession(code) if (error) { - logError( + console.error( ERROR_CODES.SUPABASE, 'Error exchanging code for session:', error ) + throw encodedRedirect('error', AUTH_URLS.SIGN_IN, error.message) } else { logInfo('OTP was successfully exchanged for user:', data.user.id) } diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts new file mode 100644 index 000000000..186f731fc --- /dev/null +++ b/src/app/api/auth/confirm/route.ts @@ -0,0 +1,136 @@ +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { logInfo, logError } from '@/lib/clients/logger' +import { createClient } from '@/lib/clients/supabase/server' +import { encodedRedirect } from '@/lib/utils/auth' +import { redirect } from 'next/navigation' +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' + +const confirmSchema = z.object({ + token_hash: z.string().min(1), + type: z.enum([ + 'signup', + 'recovery', + 'invite', + 'magiclink', + 'email', + 'email_change', + ]), + confirmation_url: z.string().url(), + next: z.string().url(), +}) + +const normalizeOrigin = (origin: string) => + origin.replace('www.', '').replace(/\/$/, '') + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + + const result = confirmSchema.safeParse({ + token_hash: searchParams.get('token_hash'), + type: searchParams.get('type'), + confirmation_url: searchParams.get('confirmation_url'), + next: searchParams.get('next'), + }) + + const dashboardSignInUrl = new URL(request.nextUrl.origin + AUTH_URLS.SIGN_IN) + + if (!result.success) { + logError('AUTH_CONFIRM_INVALID_PARAMS', { + errors: result.error.errors, + }) + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + 'Invalid Request' + ) + } + + const supabaseTokenHash = result.data.token_hash + const supabaseType = result.data.type + const supabaseClientFlowUrl = result.data.confirmation_url + const supabaseRedirectTo = result.data.next + + const dashboardUrl = request.nextUrl + + const isDifferentOrigin = + supabaseRedirectTo && + normalizeOrigin(new URL(supabaseRedirectTo).origin) !== + normalizeOrigin(dashboardUrl.origin) + + logInfo('AUTH_CONFIRM_INIT', { + supabase_token_hash: supabaseTokenHash + ? `${supabaseTokenHash.slice(0, 10)}...` + : null, + supabaseType, + supabaseRedirectTo, + isDifferentOrigin, + supabaseClientFlowUrl, + requestUrl: request.url, + origin: request.nextUrl.origin, + }) + + // when the next param is an absolute URL, with a different origin, + // we need to redirect to the supabase client flow url + if (isDifferentOrigin) { + throw redirect(supabaseClientFlowUrl!) + } + + try { + const next = + supabaseType === 'recovery' + ? `${request.nextUrl.origin}${PROTECTED_URLS.RESET_PASSWORD}` + : supabaseRedirectTo + + const redirectUrl = new URL(next) + + const response = NextResponse.redirect(redirectUrl) + const supabase = await createClient() + + const { error } = await supabase.auth.verifyOtp({ + type: supabaseType, + token_hash: supabaseTokenHash, + }) + + if (error) { + logError('AUTH_CONFIRM_ERROR', { + supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, + supabaseType, + supabaseRedirectTo, + redirectUrl: redirectUrl.toString(), + errorCode: error.code, + errorStatus: error.status, + errorMessage: error.message, + }) + + let errorMessage = 'Invalid Token' + if (error.status === 403 && error.code === 'otp_expired') { + errorMessage = 'Email link has expired. Please request a new one.' + } + + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + errorMessage + ) + } + + logInfo('AUTH_CONFIRM_SUCCESS', { + supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, + supabaseType, + supabaseRedirectTo, + redirectUrl: redirectUrl.toString(), + }) + + return response + } catch (e) { + logError('AUTH_CONFIRM_ERROR', { + error: e, + }) + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + 'Invalid Token' + ) + } +} diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index ae852809e..d2c8740de 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -3,7 +3,7 @@ import { PROTECTED_URLS } from '@/configs/urls' import { cookies } from 'next/headers' import { COOKIE_KEYS } from '@/configs/keys' import { supabaseAdmin } from '@/lib/clients/supabase/admin' -import { createRouteClient } from '@/lib/clients/supabase/server' +import { createClient } from '@/lib/clients/supabase/server' const TAB_URL_MAP: Record string> = { sandboxes: (teamId) => PROTECTED_URLS.SANDBOXES(teamId), @@ -28,7 +28,7 @@ export async function GET(request: NextRequest) { } // 2. Create Supabase client and get user - const supabase = createRouteClient(request) + const supabase = await createClient() const { data, error } = await supabase.auth.getUser() diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 901218971..be115c6ee 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -1,6 +1,5 @@ export const AUTH_URLS = { FORGOT_PASSWORD: '/forgot-password', - RESET_PASSWORD: '/dashboard/account/reset-password', SIGN_IN: '/sign-in', SIGN_UP: '/sign-up', CALLBACK: '/api/auth/callback', @@ -19,6 +18,7 @@ export const PROTECTED_URLS = { BILLING: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/billing`, BUDGET: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/budget`, KEYS: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/keys`, + RESET_PASSWORD: '/dashboard/account/reset-password', } export const BASE_URL = process.env.VERCEL_ENV diff --git a/src/lib/clients/supabase/server.ts b/src/lib/clients/supabase/server.ts index 731860a69..bc4908801 100644 --- a/src/lib/clients/supabase/server.ts +++ b/src/lib/clients/supabase/server.ts @@ -3,7 +3,7 @@ import 'server-cli-only' import { Database } from '@/types/database.types' import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' -import { NextRequest } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' export const createClient = async () => { const cookieStore = await cookies() @@ -23,7 +23,7 @@ export const createClient = async () => { }) } catch (error) { // The `set` method was called from a Server Component. - // This can be ignored if you have middleware refreshing + // This can be ignored since we have middleware refreshing // user sessions. } }, @@ -31,23 +31,3 @@ export const createClient = async () => { } ) } - -export const createRouteClient = (request: NextRequest) => - createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, - { - cookies: { - getAll() { - return request.cookies.getAll() - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value }) => - request.cookies.set(name, value) - ) - // This can be ignored if you have middleware refreshing - // user sessions. - }, - }, - } - ) diff --git a/src/middleware.ts b/src/middleware.ts index 0c7fcfdf4..2a8c6fdf4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -76,17 +76,6 @@ export async function middleware(request: NextRequest) { } ) - // Redirect to dashboard if user is logged in and on auth routes - if ( - isAuthRoute(request.nextUrl.pathname) && - (await supabase.auth.getSession()).data.session - ) { - return NextResponse.redirect( - new URL(PROTECTED_URLS.DASHBOARD, request.url) - ) - } - - // Refresh session and handle auth redirects const { error, data } = await getUserSession(supabase) // Handle authentication redirects diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 2341007e9..07e2ebc8f 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -14,6 +14,8 @@ import { shouldWarnAboutAlternateEmail, validateEmail, } from '@/server/auth/validate-email' +import { ERROR_CODES } from '@/configs/logs' +import { logInfo } from '@/lib/clients/logger' export const signInWithOAuthAction = actionClient .schema( @@ -30,6 +32,12 @@ export const signInWithOAuthAction = actionClient const origin = (await headers()).get('origin') + logInfo('SIGN_IN_WITH_OAUTH_ACTION', { + provider, + returnTo, + origin, + }) + const { data, error } = await supabase.auth.signInWithOAuth({ provider: provider, options: { @@ -164,13 +172,17 @@ export const forgotPasswordAction = actionClient .action(async ({ parsedInput }) => { const { email } = parsedInput const supabase = await createClient() - const origin = (await headers()).get('origin') - const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${origin}${AUTH_URLS.CALLBACK}?redirect_to=${AUTH_URLS.RESET_PASSWORD}`, - }) + const { error } = await supabase.auth.resetPasswordForEmail(email) if (error) { + console.error(ERROR_CODES.SUPABASE, 'Error resetting password:', error) + if (error.message.includes('security purposes')) { + return returnServerError( + 'Please wait before requesting another password reset' + ) + } + throw error } }) diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 682e65b7f..4333a67c5 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -184,7 +184,7 @@ export function getAuthRedirect( return NextResponse.redirect(buildRedirectUrl(AUTH_URLS.SIGN_IN, request)) } - if (request.nextUrl.pathname === '/' && isAuthenticated) { + if (isAuthRoute(request.nextUrl.pathname) && isAuthenticated) { return NextResponse.redirect( buildRedirectUrl(PROTECTED_URLS.DASHBOARD, request) )