Skip to content

Commit cff5145

Browse files
committed
feat(sdk-oidc): add pure fetchWellknownConfiguration effect
Add framework-agnostic wellknown configuration fetching to sdk-oidc, following the architectural principle that sdk-effects packages are pure building blocks while *-client packages own RTK integration. Changes: - Add pure fetchWellknownConfiguration() effect to sdk-oidc - Create local wellknown.api.ts in journey-client, davinci-client - Update oidc-client with its own wellknown RTK integration - Each client wraps the pure effect in RTK Query BaseQueryFn - Fix vitest/@effect/vitest version compatibility (vitest ^3.2.0) - Fix MockInstance type for vitest 3.x compatibility Architecture: - sdk-effects/oidc: Pure async functions, no framework deps - *-client packages: Wrap effects in RTK Query BaseQueryFn
1 parent 60cd9a0 commit cff5145

21 files changed

Lines changed: 926 additions & 572 deletions

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@
8484
"@typescript-eslint/parser": "^8.45.0",
8585
"@typescript-eslint/typescript-estree": "8.23.0",
8686
"@typescript-eslint/utils": "^8.13.0",
87-
"@vitest/coverage-v8": "4.0.9",
88-
"@vitest/ui": "4.0.9",
87+
"@vitest/coverage-v8": "3.2.4",
88+
"@vitest/ui": "3.2.4",
8989
"conventional-changelog-conventionalcommits": "^8.0.0",
9090
"cz-conventional-changelog": "^3.3.0",
9191
"cz-git": "^1.6.1",

packages/davinci-client/src/lib/client.store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import { createClientStore, handleUpdateValidateError, RootState } from './clien
1515
import { nodeSlice } from './node.slice.js';
1616
import { davinciApi } from './davinci.api.js';
1717
import { configSlice } from './config.slice.js';
18-
import { wellknownApi, createWellknownError } from '@forgerock/sdk-oidc';
18+
import { wellknownApi } from './wellknown.api.js';
19+
import { createWellknownError } from './wellknown.utils.js';
1920

