diff --git a/.changeset/nice-sails-trade.md b/.changeset/nice-sails-trade.md
new file mode 100644
index 0000000000..e1bc0538d1
--- /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 2bf7754292..0432a7a083 100644
--- a/e2e/davinci-app/main.ts
+++ b/e2e/davinci-app/main.ts
@@ -13,7 +13,6 @@ import type {
Collectors,
CustomLogger,
DaVinciConfig,
- DavinciClient,
GetClient,
InternalErrorResponse,
NodeStates,
@@ -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 0000000000..e0551b54f4
--- /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 0000000000..555ecb1914
--- /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 8625a27297..b885b3911c 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 0000000000..8ec1416445
--- /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/journey-app/main.ts b/e2e/journey-app/main.ts
index 18adaa6600..0fd711d8b3 100644
--- a/e2e/journey-app/main.ts
+++ b/e2e/journey-app/main.ts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
@@ -8,7 +8,7 @@ import './style.css';
import { journey } from '@forgerock/journey-client';
-import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types';
+import type { RequestMiddleware } from '@forgerock/journey-client/types';
import { renderCallbacks } from './callback-map.js';
import { renderDeleteDevicesSection } from './components/delete-device.js';
@@ -62,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 a2c14760ae..b6a0c15b10 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -111,7 +111,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 5534564ffd..a12ab1e61e 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/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md
index 2dc2071de1..463771656a 100644
--- a/packages/davinci-client/api-report/davinci-client.api.md
+++ b/packages/davinci-client/api-report/davinci-client.api.md
@@ -16,6 +16,7 @@ import type { MutationResultSelectorResult } from '@reduxjs/toolkit/query';
import { QueryStatus } from '@reduxjs/toolkit/query';
import { Reducer } from '@reduxjs/toolkit';
import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { SdkStore } from '@forgerock/sdk-store';
import { SerializedError } from '@reduxjs/toolkit';
import { Unsubscribe } from '@reduxjs/toolkit';
@@ -280,7 +281,12 @@ export function davinci(input: {
level: LogLevel;
custom?: CustomLogger;
};
+ store?: unknown;
}): Promise<{
+ error: string;
+ type: "argument_error";
+} | {
+ store: SdkStore;
subscribe: (listener: () => void) => Unsubscribe;
externalIdp: () => (() => Promise);
flow: (action: DaVinciAction) => InitFlow;
diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md
index d498f3a14f..939ed7dfa5 100644
--- a/packages/davinci-client/api-report/davinci-client.types.api.md
+++ b/packages/davinci-client/api-report/davinci-client.types.api.md
@@ -16,6 +16,7 @@ import type { MutationResultSelectorResult } from '@reduxjs/toolkit/query';
import { QueryStatus } from '@reduxjs/toolkit/query';
import { Reducer } from '@reduxjs/toolkit';
import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { SdkStore } from '@forgerock/sdk-store';
import { SerializedError } from '@reduxjs/toolkit';
import { Unsubscribe } from '@reduxjs/toolkit';
@@ -280,7 +281,12 @@ export function davinci(input: {
level: LogLevel;
custom?: CustomLogger;
};
+ store?: unknown;
}): Promise<{
+ error: string;
+ type: "argument_error";
+} | {
+ store: SdkStore;
subscribe: (listener: () => void) => Unsubscribe;
externalIdp: () => (() => Promise);
flow: (action: DaVinciAction) => InitFlow;
diff --git a/packages/davinci-client/package.json b/packages/davinci-client/package.json
index 1d6e1bd1bb..be8bf5d8d9 100644
--- a/packages/davinci-client/package.json
+++ b/packages/davinci-client/package.json
@@ -29,6 +29,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 9923ea4b67..dc306345ec 100644
--- a/packages/davinci-client/src/lib/client.store.effects.ts
+++ b/packages/davinci-client/src/lib/client.store.effects.ts
@@ -11,7 +11,8 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
import type { logger as loggerFn } from '@forgerock/sdk-logger';
-import type { ClientStore, RootState } from './client.store.utils.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';
@@ -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 ef6c2a9623..a4f2a24536 100644
--- a/packages/davinci-client/src/lib/client.store.test.ts
+++ b/packages/davinci-client/src/lib/client.store.test.ts
@@ -123,6 +123,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();
@@ -132,6 +133,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();
@@ -150,6 +152,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('');
@@ -158,6 +161,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();
@@ -175,6 +179,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 c92def90d8..6bd2ae6b97 100644
--- a/packages/davinci-client/src/lib/client.store.ts
+++ b/packages/davinci-client/src/lib/client.store.ts
@@ -19,15 +19,16 @@ import {
handleUpdateValidateError,
isValidCollectorCategory,
resolveCollectorUpdateValue,
- type RootState,
} from './client.store.utils.js';
+import type { RootState } from './davinci.state.js';
import { pollingµ, getPollingModeµ } from './client.store.effects.js';
import { nodeSlice } from './node.slice.js';
import { davinciApi } from './davinci.api.js';
import { configSlice } from './config.slice.js';
-import { wellknownApi } from './wellknown.api.js';
+import { wellknownApi, assertValidStore } from '@forgerock/sdk-store';
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
+import type { SdkStore } from '@forgerock/sdk-store';
/**
* Import the DaVinciRequest types
*/
@@ -71,6 +72,7 @@ export async function davinci({
config,
requestMiddleware,
logger,
+ store: sharedStore,
}: {
config: DaVinciConfig;
requestMiddleware?: RequestMiddleware[];
@@ -78,16 +80,22 @@ 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 (!config.serverConfig.wellknown) {
const error = new Error(
'`wellknown` property is a required as part of the `config.serverConfig`',
@@ -102,6 +110,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),
);
@@ -115,6 +130,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 ae6034f246..91af0c3b4e 100644
--- a/packages/davinci-client/src/lib/client.store.utils.ts
+++ b/packages/davinci-client/src/lib/client.store.utils.ts
@@ -4,21 +4,13 @@
* 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 { Match, Either } from 'effect';
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 {
- ErrorNode,
- ContinueNode,
- StartNode,
- SuccessNode,
- Collectors,
- CollectorCategory,
-} from './node.types.js';
+import type { Collectors, CollectorCategory } from './node.types.js';
import type {
CollectorValueType,
CollectorValueTypes,
@@ -26,54 +18,45 @@ import type {
UpdatableCollectors,
} from './client.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 { wellknownApi } from './wellknown.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 05e94872ef..403ead9976 100644
--- a/packages/davinci-client/src/lib/davinci.api.ts
+++ b/packages/davinci-client/src/lib/davinci.api.ts
@@ -25,13 +25,14 @@ 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 the DaVinci types
*/
-import type { RootStateWithNode } from './client.store.utils.js';
+import type { RootStateWithNode } from './davinci.state.js';
import type {
DaVinciCacheEntry,
OutgoingQueryParams,
@@ -47,9 +48,34 @@ 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,
+ });
}
/**
@@ -57,7 +83,7 @@ interface Extras {
@@ -81,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 = '';
@@ -126,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 {
@@ -160,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 = '';
@@ -237,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 {
@@ -270,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) {
@@ -352,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 {
@@ -381,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) {
@@ -430,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 {
@@ -464,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 0000000000..9b4a2dbfbc
--- /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 0000000000..69b1885332
--- /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
index 8b9772094e..5e7b28ba49 100644
--- a/packages/davinci-client/src/lib/wellknown.api.ts
+++ b/packages/davinci-client/src/lib/wellknown.api.ts
@@ -7,7 +7,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
-import { initWellknownQuery } from '@forgerock/sdk-oidc';
+import { initWellknownQuery } from '@forgerock/sdk-store';
import type { WellknownResponse } from '@forgerock/sdk-types';
import type {
@@ -19,7 +19,7 @@ import type {
/**
* RTK Query API for well-known endpoint discovery.
*
- * Uses the `initWellknownQuery` builder pattern from `@forgerock/sdk-oidc`.
+ * Uses the `initWellknownQuery` builder pattern from `@forgerock/sdk-store`.
* The builder constructs the request and validates the response;
* `fetchBaseQuery` handles the HTTP transport through RTK Query's pipeline.
*/
diff --git a/packages/davinci-client/tsconfig.json b/packages/davinci-client/tsconfig.json
index 141b4ebf5f..df68746659 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 6c7bbdefef..6db7b33e75 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 910ca9462e..919deeb07b 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 35a4a51dbe..b6dcab76e9 100644
--- a/packages/journey-client/api-report/journey-client.api.md
+++ b/packages/journey-client/api-report/journey-client.api.md
@@ -23,6 +23,7 @@ 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';
@@ -184,7 +185,11 @@ export function journey(input: {
level: LogLevel;
custom?: CustomLogger;
};
-}): Promise;
+ store?: unknown;
+}): Promise;
// @public
export interface JourneyClient {
@@ -197,6 +202,8 @@ export interface JourneyClient {
// (undocumented)
start: (options?: StartParam) => Promise;
// (undocumented)
+ store: SdkStore;
+ // (undocumented)
subscribe: (listener: () => void) => () => void;
// (undocumented)
terminate: (options?: {
diff --git a/packages/journey-client/api-report/journey-client.types.api.md b/packages/journey-client/api-report/journey-client.types.api.md
index d9219a9710..0840dcc3cd 100644
--- a/packages/journey-client/api-report/journey-client.types.api.md
+++ b/packages/journey-client/api-report/journey-client.types.api.md
@@ -22,6 +22,7 @@ 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';
@@ -184,6 +185,8 @@ export interface JourneyClient {
// (undocumented)
start: (options?: StartParam) => Promise;
// (undocumented)
+ store: SdkStore;
+ // (undocumented)
subscribe: (listener: () => void) => () => void;
// (undocumented)
terminate: (options?: {
diff --git a/packages/journey-client/package.json b/packages/journey-client/package.json
index 285cb707cb..ebbe10cc52 100644
--- a/packages/journey-client/package.json
+++ b/packages/journey-client/package.json
@@ -33,8 +33,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 7d0ddf9345..a4153521ec 100644
--- a/packages/journey-client/src/lib/client.store.test.ts
+++ b/packages/journey-client/src/lib/client.store.test.ts
@@ -8,7 +8,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
-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';
@@ -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;
diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts
index 32c2689a64..d133c7deec 100644
--- a/packages/journey-client/src/lib/client.store.ts
+++ b/packages/journey-client/src/lib/client.store.ts
@@ -13,6 +13,7 @@ import {
createWellknownError,
} from '@forgerock/sdk-utilities';
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';
@@ -23,7 +24,7 @@ import { createStorage } from '@forgerock/storage';
import * as Either from 'effect/Either';
import { createJourneyObject, parseJourneyResponse } from './journey.utils.js';
import type { JourneyResult } from './journey.utils.js';
-import { wellknownApi } from './wellknown.api.js';
+import { wellknownApi, assertValidStore, getClientForReducerPath } from '@forgerock/sdk-store';
import type { JourneyStep } from './step.utils.js';
import type { JourneyClientConfig } from './config.types.js';
@@ -32,6 +33,7 @@ import type { NextOptions, StartParam, ResumeOptions } from './interfaces.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;
@@ -73,6 +75,7 @@ export async function journey({
config,
requestMiddleware,
logger,
+ store: sharedStore,
}: {
config: JourneyClientConfig;
requestMiddleware?: RequestMiddleware[];
@@ -80,7 +83,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,
@@ -113,7 +121,21 @@ 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 { wellknown } = config.serverConfig;
@@ -123,6 +145,9 @@ export async function journey({
throw new Error(message);
}
+ const handle = createJourneyStore({ requestMiddleware, logger: log, store: validStore });
+ const store = handle.store;
+
const { data: wellknownResponse, error: fetchError } = await store.dispatch(
wellknownApi.endpoints.configuration.initiate(wellknown),
);
@@ -154,6 +179,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 7c08201f87..c9a939caec 100644
--- a/packages/journey-client/src/lib/client.store.utils.ts
+++ b/packages/journey-client/src/lib/client.store.utils.ts
@@ -7,40 +7,47 @@
import { logger as loggerFn } from '@forgerock/sdk-logger';
import { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
-import { combineReducers, configureStore } from '@reduxjs/toolkit';
+
+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 { SdkStore, SdkStoreHandle } from '@forgerock/sdk-store';
+
+/**
+ * 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);
-const rootReducer = combineReducers({
- [journeyApi.reducerPath]: journeyApi.reducer,
- [configSlice.name]: configSlice.reducer,
- [wellknownApi.reducerPath]: wellknownApi.reducer,
-});
+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 66c4da9a29..e503df9940 100644
--- a/packages/journey-client/src/lib/journey.api.ts
+++ b/packages/journey-client/src/lib/journey.api.ts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
@@ -10,7 +10,8 @@ import { REQUESTED_WITH, getEndpointPath, stringify, resolve } from '@forgerock/
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
import type { Step } from '@forgerock/sdk-types';
-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 {
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 0000000000..d2ea592667
--- /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
index d8f2dde936..b0d550e183 100644
--- a/packages/journey-client/src/lib/wellknown.api.ts
+++ b/packages/journey-client/src/lib/wellknown.api.ts
@@ -6,7 +6,7 @@
*/
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
-import { initWellknownQuery } from '@forgerock/sdk-oidc';
+import { initWellknownQuery } from '@forgerock/sdk-store';
import type { WellknownResponse } from '@forgerock/sdk-types';
import type {
@@ -18,7 +18,7 @@ import type {
/**
* RTK Query API for well-known endpoint discovery.
*
- * Uses the `initWellknownQuery` builder pattern from `@forgerock/sdk-oidc`.
+ * Uses the `initWellknownQuery` builder pattern from `@forgerock/sdk-store`.
* The builder constructs the request and validates the response;
* `fetchBaseQuery` handles the HTTP transport through RTK Query's pipeline.
*/
diff --git a/packages/journey-client/tsconfig.lib.json b/packages/journey-client/tsconfig.lib.json
index ca3f899b8d..573c9f707e 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 4119d84729..27bea7eb71 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/api-report/oidc-client.api.md b/packages/oidc-client/api-report/oidc-client.api.md
index 02a90f352a..4d282f83a1 100644
--- a/packages/oidc-client/api-report/oidc-client.api.md
+++ b/packages/oidc-client/api-report/oidc-client.api.md
@@ -6,9 +6,9 @@
import { ActionTypes } from '@forgerock/sdk-request-middleware';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
+import { CombinedSliceReducer } from '@reduxjs/toolkit';
import { CombinedState } from '@reduxjs/toolkit/query';
import { CustomLogger } from '@forgerock/sdk-logger';
-import { EnhancedStore } from '@reduxjs/toolkit';
import { FetchArgs } from '@reduxjs/toolkit/query';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { FetchBaseQueryMeta } from '@reduxjs/toolkit/query';
@@ -17,17 +17,14 @@ import { GetAuthorizationUrlOptions } from '@forgerock/sdk-types';
import type { JWTPayload } from 'jose';
import { logger } from '@forgerock/sdk-logger';
import { LogLevel } from '@forgerock/sdk-logger';
-import { LogMessage } from '@forgerock/sdk-logger';
import { MutationDefinition } from '@reduxjs/toolkit/query';
import { OidcConfig } from '@forgerock/sdk-types';
import { QueryDefinition } from '@reduxjs/toolkit/query';
import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
import { ResponseType as ResponseType_2 } from '@forgerock/sdk-types';
+import type { SdkStore } from '@forgerock/sdk-store';
+import type { SdkStoreHandle } from '@forgerock/sdk-store';
import { StorageConfig } from '@forgerock/storage';
-import { StoreEnhancer } from '@reduxjs/toolkit';
-import { ThunkDispatch } from '@reduxjs/toolkit';
-import { Tuple } from '@reduxjs/toolkit';
-import { UnknownAction } from '@reduxjs/toolkit';
import { Unsubscribe } from '@reduxjs/toolkit';
import { WellknownResponse } from '@forgerock/sdk-types';
@@ -113,119 +110,16 @@ export interface AuthorizeSuccessResponse {
// @public (undocumented)
export type BuildAuthorizationData = [string, GetAuthorizationUrlOptions];
-// @public (undocumented)
-export type ClientStore = ReturnType;
+// @public
+export type ClientStore = ReturnType['store'];
// @public
export function createClientStore(input: {
requestMiddleware?: RequestMiddleware[];
logger?: ReturnType;
-}): EnhancedStore< {
-oidc: CombinedState< {
-authorizeFetch: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
-par: MutationDefinition< {
-endpoint: string;
-body: URLSearchParams;
-}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
-sessionCheckIframe: MutationDefinition< {
-url: string;
-responseType: SessionCheckResponseType;
-}, BaseQueryFn, never, {
-params: Record;
-}, "oidc", unknown>;
-sessionCheckFetch: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, {
-status: 204;
-}, "oidc", unknown>;
-authorizeIframe: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
-endSession: MutationDefinition< {
-idToken: string;
-endpoint: string;
-signOutRedirectUri?: string;
-}, BaseQueryFn, never, null, "oidc", unknown>;
-exchange: MutationDefinition< {
-code: string;
-config: OidcConfig;
-endpoint: string;
-verifier?: string;
-}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
-revoke: MutationDefinition< {
-accessToken: string;
-clientId?: string;
-endpoint: string;
-}, BaseQueryFn, never, object, "oidc", unknown>;
-userInfo: MutationDefinition< {
-accessToken: string;
-endpoint: string;
-}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
-}, never, "oidc">;
-wellknown: CombinedState< {
-configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
-}, never, "wellknown">;
-}, UnknownAction, Tuple<[StoreEnhancer< {
-dispatch: ThunkDispatch< {
-oidc: CombinedState< {
-authorizeFetch: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
-par: MutationDefinition< {
-endpoint: string;
-body: URLSearchParams;
-}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
-sessionCheckIframe: MutationDefinition< {
-url: string;
-responseType: SessionCheckResponseType;
-}, BaseQueryFn, never, {
-params: Record;
-}, "oidc", unknown>;
-sessionCheckFetch: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, {
-status: 204;
-}, "oidc", unknown>;
-authorizeIframe: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
-endSession: MutationDefinition< {
-idToken: string;
-endpoint: string;
-signOutRedirectUri?: string;
-}, BaseQueryFn, never, null, "oidc", unknown>;
-exchange: MutationDefinition< {
-code: string;
-config: OidcConfig;
-endpoint: string;
-verifier?: string;
-}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
-revoke: MutationDefinition< {
-accessToken: string;
-clientId?: string;
-endpoint: string;
-}, BaseQueryFn, never, object, "oidc", unknown>;
-userInfo: MutationDefinition< {
-accessToken: string;
-endpoint: string;
-}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
-}, never, "oidc">;
-wellknown: CombinedState< {
-configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
-}, never, "wellknown">;
-}, {
-requestMiddleware: RequestMiddleware[] | undefined;
-logger: {
-changeLevel: (level: LogLevel) => void;
-error: (...args: LogMessage[]) => void;
-warn: (...args: LogMessage[]) => void;
-info: (...args: LogMessage[]) => void;
-debug: (...args: LogMessage[]) => void;
-} | undefined;
-}, UnknownAction>;
-}>, StoreEnhancer]>>;
+ store?: SdkStore;
+ clientId?: string;
+}): SdkStoreHandle;
export { CustomLogger }
@@ -275,22 +169,16 @@ export interface OauthTokens {
}
// @public
-export function oidc(input: {
- config: OidcConfig;
- requestMiddleware?: RequestMiddleware[];
- logger?: {
- level: LogLevel;
- custom?: CustomLogger;
- };
- storage?: Partial;
-}): Promise<{
+export function oidc(raw: RawOidcArgs): Promise void) => Unsubscribe;
authorize: {
url: (options?: GetAuthorizationUrlOptions) => Promise;
@@ -315,6 +203,9 @@ export type OidcClient = Awaited>;
export { OidcConfig }
+// @public (undocumented)
+export type OidcRootState = ReturnType;
+
// @public (undocumented)
export type OptionalAuthorizeOptions = Partial;
@@ -326,6 +217,18 @@ export interface PushAuthorizationResponse {
request_uri: string;
}
+// @public
+export type RawOidcArgs = {
+ config: OidcConfig;
+ requestMiddleware?: RequestMiddleware[];
+ logger?: {
+ level: LogLevel;
+ custom?: CustomLogger;
+ };
+ storage?: Partial;
+ store?: unknown;
+};
+
export { RequestMiddleware }
export { ResponseType_2 as ResponseType }
@@ -343,6 +246,150 @@ export type RevokeSuccessResult = {
deleteResponse: null;
};
+// @public
+export const rootReducer: CombinedSliceReducer< {
+oidc: CombinedState< {
+authorizeFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
+par: MutationDefinition< {
+endpoint: string;
+body: URLSearchParams;
+}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
+sessionCheckIframe: MutationDefinition< {
+url: string;
+responseType: SessionCheckResponseType;
+}, BaseQueryFn, never, {
+params: Record;
+}, "oidc", unknown>;
+sessionCheckFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, {
+status: 204;
+}, "oidc", unknown>;
+authorizeIframe: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
+endSession: MutationDefinition< {
+idToken: string;
+endpoint: string;
+signOutRedirectUri?: string;
+}, BaseQueryFn, never, null, "oidc", unknown>;
+exchange: MutationDefinition< {
+code: string;
+config: OidcConfig;
+endpoint: string;
+verifier?: string;
+}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
+revoke: MutationDefinition< {
+accessToken: string;
+clientId?: string;
+endpoint: string;
+}, BaseQueryFn, never, object, "oidc", unknown>;
+userInfo: MutationDefinition< {
+accessToken: string;
+endpoint: string;
+}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
+}, never, "oidc">;
+wellknown: CombinedState< {
+configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
+}, never, "wellknown">;
+}, {
+oidc: CombinedState< {
+authorizeFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
+par: MutationDefinition< {
+endpoint: string;
+body: URLSearchParams;
+}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
+sessionCheckIframe: MutationDefinition< {
+url: string;
+responseType: SessionCheckResponseType;
+}, BaseQueryFn, never, {
+params: Record;
+}, "oidc", unknown>;
+sessionCheckFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, {
+status: 204;
+}, "oidc", unknown>;
+authorizeIframe: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
+endSession: MutationDefinition< {
+idToken: string;
+endpoint: string;
+signOutRedirectUri?: string;
+}, BaseQueryFn, never, null, "oidc", unknown>;
+exchange: MutationDefinition< {
+code: string;
+config: OidcConfig;
+endpoint: string;
+verifier?: string;
+}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
+revoke: MutationDefinition< {
+accessToken: string;
+clientId?: string;
+endpoint: string;
+}, BaseQueryFn, never, object, "oidc", unknown>;
+userInfo: MutationDefinition< {
+accessToken: string;
+endpoint: string;
+}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
+}, never, "oidc">;
+wellknown: CombinedState< {
+configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
+}, never, "wellknown">;
+}, Partial<{
+oidc: CombinedState< {
+authorizeFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
+par: MutationDefinition< {
+endpoint: string;
+body: URLSearchParams;
+}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
+sessionCheckIframe: MutationDefinition< {
+url: string;
+responseType: SessionCheckResponseType;
+}, BaseQueryFn, never, {
+params: Record;
+}, "oidc", unknown>;
+sessionCheckFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, {
+status: 204;
+}, "oidc", unknown>;
+authorizeIframe: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
+endSession: MutationDefinition< {
+idToken: string;
+endpoint: string;
+signOutRedirectUri?: string;
+}, BaseQueryFn, never, null, "oidc", unknown>;
+exchange: MutationDefinition< {
+code: string;
+config: OidcConfig;
+endpoint: string;
+verifier?: string;
+}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
+revoke: MutationDefinition< {
+accessToken: string;
+clientId?: string;
+endpoint: string;
+}, BaseQueryFn, never, object, "oidc", unknown>;
+userInfo: MutationDefinition< {
+accessToken: string;
+endpoint: string;
+}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
+}, never, "oidc">;
+wellknown: CombinedState< {
+configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
+}, never, "wellknown">;
+}>>;
+
// @public (undocumented)
export type RootState = ReturnType;
diff --git a/packages/oidc-client/api-report/oidc-client.types.api.md b/packages/oidc-client/api-report/oidc-client.types.api.md
index 02a90f352a..4d282f83a1 100644
--- a/packages/oidc-client/api-report/oidc-client.types.api.md
+++ b/packages/oidc-client/api-report/oidc-client.types.api.md
@@ -6,9 +6,9 @@
import { ActionTypes } from '@forgerock/sdk-request-middleware';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
+import { CombinedSliceReducer } from '@reduxjs/toolkit';
import { CombinedState } from '@reduxjs/toolkit/query';
import { CustomLogger } from '@forgerock/sdk-logger';
-import { EnhancedStore } from '@reduxjs/toolkit';
import { FetchArgs } from '@reduxjs/toolkit/query';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { FetchBaseQueryMeta } from '@reduxjs/toolkit/query';
@@ -17,17 +17,14 @@ import { GetAuthorizationUrlOptions } from '@forgerock/sdk-types';
import type { JWTPayload } from 'jose';
import { logger } from '@forgerock/sdk-logger';
import { LogLevel } from '@forgerock/sdk-logger';
-import { LogMessage } from '@forgerock/sdk-logger';
import { MutationDefinition } from '@reduxjs/toolkit/query';
import { OidcConfig } from '@forgerock/sdk-types';
import { QueryDefinition } from '@reduxjs/toolkit/query';
import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
import { ResponseType as ResponseType_2 } from '@forgerock/sdk-types';
+import type { SdkStore } from '@forgerock/sdk-store';
+import type { SdkStoreHandle } from '@forgerock/sdk-store';
import { StorageConfig } from '@forgerock/storage';
-import { StoreEnhancer } from '@reduxjs/toolkit';
-import { ThunkDispatch } from '@reduxjs/toolkit';
-import { Tuple } from '@reduxjs/toolkit';
-import { UnknownAction } from '@reduxjs/toolkit';
import { Unsubscribe } from '@reduxjs/toolkit';
import { WellknownResponse } from '@forgerock/sdk-types';
@@ -113,119 +110,16 @@ export interface AuthorizeSuccessResponse {
// @public (undocumented)
export type BuildAuthorizationData = [string, GetAuthorizationUrlOptions];
-// @public (undocumented)
-export type ClientStore = ReturnType;
+// @public
+export type ClientStore = ReturnType['store'];
// @public
export function createClientStore(input: {
requestMiddleware?: RequestMiddleware[];
logger?: ReturnType;
-}): EnhancedStore< {
-oidc: CombinedState< {
-authorizeFetch: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
-par: MutationDefinition< {
-endpoint: string;
-body: URLSearchParams;
-}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
-sessionCheckIframe: MutationDefinition< {
-url: string;
-responseType: SessionCheckResponseType;
-}, BaseQueryFn, never, {
-params: Record;
-}, "oidc", unknown>;
-sessionCheckFetch: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, {
-status: 204;
-}, "oidc", unknown>;
-authorizeIframe: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
-endSession: MutationDefinition< {
-idToken: string;
-endpoint: string;
-signOutRedirectUri?: string;
-}, BaseQueryFn, never, null, "oidc", unknown>;
-exchange: MutationDefinition< {
-code: string;
-config: OidcConfig;
-endpoint: string;
-verifier?: string;
-}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
-revoke: MutationDefinition< {
-accessToken: string;
-clientId?: string;
-endpoint: string;
-}, BaseQueryFn, never, object, "oidc", unknown>;
-userInfo: MutationDefinition< {
-accessToken: string;
-endpoint: string;
-}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
-}, never, "oidc">;
-wellknown: CombinedState< {
-configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
-}, never, "wellknown">;
-}, UnknownAction, Tuple<[StoreEnhancer< {
-dispatch: ThunkDispatch< {
-oidc: CombinedState< {
-authorizeFetch: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
-par: MutationDefinition< {
-endpoint: string;
-body: URLSearchParams;
-}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
-sessionCheckIframe: MutationDefinition< {
-url: string;
-responseType: SessionCheckResponseType;
-}, BaseQueryFn, never, {
-params: Record;
-}, "oidc", unknown>;
-sessionCheckFetch: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, {
-status: 204;
-}, "oidc", unknown>;
-authorizeIframe: MutationDefinition< {
-url: string;
-}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
-endSession: MutationDefinition< {
-idToken: string;
-endpoint: string;
-signOutRedirectUri?: string;
-}, BaseQueryFn, never, null, "oidc", unknown>;
-exchange: MutationDefinition< {
-code: string;
-config: OidcConfig;
-endpoint: string;
-verifier?: string;
-}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
-revoke: MutationDefinition< {
-accessToken: string;
-clientId?: string;
-endpoint: string;
-}, BaseQueryFn, never, object, "oidc", unknown>;
-userInfo: MutationDefinition< {
-accessToken: string;
-endpoint: string;
-}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
-}, never, "oidc">;
-wellknown: CombinedState< {
-configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
-}, never, "wellknown">;
-}, {
-requestMiddleware: RequestMiddleware[] | undefined;
-logger: {
-changeLevel: (level: LogLevel) => void;
-error: (...args: LogMessage[]) => void;
-warn: (...args: LogMessage[]) => void;
-info: (...args: LogMessage[]) => void;
-debug: (...args: LogMessage[]) => void;
-} | undefined;
-}, UnknownAction>;
-}>, StoreEnhancer]>>;
+ store?: SdkStore;
+ clientId?: string;
+}): SdkStoreHandle;
export { CustomLogger }
@@ -275,22 +169,16 @@ export interface OauthTokens {
}
// @public
-export function oidc(input: {
- config: OidcConfig;
- requestMiddleware?: RequestMiddleware[];
- logger?: {
- level: LogLevel;
- custom?: CustomLogger;
- };
- storage?: Partial;
-}): Promise<{
+export function oidc(raw: RawOidcArgs): Promise void) => Unsubscribe;
authorize: {
url: (options?: GetAuthorizationUrlOptions) => Promise;
@@ -315,6 +203,9 @@ export type OidcClient = Awaited>;
export { OidcConfig }
+// @public (undocumented)
+export type OidcRootState = ReturnType;
+
// @public (undocumented)
export type OptionalAuthorizeOptions = Partial;
@@ -326,6 +217,18 @@ export interface PushAuthorizationResponse {
request_uri: string;
}
+// @public
+export type RawOidcArgs = {
+ config: OidcConfig;
+ requestMiddleware?: RequestMiddleware[];
+ logger?: {
+ level: LogLevel;
+ custom?: CustomLogger;
+ };
+ storage?: Partial;
+ store?: unknown;
+};
+
export { RequestMiddleware }
export { ResponseType_2 as ResponseType }
@@ -343,6 +246,150 @@ export type RevokeSuccessResult = {
deleteResponse: null;
};
+// @public
+export const rootReducer: CombinedSliceReducer< {
+oidc: CombinedState< {
+authorizeFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
+par: MutationDefinition< {
+endpoint: string;
+body: URLSearchParams;
+}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
+sessionCheckIframe: MutationDefinition< {
+url: string;
+responseType: SessionCheckResponseType;
+}, BaseQueryFn, never, {
+params: Record;
+}, "oidc", unknown>;
+sessionCheckFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, {
+status: 204;
+}, "oidc", unknown>;
+authorizeIframe: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
+endSession: MutationDefinition< {
+idToken: string;
+endpoint: string;
+signOutRedirectUri?: string;
+}, BaseQueryFn, never, null, "oidc", unknown>;
+exchange: MutationDefinition< {
+code: string;
+config: OidcConfig;
+endpoint: string;
+verifier?: string;
+}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
+revoke: MutationDefinition< {
+accessToken: string;
+clientId?: string;
+endpoint: string;
+}, BaseQueryFn, never, object, "oidc", unknown>;
+userInfo: MutationDefinition< {
+accessToken: string;
+endpoint: string;
+}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
+}, never, "oidc">;
+wellknown: CombinedState< {
+configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
+}, never, "wellknown">;
+}, {
+oidc: CombinedState< {
+authorizeFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
+par: MutationDefinition< {
+endpoint: string;
+body: URLSearchParams;
+}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
+sessionCheckIframe: MutationDefinition< {
+url: string;
+responseType: SessionCheckResponseType;
+}, BaseQueryFn, never, {
+params: Record;
+}, "oidc", unknown>;
+sessionCheckFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, {
+status: 204;
+}, "oidc", unknown>;
+authorizeIframe: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
+endSession: MutationDefinition< {
+idToken: string;
+endpoint: string;
+signOutRedirectUri?: string;
+}, BaseQueryFn, never, null, "oidc", unknown>;
+exchange: MutationDefinition< {
+code: string;
+config: OidcConfig;
+endpoint: string;
+verifier?: string;
+}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
+revoke: MutationDefinition< {
+accessToken: string;
+clientId?: string;
+endpoint: string;
+}, BaseQueryFn, never, object, "oidc", unknown>;
+userInfo: MutationDefinition< {
+accessToken: string;
+endpoint: string;
+}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
+}, never, "oidc">;
+wellknown: CombinedState< {
+configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
+}, never, "wellknown">;
+}, Partial<{
+oidc: CombinedState< {
+authorizeFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizeSuccessResponse, "oidc", unknown>;
+par: MutationDefinition< {
+endpoint: string;
+body: URLSearchParams;
+}, BaseQueryFn, never, PushAuthorizationResponse, "oidc", unknown>;
+sessionCheckIframe: MutationDefinition< {
+url: string;
+responseType: SessionCheckResponseType;
+}, BaseQueryFn, never, {
+params: Record;
+}, "oidc", unknown>;
+sessionCheckFetch: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, {
+status: 204;
+}, "oidc", unknown>;
+authorizeIframe: MutationDefinition< {
+url: string;
+}, BaseQueryFn, never, AuthorizationSuccess, "oidc", unknown>;
+endSession: MutationDefinition< {
+idToken: string;
+endpoint: string;
+signOutRedirectUri?: string;
+}, BaseQueryFn, never, null, "oidc", unknown>;
+exchange: MutationDefinition< {
+code: string;
+config: OidcConfig;
+endpoint: string;
+verifier?: string;
+}, BaseQueryFn, never, TokenExchangeResponse, "oidc", unknown>;
+revoke: MutationDefinition< {
+accessToken: string;
+clientId?: string;
+endpoint: string;
+}, BaseQueryFn, never, object, "oidc", unknown>;
+userInfo: MutationDefinition< {
+accessToken: string;
+endpoint: string;
+}, BaseQueryFn, never, UserInfoResponse, "oidc", unknown>;
+}, never, "oidc">;
+wellknown: CombinedState< {
+configuration: QueryDefinition, never, WellknownResponse, "wellknown", unknown>;
+}, never, "wellknown">;
+}>>;
+
// @public (undocumented)
export type RootState = ReturnType;
diff --git a/packages/oidc-client/package.json b/packages/oidc-client/package.json
index 0f245c228f..88c981f943 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 0000000000..8101b69076
--- /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.ts b/packages/oidc-client/src/lib/client.store.ts
index dbd80e4221..d53db5d083 100644
--- a/packages/oidc-client/src/lib/client.store.ts
+++ b/packages/oidc-client/src/lib/client.store.ts
@@ -12,17 +12,17 @@ import { causeIsDie, exitIsFail, exitIsSuccess } from 'effect/Micro';
import { authorizeµ, createParAuthorizeUrlµ } from './authorize.request.js';
import { buildTokenExchangeµ } from './exchange.request.js';
-import { createClientStore, createTokenError } from './client.store.utils.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 { sessionCheckNoneµ, sessionCheckIdTokenµ } from './session.micros.js';
-import { wellknownApi, wellknownSelector } from './wellknown.api.js';
+import { wellknownApi, wellknownSelector } from '@forgerock/sdk-store';
-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 { CustomLogger, LogLevel } from '@forgerock/sdk-logger';
+import type { SdkStore } from '@forgerock/sdk-store';
import type { StorageConfig } from '@forgerock/storage';
import type {
@@ -33,63 +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(
@@ -115,6 +105,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 0000000000..5e4b5018e8
--- /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 0000000000..2f3d6a323d
--- /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 a011974bf6..792461a0b2 100644
--- a/packages/oidc-client/src/lib/client.store.utils.ts
+++ b/packages/oidc-client/src/lib/client.store.utils.ts
@@ -7,51 +7,85 @@
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
import { logger as loggerFn } from '@forgerock/sdk-logger';
-import { configureStore, type SerializedError } from '@reduxjs/toolkit';
+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 { GenericError } from '@forgerock/sdk-types';
+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.
@@ -113,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 6a04c919b3..48e77159e5 100644
--- a/packages/oidc-client/src/lib/client.types.ts
+++ b/packages/oidc-client/src/lib/client.types.ts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
@@ -11,7 +11,11 @@ import { oidc } from './client.store.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 1beaf712f4..608bf7e75c 100644
--- a/packages/oidc-client/src/lib/logout.request.test.ts
+++ b/packages/oidc-client/src/lib/logout.request.test.ts
@@ -84,7 +84,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 be666dcf7b..36eb5dc8a2 100644
--- a/packages/oidc-client/src/lib/oidc.api.ts
+++ b/packages/oidc-client/src/lib/oidc.api.ts
@@ -1,5 +1,5 @@
/*
- * Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved.
+ * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
@@ -21,7 +21,8 @@ import {
type RequestMiddleware,
} from '@forgerock/sdk-request-middleware';
-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 { TokenExchangeResponse } from './exchange.types.js';
import type { AuthorizationSuccess, AuthorizeSuccessResponse } from './authorize.request.types.js';
import type { UserInfoResponse } from './client.types.js';
@@ -31,18 +32,43 @@ 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