|
| 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(); |
0 commit comments