Skip to content

Commit 74bdf01

Browse files
Merge pull request #20300 from mozilla/PAY-3464
feat(payments-next): Add FxA Webhook support
2 parents 060c1a8 + 6b60874 commit 74bdf01

16 files changed

Lines changed: 1302 additions & 3 deletions

apps/payments/api/.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,8 @@ GLEAN_CONFIG__APPLICATION_ID=
5757
GLEAN_CONFIG__VERSION=0.0.0
5858
GLEAN_CONFIG__CHANNEL='development'
5959
GLEAN_CONFIG__LOGGER_APP_NAME='fxa-payments-next'
60+
61+
# FXA Webhook Config
62+
FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_ISSUER=https://accounts.firefox.com/
63+
FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_JWKS_URI=https://oauth.accounts.firefox.com/v1/jwks/
64+
FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_AUDIENCE=

apps/payments/api/.eslintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"extends": ["../../../.eslintrc.json"],
3-
"ignorePatterns": ["!**/*"],
3+
"ignorePatterns": ["!**/*", "dist"],
44
"overrides": [
55
{
66
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],

apps/payments/api/src/app/app.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { RootConfig } from '../config';
66
import {
77
CmsWebhooksController,
88
CmsWebhookService,
9+
FxaWebhooksController,
10+
FxaWebhookService,
911
StripeEventManager,
1012
StripeWebhooksController,
1113
StripeWebhookService,
@@ -56,7 +58,7 @@ import { NimbusClient, NimbusClientConfig } from '@fxa/shared/experiments';
5658
}),
5759
}),
5860
],
59-
controllers: [AppController, CmsWebhooksController, StripeWebhooksController],
61+
controllers: [AppController, CmsWebhooksController, FxaWebhooksController, StripeWebhooksController],
6062
providers: [
6163
Logger,
6264
AccountDatabaseNestFactory,
@@ -87,6 +89,7 @@ import { NimbusClient, NimbusClientConfig } from '@fxa/shared/experiments';
8789
StrapiClient,
8890
CmsContentValidationManager,
8991
CmsWebhookService,
92+
FxaWebhookService,
9093
NimbusManager,
9194
NimbusManagerConfig,
9295
NimbusClient,

apps/payments/api/src/config/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { PaypalClientConfig } from '@fxa/payments/paypal';
77
import { StripeConfig } from '@fxa/payments/stripe';
88
import { StrapiClientConfig } from '@fxa/shared/cms';
99
import { MySQLConfig } from '@fxa/shared/db/mysql/core';
10-
import { StripeEventConfig } from '@fxa/payments/webhooks';
10+
import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks';
1111
import { StatsDConfig } from '@fxa/shared/metrics/statsd';
1212
import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config';
1313

@@ -56,4 +56,9 @@ export class RootConfig {
5656
@ValidateNested()
5757
@IsDefined()
5858
public readonly stripeEventsConfig!: Partial<StripeEventConfig>;
59+
60+
@Type(() => FxaWebhookConfig)
61+
@ValidateNested()
62+
@IsDefined()
63+
public readonly fxaWebhookConfig!: Partial<FxaWebhookConfig>;
5964
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
#!/usr/bin/env ts-node
2+
/* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5+
6+
/**
7+
* Local integration test script for the FxA webhook endpoint.
8+
*
9+
* Generates a signed JWT Security Event Token and POSTs it to the
10+
* payments API webhook route. Uses the test RSA key pair from the
11+
* event-broker test suite.
12+
*
13+
* Usage:
14+
* npx tsx apps/payments/api/src/scripts/test-fxa-webhook.ts [options]
15+
*
16+
* Options:
17+
* --url <url> Target URL (default: http://localhost:3037/webhooks/fxa)
18+
* --event <type> Event type: delete, password, profile, subscription (default: delete)
19+
* --uid <uid> FxA user ID (default: random hex)
20+
* --issuer <iss> JWT issuer (default: https://accounts.firefox.com/)
21+
* --audience <aud> JWT audience / client ID (default: abc1234)
22+
*
23+
* The --issuer and --audience must match the payments API's
24+
* FxaWebhookConfig. The API fetches public keys dynamically from
25+
* the issuer's OIDC discovery endpoint (/.well-known/openid-configuration).
26+
*
27+
* When using the defaults, configure the API with:
28+
*
29+
* FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_ISSUER=https://accounts.firefox.com/
30+
* FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_AUDIENCE=abc1234
31+
*/
32+
33+
import * as crypto from 'crypto';
34+
35+
// ---- Test RSA key pair (from packages/fxa-event-broker/src/jwtset/jwtset.service.spec.ts) ----
36+
37+
const TEST_PRIVATE_JWK = {
38+
kty: 'RSA',
39+
d: 'nvfTzcMqVr8fa-b3IIFBk0J69sZQsyhKc3jYN5pPG7FdJyA-D5aPNv5zsF64JxNJetAS44cAsGAKN3Kh7LfjvLCtV56Ckg2tkBMn3GrbhE1BX6ObYvMuOBz5FJ9GmTOqSCxotAFRbR6AOBd5PCw--Rls4MylX393TFg6jJTGLkuYGuGHf8ILWyb17hbN0iyT9hME-cgLW1uc_u7oZ0vK9IxGPTblQhr82RBPQDTvZTM4s1wYiXzbJNrI_RGTAhdbwXuoXKiBN4XL0YRDKT0ENVqQLMiBwfdT3sW-M0L6kIv-L8qX3RIhbM3WA_a_LjTOM3WwRcNanSGiAeJLHwE5cQ',
40+
dp: '5U4HJsH2g_XSGw8mrv5LZ2kvnh7cibWfmB2x_h7ZFGLsXSphG9xSo3KDQqlLw4WiUHZ5kTyL9x-MiaUSxo-yEgtoyUy8C6gGTzQGxUyAq8nvQUq0J3J8kdCvdxM370Is7QmUF97LDogFlYlJ4eY1ASaV39SwwMd0Egf-JsPA9bM',
41+
dq: 'k65nnWFsWAnPunppcedFZ6x6It1BZhqUiQQUN0Mok2aPiKjSDbQJ8_CospKDoTOgU0i3Bbnfp--PuUNwKO2VZoZ4clD-5vEJ9lz7AxgHMp4lJ-gy0TLEnITBmrYRdJY4aSGZ8L4IiUTFDUvmx8KdzkLGYZqH3cCVDGZANjgXoDU',
42+
e: 'AQAB',
43+
kid: '2019-05-08-cd8b15e7a1d6d51e31de4f6aa79e9f9e',
44+
n: 'uJIoiOOZsS7XZ5HuyBTV59YMpm73sF1OwlNgLYJ5l3RHskVp6rR7UCDZCU7tAVSx4mHl1qoqbfUSlVeseY3yuSa7Tz_SW_WDO4ihYelXX5lGF7uxn5KmY1--6p9Gx7oiwgO5EdU6vkh2T4xD1BY4GUpqTLCdYDdAsykhVpNyQiO2tSJrxJLIMAYxUIw6lMHtyJDRe6m_OUAjBm_xyS3JbbTXOoeYbFXXvktqxkxNtmYEDCjdj8v2NGy9z9zMao2KwCmu-S6L6BJid3W0rKNR_yxAQPLSSrqUwyO1wPntR5qVJ3C0n-HeqOZK3M3ObHAFK0vShNZsrY4gPpwUl3BZsw',
45+
p: '72yifmIgqTJwpU06DyKwnhJbmAXRmKZH3QswH1OvXx_o5jjr9oLLN9xdQeIt3vo2OqlLLeFf8nk0q-kQVU0f1yOB5LAaIxm7SgYA6S1qMfDIc2H8TBnG0-dJ_yNcfef2LPKuDhljiwXN5Z-SadsRbuxh1JcGHqngTJiOSc43PO8',
46+
q: 'xVlYc0LRkOvQOpl0WSOPQ-0SVYe-v29RYamYlxTvq3mHkpexvERWVlHR94Igz5Taip1pxfhAHCREInJwMtncHnEcLQt-0T62I_BTmjpGzmRLTXx2Slmn-mlRSW_rwrdxeONPzxmJiSZE0dMOln9NBjr6Vp-5-J8TYE8TChoj930',
47+
qi: 'E5GCQCyG7AGplCUyZPBS4OEW9QTmzJoG42rLZc9HNJPfjE2hrNUJqmjIWy_n3QQZaNJwps_t-PNaLHBwM043yM_neBGPIgGQwOw6YJp_nbUvDaJnHAtDhAaR7jPWQeDqypg0ysrZvWsd2x1BNowFUFNjmHkpejp2ueS6C_hgv_g',
48+
};
49+
50+
/**
51+
* Configure the payments API with this public JWK:
52+
* {"kty":"RSA","e":"AQAB","n":"uJIoiOOZsS7XZ5HuyBTV59YMpm73sF1OwlNgLYJ5l3RHskVp6rR7UCDZCU7tAVSx4mHl1qoqbfUSlVeseY3yuSa7Tz_SW_WDO4ihYelXX5lGF7uxn5KmY1--6p9Gx7oiwgO5EdU6vkh2T4xD1BY4GUpqTLCdYDdAsykhVpNyQiO2tSJrxJLIMAYxUIw6lMHtyJDRe6m_OUAjBm_xyS3JbbTXOoeYbFXXvktqxkxNtmYEDCjdj8v2NGy9z9zMao2KwCmu-S6L6BJid3W0rKNR_yxAQPLSSrqUwyO1wPntR5qVJ3C0n-HeqOZK3M3ObHAFK0vShNZsrY4gPpwUl3BZsw","kid":"2019-05-08-cd8b15e7a1d6d51e31de4f6aa79e9f9e"}
53+
*/
54+
55+
// ---- Event payloads ----
56+
57+
const EVENT_URIS: Record<string, string> = {
58+
delete: 'https://schemas.accounts.firefox.com/event/delete-user',
59+
password: 'https://schemas.accounts.firefox.com/event/password-change',
60+
profile: 'https://schemas.accounts.firefox.com/event/profile-change',
61+
subscription:
62+
'https://schemas.accounts.firefox.com/event/subscription-state-change',
63+
};
64+
65+
function buildEventPayload(eventType: string): Record<string, any> {
66+
switch (eventType) {
67+
case 'delete':
68+
return {};
69+
case 'password':
70+
return { changeTime: Date.now() };
71+
case 'profile':
72+
return { email: '[email protected]', locale: 'en-US' };
73+
case 'subscription':
74+
return {
75+
capabilities: ['test-capability'],
76+
isActive: true,
77+
changeTime: Date.now(),
78+
};
79+
default:
80+
throw new Error(`Unknown event type: ${eventType}`);
81+
}
82+
}
83+
84+
// ---- JWT signing ----
85+
86+
function base64url(input: string | Buffer): string {
87+
return Buffer.from(input)
88+
.toString('base64')
89+
.replace(/\+/g, '-')
90+
.replace(/\//g, '_')
91+
.replace(/=/g, '');
92+
}
93+
94+
function signJwt(claims: Record<string, any>): string {
95+
const privateKey = crypto.createPrivateKey({
96+
key: TEST_PRIVATE_JWK as crypto.JsonWebKey,
97+
format: 'jwk',
98+
});
99+
100+
const header = base64url(
101+
JSON.stringify({ alg: 'RS256', kid: TEST_PRIVATE_JWK.kid })
102+
);
103+
const payload = base64url(JSON.stringify(claims));
104+
const signed = header + '.' + payload;
105+
106+
const signer = crypto.createSign('RSA-SHA256');
107+
signer.update(signed);
108+
const sig = base64url(signer.sign(privateKey));
109+
110+
return signed + '.' + sig;
111+
}
112+
113+
// ---- CLI ----
114+
115+
function parseArgs(argv: string[]) {
116+
const args = argv.slice(2);
117+
const opts = {
118+
url: 'http://localhost:3037/webhooks/fxa',
119+
event: 'delete',
120+
uid: crypto.randomBytes(16).toString('hex'),
121+
issuer: 'https://accounts.firefox.com/',
122+
audience: 'abc1234',
123+
};
124+
125+
for (let i = 0; i < args.length; i += 2) {
126+
switch (args[i]) {
127+
case '--url':
128+
opts.url = args[i + 1];
129+
break;
130+
case '--event':
131+
opts.event = args[i + 1];
132+
break;
133+
case '--uid':
134+
opts.uid = args[i + 1];
135+
break;
136+
case '--issuer':
137+
opts.issuer = args[i + 1];
138+
break;
139+
case '--audience':
140+
opts.audience = args[i + 1];
141+
break;
142+
default:
143+
console.error(`Unknown option: ${args[i]}`);
144+
console.error(
145+
'Usage: test-fxa-webhook.ts [--url URL] [--event delete|password|profile|subscription] [--uid UID] [--issuer ISS] [--audience AUD]'
146+
);
147+
process.exit(1);
148+
}
149+
}
150+
151+
return opts;
152+
}
153+
154+
async function main() {
155+
const opts = parseArgs(process.argv);
156+
157+
const eventUri = EVENT_URIS[opts.event];
158+
if (!eventUri) {
159+
console.error(
160+
`Invalid event type: ${opts.event}. Must be one of: ${Object.keys(EVENT_URIS).join(', ')}`
161+
);
162+
process.exit(1);
163+
}
164+
165+
const eventPayload = buildEventPayload(opts.event);
166+
const claims = {
167+
iss: opts.issuer,
168+
aud: opts.audience,
169+
sub: opts.uid,
170+
iat: Math.floor(Date.now() / 1000),
171+
jti: crypto.randomUUID(),
172+
events: { [eventUri]: eventPayload },
173+
};
174+
175+
const jwt = signJwt(claims);
176+
177+
console.log('--- FxA Webhook Test ---');
178+
console.log(`URL: ${opts.url}`);
179+
console.log(`Event: ${opts.event} (${eventUri})`);
180+
console.log(`UID: ${opts.uid}`);
181+
console.log(`Issuer: ${opts.issuer}`);
182+
console.log(`Audience: ${opts.audience}`);
183+
console.log('');
184+
185+
try {
186+
const response = await fetch(opts.url, {
187+
method: 'POST',
188+
headers: { Authorization: 'Bearer ' + jwt },
189+
});
190+
191+
const body = await response.text();
192+
console.log(`Status: ${response.status}`);
193+
console.log(`Response: ${body}`);
194+
195+
if (response.status === 200) {
196+
console.log('\nWebhook accepted.');
197+
} else {
198+
console.log('\nWebhook rejected.');
199+
process.exit(1);
200+
}
201+
} catch (err) {
202+
console.error('\nFailed to reach server:', (err as Error).message);
203+
process.exit(1);
204+
}
205+
}
206+
207+
main();

libs/payments/webhooks/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ export * from './lib/cms-webhooks.controller';
66
export * from './lib/cms-webhooks.error';
77
export * from './lib/cms-webhooks.service';
88
export * from './lib/cms-webhooks.types';
9+
export * from './lib/fxa-webhooks.config';
10+
export * from './lib/fxa-webhooks.controller';
11+
export * from './lib/fxa-webhooks.error';
12+
export * from './lib/fxa-webhooks.schemas';
13+
export * from './lib/fxa-webhooks.service';
14+
export * from './lib/fxa-webhooks.types';
915
export * from './lib/stripe-event.config';
1016
export * from './lib/stripe-event-store.repository';
1117
export * from './lib/stripe-webhooks.controller';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { faker } from '@faker-js/faker';
6+
import { Provider } from '@nestjs/common';
7+
import { IsString } from 'class-validator';
8+
9+
export class FxaWebhookConfig {
10+
@IsString()
11+
public readonly fxaWebhookIssuer!: string;
12+
13+
@IsString()
14+
public readonly fxaWebhookAudience!: string;
15+
16+
@IsString()
17+
public readonly fxaWebhookJwksUri!: string;
18+
}
19+
20+
export const MockFxaWebhookConfig = {
21+
fxaWebhookIssuer: faker.internet.url(),
22+
fxaWebhookAudience: faker.string.hexadecimal({ length: 16 }),
23+
fxaWebhookJwksUri: faker.internet.url(),
24+
} satisfies FxaWebhookConfig;
25+
26+
export const MockFxaWebhookConfigProvider = {
27+
provide: FxaWebhookConfig,
28+
useValue: MockFxaWebhookConfig,
29+
} satisfies Provider<FxaWebhookConfig>;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Test } from '@nestjs/testing';
6+
import { Logger } from '@nestjs/common';
7+
import { FxaWebhooksController } from './fxa-webhooks.controller';
8+
import { FxaWebhookService } from './fxa-webhooks.service';
9+
import { MockFxaWebhookConfigProvider } from './fxa-webhooks.config';
10+
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
11+
12+
describe('FxaWebhooksController', () => {
13+
let controller: FxaWebhooksController;
14+
let service: FxaWebhookService;
15+
16+
beforeEach(async () => {
17+
const module = await Test.createTestingModule({
18+
providers: [
19+
{ provide: Logger, useValue: { error: jest.fn(), log: jest.fn() } },
20+
FxaWebhooksController,
21+
FxaWebhookService,
22+
MockFxaWebhookConfigProvider,
23+
MockStatsDProvider,
24+
],
25+
}).compile();
26+
27+
controller = module.get(FxaWebhooksController);
28+
service = module.get(FxaWebhookService);
29+
});
30+
31+
describe('postFxaEvent', () => {
32+
beforeEach(() => {
33+
jest.spyOn(service, 'handleWebhookEvent').mockResolvedValue(undefined);
34+
});
35+
36+
it('calls service with authorization header', async () => {
37+
await controller.postFxaEvent('Bearer test-token');
38+
39+
expect(service.handleWebhookEvent).toHaveBeenCalledWith(
40+
'Bearer test-token'
41+
);
42+
});
43+
44+
it('returns success response', async () => {
45+
const result = await controller.postFxaEvent('Bearer test-token');
46+
47+
expect(result).toEqual({ success: true });
48+
});
49+
50+
it('propagates service errors', async () => {
51+
const serviceError = new Error('webhook auth failed');
52+
jest
53+
.spyOn(service, 'handleWebhookEvent')
54+
.mockRejectedValue(serviceError);
55+
56+
await expect(
57+
controller.postFxaEvent('Bearer bad-token')
58+
).rejects.toThrow(serviceError);
59+
});
60+
});
61+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Controller, Headers, HttpCode, Post } from '@nestjs/common';
6+
import { FxaWebhookService } from './fxa-webhooks.service';
7+
8+
@Controller('webhooks')
9+
export class FxaWebhooksController {
10+
constructor(private fxaWebhookService: FxaWebhookService) {}
11+
12+
@Post('fxa')
13+
@HttpCode(200)
14+
async postFxaEvent(@Headers('authorization') authorization: string) {
15+
await this.fxaWebhookService.handleWebhookEvent(authorization);
16+
return { success: true };
17+
}
18+
}

0 commit comments

Comments
 (0)