diff --git a/.changeset/whole-mangos-find.md b/.changeset/whole-mangos-find.md new file mode 100644 index 0000000000..6b36eacfb2 --- /dev/null +++ b/.changeset/whole-mangos-find.md @@ -0,0 +1,5 @@ +--- +'@forgerock/journey-client': patch +--- + +Return `JourneyLoginFailure` by hitting the previously-unreached `LoginFailure` branch when `start()`/`next()` receives a failure payload with a login failure `code` diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index 35aa41262a..037b20a850 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -9,8 +9,6 @@ import './style.css'; import { journey } from '@forgerock/journey-client'; import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'; -import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types'; - import { renderCallbacks } from './callback-map.js'; import { renderDeleteDevicesSection } from './components/delete-device.js'; import { renderQRCodeStep } from './components/qr-code.js'; @@ -19,6 +17,8 @@ import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js'; import { webauthnComponent } from './components/webauthn-step.js'; import { serverConfigs } from './server-configs.js'; +import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types'; + const qs = window.location.search; const searchParams = new URLSearchParams(qs); @@ -210,7 +210,6 @@ if (searchParams.get('middleware') === 'true') { renderComplete(); } else if (step?.type === 'LoginFailure') { console.error('Journey failed'); - renderForm(); renderError(); } else { console.error('Unknown node status', step); diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts index f57a1c0f7c..60e981221e 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -1,17 +1,23 @@ +// @vitest-environment node /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { callbackType } from '@forgerock/sdk-types'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { GenericError, Step, WellknownResponse } from '@forgerock/sdk-types'; - import { journey } from './client.store.js'; import { createJourneyStep } from './step.utils.js'; + +import { + callbackType, + type GenericError, + type Step, + type WellknownResponse, +} from '@forgerock/sdk-types'; + import { JourneyClientConfig } from './config.types.js'; /** @@ -75,7 +81,7 @@ function getUrlFromInput(input: RequestInfo | URL): string { /** * Helper to setup mock fetch for wellknown + journey responses */ -function setupMockFetch(journeyResponse: Step | null = null) { +function setupMockFetch(journeyResponse: Step | null = null, authenticateStatus = 200) { mockFetch.mockImplementation((input: RequestInfo | URL) => { const url = getUrlFromInput(input); @@ -85,8 +91,13 @@ function setupMockFetch(journeyResponse: Step | null = null) { } // Journey authenticate endpoint - if (journeyResponse && url.includes('/authenticate')) { - return Promise.resolve(new Response(JSON.stringify(journeyResponse))); + if (url.includes('/authenticate')) { + if (journeyResponse === null) { + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + } + return Promise.resolve( + new Response(JSON.stringify(journeyResponse), { status: authenticateStatus }), + ); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); @@ -152,6 +163,30 @@ describe('journey-client', () => { } }); + test('start_401WithStepPayload_ReturnsLoginFailure', async () => { + const failurePayload: Step = { + code: 401, + message: 'Access Denied', + reason: 'Unauthorized', + detail: { failureUrl: 'https://example.com/failure' }, + }; + setupMockFetch(failurePayload, 401); + + const client = await journey({ config: mockConfig }); + const result = await client.start(); + + expect(result).toBeDefined(); + expect(isGenericError(result)).toBe(false); + expect(result).toHaveProperty('type', 'LoginFailure'); + + if (!isGenericError(result) && result.type === 'LoginFailure') { + expect(result.payload).toEqual(failurePayload); + expect(result.getCode()).toBe(401); + expect(result.getMessage()).toBe('Access Denied'); + expect(result.getReason()).toBe('Unauthorized'); + } + }); + test('next_WellknownConfig_SendsStepAndReturnsNext', async () => { const initialStep = createJourneyStep({ authId: 'test-auth-id', @@ -192,6 +227,34 @@ describe('journey-client', () => { } }); + test('next_401WithStepPayload_ReturnsLoginFailure', async () => { + const initialStep = createJourneyStep({ + authId: 'test-auth-id', + callbacks: [], + }); + const failurePayload: Step = { + code: 401, + message: 'Access Denied', + reason: 'Unauthorized', + detail: { failureUrl: 'https://example.com/failure' }, + }; + setupMockFetch(failurePayload, 401); + + const client = await journey({ config: mockConfig }); + const result = await client.next(initialStep, {}); + + expect(result).toBeDefined(); + expect(isGenericError(result)).toBe(false); + expect(result).toHaveProperty('type', 'LoginFailure'); + + if (!isGenericError(result) && result.type === 'LoginFailure') { + expect(result.payload).toEqual(failurePayload); + expect(result.getCode()).toBe(401); + expect(result.getMessage()).toBe('Access Denied'); + expect(result.getReason()).toBe('Unauthorized'); + } + }); + test('redirect_WellknownConfig_StoresStepAndCallsLocationAssign', async () => { const mockStepPayload: Step = { callbacks: [ @@ -204,6 +267,15 @@ describe('journey-client', () => { }; const step = createJourneyStep(mockStepPayload); const assignMock = vi.fn(); + // Node test environment doesn't provide `window`, so create a minimal shim + // with a real `location` getter so we can keep using vi.spyOn(..., 'get'). + (globalThis as unknown as { window?: unknown }).window = {}; + Object.defineProperty(window, 'location', { + configurable: true, + get: () => ({ + assign: vi.fn(), + }), + }); const locationSpy = vi.spyOn(window, 'location', 'get').mockReturnValue({ ...window.location, assign: assignMock, @@ -321,7 +393,7 @@ describe('journey-client', () => { expect(isGenericError(result)).toBe(true); if (isGenericError(result)) { - expect(result.error).toBe('no_response_data'); + expect(result.error).toBe('request_failed'); expect(result.type).toBe('unknown_error'); } }); diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 7cdb2a97e1..e49921a25e 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -1,22 +1,17 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ import { logger as loggerFn, LogLevel, CustomLogger } from '@forgerock/sdk-logger'; -import { callbackType } from '@forgerock/sdk-types'; import { isGenericError, isValidWellknownUrl, createWellknownError, } from '@forgerock/sdk-utilities'; -import type { GenericError } from '@forgerock/sdk-types'; -import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; -import type { Step } from '@forgerock/sdk-types'; - import { createJourneyStore } from './client.store.utils.js'; import { configSlice } from './config.slice.js'; import { journeyApi } from './journey.api.js'; @@ -24,6 +19,9 @@ import { createStorage } from '@forgerock/storage'; import { createJourneyObject } from './journey.utils.js'; import { wellknownApi } from './wellknown.api.js'; +import { callbackType, type GenericError, type Step } from '@forgerock/sdk-types'; +import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; + import type { JourneyStep } from './step.utils.js'; import type { JourneyClientConfig } from './config.types.js'; import type { RedirectCallback } from './callbacks/redirect-callback.js'; @@ -155,32 +153,18 @@ export async function journey({ const self: JourneyClient = { start: async (options?: StartParam) => { - const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); - if (!data) { - const error: GenericError = { - error: 'no_response_data', - message: 'No data received from server when starting journey', - type: 'unknown_error', - }; - return error; - } - return createJourneyObject(data); + const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); + return createJourneyObject(data, error); }, /** * 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 })); - if (!data) { - const error: GenericError = { - error: 'no_response_data', - message: 'No data received from server when submitting step', - type: 'unknown_error', - }; - return error; - } - return createJourneyObject(data); + const { data, error } = await store.dispatch( + journeyApi.endpoints.next.initiate({ step, options }), + ); + return createJourneyObject(data, error); }, // TODO: Remove the actual redirect from this method and just return the URL to the caller diff --git a/packages/journey-client/src/lib/journey.api.ts b/packages/journey-client/src/lib/journey.api.ts index 66c4da9a29..a7cbbb5014 100644 --- a/packages/journey-client/src/lib/journey.api.ts +++ b/packages/journey-client/src/lib/journey.api.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -9,6 +9,8 @@ import { initQuery, RequestMiddleware } from '@forgerock/sdk-request-middleware' import { REQUESTED_WITH, getEndpointPath, stringify, resolve } from '@forgerock/sdk-utilities'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; +import { NextOptions, StartParam } from './interfaces.js'; + import type { Step } from '@forgerock/sdk-types'; import type { logger as loggerFn } from '@forgerock/sdk-logger'; import type { @@ -21,9 +23,7 @@ import type { } from '@reduxjs/toolkit/query'; import { JourneyStep } from './step.types.js'; - import type { InternalJourneyClientConfig } from './config.types.js'; -import { NextOptions, StartParam } from './interfaces.js'; /** * Minimal state type for accessing journey config from RTK Query endpoints. diff --git a/packages/journey-client/src/lib/journey.utils.test.ts b/packages/journey-client/src/lib/journey.utils.test.ts new file mode 100644 index 0000000000..ba0568f8bc --- /dev/null +++ b/packages/journey-client/src/lib/journey.utils.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, expect, it } from 'vitest'; + +import { StepType, type Step } from '@forgerock/sdk-types'; + +import { createJourneyObject } from './journey.utils.js'; +import type { JourneyLoginFailure } from './login-failure.utils.js'; + +describe('createJourneyObject', () => { + it('returns Step when provided a step with authId', () => { + const stepPayload: Step = { + authId: 'test-auth-id', + callbacks: [], + }; + + const result = createJourneyObject(stepPayload, undefined); + + expect(result).not.toHaveProperty('error'); + expect(result).toHaveProperty('type', StepType.Step); + expect(result).toHaveProperty('payload'); + expect((result as { payload: Step }).payload).toEqual(stepPayload); + }); + + it('returns LoginSuccess when provided a step with successUrl', () => { + const successPayload: Step = { + successUrl: 'https://example.com/success', + realm: 'root', + tokenId: 'token-123', + }; + + const result = createJourneyObject(successPayload, undefined); + + expect(result).not.toHaveProperty('error'); + expect(result).toHaveProperty('type', StepType.LoginSuccess); + expect(result).toHaveProperty('payload', successPayload); + }); + + it('returns no_response_data GenericError when no step and no error', () => { + const result = createJourneyObject(undefined, undefined); + + expect(result).toMatchObject({ + error: 'no_response_data', + message: 'No data received from server', + type: 'unknown_error', + }); + }); + + it('returns request_failed GenericError when no step but error exists', () => { + const result = createJourneyObject(undefined, { status: 500 }); + + expect(result).toMatchObject({ + error: 'request_failed', + message: 'Request failed: 500', + type: 'unknown_error', + }); + }); + + it('returns request_failed when error.data is present but not Step-like', () => { + const result = createJourneyObject(undefined, { status: 401, data: { foo: 'bar' } }); + + expect(result).toMatchObject({ + error: 'request_failed', + message: 'Request failed: 401', + type: 'unknown_error', + }); + }); + + it('returns LoginFailure when error.data contains a failure Step payload', () => { + const failurePayload: Step = { + code: 401, + message: 'Access Denied', + reason: 'Unauthorized', + detail: { failureUrl: 'https://example.com/failure' }, + }; + + const result = createJourneyObject(undefined, { status: 401, data: failurePayload }); + + expect(result).not.toHaveProperty('error'); + expect(result).toHaveProperty('type', StepType.LoginFailure); + expect(result).toHaveProperty('payload', failurePayload); + + const failure = result as JourneyLoginFailure; + expect(failure.getCode()).toBe(401); + expect(failure.getMessage()).toBe('Access Denied'); + expect(failure.getReason()).toBe('Unauthorized'); + }); +}); diff --git a/packages/journey-client/src/lib/journey.utils.ts b/packages/journey-client/src/lib/journey.utils.ts index 4a42cae87e..43d21baf6e 100644 --- a/packages/journey-client/src/lib/journey.utils.ts +++ b/packages/journey-client/src/lib/journey.utils.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -17,21 +17,45 @@ import type { JourneyStep } from './step.utils.js'; import type { JourneyLoginFailure } from './login-failure.utils.js'; import type { JourneyLoginSuccess } from './login-success.utils.js'; +const STEP_LIKE_KEYS = [ + 'authId', + 'callbacks', + 'code', + 'description', + 'detail', + 'header', + 'ok', + 'realm', + 'reason', + 'stage', + 'status', + 'successUrl', + 'tokenId', +] as const; + /** * Creates a journey object from a raw Step response. * Determines the step type based on the presence of authId or successUrl properties * and returns the appropriate journey object type. * - * @param step - The raw Step response from the authentication API + * @param step - The raw Step response from the authentication API (or undefined if the request failed) + * @param error - Optional error result (e.g., RTK Query error) that may contain a Step-shaped payload in `error.data` * @returns A JourneyStep, JourneyLoginSuccess, JourneyLoginFailure, or GenericError if the step type cannot be determined */ function createJourneyObject( - step: Step, + step: Step | undefined, + error?: unknown, ): JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const resolved = resolveStep(step, error); + if ('error' in resolved) return resolved; + + const resolvedStep = resolved; + let type; - if (step.authId) { + if (resolvedStep.authId) { type = StepType.Step; - } else if (step.successUrl) { + } else if (resolvedStep.successUrl) { type = StepType.LoginSuccess; } else { type = StepType.LoginFailure; @@ -39,11 +63,11 @@ function createJourneyObject( switch (type) { case StepType.LoginSuccess: - return createJourneyLoginSuccess(step); + return createJourneyLoginSuccess(resolvedStep); case StepType.LoginFailure: - return createJourneyLoginFailure(step); + return createJourneyLoginFailure(resolvedStep); case StepType.Step: - return createJourneyStep(step); + return createJourneyStep(resolvedStep); default: return { error: 'unknown_step_type', @@ -53,4 +77,33 @@ function createJourneyObject( } } +function resolveStep(step: Step | undefined, error?: unknown): Step | GenericError { + if (step) return step; + + const errorObj = + error && typeof error === 'object' + ? (error as { data?: unknown; status?: unknown }) + : undefined; + + const data = errorObj?.data; + if (data && typeof data === 'object' && STEP_LIKE_KEYS.some((key) => key in data)) { + return data as Step; + } + + if (errorObj) { + const status = errorObj.status; + return { + error: 'request_failed', + message: status !== undefined ? `Request failed: ${status}` : 'Request failed', + type: 'unknown_error', + }; + } + + return { + error: 'no_response_data', + message: 'No data received from server', + type: 'unknown_error', + }; +} + export { createJourneyObject };