Skip to content

feat(auth-server): add accountActivity table for per-client liveness#20620

Open
nshirley wants to merge 1 commit into
mainfrom
FXA-13662
Open

feat(auth-server): add accountActivity table for per-client liveness#20620
nshirley wants to merge 1 commit into
mainfrom
FXA-13662

Conversation

@nshirley
Copy link
Copy Markdown
Contributor

@nshirley nshirley commented May 18, 2026

Because:

  • FxA lacks a durable per-(userId, clientId, service) activity signal needed for inactive-account deletion, per-RP liveness, and support tooling.
  • Existing token tables either disappear on revoke, miss Firefox Desktop's online fxa-credentials service-token mints, or are sampled and Redis-biased and therefore under-detect active users.

This commit:

  • Adds a new accountActivity table to the fxa_oauth schema, keyed by (userId, clientId, service) with secondary indexes for time-window and per-service cohort queries.
  • Adds a fire-and-forget recordActivity helper that resolves the service tag from the requested scopes (scopeToServiceName) with a clientId fallback (oauthServer.clientIdToServiceNames), then writes via a SQL-throttled upsert. The throttle predicate uses VALUES(x) > x + ? to avoid BIGINT UNSIGNED underflow on out-of-order timestamps.
  • Wires a single write site at the OAuth grant convergence point in tokenHandler so all four grant types (authorization_code, fxa-credentials, refresh_token, token-exchange) are recorded uniformly.
  • Extends _removeTokensAndCodes so accountActivity rows are cleaned up alongside tokens on account deletion.
  • Stores scopeToServiceName as a JSON string
  • Emits accountActivity.recorded / accountActivity.write_failed StatsD metrics with service + grantType tags. Unclassified ('') rows trigger a deduped log.warn once per process per clientId.

Closes FXA-13662

Put an x in the boxes that apply

  • My commit is GPG signed.
  • If applicable, I have modified or added tests which pass locally.
  • I have added necessary documentation (if appropriate).
  • I have verified that my changes render correctly in RTL (if appropriate).
  • I have manually reviewed all AI generated code.

How to review (Optional)

  • Key files/areas to focus on:
  • Suggested review order:
  • Risky or complex parts:

Screenshots (Optional)

Here's a screenshot of the table after I signed in/up through 123Done, then signed in with scopeKeys
image

Other information (Optional)

Any other information that is important to this pull request.

Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
@nshirley nshirley marked this pull request as ready for review May 19, 2026 19:15
@nshirley nshirley requested a review from a team as a code owner May 19, 2026 19:15
Copilot AI review requested due to automatic review settings May 19, 2026 19:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a durable per-(userId, clientId, service) “liveness” signal to the auth-server OAuth flow by introducing a new accountActivity table and recording activity on token issuance, with cleanup on account deletion and basic metrics/logging.

Changes:

  • Add accountActivity MySQL table + schema migration (patch level bump) and MySQL upsert/delete queries.
  • Add lib/oauth/account-activity.ts helper to resolve service tags from scopes/clientId and fire-and-forget writes with StatsD + deduped warnings.
  • Wire the activity recording into the /oauth/token grant convergence point and update tests/config accordingly.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/fxa-auth-server/lib/routes/oauth/token.spec.ts Adjust mocks and assertions to accommodate new StatsD emissions.
