diff --git a/packages/express/src/middleware/flow.ts b/packages/express/src/middleware/flow.ts index dc5908e2..eba7347d 100644 --- a/packages/express/src/middleware/flow.ts +++ b/packages/express/src/middleware/flow.ts @@ -16,7 +16,7 @@ * under the License. */ -import {executeEmbeddedSignInFlow, logger as Logger} from '@thunderid/node'; +import {executeEmbeddedSignInFlow, resolveResourceEndpoint, logger as Logger} from '@thunderid/node'; import express from 'express'; import ThunderIDExpressClient from '../ThunderIDExpressClient'; @@ -86,6 +86,7 @@ const handleFlow = (): express.RequestHandler => { authId: resolvedAuthId, baseUrl: baseUrl, payload, + url: resolveResourceEndpoint('flowExecute', config), }); if (flowResponse.redirectUrl) { diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index e5d0b5db..0956853b 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -184,6 +184,8 @@ export {default as get} from './utils/get'; export {default as startCase} from './utils/startCase'; export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; export {default as resolveFieldName} from './utils/resolveFieldName'; +export {default as resolveResourceEndpoint} from './utils/resolveResourceEndpoint'; +export type {ResourceEndpointKey, ResourceEndpointConfig} from './utils/resolveResourceEndpoint'; export {default as resolveMeta} from './utils/resolveMeta'; export {default as resolveFlowTemplateLiterals} from './utils/resolveFlowTemplateLiterals'; export {default as countryCodeToFlagEmoji} from './utils/countryCodeToFlagEmoji'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 62309820..e69db969 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -217,19 +217,38 @@ export interface BaseConfig extends WithPreferences, WithExtensions }; /** - * Optional overrides for the OIDC protocol endpoints. - * By default, the SDK derives all endpoint URLs from the well-known discovery document - * located at `{baseUrl}/oauth2/token/.well-known/openid-configuration`. - * Use this when your authorization server exposes endpoints at non-standard paths, - * or when a custom domain differs from `baseUrl`. + * Optional overrides for the endpoint URLs the SDK talks to. * - * Individual overrides take precedence over values resolved from the discovery document. + * Two independent groups live here: + * + * - **OIDC/OAuth endpoints** (`authorization`, `token`, `userInfo`, `jwks`, `introspection`, + * `endSession`, `wellKnown`) — by default derived from the well-known discovery document at + * `{baseUrl}/oauth2/token/.well-known/openid-configuration`. Individual overrides take + * precedence over values resolved from the discovery document. + * - **Resource-server endpoints** (`flowExecute`, `flowMeta`, `usersMe`) — + * by default derived by concatenating `baseUrl` with a fixed path (e.g. `{baseUrl}/flow/execute`). + * These do not participate in OIDC discovery. + * + * Split these two groups when the OAuth authorization server (IdP) and the Thunder resource + * server are different hosts — for example, when two Thunder instances are connected as trusted + * issuers. Point `baseUrl` (and hence the OAuth/discovery endpoints) at the authorization server, + * and override the resource-server endpoints to target the resource server that actually owns the + * users and flows. * * @example * endpoints: { * wellKnown: "https://custom-domain.example.com/.well-known/openid-configuration", * authorization: "https://custom-domain.example.com/oauth2/authorize", * } + * + * @example + * // Trusted-issuer setup: OAuth on the authorization server, resource APIs on the resource server. + * baseUrl: "https://idp.example.com", + * endpoints: { + * flowExecute: "https://rs.example.com/flow/execute", + * flowMeta: "https://rs.example.com/flow/meta", + * usersMe: "https://rs.example.com/users/me", + * } */ endpoints?: { /** @@ -242,6 +261,17 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, resolved from the well-known discovery document. */ endSession?: string; + /** + * The flow execution endpoint URL used by the embedded sign-in, sign-up, recovery, and user + * onboarding flows. + * If not provided, defaults to `{baseUrl}/flow/execute`. + */ + flowExecute?: string; + /** + * The flow metadata endpoint URL used to resolve flow/branding metadata. + * If not provided, defaults to `{baseUrl}/flow/meta`. + */ + flowMeta?: string; /** * The introspection endpoint URL. * If not provided, resolved from the well-known discovery document. @@ -262,6 +292,11 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, resolved from the well-known discovery document. */ userInfo?: string; + /** + * The current-user profile endpoint URL used to fetch and update the signed-in user's profile. + * If not provided, defaults to `{baseUrl}/users/me`. + */ + usersMe?: string; /** * The OpenID Connect discovery document URL. * Defaults to `{baseUrl}/oauth2/token/.well-known/openid-configuration`. diff --git a/packages/javascript/src/utils/AuthenticationHelper.ts b/packages/javascript/src/utils/AuthenticationHelper.ts index bced85d1..a9a0057f 100644 --- a/packages/javascript/src/utils/AuthenticationHelper.ts +++ b/packages/javascript/src/utils/AuthenticationHelper.ts @@ -18,6 +18,7 @@ import extractUserClaimsFromIdToken from './extractUserClaimsFromIdToken'; import processOpenIDScopes from './processOpenIDScopes'; +import {RESOURCE_ENDPOINT_KEYS} from './resolveResourceEndpoint'; import OIDCDiscoveryConstants from '../constants/OIDCDiscoveryConstants'; import TokenExchangeConstants from '../constants/TokenExchangeConstants'; import {ThunderIDAuthException} from '../errors/exception'; @@ -59,6 +60,34 @@ class AuthenticationHelper { this.cryptoHelper = cryptoHelperInstance; } + /** + * Maps the config-defined OIDC endpoint overrides to their discovery-metadata form. + * + * Endpoint names are converted from camelCase to snake_case, and resource-server endpoints + * (`RESOURCE_ENDPOINT_KEYS`) are excluded — those are resolved separately via + * `resolveResourceEndpoint` and must not leak into the OIDC provider metadata. + * + * @param configData - The resolved auth client config. + * @returns A snake_cased map of the OIDC endpoint overrides (empty when none are configured). + */ + private mapConfiguredOidcEndpoints(configData: AuthClientConfig): OIDCDiscoveryApiResponse { + const endpoints: OIDCDiscoveryApiResponse = {}; + + if (!configData.endpoints) { + return endpoints; + } + + Object.keys(configData.endpoints) + .filter((endpointName: string) => !RESOURCE_ENDPOINT_KEYS.includes(endpointName as never)) + .forEach((endpointName: string) => { + const snakeCasedName: string = endpointName.replace(/[A-Z]/g, (letter: string) => `_${letter.toLowerCase()}`); + + endpoints[snakeCasedName] = configData.endpoints ? configData.endpoints[endpointName] : ''; + }); + + return endpoints; + } + /** * Merges explicit endpoint overrides from config into the discovery response. * Config-defined endpoint names (camelCase) are converted to snake_case before merging. @@ -67,18 +96,9 @@ class AuthenticationHelper { * @returns The discovery response with any config-specified endpoint overrides applied. */ public async resolveEndpoints(response: OIDCDiscoveryApiResponse): Promise { - const oidcProviderMetaData: OIDCDiscoveryApiResponse = {}; const configData: AuthClientConfig = await this.config(); - if (configData.endpoints) { - Object.keys(configData.endpoints).forEach((endpointName: string) => { - const snakeCasedName: string = endpointName.replace(/[A-Z]/g, (letter: string) => `_${letter.toLowerCase()}`); - - oidcProviderMetaData[snakeCasedName] = configData?.endpoints ? configData.endpoints[endpointName] : ''; - }); - } - - return {...response, ...oidcProviderMetaData}; + return {...response, ...this.mapConfiguredOidcEndpoints(configData)}; } /** @@ -89,7 +109,6 @@ class AuthenticationHelper { * @throws {ThunderIDAuthException} When required endpoints are absent from the config. */ public async resolveEndpointsExplicitly(): Promise { - const oidcProviderMetaData: OIDCDiscoveryApiResponse = {}; const configData: AuthClientConfig = await this.config(); const requiredEndpoints: string[] = [ @@ -127,15 +146,7 @@ class AuthenticationHelper { ); } - if (configData.endpoints) { - Object.keys(configData.endpoints).forEach((endpointName: string) => { - const snakeCasedName: string = endpointName.replace(/[A-Z]/g, (letter: string) => `_${letter.toLowerCase()}`); - - oidcProviderMetaData[snakeCasedName] = configData?.endpoints ? configData.endpoints[endpointName] : ''; - }); - } - - return {...oidcProviderMetaData}; + return {...this.mapConfiguredOidcEndpoints(configData)}; } /** @@ -147,7 +158,6 @@ class AuthenticationHelper { * @throws {ThunderIDAuthException} When `baseUrl` is not defined in the config. */ public async resolveEndpointsByBaseURL(): Promise { - const oidcProviderMetaData: OIDCDiscoveryEndpointsApiResponse = {}; const configData: AuthClientConfig = await this.config(); const {baseUrl} = configData as any; @@ -160,13 +170,7 @@ class AuthenticationHelper { ); } - if (configData.endpoints) { - Object.keys(configData.endpoints).forEach((endpointName: string) => { - const snakeCasedName: string = endpointName.replace(/[A-Z]/g, (letter: string) => `_${letter.toLowerCase()}`); - - oidcProviderMetaData[snakeCasedName] = configData?.endpoints ? configData.endpoints[endpointName] : ''; - }); - } + const oidcProviderMetaData: OIDCDiscoveryApiResponse = this.mapConfiguredOidcEndpoints(configData); const endpointKeys: typeof OIDCDiscoveryConstants.Storage.StorageKeys.Endpoints = OIDCDiscoveryConstants.Storage.StorageKeys.Endpoints; diff --git a/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts b/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts new file mode 100644 index 00000000..70ce0148 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/require-await */ +import {describe, expect, it} from 'vitest'; +import AuthenticationHelper from '../AuthenticationHelper'; + +/** + * Builds an AuthenticationHelper backed by a minimal storage-manager stub that only needs to + * surface config data for the endpoint-resolution methods under test. + */ +const createHelper = (config: any): AuthenticationHelper => { + const storageManager: any = { + getConfigData: async () => config, + }; + + return new AuthenticationHelper(storageManager, {} as any); +}; + +// Resource-server override keys in both camelCase (config form) and snake_case (metadata form); +// none of these should ever appear in the resolved OIDC provider metadata. +const RESOURCE_KEY_FORMS: string[] = ['flowExecute', 'flowMeta', 'usersMe', 'flow_execute', 'flow_meta', 'users_me']; + +describe('AuthenticationHelper resource-endpoint filtering', (): void => { + it('keeps resource-server endpoint overrides out of the OIDC provider metadata', async (): Promise => { + const helper: AuthenticationHelper = createHelper({ + baseUrl: 'https://idp.example.com', + endpoints: { + // OIDC/OAuth override — kept. + authorization: 'https://idp.example.com/custom/authorize', + // Resource-server overrides — must NOT leak into OIDC metadata. + flowExecute: 'https://rs.example.com/flow/execute', + flowMeta: 'https://rs.example.com/flow/meta', + usersMe: 'https://rs.example.com/users/me', + }, + }); + + const resolved: Record = (await helper.resolveEndpointsByBaseURL()) as Record; + + // OIDC endpoint resolution still runs and derives the standard endpoints from baseUrl. + expect(resolved['token_endpoint']).toBe('https://idp.example.com/oauth2/token'); + // The OIDC override is still carried through. + expect(resolved['authorization']).toBe('https://idp.example.com/custom/authorize'); + + // Resource-server overrides are absent from OIDC metadata (neither camelCase nor snake_case). + RESOURCE_KEY_FORMS.forEach((key: string) => expect(resolved).not.toHaveProperty(key)); + }); + + it('excludes resource-server overrides in resolveEndpoints while preserving OIDC overrides', async (): Promise => { + const helper: AuthenticationHelper = createHelper({ + baseUrl: 'https://idp.example.com', + endpoints: { + authorization: 'https://idp.example.com/custom/authorize', + flowExecute: 'https://rs.example.com/flow/execute', + flowMeta: 'https://rs.example.com/flow/meta', + usersMe: 'https://rs.example.com/users/me', + }, + }); + + const resolved: Record = (await helper.resolveEndpoints({ + token_endpoint: 'https://idp.example.com/oauth2/token', + })) as Record; + + // The discovery response value is preserved and the OIDC override is merged in. + expect(resolved['token_endpoint']).toBe('https://idp.example.com/oauth2/token'); + expect(resolved['authorization']).toBe('https://idp.example.com/custom/authorize'); + RESOURCE_KEY_FORMS.forEach((key: string) => expect(resolved).not.toHaveProperty(key)); + }); + + it('excludes resource-server overrides in resolveEndpointsExplicitly', async (): Promise => { + const helper: AuthenticationHelper = createHelper({ + endpoints: { + // Explicit resolution requires every OIDC endpoint to be present (snake_cased storage keys). + authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', + endSessionEndpoint: 'https://idp.example.com/oauth2/logout', + issuer: 'https://idp.example.com', + jwksUri: 'https://idp.example.com/oauth2/jwks', + checkSessionIframe: 'https://idp.example.com/oauth2/checksession', + revocationEndpoint: 'https://idp.example.com/oauth2/revoke', + tokenEndpoint: 'https://idp.example.com/oauth2/token', + userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', + // Resource-server overrides — must NOT leak into OIDC metadata. + flowExecute: 'https://rs.example.com/flow/execute', + flowMeta: 'https://rs.example.com/flow/meta', + usersMe: 'https://rs.example.com/users/me', + }, + }); + + const resolved: Record = (await helper.resolveEndpointsExplicitly()) as Record; + + // OIDC endpoints are resolved from the explicit config. + expect(resolved['token_endpoint']).toBe('https://idp.example.com/oauth2/token'); + expect(resolved['authorization_endpoint']).toBe('https://idp.example.com/oauth2/authorize'); + RESOURCE_KEY_FORMS.forEach((key: string) => expect(resolved).not.toHaveProperty(key)); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts new file mode 100644 index 00000000..7174a32c --- /dev/null +++ b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, expect, it} from 'vitest'; +import resolveResourceEndpoint, {RESOURCE_ENDPOINT_KEYS} from '../resolveResourceEndpoint'; + +describe('resolveResourceEndpoint', (): void => { + it('returns undefined when no explicit URL and no override are set (fallback to baseUrl)', (): void => { + expect(resolveResourceEndpoint('flowExecute', {})).toBeUndefined(); + expect(resolveResourceEndpoint('flowExecute', {endpoints: {}})).toBeUndefined(); + expect(resolveResourceEndpoint('flowExecute', undefined)).toBeUndefined(); + }); + + it('returns the config override when set', (): void => { + const config = {endpoints: {flowExecute: 'https://rs.example.com/flow/execute'}}; + + expect(resolveResourceEndpoint('flowExecute', config)).toBe('https://rs.example.com/flow/execute'); + }); + + it('resolves each supported resource endpoint key independently', (): void => { + const config = { + endpoints: { + flowExecute: 'https://rs.example.com/flow/execute', + flowMeta: 'https://rs.example.com/flow/meta', + usersMe: 'https://rs.example.com/users/me', + }, + }; + + expect(resolveResourceEndpoint('flowExecute', config)).toBe('https://rs.example.com/flow/execute'); + expect(resolveResourceEndpoint('flowMeta', config)).toBe('https://rs.example.com/flow/meta'); + expect(resolveResourceEndpoint('usersMe', config)).toBe('https://rs.example.com/users/me'); + }); + + it('prefers an explicit per-call URL over the config override', (): void => { + const config = {endpoints: {flowExecute: 'https://rs.example.com/flow/execute'}}; + + expect(resolveResourceEndpoint('flowExecute', config, 'https://explicit.example.com/flow/execute')).toBe( + 'https://explicit.example.com/flow/execute', + ); + }); + + it('falls back to the config override when the explicit URL is undefined', (): void => { + const config = {endpoints: {flowExecute: 'https://rs.example.com/flow/execute'}}; + + expect(resolveResourceEndpoint('flowExecute', config, undefined)).toBe('https://rs.example.com/flow/execute'); + }); + + it('exposes the resource endpoint keys for filtering OIDC metadata', (): void => { + expect([...RESOURCE_ENDPOINT_KEYS].sort()).toEqual(['flowExecute', 'flowMeta', 'usersMe']); + }); +}); diff --git a/packages/javascript/src/utils/resolveResourceEndpoint.ts b/packages/javascript/src/utils/resolveResourceEndpoint.ts new file mode 100644 index 00000000..bcc43fa5 --- /dev/null +++ b/packages/javascript/src/utils/resolveResourceEndpoint.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025-2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {BaseConfig} from '../models/config'; + +/** + * Resource-server endpoints whose URLs can be overridden via `config.endpoints`. + * + * Unlike the OIDC/OAuth endpoints, these are not resolved from the well-known discovery document; + * they are derived by concatenating `baseUrl` with a fixed path. When the authorization server and + * the Thunder resource server are different hosts (e.g. two Thunder instances connected as trusted + * issuers), these overrides let the SDK send flow and user-management requests to the resource + * server while OAuth requests continue to target the authorization server. + */ +export type ResourceEndpointKey = 'flowExecute' | 'flowMeta' | 'usersMe'; + +/** + * The `config.endpoints` keys that address resource-server endpoints rather than OIDC/OAuth + * endpoints. Used to keep these overrides out of the resolved OIDC provider metadata. + */ +export const RESOURCE_ENDPOINT_KEYS: readonly ResourceEndpointKey[] = ['flowExecute', 'flowMeta', 'usersMe']; + +/** + * Minimal shape of the config needed to resolve a resource-server endpoint override. + * Reuses the `endpoints` type from {@link BaseConfig} so the override shape stays in sync. + */ +export interface ResourceEndpointConfig { + endpoints?: BaseConfig['endpoints']; +} + +/** + * Resolves the absolute `url` to use for a resource-server endpoint, honoring (in order of + * precedence) an explicit per-call URL, then a `config.endpoints` override. + * + * Returns `undefined` when neither is set, so the calling API function falls back to deriving the + * endpoint from `baseUrl` (i.e. `${baseUrl}/flow/execute`) exactly as before. Callers should keep + * passing `baseUrl` alongside the resolved `url` to preserve that fallback. + * + * @param key - The resource endpoint to resolve. + * @param config - The client config carrying optional `endpoints` overrides. + * @param explicitUrl - An explicit URL provided by the immediate caller; takes highest precedence. + * @returns The resolved override URL, or `undefined` to defer to `baseUrl`-based resolution. + * + * @example + * ```typescript + * const response = await executeEmbeddedUserOnboardingFlow({ + * baseUrl: config.baseUrl, + * url: resolveResourceEndpoint('flowExecute', config), + * payload, + * }); + * ``` + */ +const resolveResourceEndpoint = ( + key: ResourceEndpointKey, + config: ResourceEndpointConfig | undefined, + explicitUrl?: string, +): string | undefined => explicitUrl ?? config?.endpoints?.[key] ?? undefined; + +export default resolveResourceEndpoint; diff --git a/packages/nextjs/src/ThunderIDNextClient.ts b/packages/nextjs/src/ThunderIDNextClient.ts index 6cd1ac30..4e90dfe6 100644 --- a/packages/nextjs/src/ThunderIDNextClient.ts +++ b/packages/nextjs/src/ThunderIDNextClient.ts @@ -35,6 +35,7 @@ import { generateFlattenedUserProfile, getUsersMe, updateMeProfile, + resolveResourceEndpoint, } from '@thunderid/node'; import {ThunderIDNextConfig} from './models/config'; import getClientOrigin from './server/actions/getClientOrigin'; @@ -127,6 +128,7 @@ class ThunderIDNextClient e const profile: User = await getUsersMe({ baseUrl, + url: resolveResourceEndpoint('usersMe', configData), headers: { Authorization: `Bearer ${await this.getAccessToken(userId)}`, }, @@ -147,6 +149,7 @@ class ThunderIDNextClient e const profile: User = await getUsersMe({ baseUrl, + url: resolveResourceEndpoint('usersMe', configData), headers: { Authorization: `Bearer ${await this.getAccessToken(userId)}`, }, @@ -173,6 +176,7 @@ class ThunderIDNextClient e return updateMeProfile({ baseUrl, + url: resolveResourceEndpoint('usersMe', configData), headers: { Authorization: `Bearer ${await this.getAccessToken(userId)}`, }, @@ -244,7 +248,7 @@ class ThunderIDNextClient e baseUrl: configData?.baseUrl, flowSecret: arg2?.flowSecret, payload: arg1, - url: arg2?.url, + url: resolveResourceEndpoint('flowExecute', configData, arg2?.url), }) as unknown as Promise; } diff --git a/packages/nextjs/src/server/ThunderIDProvider.tsx b/packages/nextjs/src/server/ThunderIDProvider.tsx index a6d4d5ca..20b6b35a 100644 --- a/packages/nextjs/src/server/ThunderIDProvider.tsx +++ b/packages/nextjs/src/server/ThunderIDProvider.tsx @@ -27,6 +27,7 @@ import { UserProfile, getFlowMeta, extractUserClaimsFromIdToken, + resolveResourceEndpoint, } from '@thunderid/node'; import {ThunderIDProviderProps} from '@thunderid/react'; import {FC, PropsWithChildren, ReactElement} from 'react'; @@ -122,6 +123,7 @@ const ThunderIDServerProvider: FC = ({ showTitle = true, showSubtitle = true, }: InviteUserProps): ReactElement => { - const {http, baseUrl, getAccessToken, isInitialized} = useThunderID(); + const {http, baseUrl, endpoints, getAccessToken, isInitialized} = useThunderID(); + + // The user-onboarding flow runs on the resource server, which may differ from `baseUrl` (the + // authorization server) in a trusted-issuer setup. Honor the `flowExecute` endpoint override. + const flowExecuteUrl: string = resolveResourceEndpoint('flowExecute', {endpoints}) ?? `${baseUrl}/flow/execute`; /** * Initialize the invite user flow. @@ -141,7 +150,7 @@ const InviteUser: FC = ({ 'Content-Type': 'application/json', }, method: 'POST', - url: `${baseUrl}/flow/execute`, + url: flowExecuteUrl, } as any); return response.data as InviteUserFlowResponse; @@ -162,7 +171,7 @@ const InviteUser: FC = ({ 'Content-Type': 'application/json', }, method: 'POST', - url: `${baseUrl}/flow/execute`, + url: flowExecuteUrl, } as any); return response.data as InviteUserFlowResponse; diff --git a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx index cf268291..af947f48 100644 --- a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx +++ b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx @@ -21,6 +21,7 @@ import { FlowMetaType, getFlowMeta, I18nBundle, + resolveResourceEndpoint, TranslationBundleConstants, } from '@thunderid/browser'; import {FC, PropsWithChildren, ReactElement, RefObject, useCallback, useEffect, useRef, useState} from 'react'; @@ -73,7 +74,7 @@ const FlowMetaProvider: FC> = ({ enabled = true, initialMeta = null, }: PropsWithChildren): ReactElement => { - const {baseUrl, applicationId, isInitialized} = useThunderID(); + const {baseUrl, endpoints, applicationId, isInitialized} = useThunderID(); const i18nContext: I18nContextValue = useI18n(); const [meta, setMeta] = useState(initialMeta); @@ -110,6 +111,7 @@ const FlowMetaProvider: FC> = ({ try { const result: FlowMetadataResponse = await getFlowMeta({ baseUrl, + url: resolveResourceEndpoint('flowMeta', {endpoints}), ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), language: i18nContext?.currentLanguage, }); @@ -119,7 +121,7 @@ const FlowMetaProvider: FC> = ({ } finally { setIsLoading(false); } - }, [enabled, baseUrl, applicationId, isInitialized, i18nContext?.currentLanguage]); + }, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage]); const switchLanguage: (language: string) => Promise = useCallback( async (language: string): Promise => { @@ -131,6 +133,7 @@ const FlowMetaProvider: FC> = ({ try { const result: FlowMetadataResponse = await getFlowMeta({ baseUrl, + url: resolveResourceEndpoint('flowMeta', {endpoints}), ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), language, }); @@ -157,7 +160,7 @@ const FlowMetaProvider: FC> = ({ setIsLoading(false); } }, - [enabled, baseUrl, applicationId, i18nContext], + [enabled, baseUrl, endpoints, applicationId, i18nContext], ); // After injectBundles + setPendingLanguage are batched and committed, this diff --git a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts index e3ec459b..f0e44584 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts +++ b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts @@ -39,6 +39,11 @@ export type ThunderIDContextProps = { applicationId: string | undefined; baseUrl: string | undefined; clientId: string | undefined; + /** + * Optional endpoint URL overrides from the client config. Used to resolve resource-server + * endpoints (e.g. flow/user-management) independently of `baseUrl` — see `resolveResourceEndpoint`. + */ + endpoints?: ThunderIDReactConfig['endpoints']; preferences?: ThunderIDReactConfig['preferences']; scopes: string | string[] | undefined; /** diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index d5c43563..2d02e931 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -28,6 +28,7 @@ import { EmbeddedSignInFlowResponse, createPackageComponentLogger, getVendorPrefix, + resolveResourceEndpoint, } from '@thunderid/browser'; import {FC, RefObject, PropsWithChildren, ReactElement, useEffect, useMemo, useRef, useState, useCallback} from 'react'; import ThunderIDContext from './ThunderIDContext'; @@ -143,7 +144,11 @@ const ThunderIDProvider: FC> = ({ if (currentSignInStatus && shouldFetchProfile) { try { - const fetchedProfile = await getUsersMe({baseUrl: resolvedBaseUrl, instanceId}); + const fetchedProfile = await getUsersMe({ + baseUrl: resolvedBaseUrl, + url: resolveResourceEndpoint('usersMe', config), + instanceId, + }); profileData = {...claims, ...fetchedProfile}; } catch (err) { logger.warn('Failed to fetch user profile from /users/me:', err); @@ -447,7 +452,18 @@ const ThunderIDProvider: FC> = ({ ); const reInitialize: (reInitConfig: any) => Promise = useCallback( - async (reInitConfig: any): Promise => client.reInitialize(reInitConfig), + async (reInitConfig: any): Promise => { + const result: any = await client.reInitialize(reInitConfig); + + // Refresh React state from the reinitialized client so the context (config.endpoints, + // baseUrl, discovery) reflects the new configuration instead of the pre-reinitialization state. + const reinitializedConfig: ThunderIDReactConfig = await client.getConfiguration(); + setConfig(reinitializedConfig); + setBaseUrl(reinitializedConfig.baseUrl ?? ''); + setWellKnown(await client.getDiscoveryResponse()); + + return result; + }, [client], ); @@ -456,6 +472,7 @@ const ThunderIDProvider: FC> = ({ afterSignInUrl: config.afterSignInUrl, applicationId: config.applicationId, baseUrl, + endpoints: config.endpoints, scopes: config.scopes, clearSession, clientId, @@ -497,6 +514,7 @@ const ThunderIDProvider: FC> = ({ config?.organizationHandle, config.vendor, config.afterSignInUrl, + config.endpoints, config.scopes, signInUrl, signUpUrl, diff --git a/packages/vue/src/ThunderIDVueClient.ts b/packages/vue/src/ThunderIDVueClient.ts index 5770fd20..b32df471 100644 --- a/packages/vue/src/ThunderIDVueClient.ts +++ b/packages/vue/src/ThunderIDVueClient.ts @@ -36,6 +36,8 @@ import { EmbeddedSignInFlowStatus, EmbeddedSignUpFlowStatus, StorageManager, + AuthClientConfig, + resolveResourceEndpoint, } from '@thunderid/browser'; import getUsersMe from './api/getUsersMe'; import {ThunderIDVueConfig} from './models/config'; @@ -95,16 +97,19 @@ class ThunderIDVueClient exte throw new Error('Not implemented'); } - override async getUser(options?: any): Promise { + // The param is a supertype of the base `getUser(userId?: string)` so the override stays valid, + // while still typing the options object this client actually accepts. + override async getUser(options?: string | {baseUrl?: string; url?: string}): Promise { try { - let baseUrl: string = options?.baseUrl; - - if (!baseUrl) { - const configData: any = await this.getStorageManager().getConfigData(); - baseUrl = configData?.baseUrl; - } - - const profile: User = await getUsersMe({baseUrl}); + const opts: {baseUrl?: string; url?: string} | undefined = + typeof options === 'object' && options !== null ? options : undefined; + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = opts?.baseUrl ?? configData?.baseUrl; + + const profile: User = await getUsersMe({ + baseUrl, + url: resolveResourceEndpoint('usersMe', configData, opts?.url), + }); return profile; } catch (error) { @@ -120,17 +125,17 @@ class ThunderIDVueClient exte return this.withLoading(async () => super.getIdToken()); } - override async getUserProfile(options?: any): Promise { + override async getUserProfile(options?: {baseUrl?: string; url?: string}): Promise { return this.withLoading(async () => { try { - let baseUrl: string = options?.baseUrl; + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = options?.baseUrl ?? configData?.baseUrl; - if (!baseUrl) { - const configData: any = await this.getStorageManager().getConfigData(); - baseUrl = configData?.baseUrl; - } - - const profile: User = await getUsersMe({baseUrl, instanceId: this.getInstanceId()}); + const profile: User = await getUsersMe({ + baseUrl, + instanceId: this.getInstanceId(), + url: resolveResourceEndpoint('usersMe', configData, options?.url), + }); const output: UserProfile = { flattenedProfile: generateFlattenedUserProfile(profile), @@ -192,7 +197,7 @@ class ThunderIDVueClient exte authId, baseUrl, payload: arg1, - url: arg2?.url, + url: resolveResourceEndpoint('flowExecute', configData, arg2?.url), }); if ( @@ -251,6 +256,7 @@ class ThunderIDVueClient exte authId, baseUrl, payload: typeof firstArg === 'object' && 'flowType' in firstArg ? {...firstArg, verbose: true} : firstArg, + url: resolveResourceEndpoint('flowExecute', configData), }); if ( diff --git a/packages/vue/src/components/presentation/user-profile/UserProfile.ts b/packages/vue/src/components/presentation/user-profile/UserProfile.ts index 76b4ac0b..3064ae08 100644 --- a/packages/vue/src/components/presentation/user-profile/UserProfile.ts +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.ts @@ -16,7 +16,7 @@ * under the License. */ -import {ThunderIDError, User, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {ThunderIDError, User, resolveResourceEndpoint, withVendorCSSClassPrefix} from '@thunderid/browser'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h, ref, type Ref} from 'vue'; import BaseUserProfile from './BaseUserProfile'; import updateMeProfile from '../../../api/updateMeProfile'; @@ -68,7 +68,7 @@ const UserProfile: Component = defineComponent({ title: {default: 'Profile', type: String}, }, setup(props: UserProfileProps, {slots}: SetupContext): () => VNode { - const {baseUrl, instanceId} = useThunderID(); + const {baseUrl, endpoints, instanceId} = useThunderID(); const {flattenedProfile, profile, onUpdateProfile} = useUser(); const {t} = useI18n(); @@ -80,7 +80,12 @@ const UserProfile: Component = defineComponent({ error.value = null; try { - const response: User = await updateMeProfile({baseUrl, instanceId, payload}); + const response: User = await updateMeProfile({ + baseUrl, + url: resolveResourceEndpoint('usersMe', {endpoints}), + instanceId, + payload, + }); onUpdateProfile(response); } catch (caughtError: unknown) { let message: string = t('user.profile.update.generic.error') || 'Failed to update profile. Please try again.'; diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index e3533b73..319dac8c 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -47,6 +47,11 @@ export interface ThunderIDContext { applicationId: string | undefined; /** The base URL of the ThunderID tenant. */ baseUrl: string | undefined; + /** + * Optional endpoint URL overrides from the config. Used to resolve resource-server endpoints + * (e.g. flow/user-management) independently of `baseUrl` — see `resolveResourceEndpoint`. + */ + endpoints?: ThunderIDVueConfig['endpoints']; clearSession: (...args: any[]) => void; /** The OAuth2 client ID. */ clientId: string | undefined; diff --git a/packages/vue/src/providers/FlowMetaProvider.ts b/packages/vue/src/providers/FlowMetaProvider.ts index 5cfbe2cf..a56f2058 100644 --- a/packages/vue/src/providers/FlowMetaProvider.ts +++ b/packages/vue/src/providers/FlowMetaProvider.ts @@ -21,6 +21,7 @@ import { FlowMetaType, getFlowMeta, I18nBundle, + resolveResourceEndpoint, TranslationBundleConstants, } from '@thunderid/browser'; import { @@ -74,6 +75,9 @@ const FlowMetaProvider: Component = defineComponent({ const baseUrl: string | undefined = thunderIDContext?.baseUrl; const applicationId: string | undefined = thunderIDContext?.applicationId; + const flowMetaUrl: string | undefined = resolveResourceEndpoint('flowMeta', { + endpoints: thunderIDContext?.endpoints, + }); const fetchFlowMeta = async (): Promise => { if (!props.enabled) { @@ -87,6 +91,7 @@ const FlowMetaProvider: Component = defineComponent({ try { const result: FlowMetadataResponse = await getFlowMeta({ baseUrl, + url: flowMetaUrl, ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), }); meta.value = result; @@ -106,6 +111,7 @@ const FlowMetaProvider: Component = defineComponent({ try { const result: FlowMetadataResponse = await getFlowMeta({ baseUrl, + url: flowMetaUrl, ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), language, }); diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index d5748dcf..ee86ee0b 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -62,6 +62,7 @@ interface ThunderIDProviderProps { applicationId: string | undefined; baseUrl: string; clientId: string; + endpoints: ThunderIDVueConfig['endpoints']; instanceId: number; organizationChain: object | undefined; organizationHandle: string | undefined; @@ -129,6 +130,11 @@ const ThunderIDProvider: Component = defineComponent({ required: true, type: String, }, + /** Optional endpoint URL overrides (OIDC/OAuth and resource-server endpoints). */ + endpoints: { + default: undefined, + type: Object as PropType, + }, /** Instance ID for multi-instance support. */ instanceId: { default: 0, @@ -208,6 +214,7 @@ const ThunderIDProvider: Component = defineComponent({ applicationId: props.applicationId, baseUrl: props.baseUrl, clientId: props.clientId, + endpoints: props.endpoints, organizationChain: props.organizationChain, organizationHandle: props.organizationHandle, scopes: props.scopes, @@ -314,6 +321,7 @@ const ThunderIDProvider: Component = defineComponent({ afterSignInUrl: props.afterSignInUrl, applicationId: props.applicationId, baseUrl: props.baseUrl, + endpoints: props.endpoints, clearSession: async (...args: any[]): Promise => { await client.clearSession(...args); },