diff --git a/.changeset/nice-sails-trade.md b/.changeset/nice-sails-trade.md
new file mode 100644
index 00000000000..e1bc0538d12
--- /dev/null
+++ b/.changeset/nice-sails-trade.md
@@ -0,0 +1,28 @@
+---
+'@forgerock/davinci-client': minor
+'@forgerock/journey-client': minor
+'@forgerock/oidc-client': minor
+'@forgerock/sdk-store': minor
+'@forgerock/sdk-oidc': patch
+---
+
+Allow multiple SDK clients to share a single Redux store.
+
+`davinci()`, `journey()`, and `oidc()` now accept an optional `store` option. When two clients share a store they share the OpenID Connect discovery cache, so `.well-known/openid-configuration` is fetched once instead of once per client. `davinci()` and `journey()` expose the store they create as `client.store`; applications that want to own the store themselves can build one with `createSdkStore()` from the new `@forgerock/sdk-store` package.
+
+Omitting `store` is unchanged behaviour: the client creates its own store, exactly as before.
+
+**Request middleware and logging are scoped per client.** Each client's `requestMiddleware` and `logger` are registered against that client alone and are resolved only by its own requests. Middleware passed to `davinci()` or `journey()` is never applied to OIDC requests (`AUTHORIZE`, `PAR`, `TOKEN_EXCHANGE`, `REVOKE`, `USER_INFO`, `END_SESSION`), and middleware passed to `oidc()` is never applied to DaVinci or Journey requests. Both options are honoured on a shared store.
+
+**`oidc()` takes `store` as part of its options object**, alongside `config`, `requestMiddleware`, `logger`, and `storage`, consistent with every other factory in the SDK.
+
+**One OIDC client per store.** `oidc()` mounts at a fixed key, so initialising a second OIDC client on the same store with a different `clientId` returns an `argument_error` rather than silently overwriting the first client's token state. Re-initialising with the same `clientId` is allowed and idempotent. Use a separate store per `clientId`.
+
+Also in this release:
+
+- New `@forgerock/sdk-store` package (`scope:sdk-effects`) holding the single canonical `wellknownApi` instance, the shared store contract (`SdkStore`, `SdkStoreHandle`, `createSdkStore`, `injectClient`), and OpenID Connect discovery helpers (`initWellknownQuery`, `isValidWellknownResponse`). Previously each client package defined its own `wellknownApi`, which meant a separate discovery cache per client.
+- `oidc()` validates its arguments before attaching to a store, so a rejected call no longer leaves a caller-provided store modified.
+- Passing a value that is not an SDK store to `store` returns an `argument_error` instead of throwing.
+- Well-known selectors are now memoized per URL. `createWellknownSelector` previously rebuilt its selector on every call, so its cache never took effect.
+- `@forgerock/sdk-oidc`: `initWellknownQuery` and `isValidWellknownResponse` move to `@forgerock/sdk-store`. Update imports if you were using them directly.
+- `enforce-module-boundaries` lint rule promoted from `warn` to `error` across the repo. All packages pass.
diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts
index e42ee2030a6..f406feb2dde 100644
--- a/e2e/davinci-app/main.ts
+++ b/e2e/davinci-app/main.ts
@@ -4,33 +4,14 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
-import { davinci } from '@forgerock/davinci-client';
-import { oidc } from '@forgerock/oidc-client';
-import { protect } from '@forgerock/protect';
-
import './style.css';
-import booleanComponent from './components/boolean.js';
-import fidoComponent from './components/fido.js';
-import flowLinkComponent from './components/flow-link.js';
-import formImageComponent from './components/form-image.js';
-import metadataComponent from './components/metadata.js';
-import multiValueComponent from './components/multi-value.js';
-import objectValueComponent from './components/object-value.js';
-import passwordComponent from './components/password.js';
-import pollingComponent from './components/polling.js';
-import protectComponent from './components/protect.js';
-import qrCodeComponent from './components/qr-code.js';
-import readOnlyComponent from './components/read-only.js';
-import singleValueComponent from './components/single-value.js';
-import socialLoginButtonComponent from './components/social-login-button.js';
-import submitButtonComponent from './components/submit-button.js';
-import textComponent from './components/text.js';
-import { serverConfigs } from './server-configs.js';
+import { davinci } from '@forgerock/davinci-client';
+import { oidc } from '@forgerock/oidc-client';
+import type { OidcConfig } from '@forgerock/oidc-client/types';
import type {
Collectors,
CustomLogger,
- DavinciClient,
DaVinciConfig,
GetClient,
InternalErrorResponse,
@@ -38,7 +19,25 @@ import type {
ProtectCollector,
RequestMiddleware,
} from '@forgerock/davinci-client/types';
-import type { OidcConfig } from '@forgerock/oidc-client/types';
+import { protect } from '@forgerock/protect';
+
+import textComponent from './components/text.js';
+import passwordComponent from './components/password.js';
+import submitButtonComponent from './components/submit-button.js';
+import protectComponent from './components/protect.js';
+import flowLinkComponent from './components/flow-link.js';
+import socialLoginButtonComponent from './components/social-login-button.js';
+import { serverConfigs } from './server-configs.js';
+import singleValueComponent from './components/single-value.js';
+import multiValueComponent from './components/multi-value.js';
+import readOnlyComponent from './components/read-only.js';
+import objectValueComponent from './components/object-value.js';
+import fidoComponent from './components/fido.js';
+import qrCodeComponent from './components/qr-code.js';
+import formImageComponent from './components/form-image.js';
+import pollingComponent from './components/polling.js';
+import booleanComponent from './components/boolean.js';
+import metadataComponent from './components/metadata.js';
const loggerFn = {
error: () => {
@@ -88,7 +87,11 @@ const requestMiddleware: RequestMiddleware<'DAVINCI_NEXT' | 'DAVINCI_START'>[] =
const urlParams = new URLSearchParams(window.location.search);
(async () => {
- const davinciClient: DavinciClient = await davinci({ config, logger, requestMiddleware });
+ const davinciResult = await davinci({ config, logger, requestMiddleware });
+ if ('error' in davinciResult) {
+ throw new Error(`Failed to initialize davinci client: ${davinciResult.error}`);
+ }
+ const davinciClient = davinciResult;
const oidcResult = await oidc({ config: config as OidcConfig });
if ('error' in oidcResult) {
throw new Error(`Failed to initialize oidc client: ${oidcResult.error}`);
diff --git a/e2e/davinci-app/shared-store.html b/e2e/davinci-app/shared-store.html
new file mode 100644
index 00000000000..e0551b54f43
--- /dev/null
+++ b/e2e/davinci-app/shared-store.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Shared Store Test
+
+
+
initialising…
+
+
+
diff --git a/e2e/davinci-app/shared-store.ts b/e2e/davinci-app/shared-store.ts
new file mode 100644
index 00000000000..555ecb1914d
--- /dev/null
+++ b/e2e/davinci-app/shared-store.ts
@@ -0,0 +1,62 @@
+/*
+ * 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.
+ */
+
+/**
+ * Shared-store smoke test entry point.
+ *
+ * This page is navigated to by the Playwright e2e suite
+ * `shared-store.test.ts` only. It does not connect to any real PingOne
+ * endpoint — the test intercepts every `.well-known` request via
+ * `page.route()` and returns a minimal synthetic response.
+ *
+ * The page reports results by writing to `#status` so the test can
+ * assert via `page.textContent` without any app-specific UI.
+ */
+import { davinci } from '@forgerock/davinci-client';
+import type { DaVinciConfig } from '@forgerock/davinci-client/types';
+import { oidc } from '@forgerock/oidc-client';
+import type { OidcConfig } from '@forgerock/oidc-client/types';
+
+const WELLKNOWN_URL = 'https://sdk-test.example.com/as/.well-known/openid-configuration';
+
+const davinciConfig: DaVinciConfig = {
+ clientId: 'test-davinci-client',
+ redirectUri: window.location.origin,
+ scope: 'openid profile',
+ serverConfig: { wellknown: WELLKNOWN_URL },
+};
+
+const oidcConfig: OidcConfig = {
+ clientId: 'test-oidc-client',
+ redirectUri: window.location.origin,
+ scope: 'openid profile',
+ responseType: 'code',
+ serverConfig: { wellknown: WELLKNOWN_URL },
+};
+
+const statusEl = document.getElementById('status')!;
+
+async function run() {
+ // ── Mode 2: davinci creates the store, oidc attaches ─────────────────────
+ const dvClient = await davinci({ config: davinciConfig });
+ if ('error' in dvClient) {
+ statusEl.textContent = `davinci init error: ${dvClient.error}`;
+ return;
+ }
+
+ const ocClient = await oidc({ config: oidcConfig, store: dvClient.store });
+ if ('error' in ocClient) {
+ statusEl.textContent = `oidc init error: ${ocClient.error}`;
+ return;
+ }
+
+ statusEl.textContent = 'ready';
+}
+
+run().catch((err) => {
+ statusEl.textContent = `unexpected error: ${String(err)}`;
+});
diff --git a/e2e/davinci-app/vite.config.ts b/e2e/davinci-app/vite.config.ts
index 8625a27297c..b885b3911c8 100644
--- a/e2e/davinci-app/vite.config.ts
+++ b/e2e/davinci-app/vite.config.ts
@@ -17,6 +17,7 @@ export default defineConfig({
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
+ 'shared-store': path.resolve(__dirname, 'shared-store.html'),
},
output: {
entryFileNames: 'main.js',
diff --git a/e2e/davinci-suites/src/shared-store.test.ts b/e2e/davinci-suites/src/shared-store.test.ts
new file mode 100644
index 00000000000..8ec14164459
--- /dev/null
+++ b/e2e/davinci-suites/src/shared-store.test.ts
@@ -0,0 +1,84 @@
+/*
+ * 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 { expect, test } from '@playwright/test';
+
+/**
+ * Verifies that two SDK clients sharing a store fetch the OpenID Connect
+ * discovery document exactly once, regardless of which client initialises
+ * first and regardless of the ownership model.
+ *
+ * The page under test (`/shared-store`) exercises both Mode 2 (davinci owns
+ * the store) and Mode 3 (consumer-created store). Requests to the well-known
+ * URL are intercepted and served with a synthetic response so the test does
+ * not require a live PingOne endpoint.
+ */
+
+const WELLKNOWN_URL = 'https://sdk-test.example.com/as/.well-known/openid-configuration';
+
+const WELLKNOWN_RESPONSE = {
+ issuer: 'https://sdk-test.example.com/as',
+ authorization_endpoint: 'https://sdk-test.example.com/as/authorize',
+ token_endpoint: 'https://sdk-test.example.com/as/token',
+ userinfo_endpoint: 'https://sdk-test.example.com/as/userinfo',
+ jwks_uri: 'https://sdk-test.example.com/as/jwks',
+ revocation_endpoint: 'https://sdk-test.example.com/as/revoke',
+ introspection_endpoint: 'https://sdk-test.example.com/as/introspect',
+ pushed_authorization_request_endpoint: 'https://sdk-test.example.com/as/par',
+};
+
+test('shared store — one .well-known fetch across two clients (mode 2: client-owned)', async ({
+ page,
+}) => {
+ let discoveryFetchCount = 0;
+
+ // Intercept and count every discovery request; fulfil with a synthetic response
+ // so no live credential or network is needed.
+ await page.route(`**/.well-known/**`, async (route) => {
+ discoveryFetchCount++;
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(WELLKNOWN_RESPONSE),
+ });
+ });
+
+ await page.goto('/shared-store.html', { waitUntil: 'networkidle' });
+
+ // The page reports its own status so we know initialisation completed.
+ await expect(page.locator('#status')).toHaveText('ready', { timeout: 15_000 });
+
+ // Mode 2 (davinci owns the store, oidc attaches): davinci fetches once,
+ // oidc reads from cache — exactly 1 network request for 2 clients.
+ expect(discoveryFetchCount).toBe(1);
+});
+
+test("shared store — oidc attaches to davinci's store, reads discovery from cache", async ({
+ page,
+}) => {
+ const fetchedUrls: string[] = [];
+
+ await page.route(`**/.well-known/**`, async (route) => {
+ fetchedUrls.push(route.request().url());
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(WELLKNOWN_RESPONSE),
+ });
+ });
+
+ await page.goto('/shared-store.html', { waitUntil: 'networkidle' });
+ await expect(page.locator('#status')).toHaveText('ready', { timeout: 15_000 });
+
+ // Both modes use the same WELLKNOWN_URL, so each URL appears exactly once
+ // across the two calls despite four total client initialisations.
+ const unique = [...new Set(fetchedUrls)];
+ expect(unique).toHaveLength(1);
+ expect(unique[0]).toContain('.well-known');
+
+ // Mode 2: 2 clients (davinci + oidc) on 1 store → exactly 1 fetch.
+ expect(fetchedUrls.length).toBe(1);
+});
diff --git a/e2e/device-client-app/src/utils/index.ts b/e2e/device-client-app/src/utils/index.ts
index 721d453d025..531a91f4814 100644
--- a/e2e/device-client-app/src/utils/index.ts
+++ b/e2e/device-client-app/src/utils/index.ts
@@ -113,8 +113,12 @@ export const LoginAndGetClient = Effect.gen(function* () {
catch: (err) => new Error(`Failed to initialize OIDC client: ${err}`),
});
+ if ('error' in journeyClient) {
+ throw new Error(`Failed to initialize journey client: ${journeyClient.error}`);
+ }
+
if ('error' in oidcClient) {
- return yield* Effect.fail(new Error(`Failed to initialize OIDC client: ${oidcClient.error}`));
+ throw new Error(`Failed to initialize OIDC client: ${oidcClient.error}`);
}
cachedOidcClient = oidcClient;
diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts
index e5c0d735dbb..0fd711d8b32 100644
--- a/e2e/journey-app/main.ts
+++ b/e2e/journey-app/main.ts
@@ -4,18 +4,19 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
+import './style.css';
+
import { journey } from '@forgerock/journey-client';
-import './style.css';
+import type { 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';
import { renderRecoveryCodesStep } from './components/recovery-codes.js';
+import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js';
import { handleWebAuthnStep } from './components/webauthn-step.js';
import { serverConfigs } from './server-configs.js';
-import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js';
-
-import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types';
const qs = window.location.search;
const searchParams = new URLSearchParams(qs);
@@ -61,15 +62,14 @@ if (searchParams.get('middleware') === 'true') {
const formEl = document.getElementById('form') as HTMLFormElement;
const journeyEl = document.getElementById('journey') as HTMLDivElement;
- let journeyClient: JourneyClient;
- try {
- journeyClient = await journey({ config: config, requestMiddleware });
- } catch (error) {
- const message = error instanceof Error ? error.message : 'Unknown error';
+ const journeyResult = await journey({ config: config, requestMiddleware });
+ if ('error' in journeyResult) {
+ const message = journeyResult.error;
console.error('Failed to initialize journey client:', message);
errorEl.textContent = message;
return;
}
+ const journeyClient = journeyResult;
let step = await journeyClient.start({ journey: journeyName });
function renderError() {
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 0f5d2a1a963..6b5f0e357a4 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -144,7 +144,7 @@ export default [
rules: {
'import/extensions': [2, 'ignorePackages'],
'@nx/enforce-module-boundaries': [
- 'warn',
+ 'error',
{
enforceBuildableLibDependency: true,
allow: [],
diff --git a/packages/davinci-client/README.md b/packages/davinci-client/README.md
index 5534564ffd9..a12ab1e61ee 100644
--- a/packages/davinci-client/README.md
+++ b/packages/davinci-client/README.md
@@ -25,7 +25,7 @@ Configure DaVinci Client with the following minimum, required properties:
```ts
// Demo with example values
-import { davinci } from '@forgerock/davinci';
+import { davinci } from '@forgerock/davinci-client';
const davinciClient = await davinci({
config: {
@@ -42,7 +42,7 @@ If you have a need for more than one client, say you need to use two or more dif
```ts
// Demo with example values
-import { davinci } from '@forgerock/davinci';
+import { davinci } from '@forgerock/davinci-client';
const firstDavinciClient = await davinci(/** config 1 **/);
const secondDavinciClient = await davinci(/** config 2 **/);
@@ -63,6 +63,50 @@ interface DaVinciConfig {
}
```
+### Sharing a store with another client
+
+If your application also uses `@forgerock/oidc-client`, the two can share one Redux store so the well-known discovery document is fetched once rather than once per client.
+
+`davinci()` exposes the store it created as `client.store`. Pass it to the other client:
+
+```ts
+import { davinci } from '@forgerock/davinci-client';
+import { oidc } from '@forgerock/oidc-client';
+
+const davinciClient = await davinci({ config });
+
+// Attaches to davinci's store; the discovery document is already cached there.
+const oidcClient = await oidc({ config: oidcConfig, store: davinciClient.store });
+```
+
+Or create the store yourself when neither client is the natural owner:
+
+```ts
+import { createSdkStore } from '@forgerock/sdk-store';
+
+const store = createSdkStore();
+const davinciClient = await davinci({ config, store });
+const oidcClient = await oidc({ config: oidcConfig, store });
+```
+
+Omitting `store` is always valid — the client creates its own, which is the default behaviour.
+
+#### Middleware and logging stay private
+
+Sharing a store shares cached data, not configuration. `requestMiddleware` and `logger` are registered against the client you pass them to, and are resolved only by that client's own requests:
+
+```ts
+const store = createSdkStore();
+
+// Runs for DAVINCI_START, DAVINCI_NEXT, DAVINCI_FLOW and the other DaVinci actions only.
+await davinci({ config, store, requestMiddleware: [davinciMiddleware] });
+
+// Runs for OIDC requests only.
+await oidc({ config: oidcConfig, store, requestMiddleware: [oidcMiddleware] });
+```
+
+Middleware passed here will never run against an OIDC token exchange, and vice versa.
+
### Start a DaVinci flow
Call the `start` method on the returned client API:
@@ -182,10 +226,10 @@ Upon each collector in the array, some will need an `updater`, like the collecto
```ts
// Example SingleValueCollector using the TextCollector
-const collectors = davinci.collectors();
+const collectors = davinciClient.getCollectors();
collectors.map((collector) => {
if (collector.type === 'TextCollector') {
- renderTextCollector(collector, davinci.update(collector));
+ renderTextCollector(collector, davinciClient.update(collector));
}
});
```
@@ -214,7 +258,7 @@ The `SubmitCollector` is associated with the submission of the current node and
```ts
// Example SubmitCollector mapping
-const collectors = davinci.collectors();
+const collectors = davinciClient.getCollectors();
collectors.map((collector) => {
if (collector.type === 'SubmitCollector') {
renderSubmitCollector(
@@ -234,7 +278,7 @@ To do this, you call the `flow` method on the `davinciClient` passing the `key`
```ts
// Example FlowCollector mapping
-const collectors = davinci.collectors();
+const collectors = davinciClient.getCollectors();
collectors.map((collector) => {
if (collector.type === 'FlowCollector') {
renderFlowCollector(collector, davinciClient.flow(collector));
@@ -260,7 +304,7 @@ function renderFlowCollector(collector, startFlow) {
After collecting the needed data, you proceed to the next node in the DaVinci flow by calling the `.next()` method on the same `davinci` client object. This can be the result of a user clicking on the button rendered from the `SubmitCollector`, from the "submit" event of the HTML form itself, or from programmatically triggering the submission in the application layer.
```ts
-let nextStep = davinci.next();
+const nextStep = await davinciClient.next();
```
Note: There's no need to pass anything into the `next` method as the DaVinci Client internally stores the updated object needed for the server.
@@ -284,25 +328,12 @@ When you receive a success node, you will likely want to use the Authorization C
Here's a brief sample of what that might look like in pseudocode:
```ts
-// ... other imports
-
-import { Config, TokenManager } from '@forgerock/javascript-sdk';
-
-// ... other config or initialization code
-
-// This Config.set accepts the same config schema as the davinci function
-Config.set(config);
-
-const node = await davinciClient.next();
-
-if (node.status === 'success') {
- const clientInfo = davinciClient.getClient();
-
- const code = clientInfo.authorization?.code || '';
- const state = clientInfo.authorization?.state || '';
-
- const tokens = await TokenManager.getTokens({ query: { code, state } });
- // user now has session and OIDC tokens
+// oidcClient is an instance of oidc() from @forgerock/oidc-client, configured earlier
+const tokens = await oidcClient.token.exchange(code, state);
+if ('error' in tokens) {
+ console.error('Token exchange failed:', tokens.error);
+} else {
+ console.log('Access token:', tokens.accessToken);
}
```
@@ -320,7 +351,7 @@ if (node.status === 'failure') {
renderError(error);
// ... user clicks button to restart flow
- const freshNode = davinciClient.start();
+ const freshNode = await davinciClient.start();
}
```
diff --git a/packages/davinci-client/package.json b/packages/davinci-client/package.json
index d91b800251e..1ec05d73433 100644
--- a/packages/davinci-client/package.json
+++ b/packages/davinci-client/package.json
@@ -30,6 +30,7 @@
"@forgerock/sdk-logger": "workspace:*",
"@forgerock/sdk-oidc": "workspace:*",
"@forgerock/sdk-request-middleware": "workspace:*",
+ "@forgerock/sdk-store": "workspace:*",
"@forgerock/sdk-types": "workspace:*",
"@forgerock/sdk-utilities": "workspace:*",
"@forgerock/storage": "workspace:*",
diff --git a/packages/davinci-client/src/lib/client.store.effects.ts b/packages/davinci-client/src/lib/client.store.effects.ts
index 5828de19d70..dc306345ec2 100644
--- a/packages/davinci-client/src/lib/client.store.effects.ts
+++ b/packages/davinci-client/src/lib/client.store.effects.ts
@@ -6,19 +6,20 @@
*/
import { Micro } from 'effect';
-
-import { createInternalError, isInternalError } from './client.store.utils.js';
-import { davinciApi } from './davinci.api.js';
-import { nodeSlice } from './node.slice.js';
+import { SerializedError } from '@reduxjs/toolkit/react';
+import { FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
import type { logger as loggerFn } from '@forgerock/sdk-logger';
-import type { FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
-import type { SerializedError } from '@reduxjs/toolkit/react';
-import type { ClientStore, RootState } from './client.store.utils.js';
-import type { InternalErrorResponse, PollingStatus } from './client.types.js';
+import type { DavinciStore } from './client.store.utils.js';
+import type { RootState } from './davinci.state.js';
+import type { PollingStatus, InternalErrorResponse } from './client.types.js';
import type { PollingCollector } from './collector.types.js';
+import { createInternalError, isInternalError } from './client.store.utils.js';
+import { davinciApi } from './davinci.api.js';
+import { nodeSlice } from './node.slice.js';
+
/**
* Shape returned by RTK Query's dispatch for the poll endpoint.
*/
@@ -239,7 +240,7 @@ function challengePollingµ({
}: {
collector: PollingCollector;
challenge: string;
- store: ReturnType;
+ store: DavinciStore;
log: ReturnType;
}): Micro.Micro {
const maxRetries = collector.output.config.pollRetries ?? 60;
@@ -295,7 +296,7 @@ export function pollingµ({
}: {
mode: PollingMode;
collector: PollingCollector;
- store: ReturnType;
+ store: DavinciStore;
log: ReturnType;
}): Micro.Micro {
if (mode._tag === 'challenge') {
diff --git a/packages/davinci-client/src/lib/client.store.test.ts b/packages/davinci-client/src/lib/client.store.test.ts
index ff1892b9843..a244617a930 100644
--- a/packages/davinci-client/src/lib/client.store.test.ts
+++ b/packages/davinci-client/src/lib/client.store.test.ts
@@ -124,6 +124,7 @@ describe('davinci client — cache', () => {
describe('cache.getLatestResponse()', () => {
it('returns a state_error when no flow has been started (no cache key)', async () => {
const client = await davinci({ config: mockConfig });
+ if ('type' in client) throw new Error(`davinci() failed: ${client.error}`);
// Node is in start status — cache.key is null before any start() call
const result = client.cache.getLatestResponse();
@@ -133,6 +134,7 @@ describe('davinci client — cache', () => {
it('returns the raw DaVinci response object — NOT a selector function — after start()', async () => {
const client = await davinci({ config: mockConfig });
+ if ('type' in client) throw new Error(`davinci() failed: ${client.error}`);
await client.start();
const result = client.cache.getLatestResponse();
@@ -151,6 +153,7 @@ describe('davinci client — cache', () => {
describe('cache.getResponseWithId()', () => {
it('returns an argument_error when called with an empty string', async () => {
const client = await davinci({ config: mockConfig });
+ if ('type' in client) throw new Error(`davinci() failed: ${client.error}`);
const result = client.cache.getResponseWithId('');
@@ -159,6 +162,7 @@ describe('davinci client — cache', () => {
it('returns the raw DaVinci response object — NOT a selector function — for a valid request ID', async () => {
const client = await davinci({ config: mockConfig });
+ if ('type' in client) throw new Error(`davinci() failed: ${client.error}`);
await client.start();
const node = client.getNode();
@@ -176,6 +180,7 @@ describe('davinci client — cache', () => {
it('returns a state_error for a requestId not present in cache', async () => {
const client = await davinci({ config: mockConfig });
+ if ('type' in client) throw new Error(`davinci() failed: ${client.error}`);
const result = client.cache.getResponseWithId('non-existent-id');
diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts
index 1fae4b490c1..a2bc604810e 100644
--- a/packages/davinci-client/src/lib/client.store.ts
+++ b/packages/davinci-client/src/lib/client.store.ts
@@ -4,13 +4,12 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
-import { logger as loggerFn } from '@forgerock/sdk-logger';
-import { createWellknownError, isGenericError } from '@forgerock/sdk-utilities';
-import { createStorage } from '@forgerock/storage';
-import { Either, Micro } from 'effect';
+import { Micro, Either } from 'effect';
import { exitIsFail, exitIsSuccess } from 'effect/Micro';
+import { type CustomLogger, logger as loggerFn, type LogLevel } from '@forgerock/sdk-logger';
+import { createStorage } from '@forgerock/storage';
+import { isGenericError, createWellknownError } from '@forgerock/sdk-utilities';
-import { getPollingModeµ, pollingµ } from './client.store.effects.js';
/**
* Import RTK slices and api
*/
@@ -21,34 +20,15 @@ import {
isValidCollectorCategory,
resolveCollectorUpdateValue,
} from './client.store.utils.js';
-import { returnValidator } from './collector.utils.js';
-import { configSlice } from './config.slice.js';
-import { davinciApi } from './davinci.api.js';
+import type { RootState } from './davinci.state.js';
+import { pollingµ, getPollingModeµ } from './client.store.effects.js';
import { nodeSlice } from './node.slice.js';
-import { returnPasswordPolicyValidator } from './password-policy.rules.js';
-import { wellknownApi } from './wellknown.api.js';
+import { davinciApi } from './davinci.api.js';
+import { configSlice } from './config.slice.js';
+import { wellknownApi, assertValidStore, getClientForReducerPath } from '@forgerock/sdk-store';
-import type { CustomLogger, LogLevel } from '@forgerock/sdk-logger';
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
-
-import type { RootState } from './client.store.utils.js';
-import type {
- CollectorValueTypes,
- InitFlow,
- InternalErrorResponse,
- NodeStates,
- Poller,
- UpdatableCollectors,
- Updater,
- Validator,
-} from './client.types.js';
-import type {
- AutoCollectors,
- MultiValueCollectors,
- ObjectValueCollectors,
- PollingCollector,
- SingleValueCollectors,
-} from './collector.types.js';
+import type { SdkStore } from '@forgerock/sdk-store';
/**
* Import the DaVinciRequest types
*/
@@ -59,6 +39,25 @@ import type {
OutgoingQueryParams,
StartOptions,
} from './davinci.types.js';
+import type {
+ SingleValueCollectors,
+ ObjectValueCollectors,
+ AutoCollectors,
+ PollingCollector,
+ MultiValueCollectors,
+} from './collector.types.js';
+import type {
+ InitFlow,
+ InternalErrorResponse,
+ NodeStates,
+ Updater,
+ UpdatableCollectors,
+ Validator,
+ Poller,
+ CollectorValueTypes,
+} from './client.types.js';
+import { returnValidator } from './collector.utils.js';
+import { returnPasswordPolicyValidator } from './password-policy.rules.js';
import type { ContinueNode, StartNode } from './node.types.js';
/**
@@ -73,6 +72,7 @@ export async function davinci({
config,
requestMiddleware,
logger,
+ store: sharedStore,
}: {
config: DaVinciConfig;
requestMiddleware?: RequestMiddleware[];
@@ -80,16 +80,30 @@ export async function davinci({
level: LogLevel;
custom?: CustomLogger;
};
+ /**
+ * An existing SDK store to attach to, so discovery caching and state are
+ * shared with another client. Omit to create a store for this client alone.
+ */
+ store?: unknown;
}) {
const log = loggerFn({
level: logger?.level ?? config.log ?? 'error',
custom: logger?.custom,
});
- const store = createClientStore({ requestMiddleware, logger: log });
- const serverInfo = createStorage({
- type: 'localStorage',
- name: 'serverInfo',
- });
+
+ const storeError = assertValidStore(sharedStore);
+ if (storeError) return storeError;
+
+ const validStore = sharedStore as SdkStore | undefined;
+
+ if (validStore && getClientForReducerPath(validStore, davinciApi.reducerPath)) {
+ return {
+ error:
+ 'This store already has a DaVinci client attached. Use a separate store per DaVinci client.',
+ type: 'argument_error' as const,
+ };
+ }
+
if (!config.serverConfig.wellknown) {
const error = new Error(
'`wellknown` property is a required as part of the `config.serverConfig`',
@@ -104,6 +118,13 @@ export async function davinci({
throw error;
}
+ const handle = createClientStore({ requestMiddleware, logger: log, store: validStore });
+ const store = handle.store;
+ const serverInfo = createStorage({
+ type: 'localStorage',
+ name: 'serverInfo',
+ });
+
const { data: openIdResponse, error: fetchError } = await store.dispatch(
wellknownApi.endpoints.configuration.initiate(config.serverConfig.wellknown),
);
@@ -117,6 +138,8 @@ export async function davinci({
store.dispatch(configSlice.actions.set({ ...config, wellknownResponse: openIdResponse }));
return {
+ /** Pass to another SDK client's `store` option to share this store. */
+ store: handle as SdkStore,
// Pass store methods to the client
subscribe: store.subscribe,
diff --git a/packages/davinci-client/src/lib/client.store.utils.ts b/packages/davinci-client/src/lib/client.store.utils.ts
index 31e4eda11dc..91af0c3b4e3 100644
--- a/packages/davinci-client/src/lib/client.store.utils.ts
+++ b/packages/davinci-client/src/lib/client.store.utils.ts
@@ -4,76 +4,59 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
-import { configureStore } from '@reduxjs/toolkit';
-import { Either, Match } from 'effect';
+import { Match, Either } from 'effect';
-import { configSlice } from './config.slice.js';
-import { davinciApi } from './davinci.api.js';
-import { nodeSlice } from './node.slice.js';
-import { wellknownApi } from './wellknown.api.js';
-
-import type { logger as loggerFn } from '@forgerock/sdk-logger';
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { logger as loggerFn } from '@forgerock/sdk-logger';
import type { GenericError } from '@forgerock/sdk-types';
+import type { Collectors, CollectorCategory } from './node.types.js';
import type {
CollectorValueType,
CollectorValueTypes,
InternalErrorResponse,
UpdatableCollectors,
} from './client.types.js';
-import type {
- CollectorCategory,
- Collectors,
- ContinueNode,
- ErrorNode,
- StartNode,
- SuccessNode,
-} from './node.types.js';
+import { createSdkStore, injectClient } from '@forgerock/sdk-store';
+import type { SdkStore, SdkStoreHandle } from '@forgerock/sdk-store';
+
+import { configSlice } from './config.slice.js';
+import { nodeSlice } from './node.slice.js';
+import { davinciApi } from './davinci.api.js';
+
+import type { RootState } from './davinci.state.js';
+
+/**
+ * Creates, or attaches to, the store backing a DaVinci client.
+ *
+ * Passing `store` attaches to an existing SDK store so that discovery caching
+ * and state are shared; omitting it creates one, which is the default.
+ */
export function createClientStore({
requestMiddleware,
logger,
+ store,
}: {
requestMiddleware?: RequestMiddleware[];
logger?: ReturnType;
-}) {
- return configureStore({
- reducer: {
- config: configSlice.reducer,
- node: nodeSlice.reducer,
- [davinciApi.reducerPath]: davinciApi.reducer,
- [wellknownApi.reducerPath]: wellknownApi.reducer,
- },
- middleware: (getDefaultMiddleware) =>
- getDefaultMiddleware({
- thunk: {
- extraArgument: {
- /**
- * This becomes the `api.extra` argument, and will be passed into the
- * customer query wrapper for `baseQuery`
- */
- requestMiddleware,
- logger,
- },
- },
- })
- .concat(davinciApi.middleware)
- .concat(wellknownApi.middleware),
+ store?: SdkStore;
+}): SdkStoreHandle {
+ return injectClient(store ?? createSdkStore(), {
+ api: davinciApi,
+ reducerPath: davinciApi.reducerPath,
+ slices: [configSlice, nodeSlice],
+ requestMiddleware,
+ logger,
});
}
export type ClientStore = typeof createClientStore;
-export type RootState = ReturnType['getState']>;
-
-export interface RootStateWithNode<
- T extends ErrorNode | ContinueNode | StartNode | SuccessNode,
-> extends RootState {
- node: T;
-}
+/** The inner Redux store type — used by effects that need dispatch/getState. */
+export type DavinciStore = SdkStoreHandle['store'];
-export type AppDispatch = ReturnType['dispatch']>;
+export type AppDispatch = DavinciStore['dispatch'];
/**
* @function createInternalError
diff --git a/packages/davinci-client/src/lib/davinci.api.ts b/packages/davinci-client/src/lib/davinci.api.ts
index 0eabf2419f8..403ead99763 100644
--- a/packages/davinci-client/src/lib/davinci.api.ts
+++ b/packages/davinci-client/src/lib/davinci.api.ts
@@ -8,46 +8,74 @@
* Import the RTK Query library from Redux Toolkit
* @see https://redux-toolkit.js.org/rtk-query/overview
*/
-import { createAuthorizeUrl } from '@forgerock/sdk-oidc';
+import {
+ createApi,
+ FetchArgs,
+ fetchBaseQuery,
+ FetchBaseQueryError,
+ FetchBaseQueryMeta,
+ QueryReturnValue,
+} from '@reduxjs/toolkit/query';
+
/**
* Import internal modules
*/
import { initQuery } from '@forgerock/sdk-request-middleware';
-import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
+import { createAuthorizeUrl } from '@forgerock/sdk-oidc';
import { handleResponse, transformActionRequest, transformSubmitRequest } from './davinci.utils.js';
-import type { logger as loggerFn } from '@forgerock/sdk-logger';
+import { logger as loggerFn } from '@forgerock/sdk-logger';
+import { clientExtra } from '@forgerock/sdk-store';
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
-import type { GenericError } from '@forgerock/sdk-types';
-import type {
- FetchArgs,
- FetchBaseQueryError,
- FetchBaseQueryMeta,
- QueryReturnValue,
-} from '@reduxjs/toolkit/query';
-import type { StartNode } from '../types.js';
/**
* Import the DaVinci types
*/
-import type { RootStateWithNode } from './client.store.utils.js';
-import type { FidoAuthenticationCollector, FidoRegistrationCollector } from './collector.types.js';
+import type { RootStateWithNode } from './davinci.state.js';
import type {
DaVinciCacheEntry,
OutgoingQueryParams,
StartOptions,
ThrownQueryError,
} from './davinci.types.js';
+import type { GenericError } from '@forgerock/sdk-types';
+import type { FidoRegistrationCollector, FidoAuthenticationCollector } from './collector.types.js';
import type { ContinueNode } from './node.types.js';
+import type { StartNode } from '../types.js';
type BaseQueryResponse = Promise<
QueryReturnValue
>;
+const DAVINCI_REDUCER_PATH = 'davinci';
+
+/**
+ * This client's private slot on the store's `extraArgument`.
+ *
+ * Both fields are optional because a shared store may not have had a davinci
+ * slot registered yet; `davinciExtra` substitutes safe defaults so an endpoint
+ * can never fail on a missing slot.
+ */
interface Extras {
- requestMiddleware: RequestMiddleware[];
- logger: ReturnType;
+ requestMiddleware?: RequestMiddleware[];
+ logger?: ReturnType;
+}
+
+/** Fallback so a missing slot degrades to error-level logging, never a crash. */
+const fallbackLogger = loggerFn({ level: 'error' });
+
+/**
+ * Resolves this client's own middleware and logger.
+ *
+ * Reads only the `davinci` slot — never a store-wide value, which on a shared
+ * store would belong to whichever client created it.
+ */
+function davinciExtra(extra: unknown): Required {
+ return clientExtra(extra, DAVINCI_REDUCER_PATH, {
+ requestMiddleware: [],
+ logger: fallbackLogger,
+ });
}
/**
@@ -55,7 +83,7 @@ interface Extras {
@@ -79,9 +107,9 @@ export const davinciApi = createApi({
const requestBody = transformActionRequest(
state.node,
params.action,
- (api.extra as Extras).logger,
+ davinciExtra(api.extra).logger,
);
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = davinciExtra(api.extra);
let href = '';
@@ -124,7 +152,7 @@ export const davinciApi = createApi({
* parameters are pre-typed from the library.
*/
async onQueryStarted(_, api) {
- const logger = (api.extra as Extras).logger;
+ const { logger } = davinciExtra(api.extra);
let response;
try {
@@ -158,7 +186,7 @@ export const davinciApi = createApi({
async queryFn(body, api, __, baseQuery) {
const state = api.getState() as RootStateWithNode;
const links = state.node.server._links;
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = davinciExtra(api.extra);
let requestBody;
let href = '';
@@ -235,7 +263,7 @@ export const davinciApi = createApi({
* parameters are pre-typed from the library.
*/
async onQueryStarted(_, api) {
- const logger = (api.extra as Extras).logger;
+ const { logger } = davinciExtra(api.extra);
let response;
try {
@@ -268,7 +296,7 @@ export const davinciApi = createApi({
* @method queryFn - This is just a wrapper around the fetch call
*/
async queryFn(options, api, __, baseQuery) {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = davinciExtra(api.extra);
const state = api.getState() as RootStateWithNode;
if (!state) {
@@ -350,7 +378,7 @@ export const davinciApi = createApi({
* parameters are pre-typed from the library.
*/
async onQueryStarted(_, api) {
- const logger = (api.extra as Extras).logger;
+ const { logger } = davinciExtra(api.extra);
let response;
try {
@@ -379,7 +407,7 @@ export const davinciApi = createApi({
*/
resume: builder.query({
async queryFn({ serverInfo, continueToken }, api, _c, baseQuery) {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = davinciExtra(api.extra);
const links = serverInfo._links;
if (!continueToken) {
@@ -428,7 +456,7 @@ export const davinciApi = createApi({
return response;
},
async onQueryStarted(_, api) {
- const logger = (api.extra as Extras).logger;
+ const { logger } = davinciExtra(api.extra);
let response;
try {
@@ -462,7 +490,7 @@ export const davinciApi = createApi({
*/
poll: builder.mutation({
async queryFn({ endpoint, interactionId }, api, _c, baseQuery) {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = davinciExtra(api.extra);
const request: FetchArgs = {
url: endpoint,
diff --git a/packages/davinci-client/src/lib/davinci.state.ts b/packages/davinci-client/src/lib/davinci.state.ts
new file mode 100644
index 00000000000..9b4a2dbfbc2
--- /dev/null
+++ b/packages/davinci-client/src/lib/davinci.state.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 { combineSlices } from '@reduxjs/toolkit';
+
+import { configSlice } from './config.slice.js';
+import { nodeSlice } from './node.slice.js';
+import { davinciApi } from './davinci.api.js';
+import { wellknownApi } from '@forgerock/sdk-store';
+
+import type { ErrorNode, ContinueNode, StartNode, SuccessNode } from './node.types.js';
+
+/**
+ * The canonical description of the state this client contributes.
+ *
+ * Isolated into its own module to prevent a circular dependency:
+ * `node.reducer.ts` → `client.store.utils.ts` → `node.slice.ts` would cycle.
+ * Nothing that `node.reducer.ts` imports should import from this file.
+ */
+export const rootReducer = combineSlices(configSlice, nodeSlice, davinciApi, wellknownApi);
+
+export type RootState = ReturnType;
+
+export interface RootStateWithNode<
+ T extends ErrorNode | ContinueNode | StartNode | SuccessNode,
+> extends RootState {
+ node: T;
+}
diff --git a/packages/davinci-client/src/lib/store-shape.test.ts b/packages/davinci-client/src/lib/store-shape.test.ts
new file mode 100644
index 00000000000..69b1885332f
--- /dev/null
+++ b/packages/davinci-client/src/lib/store-shape.test.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+
+import { createClientStore } from './client.store.utils.js';
+
+/**
+ * `combineSlices` keys each reducer off `slice.reducerPath ?? slice.name`, where
+ * the previous `configureStore({ reducer: { ... } })` form spelled the keys out
+ * literally. That makes the published state shape an implicit consequence of
+ * slice metadata: renaming `nodeSlice.name` would silently reshape the store.
+ *
+ * These assertions pin the shape so such a rename fails loudly here instead of
+ * in a consumer's selectors.
+ */
+describe('davinci store shape', () => {
+ it('exposes exactly the expected top-level state keys', () => {
+ // Arrange
+ const { store } = createClientStore({});
+
+ // Act
+ const keys = Object.keys(store.getState()).sort();
+
+ // Assert
+ expect(keys).toEqual(['config', 'davinci', 'node', 'wellknown']);
+ });
+
+ it('registers this client\u2019s slot on the store extra, keyed by reducerPath', async () => {
+ // Arrange
+ const { store } = createClientStore({});
+ let observed: unknown;
+
+ // Act — a thunk is the supported way to observe extraArgument
+ await store.dispatch(((_dispatch: unknown, _getState: unknown, extra: unknown) => {
+ observed = extra;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any);
+
+ // Assert
+ expect(observed).toHaveProperty('clients.davinci');
+ });
+});
diff --git a/packages/davinci-client/src/lib/wellknown.api.ts b/packages/davinci-client/src/lib/wellknown.api.ts
deleted file mode 100644
index 882a66bcc38..00000000000
--- a/packages/davinci-client/src/lib/wellknown.api.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * 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 { initWellknownQuery } from '@forgerock/sdk-oidc';
-import { createSelector } from '@reduxjs/toolkit';
-import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
-
-import type { WellknownResponse } from '@forgerock/sdk-types';
-import type {
- FetchBaseQueryError,
- FetchBaseQueryMeta,
- QueryReturnValue,
-} from '@reduxjs/toolkit/query';
-
-/**
- * RTK Query API for well-known endpoint discovery.
- *
- * Uses the `initWellknownQuery` builder pattern from `@forgerock/sdk-oidc`.
- * The builder constructs the request and validates the response;
- * `fetchBaseQuery` handles the HTTP transport through RTK Query's pipeline.
- */
-export const wellknownApi = createApi({
- reducerPath: 'wellknown',
- baseQuery: fetchBaseQuery(),
- endpoints: (builder) => ({
- configuration: builder.query({
- queryFn: async (url, _api, _extra, baseQuery) => {
- const result = await initWellknownQuery(url).applyQuery(async (req) => {
- const queryResult = await baseQuery(req);
- return queryResult as QueryReturnValue;
- });
- return result as QueryReturnValue<
- WellknownResponse,
- FetchBaseQueryError,
- FetchBaseQueryMeta
- >;
- },
- }),
- }),
-});
-
-/**
- * Creates a memoized selector for cached well-known data.
- *
- * @param wellknownUrl - The well-known endpoint URL used as the cache key
- * @returns A memoized selector that extracts the WellknownResponse from state, or undefined if not yet fetched
- */
-export function createWellknownSelector(wellknownUrl: string) {
- return createSelector(
- wellknownApi.endpoints.configuration.select(wellknownUrl),
- (result) => result?.data,
- );
-}
diff --git a/packages/davinci-client/tsconfig.json b/packages/davinci-client/tsconfig.json
index 141b4ebf5f8..df687466594 100644
--- a/packages/davinci-client/tsconfig.json
+++ b/packages/davinci-client/tsconfig.json
@@ -23,6 +23,9 @@
{
"path": "../sdk-effects/sdk-request-middleware"
},
+ {
+ "path": "../sdk-effects/store"
+ },
{
"path": "../sdk-effects/oidc"
},
diff --git a/packages/davinci-client/tsconfig.lib.json b/packages/davinci-client/tsconfig.lib.json
index 6c7bbdefeff..6db7b33e75b 100644
--- a/packages/davinci-client/tsconfig.lib.json
+++ b/packages/davinci-client/tsconfig.lib.json
@@ -48,6 +48,9 @@
},
{
"path": "../sdk-effects/logger/tsconfig.lib.json"
+ },
+ {
+ "path": "../sdk-effects/store/tsconfig.lib.json"
}
]
}
diff --git a/packages/journey-client/README.md b/packages/journey-client/README.md
index 910ca9462ee..919deeb07b2 100644
--- a/packages/journey-client/README.md
+++ b/packages/journey-client/README.md
@@ -13,6 +13,7 @@
- [API Reference](#api-reference)
- [Working with Callbacks](#working-with-callbacks)
- [Request Middleware](#request-middleware)
+- [Sharing a Store With Another Client](#sharing-a-store-with-another-client)
- [Error Handling](#error-handling)
- [Building](#building)
- [Testing](#testing)
@@ -116,12 +117,13 @@ const client = await journey({
config: JourneyClientConfig,
requestMiddleware?: RequestMiddleware[],
logger?: { level: LogLevel; custom?: CustomLogger },
+ store?: SdkStore,
});
```
**Returns**: `Promise`
-**Throws**: `Error` if the wellknown URL is invalid, the fetch fails, or the server is not a ForgeRock AM instance.
+**Throws**: `Error` if the wellknown URL is invalid, the fetch fails, or the server is not a ForgeRock AM instance. Throws if the `store` argument is provided but is not a valid `SdkStore` handle.
```typescript
try {
@@ -232,6 +234,50 @@ const client = await journey({
| `JOURNEY_NEXT` | Submitting a step |
| `JOURNEY_TERMINATE` | Terminating the session |
+## Sharing a Store With Another Client
+
+If your application also uses `@forgerock/oidc-client`, the two can share one Redux store so the well-known discovery document is fetched once rather than once per client.
+
+`journey()` exposes the store it created as `client.store`. Pass it to the other client:
+
+```typescript
+import { journey } from '@forgerock/journey-client';
+import { oidc } from '@forgerock/oidc-client';
+
+const journeyClient = await journey({ config });
+
+// Attaches to journey's store; the discovery document is already cached there.
+const oidcClient = await oidc({ config: oidcConfig, store: journeyClient.store });
+```
+
+Or create the store yourself when neither client is the natural owner:
+
+```typescript
+import { createSdkStore } from '@forgerock/sdk-store';
+
+const store = createSdkStore();
+const journeyClient = await journey({ config, store });
+const oidcClient = await oidc({ config: oidcConfig, store });
+```
+
+Omitting `store` is always valid — the client creates its own, which is the default behaviour.
+
+### Middleware and logging stay private
+
+Sharing a store shares cached data, not configuration. `requestMiddleware` and `logger` are registered against the client you pass them to, and are resolved only by that client's own requests:
+
+```typescript
+const store = createSdkStore();
+
+// Runs for JOURNEY_START, JOURNEY_NEXT and JOURNEY_TERMINATE only.
+await journey({ config, store, requestMiddleware: [journeyMiddleware] });
+
+// Runs for OIDC requests only.
+await oidc({ config: oidcConfig, store, requestMiddleware: [oidcMiddleware] });
+```
+
+Middleware passed here will never run against an OIDC token exchange, and vice versa.
+
## Error Handling
The `journey()` factory throws on initialization failure. Use try/catch:
diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md
index 67d98aac9c1..71dcdf414b4 100644
--- a/packages/journey-client/api-report/journey-client.api.md
+++ b/packages/journey-client/api-report/journey-client.api.md
@@ -16,14 +16,13 @@ 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 { makeJourneyConfig } from '@forgerock/sdk-utilities';
import { NameValue } from '@forgerock/sdk-types';
import { PolicyKey } from '@forgerock/sdk-types';
import { PolicyParams } from '@forgerock/sdk-types';
import { PolicyRequirement } from '@forgerock/sdk-types';
import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { SdkStore } from '@forgerock/sdk-store';
import { Step } from '@forgerock/sdk-types';
import { StepDetail } from '@forgerock/sdk-types';
import { StepType } from '@forgerock/sdk-types';
@@ -185,6 +184,7 @@ export function journey(input: {
level: LogLevel;
custom?: CustomLogger;
};
+ store?: SdkStore;
}): Promise;
// @public
@@ -198,6 +198,8 @@ export interface JourneyClient {
// (undocumented)
start: (options?: StartParam) => Promise;
// (undocumented)
+ store: SdkStore;
+ // (undocumented)
subscribe: (listener: () => void) => () => void;
// (undocumented)
terminate: (options?: { query?: Record }) => Promise;
@@ -255,8 +257,6 @@ export class KbaCreateCallback extends BaseCallback {
setQuestion(question: string): void;
}
-export { LegacyServerConfig };
-
export { LogLevel };
export { makeJourneyConfig };
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 f6b1565f2cb..f73b91af616 100644
--- a/packages/journey-client/api-report/journey-client.types.api.md
+++ b/packages/journey-client/api-report/journey-client.types.api.md
@@ -1,491 +1,493 @@
-## 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 { ActionTypes } from '@forgerock/sdk-request-middleware';
-import { AuthResponse } from '@forgerock/sdk-types';
-import { Callback } from '@forgerock/sdk-types';
-import { CallbackType } from '@forgerock/sdk-types';
-import { createWellknownError } from '@forgerock/sdk-utilities';
-import { CustomLogger } from '@forgerock/sdk-logger';
-import { FailedPolicyRequirement } from '@forgerock/sdk-types';
-import { FailureDetail } from '@forgerock/sdk-types';
-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';
-import { PolicyParams } from '@forgerock/sdk-types';
-import { PolicyRequirement } from '@forgerock/sdk-types';
-import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
-import { Step } from '@forgerock/sdk-types';
-import { StepDetail } from '@forgerock/sdk-types';
-import { StepType } from '@forgerock/sdk-types';
-import { WellknownResponse } from '@forgerock/sdk-types';
-
-export { ActionTypes };
-
-// @public
-export class AttributeInputCallback extends BaseCallback {
- constructor(payload: Callback);
- getFailedPolicies(): PolicyRequirement[];
- getName(): string;
- getPolicies(): Record;
- getPrompt(): string;
- isRequired(): boolean;
- // (undocumented)
- payload: Callback;
- setValidateOnly(value: boolean): void;
- setValue(value: T): void;
-}
-
-export { AuthResponse };
-
-// @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;
-}
-
-export { Callback };
-
-// @public (undocumented)
-export type CallbackFactory = (callback: Callback) => BaseCallback;
-
-export { CallbackType };
-
-// @public
-export class ChoiceCallback extends BaseCallback {
- constructor(payload: Callback);
- getChoices(): string[];
- getDefaultChoice(): number;
- getPrompt(): string;
- // (undocumented)
- payload: Callback;
- setChoiceIndex(index: number): void;
- setChoiceValue(value: string): void;
-}
-
-// @public
-export class ConfirmationCallback extends BaseCallback {
- constructor(payload: Callback);
- getDefaultOption(): number;
- getMessageType(): number;
- getOptions(): string[];
- getOptionType(): number;
- getPrompt(): string;
- // (undocumented)
- payload: Callback;
- setOptionIndex(index: number): void;
- setOptionValue(value: string): void;
-}
-
-// @public (undocumented)
-export function createCallback(callback: Callback): BaseCallback;
-
-export { createWellknownError };
-
-export { CustomLogger };
-
-// @public
-export class DeviceProfileCallback extends BaseCallback {
- constructor(payload: Callback);
- getMessage(): string;
- isLocationRequired(): boolean;
- isMetadataRequired(): boolean;
- // (undocumented)
- payload: Callback;
- setProfile(profile: DeviceProfileData): void;
-}
-
-// @public (undocumented)
-export interface DeviceProfileData {
- // (undocumented)
- identifier: string;
- // (undocumented)
- location?: Geolocation_2 | Record;
- // (undocumented)
- metadata?: {
- hardware: {
- display: {
- [key: string]: string | number | null;
- };
- [key: string]: any;
- };
- browser: {
- [key: string]: string | number | null;
- };
- platform: {
- [key: string]: string | number | null;
- };
- };
-}
-
-export { FailedPolicyRequirement };
-
-export { FailureDetail };
-
-export { GenericError };
-
-// @public (undocumented)
-interface Geolocation_2 {
- // (undocumented)
- latitude: number;
- // (undocumented)
- longitude: number;
-}
-export { Geolocation_2 as Geolocation };
-
-// @public
-export class HiddenValueCallback extends BaseCallback {
- constructor(payload: Callback);
- // (undocumented)
- payload: Callback;
-}
-
-// @public (undocumented)
-export interface IdPValue {
- // (undocumented)
- provider: string;
- // (undocumented)
- uiConfig: {
- [key: string]: string;
- };
-}
-
-// @internal
-export interface InternalJourneyClientConfig {
- // (undocumented)
- error?: GenericError;
- // (undocumented)
- serverConfig: ResolvedServerConfig;
-}
-
-export { isValidWellknownUrl };
-
-// @public
-export interface JourneyClient {
- // (undocumented)
- next: (step: JourneyStep, options?: NextOptions) => Promise;
- // (undocumented)
- redirect: (step: JourneyStep) => Promise;
- // (undocumented)
- resume: (url: string, options?: ResumeOptions) => Promise;
- // (undocumented)
- start: (options?: StartParam) => Promise;
- // (undocumented)
- subscribe: (listener: () => void) => () => void;
- // (undocumented)
- terminate: (options?: { query?: Record }) => Promise;
-}
-
-export { JourneyClientConfig };
-
-// @public (undocumented)
-export type JourneyLoginFailure = AuthResponse & {
- type: StepType.LoginFailure;
- payload: Step;
- getCode: () => number;
- getDetail: () => FailureDetail | undefined;
- getMessage: () => string | undefined;
- getProcessedMessage: (messageCreator?: MessageCreator) => ProcessedPropertyError[];
- getReason: () => string | undefined;
-};
-
-// @public (undocumented)
-export type JourneyLoginSuccess = AuthResponse & {
- type: StepType.LoginSuccess;
- payload: Step;
- getRealm: () => string | undefined;
- getSessionToken: () => string | undefined;
- getSuccessUrl: () => string | undefined;
-};
-
-// @public (undocumented)
-export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
-
-export { JourneyServerConfig };
-
-// @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;
-};
-
-// @public
-export class KbaCreateCallback extends BaseCallback {
- constructor(payload: Callback);
- getPredefinedQuestions(): string[];
- getPrompt(): string;
- isAllowedUserDefinedQuestions(): boolean;
- // (undocumented)
- payload: Callback;
- setAnswer(answer: string): void;
- setQuestion(question: string): void;
-}
-
-export { LegacyServerConfig };
-
-export { LogLevel };
-
-// @public (undocumented)
-export interface MessageCreator {
- // (undocumented)
- [key: string]: (
- propertyName: string,
- params?: {
- [key: string]: unknown;
- },
- ) => string;
-}
-
-// @public
-export class MetadataCallback extends BaseCallback {
- constructor(payload: Callback);
- getData(): T;
- // (undocumented)
- payload: Callback;
-}
-
-// @public
-export class NameCallback extends BaseCallback {
- constructor(payload: Callback);
- getPrompt(): string;
- // (undocumented)
- payload: Callback;
- setName(name: string): void;
-}
-
-export { NameValue };
-
-// @public (undocumented)
-export interface NextOptions {
- // (undocumented)
- query?: Record;
-}
-
-// @public
-export class PasswordCallback extends BaseCallback {
- constructor(payload: Callback);
- getFailedPolicies(): PolicyRequirement[];
- getPolicies(): string[];
- getPrompt(): string;
- // (undocumented)
- payload: Callback;
- setPassword(password: string): void;
-}
-
-// @public
-export class PingOneProtectEvaluationCallback extends BaseCallback {
- constructor(payload: Callback);
- getPauseBehavioralData(): boolean;
- // (undocumented)
- payload: Callback;
- setClientError(errorMessage: string): void;
- setData(data: string): void;
-}
-
-// @public
-export class PingOneProtectInitializeCallback extends BaseCallback {
- constructor(payload: Callback);
- getConfig():
- | Record
- | {
- envId: string;
- consoleLogEnabled: boolean;
- deviceAttributesToIgnore: string[];
- customHost: string;
- lazyMetadata: boolean;
- behavioralDataCollection: boolean;
- deviceKeyRsyncIntervals: number;
- enableTrust: boolean;
- disableTags: boolean;
- disableHub: boolean;
- };
- // (undocumented)
- payload: Callback;
- // (undocumented)
- setClientError(errorMessage: string): void;
-}
-
-export { PolicyKey };
-
-export { PolicyParams };
-
-export { PolicyRequirement };
-
-// @public
-export class PollingWaitCallback extends BaseCallback {
- constructor(payload: Callback);
- getMessage(): string;
- getWaitTime(): number;
- // (undocumented)
- payload: Callback;
-}
-
-// @public (undocumented)
-export interface ProcessedPropertyError {
- // (undocumented)
- detail: FailedPolicyRequirement;
- // (undocumented)
- messages: string[];
-}
-
-// @public
-export class ReCaptchaCallback extends BaseCallback {
- constructor(payload: Callback);
- getSiteKey(): string;
- // (undocumented)
- payload: Callback;
- setResult(result: string): void;
-}
-
-// @public
-export class ReCaptchaEnterpriseCallback extends BaseCallback {
- constructor(payload: Callback);
- getApiUrl(): string;
- getElementClass(): string;
- getSiteKey(): string;
- // (undocumented)
- payload: Callback;
- setAction(action: string): void;
- setClientError(error: string): void;
- setPayload(payload: unknown): void;
- setResult(result: string): void;
-}
-
-// @public
-export class RedirectCallback extends BaseCallback {
- constructor(payload: Callback);
- getRedirectUrl(): string;
- // (undocumented)
- payload: Callback;
-}
-
-export { RequestMiddleware };
-
-// @public
-export interface ResolvedServerConfig {
- // (undocumented)
- baseUrl: string;
- // (undocumented)
- paths: {
- authenticate: string;
- sessions: string;
- };
-}
-
-// @public (undocumented)
-export interface ResumeOptions {
- // (undocumented)
- journey?: string;
- // (undocumented)
- query?: Record;
-}
-
-// @public
-export class SelectIdPCallback extends BaseCallback {
- constructor(payload: Callback);
- getProviders(): IdPValue[];
- // (undocumented)
- payload: Callback;
- setProvider(value: string): void;
-}
-
-// @public (undocumented)
-export interface StartParam {
- // (undocumented)
- journey: string;
- // (undocumented)
- query?: Record;
-}
-
-export { Step };
-
-export { StepDetail };
-
-export { StepType };
-
-// @public
-export class SuspendedTextOutputCallback extends TextOutputCallback {
- constructor(payload: Callback);
- // (undocumented)
- payload: Callback;
-}
-
-// @public
-export class TermsAndConditionsCallback extends BaseCallback {
- constructor(payload: Callback);
- getCreateDate(): Date | null;
- getTerms(): string;
- getVersion(): string;
- // (undocumented)
- payload: Callback;
- setAccepted(accepted?: boolean): void;
-}
-
-// @public
-export class TextInputCallback extends BaseCallback {
- constructor(payload: Callback);
- getPrompt(): string;
- // (undocumented)
- payload: Callback;
- setInput(input: string): void;
-}
-
-// @public
-export class TextOutputCallback extends BaseCallback {
- constructor(payload: Callback);
- getMessage(): string;
- getMessageType(): string;
- // (undocumented)
- payload: Callback;
-}
-
-// @public
-export class ValidatedCreatePasswordCallback extends BaseCallback {
- constructor(payload: Callback);
- getFailedPolicies(): PolicyRequirement[];
- getPolicies(): Record;
- getPrompt(): string;
- isRequired(): boolean;
- // (undocumented)
- payload: Callback;
- setPassword(password: string): void;
- setValidateOnly(value: boolean): void;
-}
-
-// @public
-export class ValidatedCreateUsernameCallback extends BaseCallback {
- constructor(payload: Callback);
- getFailedPolicies(): PolicyRequirement[];
- getPolicies(): Record;
- getPrompt(): string;
- isRequired(): boolean;
- // (undocumented)
- payload: Callback;
- setName(name: string): void;
- setValidateOnly(value: boolean): void;
-}
-
-export { WellknownResponse };
-
-// (No @packageDocumentation comment for this package)
-```
+## 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 { ActionTypes } from '@forgerock/sdk-request-middleware';
+import { AuthResponse } from '@forgerock/sdk-types';
+import { Callback } from '@forgerock/sdk-types';
+import { CallbackType } from '@forgerock/sdk-types';
+import { createWellknownError } from '@forgerock/sdk-utilities';
+import { CustomLogger } from '@forgerock/sdk-logger';
+import { FailedPolicyRequirement } from '@forgerock/sdk-types';
+import { FailureDetail } from '@forgerock/sdk-types';
+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';
+import { PolicyParams } from '@forgerock/sdk-types';
+import { PolicyRequirement } from '@forgerock/sdk-types';
+import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { SdkStore } from '@forgerock/sdk-store';
+import { Step } from '@forgerock/sdk-types';
+import { StepDetail } from '@forgerock/sdk-types';
+import { StepType } from '@forgerock/sdk-types';
+import { WellknownResponse } from '@forgerock/sdk-types';
+
+export { ActionTypes }
+
+// @public
+export class AttributeInputCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getFailedPolicies(): PolicyRequirement[];
+ getName(): string;
+ getPolicies(): Record;
+ getPrompt(): string;
+ isRequired(): boolean;
+ // (undocumented)
+ payload: Callback;
+ setValidateOnly(value: boolean): void;
+ setValue(value: T): void;
+}
+
+export { AuthResponse }
+
+// @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;
+}
+
+export { Callback }
+
+// @public (undocumented)
+export type CallbackFactory = (callback: Callback) => BaseCallback;
+
+export { CallbackType }
+
+// @public
+export class ChoiceCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getChoices(): string[];
+ getDefaultChoice(): number;
+ getPrompt(): string;
+ // (undocumented)
+ payload: Callback;
+ setChoiceIndex(index: number): void;
+ setChoiceValue(value: string): void;
+}
+
+// @public
+export class ConfirmationCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getDefaultOption(): number;
+ getMessageType(): number;
+ getOptions(): string[];
+ getOptionType(): number;
+ getPrompt(): string;
+ // (undocumented)
+ payload: Callback;
+ setOptionIndex(index: number): void;
+ setOptionValue(value: string): void;
+}
+
+// @public (undocumented)
+export function createCallback(callback: Callback): BaseCallback;
+
+export { createWellknownError }
+
+export { CustomLogger }
+
+// @public
+export class DeviceProfileCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getMessage(): string;
+ isLocationRequired(): boolean;
+ isMetadataRequired(): boolean;
+ // (undocumented)
+ payload: Callback;
+ setProfile(profile: DeviceProfileData): void;
+}
+
+// @public (undocumented)
+export interface DeviceProfileData {
+ // (undocumented)
+ identifier: string;
+ // (undocumented)
+ location?: Geolocation_2 | Record;
+ // (undocumented)
+ metadata?: {
+ hardware: {
+ display: {
+ [key: string]: string | number | null;
+ };
+ [key: string]: any;
+ };
+ browser: {
+ [key: string]: string | number | null;
+ };
+ platform: {
+ [key: string]: string | number | null;
+ };
+ };
+}
+
+export { FailedPolicyRequirement }
+
+export { FailureDetail }
+
+export { GenericError }
+
+// @public (undocumented)
+interface Geolocation_2 {
+ // (undocumented)
+ latitude: number;
+ // (undocumented)
+ longitude: number;
+}
+export { Geolocation_2 as Geolocation }
+
+// @public
+export class HiddenValueCallback extends BaseCallback {
+ constructor(payload: Callback);
+ // (undocumented)
+ payload: Callback;
+}
+
+// @public (undocumented)
+export interface IdPValue {
+ // (undocumented)
+ provider: string;
+ // (undocumented)
+ uiConfig: {
+ [key: string]: string;
+ };
+}
+
+// @internal
+export interface InternalJourneyClientConfig {
+ // (undocumented)
+ error?: GenericError;
+ // (undocumented)
+ serverConfig: ResolvedServerConfig;
+}
+
+export { isValidWellknownUrl }
+
+// @public
+export interface JourneyClient {
+ // (undocumented)
+ next: (step: JourneyStep, options?: NextOptions) => Promise;
+ // (undocumented)
+ redirect: (step: JourneyStep) => Promise;
+ // (undocumented)
+ resume: (url: string, options?: ResumeOptions) => Promise;
+ // (undocumented)
+ start: (options?: StartParam) => Promise;
+ // (undocumented)
+ store: SdkStore;
+ // (undocumented)
+ subscribe: (listener: () => void) => () => void;
+ // (undocumented)
+ terminate: (options?: {
+ query?: Record;
+ }) => Promise;
+}
+
+export { JourneyClientConfig }
+
+// @public (undocumented)
+export type JourneyLoginFailure = AuthResponse & {
+ type: StepType.LoginFailure;
+ payload: Step;
+ getCode: () => number;
+ getDetail: () => FailureDetail | undefined;
+ getMessage: () => string | undefined;
+ getProcessedMessage: (messageCreator?: MessageCreator) => ProcessedPropertyError[];
+ getReason: () => string | undefined;
+};
+
+// @public (undocumented)
+export type JourneyLoginSuccess = AuthResponse & {
+ type: StepType.LoginSuccess;
+ payload: Step;
+ getRealm: () => string | undefined;
+ getSessionToken: () => string | undefined;
+ getSuccessUrl: () => string | undefined;
+};
+
+// @public (undocumented)
+export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
+
+export { JourneyServerConfig }
+
+// @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;
+};
+
+// @public
+export class KbaCreateCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getPredefinedQuestions(): string[];
+ getPrompt(): string;
+ isAllowedUserDefinedQuestions(): boolean;
+ // (undocumented)
+ payload: Callback;
+ setAnswer(answer: string): void;
+ setQuestion(question: string): void;
+}
+
+export { LegacyServerConfig }
+
+export { LogLevel }
+
+// @public (undocumented)
+export interface MessageCreator {
+ // (undocumented)
+ [key: string]: (propertyName: string, params?: {
+ [key: string]: unknown;
+ }) => string;
+}
+
+// @public
+export class MetadataCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getData(): T;
+ // (undocumented)
+ payload: Callback;
+}
+
+// @public
+export class NameCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getPrompt(): string;
+ // (undocumented)
+ payload: Callback;
+ setName(name: string): void;
+}
+
+export { NameValue }
+
+// @public (undocumented)
+export interface NextOptions {
+ // (undocumented)
+ query?: Record;
+}
+
+// @public
+export class PasswordCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getFailedPolicies(): PolicyRequirement[];
+ getPolicies(): string[];
+ getPrompt(): string;
+ // (undocumented)
+ payload: Callback;
+ setPassword(password: string): void;
+}
+
+// @public
+export class PingOneProtectEvaluationCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getPauseBehavioralData(): boolean;
+ // (undocumented)
+ payload: Callback;
+ setClientError(errorMessage: string): void;
+ setData(data: string): void;
+}
+
+// @public
+export class PingOneProtectInitializeCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getConfig(): Record | {
+ envId: string;
+ consoleLogEnabled: boolean;
+ deviceAttributesToIgnore: string[];
+ customHost: string;
+ lazyMetadata: boolean;
+ behavioralDataCollection: boolean;
+ deviceKeyRsyncIntervals: number;
+ enableTrust: boolean;
+ disableTags: boolean;
+ disableHub: boolean;
+ };
+ // (undocumented)
+ payload: Callback;
+ // (undocumented)
+ setClientError(errorMessage: string): void;
+}
+
+export { PolicyKey }
+
+export { PolicyParams }
+
+export { PolicyRequirement }
+
+// @public
+export class PollingWaitCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getMessage(): string;
+ getWaitTime(): number;
+ // (undocumented)
+ payload: Callback;
+}
+
+// @public (undocumented)
+export interface ProcessedPropertyError {
+ // (undocumented)
+ detail: FailedPolicyRequirement;
+ // (undocumented)
+ messages: string[];
+}
+
+// @public
+export class ReCaptchaCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getSiteKey(): string;
+ // (undocumented)
+ payload: Callback;
+ setResult(result: string): void;
+}
+
+// @public
+export class ReCaptchaEnterpriseCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getApiUrl(): string;
+ getElementClass(): string;
+ getSiteKey(): string;
+ // (undocumented)
+ payload: Callback;
+ setAction(action: string): void;
+ setClientError(error: string): void;
+ setPayload(payload: unknown): void;
+ setResult(result: string): void;
+}
+
+// @public
+export class RedirectCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getRedirectUrl(): string;
+ // (undocumented)
+ payload: Callback;
+}
+
+export { RequestMiddleware }
+
+// @public
+export interface ResolvedServerConfig {
+ // (undocumented)
+ baseUrl: string;
+ // (undocumented)
+ paths: {
+ authenticate: string;
+ sessions: string;
+ };
+}
+
+// @public (undocumented)
+export interface ResumeOptions {
+ // (undocumented)
+ journey?: string;
+ // (undocumented)
+ query?: Record;
+}
+
+// @public
+export class SelectIdPCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getProviders(): IdPValue[];
+ // (undocumented)
+ payload: Callback;
+ setProvider(value: string): void;
+}
+
+// @public (undocumented)
+export interface StartParam {
+ // (undocumented)
+ journey: string;
+ // (undocumented)
+ query?: Record;
+}
+
+export { Step }
+
+export { StepDetail }
+
+export { StepType }
+
+// @public
+export class SuspendedTextOutputCallback extends TextOutputCallback {
+ constructor(payload: Callback);
+ // (undocumented)
+ payload: Callback;
+}
+
+// @public
+export class TermsAndConditionsCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getCreateDate(): Date | null;
+ getTerms(): string;
+ getVersion(): string;
+ // (undocumented)
+ payload: Callback;
+ setAccepted(accepted?: boolean): void;
+}
+
+// @public
+export class TextInputCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getPrompt(): string;
+ // (undocumented)
+ payload: Callback;
+ setInput(input: string): void;
+}
+
+// @public
+export class TextOutputCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getMessage(): string;
+ getMessageType(): string;
+ // (undocumented)
+ payload: Callback;
+}
+
+// @public
+export class ValidatedCreatePasswordCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getFailedPolicies(): PolicyRequirement[];
+ getPolicies(): Record;
+ getPrompt(): string;
+ isRequired(): boolean;
+ // (undocumented)
+ payload: Callback;
+ setPassword(password: string): void;
+ setValidateOnly(value: boolean): void;
+}
+
+// @public
+export class ValidatedCreateUsernameCallback extends BaseCallback {
+ constructor(payload: Callback);
+ getFailedPolicies(): PolicyRequirement[];
+ getPolicies(): Record;
+ getPrompt(): string;
+ isRequired(): boolean;
+ // (undocumented)
+ payload: Callback;
+ setName(name: string): void;
+ setValidateOnly(value: boolean): void;
+}
+
+export { WellknownResponse }
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/packages/journey-client/package.json b/packages/journey-client/package.json
index ff2e9148304..0a692a57499 100644
--- a/packages/journey-client/package.json
+++ b/packages/journey-client/package.json
@@ -34,8 +34,8 @@
},
"dependencies": {
"@forgerock/sdk-logger": "workspace:*",
- "@forgerock/sdk-oidc": "workspace:*",
"@forgerock/sdk-request-middleware": "workspace:*",
+ "@forgerock/sdk-store": "workspace:*",
"@forgerock/sdk-types": "workspace:*",
"@forgerock/sdk-utilities": "workspace:*",
"@forgerock/storage": "workspace:*",
diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts
index 98cfa6a9efa..f063e9447b9 100644
--- a/packages/journey-client/src/lib/client.store.test.ts
+++ b/packages/journey-client/src/lib/client.store.test.ts
@@ -6,15 +6,15 @@
* of the MIT license. See the LICENSE file for details.
*/
-import { makeJourneyConfig } from '@forgerock/sdk-utilities';
import { afterEach, describe, expect, test, vi } from 'vitest';
-import { callbackType } from '../index.js';
-import { journey } from './client.store.js';
+import { journey, type JourneyClient } from './client.store.js';
+import { makeJourneyConfig } from '@forgerock/sdk-utilities';
import { createJourneyStep } from './step.utils.js';
-import type { GenericError, Step, WellknownResponse } from '../index.js';
-import type { JourneyClientConfig } from './config.types.js';
+import { callbackType, type GenericError, type Step, type WellknownResponse } from '../index.js';
+
+import { JourneyClientConfig } from './config.types.js';
/**
* Type guard to check if a result is a GenericError
@@ -108,7 +108,7 @@ describe('journey-client', () => {
test('journey_WellknownConfig_ReturnsClientWithAllMethods', async () => {
setupMockFetch();
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
expect(client.start).toBeInstanceOf(Function);
expect(client.next).toBeInstanceOf(Function);
@@ -142,7 +142,7 @@ describe('journey-client', () => {
const mockStepResponse: Step = { authId: 'test-auth-id', callbacks: [] };
setupMockFetch(mockStepResponse);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const step = await client.start();
expect(step).toBeDefined();
@@ -169,7 +169,7 @@ describe('journey-client', () => {
};
setupMockFetch(failurePayload, 401);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const result = await client.start();
expect(result).toBeDefined();
@@ -207,7 +207,7 @@ describe('journey-client', () => {
};
setupMockFetch(nextStepPayload);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const nextStep = await client.next(initialStep, {});
expect(nextStep).toBeDefined();
@@ -237,7 +237,7 @@ describe('journey-client', () => {
};
setupMockFetch(failurePayload, 401);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const result = await client.next(initialStep, {});
expect(result).toBeDefined();
@@ -267,7 +267,7 @@ describe('journey-client', () => {
vi.stubGlobal('window', { location: { assign: assignMock } });
setupMockFetch();
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
await client.redirect(step);
expect(mockStorageInstance.set).toHaveBeenCalledWith({ step: step.payload });
@@ -285,7 +285,7 @@ describe('journey-client', () => {
const nextStepPayload: Step = { authId: 'test-auth-id', callbacks: [] };
setupMockFetch(nextStepPayload);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const resumeUrl = 'https://app.com/callback?code=123&state=abc';
const step = await client.resume(resumeUrl, {});
@@ -314,7 +314,7 @@ describe('journey-client', () => {
const nextStepPayload: Step = { authId: 'test-auth-id', callbacks: [] };
setupMockFetch(nextStepPayload);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const resumeUrl =
'https://app.com/callback?code=123&state=abc&error=access_denied&errorCode=E1&errorMessage=oops&form_post_entry=fp&nonce=n1&RelayState=rs&responsekey=rk&scope=openid&suspendedId=s1';
await client.resume(resumeUrl, {});
@@ -342,7 +342,7 @@ describe('journey-client', () => {
const nextStepPayload: Step = { authId: 'test-auth-id', callbacks: [] };
setupMockFetch(nextStepPayload);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const resumeUrl = 'https://app.com/callback?code=123&state=abc';
await client.resume(resumeUrl, { query: { code: 'override' } });
@@ -363,7 +363,7 @@ describe('journey-client', () => {
const nextStepPayload: Step = { authId: 'test-auth-id', callbacks: [] };
setupMockFetch(nextStepPayload);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const resumeUrl = 'https://app.com/callback?code=123&state=abc';
const step = await client.resume(resumeUrl, {});
@@ -384,7 +384,7 @@ describe('journey-client', () => {
mockStorageInstance.get.mockResolvedValue(undefined);
setupMockFetch();
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const resumeUrl = 'https://app.com/callback?code=123&state=abc';
await expect(client.resume(resumeUrl)).rejects.toThrow(
@@ -399,7 +399,7 @@ describe('journey-client', () => {
const mockStepResponse: Step = { authId: 'test-auth-id', callbacks: [] };
setupMockFetch(mockStepResponse);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const resumeUrl = 'https://app.com/callback?foo=bar';
const step = await client.resume(resumeUrl, {});
@@ -419,7 +419,7 @@ describe('journey-client', () => {
test('start_NoDataFromServer_ReturnsGenericError', async () => {
setupMockFetch(null);
- const client = await journey({ config: mockConfig });
+ const client = (await journey({ config: mockConfig })) as JourneyClient;
const result = await client.start();
expect(isGenericError(result)).toBe(true);
@@ -453,7 +453,7 @@ describe('journey-client', () => {
return Promise.resolve(new Response(JSON.stringify(mockStepResponse)));
});
- const client = await journey({ config: localhostConfig });
+ const client = (await journey({ config: localhostConfig })) as JourneyClient;
await client.start();
expect(mockFetch).toHaveBeenCalledTimes(2);
@@ -553,7 +553,7 @@ describe('journey-client', () => {
return Promise.resolve(new Response(JSON.stringify(mockStepResponse)));
});
- const client = await journey({ config: alphaConfig });
+ const client = (await journey({ config: alphaConfig })) as JourneyClient;
await client.start();
const request = mockFetch.mock.calls[1][0] as Request;
@@ -581,9 +581,9 @@ describe('journey-client', () => {
test('journey_BaseUrl_NoRealmPath_UsesRootAuthenticate', async () => {
setupBaseUrlFetch();
- const client = await journey({
+ const client = (await journey({
config: { serverConfig: { baseUrl } },
- });
+ })) as JourneyClient;
await client.start();
expect(mockFetch).toHaveBeenCalledTimes(1);
@@ -594,9 +594,9 @@ describe('journey-client', () => {
test('journey_BaseUrl_WithRealmPath_UsesRealmAuthenticate', async () => {
setupBaseUrlFetch();
- const client = await journey({
+ const client = (await journey({
config: { serverConfig: { baseUrl }, realmPath: 'alpha' },
- });
+ })) as JourneyClient;
await client.start();
expect(mockFetch).toHaveBeenCalledTimes(1);
@@ -609,9 +609,9 @@ describe('journey-client', () => {
test('journey_BaseUrl_TrailingSlashInput_NormalizesCorrectly', async () => {
setupBaseUrlFetch();
- const client = await journey({
+ const client = (await journey({
config: { serverConfig: { baseUrl: `${baseUrl}/` } },
- });
+ })) as JourneyClient;
await client.start();
const request = mockFetch.mock.calls[0][0] as Request;
@@ -621,9 +621,9 @@ describe('journey-client', () => {
test('journey_BaseUrl_Terminate_UsesRootSessionsUrl', async () => {
setupBaseUrlFetch();
- const client = await journey({
+ const client = (await journey({
config: { serverConfig: { baseUrl } },
- });
+ })) as JourneyClient;
await client.terminate();
expect(mockFetch).toHaveBeenCalledTimes(1);
@@ -636,9 +636,9 @@ describe('journey-client', () => {
test('journey_BaseUrl_WithRealmPath_Terminate_UsesRealmSessionsUrl', async () => {
setupBaseUrlFetch();
- const client = await journey({
+ const client = (await journey({
config: { serverConfig: { baseUrl }, realmPath: 'alpha' },
- });
+ })) as JourneyClient;
await client.terminate();
expect(mockFetch).toHaveBeenCalledTimes(1);
diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts
index 0f26e34d294..393535cd7e5 100644
--- a/packages/journey-client/src/lib/client.store.ts
+++ b/packages/journey-client/src/lib/client.store.ts
@@ -16,25 +16,27 @@ import {
import { createStorage } from '@forgerock/storage';
import * as Either from 'effect/Either';
+import type { GenericError } from '@forgerock/sdk-types';
+import type { SdkStore } from '@forgerock/sdk-store';
+import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { Step } from '@forgerock/sdk-types';
+import type { CustomLogger, LogLevel } from '@forgerock/sdk-logger';
+
import { createJourneyStore } from './client.store.utils.js';
import { configSlice } from './config.slice.js';
import { journeyApi } from './journey.api.js';
import { createJourneyObject, parseJourneyResponse } from './journey.utils.js';
-import { wellknownApi } from './wellknown.api.js';
-
-import type { CustomLogger, LogLevel } from '@forgerock/sdk-logger';
-import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
-import type { GenericError } from '@forgerock/sdk-types';
-import type { Step } from '@forgerock/sdk-types';
+import type { JourneyResult } from './journey.utils.js';
+import { wellknownApi, assertValidStore, getClientForReducerPath } from '@forgerock/sdk-store';
import type { RedirectCallback } from './callbacks/redirect-callback.js';
import type { JourneyClientConfig } from './config.types.js';
import type { NextOptions, ResumeOptions, StartParam } from './interfaces.js';
-import type { JourneyResult } from './journey.utils.js';
import type { JourneyStep } from './step.utils.js';
/** The journey client instance returned by the `journey()` function. */
export interface JourneyClient {
+ store: SdkStore;
subscribe: (listener: () => void) => () => void;
start: (options?: StartParam) => Promise;
next: (step: JourneyStep, options?: NextOptions) => Promise;
@@ -76,6 +78,7 @@ export async function journey({
config,
requestMiddleware,
logger,
+ store: sharedStore,
}: {
config: JourneyClientConfig;
requestMiddleware?: RequestMiddleware[];
@@ -83,7 +86,12 @@ export async function journey({
level: LogLevel;
custom?: CustomLogger;
};
-}): Promise {
+ /**
+ * An existing SDK store to attach to, so discovery caching and state are
+ * shared with another client. Omit to create a store for this client alone.
+ */
+ store?: unknown;
+}): Promise {
const log = loggerFn({
level: logger?.level ?? config.log ?? 'error',
custom: logger?.custom,
@@ -115,7 +123,24 @@ export async function journey({
);
}
- const store = createJourneyStore({ requestMiddleware, logger: log });
+ const storeError = assertValidStore(sharedStore);
+ if (storeError) return storeError;
+
+ const validStore = sharedStore as SdkStore | undefined;
+
+ if (validStore) {
+ const existing = getClientForReducerPath(validStore, journeyApi.reducerPath);
+ if (existing) {
+ return {
+ error:
+ 'This store already has a journey client attached. Use a separate store per journey client.',
+ type: 'argument_error' as const,
+ };
+ }
+ }
+
+ const handle = createJourneyStore({ requestMiddleware, logger: log, store: validStore });
+ const store = handle.store;
if ('baseUrl' in config.serverConfig) {
const { baseUrl } = config.serverConfig;
@@ -182,6 +207,7 @@ export async function journey({
});
const self: JourneyClient = {
+ store: handle as SdkStore,
subscribe: store.subscribe,
start: async (options?: StartParam) => {
diff --git a/packages/journey-client/src/lib/client.store.utils.ts b/packages/journey-client/src/lib/client.store.utils.ts
index 6439d9a4a5a..c9a939caec9 100644
--- a/packages/journey-client/src/lib/client.store.utils.ts
+++ b/packages/journey-client/src/lib/client.store.utils.ts
@@ -5,43 +5,49 @@
* of the MIT license. See the LICENSE file for details.
*/
-import { combineReducers, configureStore } from '@reduxjs/toolkit';
+import { logger as loggerFn } from '@forgerock/sdk-logger';
+import { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
+
+import { combineSlices } from '@reduxjs/toolkit';
import { configSlice } from './config.slice.js';
import { journeyApi } from './journey.api.js';
-import { wellknownApi } from './wellknown.api.js';
+import { createSdkStore, injectClient, wellknownApi } from '@forgerock/sdk-store';
-import type { logger as loggerFn } from '@forgerock/sdk-logger';
-import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { SdkStore, SdkStoreHandle } from '@forgerock/sdk-store';
-const rootReducer = combineReducers({
- [journeyApi.reducerPath]: journeyApi.reducer,
- [configSlice.name]: configSlice.reducer,
- [wellknownApi.reducerPath]: wellknownApi.reducer,
-});
+/**
+ * The canonical description of the state this client contributes.
+ *
+ * The runtime store is assembled by `injectClient`, which TypeScript cannot
+ * follow across lazy injection. Combining the same slices here lets the state
+ * type be *derived* from them rather than hand-written, so it cannot drift from
+ * what is actually mounted. Exported so the derived state type resolves for
+ * consumers, and so an application can compose the reducer itself if it wants.
+ */
+export const rootReducer = combineSlices(journeyApi, configSlice, wellknownApi);
+
+export type RootState = ReturnType;
+/**
+ * Creates, or attaches to, the store backing a Journey client.
+ *
+ * Passing `store` attaches to an existing SDK store so that discovery caching
+ * and state are shared; omitting it creates one, which is the default.
+ */
export const createJourneyStore = ({
requestMiddleware,
logger,
+ store,
}: {
requestMiddleware?: RequestMiddleware[];
logger?: ReturnType;
-}) => {
- return configureStore({
- reducer: rootReducer,
- middleware: (getDefaultMiddleware) =>
- getDefaultMiddleware({
- serializableCheck: true,
- thunk: {
- extraArgument: {
- requestMiddleware,
- logger,
- },
- },
- })
- .concat(journeyApi.middleware)
- .concat(wellknownApi.middleware),
+ store?: SdkStore;
+}): SdkStoreHandle =>
+ injectClient(store ?? createSdkStore(), {
+ api: journeyApi,
+ reducerPath: journeyApi.reducerPath,
+ slices: [configSlice],
+ requestMiddleware,
+ logger,
});
-};
-
-export type RootState = ReturnType;
diff --git a/packages/journey-client/src/lib/journey.api.ts b/packages/journey-client/src/lib/journey.api.ts
index 5ce1ce09c0b..aee917b463a 100644
--- a/packages/journey-client/src/lib/journey.api.ts
+++ b/packages/journey-client/src/lib/journey.api.ts
@@ -8,10 +8,11 @@
import { initQuery } from '@forgerock/sdk-request-middleware';
import { getEndpointPath, REQUESTED_WITH, resolve, stringify } from '@forgerock/sdk-utilities';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
+import { logger as loggerFn } from '@forgerock/sdk-logger';
+import { clientExtra } from '@forgerock/sdk-store';
-import type { logger as loggerFn } from '@forgerock/sdk-logger';
-import type { RequestMiddleware } from '@forgerock/sdk-request-middleware';
import type { Step } from '@forgerock/sdk-types';
+import type { RequestMiddleware } from '@forgerock/sdk-request-middleware';
import type {
BaseQueryApi,
BaseQueryFn,
@@ -84,13 +85,37 @@ function configureSessionRequest(): RequestInit {
return init;
}
+const JOURNEY_REDUCER_PATH = 'journeyReducer';
+
+/**
+ * This client's private slot on the store's `extraArgument`.
+ *
+ * Optional because a shared store may not have had a journey slot registered
+ * yet; `journeyExtra` substitutes safe defaults.
+ */
interface Extras {
- requestMiddleware: RequestMiddleware[];
- logger: ReturnType;
+ requestMiddleware?: RequestMiddleware[];
+ logger?: ReturnType;
+}
+
+/** Fallback so a missing slot degrades to error-level logging, never a crash. */
+const fallbackLogger = loggerFn({ level: 'error' });
+
+/**
+ * Resolves this client's own middleware and logger.
+ *
+ * Reads only the `journeyReducer` slot — never a store-wide value, which on a
+ * shared store would belong to whichever client created it.
+ */
+function journeyExtra(extra: unknown): Required {
+ return clientExtra(extra, JOURNEY_REDUCER_PATH, {
+ requestMiddleware: [],
+ logger: fallbackLogger,
+ });
}
export const journeyApi = createApi({
- reducerPath: 'journeyReducer',
+ reducerPath: JOURNEY_REDUCER_PATH,
baseQuery: fetchBaseQuery({
baseUrl: '/',
prepareHeaders: (headers: Headers) => {
@@ -121,7 +146,7 @@ export const journeyApi = createApi({
const url = constructUrl(serverConfig, options?.journey, query);
const request = configureRequest();
- const { requestMiddleware } = api.extra as Extras;
+ const { requestMiddleware } = journeyExtra(api.extra);
const response = await initQuery({ ...request, url: url }, 'begin', {
type: 'service',
@@ -153,7 +178,7 @@ export const journeyApi = createApi({
const url = constructUrl(serverConfig, undefined, query);
const request = configureRequest(step);
- const { requestMiddleware } = api.extra as Extras;
+ const { requestMiddleware } = journeyExtra(api.extra);
const response = await initQuery({ ...request, url }, 'continue')
.applyMiddleware(requestMiddleware)
@@ -183,7 +208,7 @@ export const journeyApi = createApi({
const url = constructSessionsUrl(serverConfig, query);
const request = configureSessionRequest();
- const { requestMiddleware } = api.extra as Extras;
+ const { requestMiddleware } = journeyExtra(api.extra);
const response = await initQuery({ ...request, url }, 'terminate')
.applyMiddleware(requestMiddleware)
diff --git a/packages/journey-client/src/lib/store-shape.test.ts b/packages/journey-client/src/lib/store-shape.test.ts
new file mode 100644
index 00000000000..d2ea5926676
--- /dev/null
+++ b/packages/journey-client/src/lib/store-shape.test.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+
+import { createJourneyStore } from './client.store.utils.js';
+
+/**
+ * `combineSlices` keys each reducer off `slice.reducerPath ?? slice.name`, where
+ * the previous `combineReducers({ ... })` form spelled the keys out literally.
+ * That makes the published state shape an implicit consequence of slice
+ * metadata: renaming `configSlice.name` would silently reshape the store.
+ *
+ * These assertions pin the shape so such a rename fails loudly here instead of
+ * in a consumer's selectors.
+ */
+describe('journey store shape', () => {
+ it('exposes exactly the expected top-level state keys', () => {
+ // Arrange
+ const { store } = createJourneyStore({});
+
+ // Act
+ const keys = Object.keys(store.getState()).sort();
+
+ // Assert
+ expect(keys).toEqual(['config', 'journeyReducer', 'wellknown']);
+ });
+
+ it('registers this client\u2019s slot on the store extra, keyed by reducerPath', async () => {
+ // Arrange
+ const { store } = createJourneyStore({});
+ let observed: unknown;
+
+ // Act — a thunk is the supported way to observe extraArgument
+ await store.dispatch(((_dispatch: unknown, _getState: unknown, extra: unknown) => {
+ observed = extra;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any);
+
+ // Assert
+ expect(observed).toHaveProperty('clients.journeyReducer');
+ });
+});
diff --git a/packages/journey-client/src/lib/wellknown.api.ts b/packages/journey-client/src/lib/wellknown.api.ts
deleted file mode 100644
index ad89ff6cd68..00000000000
--- a/packages/journey-client/src/lib/wellknown.api.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * 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 { initWellknownQuery } from '@forgerock/sdk-oidc';
-import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
-
-import type { WellknownResponse } from '@forgerock/sdk-types';
-import type {
- FetchBaseQueryError,
- FetchBaseQueryMeta,
- QueryReturnValue,
-} from '@reduxjs/toolkit/query';
-
-/**
- * RTK Query API for well-known endpoint discovery.
- *
- * Uses the `initWellknownQuery` builder pattern from `@forgerock/sdk-oidc`.
- * The builder constructs the request and validates the response;
- * `fetchBaseQuery` handles the HTTP transport through RTK Query's pipeline.
- */
-export const wellknownApi = createApi({
- reducerPath: 'wellknown',
- baseQuery: fetchBaseQuery(),
- endpoints: (builder) => ({
- configuration: builder.query({
- queryFn: async (url, _api, _extra, baseQuery) => {
- const result = await initWellknownQuery(url).applyQuery(async (req) => {
- const queryResult = await baseQuery(req);
- return queryResult as QueryReturnValue;
- });
- return result as QueryReturnValue<
- WellknownResponse,
- FetchBaseQueryError,
- FetchBaseQueryMeta
- >;
- },
- }),
- }),
-});
diff --git a/packages/journey-client/tsconfig.lib.json b/packages/journey-client/tsconfig.lib.json
index ca3f899b8dc..573c9f707ea 100644
--- a/packages/journey-client/tsconfig.lib.json
+++ b/packages/journey-client/tsconfig.lib.json
@@ -28,10 +28,10 @@
"path": "../sdk-types/tsconfig.lib.json"
},
{
- "path": "../sdk-effects/sdk-request-middleware/tsconfig.lib.json"
+ "path": "../sdk-effects/store/tsconfig.lib.json"
},
{
- "path": "../sdk-effects/oidc/tsconfig.lib.json"
+ "path": "../sdk-effects/sdk-request-middleware/tsconfig.lib.json"
},
{
"path": "../sdk-effects/logger/tsconfig.lib.json"
diff --git a/packages/oidc-client/README.md b/packages/oidc-client/README.md
index 4119d84729e..27bea7eb711 100644
--- a/packages/oidc-client/README.md
+++ b/packages/oidc-client/README.md
@@ -10,6 +10,7 @@ The oidc module follows the [OIDC](https://openid.net/specs/openid-connect-core-
- [Initialization](#initialization)
- [Configuration Options](#configuration-options)
- [Quick Start](#quick-start)
+- [Sharing a Store With Another Client](#sharing-a-store-with-another-client)
- [API Reference](#api-reference)
- [authorize](#authorize)
- [token](#token)
@@ -54,11 +55,17 @@ The `oidc()` initialization function accepts the following configuration:
- **wellknown** (required) - URL to the OIDC provider's well-known configuration endpoint
- **clientId** (required) - Your application's client ID registered with the OIDC provider
- **redirectUri** (required) - The URI where the OIDC provider will redirect after authentication
-- **scope** (required) - Space-separated list of requested scopes (e.g., `'openid profile email'`)
+- **scope** (optional, default: `'openid'`) - Space-separated list of requested scopes (e.g., `'openid profile email'`)
- **storage** (optional) - Storage configuration for tokens (defaults to localStorage)
- **timeout** (optional) - Request timeout in milliseconds
- **additionalParameters** (optional) - Additional parameters to include in authorization requests
+The `oidc()` function also accepts:
+
+- **requestMiddleware** (optional) - Middleware applied to this client's requests only
+- **logger** (optional) - Log level and custom logger for this client only
+- **store** (optional) - An existing SDK store to attach to. See [Sharing a Store](#sharing-a-store-with-another-client)
+
## Quick Start
Here's a minimal example to get started:
@@ -82,6 +89,65 @@ const user = await oidcClient.user.info();
await oidcClient.user.logout();
```
+## Sharing a Store With Another Client
+
+If your application also uses `@forgerock/davinci-client` or `@forgerock/journey-client`, the clients can share one Redux store. The well-known discovery document is then fetched once rather than once per client.
+
+Pass the other client's `store` handle:
+
+```js
+import { davinci } from '@forgerock/davinci-client';
+import { oidc } from '@forgerock/oidc-client';
+
+const davinciClient = await davinci({ config: davinciConfig });
+
+// Attaches to davinci's store; the discovery document is already cached there.
+const oidcClient = await oidc({ config: oidcConfig, store: davinciClient.store });
+```
+
+Or create the store yourself when neither client is the natural owner:
+
+```js
+import { createSdkStore } from '@forgerock/sdk-store';
+
+const store = createSdkStore();
+const davinciClient = await davinci({ config: davinciConfig, store });
+const oidcClient = await oidc({ config: oidcConfig, store });
+```
+
+Omitting `store` is always valid — the client creates its own, which is the default behaviour.
+
+### Middleware and logging stay private
+
+Sharing a store shares cached data, not configuration. Your `requestMiddleware` and `logger` are registered against this client alone and are only applied to OIDC requests:
+
+```js
+const oidcClient = await oidc({
+ config,
+ store,
+ // Runs for AUTHORIZE, PAR, TOKEN_EXCHANGE, REVOKE, USER_INFO and END_SESSION only.
+ requestMiddleware: [myOidcMiddleware],
+ logger: { level: 'debug' },
+});
+```
+
+Middleware passed to `davinci()` or `journey()` will never run against an OIDC token exchange, and middleware passed here will never run against their requests.
+
+### One OIDC client per store
+
+`oidc()` mounts at a fixed key in the store, so two OIDC clients sharing one store would overwrite each other's token state. Initialising a second client with a different `clientId` returns an `argument_error`:
+
+```js
+const store = createSdkStore();
+await oidc({ config: { ...config, clientId: 'app-one' }, store });
+
+const second = await oidc({ config: { ...config, clientId: 'app-two' }, store });
+// { error: "This store is already in use by an OIDC client with clientId 'app-one'. ...",
+// type: 'argument_error' }
+```
+
+Re-initialising with the _same_ `clientId` is allowed and idempotent. If you need two clientIds, give each its own store.
+
## API Reference
### authorize
@@ -326,7 +392,7 @@ const tokens = await oidcClient.token.get({
if ('error' in tokens) {
console.error('Failed to retrieve tokens:', tokens.error);
} else {
- console.log('Access token:', tokens.access_token);
+ console.log('Access token:', tokens.accessToken);
}
```
diff --git a/packages/oidc-client/package.json b/packages/oidc-client/package.json
index 93e7dd09676..e6c175bdc85 100644
--- a/packages/oidc-client/package.json
+++ b/packages/oidc-client/package.json
@@ -31,6 +31,7 @@
"@forgerock/sdk-logger": "workspace:*",
"@forgerock/sdk-oidc": "workspace:*",
"@forgerock/sdk-request-middleware": "workspace:*",
+ "@forgerock/sdk-store": "workspace:*",
"@forgerock/sdk-types": "workspace:*",
"@forgerock/sdk-utilities": "workspace:*",
"@forgerock/storage": "workspace:*",
diff --git a/packages/oidc-client/src/lib/client-extra.test.ts b/packages/oidc-client/src/lib/client-extra.test.ts
new file mode 100644
index 00000000000..8101b69076b
--- /dev/null
+++ b/packages/oidc-client/src/lib/client-extra.test.ts
@@ -0,0 +1,192 @@
+// @vitest-environment node
+/*
+ * 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 { configureStore } from '@reduxjs/toolkit';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { wellknownApi } from '@forgerock/sdk-store';
+import { oidcApi } from './oidc.api.js';
+
+import type { RequestMiddleware } from '@forgerock/sdk-request-middleware';
+
+/**
+ * Regression coverage for the shared-store middleware leak.
+ *
+ * OIDC endpoints resolve their middleware and logger from the store's thunk
+ * `extraArgument`. When a store is shared with davinci/journey, that `extra`
+ * belongs to the owning client — so before the per-client registry, DaVinci
+ * middleware executed against TOKEN_EXCHANGE, REVOKE, END_SESSION and friends,
+ * and oidc's own logger was silently discarded.
+ *
+ * These tests build a store whose `extra` carries slots for two clients and
+ * assert that oidc endpoints only ever see their own.
+ */
+
+const REVOKE_URL = 'https://example.pingone.com/test-env/as/revoke';
+
+function recordingMiddleware(calls: string[], label: string): RequestMiddleware {
+ return (_req, action, next) => {
+ calls.push(`${label}:${action.type}`);
+ next();
+ };
+}
+
+function makeLoggerSpy() {
+ return {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+}
+
+/** A store shaped like a shared SDK store: one `extra`, one slot per client. */
+function makeTwoClientStore(slots: Record) {
+ return configureStore({
+ reducer: {
+ [oidcApi.reducerPath]: oidcApi.reducer,
+ [wellknownApi.reducerPath]: wellknownApi.reducer,
+ },
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({ thunk: { extraArgument: { clients: slots } } })
+ .concat(wellknownApi.middleware)
+ .concat(oidcApi.middleware),
+ });
+}
+
+describe('oidc endpoints resolve middleware and logger per client', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(
+ async () =>
+ new Response(JSON.stringify({ access_token: 'at', token_type: 'Bearer' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ oidcApi.util.resetApiState();
+ });
+
+ it('does not run another client\u2019s requestMiddleware against an OIDC request', async () => {
+ // Arrange
+ const calls: string[] = [];
+ const store = makeTwoClientStore({
+ davinci: {
+ requestMiddleware: [recordingMiddleware(calls, 'davinci')],
+ logger: makeLoggerSpy(),
+ },
+ oidc: { requestMiddleware: [], logger: makeLoggerSpy() },
+ });
+
+ // Act
+ await store.dispatch(
+ oidcApi.endpoints.revoke.initiate({
+ accessToken: 'test-access-token',
+ clientId: 'test-client-id',
+ endpoint: REVOKE_URL,
+ }),
+ );
+
+ // Assert — before the per-client registry this recorded 'davinci:TOKEN_EXCHANGE'
+ expect(calls).toEqual([]);
+ });
+
+ it("runs the OIDC client's own requestMiddleware against an OIDC request", async () => {
+ // Arrange
+ const calls: string[] = [];
+ const store = makeTwoClientStore({
+ davinci: { requestMiddleware: [recordingMiddleware(calls, 'davinci')] },
+ oidc: {
+ requestMiddleware: [recordingMiddleware(calls, 'oidc')],
+ logger: makeLoggerSpy(),
+ },
+ });
+
+ // Act
+ await store.dispatch(
+ oidcApi.endpoints.revoke.initiate({
+ accessToken: 'test-access-token',
+ clientId: 'test-client-id',
+ endpoint: REVOKE_URL,
+ }),
+ );
+
+ // Assert
+ expect(calls.filter((c) => c.startsWith('oidc:'))).not.toHaveLength(0);
+ expect(calls.filter((c) => c.startsWith('davinci:'))).toHaveLength(0);
+ });
+
+ it('ignores a store-wide flat middleware list (the original leak shape)', async () => {
+ // Arrange — this is exactly what davinci/journey used to put in extraArgument.
+ // If oidc ever reads the whole `extra` again instead of its own slot, this fails.
+ const calls: string[] = [];
+ const store = configureStore({
+ reducer: {
+ [oidcApi.reducerPath]: oidcApi.reducer,
+ [wellknownApi.reducerPath]: wellknownApi.reducer,
+ },
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({
+ thunk: {
+ extraArgument: {
+ requestMiddleware: [recordingMiddleware(calls, 'store-wide')],
+ logger: makeLoggerSpy(),
+ },
+ },
+ })
+ .concat(wellknownApi.middleware)
+ .concat(oidcApi.middleware),
+ });
+
+ // Act
+ await store.dispatch(
+ oidcApi.endpoints.revoke.initiate({
+ accessToken: 'test-access-token',
+ clientId: 'test-client-id',
+ endpoint: REVOKE_URL,
+ }),
+ );
+
+ // Assert
+ expect(calls).toEqual([]);
+ });
+
+ it("uses the OIDC client's own logger, not the owning client's", async () => {
+ // Arrange
+ const oidcLogger = makeLoggerSpy();
+ const davinciLogger = makeLoggerSpy();
+ const store = makeTwoClientStore({
+ davinci: { requestMiddleware: [], logger: davinciLogger },
+ oidc: { requestMiddleware: [], logger: oidcLogger },
+ });
+
+ // Act
+ await store.dispatch(
+ oidcApi.endpoints.revoke.initiate({
+ accessToken: 'test-access-token',
+ clientId: 'test-client-id',
+ endpoint: REVOKE_URL,
+ }),
+ );
+
+ // Assert
+ const oidcCalls =
+ oidcLogger.debug.mock.calls.length +
+ oidcLogger.info.mock.calls.length +
+ oidcLogger.error.mock.calls.length;
+ const davinciCalls =
+ davinciLogger.debug.mock.calls.length +
+ davinciLogger.info.mock.calls.length +
+ davinciLogger.error.mock.calls.length;
+
+ expect(oidcCalls).toBeGreaterThan(0);
+ expect(davinciCalls).toBe(0);
+ });
+});
diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts
index 64d5b6695c8..e705e8fc945 100644
--- a/packages/oidc-client/src/lib/client.store.test.ts
+++ b/packages/oidc-client/src/lib/client.store.test.ts
@@ -5,12 +5,13 @@
* of the MIT license. See the LICENSE file for details.
*/
-import { makeOidcConfig } from '@forgerock/sdk-utilities';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
-import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { it, expect, describe, vi, beforeEach, afterEach, afterAll, beforeAll } from 'vitest';
import { oidc } from './client.store.js';
+import { makeOidcConfig } from '@forgerock/sdk-utilities';
+import { createSdkStore } from '@forgerock/sdk-store';
import type { OidcConfig } from './config.types.js';
@@ -959,6 +960,44 @@ describe('unified JSON config entry', () => {
});
});
+describe('shared wellknown cache', () => {
+ const config: OidcConfig = {
+ clientId: 'shared-client',
+ redirectUri: 'https://example.com/callback.html',
+ scope: 'openid profile',
+ serverConfig: { wellknown: 'https://api.example.com/wellknown' },
+ responseType: 'code',
+ };
+
+ it('clears a failed discovery query so a second client can retry', async () => {
+ const sharedStore = createSdkStore();
+ server.use(
+ http.get('*/wellknown', () => HttpResponse.json({ error: 'unavailable' }, { status: 500 })),
+ );
+
+ const first = await oidc({ config, storage: customStorageConfig, store: sharedStore });
+ expect(first).toMatchObject({ type: 'wellknown_error' });
+
+ server.use(
+ http.get('*/wellknown', () =>
+ HttpResponse.json({
+ issuer: 'https://api.example.com/as/issuer',
+ authorization_endpoint: 'https://api.example.com/as/authorize',
+ token_endpoint: 'https://api.example.com/as/token',
+ userinfo_endpoint: 'https://api.example.com/as/userinfo',
+ introspection_endpoint: 'https://api.example.com/as/introspect',
+ revocation_endpoint: 'https://api.example.com/as/revoke',
+ response_types_supported: ['code'],
+ response_modes_supported: ['query'],
+ }),
+ ),
+ );
+
+ const second = await oidc({ config, storage: customStorageConfig, store: sharedStore });
+ expect(second).not.toHaveProperty('error');
+ });
+});
+
describe('user.session()', async () => {
const config: OidcConfig = {
clientId: '123456789',
diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts
index e971a35ea7d..5a316d8df44 100644
--- a/packages/oidc-client/src/lib/client.store.ts
+++ b/packages/oidc-client/src/lib/client.store.ts
@@ -6,26 +6,25 @@
*/
import { logger as loggerFn } from '@forgerock/sdk-logger';
import { createAuthorizeUrl } from '@forgerock/sdk-oidc';
-import { handleMicroExit } from '@forgerock/sdk-utilities';
import { createStorage } from '@forgerock/storage';
import { Micro } from 'effect';
import { causeIsDie, exitIsFail, exitIsSuccess } from 'effect/Micro';
import { authorizeµ, createParAuthorizeUrlµ } from './authorize.request.js';
-import { createClientStore, createTokenError } from './client.store.utils.js';
import { buildTokenExchangeµ } from './exchange.request.js';
+import { createClientStore, createTokenError, parseOidcArgs } from './client.store.utils.js';
+import { handleMicroExit } from '@forgerock/sdk-utilities';
+import { isExpiryWithinThreshold } from './token.utils.js';
import { logoutµ } from './logout.request.js';
import { oidcApi } from './oidc.api.js';
-import { sessionCheckIdTokenµ, sessionCheckNoneµ } from './session.micros.js';
-import { isExpiryWithinThreshold } from './token.utils.js';
-import { wellknownApi, wellknownSelector } from './wellknown.api.js';
+import { sessionCheckNoneµ, sessionCheckIdTokenµ } from './session.micros.js';
+import { wellknownApi, wellknownSelector } from '@forgerock/sdk-store';
-import type { CustomLogger, LogLevel } from '@forgerock/sdk-logger';
-import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { ActionTypes } from '@forgerock/sdk-request-middleware';
import type { GenericError, GetAuthorizationUrlOptions } from '@forgerock/sdk-types';
+import type { SdkStore } from '@forgerock/sdk-store';
import type { StorageConfig } from '@forgerock/storage';
-import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js';
import type {
GetTokensOptions,
LogoutErrorResult,
@@ -34,62 +33,53 @@ import type {
RevokeSuccessResult,
UserInfoResponse,
} from './client.types.js';
-import type { OauthTokens, OidcConfig } from './config.types.js';
+import type { OauthTokens } from './config.types.js';
+import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js';
import type { TokenExchangeErrorResponse } from './exchange.types.js';
import type { SessionCheckOptions, SessionCheckSuccess } from './session.types.js';
+import type { RawOidcArgs } from './client.store.types.js';
/**
* @function oidc
* @description Factory function to create an OIDC client with methods for authorization, token exchange,
* user info retrieval, and logout. It initializes the client with the provided configuration,
* request middleware, logger, and storage options.
- * @param param - configuration object containing the OIDC client configuration, request middleware, logger,
- * @param {OidcConfig} param.config - OIDC configuration including server details, client ID, redirect URI,
+ * @param raw - configuration object containing the OIDC client configuration, request middleware, logger,
+ * @param {OidcConfig} raw.config - OIDC configuration including server details, client ID, redirect URI,
* storage options, scope, and response type.
- * @param {RequestMiddleware} param.requestMiddleware - optional array of request middleware functions to process requests.
- * @param {{ level: LogLevel, custom: CustomLogger }} param.logger - optional logger configuration with log level and custom logger.
- * @param {Partial} param.storage - optional storage configuration for persisting OIDC tokens.
+ * @param {RequestMiddleware} raw.requestMiddleware - optional array of request middleware functions to process requests.
+ * @param {{ level: LogLevel, custom: CustomLogger }} raw.logger - optional logger configuration with log level and custom logger.
+ * @param {Partial} raw.storage - optional storage configuration for persisting OIDC tokens.
+ * @param {unknown} raw.store - optional existing SDK store to share across clients; validated at runtime via `isSdkStoreHandle`.
* @returns {ReturnType} - Returns an object with methods for authorization, token exchange, user info retrieval, and logout.
*/
-export async function oidc({
- config,
- requestMiddleware,
- logger,
- storage,
-}: {
- config: OidcConfig;
- requestMiddleware?: RequestMiddleware[];
- logger?: {
- level: LogLevel;
- custom?: CustomLogger;
- };
- storage?: Partial;
-}) {
+export async function oidc(
+ raw: RawOidcArgs,
+) {
+ const parsed = parseOidcArgs(raw);
+ if ('type' in parsed) return parsed;
+
+ const { config, requestMiddleware, logger, storage, store: sharedStore } = parsed;
+
const log = loggerFn({
level: logger?.level ?? config.log ?? 'error',
custom: logger?.custom,
});
const oauthThreshold = config.oauthThreshold || 30 * 1000; // Default to 30 seconds
+
const storageClient = createStorage({
type: storage?.type || 'localStorage',
name: storage?.name || config.clientId,
prefix: storage?.prefix || 'pic',
...storage,
} as StorageConfig);
- const store = createClientStore({ requestMiddleware, logger: log });
-
- if (!config?.serverConfig?.wellknown) {
- return {
- error: 'Requires a wellknown url initializing this factory.',
- type: 'argument_error',
- };
- }
- if (!config?.clientId) {
- return {
- error: 'Requires a clientId.',
- type: 'argument_error',
- };
- }
+ const handle = createClientStore({
+ requestMiddleware,
+ logger: log,
+ store: sharedStore,
+ clientId: config.clientId,
+ });
+ const { store } = handle;
const wellknownUrl = config.serverConfig.wellknown;
const { data, error } = await store.dispatch(
@@ -97,6 +87,9 @@ export async function oidc({
);
if (error || !data) {
+ // RTK Query retains rejected query entries. Clear them before returning so a
+ // later client sharing this store performs a fresh discovery request.
+ store.dispatch(wellknownApi.util.resetApiState());
log.error(`Error fetching wellknown config. Please check the URL: ${wellknownUrl}`);
return {
error: `Failed to fetch well-known configuration from: ${wellknownUrl}`,
@@ -115,6 +108,8 @@ export async function oidc({
const useParFlow = config.par ?? data?.require_pushed_authorization_requests === true;
return {
+ /** Pass to another SDK client's `store` option to share this store. */
+ store: handle as SdkStore,
// Pass store methods to the client
subscribe: store.subscribe,
diff --git a/packages/oidc-client/src/lib/client.store.types.test.ts b/packages/oidc-client/src/lib/client.store.types.test.ts
new file mode 100644
index 00000000000..5e4b5018e87
--- /dev/null
+++ b/packages/oidc-client/src/lib/client.store.types.test.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { it, expect, describe } from 'vitest';
+import { logger as loggerFn } from '@forgerock/sdk-logger';
+
+import { parseOidcArgs } from './client.store.utils.js';
+import { createClientStore } from './client.store.utils.js';
+
+import type { OidcConfig } from './config.types.js';
+
+const validConfig: OidcConfig = {
+ clientId: 'test-client',
+ redirectUri: 'https://example.com/callback.html',
+ scope: 'openid profile',
+ serverConfig: { wellknown: 'https://example.com/.well-known/openid-configuration' },
+ responseType: 'code',
+};
+
+describe('parseOidcArgs', () => {
+ it('returns ParsedOidcArgs (no error) when all required fields are present', () => {
+ const result = parseOidcArgs({ config: validConfig });
+
+ expect('type' in result).toBe(false);
+ if ('type' in result) return;
+ expect(result.config.clientId).toBe('test-client');
+ expect(result.config.serverConfig.wellknown).toBe(
+ 'https://example.com/.well-known/openid-configuration',
+ );
+ expect(result.store).toBeUndefined();
+ });
+
+ it('returns argument_error when store is a non-SDK-store object', () => {
+ const result = parseOidcArgs({
+ config: validConfig,
+ store: { notAnSdkStore: true, dispatch: () => void 0 },
+ });
+
+ expect(result).toMatchObject({ type: 'argument_error' });
+ });
+
+ it('returns argument_error when config.serverConfig.wellknown is missing', () => {
+ const result = parseOidcArgs({
+ config: { ...validConfig, serverConfig: {} as OidcConfig['serverConfig'] },
+ });
+
+ expect(result).toMatchObject({
+ type: 'argument_error',
+ error: 'Requires a wellknown url initializing this factory.',
+ });
+ });
+
+ it('returns argument_error when config.clientId is missing', () => {
+ const result = parseOidcArgs({
+ config: { ...validConfig, clientId: '' },
+ });
+
+ expect(result).toMatchObject({
+ type: 'argument_error',
+ error: 'Requires a clientId.',
+ });
+ });
+
+ it('returns argument_error when clientId conflicts with the existing store', () => {
+ const log = loggerFn({ level: 'error' });
+ // Create a real store already registered with 'existing-client'.
+ // createClientStore returns a handle that passes isSdkStoreHandle at runtime.
+ const existingHandle = createClientStore({ clientId: 'existing-client', logger: log });
+
+ const result = parseOidcArgs({
+ config: { ...validConfig, clientId: 'different-client' },
+ store: existingHandle,
+ });
+
+ expect(result).toMatchObject({ type: 'argument_error' });
+ if ('type' in result) {
+ expect(result.error).toContain('existing-client');
+ expect(result.error).toContain('Use a separate store per clientId');
+ }
+ });
+});
diff --git a/packages/oidc-client/src/lib/client.store.types.ts b/packages/oidc-client/src/lib/client.store.types.ts
new file mode 100644
index 00000000000..2f3d6a323d0
--- /dev/null
+++ b/packages/oidc-client/src/lib/client.store.types.ts
@@ -0,0 +1,48 @@
+/*
+ * 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 type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { SdkStore } from '@forgerock/sdk-store';
+import type { CustomLogger, LogLevel } from '@forgerock/sdk-logger';
+import type { StorageConfig } from '@forgerock/storage';
+
+import type { OidcConfig } from './config.types.js';
+
+/**
+ * The raw, unvalidated input type — this is what public callers pass.
+ * `store` is typed as `unknown` so the parser can perform a runtime
+ * brand-check via `isSdkStoreHandle` before narrowing to `SdkStore`.
+ */
+export type RawOidcArgs = {
+ config: OidcConfig;
+ requestMiddleware?: RequestMiddleware[];
+ logger?: { level: LogLevel; custom?: CustomLogger };
+ storage?: Partial;
+ /**
+ * An existing SDK store to attach to, so discovery caching and state are
+ * shared with another client. Omit to create a store for this client alone.
+ * Typed as `unknown` — the parser validates this at runtime via `isSdkStoreHandle`.
+ */
+ store?: unknown;
+};
+
+/**
+ * The parsed, trusted type — all structural validation checks have passed.
+ * The type system records the narrowed facts:
+ * - `config.serverConfig.wellknown` is a non-empty string
+ * - `config.clientId` is a non-empty string
+ * - `store` is either a valid `SdkStore` handle or `undefined`
+ */
+export type ParsedOidcArgs = {
+ config: OidcConfig & {
+ serverConfig: { wellknown: string };
+ clientId: string;
+ };
+ requestMiddleware?: RequestMiddleware[];
+ logger?: { level: LogLevel; custom?: CustomLogger };
+ storage?: Partial;
+ store: SdkStore | undefined;
+};
diff --git a/packages/oidc-client/src/lib/client.store.utils.ts b/packages/oidc-client/src/lib/client.store.utils.ts
index be2f53eb7cb..792461a0b2c 100644
--- a/packages/oidc-client/src/lib/client.store.utils.ts
+++ b/packages/oidc-client/src/lib/client.store.utils.ts
@@ -4,55 +4,88 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
-import { configureStore } from '@reduxjs/toolkit';
+import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import { logger as loggerFn } from '@forgerock/sdk-logger';
+import { combineSlices, type SerializedError } from '@reduxjs/toolkit';
import { oidcApi } from './oidc.api.js';
-import { wellknownApi } from './wellknown.api.js';
+import {
+ createSdkStore,
+ injectClient,
+ isSdkStoreHandle,
+ INVALID_STORE_MESSAGE,
+ wellknownApi,
+ getClientForReducerPath,
+} from '@forgerock/sdk-store';
-import type { logger as loggerFn } from '@forgerock/sdk-logger';
-import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
import type { GenericError } from '@forgerock/sdk-types';
-import type { SerializedError } from '@reduxjs/toolkit';
+import type { SdkStore, SdkStoreHandle } from '@forgerock/sdk-store';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
+import type { ParsedOidcArgs, RawOidcArgs } from './client.store.types.js';
+
+/**
+ * The canonical description of the state this client contributes.
+ *
+ * The runtime store is assembled by `injectClient`, which TypeScript cannot
+ * follow across lazy injection. Combining the same slices here lets the state
+ * type be *derived* from them rather than hand-written, so it cannot drift from
+ * what is actually mounted. Exported so the derived state type resolves for
+ * consumers, and so an application can compose the reducer itself if it wants.
+ */
+export const rootReducer = combineSlices(oidcApi, wellknownApi);
+
+export type OidcRootState = ReturnType;
/**
* @function createClientStore
- * @description Creates a Redux store configured with OIDC and well-known APIs.
+ * @description Creates, or attaches to, the store backing an OIDC client.
* @param param - Configuration options for the client store.
- * @param {RequestMiddleware} param.requestMiddleware - An array of request middleware functions to be applied to the store.
- * @param {ReturnType} param.logger - An optional logger function for logging messages.
- * @returns { ReturnType } - Returns a configured Redux store with OIDC and well-known APIs.
+ * @param {RequestMiddleware} param.requestMiddleware - Request middleware applied to this client's requests only.
+ * @param {ReturnType} param.logger - An optional logger for this client only.
+ * @param {SdkStore} param.store - An existing SDK store to attach to. Omit to create one.
+ * @returns {SdkStoreHandle} - A handle to the store this client is mounted on.
*/
export function createClientStore({
requestMiddleware,
logger,
+ store,
+ clientId,
}: {
requestMiddleware?: RequestMiddleware[];
logger?: ReturnType;
-}) {
- return configureStore({
- reducer: {
- [oidcApi.reducerPath]: oidcApi.reducer,
- [wellknownApi.reducerPath]: wellknownApi.reducer,
- },
- middleware: (getDefaultMiddleware) =>
- getDefaultMiddleware({
- thunk: {
- extraArgument: {
- /**
- * This becomes the `api.extra` argument, and will be passed into the
- * customer query wrapper for `baseQuery`
- */
- requestMiddleware,
- logger,
- },
- },
- })
- .concat(wellknownApi.middleware)
- .concat(oidcApi.middleware),
+ store?: SdkStore;
+ clientId?: string;
+}): SdkStoreHandle {
+ return injectClient(store ?? createSdkStore(), {
+ api: oidcApi,
+ reducerPath: oidcApi.reducerPath,
+ requestMiddleware,
+ logger,
+ clientId,
});
}
+/**
+ * Reports the clientId already occupying a store's OIDC slot, when it differs
+ * from the one being initialised.
+ *
+ * `oidcApi.reducerPath` is the fixed string 'oidc', so two clients on one store
+ * would share a single RTK Query cache slice and silently overwrite each other's
+ * token state. Detecting that is cheaper than namespacing per clientId, and
+ * failing loudly beats corrupting tokens.
+ *
+ * @returns The conflicting clientId, or `undefined` when there is no conflict.
+ */
+export function conflictingClientId(
+ store: SdkStore | undefined,
+ clientId: string,
+): string | undefined {
+ if (!store) return undefined;
+ const existing = getClientForReducerPath(store, oidcApi.reducerPath);
+ if (!existing) return undefined;
+ return existing.clientId && existing.clientId !== clientId ? existing.clientId : undefined;
+}
+
/**
* @function createLogoutError
* @description Creates a logout error object based on the provided data and error.
@@ -114,3 +147,61 @@ export function createTokenError(type: 'no_tokens' | 'no_access_token' | 'no_id_
return error;
}
+
+/**
+ * @function parseOidcArgs
+ * @description Pure, synchronous parser for OIDC factory arguments that implements
+ * the "parse, don't validate" pattern. Returns a narrowed
+ * {@link ParsedOidcArgs} on success, or a {@link GenericError} describing
+ * the first structural failure found.
+ *
+ * The PAR check (which requires a network round-trip) is intentionally
+ * excluded — it belongs in `oidc()` after the wellknown fetch.
+ * @param raw - The unvalidated arguments to parse.
+ * @returns {ParsedOidcArgs | GenericError}
+ */
+export function parseOidcArgs(
+ raw: RawOidcArgs,
+): ParsedOidcArgs | GenericError {
+ /**
+ * Validate before touching the store. RTK's `inject` is irreversible, so
+ * mutating a caller-owned store and *then* rejecting the arguments would leave
+ * them permanently carrying a slice from a call that never succeeded.
+ */
+ if (raw.store !== undefined && !isSdkStoreHandle(raw.store)) {
+ return {
+ error: INVALID_STORE_MESSAGE,
+ type: 'argument_error',
+ };
+ }
+ if (!raw.config?.serverConfig?.wellknown) {
+ return {
+ error: 'Requires a wellknown url initializing this factory.',
+ type: 'argument_error',
+ };
+ }
+ if (!raw.config?.clientId) {
+ return {
+ error: 'Requires a clientId.',
+ type: 'argument_error',
+ };
+ }
+
+ /**
+ * `oidcApi.reducerPath` is a fixed string, so a second client on the same
+ * store would share one cache slice and clobber the first client's tokens.
+ * Re-initialising the same clientId is fine and stays idempotent.
+ */
+ const validatedStore = raw.store as SdkStore | undefined;
+ const conflict = conflictingClientId(validatedStore, raw.config.clientId);
+ if (conflict) {
+ return {
+ error:
+ `This store is already in use by an OIDC client with clientId '${conflict}'. ` +
+ 'Use a separate store per clientId.',
+ type: 'argument_error',
+ };
+ }
+
+ return raw as unknown as ParsedOidcArgs;
+}
diff --git a/packages/oidc-client/src/lib/client.types.ts b/packages/oidc-client/src/lib/client.types.ts
index 7e091b84fc9..f938a9ffdaf 100644
--- a/packages/oidc-client/src/lib/client.types.ts
+++ b/packages/oidc-client/src/lib/client.types.ts
@@ -12,7 +12,11 @@ import type { createClientStore } from './client.store.utils.js';
export type OidcClient = Awaited>;
-export type ClientStore = ReturnType;
+/**
+ * The inner Redux store. `createClientStore` returns a handle carrying the
+ * store plus the injection seams; internal code only ever needs the store.
+ */
+export type ClientStore = ReturnType['store'];
export type RootState = ReturnType;
diff --git a/packages/oidc-client/src/lib/logout.request.test.ts b/packages/oidc-client/src/lib/logout.request.test.ts
index e0591ceebac..ceaa8d47c16 100644
--- a/packages/oidc-client/src/lib/logout.request.test.ts
+++ b/packages/oidc-client/src/lib/logout.request.test.ts
@@ -86,7 +86,7 @@ const storageClient = createStorage({
});
const logger = loggerFn({ level: 'error' });
-const store = createClientStore({ logger });
+const { store } = createClientStore({ logger });
const tokens = {
accessToken: '1234567890',
diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts
index 7618f86285e..36eb5dc8a26 100644
--- a/packages/oidc-client/src/lib/oidc.api.ts
+++ b/packages/oidc-client/src/lib/oidc.api.ts
@@ -4,43 +4,71 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
-import { iFrameManager } from '@forgerock/iframe-manager';
-import { initQuery } from '@forgerock/sdk-request-middleware';
-import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
-
-import { transformError } from './oidc.api.utils.js';
-
-import type { logger as loggerFn } from '@forgerock/sdk-logger';
-import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
-import type { GenericError } from '@forgerock/sdk-types';
-import type {
- FetchArgs,
- FetchBaseQueryError,
- FetchBaseQueryMeta,
- QueryReturnValue,
+import {
+ createApi,
+ fetchBaseQuery,
+ type FetchArgs,
+ type FetchBaseQueryError,
+ type FetchBaseQueryMeta,
+ type QueryReturnValue,
} from '@reduxjs/toolkit/query';
-
-import type { AuthorizationSuccess, AuthorizeSuccessResponse } from './authorize.request.types.js';
-import type { UserInfoResponse } from './client.types.js';
import type { OidcConfig } from './config.types.js';
+import { transformError } from './oidc.api.utils.js';
+import { iFrameManager } from '@forgerock/iframe-manager';
+import {
+ initQuery,
+ type ActionTypes,
+ type RequestMiddleware,
+} from '@forgerock/sdk-request-middleware';
+
+import { logger as loggerFn } from '@forgerock/sdk-logger';
+import { clientExtra } from '@forgerock/sdk-store';
import type { TokenExchangeResponse } from './exchange.types.js';
+import type { AuthorizationSuccess, AuthorizeSuccessResponse } from './authorize.request.types.js';
+import type { UserInfoResponse } from './client.types.js';
import type { PushAuthorizationResponse } from './par.types.js';
+import type { GenericError } from '@forgerock/sdk-types';
import type { SessionCheckResponseType } from './session.types.js';
const IFRAME_TIMEOUT_MS = 3000;
+const OIDC_REDUCER_PATH = 'oidc';
+
+/**
+ * This client's private slot on the store's `extraArgument`.
+ *
+ * Both fields are optional because a shared store may not have had an oidc slot
+ * registered yet; `oidcExtra` substitutes safe defaults so an endpoint can never
+ * fail on a missing slot.
+ */
interface Extras {
- requestMiddleware: RequestMiddleware[];
- logger: ReturnType;
+ requestMiddleware?: RequestMiddleware[];
+ logger?: ReturnType;
+}
+
+/** Fallback so a missing slot degrades to error-level logging, never a crash. */
+const fallbackLogger = loggerFn({ level: 'error' });
+
+/**
+ * Resolves this client's own middleware and logger.
+ *
+ * Reads only the `oidc` slot — never a store-wide value, which on a shared
+ * store would belong to whichever client created it.
+ */
+function oidcExtra(extra: unknown): Required {
+ return clientExtra(extra, OIDC_REDUCER_PATH, {
+ requestMiddleware: [],
+ logger: fallbackLogger,
+ });
}
export const oidcApi = createApi({
- reducerPath: 'oidc',
+ reducerPath: OIDC_REDUCER_PATH,
baseQuery: fetchBaseQuery(),
endpoints: (builder) => ({
authorizeFetch: builder.mutation({
queryFn: async ({ url }, api, _, baseQuery) => {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = oidcExtra(api.extra);
const request: FetchArgs = {
url,
@@ -109,7 +137,7 @@ export const oidcApi = createApi({
}),
par: builder.mutation({
queryFn: async ({ endpoint, body }, api, _, baseQuery) => {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = oidcExtra(api.extra);
const request: FetchArgs = {
url: endpoint,
@@ -185,7 +213,7 @@ export const oidcApi = createApi({
{ url: string; responseType: SessionCheckResponseType }
>({
queryFn: async ({ url, responseType }, api) => {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = oidcExtra(api.extra);
const errorParams = ['error', 'error_description'];
const request: FetchArgs = { url };
@@ -269,7 +297,7 @@ export const oidcApi = createApi({
}),
sessionCheckFetch: builder.mutation<{ status: 204 }, { url: string }>({
queryFn: async ({ url }, api, _, baseQuery) => {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = oidcExtra(api.extra);
const request: FetchArgs = {
url,
@@ -312,7 +340,7 @@ export const oidcApi = createApi({
}),
authorizeIframe: builder.mutation({
queryFn: async ({ url }, api) => {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = oidcExtra(api.extra);
const request: FetchArgs = {
url,
@@ -399,7 +427,7 @@ export const oidcApi = createApi({
{ idToken: string; endpoint: string; signOutRedirectUri?: string }
>({
queryFn: async ({ idToken, endpoint, signOutRedirectUri }, api, _, baseQuery) => {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = oidcExtra(api.extra);
const url = new URL(endpoint);
url.searchParams.append('id_token_hint', idToken);
@@ -454,7 +482,7 @@ export const oidcApi = createApi({
}
>({
queryFn: async ({ code, config, endpoint, verifier }, api, _, baseQuery) => {
- const { requestMiddleware, logger } = api.extra as Extras;
+ const { requestMiddleware, logger } = oidcExtra(api.extra);
const { clientId, redirectUri } = config;
const body = new URLSearchParams({
@@ -513,7 +541,7 @@ export const oidcApi = createApi({
}),
revoke: builder.mutation