From d9fe0cde8611e6197053d01af5198942da6eb594 Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Thu, 11 Jun 2026 13:44:34 -0700 Subject: [PATCH 1/4] fix(fxa-settings): correct passkey sign-in Glean event reasons Because: - The passkey auth_success and enter_password events shared reason codes across the email-first, OTP, login, and alternative-auth flows, so the flows could not be told apart in analysis (FXA-13896). This commit: - Maps each sign-in surface to a distinct metric reason: the passwordless OTP page to `otplogin`, the /signin page to `signin`, and the alternative-auth page to its own `alternative_auth`. - Renames PasskeyFallbackSurface to PasskeyMetricsSurface and merges the two surface mappers into a single toPasskeyMetricsSurface. - Keeps the `_` reason vocabulary to reachable combinations only: `otplogin` and `alternative_auth` are no-password surfaces, so they never reach the existing-password fallback and pair only with `nopassword`/`createdpassword`. --- .../fxa-settings/src/lib/glean/index.test.ts | 11 ++- .../src/lib/passkeys/signin-flow.test.tsx | 74 +++++++++++++++- .../src/lib/passkeys/signin-flow.ts | 84 ++++++++++--------- .../PostVerify/SetPassword/container.test.tsx | 4 +- .../PostVerify/SetPassword/interfaces.ts | 12 ++- .../SigninPasskeyFallback/container.test.tsx | 8 +- .../SigninPasskeyFallback/index.test.tsx | 8 +- .../Signin/SigninPasskeyFallback/index.tsx | 4 +- .../src/pages/Signin/interfaces.ts | 4 +- .../metrics/glean/fxa-ui-metrics.yaml | 30 ++++--- 10 files changed, 162 insertions(+), 77 deletions(-) diff --git a/packages/fxa-settings/src/lib/glean/index.test.ts b/packages/fxa-settings/src/lib/glean/index.test.ts index b3ac46114fe..8647fee2235 100644 --- a/packages/fxa-settings/src/lib/glean/index.test.ts +++ b/packages/fxa-settings/src/lib/glean/index.test.ts @@ -749,7 +749,7 @@ describe('lib/glean', () => { it('submits a ping with the passkey_enter_password_engage event name and a reason', async () => { GleanMetrics.passkeyEnterPassword.engage({ - event: { reason: 'login' }, + event: { reason: 'signin' }, }); await GleanMetrics.isDone(); sinon.assert.calledOnce(setEventNameStub); @@ -757,7 +757,7 @@ describe('lib/glean', () => { setEventNameStub, 'passkey_enter_password_engage' ); - sinon.assert.calledWith(setEventReasonStub, 'login'); + sinon.assert.calledWith(setEventReasonStub, 'signin'); }); it('submits a ping with the passkey_enter_password_submit event name and a reason', async () => { @@ -787,7 +787,7 @@ describe('lib/glean', () => { it('submits a ping with the passkey_enter_password_success event name and a reason', async () => { GleanMetrics.passkeyEnterPassword.success({ - event: { reason: 'login' }, + event: { reason: 'signin' }, }); await GleanMetrics.isDone(); sinon.assert.calledOnce(setEventNameStub); @@ -805,6 +805,11 @@ describe('lib/glean', () => { 'emailfirst_createdpassword', 'signin_nopassword', 'signin_withpassword', + 'signin_createdpassword', + 'otplogin_nopassword', + 'otplogin_createdpassword', + 'alternative_auth_nopassword', + 'alternative_auth_createdpassword', ])('submits passkey_auth_success with reason=%s', async (reason) => { GleanMetrics.passkey.authSuccess({ event: { reason } }); await GleanMetrics.isDone(); diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx index 4c821d136a8..499cc19764a 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx @@ -360,6 +360,76 @@ describe('usePasskeySignIn', () => { } ); + it.each([ + [ + 'emailfirst' as const, + true, + '/signin_passkey_fallback', + { state: { passkeySurface: 'emailfirst' } }, + ], + [ + 'login' as const, + true, + '/signin_passkey_fallback', + { state: { passkeySurface: 'signin' } }, + ], + // No-password surfaces (otplogin, alternative_auth) route to set-password + // (createdpassword); they never reach the existing-password fallback. + [ + 'login_otp' as const, + false, + '/post_verify/set_password', + { + state: { + passwordCreationReason: 'passkey', + passkeySurface: 'otplogin', + }, + }, + ], + [ + 'alternative_auth' as const, + false, + '/post_verify/set_password', + { + state: { + passwordCreationReason: 'passkey', + passkeySurface: 'alternative_auth', + }, + }, + ], + ])( + 'threads the passkey surface for %s into the %s nav state', + async (surface, hasPassword, expectedPath, expectedOptions) => { + const { args, spies } = buildArgs({ + surface, + integration: { + isSync: () => true, + isFirefoxNonSync: () => false, + getService: () => 'sync', + type: IntegrationType.OAuthNative, + data: {}, + } as unknown as PasskeySignInIntegration, + }); + spies.completePasskeyAuthentication.mockResolvedValue({ + uid: UID, + sessionToken: SESSION_TOKEN, + verified: true, + requiresPasswordForSync: true, + hasPassword, + }); + + const { result } = renderHook(() => usePasskeySignIn(args)); + await act(async () => { + await result.current.onClick(); + }); + + expect(spies.navigateWithQuery).toHaveBeenCalledWith( + expectedPath, + expectedOptions + ); + } + ); + // Locks the contract: each DOMException name maps to its expected FTL id // AND its fallback string. Drift in either lands silently otherwise. // The fallback substrings are stable phrases pulled from webauthn-errors.ts @@ -648,8 +718,8 @@ describe('usePasskeySignIn', () => { it.each([ ['emailfirst' as const, 'emailfirst_nopassword'], ['login' as const, 'signin_nopassword'], - ['login_otp' as const, 'emailfirst_nopassword'], - ['alternative_auth' as const, 'signin_nopassword'], + ['login_otp' as const, 'otplogin_nopassword'], + ['alternative_auth' as const, 'alternative_auth_nopassword'], ])( 'fires passkey.auth_success with reason=%s on the no-Sync-password branch (surface=%s)', async (surface, expectedReason) => { diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts index 0cb055f864f..ce54ec518f0 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts @@ -37,36 +37,36 @@ export type PasskeySignInSurface = | 'login_otp' | 'alternative_auth'; -export type PasskeyFallbackSurface = 'emailfirst' | 'login'; - -/** - * Fallback page (`/signin_passkey_fallback`) collapses surfaces to two. - * 'login_otp' is reached only via the email-first flow. 'alternative_auth' - * is shown to linked-passwordless users who have no password by definition, - * so the existing-password fallback path is unreachable for them — but the - * mapping must stay exhaustive; group with 'login' as a defensive default. - */ -const surfaceToFallbackReason = ( - surface: PasskeySignInSurface -): PasskeyFallbackSurface => - surface === 'login' || surface === 'alternative_auth' - ? 'login' - : 'emailfirst'; +/** Surface vocabulary used in passkey metric reasons (see {@link toPasskeyMetricsSurface}). */ +export type PasskeyMetricsSurface = + | 'emailfirst' + | 'signin' + | 'otplogin' + | 'alternative_auth'; /** - * Maps the surface enum to the two-prefix space used by the - * `passkey.auth_success` reason codes (`emailfirst_*` vs `signin_*`). The - * passwordless OTP code page is part of the email-first journey, so it - * groups with `emailfirst`. The alternative-auth page rolls up under - * `signin` alongside the regular /signin page. Note the prefix is `signin`, - * not `login`, to match the schema Ipsita agreed in the ticket comments. + * Maps each sign-in surface to its passkey metric-reason name: `login` to + * `signin` and `login_otp` to `otplogin` (to match the agreed schema); + * `emailfirst` and `alternative_auth` are unchanged. */ -const surfaceToAuthSuccessPrefix = ( +const toPasskeyMetricsSurface = ( surface: PasskeySignInSurface -): 'emailfirst' | 'signin' => - surface === 'login' || surface === 'alternative_auth' - ? 'signin' - : 'emailfirst'; +): PasskeyMetricsSurface => { + switch (surface) { + case 'login': + return 'signin'; + case 'login_otp': + return 'otplogin'; + case 'alternative_auth': + return 'alternative_auth'; + case 'emailfirst': + return 'emailfirst'; + default: { + const _exhaustive: never = surface; + throw new Error(`Unhandled PasskeySignInSurface: ${_exhaustive}`); + } + } +}; export type PasskeyAuthSuccessOutcome = | 'nopassword' @@ -78,13 +78,20 @@ export type PasskeyAuthSuccessReason = | 'emailfirst_withpassword' | 'emailfirst_createdpassword' | 'signin_nopassword' - | 'signin_withpassword'; + | 'signin_withpassword' + | 'signin_createdpassword' + // `otplogin` and `alternative_auth` are no-password surfaces: they never + // reach the existing-password fallback, so `*_withpassword` is unreachable. + | 'otplogin_nopassword' + | 'otplogin_createdpassword' + | 'alternative_auth_nopassword' + | 'alternative_auth_createdpassword'; export const buildPasskeyAuthSuccessReason = ( - surface: PasskeySignInSurface, + prefix: PasskeyMetricsSurface, outcome: PasskeyAuthSuccessOutcome ): PasskeyAuthSuccessReason => - `${surfaceToAuthSuccessPrefix(surface)}_${outcome}` as PasskeyAuthSuccessReason; + `${prefix}_${outcome}` as PasskeyAuthSuccessReason; interface PasskeySurfaceGleanEvents { submit: () => void; @@ -332,20 +339,14 @@ export function usePasskeySignIn({ const fallbackPath = completion.hasPassword ? '/signin_passkey_fallback' : '/post_verify/set_password'; - // Thread state so the destination page can tag Glean events with - // the passkey context. `passkeySurface` drives both the - // `passkey_enter_password.*` reason on the fallback page and the - // `passkey.auth_success` reason fired from each terminal success - // (existing-password reauth on the fallback page, - // newly-created-password on the set-password page). - // `passwordCreationReason: 'passkey'` drives the - // `post_verify_set_password.*` reason on the set-password page. + // Thread the passkey context so the destination page can tag its Glean + // events with the originating surface. navigateWithQuery(fallbackPath, { state: completion.hasPassword - ? { passkeySurface: surfaceToFallbackReason(surface) } + ? { passkeySurface: toPasskeyMetricsSurface(surface) } : { passwordCreationReason: 'passkey' as const, - passkeySurface: surfaceToFallbackReason(surface), + passkeySurface: toPasskeyMetricsSurface(surface), }, }); return; @@ -385,7 +386,10 @@ export function usePasskeySignIn({ // appropriate `_nopassword` reason for Looker funnels. GleanMetrics.passkey.authSuccess({ event: { - reason: buildPasskeyAuthSuccessReason(surface, 'nopassword'), + reason: buildPasskeyAuthSuccessReason( + toPasskeyMetricsSurface(surface), + 'nopassword' + ), }, }); } diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx index b8edddd4ce6..5aa7108feba 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx @@ -350,7 +350,9 @@ describe('SetPassword-container', () => { it.each([ ['emailfirst' as const, 'emailfirst_createdpassword'], - ['login' as const, 'signin_createdpassword'], + ['signin' as const, 'signin_createdpassword'], + ['otplogin' as const, 'otplogin_createdpassword'], + ['alternative_auth' as const, 'alternative_auth_createdpassword'], ])( 'fires passkey.auth_success with reason="%s" on the passkey-flow createPassword success (passkeySurface=%s)', async (passkeySurface, expectedReason) => { diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts b/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts index 137e787e0b8..84322b88684 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts @@ -4,7 +4,7 @@ import { syncEngineConfigs } from '../../../lib/sync-engines'; import { HandledError } from '../../../lib/error-utils'; -import { PasskeyFallbackSurface } from '../../../lib/passkeys/signin-flow'; +import { PasskeyMetricsSurface } from '../../../lib/passkeys/signin-flow'; import { Integration } from '../../../models'; export interface SetPasswordFormData { @@ -41,11 +41,9 @@ export interface SetPasswordProps { export interface SetPasswordLocationState { passwordCreationReason?: PasswordCreationReason; /** - * Only set when `passwordCreationReason === 'passkey'`. The originating - * passkey sign-in surface (`emailfirst` covers both the email-first page - * and the passwordless OTP code page; `login` covers the signin/login - * page). Used to fire `passkey.auth_success` with the correct - * `_createdpassword` reason after the new Sync password is set. + * Originating passkey sign-in surface; set only for `passwordCreationReason + * === 'passkey'`. Tags the `post_verify_set_password.*` and + * `passkey.auth_success` reasons with the surface. */ - passkeySurface?: PasskeyFallbackSurface; + passkeySurface?: PasskeyMetricsSurface; } diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx index 6307f1db58c..ddafc99dc2e 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx @@ -245,13 +245,13 @@ describe('SigninPasskeyFallback container', () => { it('fires success with the passkeySurface reason after sessionReauth resolves', async () => { mockLocationState = { ...MOCK_LOCATION_STATE, - passkeySurface: 'login', + passkeySurface: 'signin', }; const { getByTestId } = render(); submitPassword(getByTestId); await waitFor(() => { expect(GleanMetrics.passkeyEnterPassword.success).toHaveBeenCalledWith({ - event: { reason: 'login' }, + event: { reason: 'signin' }, }); }); expect( @@ -302,9 +302,11 @@ describe('SigninPasskeyFallback container', () => { expect(GleanMetrics.passkeyEnterPassword.success).not.toHaveBeenCalled(); }); + // Only emailfirst/signin reach the existing-password fallback; no-password + // surfaces (otplogin, alternative_auth) never do. it.each([ ['emailfirst' as const, 'emailfirst_withpassword'], - ['login' as const, 'signin_withpassword'], + ['signin' as const, 'signin_withpassword'], ])( 'fires passkey.auth_success with reason=%s on successful reauth (passkeySurface=%s)', async (surface, expectedReason) => { diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx index 0179ccfd061..fff6a7f3013 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx @@ -69,11 +69,11 @@ describe('SigninPasskeyFallback', () => { renderWithRouter( ); expect(GleanMetrics.passkeyEnterPassword.view).toHaveBeenCalledWith({ - event: { reason: 'login' }, + event: { reason: 'signin' }, }); }); @@ -82,13 +82,13 @@ describe('SigninPasskeyFallback', () => { renderWithRouter( ); await user.type(screen.getByLabelText('Password'), 'a'); await waitFor(() => { expect(GleanMetrics.passkeyEnterPassword.engage).toHaveBeenCalledWith({ - event: { reason: 'login' }, + event: { reason: 'signin' }, }); }); const callsBefore = ( diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx index e8e5e51feb3..ea29d1d48db 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx @@ -13,7 +13,7 @@ import { Banner } from '../../../components/Banner'; import InputPassword from '../../../components/InputPassword'; import GleanMetrics from '../../../lib/glean'; import { AccountAvatar } from '../../../lib/interfaces'; -import { PasskeyFallbackSurface } from '../../../lib/passkeys/signin-flow'; +import { PasskeyMetricsSurface } from '../../../lib/passkeys/signin-flow'; export type SigninPasskeyFallbackProps = { email?: string; @@ -21,7 +21,7 @@ export type SigninPasskeyFallbackProps = { localizedErrorMessage?: string; avatarData?: { account: { avatar: AccountAvatar } }; avatarLoading?: boolean; - passkeySurface?: PasskeyFallbackSurface; + passkeySurface?: PasskeyMetricsSurface; }; type FormData = { diff --git a/packages/fxa-settings/src/pages/Signin/interfaces.ts b/packages/fxa-settings/src/pages/Signin/interfaces.ts index 279fa2b8451..40fea4d2b11 100644 --- a/packages/fxa-settings/src/pages/Signin/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signin/interfaces.ts @@ -8,7 +8,7 @@ import { AuthUiError } from '../../lib/auth-errors/auth-errors'; import { BeginSigninError } from '../../lib/error-utils'; import { AccountAvatar } from '../../lib/interfaces'; import { FinishOAuthFlowHandler } from '../../lib/oauth/hooks'; -import { PasskeyFallbackSurface } from '../../lib/passkeys/signin-flow'; +import { PasskeyMetricsSurface } from '../../lib/passkeys/signin-flow'; import { MozServices } from '../../lib/types'; import { Integration } from '../../models'; import { QueryParams } from '../..'; @@ -302,5 +302,5 @@ export interface SigninLocationState { * Used to populate the `reason` extra on `passkey_enter_password.*` Glean * events. */ - passkeySurface?: PasskeyFallbackSurface; + passkeySurface?: PasskeyMetricsSurface; } diff --git a/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml b/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml index 01e9bf0efa2..13ff424383f 100644 --- a/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml +++ b/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml @@ -2754,8 +2754,8 @@ passkey_enter_password: reason: description: | Sign-in surface the user came from before this page: 'emailfirst' - (email-first page or passwordless OTP code page) or 'login' (login - page with password). + (email-first page) or 'signin' (login page). Only these two surfaces + reach the existing-password page (no-password surfaces never do). type: string engage: type: event @@ -2778,7 +2778,9 @@ passkey_enter_password: extra_keys: reason: description: | - Sign-in surface the user came from: 'emailfirst' or 'login'. + Sign-in surface the user came from: 'emailfirst' (email-first page) + or 'signin' (login page). Only these two surfaces reach the + existing-password page (no-password surfaces never do). type: string submit: type: event @@ -2801,7 +2803,9 @@ passkey_enter_password: extra_keys: reason: description: | - Sign-in surface the user came from: 'emailfirst' or 'login'. + Sign-in surface the user came from: 'emailfirst' (email-first page) + or 'signin' (login page). Only these two surfaces reach the + existing-password page (no-password surfaces never do). type: string submit_frontend_error: type: event @@ -2849,7 +2853,9 @@ passkey_enter_password: extra_keys: reason: description: | - Sign-in surface the user came from: 'emailfirst' or 'login'. + Sign-in surface the user came from: 'emailfirst' (email-first page) + or 'signin' (login page). Only these two surfaces reach the + existing-password page (no-password surfaces never do). type: string passkey: @@ -2878,14 +2884,12 @@ passkey: extra_keys: reason: description: | - Sign-in entry surface paired with the post-auth Sync outcome. - One of: 'emailfirst_nopassword' (came from email-first or the - passwordless OTP page, no Sync password step required), - 'emailfirst_withpassword' (existing Sync password entered after - passkey), 'emailfirst_createdpassword' (new Sync password created - after passkey), 'signin_nopassword' (came from the signin/login - page, no Sync password step required), 'signin_withpassword' - (existing Sync password entered after passkey). + `_`. Surface: 'emailfirst', 'signin', + 'otplogin', or 'alternative_auth'. Outcome: 'nopassword' (no Sync + password step), 'withpassword' (existing password entered), or + 'createdpassword' (new password set). E.g. 'signin_withpassword'. + 'otplogin' and 'alternative_auth' are no-password surfaces, so they + pair only with 'nopassword'/'createdpassword' (never 'withpassword'). type: string account_pref: From cc160f9448fc9d8fb1c96fa8bfe9ccf11747050e Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Thu, 11 Jun 2026 13:44:53 -0700 Subject: [PATCH 2/4] feat(fxa-settings): add passkey.button_view impression event Because: - Only page-level view events existed for the passkey sign-in surfaces, so click-through on the "Sign in with passkey" button could not be measured. This commit: - Adds a per-surface passkey.button_view Glean event, fired once from usePasskeySignIn when the button is actually rendered (gated by a new isButtonVisible flag each surface passes), so impressions count buttons shown rather than hook mounts. --- .../fxa-settings/src/lib/glean/index.test.ts | 12 ++++++++ packages/fxa-settings/src/lib/glean/index.ts | 5 ++++ .../src/lib/passkeys/signin-flow.test.tsx | 29 +++++++++++++++++++ .../src/lib/passkeys/signin-flow.ts | 24 ++++++++++++++- .../fxa-settings/src/pages/Index/index.tsx | 1 + .../Signin/SigninPasswordlessCode/index.tsx | 1 + .../SigninAlternativeAuthOptions/index.tsx | 1 + .../src/pages/Signin/index.test.tsx | 4 +++ .../fxa-settings/src/pages/Signin/index.tsx | 13 +++++---- .../metrics/glean/fxa-ui-metrics.yaml | 26 +++++++++++++++++ .../fxa-shared/metrics/glean/web/index.ts | 1 + .../fxa-shared/metrics/glean/web/passkey.ts | 20 +++++++++++++ 12 files changed, 130 insertions(+), 7 deletions(-) diff --git a/packages/fxa-settings/src/lib/glean/index.test.ts b/packages/fxa-settings/src/lib/glean/index.test.ts index 8647fee2235..d61080a4978 100644 --- a/packages/fxa-settings/src/lib/glean/index.test.ts +++ b/packages/fxa-settings/src/lib/glean/index.test.ts @@ -799,6 +799,18 @@ describe('lib/glean', () => { }); describe('passkey', () => { + it.each(['emailfirst', 'signin', 'otplogin', 'alternative_auth'])( + 'submits passkey_button_view with reason=%s', + async (reason) => { + GleanMetrics.passkey.buttonView({ event: { reason } }); + await GleanMetrics.isDone(); + sinon.assert.calledOnce(setEventNameStub); + sinon.assert.calledWith(setEventNameStub, 'passkey_button_view'); + sinon.assert.calledOnce(setEventReasonStub); + sinon.assert.calledWith(setEventReasonStub, reason); + } + ); + it.each([ 'emailfirst_nopassword', 'emailfirst_withpassword', diff --git a/packages/fxa-settings/src/lib/glean/index.ts b/packages/fxa-settings/src/lib/glean/index.ts index 8817845214e..8a4dd5344c7 100644 --- a/packages/fxa-settings/src/lib/glean/index.ts +++ b/packages/fxa-settings/src/lib/glean/index.ts @@ -832,6 +832,11 @@ const recordEventMetric = ( reason: gleanPingMetrics?.event?.['reason'] || '', }); break; + case 'passkey_button_view': + passkey.buttonView.record({ + reason: gleanPingMetrics?.event?.['reason'] || '', + }); + break; case 'passkey_auth_success': passkey.authSuccess.record({ reason: gleanPingMetrics?.event?.['reason'] || '', diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx index 499cc19764a..c1150e74082 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx @@ -65,6 +65,7 @@ jest.mock('../glean', () => ({ passkeySubmitSuccess: jest.fn(), }, passkey: { + buttonView: jest.fn(), authSuccess: jest.fn(), }, }, @@ -648,6 +649,34 @@ describe('usePasskeySignIn', () => { }); describe('Glean metrics', () => { + it.each([ + ['emailfirst' as const, 'emailfirst'], + ['login' as const, 'signin'], + ['login_otp' as const, 'otplogin'], + ['alternative_auth' as const, 'alternative_auth'], + ])( + 'fires passkey.button_view with reason=%s when the button is visible (surface=%s)', + async (surface, expectedReason) => { + const { args } = buildArgs({ surface, isButtonVisible: true }); + renderHook(() => usePasskeySignIn(args)); + + expect(GleanMetrics.passkey.buttonView).toHaveBeenCalledTimes(1); + expect(GleanMetrics.passkey.buttonView).toHaveBeenCalledWith({ + event: { reason: expectedReason }, + }); + } + ); + + it('does NOT fire passkey.button_view when the button is hidden', async () => { + const { args } = buildArgs({ + surface: 'login', + isButtonVisible: false, + }); + renderHook(() => usePasskeySignIn(args)); + + expect(GleanMetrics.passkey.buttonView).not.toHaveBeenCalled(); + }); + it.each([ ['emailfirst' as const], ['login' as const], diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts index ce54ec518f0..a3d308ae057 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts @@ -2,7 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import * as Sentry from '@sentry/browser'; import type AuthClient from 'fxa-auth-client/browser'; import { FtlMsgResolver } from 'fxa-react/lib/utils'; @@ -183,6 +189,12 @@ export interface UsePasskeySignInArgs { navigateWithQuery: ReturnType; queryParams: string; surface: PasskeySignInSurface; + /** + * Whether the passkey button is rendered on this surface. Drives the + * `passkey.button_view` impression so it counts buttons shown, not hook + * mounts. + */ + isButtonVisible?: boolean; } export interface UsePasskeySignInResult { @@ -199,11 +211,21 @@ export function usePasskeySignIn({ navigateWithQuery, queryParams, surface, + isButtonVisible = false, }: UsePasskeySignInArgs): UsePasskeySignInResult { const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(); const inFlight = useRef(false); + // One impression per surface when the button is shown, so click-through is measurable. + useEffect(() => { + if (isButtonVisible) { + GleanMetrics.passkey.buttonView({ + event: { reason: toPasskeyMetricsSurface(surface) }, + }); + } + }, [isButtonVisible, surface]); + const errorBanner = useMemo( () => errorMessage diff --git a/packages/fxa-settings/src/pages/Index/index.tsx b/packages/fxa-settings/src/pages/Index/index.tsx index 1a908438686..c33245dc199 100644 --- a/packages/fxa-settings/src/pages/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Index/index.tsx @@ -67,6 +67,7 @@ export const Index = ({ navigateWithQuery, queryParams: location.search, surface: 'emailfirst', + isButtonVisible: showPasskeySignin, }); const handlePasskeyClick = () => { // Cancel any pending suggested-email auto-submit so it can't override diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx index 0c136806aef..a12a37112cf 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx @@ -96,6 +96,7 @@ const SigninPasswordlessCode = ({ navigateWithQuery, queryParams: location.search, surface: 'login_otp', + isButtonVisible: showPasskeySignin, }); const [localizedErrorBannerMessage, setLocalizedErrorBannerMessage] = diff --git a/packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx b/packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx index f5a3a003d2b..cf84132b9ef 100644 --- a/packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx @@ -60,6 +60,7 @@ const SigninAlternativeAuthOptions = ({ navigateWithQuery, queryParams: location.search, surface: 'alternative_auth', + isButtonVisible: showPasskeySignin, }); const { diff --git a/packages/fxa-settings/src/pages/Signin/index.test.tsx b/packages/fxa-settings/src/pages/Signin/index.test.tsx index 7d9fcf50bb7..9beb04e7763 100644 --- a/packages/fxa-settings/src/pages/Signin/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.test.tsx @@ -88,6 +88,10 @@ jest.mock('../../lib/glean', () => ({ appleDeeplink: jest.fn(), googleDeeplink: jest.fn(), }, + passkey: { + buttonView: jest.fn(), + authSuccess: jest.fn(), + }, }, })); diff --git a/packages/fxa-settings/src/pages/Signin/index.tsx b/packages/fxa-settings/src/pages/Signin/index.tsx index 08d7cee971b..55c02ba5612 100644 --- a/packages/fxa-settings/src/pages/Signin/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.tsx @@ -63,6 +63,12 @@ const Signin = ({ const sensitiveDataClient = useSensitiveDataClient(); const authClient = useAuthClient(); + // Button stays visible without WebAuthn support; the hook surfaces an error. + const showPasskeySignin = !!( + config.featureFlags?.passkeysEnabled && + config.featureFlags?.passkeyAuthenticationEnabled + ); + const passkey = usePasskeySignIn({ integration, authClient, @@ -71,6 +77,7 @@ const Signin = ({ navigateWithQuery, queryParams: location.search, surface: 'login', + isButtonVisible: showPasskeySignin, }); const [localizedBannerError, setLocalizedBannerError] = useState( @@ -101,12 +108,6 @@ const Signin = ({ const isServiceWithEmailVerification = !!clientId && config.servicesWithEmailVerification.includes(clientId); - // Button stays visible without WebAuthn support; the hook surfaces an error. - const showPasskeySignin = !!( - config.featureFlags?.passkeysEnabled && - config.featureFlags?.passkeyAuthenticationEnabled - ); - const localizedPasswordFormLabel = ftlMsgResolver.getMsg( 'signin-password-button-label', 'Password' diff --git a/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml b/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml index 13ff424383f..ff18f09b732 100644 --- a/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml +++ b/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml @@ -2859,6 +2859,32 @@ passkey_enter_password: type: string passkey: + button_view: + type: event + description: | + The "Sign in with passkey" button was shown on a sign-in surface. Fires + once per surface render (only when the passkey feature flags are on), so + click-through to the per-surface `*_passkey_submit` event is measurable. + send_in_pings: + - events + notification_emails: + - vzare@mozilla.com + - fxa-staff@mozilla.com + bugs: + - https://mozilla-hub.atlassian.net/browse/FXA-13896 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1830504 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1844121 + expires: never + data_sensitivity: + - interaction + extra_keys: + reason: + description: | + Sign-in surface the button is shown on: 'emailfirst' (email-first + page), 'signin' (login page), 'otplogin' (passwordless OTP code + page), or 'alternative_auth' (alternative-auth options page). + type: string auth_success: type: event description: | diff --git a/packages/fxa-shared/metrics/glean/web/index.ts b/packages/fxa-shared/metrics/glean/web/index.ts index 8a4d2148b9e..cdce0f35137 100644 --- a/packages/fxa-shared/metrics/glean/web/index.ts +++ b/packages/fxa-shared/metrics/glean/web/index.ts @@ -172,6 +172,7 @@ export const eventsMap = { }, passkey: { + buttonView: 'passkey_button_view', authSuccess: 'passkey_auth_success', }, diff --git a/packages/fxa-shared/metrics/glean/web/passkey.ts b/packages/fxa-shared/metrics/glean/web/passkey.ts index 4af5b0cc607..bd7274e6068 100644 --- a/packages/fxa-shared/metrics/glean/web/passkey.ts +++ b/packages/fxa-shared/metrics/glean/web/passkey.ts @@ -28,3 +28,23 @@ export const authSuccess = new EventMetricType<{ }, ['reason'] ); + +/** + * The "Sign in with passkey" button was shown on a sign-in surface. Fires + * once per surface render (only when the passkey feature flags are on), so + * click-through to the per-surface `*_passkey_submit` event is measurable. + * + * Generated from `passkey.button_view`. + */ +export const buttonView = new EventMetricType<{ + reason?: string; +}>( + { + category: 'passkey', + name: 'button_view', + sendInPings: ['events'], + lifetime: 'ping', + disabled: false, + }, + ['reason'] +); From fd4d20517de9ccae110765b34d2c38f84cb6274b Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Thu, 11 Jun 2026 13:44:53 -0700 Subject: [PATCH 3/4] feat(fxa-settings): tag set-password funnel with passkey surface Because: - The shared post_verify_set_password.* events recorded only the password creation reason ('passkey'), so the create-password funnel could not be split by the originating passkey sign-in surface. This commit: - Composes a surface-tagged reason (`_passkey`, e.g. signin_passkey) in SetPassword's container and passes it to the page as a ready-made gleanReason, keeping the shared page flow-agnostic. - Defaults a missing surface to `emailfirst` (`emailfirst_passkey`), matching the passkey.auth_success default, so the reason stays in-vocabulary. --- .../PostVerify/SetPassword/container.test.tsx | 36 ++++++++++++++++++- .../PostVerify/SetPassword/container.tsx | 10 +++++- .../PostVerify/SetPassword/index.test.tsx | 12 +++++++ .../pages/PostVerify/SetPassword/index.tsx | 15 ++++---- .../PostVerify/SetPassword/interfaces.ts | 6 ++++ .../pages/PostVerify/SetPassword/mocks.tsx | 3 ++ .../metrics/glean/fxa-ui-metrics.yaml | 24 +++++++++---- 7 files changed, 90 insertions(+), 16 deletions(-) diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx index 5aa7108feba..74e0f0da5ab 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx @@ -329,7 +329,9 @@ describe('SetPassword-container', () => { ); }); - it.each(['otp' as const, 'passkey' as const])( + // Passkey reasons are surface-tagged (`_passkey`) and covered by + // the dedicated tests below; non-passkey reasons pass through unchanged. + it.each(['third_party_auth' as const, 'otp' as const])( 'fires postVerifySetPassword.success with reason="%s" when passwordCreationReason matches', async (reason) => { mockLocation.state = { passwordCreationReason: reason }; @@ -375,6 +377,33 @@ describe('SetPassword-container', () => { } ); + it.each([ + ['emailfirst' as const, 'emailfirst_passkey'], + ['signin' as const, 'signin_passkey'], + ['otplogin' as const, 'otplogin_passkey'], + ['alternative_auth' as const, 'alternative_auth_passkey'], + ])( + 'tags postVerifySetPassword.success with the surface reason (passkeySurface=%s -> %s)', + async (passkeySurface, expectedReason) => { + mockLocation.state = { + passwordCreationReason: 'passkey', + passkeySurface, + }; + render(); + + await waitFor(() => { + expect(currentSetPasswordProps?.createPasswordHandler).toBeDefined(); + }); + await act(async () => { + await currentSetPasswordProps?.createPasswordHandler(MOCK_PASSWORD); + }); + + expect(GleanMetrics.postVerifySetPassword.success).toHaveBeenCalledWith( + { event: { reason: expectedReason } } + ); + } + ); + it('defaults passkeySurface to emailfirst when missing from router state', async () => { mockLocation.state = { passwordCreationReason: 'passkey' }; render(); @@ -387,6 +416,11 @@ describe('SetPassword-container', () => { expect(GleanMetrics.passkey.authSuccess).toHaveBeenCalledWith({ event: { reason: 'emailfirst_createdpassword' }, }); + // The funnel reason defaults the same way, so it stays in-vocabulary + // (`emailfirst_passkey`) rather than emitting a bare `passkey`. + expect(GleanMetrics.postVerifySetPassword.success).toHaveBeenCalledWith({ + event: { reason: 'emailfirst_passkey' }, + }); }); it('does NOT fire passkey.auth_success for non-passkey flows', async () => { diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx index 802d1387490..234db33226d 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx @@ -63,6 +63,12 @@ const SetPasswordContainer = ({ const passwordCreationReason = location.state?.passwordCreationReason ?? 'third_party_auth'; const passkeySurface = location.state?.passkeySurface; + // For the passkey flow, tag the reason with the originating surface (e.g. + // `signin_passkey`) so this shared page's funnel can be split per surface. + const gleanReason = + passwordCreationReason === 'passkey' + ? `${passkeySurface ?? 'emailfirst'}_passkey` + : passwordCreationReason; const metricsContext = queryParamsToMetricsContext( flowQueryParams as unknown as Record ); @@ -149,7 +155,7 @@ const SetPasswordContainer = ({ persistAccount({ uid, hasPassword: true }); GleanMetrics.postVerifySetPassword.success({ - event: { reason: passwordCreationReason }, + event: { reason: gleanReason }, }); // For passkey flow, fire the consolidated terminal-success event @@ -213,6 +219,7 @@ const SetPasswordContainer = ({ getKeyFetchToken, passwordCreationReason, passkeySurface, + gleanReason, offeredSyncEngines, location.search, ] @@ -254,6 +261,7 @@ const SetPasswordContainer = ({ offeredSyncEngineConfigs, integration, passwordCreationReason, + gleanReason, }} /> ); diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.test.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.test.tsx index b362cb7e8fd..d6c56b0e859 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.test.tsx @@ -88,6 +88,18 @@ describe('SetPassword page', () => { } ); + it('uses the container-supplied gleanReason for the funnel events', () => { + renderWithLocalizationProvider( + + ); + expect(GleanMetrics.postVerifySetPassword.view).toHaveBeenCalledWith({ + event: { reason: 'signin_passkey' }, + }); + }); + it('fires postVerifySetPassword.engage on first keystroke', async () => { const user = userEvent.setup(); renderWithLocalizationProvider( diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.tsx index a9790f41c21..920ea756c48 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/index.tsx @@ -23,6 +23,7 @@ export const SetPassword = ({ offeredSyncEngineConfigs, integration, passwordCreationReason = 'third_party_auth', + gleanReason = passwordCreationReason, }: SetPasswordProps) => { const ftlMsgResolver = useFtlMsgResolver(); const [createPasswordLoading, setCreatePasswordLoading] = @@ -31,14 +32,14 @@ export const SetPassword = ({ useEffect(() => { GleanMetrics.postVerifySetPassword.view({ - event: { reason: passwordCreationReason }, + event: { reason: gleanReason }, }); - }, [passwordCreationReason]); + }, [gleanReason]); const onSubmit = useCallback( async ({ newPassword }: SetPasswordFormData) => { GleanMetrics.postVerifySetPassword.submit({ - event: { reason: passwordCreationReason }, + event: { reason: gleanReason }, }); setCreatePasswordLoading(true); setBannerErrorText(''); @@ -51,7 +52,7 @@ export const SetPassword = ({ error ); GleanMetrics.postVerifySetPassword.submitFrontendError({ - event: { reason: passwordCreationReason }, + event: { reason: gleanReason }, }); setBannerErrorText(localizedErrorMessage); // if the request errored, loading state must be marked as false to reenable submission @@ -59,7 +60,7 @@ export const SetPassword = ({ return; } }, - [createPasswordHandler, ftlMsgResolver, passwordCreationReason] + [createPasswordHandler, ftlMsgResolver, gleanReason] ); const { @@ -89,10 +90,10 @@ export const SetPassword = ({ if (hasEngaged === false && newPasswordValue) { setHasEngaged(true); GleanMetrics.postVerifySetPassword.engage({ - event: { reason: passwordCreationReason }, + event: { reason: gleanReason }, }); } - }, [hasEngaged, newPasswordValue, passwordCreationReason]); + }, [hasEngaged, newPasswordValue, gleanReason]); const cmsInfo = integration?.getCmsInfo?.(); const cmsPage = cmsInfo?.PostVerifySetPasswordPage; diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts b/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts index 84322b88684..120bd13f9d2 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts @@ -36,6 +36,12 @@ export interface SetPasswordProps { offeredSyncEngineConfigs?: typeof syncEngineConfigs; integration?: PostVerifySetPasswordIntegration; passwordCreationReason?: PasswordCreationReason; + /** + * Glean `reason` for the funnel events, composed by the container. Defaults + * to `passwordCreationReason`; the passkey flow passes a surface-tagged + * value (e.g. `signin_passkey`). + */ + gleanReason?: string; } export interface SetPasswordLocationState { diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/mocks.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/mocks.tsx index b5e6f88af87..5398889ec94 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/mocks.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/mocks.tsx @@ -26,11 +26,13 @@ export const Subject = ({ createPasswordHandler = () => Promise.resolve({ error: null }), integration, passwordCreationReason, + gleanReason, }: { email?: string; createPasswordHandler?: CreatePasswordHandler; integration?: PostVerifySetPasswordIntegration; passwordCreationReason?: PasswordCreationReason; + gleanReason?: string; }) => { const { offeredSyncEngineConfigs } = mockUseFxAStatus(); return ( @@ -42,6 +44,7 @@ export const Subject = ({ offeredSyncEngineConfigs, integration, passwordCreationReason, + gleanReason, }} /> diff --git a/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml b/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml index ff18f09b732..8cfa1b24e5f 100644 --- a/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml +++ b/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml @@ -2627,7 +2627,9 @@ post_verify_set_password: description: | Which sign-in flow brought the user to the set-password page: 'third_party_auth' (Google/Apple OAuth), 'otp' (passwordless OTP - code), or 'passkey' (WebAuthn passkey). + code), or — for the passkey flow — '_passkey' where surface + is 'emailfirst', 'signin', 'otplogin', or 'alternative_auth' (the + passkey entry surface, e.g. 'signin_passkey'). type: string engage: type: event @@ -2653,7 +2655,9 @@ post_verify_set_password: reason: description: | Which sign-in flow brought the user to the set-password page: - 'third_party_auth', 'otp', or 'passkey'. + 'third_party_auth', 'otp', or '_passkey' for the passkey + flow (e.g. 'signin_passkey'; see the `view` event for the surface + values). type: string submit: type: event @@ -2678,7 +2682,9 @@ post_verify_set_password: reason: description: | Which sign-in flow brought the user to the set-password page: - 'third_party_auth', 'otp', or 'passkey'. + 'third_party_auth', 'otp', or '_passkey' for the passkey + flow (e.g. 'signin_passkey'; see the `view` event for the surface + values). type: string submit_frontend_error: type: event @@ -2701,9 +2707,11 @@ post_verify_set_password: reason: description: | Which sign-in flow brought the user to the set-password page: - 'third_party_auth', 'otp', or 'passkey'. Client-side validation - errors prevent submission rather than emitting this event, so the - captured failures are server-side rejections. + 'third_party_auth', 'otp', or '_passkey' for the passkey + flow (e.g. 'signin_passkey'; see the `view` event for the surface + values). Client-side validation errors prevent submission rather + than emitting this event, so the captured failures are server-side + rejections. type: string success: type: event @@ -2728,7 +2736,9 @@ post_verify_set_password: reason: description: | Which sign-in flow brought the user to the set-password page: - 'third_party_auth', 'otp', or 'passkey'. + 'third_party_auth', 'otp', or '_passkey' for the passkey + flow (e.g. 'signin_passkey'; see the `view` event for the surface + values). type: string passkey_enter_password: From 3d2845bfab47f29e803b8b3515397c6baadcf134 Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Thu, 11 Jun 2026 13:44:53 -0700 Subject: [PATCH 4/4] feat(auth-server): add server-side passkey auth funnel events Because: - The backend recorded passkey authentication completion (login.complete, reason=passkey) but neither the ceremony start nor the assertion verification, so server-side drop-off was invisible. This commit: - Adds passkey.authentication_started (challenge generated) and passkey.authentication_verification_success (assertion verified, before session/token work), giving a started -> verified -> complete funnel. - Regenerates the server Glean bindings (glean_parser v19.0.0 -> v19.2.0). --- .../lib/metrics/glean/index.spec.ts | 6 + .../lib/metrics/glean/index.ts | 4 + .../lib/metrics/glean/server_events.ts | 172 +++++++++++++++++- .../lib/routes/passkeys.spec.ts | 45 +++++ .../fxa-auth-server/lib/routes/passkeys.ts | 7 +- .../metrics/glean/fxa-backend-metrics.yaml | 41 +++++ 6 files changed, 269 insertions(+), 6 deletions(-) diff --git a/packages/fxa-auth-server/lib/metrics/glean/index.spec.ts b/packages/fxa-auth-server/lib/metrics/glean/index.spec.ts index fa8f0d11b64..d66588b3516 100644 --- a/packages/fxa-auth-server/lib/metrics/glean/index.spec.ts +++ b/packages/fxa-auth-server/lib/metrics/glean/index.spec.ts @@ -177,6 +177,12 @@ jest.mock('./server_events', () => ({ recordLoginConfirmSkipForKnownDevice: mockFn( 'recordLoginConfirmSkipForKnownDevice' ), + recordPasskeyAuthenticationStarted: mockFn( + 'recordPasskeyAuthenticationStarted' + ), + recordPasskeyAuthenticationVerificationSuccess: mockFn( + 'recordPasskeyAuthenticationVerificationSuccess' + ), recordPasskeyCreateComplete: mockFn('recordPasskeyCreateComplete'), recordPasskeyDeleteSuccess: mockFn('recordPasskeyDeleteSuccess'), recordPasskeyRenameSuccess: mockFn('recordPasskeyRenameSuccess'), diff --git a/packages/fxa-auth-server/lib/metrics/glean/index.ts b/packages/fxa-auth-server/lib/metrics/glean/index.ts index c7f60560c9c..4acb3be856b 100644 --- a/packages/fxa-auth-server/lib/metrics/glean/index.ts +++ b/packages/fxa-auth-server/lib/metrics/glean/index.ts @@ -442,6 +442,10 @@ export function gleanMetrics(config: ConfigType) { knownDevice: createEventFn('login_confirm_skip_for_known_device'), }, passkey: { + authenticationStarted: createEventFn('passkey_authentication_started'), + authenticationVerificationSuccess: createEventFn( + 'passkey_authentication_verification_success' + ), createComplete: createEventFn('passkey_create_complete'), deleteSuccess: createEventFn('passkey_delete_success'), renameSuccess: createEventFn('passkey_rename_success'), diff --git a/packages/fxa-auth-server/lib/metrics/glean/server_events.ts b/packages/fxa-auth-server/lib/metrics/glean/server_events.ts index 7389275fae9..1008129de24 100644 --- a/packages/fxa-auth-server/lib/metrics/glean/server_events.ts +++ b/packages/fxa-auth-server/lib/metrics/glean/server_events.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -// AUTOGENERATED BY glean_parser v19.0.0. DO NOT EDIT. DO NOT COMMIT. +// AUTOGENERATED BY glean_parser v19.2.0. DO NOT EDIT. DO NOT COMMIT. // This requires `uuid` and `mozlog` libraries to be in the environment // @types/uuid and mozlog types definitions are required in devDependencies @@ -146,7 +146,7 @@ class AccountsEventsServerEvent { }, // `Unknown` fields below are required in the Glean schema, however they are not useful in server context client_info: { - telemetry_sdk_build: 'glean_parser v19.0.0', + telemetry_sdk_build: 'glean_parser v19.2.0', first_run_date: 'Unknown', os: 'Unknown', os_version: 'Unknown', @@ -272,7 +272,7 @@ class EventsServerEventLogger { }, // `Unknown` fields below are required in the Glean schema, however they are not useful in server context client_info: { - telemetry_sdk_build: 'glean_parser v19.0.0', + telemetry_sdk_build: 'glean_parser v19.2.0', first_run_date: 'Unknown', os: 'Unknown', os_version: 'Unknown', @@ -2194,7 +2194,7 @@ class EventsServerEventLogger { * @param {string} utm_medium - The "medium" on which the user acted. For example, if the user clicked on a link in an email, then the value of this metric would be 'email'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. * @param {string} utm_source - The source from where the user started. For example, if the user clicked on a link on the Mozilla accounts web site, this value could be 'fx-website'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. * @param {string} utm_term - This metric is similar to the `utm.source`; it is used in the Firefox browser. For example, if the user started from about:welcome, then the value could be 'aboutwelcome-default-screen'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. - * @param {string} reason - Indicates how the user completed login. Values include "email" (traditional email/password), "otp" (passwordless OTP code), "google" (third-party login via Google), "apple" (third-party login via Apple).. + * @param {string} reason - Indicates how the user completed login. Values include "email" (traditional email/password), "otp" (passwordless OTP code), "google" (third-party login via Google), "apple" (third-party login via Apple), "passkey" (WebAuthn passkey).. */ recordLoginComplete({ user_agent, @@ -3086,6 +3086,170 @@ class EventsServerEventLogger { event, }); } + /** + * Record and submit a passkey_authentication_started event: + * Passkey authentication (assertion) ceremony started on the server: a challenge was generated for the client. Pairs with the login.complete (reason=passkey) completion signal to measure the server-side passkey sign-in funnel. + * Event is logged using internal mozlog logger. + * + * @param {string} user_agent - The user agent. + * @param {string} ip_address - The IP address. Will be used to decode Geo + * information and scrubbed at ingestion. + * @param {string} account_user_id - The firefox/mozilla account id. + * @param {string} account_user_id_sha256 - A hex string of a sha256 hash of the account's uid. + * @param {string} relying_party_oauth_client_id - The client id of the relying party. + * @param {string} relying_party_service - The service name of the relying party. + * @param {string} session_device_type - one of 'mobile', 'tablet', or ''. + * @param {string} session_entrypoint - Entrypoint to the service. + * @param {string} session_entrypoint_experiment - Identifier for the experiment the user is part of at the entrypoint. + * @param {string} session_entrypoint_variation - Identifier for the experiment variation the user is part of at the entrypoint. + * @param {string} session_flow_id - an ID generated by FxA for its flow metrics. + * @param {string} utm_campaign - A marketing campaign. For example, if a user signs into FxA from selecting a Mozilla VPN plan on Mozilla VPN's product site, then the value of this metric could be 'vpn-product-page'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters. The special value of 'page+referral+-+not+part+of+a+campaign' is also allowed.. + * @param {string} utm_content - The content on which the user acted. For example, if the user clicked on the (previously available) "Get started here" link in "Looking for Firefox Sync? Get started here", then the value for this metric would be 'fx-sync-get-started'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. + * @param {string} utm_medium - The "medium" on which the user acted. For example, if the user clicked on a link in an email, then the value of this metric would be 'email'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. + * @param {string} utm_source - The source from where the user started. For example, if the user clicked on a link on the Mozilla accounts web site, this value could be 'fx-website'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. + * @param {string} utm_term - This metric is similar to the `utm.source`; it is used in the Firefox browser. For example, if the user started from about:welcome, then the value could be 'aboutwelcome-default-screen'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. + */ + recordPasskeyAuthenticationStarted({ + user_agent, + ip_address, + account_user_id, + account_user_id_sha256, + relying_party_oauth_client_id, + relying_party_service, + session_device_type, + session_entrypoint, + session_entrypoint_experiment, + session_entrypoint_variation, + session_flow_id, + utm_campaign, + utm_content, + utm_medium, + utm_source, + utm_term, + }: { + user_agent: string; + ip_address: string; + account_user_id: string; + account_user_id_sha256: string; + relying_party_oauth_client_id: string; + relying_party_service: string; + session_device_type: string; + session_entrypoint: string; + session_entrypoint_experiment: string; + session_entrypoint_variation: string; + session_flow_id: string; + utm_campaign: string; + utm_content: string; + utm_medium: string; + utm_source: string; + utm_term: string; + }) { + const event = { + category: 'passkey', + name: 'authentication_started', + }; + this.#record({ + user_agent, + ip_address, + account_user_id, + account_user_id_sha256, + relying_party_oauth_client_id, + relying_party_service, + session_device_type, + session_entrypoint, + session_entrypoint_experiment, + session_entrypoint_variation, + session_flow_id, + utm_campaign, + utm_content, + utm_medium, + utm_source, + utm_term, + event, + }); + } + /** + * Record and submit a passkey_authentication_verification_success event: + * Passkey authentication assertion verified successfully on the server, before any session/token work. Sits between passkey.authentication_started and login.complete (reason=passkey) so failures during token/session creation are distinguishable from a failed WebAuthn ceremony. + * Event is logged using internal mozlog logger. + * + * @param {string} user_agent - The user agent. + * @param {string} ip_address - The IP address. Will be used to decode Geo + * information and scrubbed at ingestion. + * @param {string} account_user_id - The firefox/mozilla account id. + * @param {string} account_user_id_sha256 - A hex string of a sha256 hash of the account's uid. + * @param {string} relying_party_oauth_client_id - The client id of the relying party. + * @param {string} relying_party_service - The service name of the relying party. + * @param {string} session_device_type - one of 'mobile', 'tablet', or ''. + * @param {string} session_entrypoint - Entrypoint to the service. + * @param {string} session_entrypoint_experiment - Identifier for the experiment the user is part of at the entrypoint. + * @param {string} session_entrypoint_variation - Identifier for the experiment variation the user is part of at the entrypoint. + * @param {string} session_flow_id - an ID generated by FxA for its flow metrics. + * @param {string} utm_campaign - A marketing campaign. For example, if a user signs into FxA from selecting a Mozilla VPN plan on Mozilla VPN's product site, then the value of this metric could be 'vpn-product-page'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters. The special value of 'page+referral+-+not+part+of+a+campaign' is also allowed.. + * @param {string} utm_content - The content on which the user acted. For example, if the user clicked on the (previously available) "Get started here" link in "Looking for Firefox Sync? Get started here", then the value for this metric would be 'fx-sync-get-started'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. + * @param {string} utm_medium - The "medium" on which the user acted. For example, if the user clicked on a link in an email, then the value of this metric would be 'email'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. + * @param {string} utm_source - The source from where the user started. For example, if the user clicked on a link on the Mozilla accounts web site, this value could be 'fx-website'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. + * @param {string} utm_term - This metric is similar to the `utm.source`; it is used in the Firefox browser. For example, if the user started from about:welcome, then the value could be 'aboutwelcome-default-screen'. The value has a max length of 128 characters with the alphanumeric characters, _ (underscore), forward slash (/), . (period), % (percentage sign), and - (hyphen) in the allowed set of characters.. + */ + recordPasskeyAuthenticationVerificationSuccess({ + user_agent, + ip_address, + account_user_id, + account_user_id_sha256, + relying_party_oauth_client_id, + relying_party_service, + session_device_type, + session_entrypoint, + session_entrypoint_experiment, + session_entrypoint_variation, + session_flow_id, + utm_campaign, + utm_content, + utm_medium, + utm_source, + utm_term, + }: { + user_agent: string; + ip_address: string; + account_user_id: string; + account_user_id_sha256: string; + relying_party_oauth_client_id: string; + relying_party_service: string; + session_device_type: string; + session_entrypoint: string; + session_entrypoint_experiment: string; + session_entrypoint_variation: string; + session_flow_id: string; + utm_campaign: string; + utm_content: string; + utm_medium: string; + utm_source: string; + utm_term: string; + }) { + const event = { + category: 'passkey', + name: 'authentication_verification_success', + }; + this.#record({ + user_agent, + ip_address, + account_user_id, + account_user_id_sha256, + relying_party_oauth_client_id, + relying_party_service, + session_device_type, + session_entrypoint, + session_entrypoint_experiment, + session_entrypoint_variation, + session_flow_id, + utm_campaign, + utm_content, + utm_medium, + utm_source, + utm_term, + event, + }); + } /** * Record and submit a passkey_create_complete event: * Passkey registration completed successfully on the server diff --git a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts index f36565daf6f..042c561c244 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts @@ -120,6 +120,8 @@ describe('passkeys routes', () => { }; glean = { passkey: { + authenticationStarted: jest.fn(), + authenticationVerificationSuccess: jest.fn(), createComplete: jest.fn(), deleteSuccess: jest.fn(), renameSuccess: jest.fn(), @@ -997,6 +999,19 @@ describe('passkeys routes', () => { ).toHaveBeenCalledWith(); }); + it('records glean.passkey.authenticationStarted with the request', async () => { + const request = { + auth: { credentials: {} }, + app: { ua: {} }, + }; + await runTest('/passkey/authentication/start', request); + + expect(glean.passkey.authenticationStarted).toHaveBeenCalledTimes(1); + expect(glean.passkey.authenticationStarted).toHaveBeenCalledWith( + expect.objectContaining({ auth: { credentials: {} } }) + ); + }); + it('enforces rate limiting via customs.checkIpOnly', async () => { await runTest('/passkey/authentication/start', { auth: { credentials: {} }, @@ -1125,6 +1140,36 @@ describe('passkeys routes', () => { expect(glean.login.complete).not.toHaveBeenCalled(); }); + it('emits glean.passkey.authenticationVerificationSuccess on a verified assertion', async () => { + await runTest('/passkey/authentication/finish', { + auth: { credentials: {} }, + app: { ua: {} }, + payload, + }); + + expect( + glean.passkey.authenticationVerificationSuccess + ).toHaveBeenCalledTimes(1); + }); + + it('does not emit glean.passkey.authenticationVerificationSuccess when verification fails', async () => { + mockPasskeyService.verifyAuthenticationResponse = jest + .fn() + .mockRejectedValue(AppError.passkeyAuthenticationFailed()); + + await expect(() => + runTest('/passkey/authentication/finish', { + auth: { credentials: {} }, + app: { ua: {} }, + payload, + }) + ).rejects.toThrow(); + + expect( + glean.passkey.authenticationVerificationSuccess + ).not.toHaveBeenCalled(); + }); + it('enforces rate limiting via customs.checkIpOnly', async () => { await runTest('/passkey/authentication/finish', { auth: { credentials: {} }, diff --git a/packages/fxa-auth-server/lib/routes/passkeys.ts b/packages/fxa-auth-server/lib/routes/passkeys.ts index 351e7bfd9b0..52a65b7da71 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.ts @@ -367,8 +367,7 @@ export class PasskeyHandler { const options = await this.service.generateAuthenticationChallenge(); - // TODO: FXA-12914 — Glean event name needs to be defined in the Glean schema - // await this.glean.passkey.authenticationStarted(request); + await this.glean.passkey.authenticationStarted(request); return options; } @@ -410,6 +409,10 @@ export class PasskeyHandler { throw err; } + // Assertion verified; fired before token/session work so failures in + // that step are distinguishable from a failed ceremony. + await this.glean.passkey.authenticationVerificationSuccess(request); + const account = await this.db.account(uid); const sessionToken = await this.createPasskeySessionToken( diff --git a/packages/fxa-shared/metrics/glean/fxa-backend-metrics.yaml b/packages/fxa-shared/metrics/glean/fxa-backend-metrics.yaml index 4c081825480..aef9d9aea29 100644 --- a/packages/fxa-shared/metrics/glean/fxa-backend-metrics.yaml +++ b/packages/fxa-shared/metrics/glean/fxa-backend-metrics.yaml @@ -1689,6 +1689,47 @@ login_confirm_skip_for: - interaction passkey: + authentication_started: + type: event + description: | + Passkey authentication (assertion) ceremony started on the server: a + challenge was generated for the client. Pairs with the + login.complete (reason=passkey) completion signal to measure the + server-side passkey sign-in funnel. + lifetime: ping + send_in_pings: + - events + notification_emails: + - vzare@mozilla.com + - fxa-staff@mozilla.com + bugs: + - https://mozilla-hub.atlassian.net/browse/FXA-12914 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1830504 + expires: never + data_sensitivity: + - interaction + authentication_verification_success: + type: event + description: | + Passkey authentication assertion verified successfully on the server, + before any session/token work. Sits between + passkey.authentication_started and login.complete (reason=passkey) so + failures during token/session creation are distinguishable from a + failed WebAuthn ceremony. + lifetime: ping + send_in_pings: + - events + notification_emails: + - vzare@mozilla.com + - fxa-staff@mozilla.com + bugs: + - https://mozilla-hub.atlassian.net/browse/FXA-12914 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1830504 + expires: never + data_sensitivity: + - interaction create_complete: type: event description: |