Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
"version": "0.2.0",
"configurations": [
{
"type": "node",
"type": "pm2-node",
"request": "attach",
"name": "Attach to PM2",
"processId": "${command:PickProcess}"
"service": "${command:PickProcess}"
},
{
"type": "firefox",
Expand All @@ -24,9 +24,13 @@
"NODE_OPTIONS": "--dns-result-order=ipv4first"
},
"program": "${workspaceFolder}/node_modules/@playwright/test/cli.js",
"args": ["test","--config=${workspaceFolder}/packages/functional-tests/playwright.config.ts", "--project=local"],
"args": [
"test",
"--config=${workspaceFolder}/packages/functional-tests/playwright.config.ts",
"--project=local"
],
"autoAttachChildProcesses": true,
"cwd":"${workspaceFolder}/packages/functional-tests",
"cwd": "${workspaceFolder}/packages/functional-tests",
"request": "launch"
}
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */

-- Account activity / liveness signal per (userId, clientId).
-- See FXA-13662. Written from the OAuth grant path; the scopes that were
-- requested/granted on each activity are tracked in accountActivityScopes
-- (see patch 37->38) keyed by the same (userId, clientId).
CREATE TABLE `accountActivity` (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't there be an assertion that the current patch level is 36?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought there was, I had a bunch of conflicts so it probably got dropped - I'll add it back!

`userId` BINARY(16) NOT NULL,
`clientId` BINARY(8) NOT NULL,
`firstSeenAt` BIGINT UNSIGNED NOT NULL,
`lastSeenAt` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`userId`, `clientId`),
KEY `idx_lastSeenAt` (`lastSeenAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

UPDATE dbMetadata SET value = '37' WHERE name = 'schema-patch-level';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */

-- DROP TABLE IF EXISTS `accountActivity`;

-- UPDATE dbMetadata SET value = '36' WHERE name = 'schema-patch-level';
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */

-- One-to-many mapping of accountActivity rows to the scopes that were
-- requested/granted on each activity. Per-scope timestamps keep the
-- "when did this user last use Smart Window" / "Sync" / etc.
--
-- For now we record the raw scope string; once the scopes
-- table is updated with the full set of allowed scopes we can migrate
-- this to a scopeId FK and drop the raw scope column.
CREATE TABLE `accountActivityScopes` (
`userId` BINARY(16) NOT NULL,
`clientId` BINARY(8) NOT NULL,
`scope` VARCHAR(255) NOT NULL,
`firstSeenAt` BIGINT UNSIGNED NOT NULL,
`lastSeenAt` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`userId`, `clientId`, `scope`),
-- Support global time-window scans; inactive-account deletion for example.
KEY `idx_lastSeenAt` (`lastSeenAt`),
-- Support per-scope queries, e.g. 'when did this user last use a browser service (Smart Window)?'
KEY `idx_scope_lastSeenAt` (`scope`, `lastSeenAt`),
FOREIGN KEY (`userId`, `clientId`) REFERENCES `accountActivity`(`userId`, `clientId`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

UPDATE dbMetadata SET value = '38' WHERE name = 'schema-patch-level';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */

-- DROP TABLE IF EXISTS `accountActivityScopes`;

-- UPDATE dbMetadata SET value = '37' WHERE name = 'schema-patch-level';
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"level": 36
"level": 38
}
14 changes: 14 additions & 0 deletions packages/fxa-auth-server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,20 @@ const convictConf = convict({
env: 'FXA_REFRESH_TOKEN_UPDATE_AFTER',
},
},
accountActivity: {
sampleRate: {
doc: 'Decimal value between 0 & 1 representing the percentage of account activity events that should be recorded. For example, 0.25 means 25% of events will be recorded.',
format: Number,
default: 0,
env: 'FXA_ACCOUNT_ACTIVITY_SAMPLE_RATE',
},
updateAfter: {
doc: 'lastSeenAt in accountActivity is only updated in MySQL after this delay per (userId, clientId). Mirrors refreshToken.updateAfter.',
format: 'duration',
default: '24 hours',
env: 'FXA_ACCOUNT_ACTIVITY_UPDATE_AFTER',
},
},
tokenExchange: {
allowedClientIds: {
doc: 'Client IDs allowed to perform token exchange (only Firefox mobile clients as of FXA-12925)',
Expand Down
162 changes: 162 additions & 0 deletions packages/fxa-auth-server/lib/oauth/account-activity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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 fxaShared from 'fxa-shared';

import { recordActivity } from './account-activity';

const ScopeSet = (fxaShared as any).oauth.scopes;

const SCOPE_OLDSYNC = 'https://identity.mozilla.com/apps/oldsync';
const SCOPE_RELAY = 'https://identity.mozilla.com/apps/relay';

const USER_ID = Buffer.from('0123456789abcdef0123456789abcdef', 'hex');
const CLIENT_ID = Buffer.from('5882386c6d801776', 'hex');
const CLIENT_ID_HEX = '5882386c6d801776';
const NOW = 1_700_000_000_000;
const THROTTLE_MS = 86_400_000;

describe('recordActivity', () => {
let oauthDB: { recordAccountActivity: jest.Mock };
let statsd: { increment: jest.Mock };
let log: { warn: jest.Mock };

function makeDeps() {
oauthDB = { recordAccountActivity: jest.fn().mockResolvedValue(undefined) };
statsd = { increment: jest.fn() };
log = { warn: jest.fn() };
return { oauthDB, statsd, log };
}

it('calls the DB with the user, client, and the scopes from the grant', async () => {
const deps = makeDeps();
await recordActivity(deps, {
userId: USER_ID,
clientId: CLIENT_ID,
scopeSet: ScopeSet.fromArray([SCOPE_OLDSYNC, SCOPE_RELAY, 'profile']),
throttleMs: THROTTLE_MS,
grantType: 'fxa-credentials',
now: NOW,
});

expect(deps.oauthDB.recordAccountActivity).toHaveBeenCalledTimes(1);
const [userId, clientId, scopes, now, throttleMs] =
deps.oauthDB.recordAccountActivity.mock.calls[0];
expect(userId).toBe(USER_ID);
expect(clientId).toBe(CLIENT_ID);
expect(scopes).toEqual(
expect.arrayContaining([SCOPE_OLDSYNC, SCOPE_RELAY, 'profile'])
);
expect(scopes).toHaveLength(3);
expect(now).toBe(NOW);
expect(throttleMs).toBe(THROTTLE_MS);
});

it('passes an empty scopes array when the grant has no scopes', async () => {
const deps = makeDeps();
await recordActivity(deps, {
userId: USER_ID,
clientId: CLIENT_ID,
scopeSet: null,
throttleMs: THROTTLE_MS,
grantType: 'authorization_code',
now: NOW,
});

expect(deps.oauthDB.recordAccountActivity).toHaveBeenCalledWith(
USER_ID,
CLIENT_ID,
[],
NOW,
THROTTLE_MS
);
});

it('emits accountActivity.recorded with clientId and grantType tags on success', async () => {
const deps = makeDeps();
await recordActivity(deps, {
userId: USER_ID,
clientId: CLIENT_ID,
scopeSet: ScopeSet.fromArray([SCOPE_OLDSYNC]),
throttleMs: THROTTLE_MS,
grantType: 'fxa-credentials',
now: NOW,
});

expect(deps.statsd.increment).toHaveBeenCalledWith(
'accountActivity.recorded',
{ clientId: CLIENT_ID_HEX, grantType: 'fxa-credentials' }
);
});

it("tags an unspecified grantType as 'unknown' in the metric", async () => {
const deps = makeDeps();
await recordActivity(deps, {
userId: USER_ID,
clientId: CLIENT_ID,
scopeSet: ScopeSet.fromArray([SCOPE_OLDSYNC]),
throttleMs: THROTTLE_MS,
now: NOW,
});

expect(deps.statsd.increment).toHaveBeenCalledWith(
'accountActivity.recorded',
{ clientId: CLIENT_ID_HEX, grantType: 'unknown' }
);
});

it('swallows DB rejection and emits write_failed instead of recorded', async () => {
const deps = makeDeps();
deps.oauthDB.recordAccountActivity.mockRejectedValueOnce(
new Error('connection lost')
);

await expect(
recordActivity(deps, {
userId: USER_ID,
clientId: CLIENT_ID,
scopeSet: ScopeSet.fromArray([SCOPE_OLDSYNC]),
throttleMs: THROTTLE_MS,
grantType: 'refresh_token',
now: NOW,
})
).resolves.toBeUndefined();

expect(deps.statsd.increment).toHaveBeenCalledTimes(1);
expect(deps.statsd.increment).toHaveBeenCalledWith(
'accountActivity.write_failed',
{ clientId: CLIENT_ID_HEX, grantType: 'refresh_token' }
);
expect(deps.log.warn).toHaveBeenCalledWith(
'accountActivity.write_failed',
expect.objectContaining({
clientId: CLIENT_ID_HEX,
grantType: 'refresh_token',
error: 'connection lost',
})
);
});

it('filters out empty/non-string scope values defensively', async () => {
const deps = makeDeps();
// ScopeSet won't normally accept malformed values, but the helper
// defends in depth so a bad upstream change can't poison the mapping
// table with empty rows.
const fakeScopeSet = {
getScopeValues: () =>
[SCOPE_OLDSYNC, '', null as any, undefined as any, 'profile'] as any,
};
await recordActivity(deps, {
userId: USER_ID,
clientId: CLIENT_ID,
scopeSet: fakeScopeSet,
throttleMs: THROTTLE_MS,
grantType: 'authorization_code',
now: NOW,
});

const [, , scopes] = deps.oauthDB.recordAccountActivity.mock.calls[0];
expect(scopes).toEqual([SCOPE_OLDSYNC, 'profile']);
});
});
113 changes: 113 additions & 0 deletions packages/fxa-auth-server/lib/oauth/account-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* 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/. */

// Fire-and-forget writer for the accountActivity liveness signal (FXA-13662).
// Each OAuth grant produces one accountActivity row per (userId, clientId)
// and one row per granted scope in accountActivityScopes. Consumers identify
// the RP via the clientId column (JOIN clients.name for the human-readable
// name) and the requested scopes via the mapping table.

export interface ScopeSetLike {
getScopeValues(): Iterable<string>;
}

export interface AccountActivityOauthDB {
recordAccountActivity(
userId: Buffer | string,
clientId: Buffer | string,
scopes: string[],
now: number,
throttleMs: number
): Promise<unknown>;
}

export interface AccountActivityStatsD {
increment(metric: string, tags?: Record<string, string>): void;
}

export interface AccountActivityLog {
warn(event: string, data?: Record<string, unknown>): void;
}

export interface AccountActivityDeps {
oauthDB: AccountActivityOauthDB;
statsd?: AccountActivityStatsD;
log?: AccountActivityLog;
}

export interface RecordActivityParams {
userId: Buffer | string;
clientId: Buffer | string;
scopeSet: ScopeSetLike | null | undefined;
/** Throttle window in ms; lastSeenAt is only updated if the existing row was last touched longer than this ago. */
throttleMs: number;
/** For metric tagging. */
grantType?: string;
/** For test determinism. */
now?: number;
}

const hex = (v: Buffer | string): string =>
Buffer.isBuffer(v) ? v.toString('hex') : v;

function extractScopes(scopeSet: ScopeSetLike | null | undefined): string[] {
if (!scopeSet || typeof scopeSet.getScopeValues !== 'function') return [];
return Array.from(scopeSet.getScopeValues()).filter(
(scope) => typeof scope === 'string' && scope.length > 0
);
}

/**
Copy link
Copy Markdown
Contributor

@dschom dschom May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really hold this map in the DB. There's a several reasons:

  • It stops from having to use a string as part of key material, cause you can foreign key the mapping table.
  • It allows us to make changes without having sync configs or cooridante rollouts.
  • We don't have to parse JSON config on a hot call.
  • It eventually allows us a path to remove all these hardcode mappings which are hard to maintain and create multiple sources of truth.

* Fire-and-forget write of an accountActivity row plus its scope mappings
* for a single OAuth grant.
*
* Never throws. Never blocks the response. On DB failure, increments
* `accountActivity.write_failed` and logs a warning; the grant still succeeds.
*
* Returns the awaited Promise so tests can assert on completion; the OAuth
* grant path does not await it.
*/
export async function recordActivity(
deps: AccountActivityDeps,
params: RecordActivityParams
): Promise<void> {
const { oauthDB, statsd, log } = deps;
const {
userId,
clientId,
scopeSet,
throttleMs,
grantType,
now = Date.now(),
} = params;

// Grant validation upstream guarantees clientId is in the OAuth clients
// table, so the metric tag's cardinality is naturally bounded (matches the
// sibling oauth.rp.keys-jwe metric in token.js). No separate allowlist
// pass needed.
const clientIdHex = hex(clientId);
const scopes = extractScopes(scopeSet);
const metricTags = {
clientId: clientIdHex,
grantType: grantType || 'unknown',
Comment thread
nshirley marked this conversation as resolved.
};

try {
await oauthDB.recordAccountActivity(
userId,
clientId,
scopes,
now,
throttleMs
);
statsd?.increment('accountActivity.recorded', metricTags);
} catch (err) {
statsd?.increment('accountActivity.write_failed', metricTags);
log?.warn('accountActivity.write_failed', {
clientId: clientIdHex,
grantType,
error: err instanceof Error ? err.message : String(err),
});
}
}
Loading