Skip to content

feat(payments-next): Add FxA Webhook support#20300

Open
david1alvarez wants to merge 1 commit intomainfrom
PAY-3464
Open

feat(payments-next): Add FxA Webhook support#20300
david1alvarez wants to merge 1 commit intomainfrom
PAY-3464

Conversation

@david1alvarez
Copy link
Copy Markdown
Contributor

Because:

  • FxA has several webhooks that SubPlat can make use of

This commit:

  • Adds a FxaWebhookService class
  • Adds routes to the payments-api service to receive webhooks
  • Adds validations to only handle valid webhook requests

Closes #PAY-3464

Checklist

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)

To verify, take a look at apps/payments/api/src/scripts/test-fxa-webhook.ts

@david1alvarez david1alvarez requested a review from a team as a code owner April 1, 2026 01:45
Copilot AI review requested due to automatic review settings April 1, 2026 01:45
Copy link
Copy Markdown

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 Firefox Accounts (FxA) Security Event Token (SET) webhook handling to the payments-next API by introducing a new FxA webhook service/controller pair, associated config/types/errors, and a local test script to generate and send signed events.

Changes:

  • Introduces FxaWebhookService + FxaWebhooksController to authenticate and dispatch FxA webhook events.
  • Adds FxaWebhookConfig and wires it into payments-api RootConfig / AppModule to enable configuration-driven validation.
  • Adds unit tests and a local integration script (test-fxa-webhook.ts) to exercise the endpoint.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
libs/payments/webhooks/src/lib/fxa-webhooks.types.ts Defines FxA event URIs and SET payload types.
libs/payments/webhooks/src/lib/fxa-webhooks.service.ts Implements SET bearer token extraction, signature verification, and event dispatch.
libs/payments/webhooks/src/lib/fxa-webhooks.service.spec.ts Adds tests for auth validation, event dispatch, and unhandled event reporting.
libs/payments/webhooks/src/lib/fxa-webhooks.error.ts Adds structured errors for auth failures and unhandled event types.
libs/payments/webhooks/src/lib/fxa-webhooks.controller.ts Exposes POST /webhooks/fxa endpoint.
libs/payments/webhooks/src/lib/fxa-webhooks.controller.spec.ts Tests controller-to-service wiring.
libs/payments/webhooks/src/lib/fxa-webhooks.config.ts Adds typed config for issuer/audience/public JWK (with env JSON parsing).
libs/payments/webhooks/src/index.ts Exports new FxA webhook modules from the webhooks library.
apps/payments/api/src/scripts/test-fxa-webhook.ts Local script to sign and POST a SET to the webhook endpoint.
apps/payments/api/src/config/index.ts Adds FxA webhook config to the API root typed config schema.
apps/payments/api/src/app/app.module.ts Registers the new FxA controller/service in the payments API module.
apps/payments/api/.env Adds FxA webhook env var placeholders for local config.

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

Comment on lines +101 to +112
const signed = match[1] + '.' + match[2];
const signature = Buffer.from(match[3], 'base64');
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signed);

if (!verifier.verify(this.publicPem, signature)) {
return null;
}

const payload = JSON.parse(
Buffer.from(match[2], 'base64').toString()
) as FxaSecurityEventTokenPayload;
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

JWTs use base64url encoding for the header/payload/signature segments, but this code decodes them with Buffer.from(..., 'base64'). Since the regex explicitly allows '-' and '_' (base64url alphabet), using 'base64' here can lead to incorrect decoding and failed signature verification/parsing in some Node versions. Consider decoding with 'base64url' (or normalizing base64url to base64) for both the signature and payload segments.

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +176
this.log.log('handlePasswordChange', { sub, event });
this.statsd.increment('fxa.webhook.event', {
eventType: 'password-change',
});
}

private async handleProfileChange(
sub: string,
event: FxaProfileChangeEvent
): Promise<void> {
this.log.log('handleProfileChange', { sub, event });
this.statsd.increment('fxa.webhook.event', {
eventType: 'profile-change',
});
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

These handlers log the full event payload (and sub). For profile-change this can include email and other account state fields, which is likely PII/sensitive and could end up in centralized logs. Consider logging only a minimal, non-PII subset (e.g., event type + uid hash/last4) or redacting specific fields before logging.

Copilot uses AI. Check for mistakes.
@IsString()
public readonly fxaWebhookAudience!: string;

@Transform(({ value }) => (typeof value === 'string' ? JSON.parse(value) : value))
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

The JSON.parse() in this @Transform will throw a raw SyntaxError when the env var is malformed (or an empty string), which can make configuration failures harder to diagnose. Consider catching parse errors and surfacing a clearer configuration/validation error message for FXA_WEBHOOK_PUBLIC_JWK.

Suggested change
@Transform(({ value }) => (typeof value === 'string' ? JSON.parse(value) : value))
@Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch (err: any) {
const message =
'Invalid JSON provided for FXA_WEBHOOK_PUBLIC_JWK environment variable: ' +
(err && err.message ? err.message : String(err));
throw new Error(message);
}
})

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +15
#!/usr/bin/env ts-node
/* 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/. */

