diff --git a/packages/fxa-auth-server/lib/log.spec.ts b/packages/fxa-auth-server/lib/log.spec.ts index 11a7843d6ad..6a3c7ccbbf4 100644 --- a/packages/fxa-auth-server/lib/log.spec.ts +++ b/packages/fxa-auth-server/lib/log.spec.ts @@ -59,6 +59,7 @@ jest.mock('fxa-shared', () => ({ })); import logModule from './log'; +import { OAuthNativeClients, OAuthNativeServices } from '@fxa/accounts/oauth'; const validEvent = { op: 'amplitudeEvent', @@ -859,4 +860,58 @@ describe('log', () => { }, }); }); + + it('.notifyAttachedServices preserves an explicit clientId and firstAuthorization alongside an OAuthNative service', async () => { + const now = 1600000000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + + const metricsContext = { + time: now, + entrypoint: 'wibble', + entrypoint_experiment: undefined, + entrypoint_variation: undefined, + flow_id: + 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103', + flow_time: 23, + flowBeginTime: now - 23, + flowCompleteSignal: undefined, + flowType: undefined, + plan_id: undefined, + product_id: undefined, + utm_campaign: 'utm campaign', + utm_content: 'utm content', + utm_medium: 'utm medium', + utm_source: 'utm source', + utm_term: 'utm term', + }; + const mockGatherMetricsContext = jest + .fn() + .mockResolvedValue(metricsContext); + const request = { gatherMetricsContext: mockGatherMetricsContext }; + + // Browser-flow login: service is an OAuthNative service name (the `service` + // query param), not a hex client id, so the hex->clientId conversion must not + // fire and the explicit clientId/firstAuthorization pass through. + await log.notifyAttachedServices('login', request, { + service: OAuthNativeServices.SmartWindow, + clientId: OAuthNativeClients.FirefoxDesktop, + firstAuthorization: true, + ts: now, + }); + + expect(mockGatherMetricsContext).toHaveBeenCalledTimes(1); + expect(mockNotifierSend).toHaveBeenCalledTimes(1); + expect(mockNotifierSend).toHaveBeenCalledWith({ + event: 'login', + data: { + service: OAuthNativeServices.SmartWindow, + clientId: OAuthNativeClients.FirefoxDesktop, + firstAuthorization: true, + timestamp: now, + ts: now, + iss: 'example.com', + metricsContext, + }, + }); + }); }); diff --git a/packages/fxa-auth-server/lib/oauth/first-authorization.spec.ts b/packages/fxa-auth-server/lib/oauth/first-authorization.spec.ts new file mode 100644 index 00000000000..62b05eb49d4 --- /dev/null +++ b/packages/fxa-auth-server/lib/oauth/first-authorization.spec.ts @@ -0,0 +1,114 @@ +/* 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 { OAuthNativeClients, OAuthNativeServices } from '@fxa/accounts/oauth'; + +import { + deriveFirstAuthorization, + ConsentRowLike, +} from './first-authorization'; + +const DESKTOP = OAuthNativeClients.FirefoxDesktop; // native +const IOS = OAuthNativeClients.FirefoxIOS; // native, different app +const WEB_RP = '98e6508e88680e1b'; // arbitrary non-native web RP (no enum) + +function row(service: string, clientIdHex: string): ConsentRowLike { + return { service, clientId: clientIdHex }; +} + +describe('deriveFirstAuthorization', () => { + describe('browser service (native client)', () => { + it('is true on the first authorization of the service', () => { + expect( + deriveFirstAuthorization({ + serviceValue: OAuthNativeServices.SmartWindow, + clientIdHex: DESKTOP, + isNativeClient: true, + existingConsents: [], + }) + ).toBe(true); + }); + + it('is true even when the user already used a different service on the same client', () => { + expect( + deriveFirstAuthorization({ + serviceValue: OAuthNativeServices.SmartWindow, + clientIdHex: DESKTOP, + isNativeClient: true, + existingConsents: [row(OAuthNativeServices.Sync, DESKTOP)], + }) + ).toBe(true); + }); + + it('is false on a repeat authorization of the service', () => { + expect( + deriveFirstAuthorization({ + serviceValue: OAuthNativeServices.SmartWindow, + clientIdHex: DESKTOP, + isNativeClient: true, + existingConsents: [row(OAuthNativeServices.SmartWindow, DESKTOP)], + }) + ).toBe(false); + }); + + it('is false when the prior consent for the service came from a different client (cross-device)', () => { + expect( + deriveFirstAuthorization({ + serviceValue: OAuthNativeServices.Vpn, + clientIdHex: DESKTOP, + isNativeClient: true, + existingConsents: [row(OAuthNativeServices.Vpn, IOS)], + }) + ).toBe(false); + }); + }); + + describe('web RP (non-native client, no resolved service)', () => { + it('is true on the first authorization of the RP', () => { + expect( + deriveFirstAuthorization({ + serviceValue: '', + clientIdHex: WEB_RP, + isNativeClient: false, + existingConsents: [], + }) + ).toBe(true); + }); + + it('is true when the user has used a different RP before', () => { + expect( + deriveFirstAuthorization({ + serviceValue: '', + clientIdHex: WEB_RP, + isNativeClient: false, + existingConsents: [row('', 'aaaaaaaaaaaaaaaa')], + }) + ).toBe(true); + }); + + it('is false on a repeat authorization of the RP, regardless of scope', () => { + expect( + deriveFirstAuthorization({ + serviceValue: '', + clientIdHex: WEB_RP, + isNativeClient: false, + existingConsents: [row('', WEB_RP)], + }) + ).toBe(false); + }); + }); + + describe('ambiguous native client with no resolved service', () => { + it('is false (not a marketing RP, service unknown)', () => { + expect( + deriveFirstAuthorization({ + serviceValue: '', + clientIdHex: DESKTOP, + isNativeClient: true, + existingConsents: [], + }) + ).toBe(false); + }); + }); +}); diff --git a/packages/fxa-auth-server/lib/oauth/first-authorization.ts b/packages/fxa-auth-server/lib/oauth/first-authorization.ts new file mode 100644 index 00000000000..56517367c77 --- /dev/null +++ b/packages/fxa-auth-server/lib/oauth/first-authorization.ts @@ -0,0 +1,50 @@ +/* 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/. */ + +// Decide whether an OAuth signin is the *first* time a user has used a given +// service / relying party, from the existing accountAuthorizations consent rows +// read just before this authorization's rows are written. +// +// Two grains, because the ledger keys on (uid, scope, service, clientId): +// - Browser service (an OAuthNative service): Firefox shares a client ID across all +// OAuth Native services for that client, so "new to the service" must key on `service`. +// - Web RP (service=''): the clientId *is* the RP, so key on clientId. +// +// `sync` is included but NOT currently reliable: Desktop always creates a sync-scoped +// access token even when signing into another service, so a first sync can't be told apart +// from a first use of that other service. Native clients with no resolved service are +// ambiguous and excluded (return false). + +export type ConsentRowLike = { + service: string; + /** Hex-encoded OAuth client id (the caller normalizes Buffer rows to hex). */ + clientId: string; +}; + +export function deriveFirstAuthorization(params: { + /** Resolved native service for this authorization ('' for web RPs). */ + serviceValue: string; + /** Hex OAuth client id for this authorization. */ + clientIdHex: string; + /** Whether clientIdHex is a native (browser) client. */ + isNativeClient: boolean; + /** The user's accountAuthorizations rows read *before* this auth's writes. */ + existingConsents: ConsentRowLike[]; +}): boolean { + const { serviceValue, clientIdHex, isNativeClient, existingConsents } = + params; + + if (serviceValue) { + // OAuthNative: new to the service= query param passed in + return !existingConsents.some((r) => r.service === serviceValue); + } + + if (!serviceValue && !isNativeClient) { + // Web RP: new to the RP, identified by clientId. + return !existingConsents.some((r) => r.clientId === clientIdHex); + } + + // Native client with no resolved service: ambiguous + return false; +} diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 710b8f7399e..f06599b85ad 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -255,6 +255,7 @@ export class AccountHandler { const country = geoData.location && geoData.location.country; const countryCode = geoData.location && geoData.location.countryCode; if (account.emailVerified) { + const { clientId } = getClientServiceTags(request); await this.log.notifyAttachedServices('verified', request, { email: account.email, locale: account.locale, @@ -263,6 +264,7 @@ export class AccountHandler { userAgent: userAgentString, country, countryCode, + ...(clientId && { clientId }), }); } diff --git a/packages/fxa-auth-server/lib/routes/oauth/authorization.js b/packages/fxa-auth-server/lib/routes/oauth/authorization.js index dcd9ce5b24c..4b6a38ef535 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/authorization.js +++ b/packages/fxa-auth-server/lib/routes/oauth/authorization.js @@ -13,6 +13,9 @@ const validators = require('../../oauth/validators'); const { validateRequestedGrant, generateTokens } = require('../../oauth/grant'); const { makeAssertionJWT } = require('../../oauth/util'); const verifyAssertion = require('../../oauth/assertion'); +const { + deriveFirstAuthorization, +} = require('../../oauth/first-authorization'); const OAUTH_DOCS = require('../../../docs/swagger/oauth-api').default; const OAUTH_SERVER_DOCS = require('../../../docs/swagger/oauth-server-api').default; @@ -205,6 +208,10 @@ module.exports = ({ log, oauthDB, config, statsd }) => { serviceValue = inferred[0]; } } + // Expose the resolved service so the `login` event reports `service` at the + // same grain as `firstAuthorization` (serviceTag alone misses scope-only + // flows, e.g. VPN cached sign-in that sends scope but no service=). + req.app.oauthService = serviceValue; if (!oauthDB.isClientAllowedForService(serviceValue, clientIdHex)) { statsd?.increment('accountAuthorization.skipped', { reason: 'client_not_allowed', @@ -221,6 +228,28 @@ module.exports = ({ log, oauthDB, config, statsd }) => { } const now = Date.now(); const uidHex = hex(grant.userId); + // Read existing consents *before* the writes to detect the user's first use + // of this service / RP (drives `firstAuthorization` on the `login` event). + // Best-effort, so its own try/catch keeps a read failure from suppressing + // the load-bearing consent writes below. + let firstAuthorization = false; + try { + const existingConsents = await oauthDB.listAccountConsentsByUid(uidHex); + firstAuthorization = deriveFirstAuthorization({ + serviceValue, + clientIdHex, + isNativeClient: OAUTH_NATIVE_CLIENT_IDS.has(clientIdHex), + existingConsents: existingConsents.map((r) => ({ + service: r.service, + clientId: hex(r.clientId), + })), + }); + } catch (err) { + statsd?.increment('accountAuthorization.first_auth_read_failed'); + log.warn('accountAuthorization.first_auth_read_failed', { + err: err.message, + }); + } // allSettled so a second sibling rejection does not become an // unhandled rejection after the first failure has been caught upstream. const results = await Promise.allSettled( @@ -238,6 +267,10 @@ module.exports = ({ log, oauthDB, config, statsd }) => { if (failure) { throw failure.reason; } + // Only flag once the write succeeded, so the signal matches what landed. + if (firstAuthorization) { + req.app.firstAuthorization = true; + } statsd?.increment('accountAuthorization.recorded', { service: serviceValue || 'unset', access_type: grant.offline ? 'offline' : 'online', @@ -573,10 +606,15 @@ module.exports = ({ log, oauthDB, config, statsd }) => { countryCode, deviceCount: devices.length, email, - service: clientId, + // Resolved browser service (an OAuthNative service) when there is + // one, so consumers can distinguish services that share a clientId + // (Smart Window vs Sync on Desktop); else serviceTag, else the clientId + // (mapped by log.js) for web RPs. + service: req.app.oauthService || req.app.serviceTag || clientId, clientId, uid, userAgent: req.headers['user-agent'], + firstAuthorization: !!req.app.firstAuthorization, }); return result; }, diff --git a/packages/fxa-auth-server/lib/routes/oauth/authorization.spec.ts b/packages/fxa-auth-server/lib/routes/oauth/authorization.spec.ts index 7b8dfc5f369..c2682165721 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/authorization.spec.ts +++ b/packages/fxa-auth-server/lib/routes/oauth/authorization.spec.ts @@ -357,6 +357,7 @@ describe('/authorization POST consent write', () => { ), getServiceForCanonicalScope: jest.fn(() => undefined), recordSignInConsent: jest.fn().mockResolvedValue(undefined), + listAccountConsentsByUid: jest.fn().mockResolvedValue([]), ...overrides, }; } @@ -378,7 +379,11 @@ describe('/authorization POST consent write', () => { statsd?: any; log?: any; payload?: Record; + app?: Record; }) { + // Real hapi requests always have `app`; recordAuthorizationRows stashes + // service/firstAuthorization there. Returned so tests can assert on it. + const app = opts.app ?? {}; let routes: any; await jest.isolateModulesAsync(async () => { jest.doMock('../../oauth/assertion', () => @@ -404,9 +409,11 @@ describe('/authorization POST consent write', () => { }); await routes[1].config.handler({ headers: {}, + app, payload: buildPayload(opts.payload), }); }); + return { app }; } it('writes one row per requested scope plus the service canonical and consults the allowlist', async () => { @@ -497,6 +504,73 @@ describe('/authorization POST consent write', () => { ); }); + it('reads existing consents before writing and flags firstAuthorization for a first-time RP', async () => { + // buildOauthDB defaults listAccountConsentsByUid to [] (no prior consent). + const oauthDB = buildOauthDB(); + + const { app } = await runHandler({ + oauthDB, + payload: { scope: 'profile' }, + }); + + expect(oauthDB.listAccountConsentsByUid).toHaveBeenCalledWith(UID_HEX); + expect(oauthDB.recordSignInConsent).toHaveBeenCalled(); + expect(app.firstAuthorization).toBe(true); + }); + + it('does not flag firstAuthorization on a repeat authorization of the same RP', async () => { + const oauthDB = buildOauthDB({ + listAccountConsentsByUid: jest + .fn() + .mockResolvedValue([{ service: '', clientId: CLIENT_ID }]), + }); + + const { app } = await runHandler({ + oauthDB, + payload: { scope: 'profile' }, + }); + + expect(app.firstAuthorization).toBeUndefined(); + }); + + it('does not flag firstAuthorization when the consent write fails', async () => { + const oauthDB = buildOauthDB({ + listAccountConsentsByUid: jest.fn().mockResolvedValue([]), + recordSignInConsent: jest.fn().mockRejectedValue(new Error('db down')), + }); + + const { app } = await runHandler({ + oauthDB, + log: { ...mockLog, warn: jest.fn() }, + payload: { scope: 'profile' }, + }); + + expect(app.firstAuthorization).toBeUndefined(); + }); + + it('still writes consent (and reports first_auth_read_failed) when the firstAuthorization read fails', async () => { + const oauthDB = buildOauthDB({ + listAccountConsentsByUid: jest + .fn() + .mockRejectedValue(new Error('read down')), + }); + const log = { ...mockLog, warn: jest.fn() }; + const statsd = { increment: jest.fn() }; + + const { app } = await runHandler({ + oauthDB, + log, + statsd, + payload: { scope: 'profile' }, + }); + + expect(oauthDB.recordSignInConsent).toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'accountAuthorization.first_auth_read_failed' + ); + expect(app.firstAuthorization).toBeUndefined(); + }); + it('skips the consent write when clientId is not allowed for the service', async () => { const oauthDB = buildOauthDB({ isClientAllowedForService: jest.fn(() => false), diff --git a/packages/fxa-auth-server/lib/routes/utils/account.ts b/packages/fxa-auth-server/lib/routes/utils/account.ts index 1b2ae2939d9..85da56cfc04 100644 --- a/packages/fxa-auth-server/lib/routes/utils/account.ts +++ b/packages/fxa-auth-server/lib/routes/utils/account.ts @@ -6,6 +6,7 @@ import { RelyingPartiesQuery } from '../../../../../libs/shared/cms/src/__genera import { RelyingPartyConfigurationManager } from '@fxa/shared/cms'; import { ReasonForDeletion } from '@fxa/shared/cloud-tasks'; import { DB } from '../../db'; +import { getClientServiceTags } from '../../metrics/client-tags'; export async function deleteAccountIfUnverified( db: DB, @@ -133,6 +134,7 @@ export async function notifyAttachedServicesForAccountSession(options: { const notifications: Promise[] = []; if (isNewAccount && emailVerified) { + const { clientId } = getClientServiceTags(request); notifications.push( log.notifyAttachedServices('verified', request, { country, @@ -142,6 +144,7 @@ export async function notifyAttachedServicesForAccountSession(options: { service, uid: account.uid, userAgent, + ...(clientId && { clientId }), }) ); } diff --git a/packages/fxa-auth-server/lib/routes/utils/signup.js b/packages/fxa-auth-server/lib/routes/utils/signup.js index 1d76038c571..55eaa6f5bc0 100644 --- a/packages/fxa-auth-server/lib/routes/utils/signup.js +++ b/packages/fxa-auth-server/lib/routes/utils/signup.js @@ -8,6 +8,7 @@ const { OAUTH_SCOPE_OLD_SYNC } = require('fxa-shared/oauth/constants'); const { Container } = require('typedi'); const { FxaMailer } = require('../../senders/fxa-mailer'); const { FxaMailerFormat } = require('../../senders/fxa-mailer-format'); +const { getClientServiceTags } = require('../../metrics/client-tags'); const NOTIFICATION_SCOPES = ScopeSet.fromArray([OAUTH_SCOPE_OLD_SYNC]); module.exports = (log, db, mailer, push, verificationReminders, glean) => { @@ -31,6 +32,10 @@ module.exports = (log, db, mailer, push, verificationReminders, glean) => { const { deviceId, flowId, flowBeginTime, productId, planId } = await request.app.metricsContext; + // Include the OAuth client id (when the signup came through an OAuth flow) + // so attached services receive client_id alongside service. + const { clientId } = getClientServiceTags(request); + await Promise.all([ log.notifyAttachedServices('verified', request, { country, @@ -41,6 +46,7 @@ module.exports = (log, db, mailer, push, verificationReminders, glean) => { service, uid, userAgent: request.headers['user-agent'], + ...(clientId && { clientId }), }), request.emitMetricsEvent('account.verified', { deviceId, diff --git a/packages/fxa-auth-server/lib/routes/utils/signup.spec.ts b/packages/fxa-auth-server/lib/routes/utils/signup.spec.ts index 36d9c9c1786..13636b3cb8a 100644 --- a/packages/fxa-auth-server/lib/routes/utils/signup.spec.ts +++ b/packages/fxa-auth-server/lib/routes/utils/signup.spec.ts @@ -11,6 +11,10 @@ import { const mocks = require('../../../test/mocks'); const { gleanMetrics } = require('../../metrics/glean'); +const { + OAuthNativeClients, + OAuthNativeServices, +} = require('@fxa/accounts/oauth'); const TEST_EMAIL = 'test@email.com'; const TEST_UID = '123123'; @@ -158,4 +162,52 @@ describe('verifyAccount', () => { ); }); }); + + describe('verified notification', () => { + beforeEach(async () => { + utils = await setup({ db, log, mailer, push, verificationReminders }); + }); + + it('sends the full payload', async () => { + account.locale = 'en-US'; + const requestWithClient = mocks.mockRequest({ + log, + app: { clientIdTag: OAuthNativeClients.FirefoxDesktop }, + payload: { metricsContext: {} }, + }); + + await utils.verifyAccount(requestWithClient, account, { + service: OAuthNativeServices.SmartWindow, + newsletters: ['test-pilot'], + }); + + expect(log.notifyAttachedServices).toHaveBeenNthCalledWith( + 1, + 'verified', + expect.anything(), + { + uid: TEST_UID, + email: TEST_EMAIL, + locale: 'en-US', + newsletters: ['test-pilot'], + service: OAuthNativeServices.SmartWindow, + clientId: OAuthNativeClients.FirefoxDesktop, + country: 'United States', + countryCode: 'US', + userAgent: 'test user-agent', + } + ); + }); + + it('omits clientId when request.app.clientIdTag is not set', async () => { + // The default mockRequest has no clientIdTag. + await utils.verifyAccount(request, account, { service: 'sync' }); + + const verifiedCall = log.notifyAttachedServices.mock.calls.find( + (call: any[]) => call[0] === 'verified' + ); + expect(verifiedCall).toBeDefined(); + expect(verifiedCall[2]).not.toHaveProperty('clientId'); + }); + }); }); diff --git a/packages/fxa-event-broker/README.md b/packages/fxa-event-broker/README.md index 049fda17932..56af6e07420 100644 --- a/packages/fxa-event-broker/README.md +++ b/packages/fxa-event-broker/README.md @@ -9,162 +9,15 @@ Relying Parties (RPs) should receive them, then distributed via webhooks using t This event broker also stores event metadata, tracking which RPs a user has logged into to screen event delivery to relevant RPs. -## Relying Party Event Format - -A relying party will get webhook calls for events. These events are encoded in -[SET][set]s with the following formats. See the [SET RFC][set] for definitions and other -examples. - -### Password Change - -Sent when a user has reset or changed their password. Services receiving this event -should terminate user login sessions that were established prior to the event. - -- Event Identifier - - `https://schemas.accounts.firefox.com/event/password-change` -- Event Payload - - [Password Event Identifier] - - changeTime - - Time when the password reset took place. All logins established before this - time should be terminated. - -### Example Password Change Event - - { - "iss": "https://accounts.firefox.com/", - "sub": "FXA_USER_ID", - "aud": "REMOTE_SYSTEM", - "iat": 1565720808, - "jti": "e19ed6c5-4816-4171-aa43-56ffe80dbda1", - "events": { - "https://schemas.accounts.firefox.com/event/password-change": { - "changeTime": 1565721242227 - } - } - -### Profile Change - -Sent when a user has changed their profile data in some manner. Changes to any of the following user data will trigger this event. - -- Display Name - This can be changed on the account settings page -- Email Address - This can be changed on the settings page by updating the primary email address -- Profile Image - This can be changed on the settings page -- Metrics Collection Enabled - This can be changed on the account settings page through the ‘Help Improve Mozilla Accounts’ option in the `Data Collection and Use` section. -- Locale - This can be changed through the admin panel, and represents their language preference. -- Totp Enabled - This can be changed through the admin panel. -- Account Disabled - This can be changed through the admin panel. -- Account Locked - This can be changed through the admin panel. The state can be changed back to unlocked once a user accepts an account reset. -- A change to subscription state - There's several ways this can occur but in general this happens when signing up for or canceling subscriptions. - -When the event fires, it has the following structure: - -- Event Identifier - - `https://schemas.accounts.firefox.com/event/profile-change` -- Event Payload - - [Profile Event Identifier] - - uid {string} (required) - The account’s unique identifier - -It’s important to note that this event does not indicate what changed. Rather, it merely signals that services should update any cached profile data -they have for this user. Furthermore, it’s possible that the data which changed was outside the OAuth scope the service was granted, in which case -the service might not have privileges to access what was changed. - -### Example Profile Change Event - - { - "iss": "https://accounts.firefox.com/", - "sub": "FXA_USER_ID", - "aud": "REMOTE_SYSTEM", - "iat": 1565720808, - "jti": "e19ed6c5-4816-4171-aa43-56ffe80dbda1", - "events": { - "https://schemas.accounts.firefox.com/event/profile-change": { - "uid": "cd1181e0532c45cb989a7c234641468e" - } - } - -### Subscription State Change - -Sent when a user's subscription state has changed to RPs that provide the changed -subscription capability. - -**NOTE**: There are strict requirements about subscription state change handling -based on the `changeTime` as documented below. - -- Event Identifier - - `https://schemas.accounts.firefox.com/event/subscription-state-change` -- Event Payload - - [Subscription Event Identifier] - - capabilities - - List of subscription capabilities - - isActive - - Boolean indicating if the subscription should be considered active or not - for the subscription capabilities provided. - - changeTime - - Time in seconds when the state change occured in the payment system. - This value MUST be tracked by the receiving system, and events with a - changeTime older than the last tracked time MUST be discarded. - -### Example Subscription State Change Event - - { - "iss": "https://accounts.firefox.com/", - "sub": "FXA_USER_ID", - "aud": "REMOTE_SYSTEM", - "iat": 1565720808, - "jti": "e19ed6c5-4816-4171-aa43-56ffe80dbda1", - "events": { - "https://schemas.accounts.firefox.com/event/subscription-state-change": { - "capabilities": ["capability_1", "capability_2"], - "isActive": true, - "changeTime": 1565721242227 - } - } - -### Delete User - -Sent when a user has been deleted from Firefox Accounts. RPs MUST delete all user -records for the given user when receiving this event. - -- Event Identifier - - `https://schemas.accounts.firefox.com/event/delete-user` -- Event Payload - - [Delete Event Identifier] - - `{}` - -### Example Delete Event - - { - "iss": "https://accounts.firefox.com/", - "sub": "FXA_USER_ID", - "aud": "REMOTE_SYSTEM", - "iat": 1565720810, - "jti": "1b3d623a-300a-4ab8-9241-855c35586809", - "events": { - "https://schemas.accounts.firefox.com/event/delete-user": {} - } - -### Metrics Opt Out - -Sent when a user opts out of metrics / data collection from their Mozilla Accounts settings page. -RPs should stop reporting metrics for this user. Note, that when a user creates an account, metrics -collection is enabled by default. - -- Event Identifier - - `https://schemas.accounts.firefox.com/event/metrics-opt-out` -- Event Payload - - [Metrics Opt Out Event Identifier] - - `{}` - -### Metrics Opt In - -Sent when a user opts back into metrics / data collection from Firefox Accounts their Mozilla Accounts settings page. -RPs can start reporting metrics for this user again. - -- Event Identifier - - `https://schemas.accounts.firefox.com/event/metrics-opt-in` -- Event Payload - - [Metrics Opt In Event Identifier] - - `{}` +## Event Format + +The events delivered to relying parties (as SETs) and the raw internal SNS/SQS event +stream this broker consumes are both documented in one place — see +[Account Events](https://mozilla.github.io/ecosystem-platform/reference/account-events) +on the ecosystem platform site, which describes each event identifier, its payload, and +the SET envelope. For RP-side integration steps (registering an endpoint and verifying +signatures), see +[Integrating with FxA](https://mozilla.github.io/ecosystem-platform/relying-parties/tutorials/integrating-with-fxa). ## Deployment @@ -220,7 +73,6 @@ of the diagrams. This package is built using [NestJS](https://nestjs.com/) and follows module/service/providor patterns as documented for a NestJS project. -[fxasp]: https://github.com/mozilla/fxa/blob/main/packages/fxa-auth-server/docs/service_notifications.md [mermaid live editor]: https://mermaid-js.github.io/mermaid-live-editor/ [mermaid]: mermaidjs.github.io/ [set]: https://tools.ietf.org/html/rfc8417