Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/nice-sails-trade.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions e2e/davinci-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import type {
Collectors,
CustomLogger,
DaVinciConfig,
DavinciClient,
GetClient,
InternalErrorResponse,
NodeStates,
Expand Down Expand Up @@ -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}`);
Expand Down
12 changes: 12 additions & 0 deletions e2e/davinci-app/shared-store.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shared Store Test</title>
</head>
<body>
<div id="status">initialising…</div>
<script type="module" src="./shared-store.ts"></script>
</body>
</html>
62 changes: 62 additions & 0 deletions e2e/davinci-app/shared-store.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
});
1 change: 1 addition & 0 deletions e2e/davinci-app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
84 changes: 84 additions & 0 deletions e2e/davinci-suites/src/shared-store.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 6 additions & 7 deletions e2e/journey-app/main.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export default [
rules: {
'import/extensions': [2, 'ignorePackages'],
'@nx/enforce-module-boundaries': [
'warn',
'error',
{
enforceBuildableLibDependency: true,
allow: [],
Expand Down
Loading
Loading