From 18e19a43cb7e9d2501836f5e0547359f9c8efd09 Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 12 Aug 2025 10:55:25 +0530 Subject: [PATCH 1/7] feat(javascript): add organization discovery strategy and utility functions --- packages/javascript/src/index.ts | 4 +- packages/javascript/src/models/config.ts | 77 +++++++++++++ .../src/utils/organizationDiscovery.ts | 104 ++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/javascript/src/utils/organizationDiscovery.ts diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 2c9e3217..438fa37c 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -86,6 +86,8 @@ export { SignInOptions, SignOutOptions, SignUpOptions, + OrganizationDiscovery, + OrganizationDiscoveryStrategy, } from './models/config'; export {TokenResponse, IdToken, TokenExchangeRequestConfig} from './models/token'; export {Crypto, JWKInterface} from './models/crypto'; @@ -143,7 +145,7 @@ export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as processOpenIDScopes} from './utils/processOpenIDScopes'; export {default as withVendorCSSClassPrefix} from './utils/withVendorCSSClassPrefix'; export {default as transformBrandingPreferenceToTheme} from './utils/transformBrandingPreferenceToTheme'; - +export {default as organizationDiscovery} from './utils/organizationDiscovery'; export { default as logger, createLogger, diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 6c860600..9b544c00 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -53,6 +53,74 @@ export type SignOutOptions = Record; */ export type SignUpOptions = Record; +/** + * Strategy for discovering the organization handle or ID. + * The default strategy is 'baseUrl', which derives the organization handle from the baseUrl. + */ +export type OrganizationDiscoveryStrategy = + | { + /** + * Discover organization info from the baseUrl. + * This is the default strategy if no other is specified. + */ + type: 'baseUrl'; + mode?: 'handle'; // Only handle is supported for baseUrl + } + | { + /** + * Discover organization info from a URL path parameter. + * Example: { type: 'urlPath', mode: 'id', param: 't' } for /app/:t/dashboard + */ + type: 'urlPath'; + mode: 'id' | 'handle'; + param: string; + } + | { + /** + * Discover organization info from a URL query parameter. + * Example: { type: 'urlQuery', mode: 'handle', param: 'org' } for /app?org=acme + */ + type: 'urlQuery'; + mode: 'id' | 'handle'; + param: string; + } + | { + /** + * Discover organization info from the subdomain. + * Example: { type: 'subdomain', mode: 'handle' } for acme.yourapp.com + */ + type: 'subdomain'; + mode: 'id' | 'handle'; + } + | { + /** + * Use a custom resolver function to determine the organization. + * Example: { type: 'custom', mode: 'handle', resolver: () => ... } + */ + type: 'custom'; + mode: 'id' | 'handle'; + resolver: () => string | Promise; + }; + +/** + * Optional configuration to enable and control automatic organization discovery. + * This feature is disabled by default and must be explicitly enabled by setting this property. + * If no strategy is specified, 'baseUrl' will be used as the default. + * + * When enabled, the SDK will use the specified strategy to discover the organization handle or ID + * and will automatically inject `signInOptions={{ fidp: 'OrganizationSSO', orgId: '' }}` + * into authentication requests. + * + * @example + * organizationDiscovery: { + * enabled: true, + * strategy: { type: 'urlPath', mode: 'handle', param: 'org' } + * } + */ +export interface OrganizationDiscovery { + strategy?: OrganizationDiscoveryStrategy; +} + export interface BaseConfig extends WithPreferences { /** * Optional URL where the authorization server should redirect after authentication. @@ -197,6 +265,15 @@ export interface BaseConfig extends WithPreferences { * @see {@link https://openid.net/specs/openid-connect-session-management-1_0.html#IframeBasedSessionManagement} */ syncSession?: boolean; + + /** + * Optional configuration to enable automatic organization discovery. + * This must be explicitly enabled by setting `enabled: true`. + * When enabled, the SDK will inject `signInOptions={{ fidp: 'OrganizationSSO', orgId: '' }}` + * into authentication requests. + * If no strategy is specified, the SDK will use the baseUrl to derive the organization handle. + */ + organizationDiscovery?: OrganizationDiscovery; } export interface WithPreferences { diff --git a/packages/javascript/src/utils/organizationDiscovery.ts b/packages/javascript/src/utils/organizationDiscovery.ts new file mode 100644 index 00000000..8eaebf9e --- /dev/null +++ b/packages/javascript/src/utils/organizationDiscovery.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2025, 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 {OrganizationDiscoveryStrategy} from '../models/config'; +import deriveOrganizationHandleFromBaseUrl from './deriveOrganizationHandleFromBaseUrl'; + +/** + * Derives organization info from URL path parameter + * Supports patterns like /o/ or // + */ +const deriveFromUrlPath = (param: string): string | undefined => { + if (typeof globalThis !== 'undefined' && typeof globalThis.window !== 'undefined') { + const pathSegments = globalThis.window.location.pathname.split('/').filter(segment => segment); + + // Look for the parameter in the path segments + const paramIndex = pathSegments.findIndex(segment => segment === param); + + if (paramIndex !== -1 && paramIndex + 1 < pathSegments.length) { + return pathSegments[paramIndex + 1]; + } + } + + return undefined; +}; + +/** + * Derives organization info from URL query parameter + */ +const deriveFromUrlQuery = (param: string): string | undefined => { + if (typeof globalThis !== 'undefined' && typeof globalThis.window !== 'undefined') { + const urlParams = new URLSearchParams(globalThis.window.location.search); + return urlParams.get(param) || undefined; + } + + return undefined; +}; + +/** + * Derives organization info from subdomain + */ +const deriveFromSubdomain = (): string | undefined => { + if (typeof globalThis !== 'undefined' && typeof globalThis.window !== 'undefined') { + const hostname = globalThis.window.location.hostname; + const parts = hostname.split('.'); + + // Return first part if it's not 'www' and has at least 3 parts (subdomain.domain.tld) + if (parts.length >= 3 && parts[0] !== 'www') { + return parts[0]; + } + } + + return undefined; +}; + +/** + * Resolves organization info based on the discovery strategy + */ +const organizationDiscovery = async ( + strategy: OrganizationDiscoveryStrategy, + baseUrl?: string, +): Promise => { + const resolvedStrategy = strategy || { + type: 'baseUrl', + param: undefined, + resolver: () => null, + }; + + switch (resolvedStrategy?.type) { + case 'baseUrl': + return deriveOrganizationHandleFromBaseUrl(baseUrl); + + case 'urlPath': + return deriveFromUrlPath(resolvedStrategy?.param); + + case 'urlQuery': + return deriveFromUrlQuery(resolvedStrategy?.param); + + case 'subdomain': + return deriveFromSubdomain(); + + case 'custom': + return await resolvedStrategy?.resolver(); + + default: + return undefined; + } +}; + +export default organizationDiscovery; From 800a1671fab8bf674d9fb5faf57d0123f802a560 Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 12 Aug 2025 10:55:56 +0530 Subject: [PATCH 2/7] feat(react): enhance AsgardeoReactClient initialization and improve AsgardeoProvider state management --- packages/react/src/AsgardeoReactClient.ts | 27 ++++++++++---- .../contexts/Asgardeo/AsgardeoProvider.tsx | 36 +++++++++++++++++-- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 28f2d342..dacc25de 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -35,13 +35,13 @@ import { Organization, IdToken, EmbeddedFlowExecuteRequestConfig, - deriveOrganizationHandleFromBaseUrl, AllOrganizationsApiResponse, extractUserClaimsFromIdToken, TokenResponse, HttpRequestConfig, HttpResponse, Storage, + organizationDiscovery, } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; import getMeOrganizations from './api/getMeOrganizations'; @@ -90,11 +90,18 @@ class AsgardeoReactClient e } } - override initialize(config: AsgardeoReactConfig, storage?: Storage): Promise { + override async initialize(config: AsgardeoReactConfig, storage?: Storage): Promise { let resolvedOrganizationHandle: string | undefined = config?.organizationHandle; if (!resolvedOrganizationHandle) { - resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(config?.baseUrl); + try { + resolvedOrganizationHandle = await organizationDiscovery( + config?.organizationDiscovery?.strategy, + config?.baseUrl, + ); + } catch (e) { + // TODO: Add a debug log here. + } } return this.withLoading(async () => { @@ -251,15 +258,21 @@ class AsgardeoReactClient e } override isLoading(): boolean { - return this._isLoading || this.asgardeo.isLoading(); + const clientLoading: boolean = this._isLoading; + const asgardeoLoading: boolean = this.asgardeo.isLoading() ?? false; + const totalLoading: boolean = clientLoading || asgardeoLoading; + + return totalLoading; } async isInitialized(): Promise { - return this.asgardeo.isInitialized(); + const result: boolean = await this.asgardeo.isInitialized(); + return result ?? false; } - override isSignedIn(): Promise { - return this.asgardeo.isSignedIn(); + override async isSignedIn(): Promise { + const result: boolean = await this.asgardeo.isSignedIn(); + return result ?? false; } override getConfiguration(): T { diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index 07038768..db37dc80 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -105,6 +105,18 @@ const AsgardeoProvider: FC> = ({ }, [_baseUrl, baseUrl]); useEffect(() => { + // React 18.x Strict.Mode has a new check for `Ensuring reusable state` to facilitate an upcoming react feature. + // https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state + // This will remount all the useEffects to ensure that there are no unexpected side effects. + // When react remounts the signIn hook of the AuthProvider, it will cause a race condition. Hence, we have to + // prevent the re-render of this hook as suggested in the following discussion. + // https://github.com/reactwg/react-18/discussions/18#discussioncomment-795623 + if (reRenderCheckRef.current) { + return; + } + + reRenderCheckRef.current = true; + (async (): Promise => { await asgardeo.initialize(config); setConfig(await asgardeo.getConfiguration()); @@ -191,15 +203,35 @@ const AsgardeoProvider: FC> = ({ }, [asgardeo]); useEffect(() => { - (async (): Promise => { + let interval: NodeJS.Timeout; + + const checkInitializedState = async (): Promise => { try { const status: boolean = await asgardeo.isInitialized(); + console.log('[AsgardeoProvider] isInitialized status:', status); setIsInitializedSync(status); + + // If initialized, clear the interval + if (status && interval) { + clearInterval(interval); + } } catch (error) { setIsInitializedSync(false); } - })(); + }; + + // Initial check + checkInitializedState(); + + // Set up an interval to check for initialization status changes + interval = setInterval(checkInitializedState, 100); + + return (): void => { + if (interval) { + clearInterval(interval); + } + }; }, [asgardeo]); /** From 48a7993a2a7cacca266164e397d2b4a44f4227a8 Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 12 Aug 2025 12:27:53 +0530 Subject: [PATCH 3/7] fix(react): remove unnecessary console log and update branding config to include organization handle --- .../src/contexts/Asgardeo/AsgardeoProvider.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index db37dc80..3c3e8d60 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -208,7 +208,6 @@ const AsgardeoProvider: FC> = ({ const checkInitializedState = async (): Promise => { try { const status: boolean = await asgardeo.isInitialized(); - console.log('[AsgardeoProvider] isInitialized status:', status); setIsInitializedSync(status); @@ -293,7 +292,7 @@ const AsgardeoProvider: FC> = ({ const getBrandingConfig: GetBrandingPreferenceConfig = { baseUrl, locale: preferences?.i18n?.language, - // Add other branding config options as needed + name: config?.organizationHandle, }; const brandingData = await getBrandingPreference(getBrandingConfig); @@ -307,7 +306,7 @@ const AsgardeoProvider: FC> = ({ } finally { setIsBrandingLoading(false); } - }, [baseUrl, preferences?.i18n?.language]); + }, [baseUrl, preferences?.i18n?.language, config?.organizationHandle]); // Refetch branding function const refetchBranding = useCallback(async (): Promise => { @@ -320,7 +319,13 @@ const AsgardeoProvider: FC> = ({ // Enable branding by default or when explicitly enabled const shouldFetchBranding = preferences?.theme?.inheritFromBranding !== false; - if (shouldFetchBranding && isInitializedSync && baseUrl && !hasFetchedBranding && !isBrandingLoading) { + if ( + shouldFetchBranding && + isInitializedSync && + baseUrl && + !hasFetchedBranding && + !isBrandingLoading + ) { fetchBranding(); } }, [ @@ -330,6 +335,7 @@ const AsgardeoProvider: FC> = ({ hasFetchedBranding, isBrandingLoading, fetchBranding, + config?.organizationHandle, ]); const signIn = async (...args: any): Promise => { @@ -435,7 +441,7 @@ const AsgardeoProvider: FC> = ({ }), [ applicationId, - config?.organizationHandle, + organizationHandle, signInUrl, signUpUrl, afterSignInUrl, From 662b352a96435af5adee32bc4f1229d931299be4 Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 12 Aug 2025 13:52:59 +0530 Subject: [PATCH 4/7] feat(react): enhance organization discovery strategy and update base URL transformation logic --- packages/javascript/src/models/config.ts | 17 ++++++++--------- .../src/utils/organizationDiscovery.ts | 19 ++++--------------- packages/react/src/AsgardeoReactClient.ts | 17 ++++++++++------- .../contexts/Asgardeo/AsgardeoProvider.tsx | 18 ++++++++++-------- 4 files changed, 32 insertions(+), 39 deletions(-) diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 9b544c00..4533e92a 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -58,15 +58,7 @@ export type SignUpOptions = Record; * The default strategy is 'baseUrl', which derives the organization handle from the baseUrl. */ export type OrganizationDiscoveryStrategy = - | { - /** - * Discover organization info from the baseUrl. - * This is the default strategy if no other is specified. - */ - type: 'baseUrl'; - mode?: 'handle'; // Only handle is supported for baseUrl - } - | { + { /** * Discover organization info from a URL path parameter. * Example: { type: 'urlPath', mode: 'id', param: 't' } for /app/:t/dashboard @@ -118,6 +110,13 @@ export type OrganizationDiscoveryStrategy = * } */ export interface OrganizationDiscovery { + /** + * Flag to enable organization discovery. + */ + enabled: boolean; + /** + * Strategy for discovering the organization handle or ID. + */ strategy?: OrganizationDiscoveryStrategy; } diff --git a/packages/javascript/src/utils/organizationDiscovery.ts b/packages/javascript/src/utils/organizationDiscovery.ts index 8eaebf9e..56b9804d 100644 --- a/packages/javascript/src/utils/organizationDiscovery.ts +++ b/packages/javascript/src/utils/organizationDiscovery.ts @@ -17,7 +17,6 @@ */ import {OrganizationDiscoveryStrategy} from '../models/config'; -import deriveOrganizationHandleFromBaseUrl from './deriveOrganizationHandleFromBaseUrl'; /** * Derives organization info from URL path parameter @@ -72,29 +71,19 @@ const deriveFromSubdomain = (): string | undefined => { */ const organizationDiscovery = async ( strategy: OrganizationDiscoveryStrategy, - baseUrl?: string, ): Promise => { - const resolvedStrategy = strategy || { - type: 'baseUrl', - param: undefined, - resolver: () => null, - }; - - switch (resolvedStrategy?.type) { - case 'baseUrl': - return deriveOrganizationHandleFromBaseUrl(baseUrl); - + switch (strategy?.type) { case 'urlPath': - return deriveFromUrlPath(resolvedStrategy?.param); + return deriveFromUrlPath(strategy?.param); case 'urlQuery': - return deriveFromUrlQuery(resolvedStrategy?.param); + return deriveFromUrlQuery(strategy?.param); case 'subdomain': return deriveFromSubdomain(); case 'custom': - return await resolvedStrategy?.resolver(); + return await strategy?.resolver(); default: return undefined; diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index dacc25de..66a143dc 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -42,6 +42,7 @@ import { HttpResponse, Storage, organizationDiscovery, + deriveOrganizationHandleFromBaseUrl, } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; import getMeOrganizations from './api/getMeOrganizations'; @@ -94,13 +95,15 @@ class AsgardeoReactClient e let resolvedOrganizationHandle: string | undefined = config?.organizationHandle; if (!resolvedOrganizationHandle) { - try { - resolvedOrganizationHandle = await organizationDiscovery( - config?.organizationDiscovery?.strategy, - config?.baseUrl, - ); - } catch (e) { - // TODO: Add a debug log here. + if (config?.organizationDiscovery?.enabled && config?.organizationDiscovery?.strategy) { + try { + resolvedOrganizationHandle = await organizationDiscovery(config?.organizationDiscovery?.strategy); + } catch (e) { + // TODO: Add a debug log here. + resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(config?.baseUrl); + } + } else { + resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(config?.baseUrl); } } diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index 3c3e8d60..6c0286de 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -289,8 +289,16 @@ const AsgardeoProvider: FC> = ({ setBrandingError(null); try { + // Transform base URL if organization discovery is enabled + let transformedBaseUrl = baseUrl; + + if (config?.organizationDiscovery && config.organizationDiscovery.enabled && config?.organizationHandle) { + // Transform from https://localhost:9443/t/{tenant} to https://localhost:9443/o/{orgHandle} + transformedBaseUrl = baseUrl.replace(/\/t\/[^\/]+/, `/o/${config.organizationHandle}`); + } + const getBrandingConfig: GetBrandingPreferenceConfig = { - baseUrl, + baseUrl: transformedBaseUrl, locale: preferences?.i18n?.language, name: config?.organizationHandle, }; @@ -319,13 +327,7 @@ const AsgardeoProvider: FC> = ({ // Enable branding by default or when explicitly enabled const shouldFetchBranding = preferences?.theme?.inheritFromBranding !== false; - if ( - shouldFetchBranding && - isInitializedSync && - baseUrl && - !hasFetchedBranding && - !isBrandingLoading - ) { + if (shouldFetchBranding && isInitializedSync && baseUrl && !hasFetchedBranding && !isBrandingLoading) { fetchBranding(); } }, [ From 26fc308b5188e18b098e68b97d6998c1984ffa42 Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 12 Aug 2025 16:40:32 +0530 Subject: [PATCH 5/7] chore: resolve orghandle and rootorg handle separately --- .../src/constants/OIDCRequestConstants.ts | 4 + packages/javascript/src/index.ts | 2 +- packages/javascript/src/models/config.ts | 15 ++- ...RootOrganizationHandleFromBaseUrl.test.ts} | 60 +++++------ ...eriveRootOrganizationHandleFromBaseUrl.ts} | 30 +++--- packages/nextjs/src/AsgardeoNextClient.ts | 20 +++- .../contexts/Asgardeo/AsgardeoContext.ts | 2 +- .../contexts/Asgardeo/AsgardeoProvider.tsx | 15 ++- .../nextjs/src/server/AsgardeoProvider.tsx | 3 +- .../src/utils/decorateConfigWithNextEnv.ts | 16 ++- packages/react/src/AsgardeoReactClient.ts | 16 ++- .../actions/SignInButton/SignInButton.tsx | 100 +++++++++++------- .../src/contexts/Asgardeo/AsgardeoContext.ts | 26 ++++- .../contexts/Asgardeo/AsgardeoProvider.tsx | 54 +++++----- .../src/contexts/Theme/ThemeProvider.tsx | 2 +- samples/teamspace-react/src/main.tsx | 8 ++ 16 files changed, 245 insertions(+), 128 deletions(-) rename packages/javascript/src/utils/__tests__/{deriveOrganizationHandleFromBaseUrl.test.ts => deriveRootOrganizationHandleFromBaseUrl.test.ts} (59%) rename packages/javascript/src/utils/{deriveOrganizationHandleFromBaseUrl.ts => deriveRootOrganizationHandleFromBaseUrl.ts} (69%) diff --git a/packages/javascript/src/constants/OIDCRequestConstants.ts b/packages/javascript/src/constants/OIDCRequestConstants.ts index edb91369..96a4f43c 100644 --- a/packages/javascript/src/constants/OIDCRequestConstants.ts +++ b/packages/javascript/src/constants/OIDCRequestConstants.ts @@ -60,6 +60,10 @@ const OIDCRequestConstants = { * The default scopes used in OIDC sign-in requests. */ DEFAULT_SCOPES: [ScopeConstants.OPENID, ScopeConstants.PROFILE, ScopeConstants.INTERNAL_LOGIN], + /** + * The Authenticator used for organization SSO in OIDC sign-in requests. + */ + ORGANIZATION_SSO_AUTHENTICATOR: 'OrganizationSSO' }, }, diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 438fa37c..bdb4c55c 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -128,7 +128,7 @@ export {default as bem} from './utils/bem'; export {default as formatDate} from './utils/formatDate'; export {default as processUsername} from './utils/processUsername'; export {default as deepMerge} from './utils/deepMerge'; -export {default as deriveOrganizationHandleFromBaseUrl} from './utils/deriveOrganizationHandleFromBaseUrl'; +export {default as deriveRootOrganizationHandleFromBaseUrl} from './utils/deriveRootOrganizationHandleFromBaseUrl'; export {default as extractUserClaimsFromIdToken} from './utils/extractUserClaimsFromIdToken'; export {default as extractPkceStorageKeyFromState} from './utils/extractPkceStorageKeyFromState'; export {default as flattenUserSchema} from './utils/flattenUserSchema'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 4533e92a..7bdc0ba2 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -145,11 +145,20 @@ export interface BaseConfig extends WithPreferences { afterSignOutUrl?: string | undefined; /** - * Optional organization handle for the Organization in Asgardeo. - * This is used to identify the organization in the Asgardeo identity server in cases like Branding, etc. - * If not provided, the framework layer will try to use the `baseUrl` to determine the organization handle. + * Optional root organization handle for the main Organization in Asgardeo. + * This represents the top-level organization and is used for root-level operations. + * If not provided, the framework layer will try to use the `baseUrl` to determine the root organization handle. * @remarks This is mandatory if a custom domain is configured for the Asgardeo organization. */ + rootOrganizationHandle?: string | undefined; + + /** + * Optional organization handle for B2B scenarios in Asgardeo. + * This is used to identify the specific sub-organization in B2B use cases. + * In B2B scenarios, this typically represents the customer's organization while + * rootOrganizationHandle represents your main organization. + * @remarks This is used in conjunction with rootOrganizationHandle for B2B flows. + */ organizationHandle?: string | undefined; /** diff --git a/packages/javascript/src/utils/__tests__/deriveOrganizationHandleFromBaseUrl.test.ts b/packages/javascript/src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts similarity index 59% rename from packages/javascript/src/utils/__tests__/deriveOrganizationHandleFromBaseUrl.test.ts rename to packages/javascript/src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts index 8b3dda78..a879faca 100644 --- a/packages/javascript/src/utils/__tests__/deriveOrganizationHandleFromBaseUrl.test.ts +++ b/packages/javascript/src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts @@ -16,82 +16,82 @@ * under the License. */ -import deriveOrganizationHandleFromBaseUrl from '../deriveOrganizationHandleFromBaseUrl'; +import deriveRootOrganizationHandleFromBaseUrl from '../deriveRootOrganizationHandleFromBaseUrl'; import AsgardeoRuntimeError from '../../errors/AsgardeoRuntimeError'; -describe('deriveOrganizationHandleFromBaseUrl', () => { +describe('deriveRootOrganizationHandleFromBaseUrl', () => { describe('Valid Asgardeo URLs', () => { it('should extract organization handle from dev.asgardeo.io URL', () => { - const result = deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab'); + const result = deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab'); expect(result).toBe('dxlab'); }); it('should extract organization handle from stage.asgardeo.io URL', () => { - const result = deriveOrganizationHandleFromBaseUrl('https://stage.asgardeo.io/t/dxlab'); + const result = deriveRootOrganizationHandleFromBaseUrl('https://stage.asgardeo.io/t/dxlab'); expect(result).toBe('dxlab'); }); it('should extract organization handle from prod.asgardeo.io URL', () => { - const result = deriveOrganizationHandleFromBaseUrl('https://prod.asgardeo.io/t/dxlab'); + const result = deriveRootOrganizationHandleFromBaseUrl('https://prod.asgardeo.io/t/dxlab'); expect(result).toBe('dxlab'); }); it('should extract organization handle from custom subdomain asgardeo.io URL', () => { - const result = deriveOrganizationHandleFromBaseUrl('https://xxx.asgardeo.io/t/dxlab'); + const result = deriveRootOrganizationHandleFromBaseUrl('https://xxx.asgardeo.io/t/dxlab'); expect(result).toBe('dxlab'); }); it('should extract organization handle with trailing slash', () => { - const result = deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab/'); + const result = deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab/'); expect(result).toBe('dxlab'); }); it('should extract organization handle with additional path segments', () => { - const result = deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab/api/v1'); + const result = deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab/api/v1'); expect(result).toBe('dxlab'); }); it('should handle different organization handles', () => { - expect(deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/myorg')).toBe('myorg'); - expect(deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/test-org')).toBe('test-org'); - expect(deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/org123')).toBe('org123'); + expect(deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/myorg')).toBe('myorg'); + expect(deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/test-org')).toBe('test-org'); + expect(deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/org123')).toBe('org123'); }); }); describe('Invalid URLs - Custom Domains', () => { it('should throw error for custom domain without asgardeo.io', () => { expect(() => { - deriveOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); + deriveRootOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); }).toThrow(AsgardeoRuntimeError); expect(() => { - deriveOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); + deriveRootOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); }).toThrow('Organization handle is required since a custom domain is configured.'); }); it('should throw error for URLs without /t/ pattern', () => { expect(() => { - deriveOrganizationHandleFromBaseUrl('https://auth.asgardeo.io/oauth2/token'); + deriveRootOrganizationHandleFromBaseUrl('https://auth.asgardeo.io/oauth2/token'); }).toThrow(AsgardeoRuntimeError); expect(() => { - deriveOrganizationHandleFromBaseUrl('https://auth.asgardeo.io/oauth2/token'); + deriveRootOrganizationHandleFromBaseUrl('https://auth.asgardeo.io/oauth2/token'); }).toThrow('Organization handle is required since a custom domain is configured.'); }); it('should throw error for URLs with malformed /t/ pattern', () => { expect(() => { - deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/'); + deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/'); }).toThrow(AsgardeoRuntimeError); expect(() => { - deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t'); + deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t'); }).toThrow(AsgardeoRuntimeError); }); it('should throw error for URLs with empty organization handle', () => { expect(() => { - deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t//'); + deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t//'); }).toThrow(AsgardeoRuntimeError); }); }); @@ -99,31 +99,31 @@ describe('deriveOrganizationHandleFromBaseUrl', () => { describe('Invalid Input', () => { it('should throw error for undefined baseUrl', () => { expect(() => { - deriveOrganizationHandleFromBaseUrl(undefined); + deriveRootOrganizationHandleFromBaseUrl(undefined); }).toThrow(AsgardeoRuntimeError); expect(() => { - deriveOrganizationHandleFromBaseUrl(undefined); + deriveRootOrganizationHandleFromBaseUrl(undefined); }).toThrow('Base URL is required to derive organization handle.'); }); it('should throw error for empty baseUrl', () => { expect(() => { - deriveOrganizationHandleFromBaseUrl(''); + deriveRootOrganizationHandleFromBaseUrl(''); }).toThrow(AsgardeoRuntimeError); expect(() => { - deriveOrganizationHandleFromBaseUrl(''); + deriveRootOrganizationHandleFromBaseUrl(''); }).toThrow('Base URL is required to derive organization handle.'); }); it('should throw error for invalid URL format', () => { expect(() => { - deriveOrganizationHandleFromBaseUrl('not-a-valid-url'); + deriveRootOrganizationHandleFromBaseUrl('not-a-valid-url'); }).toThrow(AsgardeoRuntimeError); expect(() => { - deriveOrganizationHandleFromBaseUrl('not-a-valid-url'); + deriveRootOrganizationHandleFromBaseUrl('not-a-valid-url'); }).toThrow('Invalid base URL format'); }); }); @@ -131,26 +131,26 @@ describe('deriveOrganizationHandleFromBaseUrl', () => { describe('Error Details', () => { it('should throw AsgardeoRuntimeError with correct error codes', () => { try { - deriveOrganizationHandleFromBaseUrl(undefined); + deriveRootOrganizationHandleFromBaseUrl(undefined); } catch (error) { expect(error).toBeInstanceOf(AsgardeoRuntimeError); - expect(error.code).toBe('javascript-deriveOrganizationHandleFromBaseUrl-ValidationError-001'); + expect(error.code).toBe('javascript-deriveRootOrganizationHandleFromBaseUrl-ValidationError-001'); expect(error.origin).toBe('javascript'); } try { - deriveOrganizationHandleFromBaseUrl('invalid-url'); + deriveRootOrganizationHandleFromBaseUrl('invalid-url'); } catch (error) { expect(error).toBeInstanceOf(AsgardeoRuntimeError); - expect(error.code).toBe('javascript-deriveOrganizationHandleFromBaseUrl-ValidationError-002'); + expect(error.code).toBe('javascript-deriveRootOrganizationHandleFromBaseUrl-ValidationError-002'); expect(error.origin).toBe('javascript'); } try { - deriveOrganizationHandleFromBaseUrl('https://custom.domain.com/auth'); + deriveRootOrganizationHandleFromBaseUrl('https://custom.domain.com/auth'); } catch (error) { expect(error).toBeInstanceOf(AsgardeoRuntimeError); - expect(error.code).toBe('javascript-deriveOrganizationHandleFromBaseUrl-CustomDomainError-001'); + expect(error.code).toBe('javascript-deriveRootOrganizationHandleFromBaseUrl-CustomDomainError-001'); expect(error.origin).toBe('javascript'); } }); diff --git a/packages/javascript/src/utils/deriveOrganizationHandleFromBaseUrl.ts b/packages/javascript/src/utils/deriveRootOrganizationHandleFromBaseUrl.ts similarity index 69% rename from packages/javascript/src/utils/deriveOrganizationHandleFromBaseUrl.ts rename to packages/javascript/src/utils/deriveRootOrganizationHandleFromBaseUrl.ts index ca23e9d2..a8d74ee8 100644 --- a/packages/javascript/src/utils/deriveOrganizationHandleFromBaseUrl.ts +++ b/packages/javascript/src/utils/deriveRootOrganizationHandleFromBaseUrl.ts @@ -30,27 +30,27 @@ import AsgardeoRuntimeError from '../errors/AsgardeoRuntimeError'; * @param baseUrl - The base URL of the Asgardeo identity server * @returns The extracted organization handle * @throws {AsgardeoRuntimeError} When the URL doesn't match the expected Asgardeo pattern, - * indicating a custom domain is configured and organizationHandle must be provided explicitly + * indicating a custom domain is configured and rootOrganizationHandle must be provided explicitly * * @example * ```typescript * // Standard Asgardeo URLs - * const handle1 = deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab'); + * const handle1 = deriveRootOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab'); * // Returns: 'dxlab' * - * const handle2 = deriveOrganizationHandleFromBaseUrl('https://stage.asgardeo.io/t/myorg'); + * const handle2 = deriveRootOrganizationHandleFromBaseUrl('https://stage.asgardeo.io/t/myorg'); * // Returns: 'myorg' * * // Custom domain - throws error - * deriveOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); + * deriveRootOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); * // Throws: AsgardeoRuntimeError * ``` */ -const deriveOrganizationHandleFromBaseUrl = (baseUrl?: string): string => { +const deriveRootOrganizationHandleFromBaseUrl = (baseUrl?: string): string => { if (!baseUrl) { throw new AsgardeoRuntimeError( 'Base URL is required to derive organization handle.', - 'javascript-deriveOrganizationHandleFromBaseUrl-ValidationError-001', + 'javascript-deriveRootOrganizationHandleFromBaseUrl-ValidationError-001', 'javascript', 'A valid base URL must be provided to extract the organization handle.', ); @@ -63,7 +63,7 @@ const deriveOrganizationHandleFromBaseUrl = (baseUrl?: string): string => { } catch (error) { throw new AsgardeoRuntimeError( `Invalid base URL format: ${baseUrl}`, - 'javascript-deriveOrganizationHandleFromBaseUrl-ValidationError-002', + 'javascript-deriveRootOrganizationHandleFromBaseUrl-ValidationError-002', 'javascript', 'The provided base URL does not conform to valid URL syntax.', ); @@ -76,31 +76,31 @@ const deriveOrganizationHandleFromBaseUrl = (baseUrl?: string): string => { console.warn( new AsgardeoRuntimeError( 'Organization handle is required since a custom domain is configured.', - 'javascript-deriveOrganizationHandleFromBaseUrl-CustomDomainError-002', + 'javascript-deriveRootOrganizationHandleFromBaseUrl-CustomDomainError-002', 'javascript', - 'The provided base URL does not follow the expected URL pattern (/t/{orgHandle}). Please provide the organizationHandle explicitly in the configuration.', + 'The provided base URL does not follow the expected URL pattern (/t/{orgHandle}). Please provide the rootOrganizationHandle explicitly in the configuration.', ).toString(), ); return ''; } - const organizationHandle = pathSegments[1]; + const rootOrganizationHandle = pathSegments[1]; - if (!organizationHandle || organizationHandle.trim().length === 0) { + if (!rootOrganizationHandle || rootOrganizationHandle.trim().length === 0) { console.warn( new AsgardeoRuntimeError( 'Organization handle is required since a custom domain is configured.', - 'javascript-deriveOrganizationHandleFromBaseUrl-CustomDomainError-003', + 'javascript-deriveRootOrganizationHandleFromBaseUrl-CustomDomainError-003', 'javascript', - 'The organization handle could not be extracted from the base URL. Please provide the organizationHandle explicitly in the configuration.', + 'The organization handle could not be extracted from the base URL. Please provide the rootOrganizationHandle explicitly in the configuration.', ).toString(), ); return ''; } - return organizationHandle; + return rootOrganizationHandle; }; -export default deriveOrganizationHandleFromBaseUrl; +export default deriveRootOrganizationHandleFromBaseUrl; diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 6adcde8d..b85e8a6f 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -46,12 +46,14 @@ import { CreateOrganizationPayload, getOrganization, OrganizationDetails, - deriveOrganizationHandleFromBaseUrl, getAllOrganizations, AllOrganizationsApiResponse, extractUserClaimsFromIdToken, TokenResponse, Storage, + organizationDiscovery, + OrganizationDiscoveryStrategy, + deriveRootOrganizationHandleFromBaseUrl } from '@asgardeo/node'; import {AsgardeoNextConfig} from './models/config'; import getSessionId from './server/actions/getSessionId'; @@ -108,21 +110,34 @@ class AsgardeoNextClient exte const { baseUrl, organizationHandle, + rootOrganizationHandle, clientId, clientSecret, signInUrl, afterSignInUrl, afterSignOutUrl, signUpUrl, + organizationDiscovery: _organizationDiscovery, ...rest } = decorateConfigWithNextEnv(config); this.isInitialized = true; let resolvedOrganizationHandle: string | undefined = organizationHandle; + let resolvedRootOrganizationHandle: string | undefined = rootOrganizationHandle; if (!resolvedOrganizationHandle) { - resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(baseUrl); + if (_organizationDiscovery?.enabled && _organizationDiscovery?.strategy) { + try { + resolvedOrganizationHandle = await organizationDiscovery(_organizationDiscovery?.strategy); + } catch (e) { + // TODO: Add a debug log here. + } + } + } + + if (!resolvedRootOrganizationHandle) { + resolvedRootOrganizationHandle = deriveRootOrganizationHandleFromBaseUrl(config?.baseUrl); } const origin: string = await getClientOrigin(); @@ -130,6 +145,7 @@ class AsgardeoNextClient exte return this.asgardeo.initialize( { organizationHandle: resolvedOrganizationHandle, + rootOrganizationHandle: resolvedRootOrganizationHandle, baseUrl, clientId, clientSecret, diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts index a0658be0..02a1053b 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts @@ -19,7 +19,6 @@ 'use client'; import {AsgardeoContextProps as AsgardeoReactContextProps} from '@asgardeo/react'; -import {EmbeddedFlowExecuteRequestConfig, EmbeddedSignInFlowHandleRequestPayload, User} from '@asgardeo/node'; import {Context, createContext} from 'react'; /** @@ -31,6 +30,7 @@ export type AsgardeoContextProps = Partial; * Context object for managing the Authentication flow builder core context. */ const AsgardeoContext: Context = createContext({ + rootOrganizationHandle: undefined, organizationHandle: undefined, applicationId: undefined, signInUrl: undefined, diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index 2946ea7a..1b2279ea 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -63,6 +63,7 @@ export type AsgardeoClientProviderProps = Partial Promise<{error?: string; redirectUrl?: string; success: boolean}>; isSignedIn: boolean; myOrganizations: Organization[]; + rootOrganizationHandle: AsgardeoContextProps['rootOrganizationHandle']; organizationHandle: AsgardeoContextProps['organizationHandle']; revalidateMyOrganizations?: (sessionId?: string) => Promise; signIn: AsgardeoContextProps['signIn']; @@ -94,6 +95,7 @@ const AsgardeoClientProvider: FC> currentOrganization, updateProfile, applicationId, + rootOrganizationHandle, organizationHandle, myOrganizations, revalidateMyOrganizations, @@ -293,8 +295,19 @@ const AsgardeoClientProvider: FC> signUpUrl, applicationId, organizationHandle, + rootOrganizationHandle, }), - [baseUrl, user, isSignedIn, isLoading, signInUrl, signUpUrl, applicationId, organizationHandle], + [ + baseUrl, + user, + isSignedIn, + isLoading, + signInUrl, + signUpUrl, + applicationId, + organizationHandle, + rootOrganizationHandle, + ], ); const handleProfileUpdate = (payload: User): void => { diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index b053fa72..da379428 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -161,7 +161,7 @@ const AsgardeoServerProvider: FC> { baseUrl: config?.baseUrl as string, locale: 'en-US', - name: config.applicationId || config.organizationHandle, + name: config.applicationId || config.organizationHandle || config.rootOrganizationHandle, type: config.applicationId ? 'APP' : 'ORG', }, sessionId, @@ -173,6 +173,7 @@ const AsgardeoServerProvider: FC> return ( { - const {organizationHandle, scopes, applicationId, baseUrl, clientId, clientSecret, signInUrl, signUpUrl, afterSignInUrl, afterSignOutUrl, ...rest} = config; + const { + organizationHandle, + rootOrganizationHandle, + scopes, + applicationId, + baseUrl, + clientId, + clientSecret, + signInUrl, + signUpUrl, + afterSignInUrl, + afterSignOutUrl, + ...rest + } = config; return { ...rest, scopes: scopes || (process.env['NEXT_PUBLIC_ASGARDEO_SCOPES'] as string), organizationHandle: organizationHandle || (process.env['NEXT_PUBLIC_ASGARDEO_ORGANIZATION_HANDLE'] as string), + rootOrganizationHandle: rootOrganizationHandle || (process.env['NEXT_PUBLIC_ASGARDEO_ROOT_ORGANIZATION_HANDLE'] as string), applicationId: applicationId || (process.env['NEXT_PUBLIC_ASGARDEO_APPLICATION_ID'] as string), baseUrl: baseUrl || (process.env['NEXT_PUBLIC_ASGARDEO_BASE_URL'] as string), clientId: clientId || (process.env['NEXT_PUBLIC_ASGARDEO_CLIENT_ID'] as string), diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 66a143dc..656ad4dc 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -42,7 +42,7 @@ import { HttpResponse, Storage, organizationDiscovery, - deriveOrganizationHandleFromBaseUrl, + deriveRootOrganizationHandleFromBaseUrl, } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; import getMeOrganizations from './api/getMeOrganizations'; @@ -93,6 +93,7 @@ class AsgardeoReactClient e override async initialize(config: AsgardeoReactConfig, storage?: Storage): Promise { let resolvedOrganizationHandle: string | undefined = config?.organizationHandle; + let resolvedRootOrganizationHandle: string | undefined = config?.rootOrganizationHandle; if (!resolvedOrganizationHandle) { if (config?.organizationDiscovery?.enabled && config?.organizationDiscovery?.strategy) { @@ -100,15 +101,20 @@ class AsgardeoReactClient e resolvedOrganizationHandle = await organizationDiscovery(config?.organizationDiscovery?.strategy); } catch (e) { // TODO: Add a debug log here. - resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(config?.baseUrl); } - } else { - resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(config?.baseUrl); } } + if (!resolvedRootOrganizationHandle) { + resolvedRootOrganizationHandle = deriveRootOrganizationHandleFromBaseUrl(config?.baseUrl); + } + return this.withLoading(async () => { - return this.asgardeo.init({...config, organizationHandle: resolvedOrganizationHandle} as any); + return this.asgardeo.init({ + ...config, + organizationHandle: resolvedOrganizationHandle, + rootOrganizationHandle: resolvedRootOrganizationHandle, + } as any); }); } diff --git a/packages/react/src/components/actions/SignInButton/SignInButton.tsx b/packages/react/src/components/actions/SignInButton/SignInButton.tsx index 18941829..cb3ee9b8 100644 --- a/packages/react/src/components/actions/SignInButton/SignInButton.tsx +++ b/packages/react/src/components/actions/SignInButton/SignInButton.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {AsgardeoRuntimeError} from '@asgardeo/browser'; +import {AsgardeoRuntimeError, OIDCRequestConstants} from '@asgardeo/browser'; import {forwardRef, ForwardRefExoticComponent, MouseEvent, ReactElement, Ref, RefAttributes, useState} from 'react'; import BaseSignInButton, {BaseSignInButtonProps} from './BaseSignInButton'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; @@ -75,53 +75,71 @@ export type SignInButtonProps = BaseSignInButtonProps & { const SignInButton: ForwardRefExoticComponent> = forwardRef< HTMLButtonElement, SignInButtonProps ->(({children, onClick, preferences, signInOptions: overriddenSignInOptions = {}, ...rest}: SignInButtonProps, ref: Ref): ReactElement => { - const {signIn, signInUrl, signInOptions} = useAsgardeo(); - const {t} = useTranslation(preferences?.i18n); +>( + ( + {children, onClick, preferences, signInOptions: overriddenSignInOptions = {}, ...rest}: SignInButtonProps, + ref: Ref, + ): ReactElement => { + const {signIn, signInUrl, signInOptions, organizationDiscovery, organizationHandle} = useAsgardeo(); + const {t} = useTranslation(preferences?.i18n); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); - const handleSignIn = async (e?: MouseEvent): Promise => { - try { - setIsLoading(true); + const handleSignIn = async (e?: MouseEvent): Promise => { + try { + setIsLoading(true); - // If a custom `signInUrl` is provided, use it for navigation. - if (signInUrl) { - window.history.pushState(null, '', signInUrl); + // If a custom `signInUrl` is provided, use it for navigation. + if (signInUrl) { + window.history.pushState(null, '', signInUrl); - window.dispatchEvent(new PopStateEvent('popstate', {state: null})); - } else { - await signIn(overriddenSignInOptions ?? signInOptions); - } + window.dispatchEvent(new PopStateEvent('popstate', {state: null})); + } else { + let finalSignInOptions = overriddenSignInOptions ?? signInOptions; + + // If organization discovery is enabled, inject organization SSO options + if (organizationHandle && organizationDiscovery?.enabled && organizationDiscovery.strategy) { + const baseSignInOptions = signInOptions || {}; + finalSignInOptions = { + ...baseSignInOptions, + fidp: OIDCRequestConstants.SignIn.Payload.ORGANIZATION_SSO_AUTHENTICATOR, + orgId: organizationHandle, + ...overriddenSignInOptions, + }; + } + + await signIn(finalSignInOptions); + } - if (onClick) { - onClick(e); + if (onClick) { + onClick(e); + } + } catch (error) { + throw new AsgardeoRuntimeError( + `Sign in failed: ${error instanceof Error ? error.message : String(error)}`, + 'SignInButton-handleSignIn-RuntimeError-001', + 'react', + 'Something went wrong while trying to sign in. Please try again later.', + ); + } finally { + setIsLoading(false); } - } catch (error) { - throw new AsgardeoRuntimeError( - `Sign in failed: ${error instanceof Error ? error.message : String(error)}`, - 'SignInButton-handleSignIn-RuntimeError-001', - 'react', - 'Something went wrong while trying to sign in. Please try again later.', - ); - } finally { - setIsLoading(false); - } - }; + }; - return ( - - {children ?? t('elements.buttons.signIn')} - - ); -}); + return ( + + {children ?? t('elements.buttons.signIn')} + + ); + }, +); SignInButton.displayName = 'SignInButton'; diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts b/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts index 0f8f6e94..7ae76fac 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts @@ -17,7 +17,14 @@ */ import {Context, createContext} from 'react'; -import {HttpRequestConfig, HttpResponse, IdToken, Organization, SignInOptions} from '@asgardeo/browser'; +import { + HttpRequestConfig, + HttpResponse, + IdToken, + Organization, + OrganizationDiscovery, + SignInOptions, +} from '@asgardeo/browser'; import AsgardeoReactClient from '../../AsgardeoReactClient'; /** @@ -97,6 +104,19 @@ export type AsgardeoContextProps = { * @returns A promise that resolves to the decoded ID token payload. */ getDecodedIdToken?: () => Promise; + /** + * Organization discovery strategy to find the organization based on the provided configuration. + * This is useful in B2B scenarios where the organization is determined dynamically. + * The strategy can be a function that takes the configuration and returns the organization handle. + * If not provided, the root organization handle will be derived from the base URL. + */ + organizationDiscovery?: OrganizationDiscovery; + /** + * Root organization handle to be used in the context. + * This is useful to resolve the root organization handle for fetching organization-specific data. + * If not provided, it will be derived from the base URL. + */ + rootOrganizationHandle?: string | undefined; }; /** @@ -113,6 +133,10 @@ const AsgardeoContext: Context = createContext> = ({ afterSignInUrl = window.location.origin, afterSignOutUrl = window.location.origin, - baseUrl: _baseUrl, + baseUrl: originalBaseUrl, clientId, children, scopes, @@ -55,12 +55,15 @@ const AsgardeoProvider: FC> = ({ signInUrl, signUpUrl, organizationHandle, + rootOrganizationHandle, applicationId, signInOptions, syncSession, + organizationDiscovery, ...rest }: PropsWithChildren): ReactElement => { const reRenderCheckRef: RefObject = useRef(false); + const reInitializeCheckRef: RefObject = useRef(false); const asgardeo: AsgardeoReactClient = useMemo(() => new AsgardeoReactClient(), []); const {hasAuthParams} = useBrowserUrl(); const [user, setUser] = useState(null); @@ -72,10 +75,12 @@ const AsgardeoProvider: FC> = ({ const [myOrganizations, setMyOrganizations] = useState([]); const [userProfile, setUserProfile] = useState(null); - const [baseUrl, setBaseUrl] = useState(_baseUrl); + const [baseUrl, setBaseUrl] = useState(originalBaseUrl); const [config, setConfig] = useState({ applicationId, organizationHandle, + rootOrganizationHandle, + organizationDiscovery, afterSignInUrl, afterSignOutUrl, baseUrl, @@ -95,14 +100,14 @@ const AsgardeoProvider: FC> = ({ const [hasFetchedBranding, setHasFetchedBranding] = useState(false); useEffect(() => { - setBaseUrl(_baseUrl); + setBaseUrl(originalBaseUrl); // Reset branding state when baseUrl changes - if (_baseUrl !== baseUrl) { + if (originalBaseUrl !== baseUrl) { setHasFetchedBranding(false); setBrandingPreference(null); setBrandingError(null); } - }, [_baseUrl, baseUrl]); + }, [originalBaseUrl, baseUrl]); useEffect(() => { // React 18.x Strict.Mode has a new check for `Ensuring reusable state` to facilitate an upcoming react feature. @@ -111,11 +116,11 @@ const AsgardeoProvider: FC> = ({ // When react remounts the signIn hook of the AuthProvider, it will cause a race condition. Hence, we have to // prevent the re-render of this hook as suggested in the following discussion. // https://github.com/reactwg/react-18/discussions/18#discussioncomment-795623 - if (reRenderCheckRef.current) { + if (reInitializeCheckRef.current) { return; } - reRenderCheckRef.current = true; + reInitializeCheckRef.current = true; (async (): Promise => { await asgardeo.initialize(config); @@ -256,17 +261,17 @@ const AsgardeoProvider: FC> = ({ const updateSession = async (): Promise => { try { setIsLoadingSync(true); - let _baseUrl: string = baseUrl; + let originalBaseUrl: string = baseUrl; // If there's a `user_org` claim in the ID token, // Treat this login as a organization login. if ((await asgardeo.getDecodedIdToken())?.['user_org']) { - _baseUrl = `${(await asgardeo.getConfiguration()).baseUrl}/o`; - setBaseUrl(_baseUrl); + originalBaseUrl = `${(await asgardeo.getConfiguration()).baseUrl}/o`; + setBaseUrl(originalBaseUrl); } - setUser(await asgardeo.getUser({baseUrl: _baseUrl})); - setUserProfile(await asgardeo.getUserProfile({baseUrl: _baseUrl})); + setUser(await asgardeo.getUser({baseUrl: originalBaseUrl})); + setUserProfile(await asgardeo.getUserProfile({baseUrl: originalBaseUrl})); setCurrentOrganization(await asgardeo.getCurrentOrganization()); setMyOrganizations(await asgardeo.getMyOrganizations()); } finally { @@ -292,7 +297,7 @@ const AsgardeoProvider: FC> = ({ // Transform base URL if organization discovery is enabled let transformedBaseUrl = baseUrl; - if (config?.organizationDiscovery && config.organizationDiscovery.enabled && config?.organizationHandle) { + if (config?.organizationDiscovery && config?.organizationDiscovery.enabled && config?.organizationHandle) { // Transform from https://localhost:9443/t/{tenant} to https://localhost:9443/o/{orgHandle} transformedBaseUrl = baseUrl.replace(/\/t\/[^\/]+/, `/o/${config.organizationHandle}`); } @@ -300,7 +305,8 @@ const AsgardeoProvider: FC> = ({ const getBrandingConfig: GetBrandingPreferenceConfig = { baseUrl: transformedBaseUrl, locale: preferences?.i18n?.language, - name: config?.organizationHandle, + name: config?.applicationId || config?.organizationHandle || config?.rootOrganizationHandle, + type: config.applicationId ? 'APP' : 'ORG', }; const brandingData = await getBrandingPreference(getBrandingConfig); @@ -314,7 +320,7 @@ const AsgardeoProvider: FC> = ({ } finally { setIsBrandingLoading(false); } - }, [baseUrl, preferences?.i18n?.language, config?.organizationHandle]); + }, [baseUrl, preferences?.i18n?.language, config?.organizationHandle, config?.rootOrganizationHandle]); // Refetch branding function const refetchBranding = useCallback(async (): Promise => { @@ -338,6 +344,7 @@ const AsgardeoProvider: FC> = ({ isBrandingLoading, fetchBranding, config?.organizationHandle, + config?.rootOrganizationHandle, ]); const signIn = async (...args: any): Promise => { @@ -417,17 +424,19 @@ const AsgardeoProvider: FC> = ({ const value = useMemo( () => ({ - applicationId, + applicationId: config?.applicationId, + rootOrganizationHandle: config?.rootOrganizationHandle, organizationHandle: config?.organizationHandle, - signInUrl, - signUpUrl, - afterSignInUrl, + signInUrl: config?.signInUrl, + signUpUrl: config?.signUpUrl, + afterSignInUrl: config?.afterSignInUrl, baseUrl, getAccessToken: asgardeo.getAccessToken.bind(asgardeo), isInitialized: isInitializedSync, isLoading: isLoadingSync, isSignedIn: isSignedInSync, organization: currentOrganization, + organizationDiscovery: config?.organizationDiscovery, signIn, signInSilently, signOut: asgardeo.signOut.bind(asgardeo), @@ -442,12 +451,7 @@ const AsgardeoProvider: FC> = ({ syncSession, }), [ - applicationId, - organizationHandle, - signInUrl, - signUpUrl, - afterSignInUrl, - baseUrl, + config, isInitializedSync, isLoadingSync, isSignedInSync, diff --git a/packages/react/src/contexts/Theme/ThemeProvider.tsx b/packages/react/src/contexts/Theme/ThemeProvider.tsx index 8494dcc1..261bdbdc 100644 --- a/packages/react/src/contexts/Theme/ThemeProvider.tsx +++ b/packages/react/src/contexts/Theme/ThemeProvider.tsx @@ -109,7 +109,7 @@ const applyThemeToDOM = (theme: Theme) => { const ThemeProvider: FC> = ({ children, theme: themeConfig, - mode = 'system', + mode = 'light', detection = {}, inheritFromBranding = true, }: PropsWithChildren): ReactElement => { diff --git a/samples/teamspace-react/src/main.tsx b/samples/teamspace-react/src/main.tsx index 0c156edb..d43242cd 100644 --- a/samples/teamspace-react/src/main.tsx +++ b/samples/teamspace-react/src/main.tsx @@ -45,6 +45,14 @@ createRoot(document.getElementById('root')!).render( // }, }, }} + organizationDiscovery={{ + enabled: true, + strategy: { + type: 'urlQuery', + param: 'o', + mode: 'id', + }, + }} > From 1d2e53f6fca1e4f8da91470269b36cf3fedeb844 Mon Sep 17 00:00:00 2001 From: Brion Date: Thu, 14 Aug 2025 01:27:35 +0530 Subject: [PATCH 6/7] chore: streamline organization discovery and improve AsgardeoReactClient logic --- packages/javascript/src/models/config.ts | 4 +- .../src/utils/organizationDiscovery.ts | 6 +-- packages/react/src/AsgardeoReactClient.ts | 38 ++++++++----------- .../primitives/Dialog/Dialog.styles.ts | 13 ------- 4 files changed, 21 insertions(+), 40 deletions(-) diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 7bdc0ba2..09a182b5 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -91,7 +91,7 @@ export type OrganizationDiscoveryStrategy = */ type: 'custom'; mode: 'id' | 'handle'; - resolver: () => string | Promise; + resolver: () => string; }; /** @@ -155,7 +155,7 @@ export interface BaseConfig extends WithPreferences { /** * Optional organization handle for B2B scenarios in Asgardeo. * This is used to identify the specific sub-organization in B2B use cases. - * In B2B scenarios, this typically represents the customer's organization while + * In B2B scenarios, this typically represents the customer's organization while * rootOrganizationHandle represents your main organization. * @remarks This is used in conjunction with rootOrganizationHandle for B2B flows. */ diff --git a/packages/javascript/src/utils/organizationDiscovery.ts b/packages/javascript/src/utils/organizationDiscovery.ts index 56b9804d..0f0cbfee 100644 --- a/packages/javascript/src/utils/organizationDiscovery.ts +++ b/packages/javascript/src/utils/organizationDiscovery.ts @@ -69,9 +69,9 @@ const deriveFromSubdomain = (): string | undefined => { /** * Resolves organization info based on the discovery strategy */ -const organizationDiscovery = async ( +const organizationDiscovery = ( strategy: OrganizationDiscoveryStrategy, -): Promise => { +): string => { switch (strategy?.type) { case 'urlPath': return deriveFromUrlPath(strategy?.param); @@ -83,7 +83,7 @@ const organizationDiscovery = async ( return deriveFromSubdomain(); case 'custom': - return await strategy?.resolver(); + return strategy?.resolver(); default: return undefined; diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 656ad4dc..e5322e50 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -95,21 +95,21 @@ class AsgardeoReactClient e let resolvedOrganizationHandle: string | undefined = config?.organizationHandle; let resolvedRootOrganizationHandle: string | undefined = config?.rootOrganizationHandle; - if (!resolvedOrganizationHandle) { - if (config?.organizationDiscovery?.enabled && config?.organizationDiscovery?.strategy) { - try { - resolvedOrganizationHandle = await organizationDiscovery(config?.organizationDiscovery?.strategy); - } catch (e) { - // TODO: Add a debug log here. + return this.withLoading(async () => { + if (!resolvedOrganizationHandle) { + if (config?.organizationDiscovery?.enabled && config?.organizationDiscovery?.strategy) { + try { + resolvedOrganizationHandle = organizationDiscovery(config?.organizationDiscovery?.strategy); + } catch (e) { + // TODO: Add a debug log here. + } } } - } - if (!resolvedRootOrganizationHandle) { - resolvedRootOrganizationHandle = deriveRootOrganizationHandleFromBaseUrl(config?.baseUrl); - } + if (!resolvedRootOrganizationHandle) { + resolvedRootOrganizationHandle = deriveRootOrganizationHandleFromBaseUrl(config?.baseUrl); + } - return this.withLoading(async () => { return this.asgardeo.init({ ...config, organizationHandle: resolvedOrganizationHandle, @@ -267,21 +267,15 @@ class AsgardeoReactClient e } override isLoading(): boolean { - const clientLoading: boolean = this._isLoading; - const asgardeoLoading: boolean = this.asgardeo.isLoading() ?? false; - const totalLoading: boolean = clientLoading || asgardeoLoading; - - return totalLoading; + return this._isLoading || this.asgardeo.isLoading(); } async isInitialized(): Promise { - const result: boolean = await this.asgardeo.isInitialized(); - return result ?? false; + return this.asgardeo.isInitialized(); } - override async isSignedIn(): Promise { - const result: boolean = await this.asgardeo.isSignedIn(); - return result ?? false; + override isSignedIn(): Promise { + return this.asgardeo.isSignedIn(); } override getConfiguration(): T { @@ -381,4 +375,4 @@ class AsgardeoReactClient e } } -export default AsgardeoReactClient; +export default AsgardeoReactClient; \ No newline at end of file diff --git a/packages/react/src/components/primitives/Dialog/Dialog.styles.ts b/packages/react/src/components/primitives/Dialog/Dialog.styles.ts index 77ef2ae2..7e013ac3 100644 --- a/packages/react/src/components/primitives/Dialog/Dialog.styles.ts +++ b/packages/react/src/components/primitives/Dialog/Dialog.styles.ts @@ -41,17 +41,6 @@ const useStyles = (theme: Theme, colorScheme: string) => { border-radius: ${theme.vars.borderRadius.large}; box-shadow: 0 2px 8px ${colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.15)'}; outline: none; - max-width: 35vw; - min-width: 35vw; - @media (max-width: 900px) { - max-width: 80vw; - min-width: 80vw; - } - @media (max-width: 600px) { - max-width: 95vw; - min-width: 95vw; - } - max-height: 90vh; overflow-y: auto; z-index: 10000; `; @@ -61,8 +50,6 @@ const useStyles = (theme: Theme, colorScheme: string) => { border-radius: ${theme.vars.borderRadius.large}; box-shadow: 0 2px 8px ${colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.15)'}; outline: none; - max-width: 90vw; - max-height: 90vh; overflow-y: auto; z-index: 10000; `; From ab51e096992582b079de41bff1453ef80ff87420 Mon Sep 17 00:00:00 2001 From: Brion Date: Sat, 6 Sep 2025 07:54:36 +0530 Subject: [PATCH 7/7] chore(nextjs): fix syntax issues --- packages/nextjs/src/AsgardeoNextClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 7ef91114..d0522cec 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -53,7 +53,7 @@ import { Storage, organizationDiscovery, OrganizationDiscoveryStrategy, - deriveRootOrganizationHandleFromBaseUrl + deriveRootOrganizationHandleFromBaseUrl, TokenExchangeRequestConfig, } from '@asgardeo/node'; import {AsgardeoNextConfig} from './models/config';