Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/express/src/middleware/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -86,6 +86,7 @@ const handleFlow = (): express.RequestHandler => {
authId: resolvedAuthId,
baseUrl: baseUrl,
payload,
url: resolveResourceEndpoint('flowExecute', config),
});

if (flowResponse.redirectUrl) {
Expand Down
2 changes: 2 additions & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
47 changes: 41 additions & 6 deletions packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,19 +217,38 @@ export interface BaseConfig<T = unknown> 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?: {
/**
Expand All @@ -242,6 +261,17 @@ export interface BaseConfig<T = unknown> 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.
Expand All @@ -262,6 +292,11 @@ export interface BaseConfig<T = unknown> 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`.
Expand Down
60 changes: 32 additions & 28 deletions packages/javascript/src/utils/AuthenticationHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,6 +60,34 @@ class AuthenticationHelper<T> {
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<T>): 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.
Expand All @@ -67,18 +96,9 @@ class AuthenticationHelper<T> {
* @returns The discovery response with any config-specified endpoint overrides applied.
*/
public async resolveEndpoints(response: OIDCDiscoveryApiResponse): Promise<OIDCDiscoveryApiResponse> {
const oidcProviderMetaData: OIDCDiscoveryApiResponse = {};
const configData: AuthClientConfig<T> = 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)};
}

/**
Expand All @@ -89,7 +109,6 @@ class AuthenticationHelper<T> {
* @throws {ThunderIDAuthException} When required endpoints are absent from the config.
*/
public async resolveEndpointsExplicitly(): Promise<OIDCDiscoveryEndpointsApiResponse> {
const oidcProviderMetaData: OIDCDiscoveryApiResponse = {};
const configData: AuthClientConfig<T> = await this.config();

const requiredEndpoints: string[] = [
Expand Down Expand Up @@ -127,15 +146,7 @@ class AuthenticationHelper<T> {
);
}

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)};
}

/**
Expand All @@ -147,7 +158,6 @@ class AuthenticationHelper<T> {
* @throws {ThunderIDAuthException} When `baseUrl` is not defined in the config.
*/
public async resolveEndpointsByBaseURL(): Promise<OIDCDiscoveryEndpointsApiResponse> {
const oidcProviderMetaData: OIDCDiscoveryEndpointsApiResponse = {};
const configData: AuthClientConfig<T> = await this.config();

const {baseUrl} = configData as any;
Expand All @@ -160,13 +170,7 @@ class AuthenticationHelper<T> {
);
}

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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<any> => {
const storageManager: any = {
getConfigData: async () => config,
};

return new AuthenticationHelper<any>(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<void> => {
const helper: AuthenticationHelper<any> = 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<string, unknown> = (await helper.resolveEndpointsByBaseURL()) as Record<string, unknown>;

// 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<void> => {
const helper: AuthenticationHelper<any> = 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<string, unknown> = (await helper.resolveEndpoints({
token_endpoint: 'https://idp.example.com/oauth2/token',
})) as Record<string, unknown>;

// 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<void> => {
const helper: AuthenticationHelper<any> = 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<string, unknown> = (await helper.resolveEndpointsExplicitly()) as Record<string, unknown>;

// 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));
});
});
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading
Loading