Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,11 +27,12 @@ export async function GET(request: Request) {
const { data, error } = await supabase.auth.exchangeCodeForSession(code)

if (error) {
logError(
console.error(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why change from logError > console.error?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the logger is nice when the args do not include a class instance (error in this case). i am about to fix this but in another pr.

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)
}
Expand Down
136 changes: 136 additions & 0 deletions src/app/api/auth/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -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'
)
}
}
2 changes: 1 addition & 1 deletion src/configs/urls.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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
Expand Down
24 changes: 2 additions & 22 deletions src/lib/clients/supabase/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -23,31 +23,11 @@ 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.
}
},
},
}
)
}

export const createRouteClient = (request: NextRequest) =>
createServerClient<Database>(
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.
},
},
}
)
11 changes: 0 additions & 11 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions src/server/auth/auth-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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: {
Expand Down Expand Up @@ -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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need to return here? returnServerError throws

Copy link
Copy Markdown
Member Author

@ben-fornefeld ben-fornefeld Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, it's just a convenient wrapper i am using because next-safe-action also has returnValidationError().
returnServerError actually also just throws under the hood since this is the way the actionClient is configured

'Please wait before requesting another password reset'
)
}

throw error
}
})
Expand Down
2 changes: 1 addition & 1 deletion src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down