WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 5f35710

Browse files
committed
feat(glean): Add glean probe for passkey support
Because: * We want to know if passkey support is sufficient to justify implementation This commit: * Adds a glean event to sample capability support for passkey/WebAuthn, including PRF (Pseudo-Random Function) extension support and minimal device data. * Adds a helper function to collect the data and emit the event. Closes #FXA-12640
1 parent 7fd4d25 commit 5f35710

File tree

12 files changed

+481
-2
lines changed

12 files changed

+481
-2
lines changed

packages/fxa-content-server/server/lib/beta-settings.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ const settingsConfig = {
104104
glean: { ...config.get('glean'), appDisplayVersion: config.get('version') },
105105
redirectAllowlist: config.get('redirect_check.allow_list'),
106106
sendFxAStatusOnSettings: config.get('featureFlags.sendFxAStatusOnSettings'),
107+
metrics: {
108+
webauthnCapabilitiesSampleRate: config.get(
109+
'metrics.webauthnCapabilitiesSampleRate'
110+
),
111+
},
107112
showReactApp: {
108113
signUpRoutes: config.get('showReactApp.signUpRoutes'),
109114
signInRoutes: config.get('showReactApp.signInRoutes'),

packages/fxa-content-server/server/lib/configuration.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ const conf = (module.exports = convict({
8383
env: 'DISABLE_CLIENT_METRICS_STDERR',
8484
},
8585
},
86+
metrics: {
87+
webauthnCapabilitiesSampleRate: {
88+
default: 0.1,
89+
doc: 'Sampling rate (0..1) for WebAuthn capabilities probe in Settings',
90+
env: 'WEBAUTHN_CAPABILITIES_SAMPLE_RATE',
91+
format: Number,
92+
},
93+
},
8694
client_sessions: {
8795
cookie_name: 'session',
8896
duration: {
@@ -1044,12 +1052,12 @@ const conf = (module.exports = convict({
10441052
env: 'SUBSCRIPTIONS_FIRESTORE_CONFIGS_ENABLED',
10451053
format: Boolean,
10461054
},
1047-
usePaymentsNextSubscriptionManagement : {
1055+
usePaymentsNextSubscriptionManagement: {
10481056
default: true,
10491057
doc: 'Whether to redirect to the new Subscription Management Page',
10501058
env: 'FEATURE_FLAGS_PAYMENTS_NEXT_SUBSCRIPTION_MANAGEMENT',
10511059
format: Boolean,
1052-
}
1060+
},
10531061
},
10541062
sync_tokenserver_url: {
10551063
default: 'http://localhost:8000/token',

packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ function getIndexRouteDefinition(config) {
9191
paymentsNextHostedUrl: PAYMENTS_NEXT_HOSTED_URL,
9292
maxEventOffset: MAX_EVENT_OFFSET,
9393
env: ENV,
94+
metrics: {
95+
webauthnCapabilitiesSampleRate: config.get(
96+
'metrics.webauthnCapabilitiesSampleRate'
97+
),
98+
},
9499
isCoppaEnabled: COPPA_ENABLED,
95100
isPromptNoneEnabled: PROMPT_NONE_ENABLED,
96101
googleAuthConfig: GOOGLE_AUTH_CONFIG,

packages/fxa-settings/src/components/App/index.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from '../../models/contexts/SettingsContext';
4343

4444
import sentryMetrics from 'fxa-shared/sentry/browser';
45+
import { maybeRecordWebAuthnCapabilities } from '../../lib/webauthnCapabilitiesProbe';
4546

4647
// Components
4748
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
@@ -298,6 +299,16 @@ export const App = ({
298299
);
299300
}, [metricsEnabled, integration, config.glean, config.version, metricsFlow]);
300301

302+
// Fire the WebAuthn capability probe once at app level after metrics are initialized.
303+
useEffect(() => {
304+
if (!metricsEnabled) {
305+
return;
306+
}
307+
maybeRecordWebAuthnCapabilities(
308+
config.metrics?.webauthnCapabilitiesSampleRate
309+
);
310+
}, [metricsEnabled, config.metrics?.webauthnCapabilitiesSampleRate]);
311+
301312
useEffect(() => {
302313
if (!metricsEnabled) {
303314
return;

packages/fxa-settings/src/lib/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface Config {
2323
enabled: boolean;
2424
endpoint: string;
2525
};
26+
webauthnCapabilitiesSampleRate?: number;
2627
};
2728
sentry: {
2829
dsn: string;
@@ -123,6 +124,7 @@ export function getDefault() {
123124
marketingEmailPreferencesUrl: 'https://basket.mozilla.org/fxa/',
124125
metrics: {
125126
navTiming: { enabled: false, endpoint: '/check-your-metrics-config' },
127+
webauthnCapabilitiesSampleRate: 0.1,
126128
},
127129
mfa: {
128130
otp: {

packages/fxa-settings/src/lib/glean/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import * as sync from 'fxa-shared/metrics/glean/web/sync';
5050
import * as standard from 'fxa-shared/metrics/glean/web/standard';
5151
import * as utm from 'fxa-shared/metrics/glean/web/utm';
5252
import * as entrypointQuery from 'fxa-shared/metrics/glean/web/entrypoint';
53+
import * as webauthn from 'fxa-shared/metrics/glean/web/webauthn';
5354
import { Integration } from '../../models';
5455
import { MetricsFlow } from '../metrics-flow';
5556
import { currentAccount } from '../../lib/cache';
@@ -656,6 +657,29 @@ const recordEventMetric = (
656657
case 'third_party_auth_set_password_success':
657658
thirdPartyAuthSetPassword.success.record();
658659
break;
660+
case 'webauthn_capabilities': {
661+
const e = (gleanPingMetrics as any).event || {};
662+
const extras: Record<string, any> = {};
663+
if (typeof e['supported'] === 'boolean')
664+
extras.supported = e['supported'];
665+
if (typeof e['ppa'] === 'boolean') extras.ppa = e['ppa'];
666+
if (typeof e['cg'] === 'boolean') extras.cg = e['cg'];
667+
if (typeof e['rel'] === 'boolean') extras.rel = e['rel'];
668+
if (typeof e['hyb'] === 'boolean') extras.hyb = e['hyb'];
669+
if (typeof e['uvpa'] === 'boolean') extras.uvpa = e['uvpa'];
670+
if (typeof e['prf'] === 'boolean') extras.prf = e['prf'];
671+
if (typeof e['error_reason'] === 'string')
672+
extras.error_reason = e['error_reason'];
673+
if (typeof e['os_family'] === 'string') extras.os_family = e['os_family'];
674+
if (typeof e['os_major'] === 'string') extras.os_major = e['os_major'];
675+
if (typeof e['browser_family'] === 'string')
676+
extras.browser_family = e['browser_family'];
677+
if (typeof e['browser_major'] === 'string')
678+
extras.browser_major = e['browser_major'];
679+
if (typeof e['cpu_arm'] === 'boolean') extras.cpu_arm = e['cpu_arm'];
680+
webauthn.capabilities.record(extras);
681+
break;
682+
}
659683
}
660684
};
661685

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { maybeRecordWebAuthnCapabilities } from './webauthnCapabilitiesProbe';
6+
7+
// Mock glean wrapper used by the probe
8+
jest.mock('./glean', () => ({
9+
__esModule: true,
10+
default: {
11+
getEnabled: jest.fn(),
12+
webauthn: {
13+
capabilities: jest.fn(),
14+
},
15+
},
16+
}));
17+
18+
const GleanMetrics = require('./glean').default as {
19+
getEnabled: jest.Mock;
20+
webauthn: { capabilities: jest.Mock };
21+
};
22+
23+
function setUA(ua: string) {
24+
Object.defineProperty(window.navigator, 'userAgent', {
25+
value: ua,
26+
configurable: true,
27+
});
28+
}
29+
30+
function setMathRandom(value: number) {
31+
jest.spyOn(Math, 'random').mockReturnValue(value);
32+
}
33+
34+
async function flush() {
35+
await Promise.resolve();
36+
jest.runAllTimers();
37+
}
38+
39+
describe('webauthnCapabilitiesProbe', () => {
40+
beforeEach(() => {
41+
jest.useFakeTimers();
42+
localStorage.clear();
43+
jest.resetAllMocks();
44+
GleanMetrics.getEnabled.mockReturnValue(true);
45+
setUA(
46+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36'
47+
);
48+
(global as any).PublicKeyCredential = undefined;
49+
});
50+
51+
afterEach(() => {
52+
jest.useRealTimers();
53+
});
54+
55+
it('does nothing when telemetry disabled', () => {
56+
GleanMetrics.getEnabled.mockReturnValue(false);
57+
setMathRandom(0.01);
58+
maybeRecordWebAuthnCapabilities(1);
59+
expect(GleanMetrics.webauthn.capabilities).not.toHaveBeenCalled();
60+
});
61+
62+
it('samples ~10%: skips when random > 0.1', () => {
63+
setMathRandom(0.5);
64+
maybeRecordWebAuthnCapabilities(0.1);
65+
expect(GleanMetrics.webauthn.capabilities).not.toHaveBeenCalled();
66+
});
67+
68+
it('dedupes for 30 days when key present', () => {
69+
setMathRandom(0.01);
70+
localStorage.setItem('webauthn:caps:v1', String(Date.now()));
71+
maybeRecordWebAuthnCapabilities(1);
72+
expect(GleanMetrics.webauthn.capabilities).not.toHaveBeenCalled();
73+
});
74+
75+
it('emits supported=false when capability API missing', async () => {
76+
setMathRandom(0.01);
77+
maybeRecordWebAuthnCapabilities(1);
78+
await flush();
79+
expect(GleanMetrics.webauthn.capabilities).toHaveBeenCalledTimes(1);
80+
const arg = GleanMetrics.webauthn.capabilities.mock.calls[0][0];
81+
expect(arg.event.supported).toBe(false);
82+
expect(typeof arg.event.error_reason).toBe('string');
83+
});
84+
85+
it('emits supported=true with flags and device buckets', async () => {
86+
setMathRandom(0.01);
87+
(global as any).PublicKeyCredential = {
88+
getClientCapabilities: jest.fn().mockResolvedValue({
89+
passkeyPlatformAuthenticator: true,
90+
conditionalGet: true,
91+
relatedOrigins: false,
92+
hybridTransport: true,
93+
userVerifyingPlatformAuthenticator: true,
94+
'extension:prf': false,
95+
}),
96+
};
97+
98+
maybeRecordWebAuthnCapabilities(1);
99+
await flush();
100+
101+
expect(
102+
(global as any).PublicKeyCredential.getClientCapabilities
103+
).toHaveBeenCalled();
104+
expect(GleanMetrics.webauthn.capabilities).toHaveBeenCalledTimes(1);
105+
const { event } = GleanMetrics.webauthn.capabilities.mock.calls[0][0];
106+
expect(event.supported).toBe(true);
107+
expect(event.ppa).toBe(true);
108+
expect(event.cg).toBe(true);
109+
expect(event.rel).toBe(false);
110+
expect(event.hyb).toBe(true);
111+
expect(event.uvpa).toBe(true);
112+
expect(event.prf).toBe(false);
113+
// device buckets derived from UA
114+
expect(event.os_family).toBe('windows');
115+
expect(event.os_major).toBe('10');
116+
expect(event.browser_family).toBe('chrome');
117+
expect(event.browser_major).toBe('127');
118+
expect(typeof event.cpu_arm).toBe('boolean');
119+
});
120+
});

0 commit comments

Comments
 (0)