From a9ac960354759598339ede3b09f8c9ac61339915 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Mon, 13 Jul 2026 13:27:28 -0600 Subject: [PATCH 1/3] feat(journey-client): add-create-journey-step-to-api Add createJourneyStep to the journey-client api --- .changeset/tender-signs-crash.md | 5 +++++ packages/journey-client/api-report/journey-client.api.md | 3 +++ packages/journey-client/src/index.ts | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/tender-signs-crash.md diff --git a/.changeset/tender-signs-crash.md b/.changeset/tender-signs-crash.md new file mode 100644 index 0000000000..bc31769664 --- /dev/null +++ b/.changeset/tender-signs-crash.md @@ -0,0 +1,5 @@ +--- +'@forgerock/journey-client': patch +--- + +export create-journey-step so it's easy to deserialize a step after it's been serialized diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md index 35a4a51dbe..80c9426c1b 100644 --- a/packages/journey-client/api-report/journey-client.api.md +++ b/packages/journey-client/api-report/journey-client.api.md @@ -96,6 +96,9 @@ export class ConfirmationCallback extends BaseCallback { // @public (undocumented) export function createCallback(callback: Callback): BaseCallback; +// @public (undocumented) +export function createJourneyStep(payload: Step, callbackFactory?: CallbackFactory): JourneyStep; + export { createWellknownError } export { CustomLogger } diff --git a/packages/journey-client/src/index.ts b/packages/journey-client/src/index.ts index ad6630ab7d..d26a3fa0b9 100644 --- a/packages/journey-client/src/index.ts +++ b/packages/journey-client/src/index.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. @@ -8,5 +8,8 @@ export * from './lib/client.store.js'; export * from './types.js'; +// required to help deserialize a step after it's been serialized +export { createJourneyStep } from './lib/step.utils.js'; + // Re-export types from internal packages that consumers need export { callbackType } from '@forgerock/sdk-types'; From ea5bff7a043158fc4d2c41224a7ab12ab2194dd5 Mon Sep 17 00:00:00 2001 From: Justin Lowery Date: Mon, 13 Jul 2026 16:49:56 -0500 Subject: [PATCH 2/3] fix(journey-client): add back baseUrl & export createJourneyStep for edge cases --- .../services/delete-webauthn-device.ts | 5 +- .../api-report/journey-client._utils.api.md | 44 +++++++++++++++ .../api-report/journey-client.api.md | 6 +-- .../api-report/journey-client.types.api.md | 3 ++ packages/journey-client/package.json | 1 + packages/journey-client/src/_utils.ts | 9 ++++ packages/journey-client/src/index.ts | 3 -- .../journey-client/src/lib/client.store.ts | 53 ++++++++++++------- .../src/lib/config.slice.test.ts | 5 +- .../journey-client/src/lib/config.slice.ts | 24 ++++++--- .../src/lib/config.types.test-d.ts | 32 +++++++---- .../journey-client/src/lib/config.types.ts | 6 ++- packages/sdk-types/src/lib/config.types.ts | 7 ++- .../src/lib/config/config.test.ts | 9 ++-- 14 files changed, 158 insertions(+), 49 deletions(-) create mode 100644 packages/journey-client/api-report/journey-client._utils.api.md create mode 100644 packages/journey-client/src/_utils.ts diff --git a/e2e/journey-app/services/delete-webauthn-device.ts b/e2e/journey-app/services/delete-webauthn-device.ts index e8c5e0827b..a7d05d3a61 100644 --- a/e2e/journey-app/services/delete-webauthn-device.ts +++ b/e2e/journey-app/services/delete-webauthn-device.ts @@ -92,7 +92,10 @@ export async function deleteWebAuthnDevice(config: JourneyClientConfig): Promise return 'No credential id found. Register a WebAuthn device first.'; } - const wellknown = config.serverConfig.wellknown; + const wellknown = 'wellknown' in config.serverConfig ? config.serverConfig.wellknown : undefined; + if (!wellknown) { + throw new Error('serverConfig.wellknown is required to delete a WebAuthn device.'); + } const baseUrl = getBaseUrlFromWellknown(wellknown); const realmUrlPath = getRealmUrlPathFromWellknown(wellknown); const userId = await getUserIdFromSession(baseUrl, realmUrlPath); diff --git a/packages/journey-client/api-report/journey-client._utils.api.md b/packages/journey-client/api-report/journey-client._utils.api.md new file mode 100644 index 0000000000..d10450dfee --- /dev/null +++ b/packages/journey-client/api-report/journey-client._utils.api.md @@ -0,0 +1,44 @@ +## API Report File for "@forgerock/journey-client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import type { AuthResponse } from '@forgerock/sdk-types'; +import type { Callback } from '@forgerock/sdk-types'; +import type { CallbackType } from '@forgerock/sdk-types'; +import { Step } from '@forgerock/sdk-types'; +import type { StepType } from '@forgerock/sdk-types'; + +// @public +export class BaseCallback { + constructor(payload: Callback); + getInputValue(selector?: number | string): unknown; + getOutputByName(name: string, defaultValue: T): T; + getOutputValue(selector?: number | string): unknown; + getType(): CallbackType; + // (undocumented) + payload: Callback; + setInputValue(value: unknown, selector?: number | string | RegExp): void; +} + +// @public (undocumented) +export type CallbackFactory = (callback: Callback) => BaseCallback; + +// @public (undocumented) +export function createJourneyStep(payload: Step, callbackFactory?: CallbackFactory): JourneyStep; + +// @public (undocumented) +export type JourneyStep = AuthResponse & { + type: StepType.Step; + payload: Step; + callbacks: BaseCallback[]; + getCallbackOfType: (type: CallbackType) => T; + getCallbacksOfType: (type: CallbackType) => T[]; + setCallbackValue: (type: CallbackType, value: unknown) => void; + getDescription: () => string | undefined; + getHeader: () => string | undefined; + getStage: () => string | undefined; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md index 80c9426c1b..141da910b5 100644 --- a/packages/journey-client/api-report/journey-client.api.md +++ b/packages/journey-client/api-report/journey-client.api.md @@ -17,6 +17,7 @@ import { GenericError } from '@forgerock/sdk-types'; import { isValidWellknownUrl } from '@forgerock/sdk-utilities'; import { JourneyClientConfig } from '@forgerock/sdk-types'; import { JourneyServerConfig } from '@forgerock/sdk-types'; +import { LegacyServerConfig } from '@forgerock/sdk-types'; import { LogLevel } from '@forgerock/sdk-logger'; import { NameValue } from '@forgerock/sdk-types'; import { PolicyKey } from '@forgerock/sdk-types'; @@ -96,9 +97,6 @@ export class ConfirmationCallback extends BaseCallback { // @public (undocumented) export function createCallback(callback: Callback): BaseCallback; -// @public (undocumented) -export function createJourneyStep(payload: Step, callbackFactory?: CallbackFactory): JourneyStep; - export { createWellknownError } export { CustomLogger } @@ -259,6 +257,8 @@ export class KbaCreateCallback extends BaseCallback { setQuestion(question: string): void; } +export { LegacyServerConfig } + export { LogLevel } // @public (undocumented) diff --git a/packages/journey-client/api-report/journey-client.types.api.md b/packages/journey-client/api-report/journey-client.types.api.md index d9219a9710..7dcb8224e5 100644 --- a/packages/journey-client/api-report/journey-client.types.api.md +++ b/packages/journey-client/api-report/journey-client.types.api.md @@ -16,6 +16,7 @@ import { GenericError } from '@forgerock/sdk-types'; import { isValidWellknownUrl } from '@forgerock/sdk-utilities'; import { JourneyClientConfig } from '@forgerock/sdk-types'; import { JourneyServerConfig } from '@forgerock/sdk-types'; +import { LegacyServerConfig } from '@forgerock/sdk-types'; import { LogLevel } from '@forgerock/sdk-logger'; import { NameValue } from '@forgerock/sdk-types'; import { PolicyKey } from '@forgerock/sdk-types'; @@ -243,6 +244,8 @@ export class KbaCreateCallback extends BaseCallback { setQuestion(question: string): void; } +export { LegacyServerConfig } + export { LogLevel } // @public (undocumented) diff --git a/packages/journey-client/package.json b/packages/journey-client/package.json index 886639e90c..c22e1d4892 100644 --- a/packages/journey-client/package.json +++ b/packages/journey-client/package.json @@ -13,6 +13,7 @@ "type": "module", "exports": { ".": "./dist/src/index.js", + "./_utils": "./dist/src/_utils.js", "./device": "./dist/src/lib/device/device-profile.js", "./package.json": "./package.json", "./policy": "./dist/src/lib/policy/policy.js", diff --git a/packages/journey-client/src/_utils.ts b/packages/journey-client/src/_utils.ts new file mode 100644 index 0000000000..595323c038 --- /dev/null +++ b/packages/journey-client/src/_utils.ts @@ -0,0 +1,9 @@ +/** + * @private + * @description Creates a decorated `Step` object with convenience functions. + * Used mostly for mocking and some advanced, edge cases. + */ +export { createJourneyStep } from './lib/step.utils.js'; +export type { JourneyStep } from './lib/step.types.js'; +export type { CallbackFactory } from './lib/callbacks/factory.js'; +export { BaseCallback } from './lib/callbacks/base-callback.js'; diff --git a/packages/journey-client/src/index.ts b/packages/journey-client/src/index.ts index d26a3fa0b9..ce264eaeca 100644 --- a/packages/journey-client/src/index.ts +++ b/packages/journey-client/src/index.ts @@ -8,8 +8,5 @@ export * from './lib/client.store.js'; export * from './types.js'; -// required to help deserialize a step after it's been serialized -export { createJourneyStep } from './lib/step.utils.js'; - // Re-export types from internal packages that consumers need export { callbackType } from '@forgerock/sdk-types'; diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 32c2689a64..bf0161408f 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -115,31 +115,44 @@ export async function journey({ const store = createJourneyStore({ requestMiddleware, logger: log }); - const { wellknown } = config.serverConfig; + if ('baseUrl' in config.serverConfig) { + const { baseUrl } = config.serverConfig; - if (!isValidWellknownUrl(wellknown)) { - const message = `Invalid wellknown URL: ${wellknown}. URL must use HTTPS (or HTTP for localhost) and end with /.well-known/openid-configuration.`; - log.error(message); - throw new Error(message); - } + // Check for `baseUrl` to support non-AIC, Platform Login edge cases + // Warn of the use of `baseUrl`. This is used only in advanced use cases, and is not recommended for "normal consumer use". + log.warn( + 'Wellknown configuration is disabled due to the presence of `serverConfig.baseUrl`. It is recommended that you remove `baseUrl` and just use the `wellknown` property.', + ); - const { data: wellknownResponse, error: fetchError } = await store.dispatch( - wellknownApi.endpoints.configuration.initiate(wellknown), - ); + store.dispatch(configSlice.actions.set({ type: 'baseUrl', baseUrl })); + } else { + const { wellknown } = config.serverConfig; + // This is the normal use case for using the Wellknown endpoint for proper configuration. + if (!isValidWellknownUrl(wellknown)) { + const message = `Invalid wellknown URL: ${wellknown}. URL must use HTTPS (or HTTP for localhost) and end with /.well-known/openid-configuration.`; + log.error(message); + throw new Error(message); + } + + const { data: wellknownResponse, error: fetchError } = await store.dispatch( + wellknownApi.endpoints.configuration.initiate(wellknown), + ); - if (fetchError || !wellknownResponse) { - const error = createWellknownError(fetchError); - const message = `${error.error}: ${error.message}`; - log.error(message); - throw new Error(message); + if (fetchError || !wellknownResponse) { + const error = createWellknownError(fetchError); + const message = `${error.error}: ${error.message}`; + log.error(message); + throw new Error(message); + } + + store.dispatch( + configSlice.actions.set({ + type: 'wellknown', + wellknownResponse: wellknownResponse, + }), + ); } - store.dispatch( - configSlice.actions.set({ - wellknownResponse: wellknownResponse, - }), - ); - const configError = store.getState().config.error; if (configError) { diff --git a/packages/journey-client/src/lib/config.slice.test.ts b/packages/journey-client/src/lib/config.slice.test.ts index 0affaf7cce..ae7dcb70fb 100644 --- a/packages/journey-client/src/lib/config.slice.test.ts +++ b/packages/journey-client/src/lib/config.slice.test.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. @@ -28,6 +28,7 @@ describe('journey-client config.slice', () => { describe('configSlice_ValidAmWellknown_SetsResolvedServerConfig', () => { it('should derive baseUrl and paths from a standard AM well-known response', () => { const payload: ResolvedConfig = { + type: 'wellknown', wellknownResponse: createMockWellknown(), }; @@ -47,6 +48,7 @@ describe('journey-client config.slice', () => { describe('configSlice_NonAmIssuer_SetsError', () => { it('should set a GenericError when the issuer is not a ForgeRock AM issuer', () => { const payload: ResolvedConfig = { + type: 'wellknown', wellknownResponse: createMockWellknown({ issuer: 'https://auth.pingone.com/env-id/as', authorization_endpoint: 'https://auth.pingone.com/env-id/as/authorize', @@ -64,6 +66,7 @@ describe('journey-client config.slice', () => { describe('configSlice_MissingAuthEndpoint_SetsError', () => { it('should set a GenericError when authorization_endpoint is empty', () => { const payload: ResolvedConfig = { + type: 'wellknown', wellknownResponse: createMockWellknown({ authorization_endpoint: '', }), diff --git a/packages/journey-client/src/lib/config.slice.ts b/packages/journey-client/src/lib/config.slice.ts index 02cac72102..a3a2a2886e 100644 --- a/packages/journey-client/src/lib/config.slice.ts +++ b/packages/journey-client/src/lib/config.slice.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. @@ -16,9 +16,15 @@ import { convertWellknown } from './wellknown.utils.js'; * to internal server config via {@link convertWellknown}. */ export interface ResolvedConfig { + type: 'wellknown'; wellknownResponse: WellknownResponse; } +interface BaseConfig { + type: 'baseUrl'; + baseUrl: string; +} + const initialState: InternalJourneyClientConfig = { serverConfig: { baseUrl: '', paths: { authenticate: '', sessions: '' } }, }; @@ -35,13 +41,17 @@ export const configSlice = createSlice({ name: 'config', initialState, reducers: { - set(state, action: PayloadAction) { - const wellknown = convertWellknown(action.payload.wellknownResponse); - if ('error' in wellknown) { - state.error = wellknown; + set(state, action: PayloadAction) { + if (action.payload.type === 'baseUrl') { + state.serverConfig.baseUrl = action.payload.baseUrl; } else { - state.serverConfig = wellknown; - state.error = undefined; + const config = convertWellknown(action.payload.wellknownResponse); + if ('error' in config) { + state.error = config; + } else { + state.serverConfig = config; + state.error = undefined; + } } }, }, diff --git a/packages/journey-client/src/lib/config.types.test-d.ts b/packages/journey-client/src/lib/config.types.test-d.ts index d314f040fe..3536b20c2e 100644 --- a/packages/journey-client/src/lib/config.types.test-d.ts +++ b/packages/journey-client/src/lib/config.types.test-d.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. @@ -8,6 +8,7 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { JourneyClientConfig, JourneyServerConfig, + LegacyServerConfig, InternalJourneyClientConfig, } from './config.types.js'; import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; @@ -19,18 +20,30 @@ describe('Config Types', () => { expectTypeOf().toExtend(); }); - it('should narrow serverConfig to JourneyServerConfig', () => { - expectTypeOf().toExtend(); - expectTypeOf().toBeString(); + it('should type serverConfig as JourneyServerConfig | LegacyServerConfig', () => { + expectTypeOf().toEqualTypeOf< + JourneyServerConfig | LegacyServerConfig + >(); }); - it('should reject config without wellknown', () => { - // @ts-expect-error - wellknown is required on serverConfig + it('should have wellknown only on the JourneyServerConfig branch', () => { + type WellknownBranch = Extract; + expectTypeOf().toBeString(); + }); + + it('should reject serverConfig with neither wellknown nor baseUrl', () => { + // @ts-expect-error - serverConfig must satisfy JourneyServerConfig (wellknown) or LegacyServerConfig (baseUrl) const config: JourneyClientConfig = { serverConfig: {} }; - // This assertion verifies the variable's runtime shape doesn't satisfy the full type. expectTypeOf(config).not.toMatchObjectType>(); }); + it('should accept LegacyServerConfig with baseUrl', () => { + const config: JourneyClientConfig = { + serverConfig: { baseUrl: 'https://am.example.com' }, + }; + expectTypeOf(config).toExtend(); + }); + it('should allow AsyncLegacyConfigOptions properties', () => { const config: JourneyClientConfig = { clientId: 'test-client', @@ -53,8 +66,9 @@ describe('Config Types', () => { expectTypeOf(config).toExtend(); }); - it('should have optional timeout on serverConfig', () => { - expectTypeOf().toHaveProperty('timeout'); + it('should have optional timeout on both serverConfig branches', () => { + expectTypeOf().toHaveProperty('timeout'); + expectTypeOf().toHaveProperty('timeout'); }); }); diff --git a/packages/journey-client/src/lib/config.types.ts b/packages/journey-client/src/lib/config.types.ts index 34dc7ec0e4..b1877746ee 100644 --- a/packages/journey-client/src/lib/config.types.ts +++ b/packages/journey-client/src/lib/config.types.ts @@ -8,7 +8,11 @@ import type { GenericError } from '@forgerock/sdk-types'; import type { ResolvedServerConfig } from './wellknown.utils.js'; -export type { JourneyServerConfig, JourneyClientConfig } from '@forgerock/sdk-types'; +export type { + JourneyServerConfig, + JourneyClientConfig, + LegacyServerConfig, +} from '@forgerock/sdk-types'; /** * Internal configuration after wellknown discovery and path resolution. diff --git a/packages/sdk-types/src/lib/config.types.ts b/packages/sdk-types/src/lib/config.types.ts index 3752b970b4..060df3ab33 100644 --- a/packages/sdk-types/src/lib/config.types.ts +++ b/packages/sdk-types/src/lib/config.types.ts @@ -39,6 +39,11 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { log?: LogLevel; } +export interface LegacyServerConfig { + baseUrl: string; + timeout?: number; +} + export interface JourneyServerConfig { wellknown: string; timeout?: number; @@ -53,7 +58,7 @@ export interface JourneyServerConfig { * journey-client — a warning is logged when they are provided. */ export interface JourneyClientConfig extends AsyncLegacyConfigOptions { - serverConfig: JourneyServerConfig; + serverConfig: JourneyServerConfig | LegacyServerConfig; log?: LogLevel; } diff --git a/packages/sdk-utilities/src/lib/config/config.test.ts b/packages/sdk-utilities/src/lib/config/config.test.ts index a48407f9d8..d09e1ab29c 100644 --- a/packages/sdk-utilities/src/lib/config/config.test.ts +++ b/packages/sdk-utilities/src/lib/config/config.test.ts @@ -235,14 +235,16 @@ describe('parseToJourneyConfig', () => { it('parseToJourneyConfig_MinimalConfig_MapsWellknown', () => { const data = Either.getOrThrow(parseToJourneyConfig({ oidc: minimalOidc })); - expect(data.serverConfig.wellknown).toBe( + expect(data.serverConfig).toHaveProperty( + 'wellknown', 'https://example.com/.well-known/openid-configuration', ); }); it('parseToJourneyConfig_JourneyOnlyConfig_MapsWellknown', () => { const data = Either.getOrThrow(parseToJourneyConfig(journeyOnlyConfig)); - expect(data.serverConfig.wellknown).toBe( + expect(data.serverConfig).toHaveProperty( + 'wellknown', 'https://example.com/.well-known/openid-configuration', ); }); @@ -463,7 +465,8 @@ describe('makeOidcConfig', () => { describe('makeJourneyConfig', () => { it('makeJourneyConfig_ValidConfig_ReturnsMappedJourneyConfig', () => { const result = makeJourneyConfig(fullConfig); - expect(result.serverConfig.wellknown).toBe( + expect(result.serverConfig).toHaveProperty( + 'wellknown', 'https://example.com/.well-known/openid-configuration', ); expect(result.realmPath).toBe('alpha'); From 611620d16433086882c0c516811d7f5a3192884f Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 15 Jul 2026 09:48:41 -0600 Subject: [PATCH 3/3] chore: update-paths --- .../api-report/davinci-client.api.md | 16 +-- .../api-report/davinci-client.types.api.md | 16 +-- .../src/lib/client.store.test.ts | 106 ++++++++++++++++++ .../journey-client/src/lib/client.store.ts | 17 ++- .../src/lib/config.slice.test.ts | 25 +++++ .../journey-client/src/lib/config.slice.ts | 7 +- 6 files changed, 168 insertions(+), 19 deletions(-) diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index 1b952496d5..04b4f83ecf 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -355,14 +355,14 @@ export function davinci(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required { }); }); + describe('baseUrl mode', () => { + const baseUrl = 'https://am.example.com/am'; + const mockStepResponse: Step = { authId: 'test-auth-id', callbacks: [] }; + + function setupBaseUrlFetch() { + mockFetch.mockImplementation((input: RequestInfo | URL) => { + const url = getUrlFromInput(input); + if (url.includes('/authenticate')) { + return Promise.resolve(new Response(JSON.stringify(mockStepResponse))); + } + if (url.includes('/sessions')) { + return Promise.resolve(new Response(null, { status: 200 })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + } + + test('journey_BaseUrl_NoRealmPath_UsesRootAuthenticate', async () => { + setupBaseUrlFetch(); + + const client = await journey({ + config: { serverConfig: { baseUrl } }, + }); + await client.start(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const request = mockFetch.mock.calls[0][0] as Request; + expect(request.url).toBe('https://am.example.com/am/json/realms/root/authenticate'); + }); + + test('journey_BaseUrl_WithRealmPath_UsesRealmAuthenticate', async () => { + setupBaseUrlFetch(); + + const client = await journey({ + config: { serverConfig: { baseUrl }, realmPath: 'alpha' }, + }); + await client.start(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const request = mockFetch.mock.calls[0][0] as Request; + expect(request.url).toBe( + 'https://am.example.com/am/json/realms/root/realms/alpha/authenticate', + ); + }); + + test('journey_BaseUrl_TrailingSlashInput_NormalizesCorrectly', async () => { + setupBaseUrlFetch(); + + const client = await journey({ + config: { serverConfig: { baseUrl: `${baseUrl}/` } }, + }); + await client.start(); + + const request = mockFetch.mock.calls[0][0] as Request; + expect(request.url).toBe('https://am.example.com/am/json/realms/root/authenticate'); + }); + + test('journey_BaseUrl_Terminate_UsesRootSessionsUrl', async () => { + setupBaseUrlFetch(); + + const client = await journey({ + config: { serverConfig: { baseUrl } }, + }); + await client.terminate(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const request = mockFetch.mock.calls[0][0] as Request; + expect(request.url).toBe( + 'https://am.example.com/am/json/realms/root/sessions/?_action=logout', + ); + }); + + test('journey_BaseUrl_WithRealmPath_Terminate_UsesRealmSessionsUrl', async () => { + setupBaseUrlFetch(); + + const client = await journey({ + config: { serverConfig: { baseUrl }, realmPath: 'alpha' }, + }); + await client.terminate(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const request = mockFetch.mock.calls[0][0] as Request; + expect(request.url).toBe( + 'https://am.example.com/am/json/realms/root/realms/alpha/sessions/?_action=logout', + ); + }); + + test('journey_BaseUrl_WithRealmPath_DoesNotWarn', async () => { + setupBaseUrlFetch(); + const customLogger = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }; + + await journey({ + config: { serverConfig: { baseUrl }, realmPath: 'alpha' }, + logger: { level: 'warn', custom: customLogger }, + }); + + const warnCalls = customLogger.warn.mock.calls.map((c) => c[0] as string); + expect(warnCalls.find((msg) => msg.includes('realmPath'))).toBeUndefined(); + }); + }); + describe('unified JSON config entry', () => { test('accepts unified JSON config and initializes successfully', async () => { setupMockFetch(); diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index bf0161408f..5ea9604ed6 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -11,6 +11,7 @@ import { isGenericError, isValidWellknownUrl, createWellknownError, + getEndpointPath, } from '@forgerock/sdk-utilities'; import type { GenericError } from '@forgerock/sdk-types'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; @@ -93,7 +94,6 @@ export async function journey({ 'oauthThreshold', 'platformHeader', 'prefix', - 'realmPath', 'redirectUri', 'scope', 'tokenStore', @@ -124,7 +124,20 @@ export async function journey({ 'Wellknown configuration is disabled due to the presence of `serverConfig.baseUrl`. It is recommended that you remove `baseUrl` and just use the `wellknown` property.', ); - store.dispatch(configSlice.actions.set({ type: 'baseUrl', baseUrl })); + // resolve() treats the base URL as a directory: it drops the last path segment for relative + // paths. A trailing slash ensures the full context path is preserved (e.g. /am/json/...). + const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + + store.dispatch( + configSlice.actions.set({ + type: 'baseUrl', + baseUrl: normalizedBaseUrl, + paths: { + authenticate: getEndpointPath({ endpoint: 'authenticate', realmPath: config.realmPath }), + sessions: getEndpointPath({ endpoint: 'sessions', realmPath: config.realmPath }), + }, + }), + ); } else { const { wellknown } = config.serverConfig; // This is the normal use case for using the Wellknown endpoint for proper configuration. diff --git a/packages/journey-client/src/lib/config.slice.test.ts b/packages/journey-client/src/lib/config.slice.test.ts index ae7dcb70fb..54ec3aa51a 100644 --- a/packages/journey-client/src/lib/config.slice.test.ts +++ b/packages/journey-client/src/lib/config.slice.test.ts @@ -63,6 +63,31 @@ describe('journey-client config.slice', () => { }); }); + describe('configSlice_BaseUrlPayload_StoresVerbatim', () => { + it('should store baseUrl and paths exactly as dispatched', () => { + const state = configSlice.reducer( + undefined, + configSlice.actions.set({ + type: 'baseUrl', + baseUrl: 'https://am.example.com/am/', + paths: { + authenticate: 'json/realms/root/realms/alpha/authenticate', + sessions: 'json/realms/root/realms/alpha/sessions/', + }, + }), + ); + + expect(state.serverConfig).toEqual({ + baseUrl: 'https://am.example.com/am/', + paths: { + authenticate: 'json/realms/root/realms/alpha/authenticate', + sessions: 'json/realms/root/realms/alpha/sessions/', + }, + }); + expect(state.error).toBeUndefined(); + }); + }); + describe('configSlice_MissingAuthEndpoint_SetsError', () => { it('should set a GenericError when authorization_endpoint is empty', () => { const payload: ResolvedConfig = { diff --git a/packages/journey-client/src/lib/config.slice.ts b/packages/journey-client/src/lib/config.slice.ts index a3a2a2886e..46e6ee2730 100644 --- a/packages/journey-client/src/lib/config.slice.ts +++ b/packages/journey-client/src/lib/config.slice.ts @@ -23,6 +23,10 @@ export interface ResolvedConfig { interface BaseConfig { type: 'baseUrl'; baseUrl: string; + paths: { + authenticate: string; + sessions: string; + }; } const initialState: InternalJourneyClientConfig = { @@ -43,7 +47,8 @@ export const configSlice = createSlice({ reducers: { set(state, action: PayloadAction) { if (action.payload.type === 'baseUrl') { - state.serverConfig.baseUrl = action.payload.baseUrl; + const { baseUrl, paths } = action.payload; + state.serverConfig = { baseUrl, paths }; } else { const config = convertWellknown(action.payload.wellknownResponse); if ('error' in config) {