diff --git a/.changeset/sdks-5067-unified-config.md b/.changeset/sdks-5067-unified-config.md new file mode 100644 index 0000000000..6e4d6fbff9 --- /dev/null +++ b/.changeset/sdks-5067-unified-config.md @@ -0,0 +1,58 @@ +--- +'@forgerock/sdk-utilities': minor +'@forgerock/sdk-types': minor +'@forgerock/sdk-logger': patch +'@forgerock/sdk-oidc': minor +'@forgerock/oidc-client': minor +'@forgerock/journey-client': minor +'@forgerock/davinci-client': minor +--- + +Add unified cross-platform SDK configuration support + +New utility functions in `@forgerock/sdk-utilities` convert the cross-platform unified JSON config schema into each client's native config shape. Validation and mapping are owned entirely by the utilities layer — client factories remain typed to their existing config interfaces. + +**New in `@forgerock/sdk-utilities`:** + +- `makeOidcConfig(json)` — validates and maps unified JSON → `OidcConfig`; throws on invalid input +- `makeJourneyConfig(json)` — validates and maps unified JSON → `JourneyClientConfig`; throws on invalid input +- `makeDavinciConfig(json)` — validates and maps unified JSON → `DaVinciConfig`; throws on invalid input +- `UnifiedSdkConfig`, `UnifiedOidcConfig`, `UnifiedJourneyConfig` types +- `validateUnifiedSdkConfig` / `validateUnifiedOidcConfig` — pure validation returning `Either` +- `unifiedToOidcConfig`, `unifiedToJourneyConfig`, `unifiedToDavinciConfig` — pure mappers returning `Either` +- `AuthDisplayValue`, `AuthPromptValue` types (canonical source — shared between `OidcConfig` and `GetAuthorizationUrlOptions`) + +**Usage:** + +```ts +import { makeDavinciConfig } from '@forgerock/sdk-utilities'; + +const client = await davinci({ config: makeDavinciConfig(unifiedJsonConfig) }); +``` + +**New in `@forgerock/sdk-types`:** + +- `OidcConfig`, `JourneyClientConfig`, `DaVinciConfig` moved here as canonical types (previously mirrored in `sdk-utilities` as `Mapped*` types) +- `AuthDisplayValue`, `AuthPromptValue` types added (renamed from `OidcDisplayValue`/`OidcPromptValue`) +- `GetAuthorizationUrlOptions` extended with `loginHint`, `nonce`, `display`, `uiLocales`, `acrValues`; `prompt` widened to include `'select_account'` + +**Updated in `@forgerock/sdk-logger`:** + +- `LogLevel` now re-exported from `@forgerock/sdk-types` (single source of truth); runtime behaviour unchanged + +**New in `@forgerock/sdk-oidc`:** + +- `buildAuthorizeParams` forwards all new OIDC authorize params into the URL + +**New in `@forgerock/oidc-client`:** + +- `endSession` appends `post_logout_redirect_uri` when `signOutRedirectUri` is set on config +- Authorize URL construction forwards `loginHint`, `state`, `nonce`, `display`, `prompt`, `uiLocales`, `acrValues`, `additionalParameters` from config + +**New in `@forgerock/journey-client`:** + +- No API change — consume `makeJourneyConfig` at call-site to use unified JSON config + +**New in `@forgerock/davinci-client`:** + +- No API change — consume `makeDavinciConfig` at call-site to use unified JSON config diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index 85ef2b1930..4ba574e878 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -6,8 +6,8 @@ import { ActionCreatorWithPayload } from '@reduxjs/toolkit'; import { ActionTypes } from '@forgerock/sdk-request-middleware'; -import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; import { CustomLogger } from '@forgerock/sdk-logger'; +import type { DaVinciConfig } from '@forgerock/sdk-types'; import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryMeta } from '@reduxjs/toolkit/query'; import { GenericError } from '@forgerock/sdk-types'; @@ -289,13 +289,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -309,6 +307,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -319,7 +319,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -328,8 +328,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -345,6 +343,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -506,11 +506,7 @@ export type DaVinciCacheEntry = { // @public (undocumented) export type DavinciClient = Awaited>; -// @public (undocumented) -export interface DaVinciConfig extends AsyncLegacyConfigOptions { - // (undocumented) - responseType?: string; -} +export { DaVinciConfig } // @public (undocumented) export interface DaVinciError extends Omit { 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 7f0ca20f1c..16f628e01a 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -6,8 +6,8 @@ import { ActionCreatorWithPayload } from '@reduxjs/toolkit'; import { ActionTypes } from '@forgerock/sdk-request-middleware'; -import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; import { CustomLogger } from '@forgerock/sdk-logger'; +import type { DaVinciConfig } from '@forgerock/sdk-types'; import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryMeta } from '@reduxjs/toolkit/query'; import { GenericError } from '@forgerock/sdk-types'; @@ -289,13 +289,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -309,6 +307,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -319,7 +319,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -328,8 +328,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -345,6 +343,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -506,11 +506,7 @@ export type DaVinciCacheEntry = { // @public (undocumented) export type DavinciClient = Awaited>; -// @public (undocumented) -export interface DaVinciConfig extends AsyncLegacyConfigOptions { - // (undocumented) - responseType?: string; -} +export { DaVinciConfig } // @public (undocumented) export interface DaVinciError extends Omit { diff --git a/packages/davinci-client/src/lib/client.store.test.ts b/packages/davinci-client/src/lib/client.store.test.ts index 8d4e647b1f..ef6c2a9623 100644 --- a/packages/davinci-client/src/lib/client.store.test.ts +++ b/packages/davinci-client/src/lib/client.store.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { davinci } from './client.store.js'; +import { makeDavinciConfig } from '@forgerock/sdk-utilities'; import type { DaVinciConfig } from './config.types.js'; // --------------------------------------------------------------------------- @@ -181,3 +182,59 @@ describe('davinci client — cache', () => { }); }); }); + +// --------------------------------------------------------------------------- + +describe('unified JSON config entry', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', makeStorageStub()); + vi.stubGlobal('sessionStorage', makeStorageStub()); + mockFetchImplementation(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('accepts unified JSON config and initializes successfully', async () => { + const unifiedConfig = { + oidc: { + clientId: '123456789', + discoveryEndpoint: TEST_WELLKNOWN_URL, + scopes: ['openid', 'profile'], + redirectUri: 'https://example.com/callback', + }, + }; + + const client = await davinci({ config: makeDavinciConfig(unifiedConfig) }); + expect(client).toHaveProperty('flow'); + expect(client).toHaveProperty('subscribe'); + }); + + it('throws when unified JSON config has missing required field', async () => { + const invalidConfig = { + oidc: { + // clientId missing + discoveryEndpoint: TEST_WELLKNOWN_URL, + scopes: ['openid'], + redirectUri: 'https://example.com/callback', + }, + }; + + expect(() => makeDavinciConfig(invalidConfig)).toThrow(/Invalid unified SDK config/); + }); + + it('throws when unified JSON config has wrong field type', async () => { + const invalidConfig = { + oidc: { + clientId: '123', + discoveryEndpoint: TEST_WELLKNOWN_URL, + scopes: 'openid', // should be array + redirectUri: 'https://example.com/callback', + }, + }; + + expect(() => makeDavinciConfig(invalidConfig)).toThrow(/Invalid unified SDK config/); + }); +}); diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index dc78d00ae0..a95401dd91 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.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. @@ -77,7 +77,10 @@ export async function davinci({ custom?: CustomLogger; }; }) { - const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); + const log = loggerFn({ + level: logger?.level ?? config.log ?? 'error', + custom: logger?.custom, + }); const store = createClientStore({ requestMiddleware, logger: log }); const serverInfo = createStorage({ type: 'localStorage', diff --git a/packages/davinci-client/src/lib/config.types.ts b/packages/davinci-client/src/lib/config.types.ts index 9a16e5940b..c4ecb63a5d 100644 --- a/packages/davinci-client/src/lib/config.types.ts +++ b/packages/davinci-client/src/lib/config.types.ts @@ -1,15 +1,14 @@ /* - * 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. */ -import type { AsyncLegacyConfigOptions, WellknownResponse } from '@forgerock/sdk-types'; +import type { WellknownResponse } from '@forgerock/sdk-types'; +import type { DaVinciConfig } from '@forgerock/sdk-types'; -export interface DaVinciConfig extends AsyncLegacyConfigOptions { - responseType?: string; -} +export type { DaVinciConfig }; export interface InternalDaVinciConfig extends DaVinciConfig { wellknownResponse: WellknownResponse; diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md index d0dce2aae6..35a4a51dbe 100644 --- a/packages/journey-client/api-report/journey-client.api.md +++ b/packages/journey-client/api-report/journey-client.api.md @@ -5,7 +5,6 @@ ```ts import { ActionTypes } from '@forgerock/sdk-request-middleware'; -import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; import { AuthResponse } from '@forgerock/sdk-types'; import { Callback } from '@forgerock/sdk-types'; import { CallbackType } from '@forgerock/sdk-types'; @@ -16,6 +15,8 @@ import { FailedPolicyRequirement } from '@forgerock/sdk-types'; import { FailureDetail } from '@forgerock/sdk-types'; import { GenericError } from '@forgerock/sdk-types'; import { isValidWellknownUrl } from '@forgerock/sdk-utilities'; +import { JourneyClientConfig } from '@forgerock/sdk-types'; +import { JourneyServerConfig } from '@forgerock/sdk-types'; import { LogLevel } from '@forgerock/sdk-logger'; import { NameValue } from '@forgerock/sdk-types'; import { PolicyKey } from '@forgerock/sdk-types'; @@ -203,11 +204,7 @@ export interface JourneyClient { }) => Promise; } -// @public -export interface JourneyClientConfig extends AsyncLegacyConfigOptions { - // (undocumented) - serverConfig: JourneyServerConfig; -} +export { JourneyClientConfig } // @public (undocumented) export type JourneyLoginFailure = AuthResponse & { @@ -232,11 +229,7 @@ export type JourneyLoginSuccess = AuthResponse & { // @public (undocumented) export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError; -// @public -export interface JourneyServerConfig { - timeout?: number; - wellknown: string; -} +export { JourneyServerConfig } // @public (undocumented) export type JourneyStep = AuthResponse & { 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 2c6c1c8791..d9219a9710 100644 --- a/packages/journey-client/api-report/journey-client.types.api.md +++ b/packages/journey-client/api-report/journey-client.types.api.md @@ -5,7 +5,6 @@ ```ts import { ActionTypes } from '@forgerock/sdk-request-middleware'; -import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; import { AuthResponse } from '@forgerock/sdk-types'; import { Callback } from '@forgerock/sdk-types'; import { CallbackType } from '@forgerock/sdk-types'; @@ -15,6 +14,8 @@ import { FailedPolicyRequirement } from '@forgerock/sdk-types'; import { FailureDetail } from '@forgerock/sdk-types'; import { GenericError } from '@forgerock/sdk-types'; import { isValidWellknownUrl } from '@forgerock/sdk-utilities'; +import { JourneyClientConfig } from '@forgerock/sdk-types'; +import { JourneyServerConfig } from '@forgerock/sdk-types'; import { LogLevel } from '@forgerock/sdk-logger'; import { NameValue } from '@forgerock/sdk-types'; import { PolicyKey } from '@forgerock/sdk-types'; @@ -190,11 +191,7 @@ export interface JourneyClient { }) => Promise; } -// @public -export interface JourneyClientConfig extends AsyncLegacyConfigOptions { - // (undocumented) - serverConfig: JourneyServerConfig; -} +export { JourneyClientConfig } // @public (undocumented) export type JourneyLoginFailure = AuthResponse & { @@ -219,11 +216,7 @@ export type JourneyLoginSuccess = AuthResponse & { // @public (undocumented) export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError; -// @public -export interface JourneyServerConfig { - timeout?: number; - wellknown: string; -} +export { JourneyServerConfig } // @public (undocumented) export type JourneyStep = AuthResponse & { diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts index 3d150328bf..7d0ddf9345 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node /* - * 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. @@ -9,6 +9,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { journey } from './client.store.js'; +import { makeJourneyConfig } from '@forgerock/sdk-utilities'; import { createJourneyStep } from './step.utils.js'; import { callbackType, type GenericError, type Step, type WellknownResponse } from '../index.js'; @@ -559,4 +560,46 @@ describe('journey-client', () => { expect(request.url).toBe('https://test.com/am/json/realms/root/realms/alpha/authenticate'); }); }); + + describe('unified JSON config entry', () => { + test('accepts unified JSON config and initializes successfully', async () => { + setupMockFetch(); + + const unifiedConfig = { + oidc: { + clientId: 'ignored-by-journey', + discoveryEndpoint: mockWellknownUrl, + scopes: ['openid'], + redirectUri: 'https://example.com/callback', + }, + }; + + const client = await journey({ config: makeJourneyConfig(unifiedConfig) }); + expect(client).toHaveProperty('start'); + expect(client).toHaveProperty('next'); + }); + + test('throws when unified JSON config has missing required field', async () => { + const invalidConfig = { + oidc: { + // discoveryEndpoint missing — required even for journey + }, + }; + + expect(() => makeJourneyConfig(invalidConfig)).toThrow(/Invalid unified SDK config/); + }); + + test('throws when unified JSON config has wrong field type', async () => { + const invalidConfig = { + oidc: { + clientId: '123', + discoveryEndpoint: mockWellknownUrl, + scopes: 'openid', // should be array + redirectUri: 'https://example.com/callback', + }, + }; + + expect(() => makeJourneyConfig(invalidConfig)).toThrow(/Invalid unified SDK config/); + }); + }); }); diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 19d1fabe70..32c2689a64 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.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. @@ -12,7 +12,6 @@ import { isValidWellknownUrl, createWellknownError, } from '@forgerock/sdk-utilities'; - import type { GenericError } from '@forgerock/sdk-types'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; import type { Step } from '@forgerock/sdk-types'; @@ -82,7 +81,10 @@ export async function journey({ custom?: CustomLogger; }; }): Promise { - const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); + const log = loggerFn({ + level: logger?.level ?? config.log ?? 'error', + custom: logger?.custom, + }); const ignoredProperties = [ 'callbackFactory', diff --git a/packages/journey-client/src/lib/config.types.ts b/packages/journey-client/src/lib/config.types.ts index 25eaeaa888..34dc7ec0e4 100644 --- a/packages/journey-client/src/lib/config.types.ts +++ b/packages/journey-client/src/lib/config.types.ts @@ -1,46 +1,14 @@ /* - * 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. */ -import type { AsyncLegacyConfigOptions, GenericError } from '@forgerock/sdk-types'; +import type { GenericError } from '@forgerock/sdk-types'; import type { ResolvedServerConfig } from './wellknown.utils.js'; -/** - * Server configuration for journey-client. - * - * Only the OIDC discovery endpoint URL is required. All other configuration - * (baseUrl, paths) is automatically derived from the well-known response. - */ -export interface JourneyServerConfig { - /** Required OIDC discovery endpoint URL */ - wellknown: string; - /** Optional request timeout in milliseconds. Included for config-sharing compatibility with other clients. */ - timeout?: number; -} - -/** - * Configuration for creating a journey client instance. - * - * Extends {@link AsyncLegacyConfigOptions} so that the same config object can - * be shared across journey-client, davinci-client, and oidc-client. Properties - * like `clientId`, `scope`, and `redirectUri` are accepted but not used by - * journey-client — a warning is logged when they are provided. - * - * @example - * ```typescript - * const config: JourneyClientConfig = { - * serverConfig: { - * wellknown: 'https://am.example.com/am/oauth2/alpha/.well-known/openid-configuration', - * }, - * }; - * ``` - */ -export interface JourneyClientConfig extends AsyncLegacyConfigOptions { - serverConfig: JourneyServerConfig; -} +export type { JourneyServerConfig, JourneyClientConfig } from '@forgerock/sdk-types'; /** * Internal configuration after wellknown discovery and path resolution. diff --git a/packages/oidc-client/api-report/oidc-client.api.md b/packages/oidc-client/api-report/oidc-client.api.md index 8ee68af5a5..02a90f352a 100644 --- a/packages/oidc-client/api-report/oidc-client.api.md +++ b/packages/oidc-client/api-report/oidc-client.api.md @@ -5,7 +5,6 @@ ```ts import { ActionTypes } from '@forgerock/sdk-request-middleware'; -import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; import { BaseQueryFn } from '@reduxjs/toolkit/query'; import { CombinedState } from '@reduxjs/toolkit/query'; import { CustomLogger } from '@forgerock/sdk-logger'; @@ -20,6 +19,7 @@ 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'; @@ -146,6 +146,7 @@ url: string; endSession: MutationDefinition< { idToken: string; endpoint: string; +signOutRedirectUri?: string; }, BaseQueryFn, never, null, "oidc", unknown>; exchange: MutationDefinition< { code: string; @@ -193,6 +194,7 @@ url: string; endSession: MutationDefinition< { idToken: string; endpoint: string; +signOutRedirectUri?: string; }, BaseQueryFn, never, null, "oidc", unknown>; exchange: MutationDefinition< { code: string; @@ -311,24 +313,7 @@ export function oidc(input: { // @public (undocumented) export type OidcClient = Awaited>; -// @public (undocumented) -export interface OidcConfig extends AsyncLegacyConfigOptions { - // (undocumented) - clientId: string; - // (undocumented) - par?: boolean; - // (undocumented) - redirectUri: string; - // (undocumented) - responseType?: ResponseType_2; - // (undocumented) - scope: string; - // (undocumented) - serverConfig: { - wellknown: string; - timeout?: number; - }; -} +export { OidcConfig } // @public (undocumented) export type OptionalAuthorizeOptions = Partial; 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 8ee68af5a5..02a90f352a 100644 --- a/packages/oidc-client/api-report/oidc-client.types.api.md +++ b/packages/oidc-client/api-report/oidc-client.types.api.md @@ -5,7 +5,6 @@ ```ts import { ActionTypes } from '@forgerock/sdk-request-middleware'; -import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; import { BaseQueryFn } from '@reduxjs/toolkit/query'; import { CombinedState } from '@reduxjs/toolkit/query'; import { CustomLogger } from '@forgerock/sdk-logger'; @@ -20,6 +19,7 @@ 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'; @@ -146,6 +146,7 @@ url: string; endSession: MutationDefinition< { idToken: string; endpoint: string; +signOutRedirectUri?: string; }, BaseQueryFn, never, null, "oidc", unknown>; exchange: MutationDefinition< { code: string; @@ -193,6 +194,7 @@ url: string; endSession: MutationDefinition< { idToken: string; endpoint: string; +signOutRedirectUri?: string; }, BaseQueryFn, never, null, "oidc", unknown>; exchange: MutationDefinition< { code: string; @@ -311,24 +313,7 @@ export function oidc(input: { // @public (undocumented) export type OidcClient = Awaited>; -// @public (undocumented) -export interface OidcConfig extends AsyncLegacyConfigOptions { - // (undocumented) - clientId: string; - // (undocumented) - par?: boolean; - // (undocumented) - redirectUri: string; - // (undocumented) - responseType?: ResponseType_2; - // (undocumented) - scope: string; - // (undocumented) - serverConfig: { - wellknown: string; - timeout?: number; - }; -} +export { OidcConfig } // @public (undocumented) export type OptionalAuthorizeOptions = Partial; diff --git a/packages/oidc-client/src/lib/authorize.request.micros.ts b/packages/oidc-client/src/lib/authorize.request.micros.ts index 8c31bd2cc9..43db85a0cf 100644 --- a/packages/oidc-client/src/lib/authorize.request.micros.ts +++ b/packages/oidc-client/src/lib/authorize.request.micros.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. @@ -17,8 +17,8 @@ import { hasPushRequestUri, isFetchBaseQueryError, toDispatchError, - type PromptValue, } from './authorize.request.utils.js'; +import type { AuthPromptValue } from '@forgerock/sdk-utilities'; import { oidcApi } from './oidc.api.js'; @@ -91,7 +91,7 @@ export const buildParBodyµ = ( parBodyOptions: OptionalAuthorizeOptions, challenge: string, state: string, - prompt?: PromptValue, + prompt?: AuthPromptValue, ): Micro.Micro => { return Micro.try({ try: () => @@ -216,7 +216,7 @@ export const buildParSlimUrlµ = ( authorizationEndpoint: string, clientId: string, requestUri: string, - prompt?: PromptValue, + prompt?: AuthPromptValue, ): Micro.Micro => { return Micro.try({ try: () => buildParAuthorizeUrl({ authorizationEndpoint, clientId, requestUri, prompt }), diff --git a/packages/oidc-client/src/lib/authorize.request.utils.test.ts b/packages/oidc-client/src/lib/authorize.request.utils.test.ts index f850406461..d267eb593f 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.test.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.test.ts @@ -1,12 +1,12 @@ /* - * 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. */ -import { it, expect } from '@effect/vitest'; +import { it } from '@effect/vitest'; import { Micro } from 'effect'; -import { vi, afterEach } from 'vitest'; +import { vi, afterEach, expect } from 'vitest'; import * as sdkOidc from '@forgerock/sdk-oidc'; import { createParAuthorizeUrlµ, authorizeµ } from './authorize.request.js'; import { diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index ae70d9874f..69e23383e3 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.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. @@ -7,16 +7,15 @@ import type { SerializedError } from '@reduxjs/toolkit'; import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { WellknownResponse, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; +import type { AuthPromptValue } from '@forgerock/sdk-utilities'; import type { AuthorizationError, OptionalAuthorizeOptions } from './authorize.request.types.js'; import type { OidcConfig } from './config.types.js'; -export type PromptValue = 'none' | 'login' | 'consent'; - export type ParUrlParams = { authorizationEndpoint: string; clientId: string; requestUri: string; - prompt?: PromptValue; + prompt?: AuthPromptValue; }; export function isStringRecord(value: unknown): value is Record { diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index d6d543ac15..57bde90332 100644 --- a/packages/oidc-client/src/lib/client.store.test.ts +++ b/packages/oidc-client/src/lib/client.store.test.ts @@ -10,6 +10,7 @@ import { setupServer } from 'msw/node'; import { it, expect, describe, vi, beforeEach, afterEach, afterAll, beforeAll } from 'vitest'; import { oidc } from './client.store.js'; +import { makeOidcConfig } from '@forgerock/sdk-utilities'; import type { OidcConfig } from './config.types.js'; @@ -719,6 +720,85 @@ describe('authorize.url() with PAR enabled on non-pi.flow server', async () => { }); }); +describe('unified JSON config entry', () => { + it('accepts unified JSON config and initializes successfully', async () => { + const unifiedConfig = { + oidc: { + clientId: '123456789', + discoveryEndpoint: 'https://api.example.com/wellknown', + scopes: ['openid', 'profile'], + redirectUri: 'https://example.com/callback.html', + }, + }; + + const client = await oidc({ + config: makeOidcConfig(unifiedConfig), + storage: customStorageConfig, + }); + expect(client).not.toHaveProperty('error'); + expect(client).toHaveProperty('authorize'); + expect(client).toHaveProperty('token'); + }); + + it('rejects Promise when unified JSON config has missing required fields', async () => { + const invalidConfig = { + oidc: { + // clientId missing + discoveryEndpoint: 'https://api.example.com/wellknown', + scopes: ['openid'], + redirectUri: 'https://example.com/callback.html', + }, + }; + + expect(() => makeOidcConfig(invalidConfig)).toThrow(/Invalid unified SDK config/); + }); + + it('rejects Promise when unified JSON config has wrong field type', async () => { + const invalidConfig = { + oidc: { + clientId: '123456789', + discoveryEndpoint: 'https://api.example.com/wellknown', + scopes: 'openid', // deliberately wrong type to test runtime validation + redirectUri: 'https://example.com/callback.html', + }, + }; + + expect(() => makeOidcConfig(invalidConfig)).toThrow(/Invalid unified SDK config/); + }); + + it('surfaces authorize params from unified JSON config in authorize URL', async () => { + const unifiedConfig = { + oidc: { + clientId: '123456789', + discoveryEndpoint: 'https://api.example.com/wellknown', + scopes: ['openid', 'profile'], + redirectUri: 'https://example.com/callback.html', + loginHint: 'user@example.com', + nonce: 'my-nonce', + acrValues: 'Level3', + additionalParameters: { max_age: '3600' }, + }, + }; + + const client = await oidc({ + config: makeOidcConfig(unifiedConfig), + storage: customStorageConfig, + }); + + if ('error' in client) throw new Error('Error creating OIDC client'); + + const url = await client.authorize.url(); + + if (typeof url !== 'string') expect.fail(`Expected string URL, got: ${JSON.stringify(url)}`); + + const parsed = new URL(url); + expect(parsed.searchParams.get('login_hint')).toBe('user@example.com'); + expect(parsed.searchParams.get('nonce')).toBe('my-nonce'); + expect(parsed.searchParams.get('acr_values')).toBe('Level3'); + expect(parsed.searchParams.get('max_age')).toBe('3600'); + }); +}); + describe('user.session()', async () => { const config: OidcConfig = { clientId: '123456789', diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index d96d181805..b7824d3178 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -65,7 +65,10 @@ export async function oidc({ }; storage?: Partial; }) { - const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); + 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', @@ -172,6 +175,13 @@ export async function oidc({ redirectUri: config.redirectUri, scope: config.scope || 'openid', responseType: config.responseType || 'code', + ...(config.loginHint !== undefined && { loginHint: config.loginHint }), + ...(config.nonce !== undefined && { nonce: config.nonce }), + ...(config.display !== undefined && { display: config.display }), + ...(config.prompt !== undefined && { prompt: config.prompt }), + ...(config.uiLocales !== undefined && { uiLocales: config.uiLocales }), + ...(config.acrValues !== undefined && { acrValues: config.acrValues }), + ...(config.query !== undefined && { query: config.query }), ...options, }; diff --git a/packages/oidc-client/src/lib/config.types.ts b/packages/oidc-client/src/lib/config.types.ts index 3f25a8fa39..c846a29292 100644 --- a/packages/oidc-client/src/lib/config.types.ts +++ b/packages/oidc-client/src/lib/config.types.ts @@ -1,23 +1,10 @@ /* - * 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. */ -import type { AsyncLegacyConfigOptions, ResponseType } from '@forgerock/sdk-types'; - -export interface OidcConfig extends AsyncLegacyConfigOptions { - // Redundant properties are redeclared to define as required - clientId: string; - redirectUri: string; - scope: string; - serverConfig: { - wellknown: string; - timeout?: number; - }; - responseType?: ResponseType; - par?: boolean; -} +export type { OidcConfig } from '@forgerock/sdk-types'; export interface OauthTokens { accessToken: string; diff --git a/packages/oidc-client/src/lib/logout.request.test.ts b/packages/oidc-client/src/lib/logout.request.test.ts index 09bc0af911..1beaf712f4 100644 --- a/packages/oidc-client/src/lib/logout.request.test.ts +++ b/packages/oidc-client/src/lib/logout.request.test.ts @@ -1,10 +1,10 @@ /* - * 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. */ -import { it, expect, describe } from '@effect/vitest'; +import { it, expect, describe } from 'vitest'; import { Micro } from 'effect'; import { deepStrictEqual } from 'node:assert'; import { setupServer } from 'msw/node'; @@ -99,41 +99,24 @@ const partialWellknown = { introspection_endpoint: 'https://example.com/introspect', }; -describe('Ping AM', () => { - it.effect('logoutµ succeeds with valid wellknown endpoints', () => - Micro.gen(function* () { - const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; - const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; - - const result = yield* logoutµ({ - tokens, - config, - wellknown: { - ...partialWellknown, - end_session_endpoint, - revocation_endpoint, - }, - store, - storageClient, - }); - - expect(result).toStrictEqual({ - sessionResponse: null, - revokeResponse: null, - deleteResponse: null, - }); - }), - ); - - it.effect('logoutµ fails on bad endSession', () => - Micro.gen(function* () { - const end_session_endpoint = 'https://example.com/am/oauth2/fake-realm/connect/endSession'; - const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; - - const result = yield* Micro.exit( - logoutµ({ +describe('signOutRedirectUri', () => { + it('logoutµ appends post_logout_redirect_uri when signOutRedirectUri is set in config', () => + Micro.runPromise( + Micro.gen(function* () { + const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; + const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; + let capturedUrl = ''; + + server.use( + http.get(end_session_endpoint, ({ request }) => { + capturedUrl = request.url; + return new HttpResponse(null, { status: 204 }); + }), + ); + + yield* logoutµ({ tokens, - config, + config: { ...config, signOutRedirectUri: 'https://example.com/logout' }, wellknown: { ...partialWellknown, end_session_endpoint, @@ -141,33 +124,28 @@ describe('Ping AM', () => { }, store, storageClient, - }), - ); - - deepStrictEqual( - result, - Micro.exitFail({ - error: 'Inner request error', - sessionResponse: { - error: 'End Session failure', - message: 'An error occurred while ending the session', - type: 'auth_error', - status: 400, - }, - revokeResponse: null, - deleteResponse: null, - }), - ); - }), - ); - - it.effect('logoutµ fails on bad revoke', () => - Micro.gen(function* () { - const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; - const revocation_endpoint = 'https://example.com/am/oauth2/fake-realm/token/revoke'; - - const result = yield* Micro.exit( - logoutµ({ + }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get('post_logout_redirect_uri')).toBe('https://example.com/logout'); + }), + )); + + it('logoutµ omits post_logout_redirect_uri when signOutRedirectUri is absent', () => + Micro.runPromise( + Micro.gen(function* () { + const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; + const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; + let capturedUrl = ''; + + server.use( + http.get(end_session_endpoint, ({ request }) => { + capturedUrl = request.url; + return new HttpResponse(null, { status: 204 }); + }), + ); + + yield* logoutµ({ tokens, config, wellknown: { @@ -177,100 +155,126 @@ describe('Ping AM', () => { }, store, storageClient, - }), - ); + }); - deepStrictEqual( - result, - Micro.exitFail({ - error: 'Inner request error', - sessionResponse: null, - revokeResponse: { - error: 'End Session failure', - message: 'An error occurred while ending the session', - type: 'auth_error', - status: 400, - }, - deleteResponse: null, - }), - ); - }), - ); + const url = new URL(capturedUrl); + expect(url.searchParams.has('post_logout_redirect_uri')).toBe(false); + }), + )); }); -describe('PingOne', () => { - const fakeEndSessionEndpoint = 'https://example.com/endSession'; +describe('Ping AM', () => { + it('logoutµ succeeds with valid wellknown endpoints', () => + Micro.runPromise( + Micro.gen(function* () { + const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; + const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; - it.effect('logoutµ succeeds with valid wellknown endpoints', () => - Micro.gen(function* () { - const ping_end_idp_session_endpoint = 'https://example.com/as/idpSignoff'; - const revocation_endpoint = 'https://example.com/as/revoke'; - - const result = yield* logoutµ({ - tokens, - config, - wellknown: { - ...partialWellknown, - ping_end_idp_session_endpoint, - end_session_endpoint: fakeEndSessionEndpoint, - revocation_endpoint, - }, - store, - storageClient, - }); - - expect(result).toStrictEqual({ - sessionResponse: null, - revokeResponse: null, - deleteResponse: null, - }); - }), - ); - - it.effect('logoutµ fails on bad endSession', () => - Micro.gen(function* () { - const ping_end_idp_session_endpoint = 'https://example.com/as/badIdpSignoff'; - const revocation_endpoint = 'https://example.com/as/revoke'; - - const result = yield* Micro.exit( - logoutµ({ + const result = yield* logoutµ({ tokens, config, wellknown: { ...partialWellknown, - ping_end_idp_session_endpoint, - end_session_endpoint: fakeEndSessionEndpoint, + end_session_endpoint, revocation_endpoint, }, store, storageClient, - }), - ); - - deepStrictEqual( - result, - Micro.exitFail({ - error: 'Inner request error', - sessionResponse: { - error: 'End Session failure', - message: 'An error occurred while ending the session', - type: 'auth_error', - status: 400, - }, + }); + + expect(result).toStrictEqual({ + sessionResponse: null, revokeResponse: null, deleteResponse: null, - }), - ); - }), - ); - - it.effect('logoutµ fails on bad revoke', () => - Micro.gen(function* () { - const ping_end_idp_session_endpoint = 'https://example.com/as/idpSignoff'; - const revocation_endpoint = 'https://example.com/as/badRevoke'; - - const result = yield* Micro.exit( - logoutµ({ + }); + }), + )); + + it('logoutµ fails on bad endSession', () => + Micro.runPromise( + Micro.gen(function* () { + const end_session_endpoint = 'https://example.com/am/oauth2/fake-realm/connect/endSession'; + const revocation_endpoint = 'https://example.com/am/oauth2/alpha/token/revoke'; + + const result = yield* Micro.exit( + logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + end_session_endpoint, + revocation_endpoint, + }, + store, + storageClient, + }), + ); + + deepStrictEqual( + result, + Micro.exitFail({ + error: 'Inner request error', + sessionResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + revokeResponse: null, + deleteResponse: null, + }), + ); + }), + )); + + it('logoutµ fails on bad revoke', () => + Micro.runPromise( + Micro.gen(function* () { + const end_session_endpoint = 'https://example.com/am/oauth2/alpha/connect/endSession'; + const revocation_endpoint = 'https://example.com/am/oauth2/fake-realm/token/revoke'; + + const result = yield* Micro.exit( + logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + end_session_endpoint, + revocation_endpoint, + }, + store, + storageClient, + }), + ); + + deepStrictEqual( + result, + Micro.exitFail({ + error: 'Inner request error', + sessionResponse: null, + revokeResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + deleteResponse: null, + }), + ); + }), + )); +}); + +describe('PingOne', () => { + const fakeEndSessionEndpoint = 'https://example.com/endSession'; + + it('logoutµ succeeds with valid wellknown endpoints', () => + Micro.runPromise( + Micro.gen(function* () { + const ping_end_idp_session_endpoint = 'https://example.com/as/idpSignoff'; + const revocation_endpoint = 'https://example.com/as/revoke'; + + const result = yield* logoutµ({ tokens, config, wellknown: { @@ -281,23 +285,89 @@ describe('PingOne', () => { }, store, storageClient, - }), - ); + }); - deepStrictEqual( - result, - Micro.exitFail({ - error: 'Inner request error', + expect(result).toStrictEqual({ sessionResponse: null, - revokeResponse: { - error: 'End Session failure', - message: 'An error occurred while ending the session', - type: 'auth_error', - status: 400, - }, + revokeResponse: null, deleteResponse: null, - }), - ); - }), - ); + }); + }), + )); + + it('logoutµ fails on bad endSession', () => + Micro.runPromise( + Micro.gen(function* () { + const ping_end_idp_session_endpoint = 'https://example.com/as/badIdpSignoff'; + const revocation_endpoint = 'https://example.com/as/revoke'; + + const result = yield* Micro.exit( + logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + ping_end_idp_session_endpoint, + end_session_endpoint: fakeEndSessionEndpoint, + revocation_endpoint, + }, + store, + storageClient, + }), + ); + + deepStrictEqual( + result, + Micro.exitFail({ + error: 'Inner request error', + sessionResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + revokeResponse: null, + deleteResponse: null, + }), + ); + }), + )); + + it('logoutµ fails on bad revoke', () => + Micro.runPromise( + Micro.gen(function* () { + const ping_end_idp_session_endpoint = 'https://example.com/as/idpSignoff'; + const revocation_endpoint = 'https://example.com/as/badRevoke'; + + const result = yield* Micro.exit( + logoutµ({ + tokens, + config, + wellknown: { + ...partialWellknown, + ping_end_idp_session_endpoint, + end_session_endpoint: fakeEndSessionEndpoint, + revocation_endpoint, + }, + store, + storageClient, + }), + ); + + deepStrictEqual( + result, + Micro.exitFail({ + error: 'Inner request error', + sessionResponse: null, + revokeResponse: { + error: 'End Session failure', + message: 'An error occurred while ending the session', + type: 'auth_error', + status: 400, + }, + deleteResponse: null, + }), + ); + }), + )); }); diff --git a/packages/oidc-client/src/lib/logout.request.ts b/packages/oidc-client/src/lib/logout.request.ts index 92ee6869d6..636ab5fdd9 100644 --- a/packages/oidc-client/src/lib/logout.request.ts +++ b/packages/oidc-client/src/lib/logout.request.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. @@ -33,6 +33,7 @@ export function logoutµ({ oidcApi.endpoints.endSession.initiate({ idToken: tokens.idToken, endpoint: wellknown.ping_end_idp_session_endpoint || wellknown.end_session_endpoint, + signOutRedirectUri: config.signOutRedirectUri, }), ), ).pipe(Micro.map(({ data, error }) => createLogoutError(data, error))), diff --git a/packages/oidc-client/src/lib/oidc.api.ts b/packages/oidc-client/src/lib/oidc.api.ts index 54f9d10eba..be666dcf7b 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -396,12 +396,18 @@ export const oidcApi = createApi({ return { data: response.data } as { data: AuthorizationSuccess }; }, }), - endSession: builder.mutation({ - queryFn: async ({ idToken, endpoint }, api, _, baseQuery) => { + endSession: builder.mutation< + null, + { idToken: string; endpoint: string; signOutRedirectUri?: string } + >({ + queryFn: async ({ idToken, endpoint, signOutRedirectUri }, api, _, baseQuery) => { const { requestMiddleware, logger } = api.extra as Extras; const url = new URL(endpoint); url.searchParams.append('id_token_hint', idToken); + if (signOutRedirectUri) { + url.searchParams.append('post_logout_redirect_uri', signOutRedirectUri); + } const request: FetchArgs = { url: url.toString(), diff --git a/packages/sdk-effects/logger/package.json b/packages/sdk-effects/logger/package.json index 5a0b3d82c5..0d41eae8b1 100644 --- a/packages/sdk-effects/logger/package.json +++ b/packages/sdk-effects/logger/package.json @@ -31,7 +31,9 @@ "test": "pnpm nx nxTest", "test:watch": "pnpm nx nxTest --watch" }, - "dependencies": {}, + "dependencies": { + "@forgerock/sdk-types": "workspace:*" + }, "nx": { "tags": ["scope:sdk-effects"] } diff --git a/packages/sdk-effects/logger/src/lib/logger.types.ts b/packages/sdk-effects/logger/src/lib/logger.types.ts index 8bbc9ee95e..277aa40501 100644 --- a/packages/sdk-effects/logger/src/lib/logger.types.ts +++ b/packages/sdk-effects/logger/src/lib/logger.types.ts @@ -1,10 +1,12 @@ /* - * 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. */ +export type { LogLevel } from '@forgerock/sdk-types'; + export interface CustomLogger { error: (...args: LogMessage[]) => void; warn: (...args: LogMessage[]) => void; @@ -12,8 +14,4 @@ export interface CustomLogger { debug: (...args: LogMessage[]) => void; } -// Define log levels -export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'none'; - -// Define log message type export type LogMessage = string | number | object; diff --git a/packages/sdk-effects/logger/tsconfig.lib.json b/packages/sdk-effects/logger/tsconfig.lib.json index c0377d19ce..79c8dd7977 100644 --- a/packages/sdk-effects/logger/tsconfig.lib.json +++ b/packages/sdk-effects/logger/tsconfig.lib.json @@ -16,7 +16,7 @@ "types": ["node"] }, "include": ["src/**/*.ts"], - "references": [], + "references": [{ "path": "../../sdk-types/tsconfig.lib.json" }], "exclude": [ "vite.config.ts", "vite.config.mts", diff --git a/packages/sdk-effects/oidc/src/lib/authorize.test.ts b/packages/sdk-effects/oidc/src/lib/authorize.test.ts index 484e8bed25..437dc1f9af 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.test.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.test.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. @@ -184,5 +184,102 @@ describe('buildAuthorizeParams', () => { expect(params.has('response_mode')).toBe(false); expect(params.has('prompt')).toBe(false); + expect(params.has('login_hint')).toBe(false); + expect(params.has('nonce')).toBe(false); + expect(params.has('display')).toBe(false); + expect(params.has('ui_locales')).toBe(false); + expect(params.has('acr_values')).toBe(false); + }); + + it('includes login_hint when provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + loginHint: 'user@example.com', + }); + + expect(params.get('login_hint')).toBe('user@example.com'); + }); + + it('includes nonce when provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + nonce: 'custom-nonce-value', + }); + + expect(params.get('nonce')).toBe('custom-nonce-value'); + }); + + it('includes display when provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + display: 'popup', + }); + + expect(params.get('display')).toBe('popup'); + }); + + it('includes ui_locales when provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + uiLocales: 'en-US', + }); + + expect(params.get('ui_locales')).toBe('en-US'); + }); + + it('includes acr_values when provided', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + acrValues: 'Level3', + }); + + expect(params.get('acr_values')).toBe('Level3'); + }); + + it('includes all new OIDC params together', () => { + const params = buildAuthorizeParams({ + clientId: 'test-client', + redirectUri: 'https://example.com/cb', + scope: 'openid', + responseType: 'code', + challenge: 'abc123', + state: 'state1', + loginHint: 'user@example.com', + nonce: 'my-nonce', + display: 'page', + uiLocales: 'fr-FR', + acrValues: 'Level2', + }); + + expect(params.get('login_hint')).toBe('user@example.com'); + expect(params.get('nonce')).toBe('my-nonce'); + expect(params.get('display')).toBe('page'); + expect(params.get('ui_locales')).toBe('fr-FR'); + expect(params.get('acr_values')).toBe('Level2'); }); }); diff --git a/packages/sdk-effects/oidc/src/lib/authorize.utils.ts b/packages/sdk-effects/oidc/src/lib/authorize.utils.ts index 27e41bf14d..204749334d 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.utils.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.utils.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. @@ -29,6 +29,11 @@ export function buildAuthorizeParams( if (options.responseMode) params.set('response_mode', options.responseMode); if (options.prompt) params.set('prompt', options.prompt); + if (options.loginHint) params.set('login_hint', options.loginHint); + if (options.nonce) params.set('nonce', options.nonce); + if (options.display) params.set('display', options.display); + if (options.uiLocales) params.set('ui_locales', options.uiLocales); + if (options.acrValues) params.set('acr_values', options.acrValues); return params; } diff --git a/packages/sdk-types/src/lib/authorize.types.ts b/packages/sdk-types/src/lib/authorize.types.ts index f4815d2e73..014273d67f 100644 --- a/packages/sdk-types/src/lib/authorize.types.ts +++ b/packages/sdk-types/src/lib/authorize.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. @@ -8,6 +8,15 @@ import type { LegacyConfigOptions } from './legacy-config.types.js'; export type ResponseType = 'code' | 'token'; +// Canonical runtime lists of the OIDC `display`/`prompt` values; the union types are +// derived from them so the allowed values live in one place — runtime validators can +// iterate the arrays while the types stay in sync. Colocated as in `am-callback.types.ts`. +export const AUTH_DISPLAY_VALUES = ['page', 'popup', 'touch', 'wap'] as const; +export const AUTH_PROMPT_VALUES = ['none', 'login', 'consent', 'select_account'] as const; + +export type AuthDisplayValue = (typeof AUTH_DISPLAY_VALUES)[number]; +export type AuthPromptValue = (typeof AUTH_PROMPT_VALUES)[number]; + /** * Options for the authorization URL * @param clientId The client ID of the application @@ -29,7 +38,12 @@ export interface GetAuthorizationUrlOptions extends LegacyConfigOptions { state?: string; verifier?: string; query?: Record; - prompt?: 'none' | 'login' | 'consent'; + prompt?: AuthPromptValue; + loginHint?: string; + nonce?: string; + display?: AuthDisplayValue; + uiLocales?: string; + acrValues?: string; successParams?: string[]; errorParams?: string[]; } diff --git a/packages/sdk-types/src/lib/config.types.ts b/packages/sdk-types/src/lib/config.types.ts index 4a4a3f63cf..3752b970b4 100644 --- a/packages/sdk-types/src/lib/config.types.ts +++ b/packages/sdk-types/src/lib/config.types.ts @@ -1,10 +1,66 @@ /* - * 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. */ import { CustomStorageObject } from './tokens.types.js'; +import type { AsyncLegacyConfigOptions } from './legacy-config.types.js'; +import type { ResponseType, AuthDisplayValue, AuthPromptValue } from './authorize.types.js'; + +export const LOG_LEVEL_VALUES = ['none', 'error', 'warn', 'info', 'debug'] as const; +export type LogLevel = (typeof LOG_LEVEL_VALUES)[number]; + +export const LOG_LEVEL_UPPERCASE_VALUES = LOG_LEVEL_VALUES.map((v) => + v.toUpperCase(), +) as readonly Uppercase[]; + +/** Configuration for creating an OIDC client instance. */ +export interface OidcConfig extends AsyncLegacyConfigOptions { + clientId: string; + redirectUri: string; + scope: string; + serverConfig: { + wellknown: string; + timeout?: number; + }; + responseType?: ResponseType; + /** Use Pushed Authorization Requests (PAR) for the authorization flow. */ + par?: boolean; + /** URI to redirect to after logout; maps to `post_logout_redirect_uri` in the end-session request. */ + signOutRedirectUri?: string; + loginHint?: string; + nonce?: string; + display?: AuthDisplayValue; + prompt?: AuthPromptValue; + uiLocales?: string; + acrValues?: string; + query?: Record; + log?: LogLevel; +} + +export interface JourneyServerConfig { + wellknown: string; + timeout?: number; +} + +/** + * Configuration for creating a journey client instance. + * + * Extends {@link AsyncLegacyConfigOptions} so that the same config object can + * be shared across journey-client, davinci-client, and oidc-client. Properties + * like `clientId`, `scope`, and `redirectUri` are accepted but not used by + * journey-client — a warning is logged when they are provided. + */ +export interface JourneyClientConfig extends AsyncLegacyConfigOptions { + serverConfig: JourneyServerConfig; + log?: LogLevel; +} + +export interface DaVinciConfig extends AsyncLegacyConfigOptions { + responseType?: string; + log?: LogLevel; +} /** * Union of possible OAuth Configs diff --git a/packages/sdk-utilities/src/index.ts b/packages/sdk-utilities/src/index.ts index 042735dd55..f5dc55eb17 100644 --- a/packages/sdk-utilities/src/index.ts +++ b/packages/sdk-utilities/src/index.ts @@ -1,6 +1,6 @@ /* * - * Copyright © 2025 Ping Identity Corporation. All right reserved. + * Copyright © 2025 - 2026 Ping Identity Corporation. All right reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -15,3 +15,4 @@ export * from './lib/url/index.js'; export * from './lib/wellknown/index.js'; export * from './lib/object.utils.js'; export * from './lib/constants/index.js'; +export * from './lib/config/index.js'; diff --git a/packages/sdk-utilities/src/lib/config/config.effects.ts b/packages/sdk-utilities/src/lib/config/config.effects.ts new file mode 100644 index 0000000000..76defcf49a --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/config.effects.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 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 * as Either from 'effect/Either'; + +import { parseToOidcConfig, parseToJourneyConfig, parseToDavinciConfig } from './config.utils.js'; +import type { OidcConfig, JourneyClientConfig, DaVinciConfig } from './config.types.js'; + +function throwOnLeft(result: Either.Either): T { + if (Either.isLeft(result)) { + const messages = result.left.map((e) => `${e.field}: ${e.message}`).join(', '); + throw new Error(`Invalid unified SDK config: ${messages}`); + } + return result.right; +} + +export const makeOidcConfig = (json: unknown): OidcConfig => throwOnLeft(parseToOidcConfig(json)); + +export const makeJourneyConfig = (json: unknown): JourneyClientConfig => + throwOnLeft(parseToJourneyConfig(json)); + +export const makeDavinciConfig = (json: unknown): DaVinciConfig => + throwOnLeft(parseToDavinciConfig(json)); diff --git a/packages/sdk-utilities/src/lib/config/config.test.ts b/packages/sdk-utilities/src/lib/config/config.test.ts new file mode 100644 index 0000000000..158ffdd70c --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/config.test.ts @@ -0,0 +1,717 @@ +/* + * Copyright (c) 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, it, expect } from 'vitest'; +import * as Either from 'effect/Either'; +import { + parseToOidcConfig, + parseToJourneyConfig, + parseToDavinciConfig, + parseRefreshThreshold, + parseDisplay, + parsePrompt, + parseLog, + parseScopes, + parseDiscoveryEndpoint, + parseServerUrl, + parseTimeout, + collectErrors, + parseOidcSection, + parseUnifiedSdkConfig, +} from './config.utils.js'; +import { makeOidcConfig, makeJourneyConfig, makeDavinciConfig } from './config.effects.js'; + +const minimalOidc = { + clientId: 'my-client', + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + scopes: ['openid', 'profile'], + redirectUri: 'https://app.example.com/callback', +}; + +const fullConfig = { + timeout: 30000, + log: 'DEBUG', + journey: { + serverUrl: 'https://example.com/am', + realm: 'alpha', + cookieName: 'iPlanetDirectoryPro', + }, + oidc: { + ...minimalOidc, + signOutRedirectUri: 'https://app.example.com/logout', + refreshThreshold: 60, + loginHint: 'user@example.com', + nonce: 'custom-nonce', + display: 'page', + prompt: 'login', + uiLocales: 'en-US', + acrValues: 'Level3', + additionalParameters: { max_age: '3600' }, + openId: { deviceAuthorizationEndpoint: 'https://example.com/device/code' }, + }, +}; + +const journeyOnlyConfig = { + journey: { + serverUrl: 'https://example.com/am', + realm: 'alpha', + }, + oidc: { + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + }, +}; + +describe('parseUnifiedSdkConfig', () => { + it('parseUnifiedSdkConfig_ValidFullConfig_ReturnsSuccess', () => { + expect(Either.isRight(parseUnifiedSdkConfig(fullConfig))).toBe(true); + }); + + it('parseUnifiedSdkConfig_JourneyOnlyConfig_ReturnsSuccess', () => { + expect(Either.isRight(parseUnifiedSdkConfig(journeyOnlyConfig))).toBe(true); + }); + + it('parseUnifiedSdkConfig_NoOidcOrJourneySection_ReturnsSuccess', () => { + expect(Either.isRight(parseUnifiedSdkConfig({ timeout: 5000 }))).toBe(true); + }); + + it('parseUnifiedSdkConfig_UnknownTopLevelField_Ignored', () => { + expect(Either.isRight(parseUnifiedSdkConfig({ timeout: 5000, surprise: 'kept' }))).toBe(true); + }); + + it('parseUnifiedSdkConfig_TimeoutNotNumber_ReturnsTypeError', () => { + const errors = Either.getOrThrow( + Either.flip(parseUnifiedSdkConfig({ ...fullConfig, timeout: 'thirty' })), + ); + expect(errors.some((e) => e.field === 'timeout')).toBe(true); + }); + + it('parseUnifiedSdkConfig_JourneyMissingServerUrl_ReturnsError', () => { + const errors = Either.getOrThrow( + Either.flip( + parseUnifiedSdkConfig({ + journey: { realm: 'alpha' }, + oidc: { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration' }, + }), + ), + ); + expect(errors.some((e) => e.field === 'journey.serverUrl')).toBe(true); + }); + + it('parseUnifiedSdkConfig_InvalidOidcNested_PropagatesErrors', () => { + const errors = Either.getOrThrow( + Either.flip(parseUnifiedSdkConfig({ ...fullConfig, oidc: { ...minimalOidc, clientId: 42 } })), + ); + expect(errors.some((e) => e.field === 'oidc.clientId')).toBe(true); + }); + + it('parseUnifiedSdkConfig_MultipleErrors_AllAccumulated', () => { + const errors = Either.getOrThrow( + Either.flip(parseUnifiedSdkConfig({ timeout: 'thirty', oidc: { scopes: 'not-an-array' } })), + ); + expect(errors.length).toBeGreaterThanOrEqual(2); + expect(errors.some((e) => e.field === 'timeout')).toBe(true); + expect(errors.some((e) => e.field === 'oidc.discoveryEndpoint')).toBe(true); + }); +}); + +describe('parseServerUrl', () => { + it('parseServerUrl_String_ReturnsSuccess', () => { + expect(Either.getOrThrow(parseServerUrl('https://example.com/am'))).toBe( + 'https://example.com/am', + ); + }); + + it('parseServerUrl_Absent_ReturnsRequiredError', () => { + const errors = Either.getOrThrow(Either.flip(parseServerUrl(undefined))); + expect(errors[0]?.field).toBe('journey.serverUrl'); + }); + + it('parseServerUrl_WrongType_ReturnsTypeError', () => { + const errors = Either.getOrThrow(Either.flip(parseServerUrl(123))); + expect(errors[0]?.field).toBe('journey.serverUrl'); + }); +}); + +describe('parseTimeout', () => { + it('parseTimeout_FiniteNonNegative_ReturnsSuccess', () => { + expect(Either.getOrThrow(parseTimeout(5000))).toBe(5000); + }); + + it('parseTimeout_Absent_ReturnsUndefined', () => { + expect(Either.getOrThrow(parseTimeout(undefined))).toBeUndefined(); + }); + + it('parseTimeout_Negative_ReturnsError', () => { + const errors = Either.getOrThrow(Either.flip(parseTimeout(-5))); + expect(errors[0]?.field).toBe('timeout'); + }); + + it('parseTimeout_NotNumber_ReturnsError', () => { + const errors = Either.getOrThrow(Either.flip(parseTimeout('thirty'))); + expect(errors[0]?.field).toBe('timeout'); + }); +}); + +describe('parseToOidcConfig', () => { + it('parseToOidcConfig_NoOidcBlock_ReturnsFailure', () => { + const errors = Either.getOrThrow( + Either.flip(parseToOidcConfig({ journey: { serverUrl: 'https://example.com/am' } })), + ); + expect(errors.some((e) => e.field === 'oidc')).toBe(true); + }); + + it('parseToOidcConfig_MinimalConfig_MapsRequiredFields', () => { + const data = Either.getOrThrow(parseToOidcConfig({ oidc: minimalOidc })); + expect(data.clientId).toBe('my-client'); + expect(data.redirectUri).toBe('https://app.example.com/callback'); + expect(data.scope).toBe('openid profile'); + expect(data.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + }); + + it('parseToOidcConfig_ScopesJoinedWithSpace', () => { + const data = Either.getOrThrow( + parseToOidcConfig({ oidc: { ...minimalOidc, scopes: ['openid', 'email'] } }), + ); + expect(data.scope).toBe('openid email'); + }); + + it('parseToOidcConfig_RefreshThresholdConvertedToMs', () => { + const data = Either.getOrThrow( + parseToOidcConfig({ oidc: { ...minimalOidc, refreshThreshold: 60 } }), + ); + expect(data.oauthThreshold).toBe(60000); + }); + + it('parseToOidcConfig_NoRefreshThreshold_OauthThresholdAbsent', () => { + expect( + Either.getOrThrow(parseToOidcConfig({ oidc: minimalOidc })).oauthThreshold, + ).toBeUndefined(); + }); + + it('parseToOidcConfig_RealmMappedToRealmPath', () => { + const data = Either.getOrThrow( + parseToOidcConfig({ + journey: { serverUrl: 'https://example.com/am', realm: 'alpha' }, + oidc: minimalOidc, + }), + ); + expect(data.realmPath).toBe('alpha'); + }); + + it('parseToOidcConfig_NoRealm_RealmPathAbsent', () => { + expect(Either.getOrThrow(parseToOidcConfig({ oidc: minimalOidc })).realmPath).toBeUndefined(); + }); + + it('parseToOidcConfig_TimeoutPassedToServerConfig', () => { + const data = Either.getOrThrow(parseToOidcConfig({ timeout: 5000, oidc: minimalOidc })); + expect(data.serverConfig.timeout).toBe(5000); + }); + + it('parseToOidcConfig_NoTimeout_TimeoutAbsentInServerConfig', () => { + expect( + Either.getOrThrow(parseToOidcConfig({ oidc: minimalOidc })).serverConfig.timeout, + ).toBeUndefined(); + }); + + it('parseToOidcConfig_AuthorizeParamsMapped', () => { + const data = Either.getOrThrow( + parseToOidcConfig({ + oidc: { + ...minimalOidc, + loginHint: 'user@example.com', + nonce: 'custom-nonce', + display: 'page', + prompt: 'login', + uiLocales: 'en-US', + acrValues: 'Level3', + additionalParameters: { max_age: '3600' }, + }, + }), + ); + expect(data.loginHint).toBe('user@example.com'); + expect(data.nonce).toBe('custom-nonce'); + expect(data.display).toBe('page'); + expect(data.prompt).toBe('login'); + expect(data.uiLocales).toBe('en-US'); + expect(data.acrValues).toBe('Level3'); + expect(data.query).toEqual({ max_age: '3600' }); + }); + + it('parseToOidcConfig_EmptyScopes_ReturnsFailure', () => { + const errors = Either.getOrThrow( + Either.flip(parseToOidcConfig({ oidc: { ...minimalOidc, scopes: [] } })), + ); + expect(errors.some((e) => e.field === 'oidc.scopes')).toBe(true); + }); + + it('parseToOidcConfig_NoAuthorizeParams_AllAbsent', () => { + const data = Either.getOrThrow(parseToOidcConfig({ oidc: minimalOidc })); + expect(data.loginHint).toBeUndefined(); + expect(data.nonce).toBeUndefined(); + expect(data.query).toBeUndefined(); + }); + + it('parseToOidcConfig_OidcMissingDiscoveryEndpoint_ReturnsFailure', () => { + const errors = Either.getOrThrow( + Either.flip( + parseToOidcConfig({ oidc: { clientId: 'x', redirectUri: 'x', scopes: ['openid'] } }), + ), + ); + expect(errors.some((e) => e.field === 'oidc.discoveryEndpoint')).toBe(true); + }); + + it('parseToOidcConfig_NullInput_ReturnsFailure', () => { + expect(Either.isLeft(parseToOidcConfig(null))).toBe(true); + }); +}); + +describe('parseToJourneyConfig', () => { + it('parseToJourneyConfig_NoOidcBlock_ReturnsFailure', () => { + const errors = Either.getOrThrow( + Either.flip(parseToJourneyConfig({ journey: { serverUrl: 'https://example.com/am' } })), + ); + expect(errors.some((e) => e.field === 'oidc')).toBe(true); + }); + + it('parseToJourneyConfig_MinimalConfig_MapsWellknown', () => { + const data = Either.getOrThrow(parseToJourneyConfig({ oidc: minimalOidc })); + expect(data.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + }); + + it('parseToJourneyConfig_JourneyOnlyConfig_MapsWellknown', () => { + const data = Either.getOrThrow(parseToJourneyConfig(journeyOnlyConfig)); + expect(data.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + }); + + it('parseToJourneyConfig_RealmMappedToRealmPath', () => { + const data = Either.getOrThrow( + parseToJourneyConfig({ + journey: { serverUrl: 'https://example.com/am', realm: 'beta' }, + oidc: minimalOidc, + }), + ); + expect(data.realmPath).toBe('beta'); + }); + + it('parseToJourneyConfig_NoRealm_RealmPathAbsent', () => { + expect( + Either.getOrThrow(parseToJourneyConfig({ oidc: minimalOidc })).realmPath, + ).toBeUndefined(); + }); + + it('parseToJourneyConfig_TimeoutPassedToServerConfig', () => { + const data = Either.getOrThrow(parseToJourneyConfig({ timeout: 10000, oidc: minimalOidc })); + expect(data.serverConfig.timeout).toBe(10000); + }); + + it('parseToJourneyConfig_OidcFieldsNotLeakedToResult', () => { + const data = Either.getOrThrow(parseToJourneyConfig(fullConfig)) as unknown as Record< + string, + unknown + >; + expect(data['clientId']).toBeUndefined(); + expect(data['scope']).toBeUndefined(); + expect(data['redirectUri']).toBeUndefined(); + }); + + it('parseToJourneyConfig_OidcMissingDiscoveryEndpoint_ReturnsFailure', () => { + const errors = Either.getOrThrow( + Either.flip(parseToJourneyConfig({ oidc: { realm: 'alpha' } })), + ); + expect(errors.some((e) => e.field === 'oidc.discoveryEndpoint')).toBe(true); + }); + + it('parseToJourneyConfig_NullInput_ReturnsFailure', () => { + expect(Either.isLeft(parseToJourneyConfig(null))).toBe(true); + }); +}); + +describe('parseToDavinciConfig', () => { + it('parseToDavinciConfig_NoOidcBlock_ReturnsFailure', () => { + const errors = Either.getOrThrow( + Either.flip(parseToDavinciConfig({ journey: { serverUrl: 'https://example.com/am' } })), + ); + expect(errors.some((e) => e.field === 'oidc')).toBe(true); + }); + + it('parseToDavinciConfig_MinimalConfig_MapsRequiredFields', () => { + const data = Either.getOrThrow(parseToDavinciConfig({ oidc: minimalOidc })); + expect(data.clientId).toBe('my-client'); + expect(data.redirectUri).toBe('https://app.example.com/callback'); + expect(data.scope).toBe('openid profile'); + expect(data.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + }); + + it('parseToDavinciConfig_ScopesJoinedWithSpace', () => { + const data = Either.getOrThrow( + parseToDavinciConfig({ oidc: { ...minimalOidc, scopes: ['openid', 'email'] } }), + ); + expect(data.scope).toBe('openid email'); + }); + + it('parseToDavinciConfig_RefreshThresholdConvertedToMs', () => { + const data = Either.getOrThrow( + parseToDavinciConfig({ oidc: { ...minimalOidc, refreshThreshold: 30 } }), + ); + expect(data.oauthThreshold).toBe(30000); + }); + + it('parseToDavinciConfig_RealmMappedToRealmPath', () => { + const data = Either.getOrThrow( + parseToDavinciConfig({ + journey: { serverUrl: 'https://example.com/am', realm: 'alpha' }, + oidc: minimalOidc, + }), + ); + expect(data.realmPath).toBe('alpha'); + }); + + it('parseToDavinciConfig_EmptyScopes_ReturnsFailure', () => { + const errors = Either.getOrThrow( + Either.flip(parseToDavinciConfig({ oidc: { ...minimalOidc, scopes: [] } })), + ); + expect(errors.some((e) => e.field === 'oidc.scopes')).toBe(true); + }); + + it('parseToDavinciConfig_TimeoutPassedToServerConfig', () => { + const data = Either.getOrThrow(parseToDavinciConfig({ timeout: 7000, oidc: minimalOidc })); + expect(data.serverConfig.timeout).toBe(7000); + }); + + it('parseToDavinciConfig_OidcMissingDiscoveryEndpoint_ReturnsFailure', () => { + const errors = Either.getOrThrow( + Either.flip( + parseToDavinciConfig({ oidc: { clientId: 'x', redirectUri: 'x', scopes: ['openid'] } }), + ), + ); + expect(errors.some((e) => e.field === 'oidc.discoveryEndpoint')).toBe(true); + }); + + it('parseToDavinciConfig_NullInput_ReturnsFailure', () => { + expect(Either.isLeft(parseToDavinciConfig(null))).toBe(true); + }); +}); + +describe('parseToOidcConfig log mapping', () => { + it('parseToOidcConfig_LogFieldMapped_ToLogLevel', () => { + expect(Either.getOrThrow(parseToOidcConfig({ log: 'DEBUG', oidc: minimalOidc })).log).toBe( + 'debug', + ); + }); + + it('parseToOidcConfig_NoLogField_LogLevelAbsent', () => { + expect(Either.getOrThrow(parseToOidcConfig({ oidc: minimalOidc })).log).toBeUndefined(); + }); + + it('parseToOidcConfig_CookieName_NotMappedToResult', () => { + const data = Either.getOrThrow( + parseToOidcConfig({ + journey: { serverUrl: 'https://example.com/am', cookieName: 'iPlanetDirectoryPro' }, + oidc: minimalOidc, + }), + ) as unknown as Record; + expect(data['cookieName']).toBeUndefined(); + }); +}); + +describe('parseToJourneyConfig log mapping', () => { + it('parseToJourneyConfig_LogFieldMapped_ToLogLevel', () => { + expect(Either.getOrThrow(parseToJourneyConfig({ log: 'WARN', oidc: minimalOidc })).log).toBe( + 'warn', + ); + }); + + it('parseToJourneyConfig_NoLogField_LogLevelAbsent', () => { + expect(Either.getOrThrow(parseToJourneyConfig({ oidc: minimalOidc })).log).toBeUndefined(); + }); + + it('parseToJourneyConfig_CookieName_NotMappedToResult', () => { + const data = Either.getOrThrow( + parseToJourneyConfig({ + journey: { serverUrl: 'https://example.com/am', cookieName: 'iPlanetDirectoryPro' }, + oidc: minimalOidc, + }), + ) as unknown as Record; + expect(data['cookieName']).toBeUndefined(); + }); +}); + +describe('parseToDavinciConfig log mapping', () => { + it('parseToDavinciConfig_LogFieldMapped_ToLogLevel', () => { + expect(Either.getOrThrow(parseToDavinciConfig({ log: 'ERROR', oidc: minimalOidc })).log).toBe( + 'error', + ); + }); + + it('parseToDavinciConfig_NoLogField_LogLevelAbsent', () => { + expect(Either.getOrThrow(parseToDavinciConfig({ oidc: minimalOidc })).log).toBeUndefined(); + }); +}); + +describe('makeOidcConfig', () => { + it('makeOidcConfig_ValidFullConfig_ReturnsMappedOidcConfig', () => { + const result = makeOidcConfig(fullConfig); + expect(result.clientId).toBe('my-client'); + expect(result.redirectUri).toBe('https://app.example.com/callback'); + expect(result.scope).toBe('openid profile'); + expect(result.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + }); + + it('makeOidcConfig_NullInput_Throws', () => { + expect(() => makeOidcConfig(null)).toThrow('Invalid unified SDK config'); + }); + + it('makeOidcConfig_MissingDiscoveryEndpoint_Throws', () => { + expect(() => + makeOidcConfig({ oidc: { clientId: 'x', scopes: ['openid'], redirectUri: 'x' } }), + ).toThrow('Invalid unified SDK config'); + }); + + it('makeOidcConfig_EmptyDiscoveryEndpoint_Throws', () => { + expect(() => makeOidcConfig({ oidc: { ...minimalOidc, discoveryEndpoint: '' } })).toThrow( + 'Invalid unified SDK config', + ); + }); + + it('makeOidcConfig_EmptyScopes_Throws', () => { + expect(() => makeOidcConfig({ oidc: { ...minimalOidc, scopes: [] } })).toThrow( + 'Invalid unified SDK config', + ); + }); + + it('makeOidcConfig_MissingClientId_Throws', () => { + expect(() => + makeOidcConfig({ + oidc: { + discoveryEndpoint: 'https://example.com/.well-known', + scopes: ['openid'], + redirectUri: 'x', + }, + }), + ).toThrow('Invalid unified SDK config'); + }); +}); + +describe('makeJourneyConfig', () => { + it('makeJourneyConfig_ValidConfig_ReturnsMappedJourneyConfig', () => { + const result = makeJourneyConfig(fullConfig); + expect(result.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + expect(result.realmPath).toBe('alpha'); + }); + + it('makeJourneyConfig_NullInput_Throws', () => { + expect(() => makeJourneyConfig(null)).toThrow('Invalid unified SDK config'); + }); + + it('makeJourneyConfig_MissingOidcBlock_Throws', () => { + expect(() => makeJourneyConfig({ journey: { serverUrl: 'https://example.com/am' } })).toThrow( + 'Invalid unified SDK config', + ); + }); +}); + +describe('makeDavinciConfig', () => { + it('makeDavinciConfig_ValidFullConfig_ReturnsMappedDavinciConfig', () => { + const result = makeDavinciConfig(fullConfig); + expect(result.clientId).toBe('my-client'); + expect(result.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + }); + + it('makeDavinciConfig_NullInput_Throws', () => { + expect(() => makeDavinciConfig(null)).toThrow('Invalid unified SDK config'); + }); + + it('makeDavinciConfig_MissingDiscoveryEndpoint_Throws', () => { + expect(() => + makeDavinciConfig({ oidc: { clientId: 'x', scopes: ['openid'], redirectUri: 'x' } }), + ).toThrow('Invalid unified SDK config'); + }); + + it('makeDavinciConfig_EmptyScopes_Throws', () => { + expect(() => makeDavinciConfig({ oidc: { ...minimalOidc, scopes: [] } })).toThrow( + 'Invalid unified SDK config', + ); + }); + + it('makeDavinciConfig_MissingClientId_Throws', () => { + expect(() => + makeDavinciConfig({ + oidc: { + discoveryEndpoint: 'https://example.com/.well-known', + scopes: ['openid'], + redirectUri: 'x', + }, + }), + ).toThrow('Invalid unified SDK config'); + }); +}); + +describe('parseRefreshThreshold', () => { + it('parseRefreshThreshold_FiniteNonNegative_ReturnsSuccess', () => { + expect(Either.getOrThrow(parseRefreshThreshold(60))).toBe(60); + }); + + it('parseRefreshThreshold_Zero_ReturnsSuccess', () => { + expect(Either.getOrThrow(parseRefreshThreshold(0))).toBe(0); + }); + + it('parseRefreshThreshold_Absent_ReturnsUndefined', () => { + expect(Either.getOrThrow(parseRefreshThreshold(undefined))).toBeUndefined(); + }); + + it('parseRefreshThreshold_NaN_ReturnsError', () => { + const errors = Either.getOrThrow(Either.flip(parseRefreshThreshold(NaN))); + expect(errors[0]?.field).toBe('oidc.refreshThreshold'); + }); + + it('parseRefreshThreshold_Infinity_ReturnsError', () => { + expect(Either.isLeft(parseRefreshThreshold(Infinity))).toBe(true); + }); + + it('parseRefreshThreshold_Negative_ReturnsError', () => { + expect(Either.isLeft(parseRefreshThreshold(-1))).toBe(true); + }); + + it('parseRefreshThreshold_NotNumber_ReturnsError', () => { + expect(Either.isLeft(parseRefreshThreshold('sixty'))).toBe(true); + }); +}); + +describe('parseDisplay', () => { + it('parseDisplay_ValidMember_ReturnsSuccess', () => { + expect(Either.getOrThrow(parseDisplay('page'))).toBe('page'); + }); + + it('parseDisplay_Absent_ReturnsUndefined', () => { + expect(Either.getOrThrow(parseDisplay(undefined))).toBeUndefined(); + }); + + it('parseDisplay_InvalidMember_ReturnsError', () => { + const errors = Either.getOrThrow(Either.flip(parseDisplay('fullscreen'))); + expect(errors[0]?.field).toBe('oidc.display'); + }); +}); + +describe('parsePrompt', () => { + it('parsePrompt_ValidMember_ReturnsSuccess', () => { + expect(Either.getOrThrow(parsePrompt('login'))).toBe('login'); + }); + + it('parsePrompt_InvalidMember_ReturnsError', () => { + expect(Either.isLeft(parsePrompt('always'))).toBe(true); + }); +}); + +describe('parseLog', () => { + it('parseLog_ValidUppercase_ReturnsSuccess', () => { + expect(Either.getOrThrow(parseLog('DEBUG'))).toBe('DEBUG'); + }); + + it('parseLog_Lowercase_ReturnsError', () => { + const errors = Either.getOrThrow(Either.flip(parseLog('debug'))); + expect(errors[0]?.field).toBe('log'); + }); + + it('parseLog_UnknownValue_ReturnsError', () => { + expect(Either.isLeft(parseLog('VERBOSE'))).toBe(true); + }); +}); + +describe('parseScopes', () => { + it('parseScopes_AllStrings_ReturnsSuccess', () => { + expect(Either.getOrThrow(parseScopes(['openid', 'profile']))).toEqual(['openid', 'profile']); + }); + + it('parseScopes_NonArray_ReturnsError', () => { + const errors = Either.getOrThrow(Either.flip(parseScopes('openid profile'))); + expect(errors[0]?.field).toBe('oidc.scopes'); + }); + + it('parseScopes_ContainsNonString_ReturnsIndexedError', () => { + const errors = Either.getOrThrow(Either.flip(parseScopes(['openid', 42]))); + expect(errors.some((e) => e.field === 'oidc.scopes[1]')).toBe(true); + }); +}); + +describe('parseDiscoveryEndpoint', () => { + it('parseDiscoveryEndpoint_String_ReturnsSuccess', () => { + expect(Either.getOrThrow(parseDiscoveryEndpoint('https://example.com'))).toBe( + 'https://example.com', + ); + }); + + it('parseDiscoveryEndpoint_Absent_ReturnsRequiredError', () => { + const errors = Either.getOrThrow(Either.flip(parseDiscoveryEndpoint(undefined))); + expect(errors[0]?.field).toBe('oidc.discoveryEndpoint'); + }); + + it('parseDiscoveryEndpoint_EmptyString_ReturnsRequiredError', () => { + const errors = Either.getOrThrow(Either.flip(parseDiscoveryEndpoint(''))); + expect(errors[0]?.field).toBe('oidc.discoveryEndpoint'); + }); +}); + +describe('collectErrors', () => { + it('collectErrors_AllRight_ReturnsEmpty', () => { + expect(collectErrors([Either.right(1), Either.right('a')])).toEqual([]); + }); + + it('collectErrors_MultipleLeft_AccumulatesAllErrors', () => { + const errors = collectErrors([ + Either.right(1), + Either.left([{ field: 'a', message: 'bad a' }]), + Either.left([{ field: 'b', message: 'bad b' }]), + ]); + expect(errors.map((e) => e.field)).toEqual(['a', 'b']); + }); + + it('collectErrors_DoesNotShortCircuit', () => { + const errors = collectErrors([ + Either.left([{ field: 'first', message: 'x' }]), + Either.left([{ field: 'second', message: 'y' }]), + ]); + expect(errors).toHaveLength(2); + }); +}); + +describe('parseOidcSection', () => { + it('parseOidcSection_ValidInput_ReturnsParsedConfig', () => { + const result = parseOidcSection({ + discoveryEndpoint: 'https://example.com/.well-known', + clientId: 'my-client', + scopes: ['openid'], + }); + expect(Either.getOrThrow(result).clientId).toBe('my-client'); + }); + + it('parseOidcSection_UnknownField_Ignored', () => { + const result = parseOidcSection({ + discoveryEndpoint: 'https://example.com/.well-known', + unknownField: 'kept', + }); + expect(Either.isRight(result)).toBe(true); + }); + + it('parseOidcSection_MissingDiscoveryEndpoint_ReturnsError', () => { + const errors = Either.getOrThrow(Either.flip(parseOidcSection({ clientId: 'my-client' }))); + expect(errors.some((e) => e.field === 'oidc.discoveryEndpoint')).toBe(true); + }); +}); diff --git a/packages/sdk-utilities/src/lib/config/config.types.ts b/packages/sdk-utilities/src/lib/config/config.types.ts new file mode 100644 index 0000000000..1bf52724cd --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/config.types.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 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 * as Either from 'effect/Either'; + +import type { LogLevel, AuthDisplayValue, AuthPromptValue } from '@forgerock/sdk-types'; + +export type { OidcConfig } from '@forgerock/sdk-types'; +export type { AuthDisplayValue, AuthPromptValue }; + +export interface UnifiedOidcConfig { + clientId?: string; + discoveryEndpoint: string; + scopes?: string[]; + redirectUri?: string; + signOutRedirectUri?: string; + refreshThreshold?: number; + loginHint?: string; + nonce?: string; + display?: AuthDisplayValue; + prompt?: AuthPromptValue; + uiLocales?: string; + acrValues?: string; + additionalParameters?: Record; + openId?: { + deviceAuthorizationEndpoint?: string; + }; +} + +export interface UnifiedJourneyConfig { + serverUrl: string; + realm?: string; + cookieName?: string; +} + +export interface UnifiedSdkConfig { + timeout?: number; + log?: Uppercase; + journey?: UnifiedJourneyConfig; + oidc?: UnifiedOidcConfig; +} + +export type ConfigValidationError = { + field: string; + message: string; +}; + +/** + * A parsed result over the accumulating-error channel. Effect's `Either` is + * `Either`, so the SECOND type parameter is the error channel. + */ +export type ParseResult = Either.Either; + +/** Parses a record of unknown values into `A`. Unknown fields are silently ignored. */ +export type Parser = (input: Readonly>) => ParseResult; + +/** Parses a single field value (already extracted from its parent record) into `A`. */ +export type FieldParser = (value: unknown, fieldPath: string) => ParseResult; + +export type { LogLevel, JourneyClientConfig, DaVinciConfig } from '@forgerock/sdk-types'; diff --git a/packages/sdk-utilities/src/lib/config/config.utils.ts b/packages/sdk-utilities/src/lib/config/config.utils.ts new file mode 100644 index 0000000000..2865d73739 --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/config.utils.ts @@ -0,0 +1,536 @@ +/* + * Copyright (c) 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 { pipe } from 'effect'; +import * as Either from 'effect/Either'; + +import { + AUTH_DISPLAY_VALUES, + AUTH_PROMPT_VALUES, + LOG_LEVEL_UPPERCASE_VALUES, +} from '@forgerock/sdk-types'; +import type { LogLevel, AuthDisplayValue, AuthPromptValue } from '@forgerock/sdk-types'; + +import type { + UnifiedSdkConfig, + UnifiedOidcConfig, + UnifiedJourneyConfig, + OidcConfig, + JourneyClientConfig, + DaVinciConfig, + ConfigValidationError, + FieldParser, + Parser, + ParseResult, +} from './config.types.js'; + +/* ------------------------------------------------------------------ * + * Internal narrowed types — proof types for pure transforms, not public API + * ------------------------------------------------------------------ */ + +interface ClientOidcBlock extends UnifiedOidcConfig { + clientId: string; + redirectUri: string; + scopes: string[]; +} + +interface ClientSdkConfig extends UnifiedSdkConfig { + oidc: ClientOidcBlock; +} + +interface JourneySdkConfig extends UnifiedSdkConfig { + oidc: UnifiedOidcConfig; +} + +/** Partial record with exactly one optional key — the return type of `parsedProp`. */ +type ParsedProp = { [P in K]?: V }; + +/* ------------------------------------------------------------------ * + * Shared helpers + * ------------------------------------------------------------------ */ + +/** Human-readable type name for error messages — distinguishes array and null from object. */ +function typeName(value: unknown): string { + if (Array.isArray(value)) return 'array'; + if (value === null) return 'null'; + return typeof value; +} + +/** An absent optional field is `undefined` or `null`. */ +function isAbsent(value: unknown): value is undefined | null { + return value === undefined || value === null; +} + +/** + * Gather every error from a list of parsed results without short-circuiting (unlike + * `Either.all`, which stops at the first `Left`). Returns all accumulated errors so a + * section parser can report every invalid field in one pass. + */ +export function collectErrors( + results: ReadonlyArray>, +): ConfigValidationError[] { + return results.flatMap((result) => (Either.isLeft(result) ? result.left : [])); +} + +/** + * Unwraps a `ParseResult`, then returns `{ [key]: value }` when the value is defined, `{}` otherwise. + * Spread into an object literal to conditionally include a property without an inline ternary. + * Safe to call after `collectErrors` — every result is guaranteed `Right` past the error guard. + */ +function parsedProp(key: K, result: ParseResult): ParsedProp { + const value = Either.getOrThrow(result); + return (value !== undefined ? { [key]: value } : {}) as ParsedProp; +} + +/* ------------------------------------------------------------------ * + * Generic field parsers (reusable constraints) + * ------------------------------------------------------------------ */ + +const requiredString: FieldParser = (value, fieldPath) => { + if (isAbsent(value)) { + return Either.left([{ field: fieldPath, message: 'Required field is missing' }]); + } + return typeof value === 'string' + ? Either.right(value) + : Either.left([{ field: fieldPath, message: `Expected string, got ${typeName(value)}` }]); +}; + +/** Required, non-empty string — treats `''` as missing (a blank value can't satisfy a requirement). */ +const requiredNonEmptyString: FieldParser = (value, fieldPath) => { + if (isAbsent(value) || value === '') { + return Either.left([{ field: fieldPath, message: 'Required field is missing' }]); + } + return typeof value === 'string' + ? Either.right(value) + : Either.left([{ field: fieldPath, message: `Expected string, got ${typeName(value)}` }]); +}; + +/** Required, non-empty array of strings — treats absent or `[]` as missing. */ +const requiredNonEmptyStringArray: FieldParser = (value, fieldPath) => { + if (isAbsent(value) || (Array.isArray(value) && value.length === 0)) { + return Either.left([{ field: fieldPath, message: 'Required field is missing' }]); + } + if (!Array.isArray(value)) { + return Either.left([{ field: fieldPath, message: `Expected array, got ${typeName(value)}` }]); + } + const parsed: string[] = []; + const errors: ConfigValidationError[] = []; + value.forEach((item, index) => { + if (typeof item === 'string') { + parsed.push(item); + } else { + errors.push({ + field: `${fieldPath}[${index}]`, + message: `Expected string, got ${typeName(item)}`, + }); + } + }); + return errors.length > 0 ? Either.left(errors) : Either.right(parsed); +}; + +const optionalString: FieldParser = (value, fieldPath) => { + if (isAbsent(value)) return Either.right(undefined); + return typeof value === 'string' + ? Either.right(value) + : Either.left([{ field: fieldPath, message: `Expected string, got ${typeName(value)}` }]); +}; + +/** Finite, non-negative number (rejects NaN, Infinity, negatives). Optional-aware. */ +const finiteNonNegativeNumber: FieldParser = (value, fieldPath) => { + if (isAbsent(value)) return Either.right(undefined); + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return Either.left([ + { + field: fieldPath, + message: `Expected a finite, non-negative number, got ${typeName(value)}`, + }, + ]); + } + return Either.right(value); +}; + +/** Optional array of strings, accumulating one error per non-string element. */ +const optionalStringArray: FieldParser = (value, fieldPath) => { + if (isAbsent(value)) return Either.right(undefined); + if (!Array.isArray(value)) { + return Either.left([{ field: fieldPath, message: `Expected array, got ${typeName(value)}` }]); + } + const parsed: string[] = []; + const errors: ConfigValidationError[] = []; + value.forEach((item, index) => { + if (typeof item === 'string') { + parsed.push(item); + } else { + errors.push({ + field: `${fieldPath}[${index}]`, + message: `Expected string, got ${typeName(item)}`, + }); + } + }); + return errors.length > 0 ? Either.left(errors) : Either.right(parsed); +}; + +/** Optional `Record` — builds a fresh record, erroring on non-string values. */ +const optionalStringRecord: FieldParser | undefined> = ( + value, + fieldPath, +) => { + if (isAbsent(value)) return Either.right(undefined); + if (typeof value !== 'object' || Array.isArray(value)) { + return Either.left([{ field: fieldPath, message: `Expected object, got ${typeName(value)}` }]); + } + const parsed: Record = {}; + const errors: ConfigValidationError[] = []; + for (const [key, entryValue] of Object.entries(value)) { + if (typeof entryValue === 'string') { + parsed[key] = entryValue; + } else { + errors.push({ + field: `${fieldPath}.${key}`, + message: `Expected string, got ${typeName(entryValue)}`, + }); + } + } + return errors.length > 0 ? Either.left(errors) : Either.right(parsed); +}; + +/** + * Optional value restricted to a closed set of string literals. Narrows via a typed + * predicate `find` so the parsed value carries the literal-union type without a cast. + */ +function optionalLiteralUnion( + members: Members, +): FieldParser { + return (value, fieldPath) => { + if (isAbsent(value)) return Either.right(undefined); + if (typeof value !== 'string') { + return Either.left([ + { field: fieldPath, message: `Expected string, got ${typeName(value)}` }, + ]); + } + const matched = members.find((member): member is Members[number] => member === value); + return matched !== undefined + ? Either.right(matched) + : Either.left([ + { field: fieldPath, message: `Expected one of ${members.join(', ')}, got ${value}` }, + ]); + }; +} + +/* ------------------------------------------------------------------ * + * Per-property parsers (each owns its field path + constraint) + * ------------------------------------------------------------------ */ + +export const parseDiscoveryEndpoint = (value: unknown): ParseResult => + requiredNonEmptyString(value, 'oidc.discoveryEndpoint'); + +export const parseClientId = (value: unknown): ParseResult => + optionalString(value, 'oidc.clientId'); + +export const parseRedirectUri = (value: unknown): ParseResult => + optionalString(value, 'oidc.redirectUri'); + +export const parseScopes = (value: unknown): ParseResult => + optionalStringArray(value, 'oidc.scopes'); + +export const parseSignOutRedirectUri = (value: unknown): ParseResult => + optionalString(value, 'oidc.signOutRedirectUri'); + +export const parseRefreshThreshold = (value: unknown): ParseResult => + finiteNonNegativeNumber(value, 'oidc.refreshThreshold'); + +export const parseLoginHint = (value: unknown): ParseResult => + optionalString(value, 'oidc.loginHint'); + +export const parseNonce = (value: unknown): ParseResult => + optionalString(value, 'oidc.nonce'); + +export const parseDisplay = (value: unknown): ParseResult => + optionalLiteralUnion(AUTH_DISPLAY_VALUES)(value, 'oidc.display'); + +export const parsePrompt = (value: unknown): ParseResult => + optionalLiteralUnion(AUTH_PROMPT_VALUES)(value, 'oidc.prompt'); + +export const parseUiLocales = (value: unknown): ParseResult => + optionalString(value, 'oidc.uiLocales'); + +export const parseAcrValues = (value: unknown): ParseResult => + optionalString(value, 'oidc.acrValues'); + +export const parseAdditionalParameters = ( + value: unknown, +): ParseResult | undefined> => + optionalStringRecord(value, 'oidc.additionalParameters'); + +export const parseTimeout = (value: unknown): ParseResult => + finiteNonNegativeNumber(value, 'timeout'); + +export const parseLog = (value: unknown): ParseResult | undefined> => + optionalLiteralUnion(LOG_LEVEL_UPPERCASE_VALUES)(value, 'log'); + +export const parseServerUrl = (value: unknown): ParseResult => + requiredString(value, 'journey.serverUrl'); + +export const parseRealm = (value: unknown): ParseResult => + optionalString(value, 'journey.realm'); + +export const parseCookieName = (value: unknown): ParseResult => + optionalString(value, 'journey.cookieName'); + +/* ------------------------------------------------------------------ * + * Section parsers — compose property parsers, build typed objects cast-free + * ------------------------------------------------------------------ */ + +/** Parse the `oidc` block. Each property parser carries its own `oidc.`-prefixed path. */ +export const parseOidcSection: Parser = (input) => { + const discoveryEndpoint = parseDiscoveryEndpoint(input['discoveryEndpoint']); + const clientId = parseClientId(input['clientId']); + const redirectUri = parseRedirectUri(input['redirectUri']); + const scopes = parseScopes(input['scopes']); + const signOutRedirectUri = parseSignOutRedirectUri(input['signOutRedirectUri']); + const refreshThreshold = parseRefreshThreshold(input['refreshThreshold']); + const loginHint = parseLoginHint(input['loginHint']); + const nonce = parseNonce(input['nonce']); + const display = parseDisplay(input['display']); + const prompt = parsePrompt(input['prompt']); + const uiLocales = parseUiLocales(input['uiLocales']); + const acrValues = parseAcrValues(input['acrValues']); + const additionalParameters = parseAdditionalParameters(input['additionalParameters']); + + const errors = collectErrors([ + discoveryEndpoint, + clientId, + redirectUri, + scopes, + signOutRedirectUri, + refreshThreshold, + loginHint, + nonce, + display, + prompt, + uiLocales, + acrValues, + additionalParameters, + ]); + if (errors.length > 0) return Either.left(errors); + + const oidc: UnifiedOidcConfig = { + discoveryEndpoint: Either.getOrThrow(discoveryEndpoint), + ...parsedProp('clientId', clientId), + ...parsedProp('redirectUri', redirectUri), + ...parsedProp('scopes', scopes), + ...parsedProp('signOutRedirectUri', signOutRedirectUri), + ...parsedProp('refreshThreshold', refreshThreshold), + ...parsedProp('loginHint', loginHint), + ...parsedProp('nonce', nonce), + ...parsedProp('display', display), + ...parsedProp('prompt', prompt), + ...parsedProp('uiLocales', uiLocales), + ...parsedProp('acrValues', acrValues), + ...parsedProp('additionalParameters', additionalParameters), + }; + return Either.right(oidc); +}; + +/** Parse the `journey` block. `serverUrl` required; `realm`/`cookieName` optional. */ +export const parseJourneySection: Parser = (input) => { + const serverUrl = parseServerUrl(input['serverUrl']); + const realm = parseRealm(input['realm']); + const cookieName = parseCookieName(input['cookieName']); + + const errors = collectErrors([serverUrl, realm, cookieName]); + if (errors.length > 0) return Either.left(errors); + + const journey: UnifiedJourneyConfig = { + serverUrl: Either.getOrThrow(serverUrl), + ...parsedProp('realm', realm), + ...parsedProp('cookieName', cookieName), + }; + return Either.right(journey); +}; + +/** + * Run a section parser against an optional nested object: absent → `Right(undefined)`; + * present-but-not-an-object → `Left`; present object → delegate to `parser`. + */ +function parseOptionalSection( + value: unknown, + prefix: string, + parser: Parser, +): ParseResult { + if (isAbsent(value)) return Either.right(undefined); + if (typeof value !== 'object' || Array.isArray(value)) { + return Either.left([{ field: prefix, message: `Expected object, got ${typeName(value)}` }]); + } + return parser({ ...value }); +} + +/** Parse a full unified SDK config from an already-typed record. Unknown fields are ignored. */ +export const parseUnifiedSdkConfig: Parser = (input) => { + const timeout = parseTimeout(input['timeout']); + const log = parseLog(input['log']); + const journey = parseOptionalSection(input['journey'], 'journey', parseJourneySection); + const oidc = parseOptionalSection(input['oidc'], 'oidc', parseOidcSection); + + const errors = collectErrors([timeout, log, journey, oidc]); + if (errors.length > 0) return Either.left(errors); + + const config: UnifiedSdkConfig = { + ...parsedProp('timeout', timeout), + ...parsedProp('log', log), + ...parsedProp('journey', journey), + ...parsedProp('oidc', oidc), + }; + return Either.right(config); +}; + +/* ------------------------------------------------------------------ * + * Strict config parsers — require client fields, build the narrowed type cast-free + * ------------------------------------------------------------------ */ + +/** + * Require the client-flow fields (`discoveryEndpoint`, `clientId`, `redirectUri`, + * `scopes`) and return a `ClientSdkConfig` whose `oidc` block has them guaranteed + * present — so downstream transforms read them without any runtime guard. Errors + * accumulate per field. + */ +function parseClientSdkConfig(config: UnifiedSdkConfig): ParseResult { + if (!config.oidc) { + return Either.left([{ field: 'oidc', message: 'Required block is missing' }]); + } + const oidc = config.oidc; + // All four are required and non-optional, so `Either.all` (first-error) is enough — the + // struct form keeps field/value paired by key. Section parsers collect errors across many + // optional fields instead, so they accumulate via `collectErrors`. + return Either.map( + Either.all({ + discoveryEndpoint: requiredNonEmptyString(oidc.discoveryEndpoint, 'oidc.discoveryEndpoint'), + clientId: requiredNonEmptyString(oidc.clientId, 'oidc.clientId'), + redirectUri: requiredNonEmptyString(oidc.redirectUri, 'oidc.redirectUri'), + scopes: requiredNonEmptyStringArray(oidc.scopes, 'oidc.scopes'), + }), + (validated): ClientSdkConfig => ({ ...config, oidc: { ...oidc, ...validated } }), + ); +} + +/** + * Require only `discoveryEndpoint` (Journey derives all server config from it) and + * return a `JourneySdkConfig` whose `oidc` block is guaranteed present. + */ +function parseJourneySdkConfig(config: UnifiedSdkConfig): ParseResult { + if (!config.oidc) { + return Either.left([{ field: 'oidc', message: 'Required block is missing' }]); + } + const oidc = config.oidc; + return Either.map( + requiredNonEmptyString(oidc.discoveryEndpoint, 'oidc.discoveryEndpoint'), + (discoveryEndpoint): JourneySdkConfig => ({ + ...config, + oidc: { ...oidc, discoveryEndpoint }, + }), + ); +} + +/* ------------------------------------------------------------------ * + * Pure transforms — validated config → native client config (no guards) + * ------------------------------------------------------------------ */ + +function toMappedLogLevel(level: Uppercase): LogLevel { + return level.toLowerCase() as LogLevel; +} + +function buildOidcConfig(config: ClientSdkConfig): OidcConfig { + const { oidc } = config; + return { + clientId: oidc.clientId, + redirectUri: oidc.redirectUri, + scope: oidc.scopes.join(' '), + serverConfig: { + wellknown: oidc.discoveryEndpoint, + ...(config.timeout !== undefined && { timeout: config.timeout }), + }, + ...(oidc.refreshThreshold !== undefined && { oauthThreshold: oidc.refreshThreshold * 1000 }), + ...(config.journey?.realm !== undefined && { realmPath: config.journey.realm }), + ...(oidc.signOutRedirectUri !== undefined && { signOutRedirectUri: oidc.signOutRedirectUri }), + ...(oidc.loginHint !== undefined && { loginHint: oidc.loginHint }), + ...(oidc.nonce !== undefined && { nonce: oidc.nonce }), + ...(oidc.display !== undefined && { display: oidc.display }), + ...(oidc.prompt !== undefined && { prompt: oidc.prompt }), + ...(oidc.uiLocales !== undefined && { uiLocales: oidc.uiLocales }), + ...(oidc.acrValues !== undefined && { acrValues: oidc.acrValues }), + ...(oidc.additionalParameters !== undefined && { query: oidc.additionalParameters }), + ...(config.log !== undefined && { log: toMappedLogLevel(config.log) }), + }; +} + +function buildJourneyConfig(config: JourneySdkConfig): JourneyClientConfig { + const { oidc } = config; + return { + serverConfig: { + wellknown: oidc.discoveryEndpoint, + ...(config.timeout !== undefined && { timeout: config.timeout }), + }, + ...(config.journey?.realm !== undefined && { realmPath: config.journey.realm }), + // journey.cookieName is not used by JS — all session handling is cookie-free via tokens. + // Accepted in unified schema for cross-platform parity (Android/iOS use it) but not mapped. + ...(config.log !== undefined && { log: toMappedLogLevel(config.log) }), + }; +} + +function buildDavinciConfig(config: ClientSdkConfig): DaVinciConfig { + const { oidc } = config; + return { + clientId: oidc.clientId, + redirectUri: oidc.redirectUri, + scope: oidc.scopes.join(' '), + serverConfig: { + wellknown: oidc.discoveryEndpoint, + ...(config.timeout !== undefined && { timeout: config.timeout }), + }, + ...(oidc.refreshThreshold !== undefined && { oauthThreshold: oidc.refreshThreshold * 1000 }), + ...(config.journey?.realm !== undefined && { realmPath: config.journey.realm }), + ...(config.log !== undefined && { log: toMappedLogLevel(config.log) }), + }; +} + +/* ------------------------------------------------------------------ * + * Single-pass public parsers — the PDV entry points + * Parse from `unknown` directly to the native config type in one pass. + * ------------------------------------------------------------------ */ + +function assertObject( + input: unknown, +): Either.Either>, ConfigValidationError[]> { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return Either.left([{ field: 'config', message: `Expected object, got ${typeName(input)}` }]); + } + return Either.right(input as Readonly>); +} + +export const parseToOidcConfig = (input: unknown): ParseResult => + pipe( + assertObject(input), + Either.flatMap(parseUnifiedSdkConfig), + Either.flatMap(parseClientSdkConfig), + Either.map(buildOidcConfig), + ); + +export const parseToJourneyConfig = (input: unknown): ParseResult => + pipe( + assertObject(input), + Either.flatMap(parseUnifiedSdkConfig), + Either.flatMap(parseJourneySdkConfig), + Either.map(buildJourneyConfig), + ); + +export const parseToDavinciConfig = (input: unknown): ParseResult => + pipe( + assertObject(input), + Either.flatMap(parseUnifiedSdkConfig), + Either.flatMap(parseClientSdkConfig), + Either.map(buildDavinciConfig), + ); diff --git a/packages/sdk-utilities/src/lib/config/index.ts b/packages/sdk-utilities/src/lib/config/index.ts new file mode 100644 index 0000000000..7d538e1d3f --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) 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. + */ + +export * from './config.types.js'; +export * from './config.utils.js'; +export * from './config.effects.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9412fb80ad..e9565ac84e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,7 +565,11 @@ importers: packages/sdk-effects/iframe-manager: {} - packages/sdk-effects/logger: {} + packages/sdk-effects/logger: + dependencies: + '@forgerock/sdk-types': + specifier: workspace:* + version: link:../../sdk-types packages/sdk-effects/oidc: dependencies: @@ -11745,7 +11749,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -11804,7 +11808,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) '@vitest/utils@3.2.4': dependencies: @@ -17241,7 +17245,7 @@ snapshots: dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): dependencies: