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 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; } }