/**
* Local integration test script for the FxA webhook endpoint.
*
* Generates a signed JWT Security Event Token and POSTs it to the
* payments API webhook route. Uses the test RSA key pair from the
* event-broker test suite.
*
* Usage:
* npx tsx apps/payments/api/src/scripts/test-fxa-webhook.ts [options]
*
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

The script header says to run with npx tsx ..., but the shebang is #!/usr/bin/env ts-node. This mismatch can confuse users and may fail depending on what runtime is installed. Consider aligning the shebang and the documented invocation (either tsx everywhere or ts-node everywhere).

Copilot uses AI. Check for mistakes.
this.statsd.increment('fxa.webhook.error');
}
this.log.error(error);
Sentry.captureException(error);
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

handleWebhookEvent captures all errors (including expected auth failures like missing/invalid Authorization) to Sentry. This can create noisy alerting and higher ingestion costs if the endpoint receives routine invalid traffic. Consider skipping Sentry.captureException for FxaWebhookAuthError (while still incrementing StatsD) or capturing it at a lower severity/sampled rate.

Suggested change
Sentry.captureException(error);
if (!(error instanceof FxaWebhookAuthError)) {
Sentry.captureException(error);
}

Copilot uses AI. Check for mistakes.
@david1alvarez david1alvarez force-pushed the PAY-3464 branch 3 times, most recently from c4e2b6e to df13e09 Compare April 1, 2026 16:54
Because:

* FxA has several webhooks that SubPlat can make use of

This commit:

* Adds a FxaWebhookService class
* Adds routes to the payments-api service to receive webhooks
* Adds validations to only handle valid webhook requests

Closes #PAY-3464
}
this.log.error(error);
Sentry.captureException(error);
// Swallow error to avoid retries
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Until we have a queue in place, we probably do not want to swallow so that we do get retries

payload: FxaSecurityEventTokenPayload
): Promise<void> {
for (const eventUri of Object.keys(payload.events)) {
const eventData = payload.events[eventUri];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should use zod to enforce shape here, which can also infer the type so that the casts below aren't necessary.

events: {
[uri: string]: Record<string, any>;
};
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can replace almost all of the types above with zod schemas and then zod infer the type from those schemas.

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.

question: Is this a temporary file?

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.

Its useful for testing. We can either leave it as a script for manual local testing, or remove it before merge

);
});

it('returns success response', async () => {
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.

request: could you please add a test for an error response as well please?

changeTime: number;
}

export interface FxaProfileChangeEvent {
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.

issue: oops, this doesn't seem to match the documented payload

https://github.com/mozilla/fxa/blob/main/packages/fxa-event-broker/README.md#profile-change

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.

Agreed, but it does match the actual implementation of their generateProfileSET method

the pubsub proxy controller calls jwtset.generateProfileSET with

clientId,
uid: message.uid,
email: message.email,
locale: message.locale,
metricsEnabled: message.metricsEnabled,
totpEnabled: message.totpEnabled,
accountDisabled: message.accountDisabled,
accountLocked: message.accountLocked,

and only these fields make its way to the events field of the SET:

email: proEvent.email,
locale: proEvent.locale,
metricsEnabled: proEvent.metricsEnabled,
totpEnabled: proEvent.totpEnabled,
accountDisabled: proEvent.accountDisabled,
accountLocked: proEvent.accountLocked,

which matches the FxaProfileChangeEvent type

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 code doesn't look particularly volatile either. Last change was by Danny Coates 4 years ago, I think.

return authorization.slice(7);
}

private verifyToken(token: string): FxaSecurityEventTokenPayload | null {
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.

question: implementation differs quite a bit from FxA example

Would it be better to more closely follow the example recommended by FxA, as documented here?

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.

Yes. I hadn't come across that documentation, but its A) a bit more straightforward and B) more likely to stay in line with what FxA is doing going forward. I'll update the structure.

@Inject(StatsDService) private statsd: StatsD,
@Inject(Logger) private log: LoggerService
) {
this.publicPem = jwk2pem(fxaWebhookConfig.fxaWebhookPublicJwk);
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.

issue: fetch public keys from FxA public url instead of providing via env var.

FxA docs recommend fetching the public keys

Verify them using FxA's public keys from the JWKS endpoint

}
}

private async handlePasswordChange(
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.

question: should the handle* methods be moved to their own class?

Moving the handle methods to their own class shows a clear separation in purpose of each class.

  1. The fxa-webhooks.service receives the event, authenticates it and dispatches it to the correct handler.
  2. The "handler" class, implements the logic for how the events should be processed.

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.

As it stands right now, the FxaWebhooksService class is responsible for two things: event authentication, and event handling. I think that the handle* methods should stay in this service layer. They are where (eventually) the bulk of the business business logic for the webhooks will live, and that feels in line with the goal of a service.

If the goal is to streamline this class, I'd maybe suggest we bump out the authentication methods into the fxa webhooks controller layer, or another new class. What do you think?

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.

4 participants