diff --git a/examples/snippets/event_notification_handler_endpoint.ts b/examples/snippets/event_notification_handler_endpoint.ts index 7a9bf002f0..0ef90fb2f3 100644 --- a/examples/snippets/event_notification_handler_endpoint.ts +++ b/examples/snippets/event_notification_handler_endpoint.ts @@ -31,6 +31,23 @@ handler.on('v1.billing.meter.error_report_triggered', async (event) => { console.log(`Billing Meter ${meter.display_name} had a problem`); }); +// Handles events delivered through a channel that has already authenticated them, such as +// AWS EventBridge or Azure Event Grid. Those payloads carry no Stripe-Signature header, so +// this handler skips verification. Callbacks are registered separately from the one above. +const unverifiedHandler = client.notificationHandlerWithoutVerification( + async (unhandledEvent, client, details) => { + console.log(`Received unhandled event type: ${unhandledEvent.type}`); + } +); + +unverifiedHandler.on( + 'v1.billing.meter.error_report_triggered', + async (event) => { + const meter = await event.fetchRelatedObject(); + console.log(`Billing Meter ${meter.display_name} had a problem`); + } +); + app.post( '/webhook', express.raw({type: 'application/json'}), @@ -40,4 +57,13 @@ app.post( } ); +app.post( + '/webhook-from-cloud-provider', + express.raw({type: 'application/json'}), + async (req, res) => { + // handle() takes only the body here; there's no signature to check + await unverifiedHandler.handle(req.body); + } +); + app.listen(4242, () => console.log('Running on port 4242')); diff --git a/src/StripeEventNotificationHandler.ts b/src/StripeEventNotificationHandler.ts index 08edcf5714..f40693851e 100644 --- a/src/StripeEventNotificationHandler.ts +++ b/src/StripeEventNotificationHandler.ts @@ -109,14 +109,23 @@ const KNOWN_EVENT_TYPES = new Set([ // event-types: The end of the section generated from our OpenAPI spec ]); -export class StripeEventNotificationHandler { +/** + * Shared registration and dispatch machinery for the two handlers below. + * + * Deliberately does not declare `handle`, and is not exported. TypeScript won't + * let a subclass add a required parameter to an inherited method, so a verifying + * handler cannot extend a non-verifying one (or vice versa) without either + * loosening a signature or suppressing the error. Making them siblings lets each + * declare its own exact `handle` while sharing everything else. + */ +class BaseEventNotificationHandler { private registeredHandlers: Record = {}; - private hasHandledEvent = false; + protected hasHandledEvent = false; + // the body is empty but the parameter properties are not, so the lint is ignorable // eslint-disable-next-line no-useless-constructor constructor( - private client: Stripe, - private webhookSecret: string, + protected client: Stripe, private fallbackCallback: FallbackCallback ) {} @@ -149,19 +158,11 @@ export class StripeEventNotificationHandler { return keys; } - public async handle( - // these types are duplicated in the manual types, so they're just here for internal use - rawBody: string | Uint8Array, - signature: string | Uint8Array + protected async dispatchEvent( + event: Stripe.V2.Core.EventNotification ): Promise { // we're not worried about thread safety here because we expect callbacks will be registered synchronously on app startup this.hasHandledEvent = true; - const event = this.client.parseEventNotification( - rawBody, - signature, - this.webhookSecret - ); - // Create a new client with the event's context instead of modifying the shared client // This ensures thread-safety when processing webhooks in parallel // We create a shallow copy and override _api with a new object containing the event context @@ -187,3 +188,52 @@ export class StripeEventNotificationHandler { } } } + +export class StripeEventNotificationHandler extends BaseEventNotificationHandler { + constructor( + client: Stripe, + private webhookSecret: string, + fallbackCallback: FallbackCallback + ) { + super(client, fallbackCallback); + if (!webhookSecret) { + throw new Error('webhookSecret must be a non-empty string'); + } + } + + static withoutVerification( + client: Stripe, + fallbackCallback: FallbackCallback + ): StripeEventNotificationHandlerWithoutVerification { + return new StripeEventNotificationHandlerWithoutVerification( + client, + fallbackCallback + ); + } + + public async handle( + // these types are duplicated in the manual types, so they're just here for internal use + rawBody: string | Uint8Array, + signature: string | Uint8Array + ): Promise { + return await this.dispatchEvent( + this.client.parseEventNotification(rawBody, signature, this.webhookSecret) + ); + } +} + +/** + * A variant of StripeEventNotificationHandler that parses events without + * verifying webhook signatures. Intended for pre-authenticated channels + * like AWS EventBridge or Azure Event Grid. + * + * Prefer StripeEventNotificationHandler.withoutVerification() or + * client.notificationHandlerWithoutVerification() to construct one. + */ +export class StripeEventNotificationHandlerWithoutVerification extends BaseEventNotificationHandler { + public async handle(rawBody: string | Uint8Array): Promise { + return await this.dispatchEvent( + this.client.parseEventNotificationWithoutVerification(rawBody) + ); + } +} diff --git a/src/stripe.cjs.node.ts b/src/stripe.cjs.node.ts index f15166b0e2..e0ecdad22e 100644 --- a/src/stripe.cjs.node.ts +++ b/src/stripe.cjs.node.ts @@ -20810,6 +20810,9 @@ declare namespace StripeConstructor { export type Signature = Stripe_.Signature; export type StripeContextType = Stripe_.StripeContextType; export type StripeRawError = Stripe_.StripeRawError; + export type UnhandledNotificationDetails = Stripe_.UnhandledNotificationDetails; + export type StripeEventNotificationHandler = Stripe_.StripeEventNotificationHandler; + export type StripeEventNotificationHandlerWithoutVerification = Stripe_.StripeEventNotificationHandlerWithoutVerification; export type Decimal = Stripe_.Decimal; export namespace errors { export type StripeError = InstanceType; diff --git a/src/stripe.core.ts b/src/stripe.core.ts index aa0f510a67..35d0b21128 100644 --- a/src/stripe.core.ts +++ b/src/stripe.core.ts @@ -26,6 +26,7 @@ import { } from './utils.js'; import { StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification, UnhandledNotificationDetails, } from './StripeEventNotificationHandler.js'; import { @@ -1862,7 +1863,7 @@ export class Stripe { * parse in a single call, use `parseEventNotification(...)` instead. */ parseEventNotificationWithoutVerification( - payload: string + payload: string | Uint8Array ): V2.Core.EventNotification { return this._buildEventNotification( maybeExtractFromCloudProviderEnvelope(payload) @@ -1883,6 +1884,19 @@ export class Stripe { fallbackCallback ); } + + notificationHandlerWithoutVerification( + fallbackCallback: ( + event: UnknownEventNotification, + client: Stripe, + details: UnhandledNotificationDetails + ) => Promise + ): StripeEventNotificationHandlerWithoutVerification { + return StripeEventNotificationHandler.withoutVerification( + this, + fallbackCallback + ); + } } // For backward compatibility, export createStripe as a factory function @@ -2875,6 +2889,12 @@ export declare namespace Stripe { export {StripeContext as StripeContextType}; export {StripeRawError}; + // Type-only: these classes are not attached as statics on the Stripe constructor, + // so they can be named in annotations but not used as values. Construct handlers + // through stripe.notificationHandler() / stripe.notificationHandlerWithoutVerification(). + export type UnhandledNotificationDetails = import('./StripeEventNotificationHandler.js').UnhandledNotificationDetails; + export type StripeEventNotificationHandler = import('./StripeEventNotificationHandler.js').StripeEventNotificationHandler; + export type StripeEventNotificationHandlerWithoutVerification = import('./StripeEventNotificationHandler.js').StripeEventNotificationHandlerWithoutVerification; // ErrorTypeNamespaces: The beginning of the section generated from our OpenAPI spec export namespace ErrorType { export type StripeError = InstanceType; diff --git a/src/stripe.esm.node.ts b/src/stripe.esm.node.ts index 9771e2084b..24ef0ba31d 100644 --- a/src/stripe.esm.node.ts +++ b/src/stripe.esm.node.ts @@ -26,6 +26,7 @@ import { } from './utils.js'; import { StripeEventNotificationHandler, + StripeEventNotificationHandlerWithoutVerification, UnhandledNotificationDetails, } from './StripeEventNotificationHandler.js'; import { @@ -1862,7 +1863,7 @@ export class Stripe { * or [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) envelope. */ parseEventNotificationWithoutVerification( - payload: string + payload: string | Uint8Array ): V2.Core.EventNotification { const inner = maybeExtractFromCloudProviderEnvelope(payload); if (inner.object === 'event') { @@ -1892,6 +1893,19 @@ export class Stripe { fallbackCallback ); } + + notificationHandlerWithoutVerification( + fallbackCallback: ( + event: UnknownEventNotification, + client: Stripe, + details: UnhandledNotificationDetails + ) => Promise + ): StripeEventNotificationHandlerWithoutVerification { + return StripeEventNotificationHandler.withoutVerification( + this, + fallbackCallback + ); + } } // For backward compatibility, export createStripe as a factory function @@ -2885,6 +2899,11 @@ export declare namespace Stripe { export {StripeContext as StripeContextType}; export {StripeRawError}; export {UnhandledNotificationDetails}; + // Type-only: these classes are not attached as statics on the Stripe constructor, + // so they can be named in annotations but not used as values. Construct handlers + // through stripe.notificationHandler() / stripe.notificationHandlerWithoutVerification(). + export type StripeEventNotificationHandler = import('./StripeEventNotificationHandler.js').StripeEventNotificationHandler; + export type StripeEventNotificationHandlerWithoutVerification = import('./StripeEventNotificationHandler.js').StripeEventNotificationHandlerWithoutVerification; export import Events = V2.Core.Events; // ErrorTypeNamespaces: The beginning of the section generated from our OpenAPI spec diff --git a/src/utils.ts b/src/utils.ts index 772b42933b..e6e78933c8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -479,7 +479,7 @@ export function parsePayload( } export function maybeExtractFromCloudProviderEnvelope( - payload: string + payload: string | Uint8Array ): Record { const parsed = parsePayload(payload); diff --git a/test/StripeEventNotificationHandler.spec.ts b/test/StripeEventNotificationHandler.spec.ts index 0bb58e3bf2..9b4807bf68 100644 --- a/test/StripeEventNotificationHandler.spec.ts +++ b/test/StripeEventNotificationHandler.spec.ts @@ -469,3 +469,195 @@ describe('StripeEventNotificationHandler', () => { }); }); }); + +describe('StripeEventNotificationHandlerWithoutVerification', () => { + let stripe: any; + let withoutVerifHandler: any; + + // Event payloads (duplicated here since they are scoped to the sibling describe block) + const v1BillingMeterPayload = JSON.stringify({ + id: 'evt_123', + object: 'v2.core.event', + type: 'v1.billing.meter.error_report_triggered', + livemode: false, + created: '2022-02-15T00:27:45.330Z', + context: 'event_context_456', + related_object: { + id: 'mtr_123', + type: 'billing.meter', + url: '/v1/billing/meters/mtr_123', + }, + }); + + const unknownEventPayload = JSON.stringify({ + id: 'evt_unknown', + object: 'v2.core.event', + type: 'llama.created', + livemode: false, + created: '2022-02-15T00:27:45.330Z', + context: 'event_context_unknown', + }); + + beforeEach(() => { + stripe = getSpyableStripe({}); + withoutVerifHandler = stripe.notificationHandlerWithoutVerification( + async () => {} + ); + }); + + it('should route event to registered handler without a signature', async () => { + let callbackCalled = false; + let receivedEvent: any = null; + let receivedClient: any = null; + + withoutVerifHandler.on( + 'v1.billing.meter.error_report_triggered', + async (event: any, client: any) => { + callbackCalled = true; + receivedEvent = event; + receivedClient = client; + } + ); + + // No signature argument — just the raw body + await withoutVerifHandler.handle(v1BillingMeterPayload); + + expect(callbackCalled).to.be.true; + expect(receivedEvent.type).to.equal( + 'v1.billing.meter.error_report_triggered' + ); + expect(receivedEvent.id).to.equal('evt_123'); + expect(receivedClient).to.exist; + }); + + it('should accept a Uint8Array body without a signature', async () => { + let callbackCalled = false; + + withoutVerifHandler.on( + 'v1.billing.meter.error_report_triggered', + async () => { + callbackCalled = true; + } + ); + + const bodyAsBytes = new TextEncoder().encode(v1BillingMeterPayload); + await withoutVerifHandler.handle(bodyAsBytes); + + expect(callbackCalled).to.be.true; + }); + + it('should route known unregistered event to fallback with isKnownEventType: true', async () => { + let unhandledCalled = false; + let unhandledEvent: any = null; + let unhandledInfo: any = null; + + const handler = stripe.notificationHandlerWithoutVerification( + async (event: any, _client: any, info: any) => { + unhandledCalled = true; + unhandledEvent = event; + unhandledInfo = info; + } + ); + + await handler.handle(v1BillingMeterPayload); + + expect(unhandledCalled).to.be.true; + expect(unhandledEvent.type).to.equal( + 'v1.billing.meter.error_report_triggered' + ); + expect(unhandledInfo.isKnownEventType).to.be.true; + }); + + it('should route unknown event type to fallback with isKnownEventType: false', async () => { + let unhandledCalled = false; + let unhandledEvent: any = null; + let unhandledInfo: any = null; + + const handler = stripe.notificationHandlerWithoutVerification( + async (event: any, _client: any, info: any) => { + unhandledCalled = true; + unhandledEvent = event; + unhandledInfo = info; + } + ); + + await handler.handle(unknownEventPayload); + + expect(unhandledCalled).to.be.true; + expect(unhandledEvent.type).to.equal('llama.created'); + expect(unhandledInfo.isKnownEventType).to.be.false; + }); + + it('should propagate event stripe context to the callback client', async () => { + let receivedContext: any = null; + let normalizedContext: any = null; + + withoutVerifHandler.on( + 'v1.billing.meter.error_report_triggered', + async (event: any, client: any) => { + receivedContext = client._api.stripeContext; + normalizedContext = client._requestSender._normalizeStripeContext( + undefined, + client.getApiField('stripeContext') + ); + } + ); + + await withoutVerifHandler.handle(v1BillingMeterPayload); + + // The event has context 'event_context_456' + expect(receivedContext?.toString()).to.equal('event_context_456'); + expect(normalizedContext).to.equal('event_context_456'); + }); + + it('should return StripeEventNotificationHandlerWithoutVerification from static factory', () => { + // Access the parent class via an existing handler instance to avoid a + // separate import that would break when this file is copied to stripe-node. + const tempHandler = stripe.notificationHandler( + 'whsec_test_secret', + async () => {} + ); + const StripeEventNotificationHandlerClass = tempHandler.constructor; + + const handler = StripeEventNotificationHandlerClass.withoutVerification( + stripe, + async () => {} + ); + + expect(handler.constructor.name).to.equal( + 'StripeEventNotificationHandlerWithoutVerification' + ); + expect(typeof handler.on).to.equal('function'); + expect(typeof handler.handle).to.equal('function'); + }); + + it('should throw when constructing the original handler with an empty webhookSecret', () => { + expect(() => { + stripe.notificationHandler('', async () => {}); + }).to.throw(/webhookSecret must be a non-empty string/); + }); + + it('should treat every webhookSecret as a real secret, with no magic bypass value', async () => { + // there is no sentinel to forge: the verifying and non-verifying handlers are + // separate classes, so any string passed here is just an ordinary (wrong) secret + const handler = stripe.notificationHandler( + '__without_verification__', + async () => {} + ); + + let errorThrown = false; + + try { + await handler.handle( + v1BillingMeterPayload, + generateHeader(v1BillingMeterPayload) + ); + } catch (err) { + errorThrown = true; + // @ts-expect-error + expect(err.type).to.include('StripeSignatureVerification'); + } + + expect(errorThrown).to.be.true; + }); +}); diff --git a/testProjects/types-cjs-node16/typescriptTest.ts b/testProjects/types-cjs-node16/typescriptTest.ts index 1d75f9d5aa..aa10f7b980 100644 --- a/testProjects/types-cjs-node16/typescriptTest.ts +++ b/testProjects/types-cjs-node16/typescriptTest.ts @@ -90,3 +90,35 @@ const _signatureType: Stripe.Signature = null as any; const _nodeHttpClient: Stripe.HttpClient = Stripe.createNodeHttpClient(); const _nodeCryptoProvider: Stripe.CryptoProvider = Stripe.createNodeCryptoProvider(); + +// notificationHandlerWithoutVerification must be reachable under moduleResolution node16 +async (): Promise => { + const unverifiedHandler = stripe.notificationHandlerWithoutVerification( + async (unhandledEvent, client, details) => { + const e: Stripe.Events.UnknownEventNotification = unhandledEvent; + const s: Stripe = client; + const d: Stripe.UnhandledNotificationDetails = details; + } + ); + + unverifiedHandler.on( + 'v1.billing.meter.error_report_triggered', + async (event) => { + const meter: Stripe.Billing.Meter = await event.fetchRelatedObject(); + } + ); + + // handle() takes only the body; there is no signature to pass + const res: void = await unverifiedHandler.handle(''); + + // @ts-expect-error - the verifying two-argument handle is not available here + await unverifiedHandler.handle('', 'sig_header'); + + // Node exposes only the client factory; the handler classes are type-only. + // @ts-expect-error - StripeEventNotificationHandler is not a runtime value + Stripe.StripeEventNotificationHandler.withoutVerification(stripe, async () => {}); +}; + +// both handler types must be nameable off the namespace +let _verifyingHandler: Stripe.StripeEventNotificationHandler; +let _unverifiedHandler: Stripe.StripeEventNotificationHandlerWithoutVerification; diff --git a/testProjects/types-cjs/typescriptTest.ts b/testProjects/types-cjs/typescriptTest.ts index 2feb9ab2dd..702c04026d 100644 --- a/testProjects/types-cjs/typescriptTest.ts +++ b/testProjects/types-cjs/typescriptTest.ts @@ -428,6 +428,38 @@ event = stripe.constructEventWithoutVerification('payload'); const _notificationWV: Stripe.V2.Core.EventNotification = stripe.parseEventNotificationWithoutVerification('payload'); +// notificationHandlerWithoutVerification must be reachable through the CJS entry +async (): Promise => { + const unverifiedHandler = stripe.notificationHandlerWithoutVerification( + async (unhandledEvent, client, details) => { + const e: Stripe.Events.UnknownEventNotification = unhandledEvent; + const s: Stripe = client; + const d: Stripe.UnhandledNotificationDetails = details; + } + ); + + unverifiedHandler.on( + 'v1.billing.meter.error_report_triggered', + async (event) => { + const meter: Stripe.Billing.Meter = await event.fetchRelatedObject(); + } + ); + + // handle() takes only the body; there is no signature to pass + const res: void = await unverifiedHandler.handle(''); + + // @ts-expect-error - the verifying two-argument handle is not available here + await unverifiedHandler.handle('', 'sig_header'); + + // Node exposes only the client factory; the handler classes are type-only. + // @ts-expect-error - StripeEventNotificationHandler is not a runtime value + Stripe.StripeEventNotificationHandler.withoutVerification(stripe, async () => {}); +}; + +// both handler types must be nameable off the namespace +let _verifyingHandler: Stripe.StripeEventNotificationHandler; +let _unverifiedHandler: Stripe.StripeEventNotificationHandlerWithoutVerification; + const taxExempt: Stripe.CustomerUpdateParams.TaxExempt = 'exempt'; let subscription: Stripe.Subscription; let invoice: Stripe.Invoice; diff --git a/testProjects/types/typescriptTest.ts b/testProjects/types/typescriptTest.ts index 94c6589bab..69368af3d3 100644 --- a/testProjects/types/typescriptTest.ts +++ b/testProjects/types/typescriptTest.ts @@ -422,6 +422,41 @@ async (): Promise => { const res: void = await handler.handle('', ''); }; +// event handler that skips signature verification +async (): Promise => { + const unverifiedHandler = stripe.notificationHandlerWithoutVerification( + async (unhandledEvent, client, details) => { + const e: Stripe.Events.UnknownEventNotification = unhandledEvent; + const s: Stripe = client; + const d: Stripe.UnhandledNotificationDetails = details; + } + ); + + unverifiedHandler.on( + 'v1.billing.meter.error_report_triggered', + async (event) => { + const meter: Stripe.Billing.Meter = await event.fetchRelatedObject(); + const e: Stripe.Events.V1BillingMeterErrorReportTriggeredEventNotification = event; + } + ); + + // handle() takes only the body; there is no signature to pass + const res: void = await unverifiedHandler.handle(''); + + // @ts-expect-error - the verifying two-argument handle is not available here + await unverifiedHandler.handle('', 'sig_header'); + + // Node exposes only the client factory. The handler classes are type-only (they are + // not attached as statics on the constructor), so the static factory that the other + // SDKs offer is intentionally unreachable here. + // @ts-expect-error - StripeEventNotificationHandler is not a runtime value + Stripe.StripeEventNotificationHandler.withoutVerification(stripe, async () => {}); +}; + +// both handler types must be nameable off the namespace +let _verifyingHandler: Stripe.StripeEventNotificationHandler; +let _unverifiedHandler: Stripe.StripeEventNotificationHandlerWithoutVerification; + // Test that the Decimal type is exported { function takesDecimal(decimal: Stripe.Decimal) {