From 557a4e9b8ce6af5421cb83acf0ded8fc21264e24 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 12 Jun 2026 10:14:44 +0300 Subject: [PATCH] feat(auth)!: emit USER_UPDATED for email and phone change verification --- .../email-phone-change-user-updated-event.md | 58 +++++++++ packages/core/auth-js/src/GoTrueClient.ts | 21 ++- packages/core/auth-js/src/lib/helpers.ts | 18 ++- .../core/auth-js/test/GoTrueClient.test.ts | 123 ++++++++++++++++++ 4 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 packages/core/auth-js/migrations/email-phone-change-user-updated-event.md diff --git a/packages/core/auth-js/migrations/email-phone-change-user-updated-event.md b/packages/core/auth-js/migrations/email-phone-change-user-updated-event.md new file mode 100644 index 0000000000..f23fe3bfa0 --- /dev/null +++ b/packages/core/auth-js/migrations/email-phone-change-user-updated-event.md @@ -0,0 +1,58 @@ +# `USER_UPDATED` on email and phone change verification + +**Since:** v3.0.0 +**Action required by:** v3.0.0 + +`@supabase/auth-js` now emits `USER_UPDATED` on `onAuthStateChange` after a successful email-change or phone-change verification. Previously it emitted `SIGNED_IN`. + +This aligns the client with how the auth server (`supabase/auth`) already classifies these flows: an email or phone change is recorded as a user modification (`UserModifiedAction`), not as a sign-in. + +## What changed + +For sessions established via an `email_change` or `phone_change` verification — whether through `verifyOtp({ type: 'email_change' | 'phone_change', ... })`, an implicit-grant redirect (`?type=email_change|phone_change`), or the PKCE `exchangeCodeForSession` flow — subscribers to `onAuthStateChange` now receive: + +```ts +event: 'USER_UPDATED' +``` + +instead of the previous: + +```ts +event: 'SIGNED_IN' +``` + +All other event types are unchanged: + +- `recovery` → `PASSWORD_RECOVERY` (unchanged) +- `signup`, `invite`, `magiclink`, `email`, `sms`, etc. → `SIGNED_IN` (unchanged) + +## Who is affected + +Anyone with an `onAuthStateChange` listener that runs sign-in logic (analytics events, route redirects, welcome toasts, fresh-data fetches scoped to a new session) on `SIGNED_IN` and previously relied on it firing after an email or phone change. + +If you do not currently handle the post-email-change or post-phone-change case explicitly, you may also miss the event entirely unless you add a handler for `USER_UPDATED`. + +## What to do + +Move email/phone change handling onto `USER_UPDATED`: + +```ts +supabase.auth.onAuthStateChange((event, session) => { + if (event === 'SIGNED_IN') { + // first-login work: analytics, redirects, welcome UI + } else if (event === 'USER_UPDATED') { + // re-fetch profile, refresh email/phone display, etc. + // also fires after supabase.auth.updateUser() — same handler covers both + } else if (event === 'PASSWORD_RECOVERY') { + // show password reset UI + } +}) +``` + +If your previous `SIGNED_IN` handler was doing profile-refresh work specifically to pick up the new email or phone, that work belongs in the `USER_UPDATED` branch now. + +## Why + +The auth server treats email and phone change verifications as user modifications, not sign-ins (see `internal/api/verify.go` — both `emailChangeVerify` and the `phone_change` branch of `smsVerify` write `UserModifiedAction` to the audit log). Emitting `SIGNED_IN` on the client diverged from the server's semantics and produced spurious sign-in events on what is fundamentally a profile mutation. + +This change brings auth-js in line with the server and with the other Supabase SDKs (Flutter/Dart, Swift, Kotlin, Python), which are being updated to the same behavior. diff --git a/packages/core/auth-js/src/GoTrueClient.ts b/packages/core/auth-js/src/GoTrueClient.ts index 9891653c2a..d00460a903 100644 --- a/packages/core/auth-js/src/GoTrueClient.ts +++ b/packages/core/auth-js/src/GoTrueClient.ts @@ -660,6 +660,8 @@ export default class GoTrueClient { setTimeout(async () => { if (redirectType === 'recovery') { await this._notifyAllSubscribers('PASSWORD_RECOVERY', session) + } else if (redirectType === 'email_change' || redirectType === 'phone_change') { + await this._notifyAllSubscribers('USER_UPDATED', session) } else { await this._notifyAllSubscribers('SIGNED_IN', session) } @@ -1973,7 +1975,11 @@ export default class GoTrueClient { if (data.session) { await this._saveSession(data.session) await this._notifyAllSubscribers( - redirectType === 'recovery' ? 'PASSWORD_RECOVERY' : 'SIGNED_IN', + redirectType === 'recovery' + ? 'PASSWORD_RECOVERY' + : redirectType === 'email_change' || redirectType === 'phone_change' + ? 'USER_UPDATED' + : 'SIGNED_IN', data.session ) } @@ -2249,6 +2255,7 @@ export default class GoTrueClient { * 4. `email_change` – Used when verifying an OTP sent to a new email address during an email update process. * - The verification type used should be determined based on the corresponding auth method called before `verifyOtp` to sign up / sign-in a user. * - The `TokenHash` is contained in the [email templates](/docs/guides/auth/auth-email-templates) and can be used to sign in. You may wish to use the hash for the PKCE flow for Server Side Auth. Read [the Password-based Auth guide](/docs/guides/auth/passwords) for more details. + * - Events emitted on success: `recovery` → `PASSWORD_RECOVERY`; `email_change` and `phone_change` → `USER_UPDATED`; all other types → `SIGNED_IN`. * * @example Verify Signup One-Time Password (OTP) * ```js @@ -2401,7 +2408,11 @@ export default class GoTrueClient { if (session?.access_token) { await this._saveSession(session as Session) await this._notifyAllSubscribers( - params.type == 'recovery' ? 'PASSWORD_RECOVERY' : 'SIGNED_IN', + params.type === 'recovery' + ? 'PASSWORD_RECOVERY' + : params.type === 'email_change' || params.type === 'phone_change' + ? 'USER_UPDATED' + : 'SIGNED_IN', session ) } @@ -3333,7 +3344,8 @@ export default class GoTrueClient { if (this.flowType === 'pkce' && attributes.email != null) { ;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod( this.storage, - this.storageKey + this.storageKey, + 'email_change' ) } @@ -4054,6 +4066,7 @@ export default class GoTrueClient { * - The frequency of this event is related to the JWT expiry limit configured on your project. * - `USER_UPDATED` * - Emitted each time the `supabase.auth.updateUser()` method finishes successfully. Listen to it to update your application's UI based on new profile information. + * - Also emitted after a successful email change or phone change verification (`email_change` / `phone_change` OTP types and the corresponding implicit-grant or PKCE callback). In earlier versions this was emitted as `SIGNED_IN`. * - `PASSWORD_RECOVERY` * - Emitted instead of the `SIGNED_IN` event when the user lands on a page that includes a password recovery link in the URL. * - Use it to show a UI to the user where they can [reset their password](/docs/guides/auth/passwords#resetting-a-users-password-forgot-password). @@ -4343,7 +4356,7 @@ export default class GoTrueClient { ;[codeChallenge, codeChallengeMethod] = await getCodeChallengeAndMethod( this.storage, this.storageKey, - true // isPasswordRecovery + 'recovery' ) } try { diff --git a/packages/core/auth-js/src/lib/helpers.ts b/packages/core/auth-js/src/lib/helpers.ts index 877fd152fd..bb336fc239 100644 --- a/packages/core/auth-js/src/lib/helpers.ts +++ b/packages/core/auth-js/src/lib/helpers.ts @@ -301,15 +301,27 @@ export async function generatePKCEChallenge(verifier: string) { return btoa(hashed).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +/** + * Tags the stored PKCE verifier so that the post-redirect `_exchangeCodeForSession` + * step can recover the original flow type and emit the correct + * `onAuthStateChange` event: + * - `'recovery'` → `PASSWORD_RECOVERY` + * - `'email_change'` → `USER_UPDATED` + * + * Sign-in flows (signUp, signInWithOtp, signInWithOAuth, etc.) leave `flowType` + * undefined; those emit `SIGNED_IN`. + */ +export type PKCEStoredFlowType = 'recovery' | 'email_change' + export async function getCodeChallengeAndMethod( storage: SupportedStorage, storageKey: string, - isPasswordRecovery = false + flowType?: PKCEStoredFlowType ) { const codeVerifier = generatePKCEVerifier() let storedCodeVerifier = codeVerifier - if (isPasswordRecovery) { - storedCodeVerifier += '/recovery' + if (flowType) { + storedCodeVerifier += `/${flowType}` } await setItemAsync(storage, `${storageKey}-code-verifier`, storedCodeVerifier) const codeChallenge = await generatePKCEChallenge(codeVerifier) diff --git a/packages/core/auth-js/test/GoTrueClient.test.ts b/packages/core/auth-js/test/GoTrueClient.test.ts index a093d39be9..6d128b4577 100644 --- a/packages/core/auth-js/test/GoTrueClient.test.ts +++ b/packages/core/auth-js/test/GoTrueClient.test.ts @@ -533,6 +533,69 @@ describe('GoTrueClient', () => { expect(error?.message).toContain('@supabase/ssr') expect(error?.code).toEqual('pkce_code_verifier_not_found') }) + + test.each([ + { tag: 'email_change', expectedEvent: 'USER_UPDATED' }, + { tag: 'recovery', expectedEvent: 'PASSWORD_RECOVERY' }, + { tag: '', expectedEvent: 'SIGNED_IN' }, + ])( + 'exchangeCodeForSession() emits $expectedEvent when verifier is tagged "$tag"', + async ({ tag, expectedEvent }) => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: () => + Promise.resolve({ + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + token_type: 'bearer', + expires_in: 3600, + user: { + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'example@email.com', + app_metadata: {}, + user_metadata: {}, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + }), + }) + + const storage = memoryLocalStorageAdapter() + const client = new GoTrueClient({ + url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + autoRefreshToken: false, + persistSession: true, + storage, + flowType: 'pkce', + fetch: mockFetch as unknown as typeof fetch, + }) + + // @ts-expect-error 'Allow access to protected storageKey' + const storageKey = client.storageKey + const storedVerifier = tag ? `mock-verifier/${tag}` : 'mock-verifier' + await storage.setItem(`${storageKey}-code-verifier`, storedVerifier) + + const callback = jest.fn() + const { + data: { subscription }, + } = client.onAuthStateChange(callback) + + const { data, error } = await client.exchangeCodeForSession('mock-code') + + expect(error).toBeNull() + expect(data.session).not.toBeNull() + expect(callback).toHaveBeenCalledWith( + expectedEvent, + expect.objectContaining({ access_token: 'mock-access-token' }) + ) + + subscription?.unsubscribe() + } + ) }) describe('Email Auth', () => { @@ -913,6 +976,66 @@ describe('GoTrueClient', () => { expect(data.user).toBeNull() expect(data.session).toBeNull() }) + + test.each([ + { type: 'email_change' as const, expectedEvent: 'USER_UPDATED' }, + { type: 'phone_change' as const, expectedEvent: 'USER_UPDATED' }, + { type: 'recovery' as const, expectedEvent: 'PASSWORD_RECOVERY' }, + { type: 'magiclink' as const, expectedEvent: 'SIGNED_IN' }, + ])( + 'verifyOtp({ type: $type }) emits $expectedEvent on success', + async ({ type, expectedEvent }) => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: () => + Promise.resolve({ + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + token_type: 'bearer', + expires_in: 3600, + user: { + id: '11111111-1111-1111-1111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email: 'example@email.com', + app_metadata: {}, + user_metadata: {}, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + }), + }) + + const client = new GoTrueClient({ + url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + autoRefreshToken: false, + persistSession: false, + storage: memoryLocalStorageAdapter(), + fetch: mockFetch as unknown as typeof fetch, + }) + + const callback = jest.fn() + const { + data: { subscription }, + } = client.onAuthStateChange(callback) + + const { data, error } = await client.verifyOtp({ + token_hash: 'token-hash', + type, + }) + + expect(error).toBeNull() + expect(data.session).not.toBeNull() + expect(callback).toHaveBeenCalledWith( + expectedEvent, + expect.objectContaining({ access_token: 'mock-access-token' }) + ) + + subscription?.unsubscribe() + } + ) }) describe('signInWithOtp', () => {