Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-cows-try.md
Original file line number Diff line number Diff line change
@@ -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.
131 changes: 124 additions & 7 deletions packages/journey-client/src/lib/client.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,27 @@ 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,
} 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';

/**
* 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) !== '/') {
Expand All @@ -42,12 +52,111 @@ 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<typeof loggerFn>,
): Promise<InternalJourneyClientConfig> {
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 errorMessage = fetchError
? `Failed to fetch well-known configuration: ${JSON.stringify(fetchError)}`
: 'Failed to fetch well-known configuration: No response received';
const error = new Error(errorMessage);
log.error(error.message);
throw error;
}

// 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;
Expand All @@ -56,11 +165,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;

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: normalizedConfig });
store.dispatch(setConfig(normalizedConfig));
const store = createJourneyStore({ requestMiddleware, logger: log, config: resolvedConfig });
store.dispatch(setConfig(resolvedConfig));

const stepStorage = createStorage<{ step: Step }>({
type: 'sessionStorage',
Expand Down
10 changes: 7 additions & 3 deletions packages/journey-client/src/lib/client.store.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -25,7 +27,7 @@ export const createJourneyStore = ({
}: {
requestMiddleware?: RequestMiddleware[];
logger?: ReturnType<typeof loggerFn>;
config: JourneyClientConfig;
config: InternalJourneyClientConfig;
}) => {
return configureStore({
reducer: rootReducer,
Expand All @@ -39,7 +41,9 @@ export const createJourneyStore = ({
config,
},
},
}).concat(journeyApi.middleware),
})
.concat(journeyApi.middleware)
.concat(wellknownApi.middleware),
});
};

Expand Down
83 changes: 81 additions & 2 deletions packages/journey-client/src/lib/config.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RequestMiddleware>;
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<RequestMiddleware>;
/** 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 };
15 changes: 12 additions & 3 deletions packages/journey-client/src/lib/journey.slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand All @@ -24,7 +33,7 @@ export const journeySlice: Slice<JourneyState> = createSlice({
name: 'journey',
initialState,
reducers: {
setConfig: (state, action: PayloadAction<JourneyClientConfig>) => {
setConfig: (state, action: PayloadAction<InternalJourneyClientConfig>) => {
state.config = action.payload;
},
},
Expand Down
59 changes: 59 additions & 0 deletions packages/journey-client/src/lib/wellknown.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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';
import type { RootState } from './client.store.utils.js';

/**
* RTK Query API for fetching the OIDC well-known configuration endpoint.
*
* The well-known endpoint provides OIDC-related URLs such as:
* - authorization_endpoint
* - token_endpoint
* - userinfo_endpoint
* - end_session_endpoint
* - revocation_endpoint
*
* Note: AM-specific endpoints (authenticate, sessions) are NOT included
* in the standard OIDC well-known response and must be derived from
* the baseUrl configuration.
*/
export const wellknownApi = createApi({
reducerPath: 'wellknown',
baseQuery: fetchBaseQuery({
prepareHeaders: (headers) => {
headers.set('Accept', 'application/json');
return headers;
},
}),
endpoints: (builder) => ({
configuration: builder.query<WellKnownResponse, string>({
query: (endpoint) => endpoint,
}),
}),
});

/**
* Selector to retrieve the cached well-known response from Redux state.
*
* @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,
): WellKnownResponse | undefined {
const selector = createSelector(
wellknownApi.endpoints.configuration.select(wellknownUrl),
(result) => result?.data,
);
return selector(state);
}
Loading
Loading