diff --git a/.changeset/rich-cows-try.md b/.changeset/rich-cows-try.md new file mode 100644 index 0000000000..d56ee055f7 --- /dev/null +++ b/.changeset/rich-cows-try.md @@ -0,0 +1,5 @@ +--- +'@forgerock/journey-client': minor +--- + +Implement well-known endpoint support for the journey-client package. Allow developers to target the wellknown endpoint to gather configuration data from their tenant to use for future requests. diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index 851b17db84..94e4b4ed06 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -15,7 +15,7 @@ import { createClientStore, handleUpdateValidateError, RootState } from './clien import { nodeSlice } from './node.slice.js'; import { davinciApi } from './davinci.api.js'; import { configSlice } from './config.slice.js'; -import { wellknownApi } from './wellknown.api.js'; +import { wellknownApi, createWellknownError } from './wellknown.api.js'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; /** @@ -88,14 +88,14 @@ export async function davinci({ throw error; } - const { data: openIdResponse } = await store.dispatch( - wellknownApi.endpoints.wellknown.initiate(config.serverConfig.wellknown), + const { data: openIdResponse, error: fetchError } = await store.dispatch( + wellknownApi.endpoints.configuration.initiate(config.serverConfig.wellknown), ); - if (!openIdResponse) { - const error = new Error('error fetching `wellknown` response for OpenId Configuration'); - log.error(error.message); - throw error; + if (fetchError || !openIdResponse) { + const genericError = createWellknownError(fetchError); + log.error(`${genericError.error}: ${genericError.message}`); + throw new Error(genericError.message); } store.dispatch(configSlice.actions.set({ ...config, wellknownResponse: openIdResponse })); diff --git a/packages/davinci-client/src/lib/config.types.test-d.ts b/packages/davinci-client/src/lib/config.types.test-d.ts index 340f7d4634..2c65e8e4e5 100644 --- a/packages/davinci-client/src/lib/config.types.test-d.ts +++ b/packages/davinci-client/src/lib/config.types.test-d.ts @@ -6,8 +6,7 @@ */ import { describe, expectTypeOf, it } from 'vitest'; import type { DaVinciConfig, InternalDaVinciConfig } from './config.types.js'; -import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; -import type { WellknownResponse } from './wellknown.types.js'; +import type { AsyncLegacyConfigOptions, WellKnownResponse } from '@forgerock/sdk-types'; describe('Config Types', () => { describe('DaVinciConfig', () => { @@ -51,30 +50,9 @@ describe('Config Types', () => { authorization_endpoint: 'https://example.com/auth', token_endpoint: 'https://example.com/token', userinfo_endpoint: 'https://example.com/userinfo', - jwks_uri: 'https://example.com/jwks', - revocation_endpoint: 'https://example.com/register', end_session_endpoint: 'https://example.com/logout', - pushed_authorization_request_endpoint: '', - check_session_iframe: '', - introspection_endpoint: '', - device_authorization_endpoint: '', - claims_parameter_supported: '', - request_parameter_supported: '', - request_uri_parameter_supported: '', - require_pushed_authorization_requests: '', - scopes_supported: [], - response_types_supported: [], - response_modes_supported: [], - grant_types_supported: [], - subject_types_supported: [], - id_token_signing_alg_values_supported: [], - userinfo_signing_alg_values_supported: [], - request_object_signing_alg_values_supported: [], - token_endpoint_auth_methods_supported: [], - token_endpoint_auth_signing_alg_values_supported: [], - claim_types_supported: [], - claims_supported: [], - code_challenge_methods_supported: [], + introspection_endpoint: 'https://example.com/introspect', + revocation_endpoint: 'https://example.com/revoke', }, responseType: 'code', serverConfig: {}, @@ -100,30 +78,12 @@ describe('Config Types', () => { authorization_endpoint: 'https://example.com/auth', token_endpoint: 'https://example.com/token', userinfo_endpoint: 'https://example.com/userinfo', - jwks_uri: 'https://example.com/jwks', - revocation_endpoint: 'https://example.com/revoke', end_session_endpoint: 'https://example.com/logout', - pushed_authorization_request_endpoint: '', - check_session_iframe: '', - introspection_endpoint: '', - device_authorization_endpoint: '', - claims_parameter_supported: '', - request_parameter_supported: '', - request_uri_parameter_supported: '', - require_pushed_authorization_requests: '', - scopes_supported: [], - response_types_supported: [], - response_modes_supported: [], - grant_types_supported: [], - subject_types_supported: [], - id_token_signing_alg_values_supported: [], - userinfo_signing_alg_values_supported: [], - request_object_signing_alg_values_supported: [], - token_endpoint_auth_methods_supported: [], - token_endpoint_auth_signing_alg_values_supported: [], - claim_types_supported: [], - claims_supported: [], - code_challenge_methods_supported: [], + introspection_endpoint: 'https://example.com/introspect', + revocation_endpoint: 'https://example.com/revoke', + // Optional properties + jwks_uri: 'https://example.com/jwks', + scopes_supported: ['openid', 'profile'], }, }; expectTypeOf(config).toMatchTypeOf(); @@ -131,143 +91,101 @@ describe('Config Types', () => { }); }); -describe('WellknownResponse', () => { - it('should have all required OIDC properties', () => { - const wellknown: WellknownResponse = { +/** + * WellKnownResponse type tests. + * + * Note: WellKnownResponse is now imported from @forgerock/sdk-types. + * The type correctly follows the OIDC Discovery spec where only + * issuer, authorization_endpoint, token_endpoint, and userinfo_endpoint + * are required. Other properties are optional. + */ +describe('WellKnownResponse', () => { + it('should have required OIDC properties', () => { + // Minimal wellknown response with only required properties + const wellknown: WellKnownResponse = { issuer: 'https://example.com', authorization_endpoint: 'https://example.com/auth', token_endpoint: 'https://example.com/token', userinfo_endpoint: 'https://example.com/userinfo', - jwks_uri: 'https://example.com/jwks', - revocation_endpoint: 'https://example.com/revoke', end_session_endpoint: 'https://example.com/logout', - pushed_authorization_request_endpoint: '', - check_session_iframe: '', - introspection_endpoint: '', - device_authorization_endpoint: '', - claims_parameter_supported: '', - request_parameter_supported: '', - request_uri_parameter_supported: '', - require_pushed_authorization_requests: '', - scopes_supported: [], - response_types_supported: [], - response_modes_supported: [], - grant_types_supported: [], - subject_types_supported: [], - id_token_signing_alg_values_supported: [], - userinfo_signing_alg_values_supported: [], - request_object_signing_alg_values_supported: [], - token_endpoint_auth_methods_supported: [], - token_endpoint_auth_signing_alg_values_supported: [], - claim_types_supported: [], - claims_supported: [], - code_challenge_methods_supported: [], + introspection_endpoint: 'https://example.com/introspect', + revocation_endpoint: 'https://example.com/revoke', }; - expectTypeOf().toHaveProperty('issuer').toBeString(); - expectTypeOf().toHaveProperty('authorization_endpoint').toBeString(); - expectTypeOf().toHaveProperty('token_endpoint').toBeString(); - expectTypeOf().toHaveProperty('userinfo_endpoint').toBeString(); - expectTypeOf().toHaveProperty('jwks_uri').toBeString(); - expectTypeOf().toHaveProperty('revocation_endpoint').toBeString(); - expectTypeOf().toHaveProperty('end_session_endpoint').toBeString(); + // Required properties should be strings + expectTypeOf().toHaveProperty('issuer').toBeString(); + expectTypeOf().toHaveProperty('authorization_endpoint').toBeString(); + expectTypeOf().toHaveProperty('token_endpoint').toBeString(); + expectTypeOf().toHaveProperty('userinfo_endpoint').toBeString(); + expectTypeOf().toHaveProperty('end_session_endpoint').toBeString(); + expectTypeOf().toHaveProperty('introspection_endpoint').toBeString(); + expectTypeOf().toHaveProperty('revocation_endpoint').toBeString(); - expectTypeOf(wellknown).toMatchTypeOf(); + expectTypeOf(wellknown).toMatchTypeOf(); }); it('should allow optional OIDC properties', () => { - const wellknownWithOptionals: WellknownResponse = { + const wellknownWithOptionals: WellKnownResponse = { issuer: 'https://example.com', authorization_endpoint: 'https://example.com/auth', token_endpoint: 'https://example.com/token', userinfo_endpoint: 'https://example.com/userinfo', - jwks_uri: 'https://example.com/jwks', - revocation_endpoint: 'https://example.com/revoke', end_session_endpoint: 'https://example.com/logout', + introspection_endpoint: 'https://example.com/introspect', + revocation_endpoint: 'https://example.com/revoke', // Optional properties + jwks_uri: 'https://example.com/jwks', scopes_supported: ['openid', 'profile', 'email'], response_types_supported: ['code', 'token'], grant_types_supported: ['authorization_code', 'refresh_token'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_basic'], - pushed_authorization_request_endpoint: '', - check_session_iframe: '', - introspection_endpoint: '', - device_authorization_endpoint: '', - claims_parameter_supported: '', - request_parameter_supported: '', - request_uri_parameter_supported: '', - require_pushed_authorization_requests: '', - response_modes_supported: [], - userinfo_signing_alg_values_supported: [], - request_object_signing_alg_values_supported: [], - token_endpoint_auth_signing_alg_values_supported: [], - claim_types_supported: [], - claims_supported: [], - code_challenge_methods_supported: [], }; // Test optional properties are allowed but not required - expectTypeOf().toHaveProperty('scopes_supported'); - expectTypeOf().toHaveProperty('response_types_supported'); - expectTypeOf().toHaveProperty('grant_types_supported'); + expectTypeOf().toHaveProperty('scopes_supported'); + expectTypeOf().toHaveProperty('response_types_supported'); + expectTypeOf().toHaveProperty('grant_types_supported'); + expectTypeOf().toHaveProperty('jwks_uri'); - expectTypeOf(wellknownWithOptionals).toMatchTypeOf(); + expectTypeOf(wellknownWithOptionals).toMatchTypeOf(); }); - it('should validate property types', () => { - // Test that array properties must contain strings - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf< - string[] + it('should validate optional array property types', () => { + // Test that optional array properties are string[] | undefined + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + string[] | undefined >(); - expectTypeOf().toEqualTypeOf< - string[] + expectTypeOf().toEqualTypeOf< + string[] | undefined + >(); + expectTypeOf().toEqualTypeOf< + string[] | undefined + >(); + expectTypeOf().toEqualTypeOf< + string[] | undefined + >(); + expectTypeOf().toEqualTypeOf< + string[] | undefined >(); }); - it('should enforce URL format for endpoint properties', () => { - const wellknown: WellknownResponse = { + it('should enforce URL format for required endpoint properties', () => { + const wellknown: WellKnownResponse = { issuer: 'https://example.com', authorization_endpoint: 'https://example.com/auth', token_endpoint: 'https://example.com/token', userinfo_endpoint: 'https://example.com/userinfo', - jwks_uri: 'https://example.com/jwks', - revocation_endpoint: 'https://example.com/register', end_session_endpoint: 'https://example.com/logout', - pushed_authorization_request_endpoint: '', - check_session_iframe: '', - introspection_endpoint: '', - device_authorization_endpoint: '', - claims_parameter_supported: '', - request_parameter_supported: '', - request_uri_parameter_supported: '', - require_pushed_authorization_requests: '', - scopes_supported: [], - response_types_supported: [], - response_modes_supported: [], - grant_types_supported: [], - subject_types_supported: [], - id_token_signing_alg_values_supported: [], - userinfo_signing_alg_values_supported: [], - request_object_signing_alg_values_supported: [], - token_endpoint_auth_methods_supported: [], - token_endpoint_auth_signing_alg_values_supported: [], - claim_types_supported: [], - claims_supported: [], - code_challenge_methods_supported: [], + introspection_endpoint: 'https://example.com/introspect', + revocation_endpoint: 'https://example.com/revoke', }; - // Type assertion to ensure all endpoint properties are strings (URLs) + // Type assertion to ensure required endpoint properties are strings (URLs) expectTypeOf(wellknown.authorization_endpoint).toBeString(); expectTypeOf(wellknown.token_endpoint).toBeString(); expectTypeOf(wellknown.userinfo_endpoint).toBeString(); - expectTypeOf(wellknown.jwks_uri).toBeString(); - expectTypeOf(wellknown.revocation_endpoint).toBeString(); - expectTypeOf(wellknown.end_session_endpoint).toBeString(); }); }); diff --git a/packages/davinci-client/src/lib/config.types.ts b/packages/davinci-client/src/lib/config.types.ts index c443a704cf..8e129c442d 100644 --- a/packages/davinci-client/src/lib/config.types.ts +++ b/packages/davinci-client/src/lib/config.types.ts @@ -4,16 +4,13 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -/** - * Import LegacyConfigOptions type from the JavaScript SDK - */ -import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types'; -import { WellknownResponse } from './wellknown.types.js'; + +import type { AsyncLegacyConfigOptions, WellKnownResponse } from '@forgerock/sdk-types'; export interface DaVinciConfig extends AsyncLegacyConfigOptions { responseType?: string; } export interface InternalDaVinciConfig extends DaVinciConfig { - wellknownResponse: WellknownResponse; + wellknownResponse: WellKnownResponse; } diff --git a/packages/davinci-client/src/lib/wellknown.api.ts b/packages/davinci-client/src/lib/wellknown.api.ts index 6c3edd6118..0f60b91c4d 100644 --- a/packages/davinci-client/src/lib/wellknown.api.ts +++ b/packages/davinci-client/src/lib/wellknown.api.ts @@ -4,20 +4,11 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; -import { WellknownResponse } from './wellknown.types.js'; -export const wellknownApi = createApi({ - reducerPath: 'wellknown', - baseQuery: fetchBaseQuery({ - prepareHeaders: (headers) => { - headers.set('Accept', 'application/json'); - return headers; - }, - }), - endpoints: (builder) => ({ - wellknown: builder.query({ - query: (endpoint: string) => ({ url: endpoint }), - }), - }), -}); +/** + * Re-export the shared wellknown RTK Query API from @forgerock/sdk-oidc. + * + * The wellknown API provides OIDC endpoint discovery functionality via + * the `.well-known/openid-configuration` endpoint. + */ +export { wellknownApi, createWellknownSelector, createWellknownError } from '@forgerock/sdk-oidc'; diff --git a/packages/davinci-client/src/lib/wellknown.types.ts b/packages/davinci-client/src/lib/wellknown.types.ts index 0fa71de10c..b0adb1329b 100644 --- a/packages/davinci-client/src/lib/wellknown.types.ts +++ b/packages/davinci-client/src/lib/wellknown.types.ts @@ -4,37 +4,14 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -export interface WellknownResponse { - issuer: string; - authorization_endpoint: string; - pushed_authorization_request_endpoint: string; - token_endpoint: string; - userinfo_endpoint: string; - jwks_uri: string; - end_session_endpoint: string; - check_session_iframe: string; - introspection_endpoint: string; - revocation_endpoint: string; - device_authorization_endpoint: string; - claims_parameter_supported: string; - request_parameter_supported: string; - request_uri_parameter_supported: string; - require_pushed_authorization_requests: string; - scopes_supported: string[]; - response_types_supported: string[]; - response_modes_supported: string[]; - grant_types_supported: string[]; - subject_types_supported: string[]; - id_token_signing_alg_values_supported: string[]; - userinfo_signing_alg_values_supported: string[]; - request_object_signing_alg_values_supported: string[]; - token_endpoint_auth_methods_supported: string[]; - token_endpoint_auth_signing_alg_values_supported: string[]; - claim_types_supported: string[]; - claims_supported: string[]; - code_challenge_methods_supported: string[]; -} +// Re-export WellKnownResponse from shared types for convenience +export type { WellKnownResponse } from '@forgerock/sdk-types'; + +/** + * Simplified endpoint mapping extracted from well-known response. + * Used internally by DaVinci client for OAuth/OIDC operations. + */ export interface Endpoints { authorize: string; issuer: string; diff --git a/packages/journey-client/package.json b/packages/journey-client/package.json index 13f386b22a..31b7a4a415 100644 --- a/packages/journey-client/package.json +++ b/packages/journey-client/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@forgerock/sdk-logger": "workspace:*", + "@forgerock/sdk-oidc": "workspace:*", "@forgerock/sdk-request-middleware": "workspace:*", "@forgerock/sdk-types": "workspace:*", "@forgerock/sdk-utilities": "workspace:*", diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 9288cd0dc2..35c130bdf5 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -7,6 +7,7 @@ import { logger as loggerFn, LogLevel, CustomLogger } from '@forgerock/sdk-logger'; import { callbackType } from '@forgerock/sdk-types'; +import { isGenericError } from '@forgerock/sdk-utilities'; import type { RequestMiddleware } from '@forgerock/sdk-request-middleware'; import type { GenericError, Step } from '@forgerock/sdk-types'; @@ -16,9 +17,20 @@ import { journeyApi } from './journey.api.js'; import { setConfig } from './journey.slice.js'; import { createStorage } from '@forgerock/storage'; import { createJourneyObject } from './journey.utils.js'; +import { wellknownApi } from './wellknown.api.js'; +import { + hasWellknownConfig, + inferRealmFromIssuer, + isValidWellknownUrl, + createWellknownError, +} from './wellknown.utils.js'; import type { JourneyStep } from './step.utils.js'; -import type { JourneyClientConfig } from './config.types.js'; +import type { + JourneyClientConfig, + JourneyConfigInput, + InternalJourneyClientConfig, +} from './config.types.js'; import type { RedirectCallback } from './callbacks/redirect-callback.js'; import { NextOptions, StartParam, ResumeOptions } from './interfaces.js'; @@ -26,7 +38,7 @@ import { NextOptions, StartParam, ResumeOptions } from './interfaces.js'; * Normalizes the serverConfig to ensure baseUrl has a trailing slash. * This is required for the resolve() function to work correctly with context paths like /am. */ -function normalizeConfig(config: JourneyClientConfig): JourneyClientConfig { +function normalizeConfig(config: JourneyClientConfig): InternalJourneyClientConfig { if (config.serverConfig?.baseUrl) { const url = config.serverConfig.baseUrl; if (url.charAt(url.length - 1) !== '/') { @@ -42,12 +54,108 @@ function normalizeConfig(config: JourneyClientConfig): JourneyClientConfig { return config; } +/** + * Resolves an async configuration with well-known endpoint discovery. + * + * This function fetches the OIDC well-known configuration and merges it + * with the provided config, optionally inferring the realm path from the + * issuer URL if not explicitly provided. + * + * @param config - The async configuration with wellknown URL + * @param log - Logger instance for error reporting + * @returns The resolved internal configuration with well-known response + */ +async function resolveAsyncConfig( + config: JourneyConfigInput & { serverConfig: { wellknown: string } }, + log: ReturnType, +): Promise { + const { wellknown, baseUrl, paths, timeout } = config.serverConfig; + + // Validate wellknown URL + if (!isValidWellknownUrl(wellknown)) { + const error = new Error( + `Invalid wellknown URL: ${wellknown}. URL must use HTTPS (or HTTP for localhost).`, + ); + log.error(error.message); + throw error; + } + + // Create a temporary store to fetch well-known (we need the RTK Query infrastructure) + const tempConfig: InternalJourneyClientConfig = { + serverConfig: { baseUrl: baseUrl || '', paths, timeout }, + realmPath: config.realmPath, + }; + const tempStore = createJourneyStore({ config: tempConfig, logger: log }); + + // Fetch the well-known configuration + const { data: wellknownResponse, error: fetchError } = await tempStore.dispatch( + wellknownApi.endpoints.configuration.initiate(wellknown), + ); + + if (fetchError || !wellknownResponse) { + const genericError = createWellknownError(fetchError); + log.error(`${genericError.error}: ${genericError.message}`); + throw new Error(genericError.message); + } + + // Optionally infer realmPath from the issuer URL if not provided + const inferredRealm = config.realmPath ?? inferRealmFromIssuer(wellknownResponse.issuer); + + // Build the resolved internal configuration + const resolvedConfig: InternalJourneyClientConfig = { + serverConfig: { + baseUrl, + paths, + timeout, + }, + realmPath: inferredRealm, + middleware: config.middleware, + wellknownResponse, + }; + + return normalizeConfig(resolvedConfig); +} + +/** + * Creates a journey client for AM authentication tree/journey interactions. + * + * Supports two configuration modes: + * + * 1. **Standard configuration** - Provide `serverConfig.baseUrl` directly: + * ```typescript + * const client = await journey({ + * config: { + * serverConfig: { baseUrl: 'https://am.example.com/am/' }, + * realmPath: 'alpha', + * }, + * }); + * ``` + * + * 2. **Well-known discovery** - Provide `serverConfig.wellknown` for OIDC endpoint discovery: + * ```typescript + * const client = await journey({ + * config: { + * serverConfig: { + * baseUrl: 'https://am.example.com/am/', + * wellknown: 'https://am.example.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration', + * }, + * // realmPath is optional - can be inferred from the well-known issuer + * }, + * }); + * ``` + * + * @param options - Configuration options for the journey client + * @param options.config - Server configuration (standard or with well-known) + * @param options.requestMiddleware - Optional middleware for request customization + * @param options.logger - Optional logger configuration + * @returns A journey client instance with start, next, redirect, resume, and terminate methods + */ export async function journey({ config, requestMiddleware, logger, }: { - config: JourneyClientConfig; + config: JourneyConfigInput; requestMiddleware?: RequestMiddleware[]; logger?: { level: LogLevel; @@ -56,11 +164,19 @@ export async function journey({ }) { const log = loggerFn({ level: logger?.level || 'error', custom: logger?.custom }); - // Normalize config to ensure baseUrl has trailing slash - const normalizedConfig = normalizeConfig(config); + // Resolve configuration based on whether wellknown is provided + let resolvedConfig: InternalJourneyClientConfig; - const store = createJourneyStore({ requestMiddleware, logger: log, config: normalizedConfig }); - store.dispatch(setConfig(normalizedConfig)); + if (hasWellknownConfig(config)) { + // Async config with well-known discovery + resolvedConfig = await resolveAsyncConfig(config, log); + } else { + // Standard config - just normalize it + resolvedConfig = normalizeConfig(config); + } + + const store = createJourneyStore({ requestMiddleware, logger: log, config: resolvedConfig }); + store.dispatch(setConfig(resolvedConfig)); const stepStorage = createStorage<{ step: Step }>({ type: 'sessionStorage', @@ -121,11 +237,6 @@ export async function journey({ return (code && state) || form_post_entry || responsekey; } - // Type guard for GenericError (assuming GenericError has 'error' and 'message' properties) - function isGenericError(obj: unknown): obj is GenericError { - return typeof obj === 'object' && obj !== null && 'error' in obj && 'message' in obj; - } - // Type guard for { step: JourneyStep } function isStoredStep(obj: unknown): obj is { step: Step } { return ( diff --git a/packages/journey-client/src/lib/client.store.utils.ts b/packages/journey-client/src/lib/client.store.utils.ts index 27e6200cb0..5055bb3db8 100644 --- a/packages/journey-client/src/lib/client.store.utils.ts +++ b/packages/journey-client/src/lib/client.store.utils.ts @@ -11,11 +11,13 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { journeyApi } from './journey.api.js'; import { journeySlice } from './journey.slice.js'; -import { JourneyClientConfig } from './config.types.js'; +import { wellknownApi } from './wellknown.api.js'; +import { InternalJourneyClientConfig } from './config.types.js'; const rootReducer = combineReducers({ [journeyApi.reducerPath]: journeyApi.reducer, [journeySlice.name]: journeySlice.reducer, + [wellknownApi.reducerPath]: wellknownApi.reducer, }); export const createJourneyStore = ({ @@ -25,7 +27,7 @@ export const createJourneyStore = ({ }: { requestMiddleware?: RequestMiddleware[]; logger?: ReturnType; - config: JourneyClientConfig; + config: InternalJourneyClientConfig; }) => { return configureStore({ reducer: rootReducer, @@ -39,7 +41,9 @@ export const createJourneyStore = ({ config, }, }, - }).concat(journeyApi.middleware), + }) + .concat(journeyApi.middleware) + .concat(wellknownApi.middleware), }); }; diff --git a/packages/journey-client/src/lib/config.types.ts b/packages/journey-client/src/lib/config.types.ts index f172c2aa98..ce1d29b613 100644 --- a/packages/journey-client/src/lib/config.types.ts +++ b/packages/journey-client/src/lib/config.types.ts @@ -5,13 +5,92 @@ * of the MIT license. See the LICENSE file for details. */ -import type { BaseConfig } from '@forgerock/sdk-types'; +import type { BaseConfig, WellKnownResponse, PathsConfig } from '@forgerock/sdk-types'; import type { RequestMiddleware } from '@forgerock/sdk-request-middleware'; +/** + * Standard journey client configuration with explicit baseUrl. + * + * Use this when you want to configure the AM server directly without + * OIDC well-known endpoint discovery. + * + * @example + * ```typescript + * const config: JourneyClientConfig = { + * serverConfig: { + * baseUrl: 'https://am.example.com/am/', + * }, + * realmPath: 'alpha', + * }; + * ``` + */ export interface JourneyClientConfig extends BaseConfig { middleware?: Array; realmPath?: string; - // Add any journey-specific config options here } +/** + * Server configuration that includes well-known OIDC endpoint discovery. + * + * When wellknown is provided, the client will fetch the OIDC discovery + * document to obtain endpoints like authorization, token, userinfo, etc. + * + * Note: baseUrl is still required for AM-specific endpoints (authenticate, + * sessions) which are not part of the standard OIDC well-known response. + */ +export interface WellknownServerConfig { + /** Base URL for AM-specific endpoints (authenticate, sessions) */ + baseUrl: string; + /** URL to the OIDC well-known configuration endpoint */ + wellknown: string; + /** Custom path overrides for endpoints */ + paths?: PathsConfig['paths']; + /** Request timeout in milliseconds */ + timeout?: number; +} + +/** + * Journey client configuration with OIDC well-known endpoint discovery. + * + * This configuration fetches the OIDC discovery document to obtain + * standard OIDC endpoints while still using baseUrl for AM-specific + * journey endpoints. + * + * @example + * ```typescript + * const config: AsyncJourneyClientConfig = { + * serverConfig: { + * baseUrl: 'https://am.example.com/am/', + * wellknown: 'https://am.example.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration', + * }, + * // realmPath is optional - can be inferred from the well-known issuer + * }; + * ``` + */ +export interface AsyncJourneyClientConfig { + serverConfig: WellknownServerConfig; + middleware?: Array; + /** Optional realm path - can be inferred from well-known issuer if not provided */ + realmPath?: string; +} + +/** + * Internal configuration type that includes the resolved well-known response. + * + * This type is used internally after the well-known endpoint has been fetched + * and the configuration has been normalized. + */ +export interface InternalJourneyClientConfig extends JourneyClientConfig { + /** The fetched OIDC well-known response, if wellknown discovery was used */ + wellknownResponse?: WellKnownResponse; +} + +/** + * Union type for journey client initialization. + * + * Accepts either a standard configuration with baseUrl only, + * or an async configuration with well-known endpoint discovery. + */ +export type JourneyConfigInput = JourneyClientConfig | AsyncJourneyClientConfig; + export type { RequestMiddleware }; diff --git a/packages/journey-client/src/lib/journey.slice.ts b/packages/journey-client/src/lib/journey.slice.ts index aea31ea438..8cf2c5bbc7 100644 --- a/packages/journey-client/src/lib/journey.slice.ts +++ b/packages/journey-client/src/lib/journey.slice.ts @@ -9,13 +9,22 @@ import { createSlice, PayloadAction, Slice } from '@reduxjs/toolkit'; import type { Step } from '@forgerock/sdk-types'; -import type { JourneyClientConfig } from './config.types.js'; +import type { InternalJourneyClientConfig } from './config.types.js'; +/** + * Redux state for the journey client. + * + * Contains the current authentication state including: + * - authId: The authentication session identifier + * - step: The current authentication step + * - error: Any error that occurred during authentication + * - config: The resolved client configuration (including well-known response if used) + */ export interface JourneyState { authId?: string; step?: Step; error?: Error; - config?: JourneyClientConfig; + config?: InternalJourneyClientConfig; } const initialState: JourneyState = {}; @@ -24,7 +33,7 @@ export const journeySlice: Slice = createSlice({ name: 'journey', initialState, reducers: { - setConfig: (state, action: PayloadAction) => { + setConfig: (state, action: PayloadAction) => { state.config = action.payload; }, }, diff --git a/packages/journey-client/src/lib/wellknown.api.ts b/packages/journey-client/src/lib/wellknown.api.ts new file mode 100644 index 0000000000..067d975a7a --- /dev/null +++ b/packages/journey-client/src/lib/wellknown.api.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 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. + */ + +/** + * Re-export the shared wellknown RTK Query API from @forgerock/sdk-oidc. + * + * The wellknown API provides OIDC endpoint discovery functionality via + * the `.well-known/openid-configuration` endpoint. + */ +export { wellknownApi, createWellknownSelector } from '@forgerock/sdk-oidc'; diff --git a/packages/journey-client/src/lib/wellknown.utils.test.ts b/packages/journey-client/src/lib/wellknown.utils.test.ts new file mode 100644 index 0000000000..85d7517055 --- /dev/null +++ b/packages/journey-client/src/lib/wellknown.utils.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 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 { hasWellknownConfig } from './wellknown.utils.js'; +import type { + JourneyConfigInput, + AsyncJourneyClientConfig, + JourneyClientConfig, +} from './config.types.js'; + +/** + * Tests for journey-client specific wellknown utilities. + * + * Note: Tests for createWellknownError, inferRealmFromIssuer, and isValidWellknownUrl + * are in @forgerock/sdk-oidc and @forgerock/sdk-utilities respectively, + * as those are shared utilities. + */ +describe('wellknown.utils', () => { + describe('hasWellknownConfig', () => { + describe('hasWellknownConfig_ConfigWithWellknown_ReturnsTrue', () => { + it('should return true when wellknown is present and non-empty', () => { + // Arrange + const config: AsyncJourneyClientConfig = { + serverConfig: { + baseUrl: 'https://am.example.com/am/', + wellknown: + 'https://am.example.com/am/oauth2/realms/root/.well-known/openid-configuration', + }, + }; + + // Act + const result = hasWellknownConfig(config); + + // Assert + expect(result).toBe(true); + }); + }); + + describe('hasWellknownConfig_ConfigWithoutWellknown_ReturnsFalse', () => { + it('should return false when wellknown is not present', () => { + // Arrange + const config: JourneyClientConfig = { + serverConfig: { + baseUrl: 'https://am.example.com/am/', + }, + }; + + // Act + const result = hasWellknownConfig(config); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('hasWellknownConfig_EmptyWellknown_ReturnsFalse', () => { + it('should return false when wellknown is an empty string', () => { + // Arrange + const config: JourneyConfigInput = { + serverConfig: { + baseUrl: 'https://am.example.com/am/', + wellknown: '', + }, + } as AsyncJourneyClientConfig; + + // Act + const result = hasWellknownConfig(config); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('hasWellknownConfig_NoServerConfig_ReturnsFalse', () => { + it('should return false when serverConfig is undefined', () => { + // Arrange + const config: JourneyConfigInput = {} as JourneyClientConfig; + + // Act + const result = hasWellknownConfig(config); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('hasWellknownConfig_TypeNarrowing_AllowsAccessToWellknown', () => { + it('should allow TypeScript to access wellknown after type guard', () => { + // Arrange + const config: JourneyConfigInput = { + serverConfig: { + baseUrl: 'https://am.example.com/am/', + wellknown: 'https://am.example.com/.well-known/openid-configuration', + }, + } as AsyncJourneyClientConfig; + + // Act & Assert + if (hasWellknownConfig(config)) { + // TypeScript should allow this access after the type guard + expect(config.serverConfig.wellknown).toBe( + 'https://am.example.com/.well-known/openid-configuration', + ); + } else { + // This should not be reached + expect.fail('Type guard should have returned true'); + } + }); + }); + }); +}); diff --git a/packages/journey-client/src/lib/wellknown.utils.ts b/packages/journey-client/src/lib/wellknown.utils.ts new file mode 100644 index 0000000000..4cff21914a --- /dev/null +++ b/packages/journey-client/src/lib/wellknown.utils.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 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. + */ + +// Re-export shared wellknown utilities from sdk-oidc +export { + createWellknownError, + inferRealmFromIssuer, + isValidWellknownUrl, +} from '@forgerock/sdk-oidc'; + +import type { AsyncJourneyClientConfig, JourneyConfigInput } from './config.types.js'; + +/** + * Type guard to determine if the configuration includes well-known endpoint discovery. + * + * @param config - The journey client configuration (union of sync and async configs) + * @returns True if the config has a wellknown property in serverConfig + * + * @example + * ```typescript + * const config: JourneyConfigInput = { + * serverConfig: { + * baseUrl: 'https://am.example.com/am/', + * wellknown: 'https://am.example.com/am/oauth2/realms/root/realms/alpha/.well-known/openid-configuration' + * } + * }; + * + * if (hasWellknownConfig(config)) { + * // TypeScript now knows config is AsyncJourneyClientConfig + * const wellknownUrl = config.serverConfig.wellknown; + * } + * ``` + */ +export function hasWellknownConfig(config: JourneyConfigInput): config is AsyncJourneyClientConfig { + return ( + 'serverConfig' in config && + typeof config.serverConfig === 'object' && + config.serverConfig !== null && + 'wellknown' in config.serverConfig && + typeof config.serverConfig.wellknown === 'string' && + config.serverConfig.wellknown.length > 0 + ); +} diff --git a/packages/journey-client/src/types.ts b/packages/journey-client/src/types.ts index ed7cade5fd..6b9ac0e08c 100644 --- a/packages/journey-client/src/types.ts +++ b/packages/journey-client/src/types.ts @@ -24,6 +24,14 @@ export * from './lib/config.types.js'; export * from './lib/interfaces.js'; export * from './lib/step.types.js'; +// Re-export well-known utilities for consumers who need realm inference or type guards +export { + hasWellknownConfig, + inferRealmFromIssuer, + isValidWellknownUrl, + createWellknownError, +} from './lib/wellknown.utils.js'; + export * from './lib/callbacks/attribute-input-callback.js'; export * from './lib/callbacks/base-callback.js'; export * from './lib/callbacks/choice-callback.js'; diff --git a/packages/journey-client/tsconfig.json b/packages/journey-client/tsconfig.json index 341e0b5e7f..967b915e82 100644 --- a/packages/journey-client/tsconfig.json +++ b/packages/journey-client/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../sdk-effects/sdk-request-middleware" }, + { + "path": "../sdk-effects/oidc" + }, { "path": "../sdk-effects/logger" }, diff --git a/packages/journey-client/tsconfig.lib.json b/packages/journey-client/tsconfig.lib.json index 280db2ff6c..ca3f899b8d 100644 --- a/packages/journey-client/tsconfig.lib.json +++ b/packages/journey-client/tsconfig.lib.json @@ -21,15 +21,18 @@ { "path": "../sdk-effects/storage/tsconfig.lib.json" }, - { - "path": "../sdk-effects/sdk-request-middleware/tsconfig.lib.json" - }, { "path": "../sdk-utilities/tsconfig.lib.json" }, { "path": "../sdk-types/tsconfig.lib.json" }, + { + "path": "../sdk-effects/sdk-request-middleware/tsconfig.lib.json" + }, + { + "path": "../sdk-effects/oidc/tsconfig.lib.json" + }, { "path": "../sdk-effects/logger/tsconfig.lib.json" } diff --git a/packages/oidc-client/src/lib/wellknown.api.ts b/packages/oidc-client/src/lib/wellknown.api.ts index 3315ef345c..91373eb4f8 100644 --- a/packages/oidc-client/src/lib/wellknown.api.ts +++ b/packages/oidc-client/src/lib/wellknown.api.ts @@ -4,22 +4,32 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ + import { createSelector } from '@reduxjs/toolkit'; -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; -import type { WellKnownResponse } from '@forgerock/sdk-types'; import type { RootState } from './client.types.js'; -export const wellknownApi = createApi({ - reducerPath: 'wellknown', - baseQuery: fetchBaseQuery(), - endpoints: (builder) => ({ - configuration: builder.query({ - query: (endpoint) => `${endpoint}`, - }), - }), -}); +/** + * Re-export the shared wellknown RTK Query API from @forgerock/sdk-oidc. + * + * The wellknown API provides OIDC endpoint discovery functionality via + * the `.well-known/openid-configuration` endpoint. + */ +export { wellknownApi, createWellknownSelector } from '@forgerock/sdk-oidc'; + +// Import locally for use in selector below +import { wellknownApi } from '@forgerock/sdk-oidc'; +/** + * Selector to retrieve the cached well-known response from Redux state. + * + * This is a convenience function that wraps the shared createWellknownSelector + * for easier use with oidc-client's RootState type. + * + * @param wellknownUrl - The well-known endpoint URL used as the cache key + * @param state - The Redux root state + * @returns The cached WellKnownResponse or undefined if not yet fetched + */ export function wellknownSelector(wellknownUrl: string, state: RootState) { const selector = createSelector( wellknownApi.endpoints.configuration.select(wellknownUrl), diff --git a/packages/sdk-effects/oidc/package.json b/packages/sdk-effects/oidc/package.json index 57b74cfe27..021c0c34df 100644 --- a/packages/sdk-effects/oidc/package.json +++ b/packages/sdk-effects/oidc/package.json @@ -27,7 +27,8 @@ }, "dependencies": { "@forgerock/sdk-types": "workspace:*", - "@forgerock/sdk-utilities": "workspace:*" + "@forgerock/sdk-utilities": "workspace:*", + "@reduxjs/toolkit": "^2.8.0" }, "nx": { "tags": ["scope:sdk-effects"] diff --git a/packages/sdk-effects/oidc/src/index.ts b/packages/sdk-effects/oidc/src/index.ts index ca093486d6..e03cb69859 100644 --- a/packages/sdk-effects/oidc/src/index.ts +++ b/packages/sdk-effects/oidc/src/index.ts @@ -6,3 +6,5 @@ */ export * from './lib/authorize.effects.js'; export * from './lib/state-pkce.effects.js'; +export * from './lib/wellknown.api.js'; +export * from './lib/wellknown.utils.js'; diff --git a/packages/sdk-effects/oidc/src/lib/wellknown.api.ts b/packages/sdk-effects/oidc/src/lib/wellknown.api.ts new file mode 100644 index 0000000000..edcede0356 --- /dev/null +++ b/packages/sdk-effects/oidc/src/lib/wellknown.api.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 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 { createSelector } from '@reduxjs/toolkit'; +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'; + +import type { WellKnownResponse } from '@forgerock/sdk-types'; + +/** + * RTK Query API for fetching the OIDC well-known configuration endpoint. + * + * The well-known endpoint (`.well-known/openid-configuration`) provides + * OIDC Discovery information including: + * - `authorization_endpoint` - URL for authorization requests + * - `token_endpoint` - URL for token exchange + * - `userinfo_endpoint` - URL for user info retrieval + * - `end_session_endpoint` - URL for logout/session termination + * - `revocation_endpoint` - URL for token revocation + * - `jwks_uri` - URL for JSON Web Key Set + * - `issuer` - The OIDC issuer identifier + * + * @example + * ```typescript + * // Add to your Redux store + * const store = configureStore({ + * reducer: { + * [wellknownApi.reducerPath]: wellknownApi.reducer, + * }, + * middleware: (getDefault) => getDefault().concat(wellknownApi.middleware), + * }); + * + * // Fetch the configuration + * const { data, error } = await store.dispatch( + * wellknownApi.endpoints.configuration.initiate(wellknownUrl) + * ); + * ``` + */ +export const wellknownApi = createApi({ + reducerPath: 'wellknown', + baseQuery: fetchBaseQuery({ + prepareHeaders: (headers) => { + headers.set('Accept', 'application/json'); + return headers; + }, + }), + endpoints: (builder) => ({ + configuration: builder.query({ + query: (endpoint) => endpoint, + }), + }), +}); + +/** + * Creates a selector to retrieve the cached well-known response from Redux state. + * + * This is a factory function that works with any Redux store structure, + * as long as the wellknownApi reducer is mounted at the 'wellknown' path. + * + * @param wellknownUrl - The well-known endpoint URL used as the cache key + * @returns A selector function that extracts the WellKnownResponse from state + * + * @example + * ```typescript + * const selector = createWellknownSelector(wellknownUrl); + * const wellknownResponse = selector(store.getState()); + * + * if (wellknownResponse?.authorization_endpoint) { + * // Use the authorization endpoint + * } + * ``` + */ +export function createWellknownSelector(wellknownUrl: string) { + return createSelector( + wellknownApi.endpoints.configuration.select(wellknownUrl), + (result) => result?.data, + ); +} diff --git a/packages/sdk-effects/oidc/src/lib/wellknown.utils.test.ts b/packages/sdk-effects/oidc/src/lib/wellknown.utils.test.ts new file mode 100644 index 0000000000..9dea2675a4 --- /dev/null +++ b/packages/sdk-effects/oidc/src/lib/wellknown.utils.test.ts @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2025 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 { createWellknownError } from './wellknown.utils.js'; + +describe('wellknown.utils', () => { + describe('createWellknownError', () => { + describe('createWellknownError_UndefinedError_ReturnsDefaultMessage', () => { + it('should return default error when no error provided', () => { + // Arrange & Act + const result = createWellknownError(undefined); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('No response received from well-known endpoint'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe('unknown'); + }); + }); + + describe('createWellknownError_FetchErrorWithString_ReturnsErrorString', () => { + it('should extract error string from FETCH_ERROR type', () => { + // Arrange + const fetchError = { + status: 'FETCH_ERROR', + error: 'Network request failed', + } as const; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('Network request failed'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe('FETCH_ERROR'); + }); + }); + + describe('createWellknownError_HttpErrorWithDataMessage_ReturnsDataMessage', () => { + it('should extract message from HTTP error response data', () => { + // Arrange + const fetchError = { + status: 404, + data: { message: 'Endpoint not found' }, + }; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('Endpoint not found'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe(404); + }); + }); + + describe('createWellknownError_HttpErrorWithDataError_ReturnsDataError', () => { + it('should extract error field from HTTP error response data', () => { + // Arrange + const fetchError = { + status: 401, + data: { error: 'unauthorized' }, + }; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('unauthorized'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe(401); + }); + }); + + describe('createWellknownError_HttpErrorWithErrorDescription_ReturnsDescription', () => { + it('should extract error_description from OAuth-style error response', () => { + // Arrange + const fetchError = { + status: 400, + data: { error_description: 'Invalid client credentials' }, + }; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('Invalid client credentials'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe(400); + }); + }); + + describe('createWellknownError_HttpErrorWithUnknownData_ReturnsStringifiedData', () => { + it('should stringify unknown data structure', () => { + // Arrange + const fetchError = { + status: 500, + data: { code: 'INTERNAL_ERROR', details: ['Something went wrong'] }, + }; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('{"code":"INTERNAL_ERROR","details":["Something went wrong"]}'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe(500); + }); + }); + + describe('createWellknownError_HttpErrorWithoutData_ReturnsHttpStatus', () => { + it('should return HTTP status message when no data', () => { + // Arrange + // FetchBaseQueryError with numeric status requires data property + const fetchError = { + status: 503, + data: undefined, + }; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('HTTP error 503'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe(503); + }); + }); + + describe('createWellknownError_SerializedErrorWithMessage_ReturnsMessage', () => { + it('should extract message from SerializedError', () => { + // Arrange + const serializedError = { + name: 'TypeError', + message: 'Cannot read property of undefined', + }; + + // Act + const result = createWellknownError(serializedError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('Cannot read property of undefined'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe('unknown'); + }); + }); + + describe('createWellknownError_SerializedErrorWithoutMessage_ReturnsDefault', () => { + it('should return default message when SerializedError has no message', () => { + // Arrange + const serializedError = { + name: 'Error', + }; + + // Act + const result = createWellknownError(serializedError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('An unknown error occurred'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe('unknown'); + }); + }); + + describe('createWellknownError_TimeoutError_ReturnsTimeoutMessage', () => { + it('should handle TIMEOUT_ERROR from RTK Query', () => { + // Arrange + const fetchError = { + status: 'TIMEOUT_ERROR', + error: 'Request timed out', + } as const; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('Request timed out'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe('TIMEOUT_ERROR'); + }); + }); + + describe('createWellknownError_ParsingError_ReturnsParsingMessage', () => { + it('should handle PARSING_ERROR from RTK Query', () => { + // Arrange + const fetchError = { + status: 'PARSING_ERROR', + originalStatus: 200, + error: 'Unexpected token < in JSON at position 0', + data: '', + } as const; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('Unexpected token < in JSON at position 0'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe('PARSING_ERROR'); + }); + }); + + describe('createWellknownError_CustomError_ReturnsCustomMessage', () => { + it('should handle CUSTOM_ERROR from RTK Query', () => { + // Arrange + const fetchError = { + status: 'CUSTOM_ERROR', + error: 'Custom validation failed', + data: { custom: 'data' }, + } as const; + + // Act + const result = createWellknownError(fetchError); + + // Assert + expect(result.error).toBe('Well-known configuration fetch failed'); + expect(result.message).toBe('Custom validation failed'); + expect(result.type).toBe('wellknown_error'); + expect(result.status).toBe('CUSTOM_ERROR'); + }); + }); + }); +}); diff --git a/packages/sdk-effects/oidc/src/lib/wellknown.utils.ts b/packages/sdk-effects/oidc/src/lib/wellknown.utils.ts new file mode 100644 index 0000000000..0dcd444476 --- /dev/null +++ b/packages/sdk-effects/oidc/src/lib/wellknown.utils.ts @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 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 { SerializedError } from '@reduxjs/toolkit'; +import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import type { GenericError } from '@forgerock/sdk-types'; + +// Re-export shared pure utilities from sdk-utilities +export { inferRealmFromIssuer, isValidWellknownUrl } from '@forgerock/sdk-utilities'; + +/** + * Type guard that checks if a value is a non-null object. + * Used for safe property access without `as` casting. + * + * @param value - The value to check + * @returns True if value is a non-null object + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Extracts a human-readable error message from an RTK Query error. + * + * RTK Query can return two types of errors: + * - `FetchBaseQueryError`: Has `status` and either `error` (string) or `data` (parsed response) + * - `SerializedError`: Has `message`, `name`, `code`, `stack` (from JS errors) + * + * @param error - The error from RTK Query dispatch result + * @returns A human-readable error message + * + * @see https://redux-toolkit.js.org/rtk-query/usage-with-typescript#type-safe-error-handling + */ +function extractErrorMessage(error: FetchBaseQueryError | SerializedError): string { + // FetchBaseQueryError has 'status' property + if ('status' in error) { + // 'error' is present for FETCH_ERROR, PARSING_ERROR, TIMEOUT_ERROR, CUSTOM_ERROR + if ('error' in error) { + return error.error; + } + // 'data' contains the parsed response body for HTTP errors + if ('data' in error && isObject(error.data)) { + const data = error.data; + // Try to extract message from common error response formats + if (typeof data.message === 'string') { + return data.message; + } + if (typeof data.error === 'string') { + return data.error; + } + if (typeof data.error_description === 'string') { + return data.error_description; + } + // Fallback to stringifying the data + return JSON.stringify(data); + } + return `HTTP error ${error.status}`; + } + + // SerializedError has 'message' property + return error.message ?? 'An unknown error occurred'; +} + +/** + * Extracts the HTTP status code from an RTK Query error, if available. + * + * @param error - The error from RTK Query dispatch result + * @returns The HTTP status code, or 'unknown' if not available + */ +function extractErrorStatus(error: FetchBaseQueryError | SerializedError): number | string { + if ('status' in error) { + return error.status; + } + return 'unknown'; +} + +/** + * Creates a GenericError from an RTK Query error for well-known fetch failures. + * + * This follows the established pattern in the codebase for converting RTK Query + * errors to the SDK's GenericError type with `type: 'wellknown_error'`. + * + * @param error - The error from RTK Query dispatch result, or undefined if no response + * @returns A GenericError with type 'wellknown_error' + * + * @example + * ```typescript + * const { data, error } = await store.dispatch( + * wellknownApi.endpoints.configuration.initiate(url) + * ); + * + * if (error || !data) { + * const genericError = createWellknownError(error); + * log.error(genericError.message); + * throw new Error(genericError.message); + * } + * ``` + */ +export function createWellknownError(error?: FetchBaseQueryError | SerializedError): GenericError { + if (error) { + return { + error: 'Well-known configuration fetch failed', + message: extractErrorMessage(error), + type: 'wellknown_error', + status: extractErrorStatus(error), + }; + } + + return { + error: 'Well-known configuration fetch failed', + message: 'No response received from well-known endpoint', + type: 'wellknown_error', + status: 'unknown', + }; +} diff --git a/packages/sdk-utilities/src/index.ts b/packages/sdk-utilities/src/index.ts index d2ef0ad097..f01a5f77b8 100644 --- a/packages/sdk-utilities/src/index.ts +++ b/packages/sdk-utilities/src/index.ts @@ -11,5 +11,6 @@ export * from './lib/error/index.js'; export * from './lib/oidc/index.js'; export * from './lib/strings/index.js'; export * from './lib/url/index.js'; +export * from './lib/wellknown/index.js'; export * from './lib/object.utils.js'; export * from './lib/constants/index.js'; diff --git a/packages/sdk-utilities/src/lib/wellknown/index.ts b/packages/sdk-utilities/src/lib/wellknown/index.ts new file mode 100644 index 0000000000..af828cdf18 --- /dev/null +++ b/packages/sdk-utilities/src/lib/wellknown/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2025 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 './wellknown.utils.js'; diff --git a/packages/sdk-utilities/src/lib/wellknown/wellknown.utils.test.ts b/packages/sdk-utilities/src/lib/wellknown/wellknown.utils.test.ts new file mode 100644 index 0000000000..d688dd9715 --- /dev/null +++ b/packages/sdk-utilities/src/lib/wellknown/wellknown.utils.test.ts @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2025 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 { inferRealmFromIssuer, isValidWellknownUrl } from './wellknown.utils.js'; + +describe('wellknown.utils', () => { + describe('inferRealmFromIssuer', () => { + describe('inferRealmFromIssuer_SubrealmIssuer_ReturnsSubrealm', () => { + it('should extract subrealm from standard AM issuer URL', () => { + // Arrange + const issuer = 'https://am.example.com/am/oauth2/realms/root/realms/alpha'; + + // Act + const result = inferRealmFromIssuer(issuer); + + // Assert + expect(result).toBe('alpha'); + }); + }); + + describe('inferRealmFromIssuer_NestedSubrealm_ReturnsFullPath', () => { + it('should extract nested subrealm path', () => { + // Arrange + const issuer = + 'https://am.example.com/am/oauth2/realms/root/realms/customers/realms/premium'; + + // Act + const result = inferRealmFromIssuer(issuer); + + // Assert + expect(result).toBe('customers/realms/premium'); + }); + }); + + describe('inferRealmFromIssuer_RootRealmOnly_ReturnsRoot', () => { + it('should return "root" for root realm issuer', () => { + // Arrange + const issuer = 'https://am.example.com/am/oauth2/realms/root'; + + // Act + const result = inferRealmFromIssuer(issuer); + + // Assert + expect(result).toBe('root'); + }); + }); + + describe('inferRealmFromIssuer_NonAmIssuer_ReturnsUndefined', () => { + it('should return undefined for non-AM issuer (PingOne)', () => { + // Arrange + const issuer = 'https://auth.pingone.com/env-id/as'; + + // Act + const result = inferRealmFromIssuer(issuer); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe('inferRealmFromIssuer_GenericOidcIssuer_ReturnsUndefined', () => { + it('should return undefined for generic OIDC issuer', () => { + // Arrange + const issuer = 'https://accounts.google.com'; + + // Act + const result = inferRealmFromIssuer(issuer); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe('inferRealmFromIssuer_InvalidUrl_ReturnsUndefined', () => { + it('should return undefined for invalid URL', () => { + // Arrange + const issuer = 'not-a-valid-url'; + + // Act + const result = inferRealmFromIssuer(issuer); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe('inferRealmFromIssuer_IssuerWithPort_ReturnsRealm', () => { + it('should correctly parse issuer with port number', () => { + // Arrange + const issuer = 'https://am.example.com:8443/am/oauth2/realms/root/realms/test'; + + // Act + const result = inferRealmFromIssuer(issuer); + + // Assert + expect(result).toBe('test'); + }); + }); + + describe('inferRealmFromIssuer_IssuerWithQueryParams_ReturnsRealm', () => { + it('should correctly parse issuer even with query parameters (edge case)', () => { + // Arrange - Note: well-formed issuer URLs shouldn't have query params, + // but we should handle this gracefully + const issuer = 'https://am.example.com/am/oauth2/realms/root/realms/alpha?extra=param'; + + // Act + const result = inferRealmFromIssuer(issuer); + + // Assert + // The regex matches the pathname, so query params don't interfere + expect(result).toBe('alpha'); + }); + }); + }); + + describe('isValidWellknownUrl', () => { + describe('isValidWellknownUrl_HttpsUrl_ReturnsTrue', () => { + it('should return true for HTTPS URL', () => { + // Arrange + const url = 'https://am.example.com/.well-known/openid-configuration'; + + // Act + const result = isValidWellknownUrl(url); + + // Assert + expect(result).toBe(true); + }); + }); + + describe('isValidWellknownUrl_HttpLocalhost_ReturnsTrue', () => { + it('should return true for HTTP localhost', () => { + // Arrange + const url = 'http://localhost:8080/.well-known/openid-configuration'; + + // Act + const result = isValidWellknownUrl(url); + + // Assert + expect(result).toBe(true); + }); + }); + + describe('isValidWellknownUrl_Http127001_ReturnsTrue', () => { + it('should return true for HTTP 127.0.0.1', () => { + // Arrange + const url = 'http://127.0.0.1:8080/.well-known/openid-configuration'; + + // Act + const result = isValidWellknownUrl(url); + + // Assert + expect(result).toBe(true); + }); + }); + + describe('isValidWellknownUrl_HttpNonLocalhost_ReturnsFalse', () => { + it('should return false for HTTP non-localhost URL', () => { + // Arrange + const url = 'http://am.example.com/.well-known/openid-configuration'; + + // Act + const result = isValidWellknownUrl(url); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('isValidWellknownUrl_InvalidUrl_ReturnsFalse', () => { + it('should return false for invalid URL', () => { + // Arrange + const url = 'not-a-valid-url'; + + // Act + const result = isValidWellknownUrl(url); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('isValidWellknownUrl_EmptyString_ReturnsFalse', () => { + it('should return false for empty string', () => { + // Arrange + const url = ''; + + // Act + const result = isValidWellknownUrl(url); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('isValidWellknownUrl_FtpProtocol_ReturnsFalse', () => { + it('should return false for non-HTTP protocols', () => { + // Arrange + const url = 'ftp://am.example.com/.well-known/openid-configuration'; + + // Act + const result = isValidWellknownUrl(url); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('isValidWellknownUrl_HttpsLocalhost_ReturnsTrue', () => { + it('should return true for HTTPS localhost', () => { + // Arrange + const url = 'https://localhost:8443/.well-known/openid-configuration'; + + // Act + const result = isValidWellknownUrl(url); + + // Assert + expect(result).toBe(true); + }); + }); + }); +}); diff --git a/packages/sdk-utilities/src/lib/wellknown/wellknown.utils.ts b/packages/sdk-utilities/src/lib/wellknown/wellknown.utils.ts new file mode 100644 index 0000000000..912245840c --- /dev/null +++ b/packages/sdk-utilities/src/lib/wellknown/wellknown.utils.ts @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 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. + */ + +/** + * Attempts to infer the realm path from an OIDC issuer URL. + * + * AM issuer URLs follow the pattern: + * `https://{host}/am/oauth2/realms/root/realms/{subrealm}` + * + * This function extracts the realm path after `/realms/root/realms/`. + * If the issuer doesn't match the expected pattern, returns undefined. + * + * @param issuer - The issuer URL from the well-known response + * @returns The inferred realm path, or undefined if it cannot be determined + * + * @example + * ```typescript + * // Standard AM issuer with subrealm + * inferRealmFromIssuer('https://am.example.com/am/oauth2/realms/root/realms/alpha') + * // Returns: 'alpha' + * + * // Nested subrealm + * inferRealmFromIssuer('https://am.example.com/am/oauth2/realms/root/realms/customers/realms/premium') + * // Returns: 'customers/realms/premium' + * + * // Root realm only + * inferRealmFromIssuer('https://am.example.com/am/oauth2/realms/root') + * // Returns: 'root' + * + * // Non-AM issuer (e.g., PingOne) + * inferRealmFromIssuer('https://auth.pingone.com/env-id/as') + * // Returns: undefined + * ``` + */ +export function inferRealmFromIssuer(issuer: string): string | undefined { + try { + const url = new URL(issuer); + const pathname = url.pathname; + + // Pattern 1: Subrealm - /oauth2/realms/root/realms/{subrealm} + const subRealmMatch = pathname.match(/\/oauth2\/realms\/root\/realms\/(.+)$/); + if (subRealmMatch) { + return subRealmMatch[1]; + } + + // Pattern 2: Root realm only - /oauth2/realms/root + const rootRealmMatch = pathname.match(/\/oauth2\/realms\/(root)$/); + if (rootRealmMatch) { + return rootRealmMatch[1]; + } + + // Could not infer realm from issuer URL + return undefined; + } catch { + // Invalid URL - return undefined + return undefined; + } +} + +/** + * Validates that a well-known URL is properly formatted. + * + * @param wellknownUrl - The URL to validate + * @returns True if the URL is valid and uses HTTPS (or HTTP for localhost) + * + * @example + * ```typescript + * isValidWellknownUrl('https://am.example.com/.well-known/openid-configuration') + * // Returns: true + * + * isValidWellknownUrl('http://localhost:8080/.well-known/openid-configuration') + * // Returns: true (localhost allows HTTP) + * + * isValidWellknownUrl('http://am.example.com/.well-known/openid-configuration') + * // Returns: false (non-localhost requires HTTPS) + * + * isValidWellknownUrl('not-a-url') + * // Returns: false + * ``` + */ +export function isValidWellknownUrl(wellknownUrl: string): boolean { + try { + const url = new URL(wellknownUrl); + + // Allow HTTP only for localhost (development) + const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; + const isSecure = url.protocol === 'https:'; + const isHttpLocalhost = url.protocol === 'http:' && isLocalhost; + + return isSecure || isHttpLocalhost; + } catch { + return false; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1145deb954..cc3a0c22de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,6 +436,9 @@ importers: '@forgerock/sdk-logger': specifier: workspace:* version: link:../sdk-effects/logger + '@forgerock/sdk-oidc': + specifier: workspace:* + version: link:../sdk-effects/oidc '@forgerock/sdk-request-middleware': specifier: workspace:* version: link:../sdk-effects/sdk-request-middleware @@ -516,6 +519,9 @@ importers: '@forgerock/sdk-utilities': specifier: workspace:* version: link:../../sdk-utilities + '@reduxjs/toolkit': + specifier: ^2.8.0 + version: 2.10.1 packages/sdk-effects/sdk-request-middleware: dependencies: