Skip to content
Draft
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.
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);
});
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
85 changes: 58 additions & 27 deletions packages/davinci-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 **/);
Expand All @@ -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:
Expand Down Expand Up @@ -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));
}
});
```
Expand Down Expand Up @@ -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(
Expand All @@ -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));
Expand All @@ -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.
Expand All @@ -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);
}
```

Expand All @@ -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();
}
```

Expand Down
Loading
Loading