From 6477b3b60e6dcc685f2dc6ff75e4b91d0be3fa80 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Thu, 3 Jul 2025 11:48:27 +0200 Subject: [PATCH 01/15] Refactor: Improve error logging in authentication flow This commit updates the error handling in the authentication process by replacing `logError` with `console.error` for better visibility of errors during code exchange and password reset actions. Additionally, it removes an unused import of `AUTH_URLS` to streamline the code. --- src/app/api/auth/callback/route.ts | 6 +++--- src/server/auth/auth-actions.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index a9c04939c..a794647a6 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -1,7 +1,7 @@ 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 { PROTECTED_URLS } from '@/configs/urls' +import { logInfo } from '@/lib/clients/logger' import { ERROR_CODES } from '@/configs/logs' export async function GET(request: Request) { @@ -26,7 +26,7 @@ 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 diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 2341007e9..8019793c8 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -14,6 +14,7 @@ import { shouldWarnAboutAlternateEmail, validateEmail, } from '@/server/auth/validate-email' +import { ERROR_CODES } from '@/configs/logs' export const signInWithOAuthAction = actionClient .schema( @@ -171,6 +172,13 @@ export const forgotPasswordAction = actionClient }) 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 } }) From 0335a02173a8fe6fa0e3c124ee6b5fa610113584 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 4 Jul 2025 14:00:16 +0200 Subject: [PATCH 02/15] wip: PKCE --- src/app/api/auth/confirm/route.ts | 38 +++++++++++++++++++++++++++++++ src/configs/urls.ts | 2 +- src/middleware.ts | 11 --------- src/server/auth/auth-actions.ts | 2 +- src/server/middleware.ts | 2 +- 5 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 src/app/api/auth/confirm/route.ts diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts new file mode 100644 index 000000000..57dd9e078 --- /dev/null +++ b/src/app/api/auth/confirm/route.ts @@ -0,0 +1,38 @@ +import { createClient, createRouteClient } from '@/lib/clients/supabase/server' +import { type EmailOtpType } from '@supabase/supabase-js' +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const token_hash = searchParams.get('token_hash') + const type = searchParams.get('type') as EmailOtpType | null + const next = searchParams.get('next') ?? '/' + const redirectTo = request.nextUrl.clone() + redirectTo.pathname = next + + console.log('Auth confirm route:', { + token_hash, + type, + next, + redirectTo: redirectTo.toString(), + }) + + if (token_hash && type) { + const supabase = createRouteClient(request) + + const { error } = await supabase.auth.verifyOtp({ + type, + token_hash, + }) + + console.log('OTP verification result:', { error }) + + if (!error) { + return NextResponse.redirect(redirectTo) + } + } + + // return the user to an error page with some instructions + redirectTo.pathname = '/auth/auth-code-error' + return NextResponse.redirect(redirectTo) +} 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/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 8019793c8..b830645f8 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -168,7 +168,7 @@ export const forgotPasswordAction = actionClient const origin = (await headers()).get('origin') const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${origin}${AUTH_URLS.CALLBACK}?redirect_to=${AUTH_URLS.RESET_PASSWORD}`, + redirectTo: `${origin}${AUTH_URLS.CALLBACK}?redirect_to=${PROTECTED_URLS.RESET_PASSWORD}`, }) if (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) ) From 5823dac12eea83c29aa0d45c90643bd41004bb44 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Fri, 4 Jul 2025 18:55:30 +0200 Subject: [PATCH 03/15] Refactor: Enhance authentication flow with improved redirect handling This commit updates the authentication confirmation route by introducing better handling of redirect URLs based on the type of authentication request. It adds logging for successful and error cases, ensuring clearer visibility of the authentication process. Additionally, the `createRouteClient` function is modified to accept an optional `NextResponse` parameter, allowing for more flexible cookie management during authentication operations. --- src/app/api/auth/confirm/route.ts | 54 ++++++++++++++++++------------ src/lib/clients/supabase/server.ts | 32 +++++++++++++----- src/server/auth/auth-actions.ts | 5 +-- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 57dd9e078..0093f33bd 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -1,4 +1,7 @@ -import { createClient, createRouteClient } from '@/lib/clients/supabase/server' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { logInfo } from '@/lib/clients/logger' +import { createRouteClient } from '@/lib/clients/supabase/server' +import { encodedRedirect } from '@/lib/utils/auth' import { type EmailOtpType } from '@supabase/supabase-js' import { NextRequest, NextResponse } from 'next/server' @@ -6,33 +9,42 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const token_hash = searchParams.get('token_hash') const type = searchParams.get('type') as EmailOtpType | null - const next = searchParams.get('next') ?? '/' - const redirectTo = request.nextUrl.clone() - redirectTo.pathname = next + const next = + type === 'recovery' + ? PROTECTED_URLS.RESET_PASSWORD + : (searchParams.get('next') ?? PROTECTED_URLS.DASHBOARD) - console.log('Auth confirm route:', { - token_hash, + const signInUrl = new URL(request.nextUrl.origin + AUTH_URLS.SIGN_IN) + const redirectUrl = new URL(request.nextUrl.origin + next) + + if (!token_hash || !type) + return encodedRedirect('error', signInUrl.toString(), 'Invalid Request') + + logInfo('AUTH_CONFIRM', { + token_hash: token_hash.slice(0, 10) + '...', type, next, - redirectTo: redirectTo.toString(), + redirectUrl: redirectUrl.toString(), }) - if (token_hash && type) { - const supabase = createRouteClient(request) - - const { error } = await supabase.auth.verifyOtp({ - type, - token_hash, - }) + const response = NextResponse.redirect(redirectUrl) + const supabase = createRouteClient(request, response) - console.log('OTP verification result:', { error }) + const { error } = await supabase.auth.verifyOtp({ type, token_hash }) - if (!error) { - return NextResponse.redirect(redirectTo) - } + if (error) { + console.error( + 'AUTH_CONFIRM', + { + token_hash: token_hash.slice(0, 10) + '...', + type, + next, + redirectUrl: redirectUrl.toString(), + }, + error + ) + return encodedRedirect('error', signInUrl.toString(), 'Invalid Token') } - // return the user to an error page with some instructions - redirectTo.pathname = '/auth/auth-code-error' - return NextResponse.redirect(redirectTo) + return response } diff --git a/src/lib/clients/supabase/server.ts b/src/lib/clients/supabase/server.ts index 731860a69..4c085083c 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. } }, @@ -32,7 +32,15 @@ export const createClient = async () => { ) } -export const createRouteClient = (request: NextRequest) => +/** + * Creates a Supabase client for route handlers and middleware + * @param response - Optional NextResponse to attach Set-Cookie headers from Supabase auth operations (verifyOtp, signInWithPassword, etc) + * If not provided, falls back to mutating request cookies which works for Server Components that refresh sessions via middleware + */ +export const createRouteClient = ( + request: NextRequest, + response?: NextResponse +) => createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, @@ -42,11 +50,19 @@ export const createRouteClient = (request: NextRequest) => 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. + if (response) { + cookiesToSet.forEach(({ name, value, options }) => { + if (options) { + response.cookies.set({ name, value, ...options }) + } else { + response.cookies.set(name, value) + } + }) + } else { + cookiesToSet.forEach(({ name, value, options }) => { + request.cookies.set(name, value) + }) + } }, }, } diff --git a/src/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index b830645f8..68777aa4a 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -165,11 +165,8 @@ 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=${PROTECTED_URLS.RESET_PASSWORD}`, - }) + const { error } = await supabase.auth.resetPasswordForEmail(email) if (error) { console.error(ERROR_CODES.SUPABASE, 'Error resetting password:', error) From 5fba558abace1aae65bd89e70593c2ec3312013d Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sat, 5 Jul 2025 19:43:06 +0200 Subject: [PATCH 04/15] Refactor: Enhance redirect handling in authentication confirmation route This commit improves the logic for determining redirect URLs in the authentication confirmation process. It introduces checks for absolute URLs and refines the handling of the 'next' parameter, ensuring that recovery requests without a specified destination correctly redirect to the password reset page. This change aims to streamline user navigation during authentication flows. --- src/app/api/auth/confirm/route.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 0093f33bd..c751f6f9e 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -9,13 +9,28 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const token_hash = searchParams.get('token_hash') const type = searchParams.get('type') as EmailOtpType | null - const next = - type === 'recovery' - ? PROTECTED_URLS.RESET_PASSWORD - : (searchParams.get('next') ?? PROTECTED_URLS.DASHBOARD) const signInUrl = new URL(request.nextUrl.origin + AUTH_URLS.SIGN_IN) - const redirectUrl = new URL(request.nextUrl.origin + next) + + const nextParam = searchParams.get('next') + const isAbsoluteNext = !!nextParam && /^https?:\/\//i.test(nextParam) + + let next: string + let redirectUrl: URL + + if (isAbsoluteNext) { + // absolute URLs take precedence over any other rule + next = nextParam as string + redirectUrl = new URL(next) + } else { + // when recovering without an explicit next destination, force RESET_PASSWORD + next = + type === 'recovery' && (!nextParam || nextParam.trim() === '') + ? PROTECTED_URLS.RESET_PASSWORD + : (nextParam ?? PROTECTED_URLS.DASHBOARD) + + redirectUrl = new URL(request.nextUrl.origin + next) + } if (!token_hash || !type) return encodedRedirect('error', signInUrl.toString(), 'Invalid Request') From be2549edcd743676ed03b70a14a96f03353fb721 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 12:41:56 +0200 Subject: [PATCH 05/15] Refactor: Improve OTP verification error handling in authentication confirmation route This commit enhances the error handling for OTP verification by providing more specific error messages based on the error status. It also updates the response cookie management to support domain-specific settings for absolute redirect URLs, improving the overall user experience during the authentication process. --- src/app/api/auth/confirm/route.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index c751f6f9e..3a7e0b666 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -45,7 +45,7 @@ export async function GET(request: NextRequest) { const response = NextResponse.redirect(redirectUrl) const supabase = createRouteClient(request, response) - const { error } = await supabase.auth.verifyOtp({ type, token_hash }) + const { data, error } = await supabase.auth.verifyOtp({ type, token_hash }) if (error) { console.error( @@ -58,7 +58,29 @@ export async function GET(request: NextRequest) { }, error ) - return encodedRedirect('error', signInUrl.toString(), 'Invalid Token') + + 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', signInUrl.toString(), errorMessage) + } + + if (isAbsoluteNext) { + const baseDomain = (() => { + const hostParts = redirectUrl.hostname.split('.') + return hostParts.length > 2 + ? hostParts.slice(-2).join('.') + : redirectUrl.hostname + })() + + response.cookies.getAll().forEach(({ name, value, ...options }) => { + response.cookies.set(name, value, { + ...options, + domain: `.${baseDomain}`, + }) + }) } return response From c0a0dc01e0e2c3e3b3de717c5ac724b8793d2990 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld <50748440+ben-fornefeld@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:04:24 +0200 Subject: [PATCH 06/15] Fix password-reset rate-limit UX & add robust email-confirm route (#91) This PR refactors our authentication flow to improve both user experience and security. Key changes - Hardened redirect logic in the auth callback & confirm routes (origin checks, graceful fallbacks). - Enhanced server-side logging for easier debugging of auth issues. --- src/app/api/auth/confirm/route.ts | 97 +++++++++++++++++++------------ 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 3a7e0b666..a2be0eb6f 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -1,42 +1,77 @@ -import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' -import { logInfo } from '@/lib/clients/logger' +import { AUTH_URLS, PROTECTED_URLS, BASE_URL } from '@/configs/urls' +import { logInfo, logError } from '@/lib/clients/logger' import { createRouteClient } from '@/lib/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' import { type EmailOtpType } from '@supabase/supabase-js' +import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const token_hash = searchParams.get('token_hash') const type = searchParams.get('type') as EmailOtpType | null + const confirmationUrl = searchParams.get('confirmation_url') const signInUrl = new URL(request.nextUrl.origin + AUTH_URLS.SIGN_IN) + const normalizeOrigin = (origin: string) => origin.replace('www.', '') + const nextParam = searchParams.get('next') - const isAbsoluteNext = !!nextParam && /^https?:\/\//i.test(nextParam) + + const isDifferentOrigin = + nextParam && + normalizeOrigin(new URL(nextParam).origin) !== + normalizeOrigin(request.nextUrl.origin) let next: string let redirectUrl: URL - if (isAbsoluteNext) { - // absolute URLs take precedence over any other rule + logInfo('AUTH_CONFIRM_INIT', { + token_hash: token_hash ? `${token_hash.slice(0, 10)}...` : null, + type, + nextParam, + isDifferentOrigin, + confirmationUrl, + requestUrl: request.url, + origin: request.nextUrl.origin, + }) + + if (isDifferentOrigin) { + if (confirmationUrl) { + logInfo('AUTH_CONFIRM_REDIRECT_CONFIRMATION', { + confirmationUrl, + }) + throw redirect(confirmationUrl) + } next = nextParam as string redirectUrl = new URL(next) } else { - // when recovering without an explicit next destination, force RESET_PASSWORD next = - type === 'recovery' && (!nextParam || nextParam.trim() === '') + type === 'recovery' ? PROTECTED_URLS.RESET_PASSWORD : (nextParam ?? PROTECTED_URLS.DASHBOARD) - redirectUrl = new URL(request.nextUrl.origin + next) + try { + redirectUrl = new URL(next) + } catch (e) { + logInfo('AUTH_CONFIRM_URL_FALLBACK', { + next, + error: e instanceof Error ? e.message : String(e), + }) + redirectUrl = new URL(request.nextUrl.origin + next) + } } - if (!token_hash || !type) + if (!token_hash || !type) { + logError('AUTH_CONFIRM_INVALID_PARAMS', { + token_hash: !!token_hash, + type: !!type, + }) return encodedRedirect('error', signInUrl.toString(), 'Invalid Request') + } - logInfo('AUTH_CONFIRM', { - token_hash: token_hash.slice(0, 10) + '...', + logInfo('AUTH_CONFIRM_VERIFY', { + token_hash: `${token_hash.slice(0, 10)}...`, type, next, redirectUrl: redirectUrl.toString(), @@ -45,19 +80,18 @@ export async function GET(request: NextRequest) { const response = NextResponse.redirect(redirectUrl) const supabase = createRouteClient(request, response) - const { data, error } = await supabase.auth.verifyOtp({ type, token_hash }) + const { error } = await supabase.auth.verifyOtp({ type, token_hash }) if (error) { - console.error( - 'AUTH_CONFIRM', - { - token_hash: token_hash.slice(0, 10) + '...', - type, - next, - redirectUrl: redirectUrl.toString(), - }, - error - ) + logError('AUTH_CONFIRM_ERROR', { + token_hash: `${token_hash.slice(0, 10)}...`, + type, + next, + redirectUrl: redirectUrl.toString(), + errorCode: error.code, + errorStatus: error.status, + errorMessage: error.message, + }) let errorMessage = 'Invalid Token' if (error.status === 403 && error.code === 'otp_expired') { @@ -67,21 +101,10 @@ export async function GET(request: NextRequest) { return encodedRedirect('error', signInUrl.toString(), errorMessage) } - if (isAbsoluteNext) { - const baseDomain = (() => { - const hostParts = redirectUrl.hostname.split('.') - return hostParts.length > 2 - ? hostParts.slice(-2).join('.') - : redirectUrl.hostname - })() - - response.cookies.getAll().forEach(({ name, value, ...options }) => { - response.cookies.set(name, value, { - ...options, - domain: `.${baseDomain}`, - }) - }) - } + logInfo('AUTH_CONFIRM_SUCCESS', { + type, + redirectUrl: redirectUrl.toString(), + }) return response } From d26c08b49137cab9bf5344bfc4433e5b92fe7269 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 15:27:08 +0200 Subject: [PATCH 07/15] Refactor: Enhance authentication confirmation route with improved parameter handling - Renaming variables for clarity and consistency. - Adding validation for required parameters before processing. - Streamlining the redirect logic based on the type of authentication request. --- src/app/api/auth/confirm/route.ts | 120 +++++++++++++++++------------- 1 file changed, 70 insertions(+), 50 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index a2be0eb6f..66fd7388c 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -6,73 +6,90 @@ import { type EmailOtpType } from '@supabase/supabase-js' import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' +const normalizeOrigin = (origin: string) => origin.replace('www.', '') + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) - const token_hash = searchParams.get('token_hash') - const type = searchParams.get('type') as EmailOtpType | null - const confirmationUrl = searchParams.get('confirmation_url') - const signInUrl = new URL(request.nextUrl.origin + AUTH_URLS.SIGN_IN) + const supabaseTokenHash = searchParams.get('token_hash') + const supabaseType = searchParams.get('type') as EmailOtpType | null + const supabaseClientFlowUrl = searchParams.get('confirmation_url') - const normalizeOrigin = (origin: string) => origin.replace('www.', '') + const dashboardUrl = request.nextUrl + const dashboardSignInUrl = new URL(request.nextUrl.origin + AUTH_URLS.SIGN_IN) - const nextParam = searchParams.get('next') + const supabaseRedirectTo = searchParams.get('next') const isDifferentOrigin = - nextParam && - normalizeOrigin(new URL(nextParam).origin) !== - normalizeOrigin(request.nextUrl.origin) - - let next: string - let redirectUrl: URL + supabaseRedirectTo && + normalizeOrigin(new URL(supabaseRedirectTo).origin) !== + normalizeOrigin(dashboardUrl.origin) logInfo('AUTH_CONFIRM_INIT', { - token_hash: token_hash ? `${token_hash.slice(0, 10)}...` : null, - type, - nextParam, + supabase_token_hash: supabaseTokenHash + ? `${supabaseTokenHash.slice(0, 10)}...` + : null, + supabaseType, + supabaseRedirectTo, isDifferentOrigin, - confirmationUrl, + supabaseClientFlowUrl, requestUrl: request.url, origin: request.nextUrl.origin, }) + if ( + !supabaseTokenHash || + !supabaseType || + !supabaseRedirectTo || + !supabaseClientFlowUrl + ) { + logError('AUTH_CONFIRM_INVALID_PARAMS', { + supabaseTokenHash: !!supabaseTokenHash, + supabaseType: !!supabaseType, + supabaseRedirectTo: !!supabaseRedirectTo, + supabaseClientFlowUrl: !!supabaseClientFlowUrl, + }) + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + 'Invalid Request' + ) + } + + // when the next param is an absolute URL, with a different origin, + // we need to redirect to the supabase client flow url if (isDifferentOrigin) { - if (confirmationUrl) { - logInfo('AUTH_CONFIRM_REDIRECT_CONFIRMATION', { - confirmationUrl, - }) - throw redirect(confirmationUrl) - } - next = nextParam as string + throw redirect(supabaseClientFlowUrl) + } + + let redirectUrl: URL + + const next = + supabaseType === 'recovery' + ? PROTECTED_URLS.RESET_PASSWORD + : (supabaseRedirectTo ?? PROTECTED_URLS.DASHBOARD) + + // try absolute url, else relative + try { redirectUrl = new URL(next) - } else { - next = - type === 'recovery' - ? PROTECTED_URLS.RESET_PASSWORD - : (nextParam ?? PROTECTED_URLS.DASHBOARD) - - try { - redirectUrl = new URL(next) - } catch (e) { - logInfo('AUTH_CONFIRM_URL_FALLBACK', { - next, - error: e instanceof Error ? e.message : String(e), - }) - redirectUrl = new URL(request.nextUrl.origin + next) - } + } catch (e) { + redirectUrl = new URL(request.nextUrl.origin + next) } - if (!token_hash || !type) { - logError('AUTH_CONFIRM_INVALID_PARAMS', { - token_hash: !!token_hash, - type: !!type, + if (!redirectUrl) { + logError('AUTH_CONFIRM_INVALID_NEXT', { + next, }) - return encodedRedirect('error', signInUrl.toString(), 'Invalid Request') + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + 'Invalid Next' + ) } logInfo('AUTH_CONFIRM_VERIFY', { - token_hash: `${token_hash.slice(0, 10)}...`, - type, + supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, + supabaseType, next, redirectUrl: redirectUrl.toString(), }) @@ -80,12 +97,15 @@ export async function GET(request: NextRequest) { const response = NextResponse.redirect(redirectUrl) const supabase = createRouteClient(request, response) - const { error } = await supabase.auth.verifyOtp({ type, token_hash }) + const { error } = await supabase.auth.verifyOtp({ + type: supabaseType, + token_hash: supabaseTokenHash, + }) if (error) { logError('AUTH_CONFIRM_ERROR', { - token_hash: `${token_hash.slice(0, 10)}...`, - type, + supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, + supabaseType, next, redirectUrl: redirectUrl.toString(), errorCode: error.code, @@ -98,11 +118,11 @@ export async function GET(request: NextRequest) { errorMessage = 'Email link has expired. Please request a new one.' } - return encodedRedirect('error', signInUrl.toString(), errorMessage) + return encodedRedirect('error', dashboardSignInUrl.toString(), errorMessage) } logInfo('AUTH_CONFIRM_SUCCESS', { - type, + supabaseType, redirectUrl: redirectUrl.toString(), }) From 68ae450478302f62078fb09e1cab1441e680b9b9 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 15:37:01 +0200 Subject: [PATCH 08/15] Refactor: Enhance parameter validation and error handling in authentication confirmation route - Introduced Zod schema for validating request parameters. - Improved error logging for invalid parameters and OTP verification. - Streamlined redirect logic based on authentication type and next URL handling. --- src/app/api/auth/confirm/route.ts | 166 ++++++++++++++++-------------- 1 file changed, 89 insertions(+), 77 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 66fd7388c..aa65ca30c 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -5,20 +5,53 @@ import { encodedRedirect } from '@/lib/utils/auth' import { type EmailOtpType } from '@supabase/supabase-js' 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.', '') export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) - const supabaseTokenHash = searchParams.get('token_hash') - const supabaseType = searchParams.get('type') as EmailOtpType | null - const supabaseClientFlowUrl = searchParams.get('confirmation_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 dashboardUrl = request.nextUrl const dashboardSignInUrl = new URL(request.nextUrl.origin + AUTH_URLS.SIGN_IN) - const supabaseRedirectTo = searchParams.get('next') + 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 && @@ -37,94 +70,73 @@ export async function GET(request: NextRequest) { origin: request.nextUrl.origin, }) - if ( - !supabaseTokenHash || - !supabaseType || - !supabaseRedirectTo || - !supabaseClientFlowUrl - ) { - logError('AUTH_CONFIRM_INVALID_PARAMS', { - supabaseTokenHash: !!supabaseTokenHash, - supabaseType: !!supabaseType, - supabaseRedirectTo: !!supabaseRedirectTo, - supabaseClientFlowUrl: !!supabaseClientFlowUrl, - }) - return encodedRedirect( - 'error', - dashboardSignInUrl.toString(), - 'Invalid Request' - ) - } - // 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) + throw redirect(supabaseClientFlowUrl!) } - let redirectUrl: URL - - const next = - supabaseType === 'recovery' - ? PROTECTED_URLS.RESET_PASSWORD - : (supabaseRedirectTo ?? PROTECTED_URLS.DASHBOARD) - - // try absolute url, else relative try { - redirectUrl = new URL(next) - } catch (e) { - redirectUrl = new URL(request.nextUrl.origin + next) - } + const next = + supabaseType === 'recovery' + ? `${request.nextUrl.origin}${PROTECTED_URLS.RESET_PASSWORD}` + : (supabaseRedirectTo ?? + `${request.nextUrl.origin}${PROTECTED_URLS.DASHBOARD}`) + + const redirectUrl = new URL(next) - if (!redirectUrl) { - logError('AUTH_CONFIRM_INVALID_NEXT', { + logInfo('AUTH_CONFIRM_VERIFY', { + supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, + supabaseType, next, + redirectUrl: redirectUrl.toString(), }) - return encodedRedirect( - 'error', - dashboardSignInUrl.toString(), - 'Invalid Next' - ) - } - logInfo('AUTH_CONFIRM_VERIFY', { - supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, - supabaseType, - next, - redirectUrl: redirectUrl.toString(), - }) + const response = NextResponse.redirect(redirectUrl) + const supabase = createRouteClient(request, response) - const response = NextResponse.redirect(redirectUrl) - const supabase = createRouteClient(request, response) + const { error } = await supabase.auth.verifyOtp({ + type: supabaseType, + token_hash: supabaseTokenHash, + }) - const { error } = await supabase.auth.verifyOtp({ - type: supabaseType, - token_hash: supabaseTokenHash, - }) + if (error) { + logError('AUTH_CONFIRM_ERROR', { + supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, + supabaseType, + next, + 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 + ) + } - if (error) { - logError('AUTH_CONFIRM_ERROR', { - supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, + logInfo('AUTH_CONFIRM_SUCCESS', { supabaseType, - next, 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) + return response + } catch (e) { + logError('AUTH_CONFIRM_ERROR', { + error: e, + }) + return encodedRedirect( + 'error', + dashboardSignInUrl.toString(), + 'Invalid Token' + ) } - - logInfo('AUTH_CONFIRM_SUCCESS', { - supabaseType, - redirectUrl: redirectUrl.toString(), - }) - - return response } From a0abb27a215ee64b872391a7b3a8e4c1397461b2 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 16:01:14 +0200 Subject: [PATCH 09/15] Refactor: Remove unused import in authentication confirmation route & normalize trailing slashes --- src/app/api/auth/confirm/route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index aa65ca30c..940a31f7c 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -1,8 +1,7 @@ -import { AUTH_URLS, PROTECTED_URLS, BASE_URL } from '@/configs/urls' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { logInfo, logError } from '@/lib/clients/logger' import { createRouteClient } from '@/lib/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' -import { type EmailOtpType } from '@supabase/supabase-js' import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -21,7 +20,8 @@ const confirmSchema = z.object({ next: z.string().url(), }) -const normalizeOrigin = (origin: string) => origin.replace('www.', '') +const normalizeOrigin = (origin: string) => + origin.replace('www.', '').replace(/\/$/, '') export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) From 0c7f708f339c70b6895d97f71f466c0d04033195 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 16:14:46 +0200 Subject: [PATCH 10/15] Refactor: Enhance authentication callback and action logging - Added logging for OAuth sign-in actions to improve traceability. - Introduced a new utility for error handling during the code exchange process, enhancing redirect logic for error scenarios. --- src/app/api/auth/callback/route.ts | 4 +++- src/server/auth/auth-actions.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index a794647a6..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 { PROTECTED_URLS } from '@/configs/urls' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' 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 @@ -31,6 +32,7 @@ export async function GET(request: Request) { '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/server/auth/auth-actions.ts b/src/server/auth/auth-actions.ts index 68777aa4a..07e2ebc8f 100644 --- a/src/server/auth/auth-actions.ts +++ b/src/server/auth/auth-actions.ts @@ -15,6 +15,7 @@ import { validateEmail, } from '@/server/auth/validate-email' import { ERROR_CODES } from '@/configs/logs' +import { logInfo } from '@/lib/clients/logger' export const signInWithOAuthAction = actionClient .schema( @@ -31,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: { From 9542b7d57fa0f9927d9fde18f45631ff416b422a Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 17:35:32 +0200 Subject: [PATCH 11/15] Chore: remove unecessary condition handling --- src/app/api/auth/confirm/route.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index 940a31f7c..f97bbf1c4 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -80,8 +80,7 @@ export async function GET(request: NextRequest) { const next = supabaseType === 'recovery' ? `${request.nextUrl.origin}${PROTECTED_URLS.RESET_PASSWORD}` - : (supabaseRedirectTo ?? - `${request.nextUrl.origin}${PROTECTED_URLS.DASHBOARD}`) + : supabaseRedirectTo const redirectUrl = new URL(next) From 84fd3af39ba0ca85d8307e9ab260bab7b9fe6e45 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 17:37:27 +0200 Subject: [PATCH 12/15] Chore: improve logs --- src/app/api/auth/confirm/route.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index f97bbf1c4..ef6742edb 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -84,13 +84,6 @@ export async function GET(request: NextRequest) { const redirectUrl = new URL(next) - logInfo('AUTH_CONFIRM_VERIFY', { - supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, - supabaseType, - next, - redirectUrl: redirectUrl.toString(), - }) - const response = NextResponse.redirect(redirectUrl) const supabase = createRouteClient(request, response) @@ -103,7 +96,7 @@ export async function GET(request: NextRequest) { logError('AUTH_CONFIRM_ERROR', { supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, supabaseType, - next, + supabaseRedirectTo, redirectUrl: redirectUrl.toString(), errorCode: error.code, errorStatus: error.status, @@ -123,7 +116,9 @@ export async function GET(request: NextRequest) { } logInfo('AUTH_CONFIRM_SUCCESS', { + supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`, supabaseType, + supabaseRedirectTo, redirectUrl: redirectUrl.toString(), }) From 652268076da2e0b52ff56fbfa3d26b07cde76255 Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 18:22:19 +0200 Subject: [PATCH 13/15] Refactor: Update Supabase client creation in authentication confirmation route - Replaced `createRouteClient` with `createClient` for improved client instantiation. - Removed the deprecated `createRouteClient` function to streamline the codebase. --- src/app/api/auth/confirm/route.ts | 4 ++-- src/lib/clients/supabase/server.ts | 36 ------------------------------ 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/src/app/api/auth/confirm/route.ts b/src/app/api/auth/confirm/route.ts index ef6742edb..186f731fc 100644 --- a/src/app/api/auth/confirm/route.ts +++ b/src/app/api/auth/confirm/route.ts @@ -1,6 +1,6 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { logInfo, logError } from '@/lib/clients/logger' -import { createRouteClient } from '@/lib/clients/supabase/server' +import { createClient } from '@/lib/clients/supabase/server' import { encodedRedirect } from '@/lib/utils/auth' import { redirect } from 'next/navigation' import { NextRequest, NextResponse } from 'next/server' @@ -85,7 +85,7 @@ export async function GET(request: NextRequest) { const redirectUrl = new URL(next) const response = NextResponse.redirect(redirectUrl) - const supabase = createRouteClient(request, response) + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type: supabaseType, diff --git a/src/lib/clients/supabase/server.ts b/src/lib/clients/supabase/server.ts index 4c085083c..bc4908801 100644 --- a/src/lib/clients/supabase/server.ts +++ b/src/lib/clients/supabase/server.ts @@ -31,39 +31,3 @@ export const createClient = async () => { } ) } - -/** - * Creates a Supabase client for route handlers and middleware - * @param response - Optional NextResponse to attach Set-Cookie headers from Supabase auth operations (verifyOtp, signInWithPassword, etc) - * If not provided, falls back to mutating request cookies which works for Server Components that refresh sessions via middleware - */ -export const createRouteClient = ( - request: NextRequest, - response?: NextResponse -) => - createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, - { - cookies: { - getAll() { - return request.cookies.getAll() - }, - setAll(cookiesToSet) { - if (response) { - cookiesToSet.forEach(({ name, value, options }) => { - if (options) { - response.cookies.set({ name, value, ...options }) - } else { - response.cookies.set(name, value) - } - }) - } else { - cookiesToSet.forEach(({ name, value, options }) => { - request.cookies.set(name, value) - }) - } - }, - }, - } - ) From 9daec690addf90733b65820ea9c3836e215befae Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 18:28:43 +0200 Subject: [PATCH 14/15] chore: replace unused supabase browser client --- src/app/dashboard/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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() From b9e72994353b1bb794cf473fe17cc2e921fea22b Mon Sep 17 00:00:00 2001 From: Ben Fornefeld Date: Sun, 6 Jul 2025 18:47:56 +0200 Subject: [PATCH 15/15] docs: Update README with email template configuration instructions for authentication --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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: