From cd1f008cac144b399fb315beffcabaeb93e4c236 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 26 Feb 2026 17:39:58 -0700 Subject: [PATCH 1/2] fix(journey-client): classify HTTP 4xx error bodies as LoginFailure RTK Query's fetchBaseQuery places HTTP error response bodies in the error slot, not data. start() and next() only destructured { data }, silently discarding AM failure bodies (e.g. 401 from social login) and returning a generic no_response_data error instead. Now extracts error.data, checks if it resembles an AM Step via isStepLike(), and routes it through createJourneyObject() so 4xx responses are properly classified as JourneyLoginFailure. Also converts resume() from throwing raw Error objects to returning typed GenericError values, maintaining the JourneyResult contract. --- .../src/lib/client.store.test.ts | 114 ++++++++++++++++-- .../journey-client/src/lib/client.store.ts | 43 ++++++- 2 files changed, 142 insertions(+), 15 deletions(-) diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts index a1c52bdba..3844748f6 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -10,6 +10,8 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import type { GenericError, Step, WellknownResponse } from '@forgerock/sdk-types'; +import { StepType } from '@forgerock/sdk-types'; + import { journey } from './client.store.js'; import { createJourneyStep } from './step.utils.js'; import { JourneyClientConfig } from './config.types.js'; @@ -73,9 +75,11 @@ function getUrlFromInput(input: RequestInfo | URL): string { } /** - * Helper to setup mock fetch for wellknown + journey responses + * Helper to setup mock fetch for wellknown + journey responses. + * When `errorStatus` is provided, the journey response will be returned + * with that HTTP status code (simulating AM error responses like 401). */ -function setupMockFetch(journeyResponse: Step | null = null) { +function setupMockFetch(journeyResponse: Step | null = null, options?: { errorStatus?: number }) { mockFetch.mockImplementation((input: RequestInfo | URL) => { const url = getUrlFromInput(input); @@ -86,7 +90,13 @@ function setupMockFetch(journeyResponse: Step | null = null) { // Journey authenticate endpoint if (journeyResponse && url.includes('/authenticate')) { - return Promise.resolve(new Response(JSON.stringify(journeyResponse))); + const status = options?.errorStatus ?? 200; + return Promise.resolve( + new Response(JSON.stringify(journeyResponse), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); @@ -205,6 +215,64 @@ describe('journey-client', () => { } }); + test('start_ServerReturns401_ReturnsLoginFailure', async () => { + // Arrange — AM returns 401 with a login failure body + const failureResponse: Step = { + code: 401, + reason: 'Unauthorized', + message: 'Authentication Failed', + }; + setupMockFetch(failureResponse, { errorStatus: 401 }); + + // Act + const client = await journey({ config: mockConfig }); + const result = await client.start(); + + // Assert — should be classified as LoginFailure, not GenericError + expect(result).toBeDefined(); + expect(isGenericError(result)).toBe(false); + expect(result).toHaveProperty('type', StepType.LoginFailure); + if (result && 'getCode' in result) { + expect(result.getCode()).toBe(401); + expect(result.getReason()).toBe('Unauthorized'); + expect(result.getMessage()).toBe('Authentication Failed'); + } + }); + + test('next_ServerReturns401_ReturnsLoginFailure', async () => { + // Arrange — AM returns 401 after submitting credentials + const initialStep = createJourneyStep({ + authId: 'test-auth-id', + callbacks: [ + { + type: callbackType.NameCallback, + input: [{ name: 'IDToken1', value: 'test-user' }], + output: [], + }, + ], + }); + const failureResponse: Step = { + code: 401, + reason: 'Unauthorized', + message: 'Authentication Failed', + }; + setupMockFetch(failureResponse, { errorStatus: 401 }); + + // Act + const client = await journey({ config: mockConfig }); + const result = await client.next(initialStep, {}); + + // Assert — should be classified as LoginFailure, not GenericError + expect(result).toBeDefined(); + expect(isGenericError(result)).toBe(false); + expect(result).toHaveProperty('type', StepType.LoginFailure); + if (result && 'getCode' in result) { + expect(result.getCode()).toBe(401); + expect(result.getReason()).toBe('Unauthorized'); + expect(result.getMessage()).toBe('Authentication Failed'); + } + }); + test('redirect_WellknownConfig_StoresStepAndCallsLocationAssign', async () => { // Arrange const mockStepPayload: Step = { @@ -299,7 +367,7 @@ describe('journey-client', () => { } }); - test('resume_PreviousStepRequiredButNotFound_ThrowsError', async () => { + test('resume_PreviousStepRequiredButNotFound_ReturnsGenericError', async () => { // Arrange mockStorageInstance.get.mockResolvedValue(undefined); setupMockFetch(); @@ -307,15 +375,43 @@ describe('journey-client', () => { // Act const client = await journey({ config: mockConfig }); const resumeUrl = 'https://app.com/callback?code=123&state=abc'; - - // Assert - await expect(client.resume(resumeUrl)).rejects.toThrow( - 'Error: previous step information not found in storage for resume operation.', - ); + const result = await client.resume(resumeUrl); + + // Assert — returns GenericError instead of throwing + expect(result).toBeDefined(); + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.error).toBe('missing_previous_step'); + expect(result.message).toContain('not found in storage'); + } expect(mockStorageInstance.get).toHaveBeenCalledTimes(1); expect(mockStorageInstance.remove).toHaveBeenCalledTimes(1); }); + test('resume_StorageReturnsError_ReturnsGenericError', async () => { + // Arrange — storage returns a GenericError (e.g. sessionStorage unavailable) + const storageError: GenericError = { + error: 'storage_unavailable', + message: 'sessionStorage is not available', + type: 'unknown_error', + }; + mockStorageInstance.get.mockResolvedValue(storageError); + setupMockFetch(); + + // Act + const client = await journey({ config: mockConfig }); + const resumeUrl = 'https://app.com/callback?code=123&state=abc'; + const result = await client.resume(resumeUrl); + + // Assert — returns GenericError instead of throwing + expect(result).toBeDefined(); + expect(isGenericError(result)).toBe(true); + if (isGenericError(result)) { + expect(result.error).toBe('storage_error'); + expect(result.message).toContain('sessionStorage is not available'); + } + }); + test('resume_NoPreviousStepRequired_CallsStartWithUrlParams', async () => { // Arrange mockStorageInstance.get.mockResolvedValue(undefined); diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 9e7b0b40b..de3e02d93 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -32,6 +32,21 @@ import { NextOptions, StartParam, ResumeOptions } from './interfaces.js'; import type { JourneyLoginFailure } from './login-failure.utils.js'; import type { JourneyLoginSuccess } from './login-success.utils.js'; +/** + * Checks whether an unknown value resembles an AM Step response. + * RTK Query's fetchBaseQuery places HTTP error response bodies in `error.data`. + * When AM returns a 4xx (e.g. 401 for failed social login), the body contains + * `code`, `reason`, and `message` — which is a valid Step that should be + * classified via `createJourneyObject()`. + */ +function isStepLike(data: unknown): data is Step { + return ( + typeof data === 'object' && + data !== null && + ('authId' in data || 'code' in data || 'callbacks' in data) + ); +} + /** Result type for journey client methods. */ type JourneyResult = | JourneyStep @@ -135,8 +150,13 @@ export async function journey({ const self: JourneyClient = { start: async (options?: StartParam) => { - const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); + const { data, error: queryError } = await store.dispatch( + journeyApi.endpoints.start.initiate(options), + ); if (!data) { + if (queryError && 'data' in queryError && isStepLike(queryError.data)) { + return createJourneyObject(queryError.data as Step); + } const error: GenericError = { error: 'no_response_data', message: 'No data received from server when starting journey', @@ -151,8 +171,13 @@ export async function journey({ * Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey. */ next: async (step: JourneyStep, options?: NextOptions) => { - const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options })); + const { data, error: queryError } = await store.dispatch( + journeyApi.endpoints.next.initiate({ step, options }), + ); if (!data) { + if (queryError && 'data' in queryError && isStepLike(queryError.data)) { + return createJourneyObject(queryError.data as Step); + } const error: GenericError = { error: 'no_response_data', message: 'No data received from server when submitting step', @@ -210,7 +235,11 @@ export async function journey({ if (stored) { if (isGenericError(stored)) { - throw new Error(`Error retrieving previous step: ${stored.message || stored.error}`); + return { + error: 'storage_error', + message: `Error retrieving previous step: ${stored.message || stored.error}`, + type: 'unknown_error', + } satisfies GenericError; } else if (isStoredStep(stored)) { previousStep = createJourneyObject(stored.step) as JourneyStep; } @@ -218,9 +247,11 @@ export async function journey({ await stepStorage.remove(); if (!previousStep) { - throw new Error( - 'Error: previous step information not found in storage for resume operation.', - ); + return { + error: 'missing_previous_step', + message: 'Previous step information not found in storage for resume operation.', + type: 'state_error', + } satisfies GenericError; } } From 860aed3bd1d0915af828ecb929d8bd81dfa67787 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 26 Feb 2026 17:41:30 -0700 Subject: [PATCH 2/2] chore: add changeset for journey-client error handling fix --- .changeset/fix-journey-social-login-errors.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/fix-journey-social-login-errors.md diff --git a/.changeset/fix-journey-social-login-errors.md b/.changeset/fix-journey-social-login-errors.md new file mode 100644 index 000000000..203e67313 --- /dev/null +++ b/.changeset/fix-journey-social-login-errors.md @@ -0,0 +1,8 @@ +--- +'@forgerock/journey-client': patch +--- + +Fix social login error handling in journey-client + +- Fix `start()` and `next()` to extract AM error bodies from RTK Query's `error.data` slot, properly classifying HTTP 4xx responses (e.g. 401 from failed social login) as `JourneyLoginFailure` instead of generic `no_response_data` errors +- Fix `resume()` to return typed `GenericError` values instead of throwing raw `Error` objects, maintaining the `JourneyResult` contract