Skip to content

Commit d400c43

Browse files
Feat: Custom OTP PKCE flow + Improve error logging in authentication flow & handle supabase rate-limit error (e2b-dev#89)
This pr features a new custom OTP PKCE flow under `/api/auth/confirm`, which we can use to route password reset and first time e-mail sign-up e-mail urls to. Also this pr improves the error logging inside `/api/auth/callback` and adds explicit error handling for supabase request password rate-limit errors, to inform the user about this state. The old `/api/auth/callback` will eventually be removed when we ensure it isn't used anymore. **Migration Strategy** Replace the confirmationUrl value inside our Supbase "Reset Password" and "Confirm Sign-Up", with the following when this pr is deployed: **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 }}`
1 parent bf6b1b6 commit d400c43

9 files changed

Lines changed: 175 additions & 43 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,19 @@ This project requires a Redis-compatible key-value store. You'll need to:
8888
- Go to Authentication > Providers
8989
- Enable the providers you want to use (GitHub, Google, E-Mail)
9090
- Configure each provider with the appropriate credentials
91+
6. Configure e-mail templates:
92+
- Navigate to **Authentication → Templates** in the Supabase dashboard
93+
- 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:
94+
95+
**Reset Password**
96+
```
97+
{{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}&confirmation_url={{ .ConfirmationURL }}
98+
```
99+
100+
**Confirm Sign-Up**
101+
```
102+
{{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}&confirmation_url={{ .ConfirmationURL }}
103+
```
91104

92105
#### c. Database Setup
93106
1. Apply the database migrations manually:

src/app/api/auth/callback/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { createClient } from '@/lib/clients/supabase/server'
22
import { redirect } from 'next/navigation'
33
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
4-
import { logError, logInfo } from '@/lib/clients/logger'
4+
import { logInfo } from '@/lib/clients/logger'
55
import { ERROR_CODES } from '@/configs/logs'
6+
import { encodedRedirect } from '@/lib/utils/auth'
67

78
export async function GET(request: Request) {
89
// The `/auth/callback` route is required for the server-side auth flow implemented
@@ -26,11 +27,12 @@ export async function GET(request: Request) {
2627
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
2728

2829
if (error) {
29-
logError(
30+
console.error(
3031
ERROR_CODES.SUPABASE,
3132
'Error exchanging code for session:',
3233
error
3334
)
35+
throw encodedRedirect('error', AUTH_URLS.SIGN_IN, error.message)
3436
} else {
3537
logInfo('OTP was successfully exchanged for user:', data.user.id)
3638
}

src/app/api/auth/confirm/route.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
2+
import { logInfo, logError } from '@/lib/clients/logger'
3+
import { createClient } from '@/lib/clients/supabase/server'
4+
import { encodedRedirect } from '@/lib/utils/auth'
5+
import { redirect } from 'next/navigation'
6+
import { NextRequest, NextResponse } from 'next/server'
7+
import { z } from 'zod'
8+
9+
const confirmSchema = z.object({
10+
token_hash: z.string().min(1),
11+
type: z.enum([
12+
'signup',
13+
'recovery',
14+
'invite',
15+
'magiclink',
16+
'email',
17+
'email_change',
18+
]),
19+
confirmation_url: z.string().url(),
20+
next: z.string().url(),
21+
})
22+
23+
const normalizeOrigin = (origin: string) =>
24+
origin.replace('www.', '').replace(/\/$/, '')
25+
26+
export async function GET(request: NextRequest) {
27+
const { searchParams } = new URL(request.url)
28+
29+
const result = confirmSchema.safeParse({
30+
token_hash: searchParams.get('token_hash'),
31+
type: searchParams.get('type'),
32+
confirmation_url: searchParams.get('confirmation_url'),
33+
next: searchParams.get('next'),
34+
})
35+
36+
const dashboardSignInUrl = new URL(request.nextUrl.origin + AUTH_URLS.SIGN_IN)
37+
38+
if (!result.success) {
39+
logError('AUTH_CONFIRM_INVALID_PARAMS', {
40+
errors: result.error.errors,
41+
})
42+
return encodedRedirect(
43+
'error',
44+
dashboardSignInUrl.toString(),
45+
'Invalid Request'
46+
)
47+
}
48+
49+
const supabaseTokenHash = result.data.token_hash
50+
const supabaseType = result.data.type
51+
const supabaseClientFlowUrl = result.data.confirmation_url
52+
const supabaseRedirectTo = result.data.next
53+
54+
const dashboardUrl = request.nextUrl
55+
56+
const isDifferentOrigin =
57+
supabaseRedirectTo &&
58+
normalizeOrigin(new URL(supabaseRedirectTo).origin) !==
59+
normalizeOrigin(dashboardUrl.origin)
60+
61+
logInfo('AUTH_CONFIRM_INIT', {
62+
supabase_token_hash: supabaseTokenHash
63+
? `${supabaseTokenHash.slice(0, 10)}...`
64+
: null,
65+
supabaseType,
66+
supabaseRedirectTo,
67+
isDifferentOrigin,
68+
supabaseClientFlowUrl,
69+
requestUrl: request.url,
70+
origin: request.nextUrl.origin,
71+
})
72+
73+
// when the next param is an absolute URL, with a different origin,
74+
// we need to redirect to the supabase client flow url
75+
if (isDifferentOrigin) {
76+
throw redirect(supabaseClientFlowUrl!)
77+
}
78+
79+
try {
80+
const next =
81+
supabaseType === 'recovery'
82+
? `${request.nextUrl.origin}${PROTECTED_URLS.RESET_PASSWORD}`
83+
: supabaseRedirectTo
84+
85+
const redirectUrl = new URL(next)
86+
87+
const response = NextResponse.redirect(redirectUrl)
88+
const supabase = await createClient()
89+
90+
const { error } = await supabase.auth.verifyOtp({
91+
type: supabaseType,
92+
token_hash: supabaseTokenHash,
93+
})
94+
95+
if (error) {
96+
logError('AUTH_CONFIRM_ERROR', {
97+
supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`,
98+
supabaseType,
99+
supabaseRedirectTo,
100+
redirectUrl: redirectUrl.toString(),
101+
errorCode: error.code,
102+
errorStatus: error.status,
103+
errorMessage: error.message,
104+
})
105+
106+
let errorMessage = 'Invalid Token'
107+
if (error.status === 403 && error.code === 'otp_expired') {
108+
errorMessage = 'Email link has expired. Please request a new one.'
109+
}
110+
111+
return encodedRedirect(
112+
'error',
113+
dashboardSignInUrl.toString(),
114+
errorMessage
115+
)
116+
}
117+
118+
logInfo('AUTH_CONFIRM_SUCCESS', {
119+
supabaseTokenHash: `${supabaseTokenHash.slice(0, 10)}...`,
120+
supabaseType,
121+
supabaseRedirectTo,
122+
redirectUrl: redirectUrl.toString(),
123+
})
124+
125+
return response
126+
} catch (e) {
127+
logError('AUTH_CONFIRM_ERROR', {
128+
error: e,
129+
})
130+
return encodedRedirect(
131+
'error',
132+
dashboardSignInUrl.toString(),
133+
'Invalid Token'
134+
)
135+
}
136+
}

src/app/dashboard/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { PROTECTED_URLS } from '@/configs/urls'
33
import { cookies } from 'next/headers'
44
import { COOKIE_KEYS } from '@/configs/keys'
55
import { supabaseAdmin } from '@/lib/clients/supabase/admin'
6-
import { createRouteClient } from '@/lib/clients/supabase/server'
6+
import { createClient } from '@/lib/clients/supabase/server'
77

88
const TAB_URL_MAP: Record<string, (teamId: string) => string> = {
99
sandboxes: (teamId) => PROTECTED_URLS.SANDBOXES(teamId),
@@ -28,7 +28,7 @@ export async function GET(request: NextRequest) {
2828
}
2929

3030
// 2. Create Supabase client and get user
31-
const supabase = createRouteClient(request)
31+
const supabase = await createClient()
3232

3333
const { data, error } = await supabase.auth.getUser()
3434

src/configs/urls.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
export const AUTH_URLS = {
22
FORGOT_PASSWORD: '/forgot-password',
3-
RESET_PASSWORD: '/dashboard/account/reset-password',
43
SIGN_IN: '/sign-in',
54
SIGN_UP: '/sign-up',
65
CALLBACK: '/api/auth/callback',
@@ -19,6 +18,7 @@ export const PROTECTED_URLS = {
1918
BILLING: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/billing`,
2019
BUDGET: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/budget`,
2120
KEYS: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/keys`,
21+
RESET_PASSWORD: '/dashboard/account/reset-password',
2222
}
2323

2424
export const BASE_URL = process.env.VERCEL_ENV

src/lib/clients/supabase/server.ts

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import 'server-cli-only'
33
import { Database } from '@/types/database.types'
44
import { createServerClient } from '@supabase/ssr'
55
import { cookies } from 'next/headers'
6-
import { NextRequest } from 'next/server'
6+
import { NextRequest, NextResponse } from 'next/server'
77

88
export const createClient = async () => {
99
const cookieStore = await cookies()
@@ -23,31 +23,11 @@ export const createClient = async () => {
2323
})
2424
} catch (error) {
2525
// The `set` method was called from a Server Component.
26-
// This can be ignored if you have middleware refreshing
26+
// This can be ignored since we have middleware refreshing
2727
// user sessions.
2828
}
2929
},
3030
},
3131
}
3232
)
3333
}
34-
35-
export const createRouteClient = (request: NextRequest) =>
36-
createServerClient<Database>(
37-
process.env.NEXT_PUBLIC_SUPABASE_URL,
38-
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
39-
{
40-
cookies: {
41-
getAll() {
42-
return request.cookies.getAll()
43-
},
44-
setAll(cookiesToSet) {
45-
cookiesToSet.forEach(({ name, value }) =>
46-
request.cookies.set(name, value)
47-
)
48-
// This can be ignored if you have middleware refreshing
49-
// user sessions.
50-
},
51-
},
52-
}
53-
)

src/middleware.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,6 @@ export async function middleware(request: NextRequest) {
7676
}
7777
)
7878

79-
// Redirect to dashboard if user is logged in and on auth routes
80-
if (
81-
isAuthRoute(request.nextUrl.pathname) &&
82-
(await supabase.auth.getSession()).data.session
83-
) {
84-
return NextResponse.redirect(
85-
new URL(PROTECTED_URLS.DASHBOARD, request.url)
86-
)
87-
}
88-
89-
// Refresh session and handle auth redirects
9079
const { error, data } = await getUserSession(supabase)
9180

9281
// Handle authentication redirects

src/server/auth/auth-actions.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
shouldWarnAboutAlternateEmail,
1515
validateEmail,
1616
} from '@/server/auth/validate-email'
17+
import { ERROR_CODES } from '@/configs/logs'
18+
import { logInfo } from '@/lib/clients/logger'
1719

