From f976688a038fbdae532cf74a78f2421eee880f7b Mon Sep 17 00:00:00 2001 From: Lawrence Li <740186111serious@gmail.com> Date: Sat, 28 Feb 2026 21:30:02 +0800 Subject: [PATCH 1/3] auth.resend() consistent confirmation flow --- packages/core/auth-js/src/GoTrueClient.ts | 11 +++++++++++ packages/core/auth-js/test/GoTrueClient.test.ts | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/core/auth-js/src/GoTrueClient.ts b/packages/core/auth-js/src/GoTrueClient.ts index 4de54bd57..dad6b880d 100644 --- a/packages/core/auth-js/src/GoTrueClient.ts +++ b/packages/core/auth-js/src/GoTrueClient.ts @@ -1434,12 +1434,22 @@ 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, }, redirectTo: options?.emailRedirectTo, }) @@ -1463,6 +1473,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 }) } diff --git a/packages/core/auth-js/test/GoTrueClient.test.ts b/packages/core/auth-js/test/GoTrueClient.test.ts index 4e77e7e81..4c0d223ac 100644 --- a/packages/core/auth-js/test/GoTrueClient.test.ts +++ b/packages/core/auth-js/test/GoTrueClient.test.ts @@ -605,6 +605,16 @@ describe('GoTrueClient', () => { expect(error).toBeNull() }) + test('resend with email and PKCE flowType', async () => { + const { email } = mockUserCredentials() + const { error } = await pkceClient.resend({ + email, + type: 'signup', + options: { emailRedirectTo: 'http://localhost:9999/welcome' }, + }) + expect(error).toBeNull() + }) + // Phone resend tests moved to docker-tests/phone-otp.test.ts test('resend() fails without email or phone', async () => { From 26066506da850a17b54486df22584b36f0f9fbc1 Mon Sep 17 00:00:00 2001 From: Lawrence Li <740186111serious@gmail.com> Date: Sun, 29 Mar 2026 17:59:58 +0800 Subject: [PATCH 2/3] feat(auth): add email_change PKCE test, strengthen assertions, clean up verifier on error --- packages/core/auth-js/src/GoTrueClient.ts | 3 + .../core/auth-js/test/GoTrueClient.test.ts | 104 +++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/core/auth-js/src/GoTrueClient.ts b/packages/core/auth-js/src/GoTrueClient.ts index ae21d9beb..3de593e3b 100644 --- a/packages/core/auth-js/src/GoTrueClient.ts +++ b/packages/core/auth-js/src/GoTrueClient.ts @@ -2499,6 +2499,9 @@ export default class GoTrueClient { }, 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 diff --git a/packages/core/auth-js/test/GoTrueClient.test.ts b/packages/core/auth-js/test/GoTrueClient.test.ts index 4c0d223ac..aa0caa3bf 100644 --- a/packages/core/auth-js/test/GoTrueClient.test.ts +++ b/packages/core/auth-js/test/GoTrueClient.test.ts @@ -605,14 +605,114 @@ describe('GoTrueClient', () => { expect(error).toBeNull() }) - test('resend with email and PKCE flowType', async () => { + 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 pkceClient.resend({ + 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') + }) + + 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() }) // Phone resend tests moved to docker-tests/phone-otp.test.ts From 1bc0edafcacc04a96a657b770b52113679d45932 Mon Sep 17 00:00:00 2001 From: Lawrence Li <740186111serious@gmail.com> Date: Wed, 1 Apr 2026 11:16:55 +0800 Subject: [PATCH 3/3] feat(auth): add implicit flow test and strengthen email_change assertions --- .../core/auth-js/test/GoTrueClient.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/core/auth-js/test/GoTrueClient.test.ts b/packages/core/auth-js/test/GoTrueClient.test.ts index aa0caa3bf..4d037fccc 100644 --- a/packages/core/auth-js/test/GoTrueClient.test.ts +++ b/packages/core/auth-js/test/GoTrueClient.test.ts @@ -678,6 +678,11 @@ describe('GoTrueClient', () => { 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 () => { @@ -715,6 +720,37 @@ describe('GoTrueClient', () => { 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 () => {