Skip to content

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

Merged
david1alvarez merged 1 commit intomainfrom
PAY-3464
Apr 7, 2026
Merged

feat(payments-next): Add FxA Webhook support#20300
david1alvarez merged 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 4 times, most recently from df13e09 to 83b6e3a Compare April 1, 2026 17:34
}
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.

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'm using type-dependent safeParse(eventData) calls below this to ensure the type is what we expect. A top-level globalFooPattern.safeParse(eventData) isn't viable with how different their structures are.

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

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 it possible to test this locally with the fxa-event-broker service?

If the fxa-event-broker service works in the local stack, and the events can be tested in that way, then I'm leaning towards not merging the script. It adds code that might need to be maintained in future, etc. But that's my 2 cents, also happy to merge if it makes sense.

Comment thread libs/payments/webhooks/src/lib/fxa-webhooks.controller.spec.ts
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.

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've updated the typing to be more in line with what the docs have, but in a way that still works with the actual structure theyre sending

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.

Oh nice, well spotted. As an optional task, it'd be good to update the docs.

Comment thread libs/payments/webhooks/src/lib/fxa-webhooks.service.ts Outdated
@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?

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.

That sounds good to me.

Just to share my thinking, for the Stripe Webhook logic I opted to have the StripeWebhookService to handle just the logic around decoding the event and making sure it goes to the right place, and then have the SubscriptionEventsService that contains the business logic for each event. IMO it creates a nice separation of concerns, so I figured I'd just suggest it.

@david1alvarez david1alvarez force-pushed the PAY-3464 branch 3 times, most recently from 0eef349 to 2c2fc49 Compare April 6, 2026 22:38
Comment on lines +79 to +94
const issuer = this.fxaWebhookConfig.fxaWebhookIssuer.endsWith('/')
? this.fxaWebhookConfig.fxaWebhookIssuer
: this.fxaWebhookConfig.fxaWebhookIssuer + '/';
const discoveryUrl = `${issuer}.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl);
if (!discoveryResponse.ok) {
throw new FxaWebhookJwksError(
`Failed to fetch OIDC discovery document: ${discoveryResponse.status}`
);
}
const { jwks_uri: jwksUri } = await discoveryResponse.json();
if (!jwksUri) {
throw new FxaWebhookJwksError(
'OIDC discovery document missing jwks_uri'
);
}
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.

suggestion: replace this logic with a config var for the jwks endpoint

I don't think the JWKS endpoint changes often, so it should be fine to provide it via an env var instead of fetching it every time.

);
}

const jwksResponse = await fetch(jwksUri);
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: do not fetch jwks on every request

This webhook handler will be receiving quite a few requests. I believe the suggestion in the FxA docs was to fetch the jwk once on startup, although an alternative maybe better solution might be have a in memory cache.

suggestion: use in memory cache decorator (currently being used used by Strapi CMS)

Copy link
Copy Markdown
Contributor

@StaberindeZA StaberindeZA left a comment

Choose a reason for hiding this comment

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

r+wc. Looks good, lets get this into Stage. Just some small remaining changes.

Also it'd be great if we could test this locally with auth-server and event-broker if it's simple enough to setup.

ttlSeconds: DEFAULT_JWKS_FALLBACK_TTL_SECONDS,
client: (_: any, context: FxaWebhookService) =>
context.fallbackCacheAdapter,
})
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.

suggestion: remove fallback. Just having the memory cache adapter should be good enough.

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'd like to keep it, if that's alright. Its the same caching pattern as we use elsewhere and I don't see much of a drawback. I'm planning to merge without, but let me know if you'd like me to file a followup!

Comment on lines +52 to +53
const DEFAULT_JWKS_CACHE_TTL_SECONDS = 300; // 300 seconds is 5 minutes.
const DEFAULT_JWKS_FALLBACK_TTL_SECONDS = 1800; // 1800 seconds is 30 minutes.
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(non-blocking): should these be configurable?

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 don't think its necessary, it's the same pattern as elsewhere and I don't expect these to change

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
@david1alvarez david1alvarez merged commit 74bdf01 into main Apr 7, 2026
22 checks passed
@david1alvarez david1alvarez deleted the PAY-3464 branch April 7, 2026 23:20
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