2021
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
2122
/**

packages/davinci-client/src/lib/client.store.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { configSlice } from './config.slice.js';
1313
import { nodeSlice } from './node.slice.js';
1414
import { davinciApi } from './davinci.api.js';
1515
import { ErrorNode, ContinueNode, StartNode, SuccessNode } from '../types.js';
16-
import { wellknownApi } from '@forgerock/sdk-oidc';
16+
import { wellknownApi } from './wellknown.api.js';
1717
import { InternalErrorResponse } from './client.types.js';
1818

1919
export function createClientStore<ActionType extends ActionTypes>({
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
8+
import { createSelector } from '@reduxjs/toolkit';
9+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
10+
import { fetchWellknownConfiguration } from '@forgerock/sdk-oidc';
11+
12+
import type { WellknownResponse, GenericError } from '@forgerock/sdk-types';
13+
import type { BaseQueryFn, FetchBaseQueryError } from '@reduxjs/toolkit/query';
14+
15+
function isObject(value: unknown): value is Record<string, unknown> {
16+
return typeof value === 'object' && value !== null;
17+
}
18+
19+
/**
20+
* Converts FetchBaseQueryError to GenericError.
21+
*/
22+
function toGenericError(error: FetchBaseQueryError): GenericError {
23+
const status = error.status;
24+
let message = `HTTP error ${String(status)}`;
25+
26+
if ('error' in error) {
27+
message = error.error;
28+
} else if ('data' in error && isObject(error.data)) {
29+
if (typeof error.data['message'] === 'string') message = error.data['message'];
30+
else if (typeof error.data['error'] === 'string') message = error.data['error'];
31+
else if (typeof error.data['error_description'] === 'string')
32+
message = error.data['error_description'];
33+
else message = JSON.stringify(error.data);
34+
}
35+
36+
return {
37+
error: 'Well-known configuration fetch failed',
38+
message,
39+
type: 'wellknown_error',
40+
status,
41+
};
42+
}
43+
44+
/**
45+
* Configured fetchBaseQuery that sets `Accept: application/json` headers.
46+
*/
47+
const innerBaseQuery = fetchBaseQuery({
48+
prepareHeaders: (headers) => {
49+
headers.set('Accept', 'application/json');
50+
return headers;
51+
},
52+
});
53+
54+
/**
55+
* BaseQuery wrapper that normalizes FetchBaseQueryError to GenericError.
56+
*
57+
* This allows the wellknownApi to use GenericError as its error type
58+
* while the actual HTTP transport goes through RTK Query's pipeline.
59+
*/
60+
const wellknownBaseQuery: BaseQueryFn<string, unknown, GenericError> = async (
61+
args,
62+
api,
63+
extraOptions,
64+
) => {
65+
const result = await innerBaseQuery(args, api, extraOptions);
66+
if (result.error) {
67+
return { ...result, error: toGenericError(result.error) };
68+
}
69+
return result;
70+
};
71+
72+
/**
73+
* RTK Query API for well-known endpoint discovery.
74+
*
75+
* Uses `queryFn` to pass the baseQuery into the framework-agnostic
76+
* `fetchWellknownConfiguration` effect, which handles response validation.
77+
* The baseQuery handles HTTP transport.
78+
*/
79+
export const wellknownApi = createApi({
80+
reducerPath: 'wellknown',
81+
baseQuery: wellknownBaseQuery,
82+
endpoints: (builder) => ({
83+
configuration: builder.query<WellknownResponse, string>({
84+
queryFn: async (url, _api, _extra, baseQuery) => {
85+
const result = await fetchWellknownConfiguration(url, baseQuery);
86+
return result.success ? { data: result.data } : { error: result.error };
87+
},
88+
}),
89+
}),
90+
});
91+
92+
/**
93+
* Creates a memoized selector for cached well-known data.
94+
*
95+
* @param wellknownUrl - The well-known endpoint URL used as the cache key
96+
* @returns A memoized selector that extracts the WellknownResponse from state, or undefined if not yet fetched
97+
*/
98+
export function createWellknownSelector(wellknownUrl: string) {
99+
return createSelector(
100+
wellknownApi.endpoints.configuration.select(wellknownUrl),
101+
(result) => result?.data,
102+
);
103+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
8+
import type { SerializedError } from '@reduxjs/toolkit';
9+
import type { GenericError } from '@forgerock/sdk-types';
10+
11+
/**
12+
* Type guard to check if an error is already a GenericError.
13+
*
14+
* GenericError from our custom wellknownBaseQuery has: error, message, type, status
15+
*/
16+
function isGenericError(error: unknown): error is GenericError {
17+
return (
18+
typeof error === 'object' &&
19+
error !== null &&
20+
'type' in error &&
21+
'message' in error &&
22+
'error' in error
23+
);
24+
}
25+
26+
/**
27+
* Creates a GenericError from an RTK Query error for well-known fetch failures.
28+
*
29+
* Since the wellknownApi uses a custom baseQuery that returns GenericError directly,
30+
* this function handles both GenericError (from successful error responses) and
31+
* SerializedError (from unexpected JS errors during the fetch).
32+
*
33+
* @param error - The error from RTK Query dispatch result, or undefined if no response
34+
* @returns A GenericError with type 'wellknown_error'
35+
*
36+
* @example
37+
* ```typescript
38+
* const { data, error } = await store.dispatch(
39+
* wellknownApi.endpoints.configuration.initiate(url)
40+
* );
41+
*
42+
* if (error || !data) {
43+
* const genericError = createWellknownError(error);
44+
* log.error(genericError.message);
45+
* throw new Error(genericError.message);
46+
* }
47+
* ```
48+
*/
49+
export function createWellknownError(error?: GenericError | SerializedError): GenericError {
50+
if (!error) {
51+
return {
52+
error: 'Well-known configuration fetch failed',
53+
message: 'No response received from well-known endpoint',
54+
type: 'wellknown_error',
55+
status: 'unknown',
56+
};
57+
}
58+
59+
// If it's already a GenericError from our custom baseQuery, return it directly
60+
if (isGenericError(error)) {
61+
return error;
62+
}
63+
64+
// SerializedError from unexpected JS errors
65+
return {
66+
error: 'Well-known configuration fetch failed',
67+
message: error.message ?? 'An unknown error occurred',
68+
type: 'wellknown_error',
69+
status: 'unknown',
70+
};
71+
}

packages/journey-client/src/lib/client.store.test.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,21 @@ const mockConfig: JourneyClientConfig = {
5757
// realmPath will be inferred from issuer as 'root'
5858
};
5959

60+
/**
61+
* Extracts URL from fetch input (handles both string URLs and Request objects).
62+
*/
63+
function getUrlFromInput(input: RequestInfo | URL): string {
64+
if (typeof input === 'string') return input;
65+
if (input instanceof Request) return input.url;
66+
return input.toString();
67+
}
68+
6069
/**
6170
* Helper to setup mock fetch for wellknown + journey responses
6271
*/
6372
function setupMockFetch(journeyResponse: Step | null = null) {
64-
mockFetch.mockImplementation((request: Request) => {
65-
const url = request.url;
73+
mockFetch.mockImplementation((input: RequestInfo | URL) => {
74+
const url = getUrlFromInput(input);
6675

6776
// Wellknown endpoint
6877
if (url.includes('.well-known/openid-configuration')) {
@@ -150,7 +159,9 @@ describe('journey-client', () => {
150159
// Assert
151160
expect(step).toBeDefined();
152161
expect(mockFetch).toHaveBeenCalledTimes(2); // wellknown + start
153-
const requests = mockFetch.mock.calls.map((call) => (call[0] as Request).url);
162+
const requests = mockFetch.mock.calls.map((call) =>
163+
getUrlFromInput(call[0] as RequestInfo | URL),
164+
);
154165
expect(requests[0]).toContain('.well-known/openid-configuration');
155166
expect(requests[1]).toBe('https://test.com/am/json/realms/root/authenticate');
156167
expect(step).toHaveProperty('type', 'Step');
@@ -341,8 +352,9 @@ describe('journey-client', () => {
341352
},
342353
};
343354
const mockStepResponse: Step = { authId: 'test-auth-id', callbacks: [] };
344-
mockFetch.mockImplementation((request: Request) => {
345-
if (request.url.includes('.well-known')) {
355+
mockFetch.mockImplementation((input: RequestInfo | URL) => {
356+
const url = getUrlFromInput(input);
357+
if (url.includes('.well-known')) {
346358
return Promise.resolve(
347359
new Response(
348360
JSON.stringify({
@@ -377,8 +389,9 @@ describe('journey-client', () => {
377389
},
378390
};
379391
const mockStepResponse: Step = { authId: 'test-auth-id', callbacks: [] };
380-
mockFetch.mockImplementation((request: Request) => {
381-
if (request.url.includes('.well-known')) {
392+
mockFetch.mockImplementation((input: RequestInfo | URL) => {
393+
const url = getUrlFromInput(input);
394+
if (url.includes('.well-known')) {
382395
return Promise.resolve(
383396
new Response(
384397
JSON.stringify({
@@ -412,8 +425,9 @@ describe('journey-client', () => {
412425
realmPath: 'beta', // Explicit override
413426
};
414427
const mockStepResponse: Step = { authId: 'test-auth-id', callbacks: [] };
415-
mockFetch.mockImplementation((request: Request) => {
416-
if (request.url.includes('.well-known')) {
428+
mockFetch.mockImplementation((input: RequestInfo | URL) => {
429+
const url = getUrlFromInput(input);
430+
if (url.includes('.well-known')) {
417431
return Promise.resolve(
418432
new Response(
419433
JSON.stringify({

packages/journey-client/src/lib/client.store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import { journeyApi } from './journey.api.js';
1919
import { setConfig } from './journey.slice.js';
2020
import { createStorage } from '@forgerock/storage';
2121
import { createJourneyObject } from './journey.utils.js';
22-
import { wellknownApi, createWellknownError } from '@forgerock/sdk-oidc';
22+
import { wellknownApi } from './wellknown.api.js';
23+
import { createWellknownError } from './wellknown.utils.js';
2324
import { isValidWellknownUrl } from '@forgerock/sdk-utilities';
2425
import { inferRealmFromIssuer, inferBaseUrlFromWellknown } from './wellknown.utils.js';
2526

packages/journey-client/src/lib/client.store.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit';
1111

1212
import { journeyApi } from './journey.api.js';
1313
import { journeySlice } from './journey.slice.js';
14-
import { wellknownApi } from '@forgerock/sdk-oidc';
14+
import { wellknownApi } from './wellknown.api.js';
1515

1616
const rootReducer = combineReducers({
1717
[journeyApi.reducerPath]: journeyApi.reducer,

packages/journey-client/src/lib/device/device-profile.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* This software may be modified and distributed under the terms
88
* of the MIT license. See the LICENSE file for details.
99
*/
10-
import { vi, expect, describe, it, afterEach, beforeEach, Mock } from 'vitest';
10+
import { vi, expect, describe, it, afterEach, beforeEach, type MockInstance } from 'vitest';
1111

1212
import { Device } from './device-profile.js';
1313

@@ -86,10 +86,7 @@ describe('Test DeviceProfile', () => {
8686
});
8787

8888
describe('logLevel tests', () => {
89-
let warnSpy: Mock<{
90-
(...data: unknown[]): void;
91-
(message?: string, ...optionalParams: unknown[]): void;
92-
}>;
89+
let warnSpy: MockInstance;
9390
const originalNavigator = global.navigator;
9491

9592
beforeEach(() => {

0 commit comments

Comments
 (0)