Skip to content

Commit c4e2b6e

Browse files
committed
feat(payments-next): Add FxA Webhook support
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
1 parent d144d21 commit c4e2b6e

File tree

12 files changed

+879
-2
lines changed

12 files changed

+879
-2
lines changed

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_AUDIENCE=
64+
FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_PUBLIC_JWK=

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

libs/payments/webhooks/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ 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.service';
13+
export * from './lib/fxa-webhooks.types';
914
export * from './lib/stripe-event.config';
1015
export * from './lib/stripe-event-store.repository';
1116
export * from './lib/stripe-webhooks.controller';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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 { Transform } from 'class-transformer';
8+
import { IsObject, IsString } from 'class-validator';
9+
10+
export class FxaWebhookConfig {
11+
@IsString()
12+
public readonly fxaWebhookIssuer!: string;
13+
14+
@IsString()
15+
public readonly fxaWebhookAudience!: string;
16+
17+
@Transform(
18+
({ value }) => {
19+
return value instanceof Object ? value : JSON.parse(value);
20+
},
21+
{ toClassOnly: true }
22+
)
23+
@IsObject()
24+
public readonly fxaWebhookPublicJwk!: Record<string, any>;
25+
}
26+
27+
export const MockFxaWebhookConfig = {
28+
fxaWebhookIssuer: faker.internet.url(),
29+
fxaWebhookAudience: faker.string.hexadecimal({ length: 16 }),
30+
fxaWebhookPublicJwk: { kty: 'RSA', e: 'AQAB', n: 'mock', kid: 'mock-kid' },
31+
} satisfies FxaWebhookConfig;
32+
33+
export const MockFxaWebhookConfigProvider = {
34+
provide: FxaWebhookConfig,
35+
useValue: MockFxaWebhookConfig,
36+
} satisfies Provider<FxaWebhookConfig>;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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
34+
.spyOn(service, 'handleWebhookEvent')
35+
.mockResolvedValue(undefined);
36+
});
37+
38+
it('calls service with authorization header', async () => {
39+
await controller.postFxaEvent('Bearer test-token');
40+
41+
expect(service.handleWebhookEvent).toHaveBeenCalledWith(
42+
'Bearer test-token'
43+
);
44+
});
45+
46+
it('returns success response', async () => {
47+
const result = await controller.postFxaEvent('Bearer test-token');
48+
49+
expect(result).toEqual({ success: true });
50+
});
51+
});
52+
});
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)