From 7c66a718a7e38e5169dc131fde5c32fc452c0a08 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 17 Apr 2026 16:48:05 -0500 Subject: [PATCH 1/3] fix: Type error responses and throw on unrecognized webhook events - Add typed `rawData` (WorkOSErrorData) and `code` property to GenericServerException so callers can inspect error codes without casting (#959, #1204, #1310) - Add AuthenticationException for auth-specific errors like email_verification_required and organization_selection_required - Throw on unrecognized event types in deserializeEvent instead of silently returning undefined (#864) --- .../exceptions/authentication.exception.ts | 54 +++++++++++++++++++ .../exceptions/generic-server.exception.ts | 10 +++- src/common/exceptions/index.ts | 1 + src/common/serializers/event.serializer.ts | 4 ++ src/workos.ts | 4 ++ 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/common/exceptions/authentication.exception.ts diff --git a/src/common/exceptions/authentication.exception.ts b/src/common/exceptions/authentication.exception.ts new file mode 100644 index 000000000..c96f07500 --- /dev/null +++ b/src/common/exceptions/authentication.exception.ts @@ -0,0 +1,54 @@ +import { + GenericServerException, + WorkOSErrorData, +} from './generic-server.exception'; + +export type AuthenticationErrorCode = + | 'email_verification_required' + | 'organization_selection_required' + | 'mfa_enrollment' + | 'mfa_challenge' + | 'mfa_verification' + | 'sso_required'; + +export interface AuthenticationErrorData extends WorkOSErrorData { + code: AuthenticationErrorCode; + pending_authentication_token: string; + user?: Record; + organizations?: Array<{ id: string; name: string }>; +} + +const AUTHENTICATION_ERROR_CODES: ReadonlySet = new Set([ + 'email_verification_required', + 'organization_selection_required', + 'mfa_enrollment', + 'mfa_challenge', + 'mfa_verification', + 'sso_required', +]); + +export function isAuthenticationErrorData( + data: WorkOSErrorData, +): data is AuthenticationErrorData { + return ( + typeof data.code === 'string' && + AUTHENTICATION_ERROR_CODES.has(data.code) && + typeof data.pending_authentication_token === 'string' + ); +} + +export class AuthenticationException extends GenericServerException { + readonly name = 'AuthenticationException'; + readonly code: AuthenticationErrorCode; + readonly pendingAuthenticationToken: string; + + constructor( + status: number, + readonly rawData: AuthenticationErrorData, + requestID: string, + ) { + super(status, rawData.message, rawData, requestID); + this.code = rawData.code; + this.pendingAuthenticationToken = rawData.pending_authentication_token; + } +} diff --git a/src/common/exceptions/generic-server.exception.ts b/src/common/exceptions/generic-server.exception.ts index a9241eed3..40849b14f 100644 --- a/src/common/exceptions/generic-server.exception.ts +++ b/src/common/exceptions/generic-server.exception.ts @@ -1,18 +1,26 @@ import { RequestException } from '../interfaces/request-exception.interface'; +export interface WorkOSErrorData { + code?: string; + message?: string; + [key: string]: unknown; +} + export class GenericServerException extends Error implements RequestException { readonly name: string = 'GenericServerException'; readonly message: string = 'The request could not be completed.'; + readonly code: string | undefined; constructor( readonly status: number, message: string | undefined, - readonly rawData: unknown, + readonly rawData: WorkOSErrorData, readonly requestID: string, ) { super(); if (message) { this.message = message; } + this.code = rawData.code; } } diff --git a/src/common/exceptions/index.ts b/src/common/exceptions/index.ts index a3bdc7575..4838bed50 100644 --- a/src/common/exceptions/index.ts +++ b/src/common/exceptions/index.ts @@ -1,4 +1,5 @@ export * from './api-key-required.exception'; +export * from './authentication.exception'; export * from './generic-server.exception'; export * from './bad-request.exception'; export * from './no-api-key-provided.exception'; diff --git a/src/common/serializers/event.serializer.ts b/src/common/serializers/event.serializer.ts index 6a74d8e60..dac2410e1 100644 --- a/src/common/serializers/event.serializer.ts +++ b/src/common/serializers/event.serializer.ts @@ -302,5 +302,9 @@ export const deserializeEvent = (event: EventResponse): Event => { event: event.event, data: deserializeVaultByokKeyVerificationCompletedEvent(event.data), }; + default: + throw new Error( + `Unrecognized event type: ${(event as { event: string }).event}`, + ); } }; diff --git a/src/workos.ts b/src/workos.ts index 1014c4a5c..17f066009 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -1,6 +1,8 @@ import { ApiKeyRequiredException, + AuthenticationException, GenericServerException, + isAuthenticationErrorData, NotFoundException, UnauthorizedException, UnprocessableEntityException, @@ -494,6 +496,8 @@ export class WorkOS { message, requestID, }); + } else if (isAuthenticationErrorData(data)) { + throw new AuthenticationException(status, data, requestID); } else { throw new GenericServerException( status, From 98fb7cbf24da1c3a68b401e398fb35cfdb57295d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 17 Apr 2026 16:49:41 -0500 Subject: [PATCH 2/3] fix(types): make WorkOSResponseError compatible with WorkOSErrorData --- src/common/interfaces/workos-response-error.interface.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/common/interfaces/workos-response-error.interface.ts b/src/common/interfaces/workos-response-error.interface.ts index c8d5b7a33..14b74ac81 100644 --- a/src/common/interfaces/workos-response-error.interface.ts +++ b/src/common/interfaces/workos-response-error.interface.ts @@ -6,4 +6,5 @@ export interface WorkOSResponseError { error?: string; errors?: UnprocessableEntityError[]; message: string; + [key: string]: unknown; } From d88636fd46fc5cc410f43403d3fb25e93cbb810b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:42:37 +0000 Subject: [PATCH 3/3] fix: address review comments on error handling and event deserialization - Remove redundant this.code assignment in AuthenticationException (already set by GenericServerException) - Make pending_authentication_token optional so auth errors without token still get caught as AuthenticationException - Relax isAuthenticationErrorData guard to match on code alone - Return passthrough object for unrecognized event types instead of throwing (forward-compatible with new server-side events) - Export UnknownEvent interface for consumer reference Co-Authored-By: garen.torikian --- src/common/exceptions/authentication.exception.ts | 10 +++------- src/common/interfaces/event.interface.ts | 5 +++++ src/common/serializers/event.serializer.ts | 8 +++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/common/exceptions/authentication.exception.ts b/src/common/exceptions/authentication.exception.ts index c96f07500..3962f0245 100644 --- a/src/common/exceptions/authentication.exception.ts +++ b/src/common/exceptions/authentication.exception.ts @@ -13,7 +13,7 @@ export type AuthenticationErrorCode = export interface AuthenticationErrorData extends WorkOSErrorData { code: AuthenticationErrorCode; - pending_authentication_token: string; + pending_authentication_token?: string; user?: Record; organizations?: Array<{ id: string; name: string }>; } @@ -31,16 +31,13 @@ export function isAuthenticationErrorData( data: WorkOSErrorData, ): data is AuthenticationErrorData { return ( - typeof data.code === 'string' && - AUTHENTICATION_ERROR_CODES.has(data.code) && - typeof data.pending_authentication_token === 'string' + typeof data.code === 'string' && AUTHENTICATION_ERROR_CODES.has(data.code) ); } export class AuthenticationException extends GenericServerException { readonly name = 'AuthenticationException'; - readonly code: AuthenticationErrorCode; - readonly pendingAuthenticationToken: string; + readonly pendingAuthenticationToken: string | undefined; constructor( status: number, @@ -48,7 +45,6 @@ export class AuthenticationException extends GenericServerException { requestID: string, ) { super(status, rawData.message, rawData, requestID); - this.code = rawData.code; this.pendingAuthenticationToken = rawData.pending_authentication_token; } } diff --git a/src/common/interfaces/event.interface.ts b/src/common/interfaces/event.interface.ts index d1108b0c9..9def867be 100644 --- a/src/common/interfaces/event.interface.ts +++ b/src/common/interfaces/event.interface.ts @@ -856,6 +856,11 @@ export interface VaultByokKeyVerificationCompletedEventResponse extends EventRes data: VaultByokKeyVerificationCompletedEventResponseData; } +export interface UnknownEvent extends EventBase { + event: string; + data: Record; +} + export type Event = | AuthenticationEmailVerificationSucceededEvent | AuthenticationMfaSucceededEvent diff --git a/src/common/serializers/event.serializer.ts b/src/common/serializers/event.serializer.ts index dac2410e1..662dfb8d2 100644 --- a/src/common/serializers/event.serializer.ts +++ b/src/common/serializers/event.serializer.ts @@ -303,8 +303,10 @@ export const deserializeEvent = (event: EventResponse): Event => { data: deserializeVaultByokKeyVerificationCompletedEvent(event.data), }; default: - throw new Error( - `Unrecognized event type: ${(event as { event: string }).event}`, - ); + return { + ...eventBase, + event: (event as { event: string }).event, + data: (event as { data: Record }).data, + } as Event; } };