Skip to content

Commit 2af904a

Browse files
authored
feat: add PKCE support for /resend (#2401)
## What kind of change does this PR introduce? Bug fix ## What is the current behavior? The `/resend` endpoint hardcodes `models.ImplicitFlow` for both `signup` and `email_change` verification types ([#42527](supabase/supabase#42527)). This means resent confirmation emails always use the implicit flow — redirecting with tokens in the URL hash fragment (`#access_token=...`) — even when the original `signUp()` used PKCE. This creates an inconsistency where: - Initial signup email: `https://example.com/auth/confirm?code=xxx` (PKCE, works with server routes) - Resent email: `https://example.com/auth/confirm#access_token=xxx` (implicit, requires client-side handling) Server-side route handlers (e.g., Next.js `route.ts`) cannot read hash fragments, forcing developers to implement workarounds with client components and dual flow handling. Closes #42527 ## What is the new behavior? The `/resend` endpoint now accepts optional `code_challenge` and `code_challenge_method` parameters for `signup` and `email_change` types. When provided, the endpoint: 1. Determines the flow type from `code_challenge` (PKCE if present, implicit if absent) 2. Creates a `FlowState` record for PKCE flows (needed by `/verify` to issue an auth code) 3. Passes the correct flow type to `sendConfirmation` / `sendEmailChange` This produces confirmation emails with `?code=...` query params instead of `#access_token=...` hash fragments, consistent with the initial signup flow. When `code_challenge` is not provided, behavior is **unchanged** — implicit flow is used, maintaining full backward compatibility. **Changes:** - `internal/api/resend.go`: Added `CodeChallenge` and `CodeChallengeMethod` fields to `ResendConfirmationParams`. Added PKCE param validation for email-based types. Replaced hardcoded `ImplicitFlow` with flow-aware logic for `signup` and `email_change` cases. - `internal/api/resend_test.go`: Added `TestResendPKCEValidation` (invalid PKCE params return 400) and `TestResendPKCESuccess` (signup and email change tokens get `pkce_` prefix when PKCE params are provided). ## Additional context This is the server-side half of the fix. The JS SDK (`auth-js`) needs a corresponding update to send `code_challenge` / `code_challenge_method` in `resend()` calls when `flowType === 'pkce'`, following the same pattern already used by `signUp()` and `signInWithOtp()`. See [this PR](supabase/supabase-js#2144) The implementation mirrors the existing PKCE pattern used across the codebase (`signup.go`, `user.go`, `recover.go`, `magic_link.go`): `getFlowFromChallenge` → conditional `generateFlowState` → pass `flowType` to the email sender.
1 parent 3821290 commit 2af904a

2 files changed

Lines changed: 156 additions & 9 deletions

File tree

internal/api/resend.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,22 @@ import (
1212

1313
// ResendConfirmationParams holds the parameters for a resend request
1414
type ResendConfirmationParams struct {
15-
Type string `json:"type"`
16-
Email string `json:"email"`
17-
Phone string `json:"phone"`
15+
Type string `json:"type"`
16+
Email string `json:"email"`
17+
Phone string `json:"phone"`
18+
CodeChallenge string `json:"code_challenge"`
19+
CodeChallengeMethod string `json:"code_challenge_method"`
1820
}
1921

2022
func (p *ResendConfirmationParams) Validate(a *API) error {
2123
config := a.config
2224

2325
switch p.Type {
24-
case mail.SignupVerification, mail.EmailChangeVerification, smsVerification, phoneChangeVerification:
26+
case mail.SignupVerification, mail.EmailChangeVerification:
27+
if err := validatePKCEParams(p.CodeChallengeMethod, p.CodeChallenge); err != nil {
28+
return err
29+
}
30+
case smsVerification, phoneChangeVerification:
2531
break
2632
default:
2733
// type does not match one of the above
@@ -121,8 +127,13 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
121127
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserConfirmationRequestedAction, "", nil); terr != nil {
122128
return terr
123129
}
124-
// PKCE not implemented yet
125-
return a.sendConfirmation(r, tx, user, models.ImplicitFlow)
130+
flowType := getFlowFromChallenge(params.CodeChallenge)
131+
if isPKCEFlow(flowType) {
132+
if _, terr := generateFlowState(tx, models.EmailSignup.String(), models.EmailSignup, params.CodeChallengeMethod, params.CodeChallenge, &user.ID); terr != nil {
133+
return terr
134+
}
135+
}
136+
return a.sendConfirmation(r, tx, user, flowType)
126137
case smsVerification:
127138
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserRecoveryRequestedAction, "", nil); terr != nil {
128139
return terr
@@ -133,7 +144,13 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
133144
}
134145
messageID = mID
135146
case mail.EmailChangeVerification:
136-
return a.sendEmailChange(r, tx, user, user.EmailChange, models.ImplicitFlow)
147+
flowType := getFlowFromChallenge(params.CodeChallenge)
148+
if isPKCEFlow(flowType) {
149+
if _, terr := generateFlowState(tx, models.EmailChange.String(), models.EmailChange, params.CodeChallengeMethod, params.CodeChallenge, &user.ID); terr != nil {
150+
return terr
151+
}
152+
}
153+
return a.sendEmailChange(r, tx, user, user.EmailChange, flowType)
137154
case phoneChangeVerification:
138155
mID, terr := a.sendPhoneConfirmation(r, tx, user, user.PhoneChange, phoneChangeVerification, sms_provider.SMSProvider)
139156
if terr != nil {

internal/api/resend_test.go

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8+
"strings"
89
"testing"
910
"time"
1011

@@ -109,6 +110,68 @@ func (ts *ResendTestSuite) TestResendValidation() {
109110

110111
}
111112

113+
func (ts *ResendTestSuite) TestResendPKCEValidation() {
114+
const validChallenge = "testtesttesttesttesttesttestteststeststesttesttesttest"
115+
cases := []struct {
116+
desc string
117+
params map[string]interface{}
118+
expected map[string]interface{}
119+
}{
120+
{
121+
desc: "Signup with code_challenge but missing code_challenge_method",
122+
params: map[string]interface{}{
123+
"type": "signup",
124+
"email": "foo@example.com",
125+
"code_challenge": validChallenge,
126+
},
127+
expected: map[string]interface{}{
128+
"code": http.StatusBadRequest,
129+
"message": InvalidPKCEParamsErrorMessage,
130+
},
131+
},
132+
{
133+
desc: "Signup with code_challenge_method but missing code_challenge",
134+
params: map[string]interface{}{
135+
"type": "signup",
136+
"email": "foo@example.com",
137+
"code_challenge_method": "s256",
138+
},
139+
expected: map[string]interface{}{
140+
"code": http.StatusBadRequest,
141+
"message": InvalidPKCEParamsErrorMessage,
142+
},
143+
},
144+
{
145+
desc: "Email change with code_challenge but missing code_challenge_method",
146+
params: map[string]interface{}{
147+
"type": "email_change",
148+
"email": "foo@example.com",
149+
"code_challenge": validChallenge,
150+
},
151+
expected: map[string]interface{}{
152+
"code": http.StatusBadRequest,
153+
"message": InvalidPKCEParamsErrorMessage,
154+
},
155+
},
156+
}
157+
for _, c := range cases {
158+
ts.Run(c.desc, func() {
159+
var buffer bytes.Buffer
160+
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params))
161+
req := httptest.NewRequest(http.MethodPost, "http://localhost/resend", &buffer)
162+
req.Header.Set("Content-Type", "application/json")
163+
164+
w := httptest.NewRecorder()
165+
ts.API.handler.ServeHTTP(w, req)
166+
require.Equal(ts.T(), c.expected["code"], w.Code)
167+
168+
data := make(map[string]interface{})
169+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
170+
require.Equal(ts.T(), c.expected["message"], data["msg"])
171+
})
172+
}
173+
}
174+
112175
func (ts *ResendTestSuite) TestResendSuccess() {
113176
// Create user
114177
u, err := models.NewUser("123456789", "foo@example.com", "password", ts.Config.JWT.Aud, nil)
@@ -150,8 +213,7 @@ func (ts *ResendTestSuite) TestResendSuccess() {
150213
cases := []struct {
151214
desc string
152215
params map[string]interface{}
153-
// expected map[string]interface{}
154-
user *models.User
216+
user *models.User
155217
}{
156218
{
157219
desc: "Resend signup confirmation",
@@ -215,3 +277,71 @@ func (ts *ResendTestSuite) TestResendSuccess() {
215277
})
216278
}
217279
}
280+
281+
func (ts *ResendTestSuite) TestResendPKCESuccess() {
282+
const testCodeChallenge = "testtesttesttesttesttesttestteststeststesttesttesttest"
283+
284+
// Avoid max freq limit error
285+
now := time.Now().Add(-1 * time.Minute)
286+
287+
ts.Config.Mailer.SecureEmailChangeEnabled = false
288+
289+
// Fresh user for signup PKCE resend
290+
signupUser, err := models.NewUser("", "pkce-signup@example.com", "password", ts.Config.JWT.Aud, nil)
291+
require.NoError(ts.T(), err)
292+
signupUser.ConfirmationToken = "oldtoken"
293+
signupUser.ConfirmationSentAt = &now
294+
require.NoError(ts.T(), ts.API.db.Create(signupUser))
295+
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, signupUser.ID, signupUser.GetEmail(), signupUser.ConfirmationToken, models.ConfirmationToken))
296+
297+
// Fresh user for email_change PKCE resend
298+
emailChangeUser, err := models.NewUser("", "pkce-change@example.com", "password", ts.Config.JWT.Aud, nil)
299+
require.NoError(ts.T(), err)
300+
emailChangeUser.EmailChange = "pkce-change-new@example.com"
301+
emailChangeUser.EmailChangeSentAt = &now
302+
emailChangeUser.EmailChangeTokenNew = "oldchangetoken"
303+
require.NoError(ts.T(), ts.API.db.Create(emailChangeUser))
304+
require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, emailChangeUser.ID, emailChangeUser.EmailChange, emailChangeUser.EmailChangeTokenNew, models.EmailChangeTokenNew))
305+
306+
ts.Run("Resend signup confirmation with PKCE", func() {
307+
var buffer bytes.Buffer
308+
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
309+
"type": "signup",
310+
"email": signupUser.GetEmail(),
311+
"code_challenge": testCodeChallenge,
312+
"code_challenge_method": "s256",
313+
}))
314+
req := httptest.NewRequest(http.MethodPost, "http://localhost/resend", &buffer)
315+
req.Header.Set("Content-Type", "application/json")
316+
317+
w := httptest.NewRecorder()
318+
ts.API.handler.ServeHTTP(w, req)
319+
require.Equal(ts.T(), http.StatusOK, w.Code)
320+
321+
dbUser, err := models.FindUserByID(ts.API.db, signupUser.ID)
322+
require.NoError(ts.T(), err)
323+
require.NotEqual(ts.T(), dbUser.ConfirmationToken, signupUser.ConfirmationToken)
324+
require.True(ts.T(), strings.HasPrefix(dbUser.ConfirmationToken, PKCEPrefix), "expected pkce_ prefix on ConfirmationToken, got: %s", dbUser.ConfirmationToken)
325+
})
326+
327+
ts.Run("Resend email change with PKCE", func() {
328+
var buffer bytes.Buffer
329+
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
330+
"type": "email_change",
331+
"email": emailChangeUser.GetEmail(),
332+
"code_challenge": testCodeChallenge,
333+
"code_challenge_method": "s256",
334+
}))
335+
req := httptest.NewRequest(http.MethodPost, "http://localhost/resend", &buffer)
336+
req.Header.Set("Content-Type", "application/json")
337+
338+
w := httptest.NewRecorder()
339+
ts.API.handler.ServeHTTP(w, req)
340+
require.Equal(ts.T(), http.StatusOK, w.Code)
341+
342+
dbUser, err := models.FindUserByID(ts.API.db, emailChangeUser.ID)
343+
require.NoError(ts.T(), err)
344+
require.NotEqual(ts.T(), dbUser.EmailChangeTokenNew, emailChangeUser.EmailChangeTokenNew)
345+
require.True(ts.T(), strings.HasPrefix(dbUser.EmailChangeTokenNew, PKCEPrefix), "expected pkce_ prefix on EmailChangeTokenNew, got: %s", dbUser.EmailChangeTokenNew)
346+
})
347+
}

0 commit comments

Comments
 (0)