1820
export const signInWithOAuthAction = actionClient
1921
.schema(
@@ -30,6 +32,12 @@ export const signInWithOAuthAction = actionClient
3032

3133
const origin = (await headers()).get('origin')
3234

35+
logInfo('SIGN_IN_WITH_OAUTH_ACTION', {
36+
provider,
37+
returnTo,
38+
origin,
39+
})
40+
3341
const { data, error } = await supabase.auth.signInWithOAuth({
3442
provider: provider,
3543
options: {
@@ -164,13 +172,17 @@ export const forgotPasswordAction = actionClient
164172
.action(async ({ parsedInput }) => {
165173
const { email } = parsedInput
166174
const supabase = await createClient()
167-
const origin = (await headers()).get('origin')
168175

169-
const { error } = await supabase.auth.resetPasswordForEmail(email, {
170-
redirectTo: `${origin}${AUTH_URLS.CALLBACK}?redirect_to=${AUTH_URLS.RESET_PASSWORD}`,
171-
})
176+
const { error } = await supabase.auth.resetPasswordForEmail(email)
172177

173178
if (error) {
179+
console.error(ERROR_CODES.SUPABASE, 'Error resetting password:', error)
180+
if (error.message.includes('security purposes')) {
181+
return returnServerError(
182+
'Please wait before requesting another password reset'
183+
)
184+
}
185+
174186
throw error
175187
}
176188
})

src/server/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export function getAuthRedirect(
184184
return NextResponse.redirect(buildRedirectUrl(AUTH_URLS.SIGN_IN, request))
185185
}
186186

187-
if (request.nextUrl.pathname === '/' && isAuthenticated) {
187+
if (isAuthRoute(request.nextUrl.pathname) && isAuthenticated) {
188188
return NextResponse.redirect(
189189
buildRedirectUrl(PROTECTED_URLS.DASHBOARD, request)
190190
)

0 commit comments

Comments
 (0)