packages/fxa-auth-server/lib/routes/oauth/token.js Call recordActivity after token issuance (all grant types converge here).
packages/fxa-auth-server/lib/oauth/db/mysql/index.js Add SQL upsert/delete queries and _recordAccountActivity DB method; delete activity rows on account deletion.
packages/fxa-auth-server/lib/oauth/db/index.js Expose recordAccountActivity on the OAuth DB wrapper.
packages/fxa-auth-server/lib/oauth/account-activity.ts Implement service resolution, fire-and-forget write wrapper, metrics, and deduped warning logic.
packages/fxa-auth-server/lib/oauth/account-activity.spec.ts Unit tests for parsing, service resolution, metrics/logging, and error handling.
packages/fxa-auth-server/config/index.ts Add oauthServer.accountActivity config (throttle + scope→service JSON string).
packages/db-migrations/databases/fxa_oauth/target-patch.json Bump patch level to 34.
packages/db-migrations/databases/fxa_oauth/patches/patch-034-033.sql Down migration stub for patch 34.
packages/db-migrations/databases/fxa_oauth/patches/patch-033-034.sql Create accountActivity table + indexes and bump schema-patch-level.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/fxa-auth-server/lib/routes/oauth/token.js Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
default: '24 hours',
env: 'FXA_ACCOUNT_ACTIVITY_UPDATE_AFTER',
},
scopeToServiceName: {
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'll be honest, I don't love this since it means we need to keep this in sync with RPs as we add/modify them in admin panel. It doesn't happen often, but we can quickly lose sight of this

One solution too would be to parse out the service from the scope url... it's not great either, but might be better so we don't have to remember to update configs

// account-activity.ts

// First path segment after /apps/ in a canonical FXA OAuth scope URL.
// e.g. https://identity.mozilla.com/apps/oldsync → 'oldsync'
//      https://identity.mozilla.com/apps/oldsync/keys → 'oldsync'
//      https://identity.mozilla.com/apps/relay#metadata → 'relay'
const SCOPE_URL_SERVICE_PATTERN = /^https?:\/\/[^/]+\/apps\/([a-z0-9_-]+)/i;

/**
 * Derive a service tag from a canonical OAuth scope URL.
 *
 * Short-name scopes like `profile` or `openid` return '' — they're capabilities,
 * not services. URL-format scopes return the first path segment after `/apps/`.
 * The `oldsync` → `sync` rename matches the existing tag convention applied in
 * token.js:918-922 for the `account.signed` metric event.
 */
export function deriveServiceFromScope(scopeUrl: string): string {
  const match = scopeUrl.match(SCOPE_URL_SERVICE_PATTERN);
  if (!match) return '';
  const segment = match[1].toLowerCase();
  return segment === 'oldsync' ? 'sync' : segment;
}

// then the deriveServiceFromScope gets used in the resolveService

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.

One solution too would be to parse out the service from the scope url... it's not great either, but might be better so we don't have to remember to update configs

I don't think we should record 'service' unless it's given to us in the URL. Otherwise I feel like that gets really confusing - plus, if some other RP requests one of these scopes, it doesn't mean we consider that the same "project" or "service".

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.

I personally think this is fine to keep as is at least for now. The correct solution is to have the service name be on the scope column in the database. I lean towards keeping this simple for the time being.

You can populate this with defaults

  {
    "https://identity.mozilla.com/apps/oldsync": "sync",
    "https://identity.mozilla.com/apps/relay": "relay",
    "https://identity.mozilla.com/apps/vpn": "vpn",
    "https://identity.mozilla.com/apps/smartwindow": "smartwindow"
  }

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.

Per Slack convo, we are going to gate this on fxa-credentials grant-type + client_id + above scopes.

We already have exchange.serviceScopes config with exactly the same thing - maybe we can repurpose it.


// Resolve once at module load — the /token endpoint is hot and convict's
// per-request lookup + JSON.parse of scopeToServiceName would otherwise run
// on every grant. See AccountActivityConfig in lib/oauth/account-activity.ts.
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.

I don't think we are throttling the authorization table authorizedAt writes - after looking at this, do you think we should be?

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.

This wasn't so much the db writes that were the concern, rather the fact that the code initially was loading the config on every request, and running json parse as a result it could create unnecessary overhead - came from a comment from Copilot:

tokenHandler calls config.get('oauthServer') on every /token request. Since this endpoint is hot, consider hoisting the oauthServer config (or just the accountActivity subset) to module scope and passing it in, to avoid per-request config lookups and repeated JSON parsing done in recordActivity.

default: '24 hours',
env: 'FXA_ACCOUNT_ACTIVITY_UPDATE_AFTER',
},
scopeToServiceName: {
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.

One solution too would be to parse out the service from the scope url... it's not great either, but might be better so we don't have to remember to update configs

I don't think we should record 'service' unless it's given to us in the URL. Otherwise I feel like that gets really confusing - plus, if some other RP requests one of these scopes, it doesn't mean we consider that the same "project" or "service".

@nshirley nshirley force-pushed the FXA-13662 branch 2 times, most recently from cce2474 to fecb319 Compare May 20, 2026 16:29
-- Account activity / liveness signal per (userId, clientId, service).
-- See FXA-13662. Written from the OAuth grant path; consumers include
-- inactive-account deletion, support tooling, and per-service MAU.
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!

CREATE TABLE `accountActivity` (
`userId` BINARY(16) NOT NULL,
`clientId` BINARY(8) NOT NULL,
`service` VARCHAR(32) NOT NULL DEFAULT '',
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.

I don't love having an arbitrary string value here...

return services;
}

services.add('');
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.

Huh?

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.

If there's no explicit mapping, and no fallback we can discern then we still want a value in the set so it gets recorded to the db. There's activity from the user of some kind and we should still record it... that said, maybe there's a better way to do that

Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts Outdated
Comment thread packages/fxa-auth-server/lib/oauth/account-activity.ts
// the grant response. All four grant types (authorization_code,
// fxa-credentials, refresh_token, token-exchange) converge here with a
// populated grant.scope, so one call site covers everything.
accountActivity.recordActivity(
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.

We need a kill switch here in case the DB call ends up being more load than anticipated. The write throttle is effective on a populated table, but initially the table will be unpopulated, which will incur a ton of writes out the gate. Perhaps even better than a kill switch is a secondary throttle / sampler of some sort.

Until this is addressed, I can't r+.

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.

@nshirley Lets add the feature flag just in case.

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.

Yep, agreed, adding it meow

// the unclassified ('') bucket. The metric tag is the durable signal.
const warnedUnclassifiedClientIds = new Set<string>();

/**
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.

}

// Per-process dedupe so we only warn once per clientId when activity lands in
// the unclassified ('') bucket. The metric tag is the durable signal.
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.

I feel like we should be passing in these config values. They really only need to be looked up / parsed once, and this could be done at the route handler layer.

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

/**
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.

This isn't really typesafe. Make the config take a concrete type, or just drop it and pass values to the function.

Copy link
Copy Markdown
Contributor

@dschom dschom left a comment

Choose a reason for hiding this comment

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

service

Copy link
Copy Markdown
Contributor

@vbudhram vbudhram left a comment

Choose a reason for hiding this comment

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

@nshirley Thanks, please add the feature flag and few comments to consider.

Screenshot 2026-05-20 at 1 08 36 PM

const QUERY_REFRESH_TOKEN_DELETE_USER =
'DELETE FROM refreshTokens WHERE userId=?';
const QUERY_CODE_DELETE_USER = 'DELETE FROM codes WHERE userId=?';
// Throttle is applied SQL-side: if the existing row was touched within the
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.

Lets make this comment more concise, try /fxa-simplify

// the grant response. All four grant types (authorization_code,
// fxa-credentials, refresh_token, token-exchange) converge here with a
// populated grant.scope, so one call site covers everything.
accountActivity.recordActivity(
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.

@nshirley Lets add the feature flag just in case.

default: '24 hours',
env: 'FXA_ACCOUNT_ACTIVITY_UPDATE_AFTER',
},
scopeToServiceName: {
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.

I personally think this is fine to keep as is at least for now. The correct solution is to have the service name be on the scope column in the database. I lean towards keeping this simple for the time being.

You can populate this with defaults

  {
    "https://identity.mozilla.com/apps/oldsync": "sync",
    "https://identity.mozilla.com/apps/relay": "relay",
    "https://identity.mozilla.com/apps/vpn": "vpn",
    "https://identity.mozilla.com/apps/smartwindow": "smartwindow"
  }

scopeToServiceName: Record<string, unknown>;
};
/** clientId (hex) → service tag fallback for clients without a scope mapping. */
clientIdToServiceNames: Record<string, unknown>;
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.

I would probably remove this. The service is the browser service, not the oauth client service name. If we wanted to get the other we could JOIN the table when doing analysis.

@nshirley
Copy link
Copy Markdown
Contributor Author

nshirley commented May 20, 2026

After some further discussion, we landed on using a junction-like table between the accountActivity and scopes. However, the scopes table is not currently up to date so we're not FK onto the scope id, rather just storing the string for now and then we can update/migrate at a later date.

Few reasons for this:

  • We have to capture the scopes, because of how desktop auth works. The client_id on the request is always desktop, but we can't infer the service being used without introspecting the scopes
  • To that, we also must capture the scopes individually with their own lastUsedAt because another request from desktop (or any same clientId/userId combo), say for smartwindow scope, would update the accountActivity.lastUsedAt, essentially giving a false positive that another existing scope that was prior authorized was recently used.

This gives us:

  1. The ability to centrally know account activity and last active status; Inactive-account-deletion liveness
  2. Liveness for lapsed Smart Window, Sync etc. users, regardless of desktop service or direct oauth
  3. Support tooling / dashboards showing user-to-service relationships

Testing notes. Signing in locally to a few services with initial signin and then re-authing or re-using the service after a few minutes generated the following results
image

-- used to join/validate the results
SELECT 
	c.name, 
    aats.scope,
    FROM_UNIXTIME(aa.firstSeenAt/1000) AS accountFirstSeenAt,
    FROM_UNIXTIME(aa.lastSeenAt/1000) AS accountLastSeenAt,
    FROM_UNIXTIME(aats.firstSeenAt/1000) AS scopeFirstSeenAt,
    FROM_UNIXTIME(aats.lastSeenAt/1000) AS scopeLastSeenAt
    FROM accountActivity AS aa
	LEFT JOIN accountActivityToScope 
		AS aats 
        USING (userId, clientId)
    LEFT JOIN clients 
		AS c 
        ON c.id = aa.clientId;

Okay, I've made the suggested improvements:

  • Remove the clientIdToServiceNames mapping and just relying on the scopeToServiceName mapping with suggested defaults
  • Added a throttling around the write so we can tune how fast we write the records
  • Few other clean ups

Here's some testing I did locally too after changes,

Initial sign into sync (joined to clients table for reference)
[removed for brevity]

Then, waited 5+ minutes with adjust throttleMs to make sure the db level write worked as expected (note the updated lastSeenAt left most column) I also signed into 123Done locally to generate the additional activity rows here
[removed for brevity]

Because:
- FxA lacks a durable per-(userId, clientId) activity signal needed for
  inactive-account deletion, per-RP liveness, lapsed-user analysis
  (Smart Window, Sync), and support tooling.
- Existing token tables either disappear on revoke, miss Firefox
  Desktop's online fxa-credentials service-token mints, or are sampled
  and Redis-biased and therefore under-detect active users.

This commit:
- Adds two new tables to the fxa_oauth schema. accountActivity (patch
  36->37, PK (userId, clientId)) holds per-client liveness timestamps.
  accountActivityScopes (patch 37->38, PK (userId, clientId, scope),
  FK ON DELETE CASCADE to the parent) records each granted scope with
  its own firstSeenAt/lastSeenAt so a fresh grant for one scope does
  not pollute the per-scope liveness of another.
- Indexes the scope mapping on (scope, lastSeenAt) for per-scope
  cohort queries (e.g. "users who last used Smart Window > 90 days
  ago") and on (lastSeenAt) for global time-window scans.
- Adds a fire-and-forget recordActivity helper (lib/oauth/account-activity.ts)
  that writes via a SQL-throttled upsert. The throttle predicate uses
  'VALUES(x) > x + ?' to avoid BIGINT UNSIGNED underflow on
  out-of-order timestamps. Per-scope upserts use the same predicate so
  each scope row's lastSeenAt advances independently of the parent's.
- Wires a single write site at the OAuth grant convergence point in
  tokenHandler so all four grant types (authorization_code,
  fxa-credentials, refresh_token, token-exchange) are recorded
  uniformly. Sample-gated by oauthServer.accountActivity.sampleRate
  (default 0) so the writer can be ramped per environment after stage
  validation.
- Extends _removeTokensAndCodes so accountActivity rows are cleaned up
  alongside tokens on account deletion; accountActivityScopes rows
  follow via the FK CASCADE.
- Emits accountActivity.recorded / accountActivity.write_failed StatsD
  metrics tagged with clientId (routed through getClientServiceTags
  for cardinality safety) and grantType.
- Adds an integration spec (test/remote/account_activity.in.spec.ts)
  covering the throttle window, per-scope independence (the pollution
  case), and FK CASCADE on account deletion. Adds a unit-level test
  in token.spec.ts that forces sampleRate = 1 to exercise the
  route-handler wiring.

Closes FXA-13662
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants