Skip to content

Commit e51b70a

Browse files
authored
Merge pull request #20233 from mozilla/FXA-13313
feat(logging): Add client_id + service to more logs, expand valid client_ids to log
2 parents cd07d58 + cd94428 commit e51b70a

9 files changed

Lines changed: 220 additions & 50 deletions

File tree

packages/fxa-auth-server/bin/key_server.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,8 @@ async function run(config) {
432432
database,
433433
statsd,
434434
glean,
435-
customs
435+
customs,
436+
oauthDb
436437
);
437438

438439
try {

packages/fxa-auth-server/lib/account-events.spec.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ describe('Account Events', () => {
133133
describe('security events', () => {
134134
it('can record security event', async () => {
135135
const message = {
136-
name: 'account.login' as const,
136+
name: 'account.login',
137137
uid: '000',
138138
ipAddr: '123.123.123.123',
139139
tokenId: '123',
@@ -144,14 +144,54 @@ describe('Account Events', () => {
144144

145145
sinon.assert.calledOnceWithExactly(
146146
statsd.increment,
147-
'accountEvents.recordSecurityEvent.write.account.login'
147+
'accountEvents.recordSecurityEvent.write.account.login',
148+
{ clientId: 'none', service: 'none' }
149+
);
150+
});
151+
152+
it('includes clientId and service tags from additionalInfo', async () => {
153+
const message = {
154+
name: 'account.login',
155+
uid: '000',
156+
ipAddr: '123.123.123.123',
157+
tokenId: '123',
158+
additionalInfo: {
159+
client_id: '5882386c6d801776',
160+
service: 'sync',
161+
},
162+
};
163+
await accountEventsManager.recordSecurityEvent(mockDb, message);
164+
165+
sinon.assert.calledOnceWithExactly(
166+
statsd.increment,
167+
'accountEvents.recordSecurityEvent.write.account.login',
168+
{ clientId: '5882386c6d801776', service: 'sync' }
169+
);
170+
});
171+
172+
it('uses none for missing service when clientId is present', async () => {
173+
const message = {
174+
name: 'account.login',
175+
uid: '000',
176+
ipAddr: '123.123.123.123',
177+
tokenId: '123',
178+
additionalInfo: {
179+
client_id: 'deadbeefdeadbeef',
180+
},
181+
};
182+
await accountEventsManager.recordSecurityEvent(mockDb, message);
183+
184+
sinon.assert.calledOnceWithExactly(
185+
statsd.increment,
186+
'accountEvents.recordSecurityEvent.write.account.login',
187+
{ clientId: 'deadbeefdeadbeef', service: 'none' }
148188
);
149189
});
150190

151191
it('logs and does not throw on failure', async () => {
152192
mockDb.securityEvent = sinon.stub().throws();
153193
const message = {
154-
name: 'account.login' as const,
194+
name: 'account.login',
155195
uid: '000',
156196
ip: '123.123.123.123',
157197
tokenId: '123',
@@ -160,7 +200,29 @@ describe('Account Events', () => {
160200
expect(addMock.called).toBe(false);
161201
sinon.assert.calledOnceWithExactly(
162202
statsd.increment,
163-
'accountEvents.recordSecurityEvent.error.account.login'
203+
'accountEvents.recordSecurityEvent.error.account.login',
204+
{ clientId: 'none', service: 'none' }
205+
);
206+
});
207+
208+
it('includes tags on error path', async () => {
209+
mockDb.securityEvent = sinon.stub().throws();
210+
const message = {
211+
name: 'account.login',
212+
uid: '000',
213+
ipAddr: '123.123.123.123',
214+
tokenId: '123',
215+
additionalInfo: {
216+
client_id: '5882386c6d801776',
217+
service: 'sync',
218+
},
219+
};
220+
await accountEventsManager.recordSecurityEvent(mockDb, message as any);
221+
222+
sinon.assert.calledOnceWithExactly(
223+
statsd.increment,
224+
'accountEvents.recordSecurityEvent.error.account.login',
225+
{ clientId: '5882386c6d801776', service: 'sync' }
164226
);
165227
});
166228
});

packages/fxa-auth-server/lib/account-events.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { Container } from 'typedi';
66

77
import { AuthFirestore, AppConfig } from '../lib/types';
88
import { StatsD } from 'hot-shots';
9-
import { SecurityEventNames } from 'fxa-shared/db/models/auth/security-event';
9+
import * as Sentry from '@sentry/node';
10+
import {
11+
EVENT_NAMES,
12+
SecurityEventNames,
13+
} from 'fxa-shared/db/models/auth/security-event';
1014

1115
type BaseEvent = {
1216
// General metric properties
@@ -163,6 +167,11 @@ export class AccountEventsManager {
163167
public async recordSecurityEvent(db: AuthDatabase, message: SecurityEvent) {
164168
const { uid, name, ipAddr, tokenId, additionalInfo } = message;
165169

170+
if (!EVENT_NAMES[name]) {
171+
Sentry.captureMessage(`Unknown security event name: ${name}`);
172+
return;
173+
}
174+
166175
const eventData: SecurityEvent = {
167176
name,
168177
uid,
@@ -175,12 +184,23 @@ export class AccountEventsManager {
175184
}),
176185
};
177186

187+
const metricTags = {
188+
clientId: additionalInfo?.client_id || 'none',
189+
service: additionalInfo?.service || 'none',
190+
};
191+
178192
try {
179193
await db.securityEvent(eventData);
180-
this.statsd.increment(`accountEvents.recordSecurityEvent.write.${name}`);
194+
this.statsd.increment(
195+
`accountEvents.recordSecurityEvent.write.${name}`,
196+
metricTags
197+
);
181198
} catch (err) {
182199
// Failing to write to events shouldn't break anything
183-
this.statsd.increment(`accountEvents.recordSecurityEvent.error.${name}`);
200+
this.statsd.increment(
201+
`accountEvents.recordSecurityEvent.error.${name}`,
202+
metricTags
203+
);
184204
}
185205
}
186206
}

packages/fxa-auth-server/lib/metrics/client-tags.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
import * as Sentry from '@sentry/node';
56
import { OAuthNativeClients, OAuthNativeServices } from '@fxa/accounts/oauth';
67
import { MetricsContext } from '@fxa/shared/metrics/glean';
78

@@ -15,15 +16,23 @@ export type ClientTagsRequest = {
1516
query: any;
1617
};
1718

18-
const validClientIds = new Set<string>(Object.values(OAuthNativeClients));
19+
const nativeClientIds = new Set<string>(Object.values(OAuthNativeClients));
1920
const validServices = new Set<string>(Object.values(OAuthNativeServices));
2021

2122
/**
2223
* Resolves and validates clientId and service from the request's metricsContext.
2324
* Only returns values that are in the known allowlists to prevent infinite
2425
* cardinality in StatsD metrics.
26+
*
27+
* clientId is accepted if it exists in configuredClientIds (loaded from the
28+
* fxa_oauth.clients table per-request, cached). service is only resolved for
29+
* native clients, as those are the only IDs where service applies (e.g.
30+
* Firefox Desktop can be sync, relay, vpn, etc.).
2531
*/
26-
export async function resolveClientTags(request: ClientTagsRequest): Promise<{
32+
export async function resolveClientTags(
33+
request: ClientTagsRequest,
34+
configuredClientIds?: Set<string>
35+
): Promise<{
2736
clientId: string | undefined;
2837
service: string | undefined;
2938
}> {
@@ -32,25 +41,27 @@ export async function resolveClientTags(request: ClientTagsRequest): Promise<{
3241

3342
try {
3443
const metricsContext = await request.app.metricsContext;
44+
const rawClientId = metricsContext?.clientId;
3545

36-
if (
37-
metricsContext?.clientId &&
38-
validClientIds.has(metricsContext.clientId)
39-
) {
40-
clientId = metricsContext.clientId;
41-
}
46+
if (rawClientId && configuredClientIds?.has(rawClientId)) {
47+
clientId = rawClientId;
4248

43-
// service can come from payload, query, or stashed metricsContext
44-
const rawService =
45-
(request.payload as any)?.service ||
46-
(request.query as any)?.service ||
47-
metricsContext?.service;
49+
// service only applies to native clients and can come from payload,
50+
// query, or stashed metricsContext
51+
if (nativeClientIds.has(rawClientId)) {
52+
const rawService =
53+
request.payload?.service ||
54+
request.query?.service ||
55+
metricsContext?.service;
4856

49-
if (rawService && validServices.has(rawService)) {
50-
service = rawService;
57+
if (rawService && validServices.has(rawService)) {
58+
service = rawService;
59+
}
60+
}
5161
}
52-
} catch {
53-
// If metricsContext resolution fails, just return undefined for both
62+
} catch (err) {
63+
// Capture in Sentry, return undefined clientId/service.
64+
Sentry.captureException(err);
5465
}
5566

5667
return { clientId, service };

packages/fxa-auth-server/lib/oauth/db/mysql/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const QUERY_DEVELOPER_OWNS_CLIENT =
3939
const QUERY_DEVELOPER_INSERT =
4040
'INSERT INTO developers ' + '(developerId, email) ' + 'VALUES (?, ?);';
4141
const QUERY_CLIENT_GET = 'SELECT * FROM clients WHERE id=?';
42+
const QUERY_CLIENT_IDS = 'SELECT id FROM clients';
4243
const QUERY_CLIENT_LIST =
4344
'SELECT id, name, redirectUri, imageUri, ' +
4445
'canGrant, publicClient, trusted ' +
@@ -342,6 +343,9 @@ class MysqlStore extends MysqlOAuthShared {
342343
getClient(id) {
343344
return this._readOne(QUERY_CLIENT_GET, [buf(id)]);
344345
}
346+
getAllClientIds() {
347+
return this._read(QUERY_CLIENT_IDS);
348+
}
345349
getClients(email) {
346350
return this._read(QUERY_CLIENT_LIST, [email]);
347351
}

packages/fxa-auth-server/lib/routes/utils/signin.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,8 @@ module.exports = (
642642
}
643643

644644
async function recordSecurityEvent() {
645+
const clientId = request.app.clientIdTag;
646+
const service = request.app.serviceTag;
645647
await accountEventsManager.recordSecurityEvent(db, {
646648
name: 'account.login',
647649
uid: accountRecord.uid,
@@ -650,6 +652,8 @@ module.exports = (
650652
additionalInfo: {
651653
userAgent: request.headers['user-agent'],
652654
location: request.app.geo.location,
655+
...(clientId && { client_id: clientId }),
656+
...(service && { service }),
653657
},
654658
});
655659
}

packages/fxa-auth-server/lib/server.js

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,17 @@ function translateStripeErrors(response) {
8585
return response;
8686
}
8787

88-
async function create(log, error, config, routes, db, statsd, glean, customs) {
88+
async function create(
89+
log,
90+
error,
91+
config,
92+
routes,
93+
db,
94+
statsd,
95+
glean,
96+
customs,
97+
oauthDb
98+
) {
8999
const getGeoData = require('./geodb')(log);
90100
const metricsContext = require('./metrics/context')(log, config);
91101
const metricsEvents = require('./metrics/events')(log, config, glean);
@@ -317,11 +327,19 @@ async function create(log, error, config, routes, db, statsd, glean, customs) {
317327
await customs.checkIpOnly(request, action);
318328
}
319329

320-
// Validate clientId/service against known enums (to prevent unbounded
321-
// cardinality) and normalize onto request.app for use as StatsD/Sentry tags.
322-
// clientId comes from metricsContext (async, may be stashed in Redis);
323-
// service comes from query/payload but needs allowlist validation.
324-
const tags = await resolveClientTags(request);
330+
// Validate clientId/service against registered OAuth clients to prevent
331+
// unbounded cardinality and normalize onto request.app for StatsD/Sentry tags.
332+
// Client IDs are loaded from the fxa_oauth.clients table on each request;
333+
// the table is small and MySQL serves it from cache.
334+
let registeredClientIds;
335+
try {
336+
const clients = await oauthDb.mysql.getAllClientIds();
337+
registeredClientIds = new Set(clients.map((c) => c.id.toString('hex')));
338+
} catch (err) {
339+
Sentry.captureException(err);
340+
registeredClientIds = new Set();
341+
}
342+
const tags = await resolveClientTags(request, registeredClientIds);
325343
if (tags.clientId) {
326344
request.app.clientIdTag = tags.clientId;
327345
Sentry.setTag('clientId', tags.clientId);
@@ -331,7 +349,6 @@ async function create(log, error, config, routes, db, statsd, glean, customs) {
331349
Sentry.setTag('service', tags.service);
332350
}
333351

334-
335352
return h.continue;
336353
});
337354

packages/fxa-auth-server/test/local/account-events.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ describe('Account Events', () => {
132132

133133
assert.calledOnceWithExactly(
134134
statsd.increment,
135-
'accountEvents.recordSecurityEvent.write.account.login'
135+
'accountEvents.recordSecurityEvent.write.account.login',
136+
{ clientId: 'none', service: 'none' }
136137
);
137138
});
138139

@@ -148,7 +149,8 @@ describe('Account Events', () => {
148149
assert.isFalse(addMock.called);
149150
assert.calledOnceWithExactly(
150151
statsd.increment,
151-
'accountEvents.recordSecurityEvent.error.account.login'
152+
'accountEvents.recordSecurityEvent.error.account.login',
153+
{ clientId: 'none', service: 'none' }
152154
);
153155
});
154156
});

0 commit comments

Comments
 (0)