From 2a4fee25cb5a29378f8c3ac0fbc1e818036a8f5f Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Thu, 4 Jun 2026 16:36:54 -0700 Subject: [PATCH] feat(ping-sdk): standardize sdk configuration feat(sdk-utilities): add pure unified config validation feat(sdk-utilities): add unified config mapping functions feat(sdk-utilities): export config module from sdk-utilities test(sdk-utilities): add unit tests for config mapping and validation feat(sdk-types): add OIDC authorize params to GetAuthorizationUrlOptions feat(sdk-oidc): wire OIDC authorize params into buildAuthorizeParams feat(oidc-client): add signOutRedirectUri to endSession URL feat(oidc-client): accept unified JSON config in oidc factory feat(journey-client): accept unified JSON config in journey factory feat(davinci-client): accept unified JSON config in davinci factory chore: update api-reports for unified config param widening feat(sdk-utilities): align unified config schema with new journey sub-object refactor(sdk-utilities): mapper functions return ConfigMappingResult with single error fix(sdk-utilities): map log field and use config.log in factories fix(sdk-utilities): tighten isUnifiedSdkConfig discriminant to require object values fix(oidc-client): use throw instead of Promise.reject in unified config error paths refactor(oidc-client): migrate logout.request.test.ts to it + Micro.runPromise pattern docs(oidc-client): add JSDoc to OidcConfig and all new fields --- .changeset/sdks-5067-unified-config.md | 41 ++ .../api-report/davinci-client.api.md | 16 +- .../api-report/davinci-client.types.api.md | 16 +- .../src/lib/client.store.test.ts | 56 ++ .../davinci-client/src/lib/client.store.ts | 34 +- .../davinci-client/src/lib/config.types.ts | 2 + .../api-report/journey-client.api.md | 4 +- .../api-report/journey-client.types.api.md | 2 + .../src/lib/client.store.test.ts | 46 ++ .../journey-client/src/lib/client.store.ts | 29 +- .../journey-client/src/lib/config.types.ts | 2 + .../oidc-client/api-report/oidc-client.api.md | 17 +- .../api-report/oidc-client.types.api.md | 17 +- .../src/lib/authorize.request.micros.test.ts | 652 +++++++++--------- .../src/lib/authorize.request.utils.test.ts | 274 ++++---- .../src/lib/authorize.request.utils.ts | 2 +- .../oidc-client/src/lib/client.store.test.ts | 77 +++ packages/oidc-client/src/lib/client.store.ts | 39 +- packages/oidc-client/src/lib/config.types.ts | 23 + .../src/lib/logout.request.test.ts | 386 ++++++----- .../oidc-client/src/lib/logout.request.ts | 1 + packages/oidc-client/src/lib/oidc.api.ts | 9 +- .../oidc/src/lib/authorize.test.ts | 97 +++ .../oidc/src/lib/authorize.utils.ts | 5 + packages/sdk-types/src/lib/authorize.types.ts | 7 +- packages/sdk-utilities/src/index.ts | 1 + .../src/lib/config/config.test.ts | 551 +++++++++++++++ .../src/lib/config/config.types.ts | 91 +++ .../src/lib/config/config.utils.ts | 290 ++++++++ .../sdk-utilities/src/lib/config/index.ts | 9 + 30 files changed, 2160 insertions(+), 636 deletions(-) create mode 100644 .changeset/sdks-5067-unified-config.md create mode 100644 packages/sdk-utilities/src/lib/config/config.test.ts create mode 100644 packages/sdk-utilities/src/lib/config/config.types.ts create mode 100644 packages/sdk-utilities/src/lib/config/config.utils.ts create mode 100644 packages/sdk-utilities/src/lib/config/index.ts diff --git a/.changeset/sdks-5067-unified-config.md b/.changeset/sdks-5067-unified-config.md new file mode 100644 index 0000000000..81a90d8ab2 --- /dev/null +++ b/.changeset/sdks-5067-unified-config.md @@ -0,0 +1,41 @@ +--- +'@forgerock/sdk-utilities': minor +'@forgerock/sdk-types': minor +'@forgerock/sdk-oidc': minor +'@forgerock/oidc-client': minor +'@forgerock/journey-client': minor +'@forgerock/davinci-client': minor +--- + +Add unified cross-platform SDK configuration support + +All three client factories (`oidc`, `journey`, `davinci`) now accept the cross-platform unified JSON config schema alongside the existing internal config shape. Pass a unified config object and the factory maps, validates, and rejects on invalid input. + +**New in `@forgerock/sdk-utilities`:** + +- `UnifiedSdkConfig`, `UnifiedOidcConfig`, `UnifiedJourneyConfig` types +- `validateUnifiedSdkConfig` / `validateUnifiedOidcConfig` — pure validation returning `ConfigValidationResult` (no throws) +- `unifiedToOidcConfig`, `unifiedToJourneyConfig`, `unifiedToDavinciConfig` — pure mapping functions +- `isUnifiedSdkConfig` discriminator (`'oidc' in input || 'journey' in input`) + +**New in `@forgerock/sdk-types`:** + +- `GetAuthorizationUrlOptions` extended with `loginHint`, `nonce`, `display`, `uiLocales`, `acrValues`; `prompt` widened to include `'select_account'` + +**New in `@forgerock/sdk-oidc`:** + +- `buildAuthorizeParams` forwards all new OIDC authorize params into the URL + +**New in `@forgerock/oidc-client`:** + +- `oidc()` factory accepts unified JSON config; rejects Promise on validation failure +- `endSession` appends `post_logout_redirect_uri` when `signOutRedirectUri` is set +- Authorize URL construction forwards `loginHint`, `state`, `nonce`, `display`, `prompt`, `uiLocales`, `acrValues`, `additionalParameters` + +**New in `@forgerock/journey-client`:** + +- `journey()` factory accepts unified JSON config; throws on validation failure + +**New in `@forgerock/davinci-client`:** + +- `davinci()` factory accepts unified JSON config; throws on validation failure diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index d277642c94..c7aca182ea 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -275,7 +275,7 @@ export type CustomPollingStatus = string & {}; // @public export function davinci(input: { - config: DaVinciConfig; + config: DaVinciConfig | Record; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; @@ -289,11 +289,13 @@ 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; @@ -307,8 +309,6 @@ 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 | ErrorNode | FailureNode | StartNode | SuccessNode; + getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -328,6 +328,8 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -343,8 +345,6 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -508,6 +508,8 @@ export type DavinciClient = Awaited>; // @public (undocumented) export interface DaVinciConfig extends AsyncLegacyConfigOptions { + // (undocumented) + log?: LogLevel; // (undocumented) responseType?: string; } 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 b88390b3c1..4772b590d9 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -275,7 +275,7 @@ export type CustomPollingStatus = string & {}; // @public export function davinci(input: { - config: DaVinciConfig; + config: DaVinciConfig | Record; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; @@ -289,11 +289,13 @@ 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; @@ -307,8 +309,6 @@ 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 | ErrorNode | FailureNode | StartNode | SuccessNode; + getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -328,6 +328,8 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -343,8 +345,6 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -508,6 +508,8 @@ export type DavinciClient = Awaited>; // @public (undocumented) export interface DaVinciConfig extends AsyncLegacyConfigOptions { + // (undocumented) + log?: LogLevel; // (undocumented) responseType?: string; } diff --git a/packages/davinci-client/src/lib/client.store.test.ts b/packages/davinci-client/src/lib/client.store.test.ts index 8d4e647b1f..6f23eeca85 100644 --- a/packages/davinci-client/src/lib/client.store.test.ts +++ b/packages/davinci-client/src/lib/client.store.test.ts @@ -181,3 +181,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', + }, + } as unknown as DaVinciConfig; + + const client = await davinci({ config: 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', + }, + } as unknown as DaVinciConfig; + + await expect(davinci({ config: invalidConfig })).rejects.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', + }, + } as unknown as DaVinciConfig; + + await expect(davinci({ config: invalidConfig })).rejects.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..22c56c4827 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -8,7 +8,13 @@ import { Micro } from 'effect'; import { exitIsFail, exitIsSuccess } from 'effect/Micro'; import { type CustomLogger, logger as loggerFn, type LogLevel } from '@forgerock/sdk-logger'; import { createStorage } from '@forgerock/storage'; -import { isGenericError, createWellknownError } from '@forgerock/sdk-utilities'; +import { + isGenericError, + isUnifiedSdkConfig, + unifiedToDavinciConfig, + validateUnifiedSdkConfig, + createWellknownError, +} from '@forgerock/sdk-utilities'; /** * Import RTK slices and api @@ -66,18 +72,38 @@ import type { ContinueNode, StartNode } from './node.types.js'; * @returns {Observable} - an observable client for DaVinci flows */ export async function davinci({ - config, + config: rawConfig, requestMiddleware, logger, }: { - config: DaVinciConfig; + config: DaVinciConfig | Record; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; custom?: CustomLogger; }; }) { - const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); + let config: DaVinciConfig; + + if (isUnifiedSdkConfig(rawConfig)) { + const validation = validateUnifiedSdkConfig(rawConfig, true); + if (!validation.success) { + const messages = validation.errors.map((e) => `${e.field}: ${e.message}`).join(', '); + throw new Error(`Invalid unified SDK config: ${messages}`); + } + const mapped = unifiedToDavinciConfig(validation.data); + if (!mapped.success) { + throw new Error(`Invalid unified SDK config: ${mapped.error.field}: ${mapped.error.message}`); + } + config = mapped.data as DaVinciConfig; + } else { + config = rawConfig as DaVinciConfig; + } + + 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..9512fa27d7 100644 --- a/packages/davinci-client/src/lib/config.types.ts +++ b/packages/davinci-client/src/lib/config.types.ts @@ -6,9 +6,11 @@ */ import type { AsyncLegacyConfigOptions, WellknownResponse } from '@forgerock/sdk-types'; +import type { LogLevel } from '@forgerock/sdk-logger'; export interface DaVinciConfig extends AsyncLegacyConfigOptions { responseType?: string; + log?: LogLevel; } export interface InternalDaVinciConfig extends DaVinciConfig { diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md index 368fa625f2..3484327ba4 100644 --- a/packages/journey-client/api-report/journey-client.api.md +++ b/packages/journey-client/api-report/journey-client.api.md @@ -176,7 +176,7 @@ export { isValidWellknownUrl } // @public export function journey(input: { - config: JourneyClientConfig; + config: JourneyClientConfig | Record; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; @@ -204,6 +204,8 @@ export interface JourneyClient { // @public export interface JourneyClientConfig extends AsyncLegacyConfigOptions { + // (undocumented) + log?: LogLevel; // (undocumented) serverConfig: JourneyServerConfig; } 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 9d49d2fedd..5c9562ed99 100644 --- a/packages/journey-client/api-report/journey-client.types.api.md +++ b/packages/journey-client/api-report/journey-client.types.api.md @@ -191,6 +191,8 @@ export interface JourneyClient { // @public export interface JourneyClientConfig extends AsyncLegacyConfigOptions { + // (undocumented) + log?: LogLevel; // (undocumented) serverConfig: JourneyServerConfig; } diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts index 180188c6bc..074c3d65a4 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -502,4 +502,50 @@ 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', + }, + } as unknown as JourneyClientConfig; + + const client = await journey({ config: 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 + }, + } as unknown as JourneyClientConfig; + + await expect(journey({ config: invalidConfig })).rejects.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', + }, + } as unknown as JourneyClientConfig; + + await expect(journey({ config: invalidConfig })).rejects.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 57bc2e7f03..ddcd082694 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -9,7 +9,10 @@ import { logger as loggerFn, LogLevel, CustomLogger } from '@forgerock/sdk-logge import { callbackType } from '@forgerock/sdk-types'; import { isGenericError, + isUnifiedSdkConfig, isValidWellknownUrl, + unifiedToJourneyConfig, + validateUnifiedSdkConfig, createWellknownError, } from '@forgerock/sdk-utilities'; @@ -74,18 +77,38 @@ export interface JourneyClient { * ``` */ export async function journey({ - config, + config: rawConfig, requestMiddleware, logger, }: { - config: JourneyClientConfig; + config: JourneyClientConfig | Record; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; custom?: CustomLogger; }; }): Promise { - const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); + let config: JourneyClientConfig; + + if (isUnifiedSdkConfig(rawConfig)) { + const validation = validateUnifiedSdkConfig(rawConfig); + if (!validation.success) { + const messages = validation.errors.map((e) => `${e.field}: ${e.message}`).join(', '); + throw new Error(`Invalid unified SDK config: ${messages}`); + } + const mapped = unifiedToJourneyConfig(validation.data); + if (!mapped.success) { + throw new Error(`Invalid unified SDK config: ${mapped.error.field}: ${mapped.error.message}`); + } + config = mapped.data as JourneyClientConfig; + } else { + config = rawConfig as JourneyClientConfig; + } + + 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..e770c0157b 100644 --- a/packages/journey-client/src/lib/config.types.ts +++ b/packages/journey-client/src/lib/config.types.ts @@ -6,6 +6,7 @@ */ import type { AsyncLegacyConfigOptions, GenericError } from '@forgerock/sdk-types'; +import type { LogLevel } from '@forgerock/sdk-logger'; import type { ResolvedServerConfig } from './wellknown.utils.js'; /** @@ -40,6 +41,7 @@ export interface JourneyServerConfig { */ export interface JourneyClientConfig extends AsyncLegacyConfigOptions { serverConfig: JourneyServerConfig; + log?: LogLevel; } /** diff --git a/packages/oidc-client/api-report/oidc-client.api.md b/packages/oidc-client/api-report/oidc-client.api.md index e934c022e6..50754d9e5e 100644 --- a/packages/oidc-client/api-report/oidc-client.api.md +++ b/packages/oidc-client/api-report/oidc-client.api.md @@ -134,6 +134,7 @@ url: string; endSession: MutationDefinition< { idToken: string; endpoint: string; +signOutRedirectUri?: string; }, BaseQueryFn, never, null, "oidc", unknown>; exchange: MutationDefinition< { code: string; @@ -170,6 +171,7 @@ url: string; endSession: MutationDefinition< { idToken: string; endpoint: string; +signOutRedirectUri?: string; }, BaseQueryFn, never, null, "oidc", unknown>; exchange: MutationDefinition< { code: string; @@ -251,7 +253,7 @@ export interface OauthTokens { // @public export function oidc(input: { - config: OidcConfig; + config: OidcConfig | Record; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; @@ -287,12 +289,18 @@ export function oidc(input: { // @public (undocumented) export type OidcClient = Awaited>; -// @public (undocumented) +// @public export interface OidcConfig extends AsyncLegacyConfigOptions { + acrValues?: string; // (undocumented) clientId: string; - // (undocumented) + display?: 'page' | 'popup' | 'touch' | 'wap'; + log?: LogLevel; + loginHint?: string; + nonce?: string; par?: boolean; + prompt?: 'none' | 'login' | 'consent' | 'select_account'; + query?: Record; // (undocumented) redirectUri: string; // (undocumented) @@ -304,6 +312,9 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { wellknown: string; timeout?: number; }; + signOutRedirectUri?: string; + state?: string; + uiLocales?: string; } // @public (undocumented) 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 e934c022e6..50754d9e5e 100644 --- a/packages/oidc-client/api-report/oidc-client.types.api.md +++ b/packages/oidc-client/api-report/oidc-client.types.api.md @@ -134,6 +134,7 @@ url: string; endSession: MutationDefinition< { idToken: string; endpoint: string; +signOutRedirectUri?: string; }, BaseQueryFn, never, null, "oidc", unknown>; exchange: MutationDefinition< { code: string; @@ -170,6 +171,7 @@ url: string; endSession: MutationDefinition< { idToken: string; endpoint: string; +signOutRedirectUri?: string; }, BaseQueryFn, never, null, "oidc", unknown>; exchange: MutationDefinition< { code: string; @@ -251,7 +253,7 @@ export interface OauthTokens { // @public export function oidc(input: { - config: OidcConfig; + config: OidcConfig | Record; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; @@ -287,12 +289,18 @@ export function oidc(input: { // @public (undocumented) export type OidcClient = Awaited>; -// @public (undocumented) +// @public export interface OidcConfig extends AsyncLegacyConfigOptions { + acrValues?: string; // (undocumented) clientId: string; - // (undocumented) + display?: 'page' | 'popup' | 'touch' | 'wap'; + log?: LogLevel; + loginHint?: string; + nonce?: string; par?: boolean; + prompt?: 'none' | 'login' | 'consent' | 'select_account'; + query?: Record; // (undocumented) redirectUri: string; // (undocumented) @@ -304,6 +312,9 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { wellknown: string; timeout?: number; }; + signOutRedirectUri?: string; + state?: string; + uiLocales?: string; } // @public (undocumented) diff --git a/packages/oidc-client/src/lib/authorize.request.micros.test.ts b/packages/oidc-client/src/lib/authorize.request.micros.test.ts index 289617eae9..844d5241a3 100644 --- a/packages/oidc-client/src/lib/authorize.request.micros.test.ts +++ b/packages/oidc-client/src/lib/authorize.request.micros.test.ts @@ -4,7 +4,7 @@ * 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, expect } from 'vitest'; import { Micro } from 'effect'; import { vi, afterEach } from 'vitest'; import * as sdkOidc from '@forgerock/sdk-oidc'; @@ -60,290 +60,313 @@ afterEach(() => { // ─── generateAuthValuesµ ─────────────────────────────────────────────────────── -it.effect('generateAuthValuesµ returns auth URL options and store function', () => - Micro.gen(function* () { - vi.stubGlobal('sessionStorage', sessionStorageStub); - const result = yield* generateAuthValuesµ(config, wellknown); - const [opts, storeFn] = result; - expect(opts.clientId).toBe(clientId); - expect(typeof opts.state).toBe('string'); - expect(typeof opts.verifier).toBe('string'); - expect(typeof storeFn).toBe('function'); - }), -); - -it.effect('generateAuthValuesµ fails with auth_error when sessionStorage throws', () => - Micro.gen(function* () { - vi.spyOn(sdkOidc, 'generateAndStoreAuthUrlValues').mockImplementation(() => { - throw new Error('storage unavailable'); - }); - const exit = yield* Micro.exit(generateAuthValuesµ(config, wellknown)); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('auth_error'); - expect(exit.cause.error.error).toBe('PAR_PARAM_BUILD_ERROR'); - }), -); +it('generateAuthValuesµ returns auth URL options and store function', () => + Micro.runPromise( + Micro.gen(function* () { + vi.stubGlobal('sessionStorage', sessionStorageStub); + const result = yield* generateAuthValuesµ(config, wellknown); + const [opts, storeFn] = result; + expect(opts.clientId).toBe(clientId); + expect(typeof opts.state).toBe('string'); + expect(typeof opts.verifier).toBe('string'); + expect(typeof storeFn).toBe('function'); + }), + )); + +it('generateAuthValuesµ fails with auth_error when sessionStorage throws', () => + Micro.runPromise( + Micro.gen(function* () { + vi.spyOn(sdkOidc, 'generateAndStoreAuthUrlValues').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + const exit = yield* Micro.exit(generateAuthValuesµ(config, wellknown)); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('auth_error'); + expect(exit.cause.error.error).toBe('PAR_PARAM_BUILD_ERROR'); + }), + )); // ─── generatePkceChallengeµ ──────────────────────────────────────────────────── -it.effect('generatePkceChallengeµ returns a non-empty challenge string', () => - Micro.gen(function* () { - vi.stubGlobal('crypto', { - subtle: { - digest: vi.fn().mockResolvedValue(new ArrayBuffer(32)), - }, - getRandomValues: vi.fn((arr: Uint8Array) => arr.fill(1)), - }); - const challenge = yield* generatePkceChallengeµ('test-verifier'); - expect(typeof challenge).toBe('string'); - expect(challenge.length).toBeGreaterThan(0); - }), -); - -it.effect('generatePkceChallengeµ fails with auth_error when createChallenge throws', () => - Micro.gen(function* () { - vi.spyOn(sdkUtilities, 'createChallenge').mockRejectedValue(new Error('crypto unavailable')); - const exit = yield* Micro.exit(generatePkceChallengeµ('bad-verifier')); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('auth_error'); - expect(exit.cause.error.error).toBe('PAR_CHALLENGE_ERROR'); - }), -); +it('generatePkceChallengeµ returns a non-empty challenge string', () => + Micro.runPromise( + Micro.gen(function* () { + vi.stubGlobal('crypto', { + subtle: { + digest: vi.fn().mockResolvedValue(new ArrayBuffer(32)), + }, + getRandomValues: vi.fn((arr: Uint8Array) => arr.fill(1)), + }); + const challenge = yield* generatePkceChallengeµ('test-verifier'); + expect(typeof challenge).toBe('string'); + expect(challenge.length).toBeGreaterThan(0); + }), + )); + +it('generatePkceChallengeµ fails with auth_error when createChallenge throws', () => + Micro.runPromise( + Micro.gen(function* () { + vi.spyOn(sdkUtilities, 'createChallenge').mockRejectedValue(new Error('crypto unavailable')); + const exit = yield* Micro.exit(generatePkceChallengeµ('bad-verifier')); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('auth_error'); + expect(exit.cause.error.error).toBe('PAR_CHALLENGE_ERROR'); + }), + )); // ─── buildParBodyµ ───────────────────────────────────────────────────────────── -it.effect('buildParBodyµ returns URLSearchParams with expected fields', () => - Micro.gen(function* () { - const params = yield* buildParBodyµ(config, {}, 'challenge-abc', 'state-xyz'); - expect(params.get('client_id')).toBe(clientId); - expect(params.get('code_challenge')).toBe('challenge-abc'); - expect(params.get('state')).toBe('state-xyz'); - expect(params.get('scope')).toBe(scope); - expect(params.has('prompt')).toBe(false); - }), -); - -it.effect('buildParBodyµ includes prompt when provided', () => - Micro.gen(function* () { - const params = yield* buildParBodyµ(config, {}, 'challenge-abc', 'state-xyz', 'login'); - expect(params.get('prompt')).toBe('login'); - }), -); - -it.effect('buildParBodyµ fails with auth_error when buildAuthorizeParams throws', () => - Micro.gen(function* () { - vi.spyOn(sdkOidc, 'buildAuthorizeParams').mockImplementation(() => { - throw new Error('build failed'); - }); - const exit = yield* Micro.exit(buildParBodyµ(config, {}, 'ch', 'st')); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('auth_error'); - expect(exit.cause.error.error).toBe('PAR_PARAM_BUILD_ERROR'); - }), -); +it('buildParBodyµ returns URLSearchParams with expected fields', () => + Micro.runPromise( + Micro.gen(function* () { + const params = yield* buildParBodyµ(config, {}, 'challenge-abc', 'state-xyz'); + expect(params.get('client_id')).toBe(clientId); + expect(params.get('code_challenge')).toBe('challenge-abc'); + expect(params.get('state')).toBe('state-xyz'); + expect(params.get('scope')).toBe(scope); + expect(params.has('prompt')).toBe(false); + }), + )); + +it('buildParBodyµ includes prompt when provided', () => + Micro.runPromise( + Micro.gen(function* () { + const params = yield* buildParBodyµ(config, {}, 'challenge-abc', 'state-xyz', 'login'); + expect(params.get('prompt')).toBe('login'); + }), + )); + +it('buildParBodyµ fails with auth_error when buildAuthorizeParams throws', () => + Micro.runPromise( + Micro.gen(function* () { + vi.spyOn(sdkOidc, 'buildAuthorizeParams').mockImplementation(() => { + throw new Error('build failed'); + }); + const exit = yield* Micro.exit(buildParBodyµ(config, {}, 'ch', 'st')); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('auth_error'); + expect(exit.cause.error.error).toBe('PAR_PARAM_BUILD_ERROR'); + }), + )); // ─── buildParSlimUrlµ ────────────────────────────────────────────────────────── -it.effect('buildParSlimUrlµ returns URL with only client_id and request_uri', () => - Micro.gen(function* () { - const url = yield* buildParSlimUrlµ( - wellknown.authorization_endpoint, - clientId, - 'urn:ietf:params:oauth:request_uri:abc123', - ); - const parsed = new URL(url); - expect(parsed.searchParams.get('client_id')).toBe(clientId); - expect(parsed.searchParams.get('request_uri')).toBe('urn:ietf:params:oauth:request_uri:abc123'); - expect(parsed.searchParams.has('scope')).toBe(false); - }), -); - -it.effect('buildParSlimUrlµ includes prompt when provided', () => - Micro.gen(function* () { - const url = yield* buildParSlimUrlµ( - wellknown.authorization_endpoint, - clientId, - 'urn:ietf:params:oauth:request_uri:abc123', - 'none', - ); - expect(new URL(url).searchParams.get('prompt')).toBe('none'); - }), -); +it('buildParSlimUrlµ returns URL with only client_id and request_uri', () => + Micro.runPromise( + Micro.gen(function* () { + const url = yield* buildParSlimUrlµ( + wellknown.authorization_endpoint, + clientId, + 'urn:ietf:params:oauth:request_uri:abc123', + ); + const parsed = new URL(url); + expect(parsed.searchParams.get('client_id')).toBe(clientId); + expect(parsed.searchParams.get('request_uri')).toBe( + 'urn:ietf:params:oauth:request_uri:abc123', + ); + expect(parsed.searchParams.has('scope')).toBe(false); + }), + )); + +it('buildParSlimUrlµ includes prompt when provided', () => + Micro.runPromise( + Micro.gen(function* () { + const url = yield* buildParSlimUrlµ( + wellknown.authorization_endpoint, + clientId, + 'urn:ietf:params:oauth:request_uri:abc123', + 'none', + ); + expect(new URL(url).searchParams.get('prompt')).toBe('none'); + }), + )); // ─── storeAuthOptionsµ ───────────────────────────────────────────────────────── -it.effect('storeAuthOptionsµ calls the provided store function', () => - Micro.gen(function* () { - const storeFn = vi.fn(); - yield* storeAuthOptionsµ(storeFn); - expect(storeFn).toHaveBeenCalledOnce(); - }), -); - -it.effect('storeAuthOptionsµ fails with unknown_error when store function throws', () => - Micro.gen(function* () { - const exit = yield* Micro.exit( - storeAuthOptionsµ(() => { - throw new Error('storage write failed'); - }), - ); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('unknown_error'); - expect(exit.cause.error.error).toBe('PAR_STORAGE_ERROR'); - }), -); +it('storeAuthOptionsµ calls the provided store function', () => + Micro.runPromise( + Micro.gen(function* () { + const storeFn = vi.fn(); + yield* storeAuthOptionsµ(storeFn); + expect(storeFn).toHaveBeenCalledOnce(); + }), + )); + +it('storeAuthOptionsµ fails with unknown_error when store function throws', () => + Micro.runPromise( + Micro.gen(function* () { + const exit = yield* Micro.exit( + storeAuthOptionsµ(() => { + throw new Error('storage write failed'); + }), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('unknown_error'); + expect(exit.cause.error.error).toBe('PAR_STORAGE_ERROR'); + }), + )); // ─── validateParResponseµ ────────────────────────────────────────────────────── -it.effect('validateParResponseµ succeeds when request_uri is present', () => - Micro.gen(function* () { - const result = yield* validateParResponseµ({ - data: { request_uri: 'urn:ietf:params:oauth:request_uri:xyz', expires_in: 60 }, - }); - expect(result.request_uri).toBe('urn:ietf:params:oauth:request_uri:xyz'); - }), -); - -it.effect('validateParResponseµ fails with network_error on RTK error', () => - Micro.gen(function* () { - const exit = yield* Micro.exit( - validateParResponseµ({ - error: { - status: 400, - data: { error: 'invalid_client', error_description: 'bad creds', type: 'auth_error' }, - }, - }), - ); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.error).toBe('invalid_client'); - }), -); - -it.effect('validateParResponseµ fails with network_error when request_uri is absent', () => - Micro.gen(function* () { - const exit = yield* Micro.exit(validateParResponseµ({ data: { expires_in: 60 } })); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('network_error'); - expect(exit.cause.error.error_description).toContain('request_uri'); - }), -); +it('validateParResponseµ succeeds when request_uri is present', () => + Micro.runPromise( + Micro.gen(function* () { + const result = yield* validateParResponseµ({ + data: { request_uri: 'urn:ietf:params:oauth:request_uri:xyz', expires_in: 60 }, + }); + expect(result.request_uri).toBe('urn:ietf:params:oauth:request_uri:xyz'); + }), + )); + +it('validateParResponseµ fails with network_error on RTK error', () => + Micro.runPromise( + Micro.gen(function* () { + const exit = yield* Micro.exit( + validateParResponseµ({ + error: { + status: 400, + data: { error: 'invalid_client', error_description: 'bad creds', type: 'auth_error' }, + }, + }), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.error).toBe('invalid_client'); + }), + )); + +it('validateParResponseµ fails with network_error when request_uri is absent', () => + Micro.runPromise( + Micro.gen(function* () { + const exit = yield* Micro.exit(validateParResponseµ({ data: { expires_in: 60 } })); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('network_error'); + expect(exit.cause.error.error_description).toContain('request_uri'); + }), + )); // ─── createAuthorizeUrlµ ────────────────────────────────────────────────── -it.effect('createAuthorizeUrlµ returns [url, options] tuple', () => - Micro.gen(function* () { - vi.stubGlobal('sessionStorage', sessionStorageStub); - const opts = { - clientId, - redirectUri, - scope, - responseType: 'code' as const, - state: 'test-state', - verifier: 'test-verifier', - }; - vi.spyOn(sdkOidc, 'createAuthorizeUrl').mockResolvedValue( - 'https://example.com/authorize?foo=bar', - ); - const [url, returnedOpts] = yield* createAuthorizeUrlµ(wellknown.authorization_endpoint, opts); - expect(url).toBe('https://example.com/authorize?foo=bar'); - expect(returnedOpts).toBe(opts); - }), -); - -it.effect('createAuthorizeUrlµ fails with auth_error when createAuthorizeUrl rejects', () => - Micro.gen(function* () { - vi.spyOn(sdkOidc, 'createAuthorizeUrl').mockRejectedValue(new Error('url build failed')); - const exit = yield* Micro.exit( - createAuthorizeUrlµ(wellknown.authorization_endpoint, { +it('createAuthorizeUrlµ returns [url, options] tuple', () => + Micro.runPromise( + Micro.gen(function* () { + vi.stubGlobal('sessionStorage', sessionStorageStub); + const opts = { clientId, redirectUri, scope, - responseType, - }), - ); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('auth_error'); - expect(exit.cause.error.error).toBe('AuthorizationUrlError'); - }), -); + responseType: 'code' as const, + state: 'test-state', + verifier: 'test-verifier', + }; + vi.spyOn(sdkOidc, 'createAuthorizeUrl').mockResolvedValue( + 'https://example.com/authorize?foo=bar', + ); + const [url, returnedOpts] = yield* createAuthorizeUrlµ( + wellknown.authorization_endpoint, + opts, + ); + expect(url).toBe('https://example.com/authorize?foo=bar'); + expect(returnedOpts).toBe(opts); + }), + )); + +it('createAuthorizeUrlµ fails with auth_error when createAuthorizeUrl rejects', () => + Micro.runPromise( + Micro.gen(function* () { + vi.spyOn(sdkOidc, 'createAuthorizeUrl').mockRejectedValue(new Error('url build failed')); + const exit = yield* Micro.exit( + createAuthorizeUrlµ(wellknown.authorization_endpoint, { + clientId, + redirectUri, + scope, + responseType, + }), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('auth_error'); + expect(exit.cause.error.error).toBe('AuthorizationUrlError'); + }), + )); // ─── handleDispatchErrorµ ────────────────────────────────────────────────────── -it.effect('handleDispatchErrorµ fails immediately for CONFIGURATION_ERROR', () => - Micro.gen(function* () { - const exit = yield* Micro.exit( - handleDispatchErrorµ( - { - status: 'CUSTOM_ERROR', - statusText: 'CONFIGURATION_ERROR', - error: 'config error', - data: undefined, - } as FetchBaseQueryError, - wellknown, - { clientId, redirectUri, scope, responseType }, - ), - ); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('unknown_error'); - }), -); - -it.effect('handleDispatchErrorµ builds redirect URL for non-config errors', () => - Micro.gen(function* () { - vi.spyOn(sdkOidc, 'createAuthorizeUrl').mockResolvedValue( - 'https://example.com/authorize?error=login_required', - ); - const exit = yield* Micro.exit( - handleDispatchErrorµ( - { - status: 400, - data: { - error: 'login_required', - error_description: 'User must authenticate', - type: 'auth_error', +it('handleDispatchErrorµ fails immediately for CONFIGURATION_ERROR', () => + Micro.runPromise( + Micro.gen(function* () { + const exit = yield* Micro.exit( + handleDispatchErrorµ( + { + status: 'CUSTOM_ERROR', + statusText: 'CONFIGURATION_ERROR', + error: 'config error', + data: undefined, + } as FetchBaseQueryError, + wellknown, + { clientId, redirectUri, scope, responseType }, + ), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('unknown_error'); + }), + )); + +it('handleDispatchErrorµ builds redirect URL for non-config errors', () => + Micro.runPromise( + Micro.gen(function* () { + vi.spyOn(sdkOidc, 'createAuthorizeUrl').mockResolvedValue( + 'https://example.com/authorize?error=login_required', + ); + const exit = yield* Micro.exit( + handleDispatchErrorµ( + { + status: 400, + data: { + error: 'login_required', + error_description: 'User must authenticate', + type: 'auth_error', + }, }, - }, - wellknown, - { clientId, redirectUri, scope, responseType }, - ), - ); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.error).toBe('login_required'); - expect(exit.cause.error).toHaveProperty('redirectUrl'); - }), -); + wellknown, + { clientId, redirectUri, scope, responseType }, + ), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.error).toBe('login_required'); + expect(exit.cause.error).toHaveProperty('redirectUrl'); + }), + )); // ─── dispatchAuthorizeFetchµ ─────────────────────────────────────────────────── -it.effect('dispatchAuthorizeFetchµ succeeds with authorizeResponse', () => - Micro.gen(function* () { - const authorizeResponse = { code: 'auth-code-abc', state: 'state-xyz' }; - vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ - data: { authorizeResponse }, - } as unknown as ReturnType); - - const result = yield* dispatchAuthorizeFetchµ( - mockStore, - 'https://example.com/authorize?request_uri=...', - wellknown, - { clientId, redirectUri, scope, responseType }, - ); - expect(result).toStrictEqual(authorizeResponse); - }), -); - -it.effect( - 'dispatchAuthorizeFetchµ fails with unknown_error when data has no authorizeResponse', - () => +it('dispatchAuthorizeFetchµ succeeds with authorizeResponse', () => + Micro.runPromise( + Micro.gen(function* () { + const authorizeResponse = { code: 'auth-code-abc', state: 'state-xyz' }; + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: { authorizeResponse }, + } as unknown as ReturnType); + + const result = yield* dispatchAuthorizeFetchµ( + mockStore, + 'https://example.com/authorize?request_uri=...', + wellknown, + { clientId, redirectUri, scope, responseType }, + ); + expect(result).toStrictEqual(authorizeResponse); + }), + )); + +it('dispatchAuthorizeFetchµ fails with unknown_error when data has no authorizeResponse', () => + Micro.runPromise( Micro.gen(function* () { vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ data: {}, @@ -361,64 +384,67 @@ it.effect( if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; expect(exit.cause.error.type).toBe('unknown_error'); }), -); + )); // ─── dispatchAuthorizeIframeµ ────────────────────────────────────────────────── -it.effect('dispatchAuthorizeIframeµ succeeds with iframe data', () => - Micro.gen(function* () { - const iframeData = { code: 'iframe-code', state: 'state-abc' }; - vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ - data: iframeData, - } as unknown as ReturnType); - - const result = yield* dispatchAuthorizeIframeµ( - mockStore, - 'https://example.com/authorize', - wellknown, - { clientId, redirectUri, scope, responseType }, - ); - expect(result).toStrictEqual(iframeData); - }), -); - -it.effect('dispatchAuthorizeIframeµ fails with unknown_error when data is undefined', () => - Micro.gen(function* () { - vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ - data: undefined, - } as unknown as ReturnType); - - const exit = yield* Micro.exit( - dispatchAuthorizeIframeµ(mockStore, 'https://example.com/authorize', wellknown, { - clientId, - redirectUri, - scope, - responseType, - }), - ); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('unknown_error'); - }), -); - -it.effect('dispatchAuthorizeIframeµ fails with unknown_error when data has no code or state', () => - Micro.gen(function* () { - vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ - data: { unexpected: 'shape' }, - } as unknown as ReturnType); - - const exit = yield* Micro.exit( - dispatchAuthorizeIframeµ( +it('dispatchAuthorizeIframeµ succeeds with iframe data', () => + Micro.runPromise( + Micro.gen(function* () { + const iframeData = { code: 'iframe-code', state: 'state-abc' }; + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: iframeData, + } as unknown as ReturnType); + + const result = yield* dispatchAuthorizeIframeµ( mockStore, - 'https://example.com/authorize?foo=bar', + 'https://example.com/authorize', wellknown, - {} as import('@forgerock/sdk-types').GetAuthorizationUrlOptions, - ), - ); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - expect(exit.cause.error.type).toBe('unknown_error'); - expect(exit.cause.error.error).toBe('Unknown_Error'); - }), -); + { clientId, redirectUri, scope, responseType }, + ); + expect(result).toStrictEqual(iframeData); + }), + )); + +it('dispatchAuthorizeIframeµ fails with unknown_error when data is undefined', () => + Micro.runPromise( + Micro.gen(function* () { + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: undefined, + } as unknown as ReturnType); + + const exit = yield* Micro.exit( + dispatchAuthorizeIframeµ(mockStore, 'https://example.com/authorize', wellknown, { + clientId, + redirectUri, + scope, + responseType, + }), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('unknown_error'); + }), + )); + +it('dispatchAuthorizeIframeµ fails with unknown_error when data has no code or state', () => + Micro.runPromise( + Micro.gen(function* () { + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: { unexpected: 'shape' }, + } as unknown as ReturnType); + + const exit = yield* Micro.exit( + dispatchAuthorizeIframeµ( + mockStore, + 'https://example.com/authorize?foo=bar', + wellknown, + {} as import('@forgerock/sdk-types').GetAuthorizationUrlOptions, + ), + ); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + expect(exit.cause.error.type).toBe('unknown_error'); + expect(exit.cause.error.error).toBe('Unknown_Error'); + }), + )); 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..c9774446f3 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.test.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.test.ts @@ -4,7 +4,7 @@ * 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, expect } from 'vitest'; import { Micro } from 'effect'; import { vi, afterEach } from 'vitest'; import * as sdkOidc from '@forgerock/sdk-oidc'; @@ -130,75 +130,82 @@ it('toDispatchError delegates to toAuthorizationError for FetchBaseQueryError', // ─── createParAuthorizeUrlµ ─────────────────────────────────────────────────────────── -it.effect('createParAuthorizeUrlµ fails with PAR_NOT_CONFIGURED when par endpoint is missing', () => - Micro.gen(function* () { - const configWithPar: OidcConfig = { ...config, par: true }; - const result = yield* Micro.exit( - createParAuthorizeUrlµ(wellknown, configWithPar, mockLog, mockStore), - ); - expect(Micro.exitIsFailure(result)).toBe(true); - if (!Micro.exitIsFailure(result)) return; - expect(Micro.causeIsFail(result.cause)).toBe(true); - if (Micro.causeIsFail(result.cause)) { - expect(result.cause.error.error).toBe('PAR_NOT_CONFIGURED'); - expect(result.cause.error.type).toBe('wellknown_error'); - expect(result.cause.error.error_description).toBe( - 'PAR endpoint not found in server configuration', +it('createParAuthorizeUrlµ fails with PAR_NOT_CONFIGURED when par endpoint is missing', () => + Micro.runPromise( + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + const result = yield* Micro.exit( + createParAuthorizeUrlµ(wellknown, configWithPar, mockLog, mockStore), + ); + expect(Micro.exitIsFailure(result)).toBe(true); + if (!Micro.exitIsFailure(result)) return; + expect(Micro.causeIsFail(result.cause)).toBe(true); + if (Micro.causeIsFail(result.cause)) { + expect(result.cause.error.error).toBe('PAR_NOT_CONFIGURED'); + expect(result.cause.error.type).toBe('wellknown_error'); + expect(result.cause.error.error_description).toBe( + 'PAR endpoint not found in server configuration', + ); + } + }), + )); + +it('createParAuthorizeUrlµ succeeds and returns slim authorize URL', () => + Micro.runPromise( + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + const requestUri = 'urn:ietf:params:oauth:request_uri:abc123'; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + data: { request_uri: requestUri, expires_in: 60 }, + } as unknown as ReturnType); + + const url = yield* createParAuthorizeUrlµ( + wellknownWithPar, + configWithPar, + mockLog, + mockStore, ); - } - }), -); - -it.effect('createParAuthorizeUrlµ succeeds and returns slim authorize URL', () => - Micro.gen(function* () { - const configWithPar: OidcConfig = { ...config, par: true }; - const requestUri = 'urn:ietf:params:oauth:request_uri:abc123'; - - vi.stubGlobal('sessionStorage', sessionStorageStub); - vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ - data: { request_uri: requestUri, expires_in: 60 }, - } as unknown as ReturnType); - - const url = yield* createParAuthorizeUrlµ(wellknownWithPar, configWithPar, mockLog, mockStore); - - expect(url).toContain('client_id=123456789'); - expect(url).toContain(`request_uri=${encodeURIComponent(requestUri)}`); - expect(url).not.toContain('scope='); - expect(url).not.toContain('code_challenge='); - expect(sessionStorageStub.setItem).toHaveBeenCalled(); - }), -); - -it.effect('createParAuthorizeUrlµ fails with network_error when PAR POST returns error', () => - Micro.gen(function* () { - const configWithPar: OidcConfig = { ...config, par: true }; - - vi.stubGlobal('sessionStorage', sessionStorageStub); - vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ - error: { - status: 400, - statusText: 'PAR_ERROR', - data: { error: 'PAR_ERROR', error_description: 'invalid_client', type: 'network_error' }, - }, - } as unknown as ReturnType); - - const result = yield* Micro.exit( - createParAuthorizeUrlµ(wellknownWithPar, configWithPar, mockLog, mockStore), - ); - - expect(Micro.exitIsFailure(result)).toBe(true); - if (!Micro.exitIsFailure(result)) return; - expect(Micro.causeIsFail(result.cause)).toBe(true); - if (Micro.causeIsFail(result.cause)) { - expect(result.cause.error.type).toBe('network_error'); - } - expect(sessionStorageStub.setItem).not.toHaveBeenCalled(); - }), -); - -it.effect( - 'createParAuthorizeUrlµ fails with network_error when PAR response is missing request_uri', - () => + + expect(url).toContain('client_id=123456789'); + expect(url).toContain(`request_uri=${encodeURIComponent(requestUri)}`); + expect(url).not.toContain('scope='); + expect(url).not.toContain('code_challenge='); + expect(sessionStorageStub.setItem).toHaveBeenCalled(); + }), + )); + +it('createParAuthorizeUrlµ fails with network_error when PAR POST returns error', () => + Micro.runPromise( + Micro.gen(function* () { + const configWithPar: OidcConfig = { ...config, par: true }; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ + error: { + status: 400, + statusText: 'PAR_ERROR', + data: { error: 'PAR_ERROR', error_description: 'invalid_client', type: 'network_error' }, + }, + } as unknown as ReturnType); + + const result = yield* Micro.exit( + createParAuthorizeUrlµ(wellknownWithPar, configWithPar, mockLog, mockStore), + ); + + expect(Micro.exitIsFailure(result)).toBe(true); + if (!Micro.exitIsFailure(result)) return; + expect(Micro.causeIsFail(result.cause)).toBe(true); + if (Micro.causeIsFail(result.cause)) { + expect(result.cause.error.type).toBe('network_error'); + } + expect(sessionStorageStub.setItem).not.toHaveBeenCalled(); + }), + )); + +it('createParAuthorizeUrlµ fails with network_error when PAR response is missing request_uri', () => + Micro.runPromise( Micro.gen(function* () { const configWithPar: OidcConfig = { ...config, par: true }; @@ -222,11 +229,10 @@ it.effect( } expect(sessionStorageStub.setItem).not.toHaveBeenCalled(); }), -); + )); -it.effect( - 'createParAuthorizeUrlµ with prompt=none includes prompt on slim authorize URL and in PAR body', - () => +it('createParAuthorizeUrlµ with prompt=none includes prompt on slim authorize URL and in PAR body', () => + Micro.runPromise( Micro.gen(function* () { const configWithPar: OidcConfig = { ...config, par: true }; const requestUri = 'urn:ietf:params:oauth:request_uri:prompt-none-test'; @@ -253,7 +259,7 @@ it.effect( const parBodyArg = buildParamsSpy.mock.calls[0][0] as unknown as Record; expect(parBodyArg.prompt).toBe('none'); }), -); + )); // ─── isStringRecord ────────────────────────────────────────────────────────── @@ -287,16 +293,17 @@ it('hasPushRequestUri returns false when request_uri is missing', () => { import { validateParResponseµ } from './authorize.request.micros.js'; -it.effect('validateParResponseµ with SerializedError preserves error message', () => - Micro.gen(function* () { - const serializedError = { name: 'Error', message: 'network timeout', code: 'FETCH_ERROR' }; - const exit = yield* Micro.exit(validateParResponseµ({ error: serializedError })); - expect(Micro.exitIsFailure(exit)).toBe(true); - if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; - // Should surface the actual message, not generic "Unknown_Error" - expect(exit.cause.error.error_description).toContain('network timeout'); - }), -); +it('validateParResponseµ with SerializedError preserves error message', () => + Micro.runPromise( + Micro.gen(function* () { + const serializedError = { name: 'Error', message: 'network timeout', code: 'FETCH_ERROR' }; + const exit = yield* Micro.exit(validateParResponseµ({ error: serializedError })); + expect(Micro.exitIsFailure(exit)).toBe(true); + if (!Micro.exitIsFailure(exit) || !Micro.causeIsFail(exit.cause)) return; + // Should surface the actual message, not generic "Unknown_Error" + expect(exit.cause.error.error_description).toContain('network timeout'); + }), + )); // ─── buildParAuthorizeUrl ──────────────────────────────────────────────────── @@ -377,51 +384,59 @@ it('buildAuthorizeOptions falls back to "openid" scope and "code" responseType w // ─── authorizeµ flow routing ────────────────────────────────────────────────── -it.effect('authorizeµ uses PAR flow when useParFlow=true', () => - Micro.gen(function* () { - const requestUri = 'urn:ietf:params:oauth:request_uri:par-routing-test'; - const authorizeResponse = { code: 'par-code', state: 'par-state' }; +it('authorizeµ uses PAR flow when useParFlow=true', () => + Micro.runPromise( + Micro.gen(function* () { + const requestUri = 'urn:ietf:params:oauth:request_uri:par-routing-test'; + const authorizeResponse = { code: 'par-code', state: 'par-state' }; - vi.stubGlobal('sessionStorage', sessionStorageStub); - // First dispatch: PAR POST → returns request_uri - vi.mocked(mockStore.dispatch) - .mockResolvedValueOnce({ - data: { request_uri: requestUri, expires_in: 60 }, - } as unknown as ReturnType) - // Second dispatch: authorize iframe/fetch → returns code+state - .mockResolvedValueOnce({ + vi.stubGlobal('sessionStorage', sessionStorageStub); + // First dispatch: PAR POST → returns request_uri + vi.mocked(mockStore.dispatch) + .mockResolvedValueOnce({ + data: { request_uri: requestUri, expires_in: 60 }, + } as unknown as ReturnType) + // Second dispatch: authorize iframe/fetch → returns code+state + .mockResolvedValueOnce({ + data: authorizeResponse, + } as unknown as ReturnType); + + const result = yield* authorizeµ( + wellknownWithPar, + config, + mockLog, + mockStore, + undefined, + true, + ); + expect(result).toStrictEqual(authorizeResponse); + // PAR POST + authorize dispatch = 2 calls + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + }), + )); + +it('authorizeµ uses standard flow when useParFlow=false', () => + Micro.runPromise( + Micro.gen(function* () { + const authorizeResponse = { code: 'std-code', state: 'std-state' }; + + vi.stubGlobal('sessionStorage', sessionStorageStub); + vi.spyOn(sdkOidc, 'createAuthorizeUrl').mockResolvedValue( + 'https://example.com/authorize?code_challenge=xxx', + ); + vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ data: authorizeResponse, } as unknown as ReturnType); - const result = yield* authorizeµ(wellknownWithPar, config, mockLog, mockStore, undefined, true); - expect(result).toStrictEqual(authorizeResponse); - // PAR POST + authorize dispatch = 2 calls - expect(mockStore.dispatch).toHaveBeenCalledTimes(2); - }), -); - -it.effect('authorizeµ uses standard flow when useParFlow=false', () => - Micro.gen(function* () { - const authorizeResponse = { code: 'std-code', state: 'std-state' }; - - vi.stubGlobal('sessionStorage', sessionStorageStub); - vi.spyOn(sdkOidc, 'createAuthorizeUrl').mockResolvedValue( - 'https://example.com/authorize?code_challenge=xxx', - ); - vi.mocked(mockStore.dispatch).mockResolvedValueOnce({ - data: authorizeResponse, - } as unknown as ReturnType); - - const result = yield* authorizeµ(wellknown, config, mockLog, mockStore, undefined, false); - expect(result).toStrictEqual(authorizeResponse); - // Only one dispatch: the iframe/fetch call (no PAR POST) - expect(mockStore.dispatch).toHaveBeenCalledTimes(1); - }), -); - -it.effect( - 'authorizeµ uses PAR flow when caller passes useParFlow=true (e.g. server requires PAR)', - () => + const result = yield* authorizeµ(wellknown, config, mockLog, mockStore, undefined, false); + expect(result).toStrictEqual(authorizeResponse); + // Only one dispatch: the iframe/fetch call (no PAR POST) + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + }), + )); + +it('authorizeµ uses PAR flow when caller passes useParFlow=true (e.g. server requires PAR)', () => + Micro.runPromise( Micro.gen(function* () { const requestUri = 'urn:ietf:params:oauth:request_uri:required-par-test'; const authorizeResponse = { code: 'req-par-code', state: 'req-par-state' }; @@ -447,11 +462,10 @@ it.effect( expect(result).toStrictEqual(authorizeResponse); expect(mockStore.dispatch).toHaveBeenCalledTimes(2); }), -); + )); -it.effect( - 'authorizeµ routes to pi.flow fetch when options.responseMode is pi.flow (unwraps authorizeResponse)', - () => +it('authorizeµ routes to pi.flow fetch when options.responseMode is pi.flow (unwraps authorizeResponse)', () => + Micro.runPromise( Micro.gen(function* () { // pi.flow dispatch goes through dispatchAuthorizeFetch which unwraps { authorizeResponse } const requestUri = 'urn:ietf:params:oauth:request_uri:pi-flow-test'; @@ -482,4 +496,4 @@ it.effect( ); expect(result).toStrictEqual(authorizeResponse); }), -); + )); diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index ae70d9874f..39d3c95877 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -10,7 +10,7 @@ import type { WellknownResponse, GetAuthorizationUrlOptions } from '@forgerock/s import type { AuthorizationError, OptionalAuthorizeOptions } from './authorize.request.types.js'; import type { OidcConfig } from './config.types.js'; -export type PromptValue = 'none' | 'login' | 'consent'; +export type PromptValue = 'none' | 'login' | 'consent' | 'select_account'; export type ParUrlParams = { authorizationEndpoint: string; diff --git a/packages/oidc-client/src/lib/client.store.test.ts b/packages/oidc-client/src/lib/client.store.test.ts index d15f8352ba..bc0984df4d 100644 --- a/packages/oidc-client/src/lib/client.store.test.ts +++ b/packages/oidc-client/src/lib/client.store.test.ts @@ -718,3 +718,80 @@ describe('authorize.url() with PAR enabled on non-pi.flow server', async () => { expect(parsed.searchParams.has('redirect_uri')).toBe(false); }); }); + +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: 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', + }, + }; + + await expect(oidc({ config: invalidConfig, storage: customStorageConfig })).rejects.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', // should be array + redirectUri: 'https://example.com/callback.html', + }, + }; + + await expect(oidc({ config: invalidConfig, storage: customStorageConfig })).rejects.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: 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'); + }); +}); diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index 5a79f04ace..982b7b79f3 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -29,6 +29,11 @@ import type { RevokeSuccessResult, UserInfoResponse, } from './client.types.js'; +import { + isUnifiedSdkConfig, + unifiedToOidcConfig, + validateUnifiedSdkConfig, +} from '@forgerock/sdk-utilities'; import type { OauthTokens, OidcConfig } from './config.types.js'; import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js'; import type { TokenExchangeErrorResponse } from './exchange.types.js'; @@ -49,12 +54,12 @@ import { logoutµ } from './logout.request.js'; * @returns {ReturnType} - Returns an object with methods for authorization, token exchange, user info retrieval, and logout. */ export async function oidc({ - config, + config: rawConfig, requestMiddleware, logger, storage, }: { - config: OidcConfig; + config: OidcConfig | Record; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; @@ -62,7 +67,27 @@ export async function oidc({ }; storage?: Partial; }) { - const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); + let config: OidcConfig; + + if (isUnifiedSdkConfig(rawConfig)) { + const validation = validateUnifiedSdkConfig(rawConfig, true); + if (!validation.success) { + const messages = validation.errors.map((e) => `${e.field}: ${e.message}`).join(', '); + throw new Error(`Invalid unified SDK config: ${messages}`); + } + const mapped = unifiedToOidcConfig(validation.data); + if (!mapped.success) { + throw new Error(`Invalid unified SDK config: ${mapped.error.field}: ${mapped.error.message}`); + } + config = mapped.data as OidcConfig; + } else { + config = rawConfig as OidcConfig; + } + + 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', @@ -169,6 +194,14 @@ export async function oidc({ redirectUri: config.redirectUri, scope: config.scope || 'openid', responseType: config.responseType || 'code', + ...(config.loginHint !== undefined && { loginHint: config.loginHint }), + ...(config.state !== undefined && { state: config.state }), + ...(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..c21e637456 100644 --- a/packages/oidc-client/src/lib/config.types.ts +++ b/packages/oidc-client/src/lib/config.types.ts @@ -5,7 +5,9 @@ * of the MIT license. See the LICENSE file for details. */ import type { AsyncLegacyConfigOptions, ResponseType } from '@forgerock/sdk-types'; +import type { LogLevel } from '@forgerock/sdk-logger'; +/** Configuration for creating an OIDC client instance. */ export interface OidcConfig extends AsyncLegacyConfigOptions { // Redundant properties are redeclared to define as required clientId: string; @@ -16,7 +18,28 @@ export interface OidcConfig extends AsyncLegacyConfigOptions { 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; + /** Pre-fill the login_hint parameter in the authorization request. */ + loginHint?: string; + /** Opaque value used to maintain state between the request and callback. */ + state?: string; + /** String value used to associate a client session with an ID token and mitigate replay attacks. */ + nonce?: string; + /** Controls the display mode for the authorization UI. */ + display?: 'page' | 'popup' | 'touch' | 'wap'; + /** Specifies whether the authorization server should prompt the user for re-authentication or consent. */ + prompt?: 'none' | 'login' | 'consent' | 'select_account'; + /** Space-separated BCP47 language tag values indicating the preferred display language for the UI. */ + uiLocales?: string; + /** Space-separated Authentication Context Class Reference values requested for the authentication. */ + acrValues?: string; + /** Additional query parameters to append to the authorization URL. */ + query?: Record; + /** Log level for the client logger when initialized from a unified JSON config. */ + log?: LogLevel; } export interface OauthTokens { diff --git a/packages/oidc-client/src/lib/logout.request.test.ts b/packages/oidc-client/src/lib/logout.request.test.ts index 09bc0af911..bf8fde3e61 100644 --- a/packages/oidc-client/src/lib/logout.request.test.ts +++ b/packages/oidc-client/src/lib/logout.request.test.ts @@ -4,7 +4,7 @@ * 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..406390e6d3 100644 --- a/packages/oidc-client/src/lib/logout.request.ts +++ b/packages/oidc-client/src/lib/logout.request.ts @@ -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 1fee7fd373..5b68ca535e 100644 --- a/packages/oidc-client/src/lib/oidc.api.ts +++ b/packages/oidc-client/src/lib/oidc.api.ts @@ -260,12 +260,17 @@ 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/oidc/src/lib/authorize.test.ts b/packages/sdk-effects/oidc/src/lib/authorize.test.ts index 484e8bed25..aab570541b 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.test.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.test.ts @@ -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..3ab31126b9 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.utils.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.utils.ts @@ -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..dbce32aa04 100644 --- a/packages/sdk-types/src/lib/authorize.types.ts +++ b/packages/sdk-types/src/lib/authorize.types.ts @@ -29,7 +29,12 @@ export interface GetAuthorizationUrlOptions extends LegacyConfigOptions { state?: string; verifier?: string; query?: Record; - prompt?: 'none' | 'login' | 'consent'; + prompt?: 'none' | 'login' | 'consent' | 'select_account'; + loginHint?: string; + nonce?: string; + display?: 'page' | 'popup' | 'touch' | 'wap'; + uiLocales?: string; + acrValues?: string; successParams?: string[]; errorParams?: string[]; } diff --git a/packages/sdk-utilities/src/index.ts b/packages/sdk-utilities/src/index.ts index f01a5f77b8..016204e6b2 100644 --- a/packages/sdk-utilities/src/index.ts +++ b/packages/sdk-utilities/src/index.ts @@ -14,3 +14,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.test.ts b/packages/sdk-utilities/src/lib/config/config.test.ts new file mode 100644 index 0000000000..416de9c0db --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/config.test.ts @@ -0,0 +1,551 @@ +/* + * 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 { + validateUnifiedSdkConfig, + validateUnifiedOidcConfig, + unifiedToOidcConfig, + unifiedToJourneyConfig, + unifiedToDavinciConfig, + isUnifiedSdkConfig, +} from './config.utils.js'; +import type { UnifiedSdkConfig } from './config.types.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: UnifiedSdkConfig = { + 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', + state: 'custom-state', + 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: UnifiedSdkConfig = { + journey: { + serverUrl: 'https://example.com/am', + realm: 'alpha', + }, + oidc: { + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + }, +}; + +describe('validateUnifiedOidcConfig', () => { + it('validateUnifiedOidcConfig_ValidMinimalInput_ReturnsSuccess', () => { + const result = validateUnifiedOidcConfig(minimalOidc); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.clientId).toBe('my-client'); + } + }); + + it('validateUnifiedOidcConfig_StrictMode_MissingClientId_ReturnsError', () => { + const { clientId: _removed, ...input } = minimalOidc; + const result = validateUnifiedOidcConfig(input, 'oidc', true); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.clientId')).toBe(true); + } + }); + + it('validateUnifiedOidcConfig_MissingDiscoveryEndpoint_ReturnsError', () => { + const { discoveryEndpoint: _removed, ...input } = minimalOidc; + const result = validateUnifiedOidcConfig(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.discoveryEndpoint')).toBe(true); + } + }); + + it('validateUnifiedOidcConfig_StrictMode_MissingScopes_ReturnsError', () => { + const { scopes: _removed, ...input } = minimalOidc; + const result = validateUnifiedOidcConfig(input, 'oidc', true); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.scopes')).toBe(true); + } + }); + + it('validateUnifiedOidcConfig_StrictMode_MissingRedirectUri_ReturnsError', () => { + const { redirectUri: _removed, ...input } = minimalOidc; + const result = validateUnifiedOidcConfig(input, 'oidc', true); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.redirectUri')).toBe(true); + } + }); + + it('validateUnifiedOidcConfig_ScopesNotArray_ReturnsTypeError', () => { + const result = validateUnifiedOidcConfig({ ...minimalOidc, scopes: 'openid profile' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.scopes')).toBe(true); + } + }); + + it('validateUnifiedOidcConfig_ClientIdNotString_ReturnsTypeError', () => { + const result = validateUnifiedOidcConfig({ ...minimalOidc, clientId: 123 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.clientId')).toBe(true); + } + }); + + it('validateUnifiedOidcConfig_RefreshThresholdNotNumber_ReturnsTypeError', () => { + const result = validateUnifiedOidcConfig({ ...minimalOidc, refreshThreshold: 'sixty' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.refreshThreshold')).toBe(true); + } + }); + + it('validateUnifiedOidcConfig_AdditionalParametersIsArray_ReturnsTypeError', () => { + const result = validateUnifiedOidcConfig({ + ...minimalOidc, + additionalParameters: ['foo'], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.additionalParameters')).toBe(true); + } + }); + + it('validateUnifiedOidcConfig_UnknownFieldPresent_Ignored', () => { + const result = validateUnifiedOidcConfig({ ...minimalOidc, unknownField: 'surprise' }); + expect(result.success).toBe(true); + }); + + it('validateUnifiedOidcConfig_NullInput_ReturnsError', () => { + const result = validateUnifiedOidcConfig(null); + expect(result.success).toBe(false); + }); +}); + +describe('validateUnifiedSdkConfig', () => { + it('validateUnifiedSdkConfig_ValidFullConfig_ReturnsSuccess', () => { + const result = validateUnifiedSdkConfig(fullConfig); + expect(result.success).toBe(true); + }); + + it('validateUnifiedSdkConfig_JourneyOnlyConfig_ReturnsSuccess', () => { + const result = validateUnifiedSdkConfig(journeyOnlyConfig); + expect(result.success).toBe(true); + }); + + it('validateUnifiedSdkConfig_NoOidcOrJourneySection_ReturnsSuccess', () => { + const result = validateUnifiedSdkConfig({ timeout: 5000 }); + expect(result.success).toBe(true); + }); + + it('validateUnifiedSdkConfig_TimeoutNotNumber_ReturnsTypeError', () => { + const result = validateUnifiedSdkConfig({ ...fullConfig, timeout: 'thirty' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'timeout')).toBe(true); + } + }); + + it('validateUnifiedSdkConfig_JourneyMissingServerUrl_ReturnsError', () => { + const result = validateUnifiedSdkConfig({ + journey: { realm: 'alpha' }, + oidc: { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration' }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'journey.serverUrl')).toBe(true); + } + }); + + it('validateUnifiedSdkConfig_JourneyServerUrlWrongType_ReturnsError', () => { + const result = validateUnifiedSdkConfig({ journey: { serverUrl: 123 } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'journey.serverUrl')).toBe(true); + } + }); + + it('validateUnifiedSdkConfig_StrictMode_MissingOidcClientId_ReturnsError', () => { + const result = validateUnifiedSdkConfig( + { + oidc: { + discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', + scopes: ['openid'], + redirectUri: 'https://example.com/cb', + }, + }, + true, + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.clientId')).toBe(true); + } + }); + + it('validateUnifiedSdkConfig_InvalidOidcNested_PropagatesErrors', () => { + const result = validateUnifiedSdkConfig({ + ...fullConfig, + oidc: { ...minimalOidc, clientId: 42 }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.some((e) => e.field === 'oidc.clientId')).toBe(true); + } + }); + + it('validateUnifiedSdkConfig_NullInput_ReturnsError', () => { + const result = validateUnifiedSdkConfig(null); + expect(result.success).toBe(false); + }); +}); + +describe('unifiedToOidcConfig', () => { + it('unifiedToOidcConfig_NoOidcBlock_ReturnsFailure', () => { + const result = unifiedToOidcConfig({ journey: { serverUrl: 'https://example.com/am' } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.field).toBe('oidc'); + } + }); + + it('unifiedToOidcConfig_MinimalConfig_MapsRequiredFields', () => { + const result = unifiedToOidcConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.clientId).toBe('my-client'); + expect(result.data.redirectUri).toBe('https://app.example.com/callback'); + expect(result.data.scope).toBe('openid profile'); + expect(result.data.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + } + }); + + it('unifiedToOidcConfig_ScopesJoinedWithSpace', () => { + const result = unifiedToOidcConfig({ oidc: { ...minimalOidc, scopes: ['openid', 'email'] } }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.scope).toBe('openid email'); + }); + + it('unifiedToOidcConfig_RefreshThresholdConvertedToMs', () => { + const result = unifiedToOidcConfig({ oidc: { ...minimalOidc, refreshThreshold: 60 } }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.oauthThreshold).toBe(60000); + }); + + it('unifiedToOidcConfig_NoRefreshThreshold_OauthThresholdAbsent', () => { + const result = unifiedToOidcConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.oauthThreshold).toBeUndefined(); + }); + + it('unifiedToOidcConfig_RealmMappedToRealmPath', () => { + const result = unifiedToOidcConfig({ + journey: { serverUrl: 'https://example.com/am', realm: 'alpha' }, + oidc: minimalOidc, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.realmPath).toBe('alpha'); + }); + + it('unifiedToOidcConfig_NoRealm_RealmPathAbsent', () => { + const result = unifiedToOidcConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.realmPath).toBeUndefined(); + }); + + it('unifiedToOidcConfig_TimeoutPassedToServerConfig', () => { + const result = unifiedToOidcConfig({ timeout: 5000, oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.serverConfig.timeout).toBe(5000); + }); + + it('unifiedToOidcConfig_NoTimeout_TimeoutAbsentInServerConfig', () => { + const result = unifiedToOidcConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.serverConfig.timeout).toBeUndefined(); + }); + + it('unifiedToOidcConfig_AuthorizeParamsMapped', () => { + const result = unifiedToOidcConfig({ + oidc: { + ...minimalOidc, + loginHint: 'user@example.com', + state: 'custom-state', + nonce: 'custom-nonce', + display: 'page', + prompt: 'login', + uiLocales: 'en-US', + acrValues: 'Level3', + additionalParameters: { max_age: '3600' }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.loginHint).toBe('user@example.com'); + expect(result.data.state).toBe('custom-state'); + expect(result.data.nonce).toBe('custom-nonce'); + expect(result.data.display).toBe('page'); + expect(result.data.prompt).toBe('login'); + expect(result.data.uiLocales).toBe('en-US'); + expect(result.data.acrValues).toBe('Level3'); + expect(result.data.query).toEqual({ max_age: '3600' }); + } + }); + + it('unifiedToOidcConfig_NoAuthorizeParams_AllAbsent', () => { + const result = unifiedToOidcConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.loginHint).toBeUndefined(); + expect(result.data.nonce).toBeUndefined(); + expect(result.data.query).toBeUndefined(); + } + }); +}); + +describe('unifiedToJourneyConfig', () => { + it('unifiedToJourneyConfig_NoOidcBlock_ReturnsFailure', () => { + const result = unifiedToJourneyConfig({ journey: { serverUrl: 'https://example.com/am' } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.field).toBe('oidc'); + } + }); + + it('unifiedToJourneyConfig_MinimalConfig_MapsWellknown', () => { + const result = unifiedToJourneyConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + } + }); + + it('unifiedToJourneyConfig_JourneyOnlyConfig_MapsWellknown', () => { + const result = unifiedToJourneyConfig(journeyOnlyConfig); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + } + }); + + it('unifiedToJourneyConfig_RealmMappedToRealmPath', () => { + const result = unifiedToJourneyConfig({ + journey: { serverUrl: 'https://example.com/am', realm: 'beta' }, + oidc: minimalOidc, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.realmPath).toBe('beta'); + }); + + it('unifiedToJourneyConfig_NoRealm_RealmPathAbsent', () => { + const result = unifiedToJourneyConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.realmPath).toBeUndefined(); + }); + + it('unifiedToJourneyConfig_TimeoutPassedToServerConfig', () => { + const result = unifiedToJourneyConfig({ timeout: 10000, oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.serverConfig.timeout).toBe(10000); + }); + + it('unifiedToJourneyConfig_OidcFieldsNotLeakedToResult', () => { + const result = unifiedToJourneyConfig(fullConfig); + expect(result.success).toBe(true); + if (result.success) { + const data = result.data as unknown as Record; + expect(data['clientId']).toBeUndefined(); + expect(data['scope']).toBeUndefined(); + expect(data['redirectUri']).toBeUndefined(); + } + }); +}); + +describe('unifiedToDavinciConfig', () => { + it('unifiedToDavinciConfig_NoOidcBlock_ReturnsFailure', () => { + const result = unifiedToDavinciConfig({ journey: { serverUrl: 'https://example.com/am' } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.field).toBe('oidc'); + } + }); + + it('unifiedToDavinciConfig_MinimalConfig_MapsRequiredFields', () => { + const result = unifiedToDavinciConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.clientId).toBe('my-client'); + expect(result.data.redirectUri).toBe('https://app.example.com/callback'); + expect(result.data.scope).toBe('openid profile'); + expect(result.data.serverConfig.wellknown).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + } + }); + + it('unifiedToDavinciConfig_ScopesJoinedWithSpace', () => { + const result = unifiedToDavinciConfig({ + oidc: { ...minimalOidc, scopes: ['openid', 'email'] }, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.scope).toBe('openid email'); + }); + + it('unifiedToDavinciConfig_RefreshThresholdConvertedToMs', () => { + const result = unifiedToDavinciConfig({ oidc: { ...minimalOidc, refreshThreshold: 30 } }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.oauthThreshold).toBe(30000); + }); + + it('unifiedToDavinciConfig_RealmMappedToRealmPath', () => { + const result = unifiedToDavinciConfig({ + journey: { serverUrl: 'https://example.com/am', realm: 'alpha' }, + oidc: minimalOidc, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.realmPath).toBe('alpha'); + }); + + it('unifiedToDavinciConfig_TimeoutPassedToServerConfig', () => { + const result = unifiedToDavinciConfig({ timeout: 7000, oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.serverConfig.timeout).toBe(7000); + }); +}); + +describe('unifiedToOidcConfig log mapping', () => { + it('unifiedToOidcConfig_LogFieldMapped_ToLogLevel', () => { + const result = unifiedToOidcConfig({ log: 'DEBUG', oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.log).toBe('debug'); + }); + + it('unifiedToOidcConfig_NoLogField_LogLevelAbsent', () => { + const result = unifiedToOidcConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.log).toBeUndefined(); + }); + + it('unifiedToOidcConfig_CookieName_NotMappedToResult', () => { + const result = unifiedToOidcConfig({ + journey: { serverUrl: 'https://example.com/am', cookieName: 'iPlanetDirectoryPro' }, + oidc: minimalOidc, + }); + expect(result.success).toBe(true); + if (result.success) { + const data = result.data as unknown as Record; + expect(data['cookieName']).toBeUndefined(); + } + }); +}); + +describe('unifiedToJourneyConfig log mapping', () => { + it('unifiedToJourneyConfig_LogFieldMapped_ToLogLevel', () => { + const result = unifiedToJourneyConfig({ log: 'WARN', oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.log).toBe('warn'); + }); + + it('unifiedToJourneyConfig_NoLogField_LogLevelAbsent', () => { + const result = unifiedToJourneyConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.log).toBeUndefined(); + }); + + it('unifiedToJourneyConfig_CookieName_NotMappedToResult', () => { + const result = unifiedToJourneyConfig({ + journey: { serverUrl: 'https://example.com/am', cookieName: 'iPlanetDirectoryPro' }, + oidc: minimalOidc, + }); + expect(result.success).toBe(true); + if (result.success) { + const data = result.data as unknown as Record; + expect(data['cookieName']).toBeUndefined(); + } + }); +}); + +describe('unifiedToDavinciConfig log mapping', () => { + it('unifiedToDavinciConfig_LogFieldMapped_ToLogLevel', () => { + const result = unifiedToDavinciConfig({ log: 'ERROR', oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.log).toBe('error'); + }); + + it('unifiedToDavinciConfig_NoLogField_LogLevelAbsent', () => { + const result = unifiedToDavinciConfig({ oidc: minimalOidc }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.log).toBeUndefined(); + }); +}); + +describe('isUnifiedSdkConfig', () => { + it('isUnifiedSdkConfig_FullConfig_ReturnsTrue', () => { + expect(isUnifiedSdkConfig(fullConfig)).toBe(true); + }); + + it('isUnifiedSdkConfig_OidcBlockPresent_ReturnsTrue', () => { + expect( + isUnifiedSdkConfig({ oidc: { discoveryEndpoint: 'https://example.com/.well-known' } }), + ).toBe(true); + }); + + it('isUnifiedSdkConfig_JourneyBlockPresent_ReturnsTrue', () => { + expect(isUnifiedSdkConfig({ journey: { serverUrl: 'https://example.com/am' } })).toBe(true); + }); + + it('isUnifiedSdkConfig_JourneyOnlyConfig_ReturnsTrue', () => { + expect(isUnifiedSdkConfig(journeyOnlyConfig)).toBe(true); + }); + + it('isUnifiedSdkConfig_LegacyInternalConfig_ReturnsFalse', () => { + expect(isUnifiedSdkConfig({ clientId: 'x', serverConfig: { wellknown: 'x' } })).toBe(false); + }); + + it('isUnifiedSdkConfig_NullInput_ReturnsFalse', () => { + expect(isUnifiedSdkConfig(null)).toBe(false); + }); + + it('isUnifiedSdkConfig_StringInput_ReturnsFalse', () => { + expect(isUnifiedSdkConfig('not an object')).toBe(false); + }); + + it('isUnifiedSdkConfig_OidcNull_ReturnsFalse', () => { + expect(isUnifiedSdkConfig({ oidc: null })).toBe(false); + }); + + it('isUnifiedSdkConfig_JourneyPrimitive_ReturnsFalse', () => { + expect(isUnifiedSdkConfig({ journey: 42 })).toBe(false); + }); +}); 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..983c95aaff --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/config.types.ts @@ -0,0 +1,91 @@ +/* + * 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 type LogLevelString = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'NONE'; + +export type OidcDisplayValue = 'page' | 'popup' | 'touch' | 'wap'; + +export type OidcPromptValue = 'none' | 'login' | 'consent' | 'select_account'; + +export interface UnifiedOidcConfig { + clientId?: string; + discoveryEndpoint: string; + scopes?: string[]; + redirectUri?: string; + signOutRedirectUri?: string; + refreshThreshold?: number; + loginHint?: string; + state?: string; + nonce?: string; + display?: OidcDisplayValue; + prompt?: OidcPromptValue; + uiLocales?: string; + acrValues?: string; + additionalParameters?: Record; + openId?: { + deviceAuthorizationEndpoint?: string; + }; +} + +export interface UnifiedJourneyConfig { + serverUrl: string; + realm?: string; + cookieName?: string; +} + +export interface UnifiedSdkConfig { + timeout?: number; + log?: LogLevelString; + journey?: UnifiedJourneyConfig; + oidc?: UnifiedOidcConfig; +} + +export type MappedLogLevel = 'error' | 'warn' | 'info' | 'debug' | 'none'; + +export interface MappedOidcConfig { + clientId: string; + redirectUri: string; + scope: string; + serverConfig: { + wellknown: string; + timeout?: number; + }; + oauthThreshold?: number; + realmPath?: string; + signOutRedirectUri?: string; + loginHint?: string; + state?: string; + nonce?: string; + display?: OidcDisplayValue; + prompt?: OidcPromptValue; + uiLocales?: string; + acrValues?: string; + query?: Record; + log?: MappedLogLevel; +} + +export interface MappedJourneyConfig { + serverConfig: { + wellknown: string; + timeout?: number; + }; + realmPath?: string; + log?: MappedLogLevel; +} + +export interface MappedDavinciConfig { + clientId?: string; + redirectUri?: string; + scope?: string; + serverConfig: { + wellknown: string; + timeout?: number; + }; + oauthThreshold?: number; + realmPath?: string; + log?: MappedLogLevel; +} 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..31db21f2aa --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/config.utils.ts @@ -0,0 +1,290 @@ +/* + * 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 { + UnifiedSdkConfig, + UnifiedOidcConfig, + MappedOidcConfig, + MappedJourneyConfig, + MappedDavinciConfig, + MappedLogLevel, + LogLevelString, +} from './config.types.js'; + +export type ConfigValidationError = { + field: string; + message: string; +}; + +export type ConfigValidationResult = + | { success: true; data: T } + | { success: false; errors: ConfigValidationError[] }; + +export type ConfigMappingResult = + | { success: true; data: T } + | { success: false; error: ConfigValidationError }; + +function toMappedLogLevel(level: LogLevelString): MappedLogLevel { + return level.toLowerCase() as MappedLogLevel; +} + +const REQUIRED_OIDC_FIELDS_STRICT: ReadonlyArray = [ + 'clientId', + 'discoveryEndpoint', + 'scopes', + 'redirectUri', +]; + +const REQUIRED_OIDC_FIELDS_JOURNEY: ReadonlyArray = ['discoveryEndpoint']; + +function validateType( + value: unknown, + expected: string, + field: string, + errors: ConfigValidationError[], +): void { + if (expected === 'array') { + if (!Array.isArray(value)) { + errors.push({ field, message: `Expected array, got ${typeof value}` }); + } + return; + } + if (typeof value !== expected) { + errors.push({ field, message: `Expected ${expected}, got ${typeof value}` }); + } +} + +export function validateUnifiedOidcConfig( + input: unknown, + prefix = 'oidc', + strict = false, +): ConfigValidationResult { + const errors: ConfigValidationError[] = []; + + if (typeof input !== 'object' || input === null) { + return { + success: false, + errors: [{ field: prefix, message: `Expected object, got ${typeof input}` }], + }; + } + + const raw = input as Record; + const requiredFields = strict ? REQUIRED_OIDC_FIELDS_STRICT : REQUIRED_OIDC_FIELDS_JOURNEY; + + for (const field of requiredFields) { + if (raw[field] === undefined || raw[field] === null) { + errors.push({ field: `${prefix}.${field}`, message: 'Required field is missing' }); + continue; + } + if (field === 'scopes') { + validateType(raw[field], 'array', `${prefix}.${field}`, errors); + } else { + validateType(raw[field], 'string', `${prefix}.${field}`, errors); + } + } + + if (!strict) { + for (const field of ['clientId', 'scopes', 'redirectUri'] as const) { + if (raw[field] !== undefined && raw[field] !== null) { + if (field === 'scopes') { + validateType(raw[field], 'array', `${prefix}.${field}`, errors); + } else { + validateType(raw[field], 'string', `${prefix}.${field}`, errors); + } + } + } + } + + const optionalStringFields: ReadonlyArray = [ + 'signOutRedirectUri', + 'loginHint', + 'state', + 'nonce', + 'display', + 'prompt', + 'uiLocales', + 'acrValues', + ]; + + for (const field of optionalStringFields) { + if (raw[field] !== undefined && raw[field] !== null) { + validateType(raw[field], 'string', `${prefix}.${field}`, errors); + } + } + + if (raw['refreshThreshold'] !== undefined && raw['refreshThreshold'] !== null) { + validateType(raw['refreshThreshold'], 'number', `${prefix}.refreshThreshold`, errors); + } + + if (raw['additionalParameters'] !== undefined && raw['additionalParameters'] !== null) { + if ( + typeof raw['additionalParameters'] !== 'object' || + Array.isArray(raw['additionalParameters']) + ) { + errors.push({ + field: `${prefix}.additionalParameters`, + message: `Expected object, got ${Array.isArray(raw['additionalParameters']) ? 'array' : typeof raw['additionalParameters']}`, + }); + } + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: raw as unknown as UnifiedOidcConfig }; +} + +export function validateUnifiedSdkConfig( + input: unknown, + strictOidc = false, +): ConfigValidationResult { + const errors: ConfigValidationError[] = []; + + if (typeof input !== 'object' || input === null) { + return { + success: false, + errors: [{ field: 'config', message: `Expected object, got ${typeof input}` }], + }; + } + + const raw = input as Record; + + if (raw['log'] !== undefined && raw['log'] !== null) { + validateType(raw['log'], 'string', 'log', errors); + } + + if (raw['timeout'] !== undefined && raw['timeout'] !== null) { + validateType(raw['timeout'], 'number', 'timeout', errors); + } + + if (raw['journey'] !== undefined && raw['journey'] !== null) { + if (typeof raw['journey'] !== 'object' || Array.isArray(raw['journey'])) { + errors.push({ field: 'journey', message: `Expected object, got ${typeof raw['journey']}` }); + } else { + const journey = raw['journey'] as Record; + if (journey['serverUrl'] === undefined || journey['serverUrl'] === null) { + errors.push({ field: 'journey.serverUrl', message: 'Required field is missing' }); + } else { + validateType(journey['serverUrl'], 'string', 'journey.serverUrl', errors); + } + for (const field of ['realm', 'cookieName'] as const) { + if (journey[field] !== undefined && journey[field] !== null) { + validateType(journey[field], 'string', `journey.${field}`, errors); + } + } + } + } + + if (raw['oidc'] !== undefined && raw['oidc'] !== null) { + const oidcResult = validateUnifiedOidcConfig(raw['oidc'], 'oidc', strictOidc); + if (!oidcResult.success) { + errors.push(...oidcResult.errors); + } + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: raw as unknown as UnifiedSdkConfig }; +} + +export function unifiedToOidcConfig( + config: UnifiedSdkConfig, +): ConfigMappingResult { + if (!config.oidc) { + return { success: false, error: { field: 'oidc', message: 'Required block is missing' } }; + } + const oidc = config.oidc; + const { clientId, redirectUri, scopes } = oidc; + if (!clientId || !redirectUri || !scopes) { + return { + success: false, + error: { field: 'oidc', message: 'clientId, redirectUri, and scopes are required' }, + }; + } + return { + success: true, + data: { + clientId, + redirectUri, + scope: 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.state !== undefined && { state: oidc.state }), + ...(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) }), + }, + }; +} + +export function unifiedToJourneyConfig( + config: UnifiedSdkConfig, +): ConfigMappingResult { + if (!config.oidc) { + return { success: false, error: { field: 'oidc', message: 'Required block is missing' } }; + } + const oidc = config.oidc; + return { + success: true, + data: { + 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) }), + }, + }; +} + +export function unifiedToDavinciConfig( + config: UnifiedSdkConfig, +): ConfigMappingResult { + if (!config.oidc) { + return { success: false, error: { field: 'oidc', message: 'Required block is missing' } }; + } + const oidc = config.oidc; + return { + success: true, + data: { + 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) }), + }, + }; +} + +export function isUnifiedSdkConfig(input: unknown): input is UnifiedSdkConfig { + if (typeof input !== 'object' || input === null) return false; + const raw = input as Record; + const oidcIsObject = 'oidc' in raw && typeof raw['oidc'] === 'object' && raw['oidc'] !== null; + const journeyIsObject = + 'journey' in raw && typeof raw['journey'] === 'object' && raw['journey'] !== null; + return oidcIsObject || journeyIsObject; +} 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..b4f771d760 --- /dev/null +++ b/packages/sdk-utilities/src/lib/config/index.ts @@ -0,0 +1,9 @@ +/* + * 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';