From 73386a29d5f348bfec5fd3b66516fc687dd3460a Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 22 Jan 2026 11:47:38 -0700 Subject: [PATCH] feat(journey-client): wellknown-endpoint-config-support --- .changeset/rich-cows-try.md | 5 + .../journey-client/src/lib/client.store.ts | 131 ++++++- .../src/lib/client.store.utils.ts | 10 +- .../journey-client/src/lib/config.types.ts | 83 ++++- .../journey-client/src/lib/journey.slice.ts | 15 +- .../journey-client/src/lib/wellknown.api.ts | 59 ++++ .../src/lib/wellknown.utils.test.ts | 327 ++++++++++++++++++ .../journey-client/src/lib/wellknown.utils.ts | 132 +++++++ packages/journey-client/src/types.ts | 7 + 9 files changed, 754 insertions(+), 15 deletions(-) create mode 100644 .changeset/rich-cows-try.md create mode 100644 packages/journey-client/src/lib/wellknown.api.ts create mode 100644 packages/journey-client/src/lib/wellknown.utils.test.ts create mode 100644 packages/journey-client/src/lib/wellknown.utils.ts diff --git a/.changeset/rich-cows-try.md b/.changeset/rich-cows-try.md new file mode 100644 index 000000000..d56ee055f --- /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/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 9288cd0dc..c71fe799e 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -16,9 +16,19 @@ 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'; @@ -26,7 +36,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 +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, +): 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 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; @@ -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', diff --git a/packages/journey-client/src/lib/client.store.utils.ts b/packages/journey-client/src/lib/client.store.utils.ts index 27e6200cb..5055bb3db 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 f172c2aa9..ce1d29b61 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 aea31ea43..8cf2c5bbc 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 000000000..b14b9834a --- /dev/null +++ b/packages/journey-client/src/lib/wellknown.api.ts @@ -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({ + 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); +} 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 000000000..9c8538684 --- /dev/null +++ b/packages/journey-client/src/lib/wellknown.utils.test.ts @@ -0,0 +1,327 @@ +/* + * 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, + inferRealmFromIssuer, + isValidWellknownUrl, +} from './wellknown.utils.js'; +import type { + JourneyConfigInput, + AsyncJourneyClientConfig, + JourneyClientConfig, +} from './config.types.js'; + +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'); + } + }); + }); + }); + + 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/journey-client/src/lib/wellknown.utils.ts b/packages/journey-client/src/lib/wellknown.utils.ts new file mode 100644 index 000000000..9a71cca7a --- /dev/null +++ b/packages/journey-client/src/lib/wellknown.utils.ts @@ -0,0 +1,132 @@ +/* + * 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 { 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 + ); +} + +/** + * 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/packages/journey-client/src/types.ts b/packages/journey-client/src/types.ts index ed7cade5f..382c8e2ca 100644 --- a/packages/journey-client/src/types.ts +++ b/packages/journey-client/src/types.ts @@ -24,6 +24,13 @@ 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, +} 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';