Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions packages/core/auth-js/src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2480,15 +2480,28 @@ export default class GoTrueClient {
const endpoint = `${this.url}/resend`
if ('email' in credentials) {
const { email, type, options } = credentials
let codeChallenge: string | null = null
let codeChallengeMethod: string | null = null
if (this.flowType === 'pkce') {
;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
this.storage,
this.storageKey
)
}
const { error } = await _request(this.fetch, 'POST', endpoint, {
headers: this.headers,
body: {
email,
type,
gotrue_meta_security: { captcha_token: options?.captchaToken },
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
},
Comment on lines +2483 to 2499
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

In the non-PKCE case, code_challenge / code_challenge_method are still being included in the /resend request body as explicit null values. This changes the payload compared to prior versions and can trigger backend validation differences vs omitting the fields entirely. Consider only adding these properties to the body when this.flowType === 'pkce' (e.g., via a conditional object spread) so implicit-flow resend requests remain unchanged.

Suggested change
let codeChallenge: string | null = null
let codeChallengeMethod: string | null = null
if (this.flowType === 'pkce') {
;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
this.storage,
this.storageKey
)
}
const { error } = await _request(this.fetch, 'POST', endpoint, {
headers: this.headers,
body: {
email,
type,
gotrue_meta_security: { captcha_token: options?.captchaToken },
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
},
const body: {
email: string
type: typeof type
gotrue_meta_security: { captcha_token: string | undefined }
code_challenge?: string | null
code_challenge_method?: string | null
} = {
email,
type,
gotrue_meta_security: { captcha_token: options?.captchaToken },
}
if (this.flowType === 'pkce') {
const [codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod(
this.storage,
this.storageKey
)
body.code_challenge = codeChallenge
body.code_challenge_method = codeChallengeMethod
}
const { error } = await _request(this.fetch, 'POST', endpoint, {
headers: this.headers,
body,

Copilot uses AI. Check for mistakes.
redirectTo: options?.emailRedirectTo,
})
if (error) {
await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
}
return this._returnResult({ data: { user: null, session: null }, error })
} else if ('phone' in credentials) {
const { phone, type, options } = credentials
Expand All @@ -2509,6 +2522,7 @@ export default class GoTrueClient {
'You must provide either an email or phone number and a type'
)
} catch (error) {
await removeItemAsync(this.storage, `${this.storageKey}-code-verifier`)
if (isAuthError(error)) {
return this._returnResult({ data: { user: null, session: null }, error })
}
Expand Down
146 changes: 146 additions & 0 deletions packages/core/auth-js/test/GoTrueClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,152 @@ describe('GoTrueClient', () => {
expect(error).toBeNull()
})

test('resend with email and PKCE flowType includes code_challenge in request', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({}),
})

const storage = memoryLocalStorageAdapter()
const client = new GoTrueClient({
url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
autoRefreshToken: false,
persistSession: true,
storage,
flowType: 'pkce',
fetch: mockFetch,
})

const { email } = mockUserCredentials()
const { error } = await client.resend({
email,
type: 'signup',
options: { emailRedirectTo: 'http://localhost:9999/welcome' },
})
expect(error).toBeNull()

// Verify code_challenge was included in the request body
expect(mockFetch).toHaveBeenCalledTimes(1)
const [, fetchOptions] = mockFetch.mock.calls[0]
const body = JSON.parse(fetchOptions.body)
expect(body.code_challenge).toBeDefined()
expect(body.code_challenge).not.toBeNull()
expect(body.code_challenge_method).toBe('s256')

// Verify code verifier was stored for later exchange
// @ts-expect-error 'Allow access to protected storageKey'
const storageKey = client.storageKey
const codeVerifier = await storage.getItem(`${storageKey}-code-verifier`)
expect(codeVerifier).not.toBeNull()
})

test('resend with email_change type and PKCE flowType includes code_challenge', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({}),
})

const storage = memoryLocalStorageAdapter()
const client = new GoTrueClient({
url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
autoRefreshToken: false,
persistSession: true,
storage,
flowType: 'pkce',
fetch: mockFetch,
})

const { error } = await client.resend({
email: 'newemail@example.com',
type: 'email_change',
})
expect(error).toBeNull()

// Verify code_challenge was included in the request body
expect(mockFetch).toHaveBeenCalledTimes(1)
const [, fetchOptions] = mockFetch.mock.calls[0]
const body = JSON.parse(fetchOptions.body)
expect(body.code_challenge).toBeDefined()
expect(body.code_challenge).not.toBeNull()
expect(body.code_challenge_method).toBe('s256')
expect(body.type).toBe('email_change')

// @ts-expect-error 'Allow access to protected storageKey'
const storageKey = client.storageKey
const codeVerifier = await storage.getItem(`${storageKey}-code-verifier`)
expect(codeVerifier).not.toBeNull()
})

test('resend with PKCE cleans up code verifier on HTTP error', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: false,
status: 400,
headers: new Headers(),
json: () =>
Promise.resolve({
error: 'bad_request',
error_description: 'Invalid email',
}),
})

const storage = memoryLocalStorageAdapter()
const client = new GoTrueClient({
url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
autoRefreshToken: false,
persistSession: true,
storage,
flowType: 'pkce',
fetch: mockFetch,
})

const { error } = await client.resend({
email: 'bad@example.com',
type: 'signup',
})
expect(error).not.toBeNull()

// Verify code verifier was cleaned up after HTTP error
// @ts-expect-error 'Allow access to protected storageKey'
const storageKey = client.storageKey
const codeVerifier = await storage.getItem(`${storageKey}-code-verifier`)
expect(codeVerifier).toBeNull()
})

test('resend without PKCE does not include code_challenge in request', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({}),
})

const storage = memoryLocalStorageAdapter()
const client = new GoTrueClient({
url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
autoRefreshToken: false,
persistSession: true,
storage,
fetch: mockFetch,
})

const { error } = await client.resend({
email: 'test@example.com',
type: 'signup',
})
expect(error).toBeNull()

// Verify code_challenge fields are null in implicit flow
expect(mockFetch).toHaveBeenCalledTimes(1)
const [, fetchOptions] = mockFetch.mock.calls[0]
const body = JSON.parse(fetchOptions.body)
expect(body.code_challenge).toBeNull()
expect(body.code_challenge_method).toBeNull()
})

// Phone resend tests moved to docker-tests/phone-otp.test.ts

test('resend() fails without email or phone', async () => {
Expand Down
Loading