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 2c9e3217..bdb4c55c 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'; @@ -126,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'; @@ -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..09a182b5 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -53,6 +53,73 @@ 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 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; + }; + +/** + * 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 { + /** + * Flag to enable organization discovery. + */ + enabled: boolean; + /** + * Strategy for discovering the organization handle or ID. + */ + strategy?: OrganizationDiscoveryStrategy; +} + export interface BaseConfig extends WithPreferences { /** * Optional URL where the authorization server should redirect after authentication. @@ -78,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; /** @@ -197,6 +273,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/__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/javascript/src/utils/organizationDiscovery.ts b/packages/javascript/src/utils/organizationDiscovery.ts new file mode 100644 index 00000000..0f0cbfee --- /dev/null +++ b/packages/javascript/src/utils/organizationDiscovery.ts @@ -0,0 +1,93 @@ +/** + * 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'; + +/** + * 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 = ( + strategy: OrganizationDiscoveryStrategy, +): string => { + switch (strategy?.type) { + case 'urlPath': + return deriveFromUrlPath(strategy?.param); + + case 'urlQuery': + return deriveFromUrlQuery(strategy?.param); + + case 'subdomain': + return deriveFromSubdomain(); + + case 'custom': + return strategy?.resolver(); + + default: + return undefined; + } +}; + +export default organizationDiscovery; diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index d31343bb..d0522cec 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, TokenExchangeRequestConfig, } from '@asgardeo/node'; import {AsgardeoNextConfig} from './models/config'; @@ -109,21 +111,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(); @@ -131,6 +146,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 07b533e5..762a28aa 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -35,13 +35,14 @@ import { Organization, IdToken, EmbeddedFlowExecuteRequestConfig, - deriveOrganizationHandleFromBaseUrl, AllOrganizationsApiResponse, extractUserClaimsFromIdToken, TokenResponse, HttpRequestConfig, HttpResponse, Storage, + organizationDiscovery, + deriveRootOrganizationHandleFromBaseUrl, TokenExchangeRequestConfig, } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; @@ -91,15 +92,30 @@ 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); - } + let resolvedRootOrganizationHandle: string | undefined = config?.rootOrganizationHandle; return this.withLoading(async () => { - return this.asgardeo.init({...config, organizationHandle: resolvedOrganizationHandle} as any); + 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); + } + + 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/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; `; diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts b/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts index c5dcdba1..b7926afb 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts @@ -22,6 +22,7 @@ import { HttpResponse, IdToken, Organization, + OrganizationDiscovery, SignInOptions, TokenExchangeRequestConfig, TokenResponse, @@ -105,6 +106,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; /** * Retrieves the access token stored in the storage. @@ -136,6 +150,10 @@ const AsgardeoContext: Context = createContext> = ({ afterSignInUrl = window.location.origin, afterSignOutUrl = window.location.origin, - baseUrl: _baseUrl, + baseUrl: originalBaseUrl, clientId, children, scopes, @@ -56,12 +56,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); @@ -73,10 +76,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, @@ -98,16 +103,28 @@ 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. + // 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 (reInitializeCheckRef.current) { + return; + } + + reInitializeCheckRef.current = true; + (async (): Promise => { await asgardeo.initialize(config); setConfig(await asgardeo.getConfiguration()); @@ -201,15 +218,34 @@ const AsgardeoProvider: FC> = ({ }, [asgardeo]); useEffect(() => { - (async (): Promise => { + let interval: NodeJS.Timeout; + + const checkInitializedState = async (): Promise => { try { const status: boolean = await asgardeo.isInitialized(); 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]); /** @@ -291,10 +327,19 @@ 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, - // Add other branding config options as needed + name: config?.applicationId || config?.organizationHandle || config?.rootOrganizationHandle, + type: config.applicationId ? 'APP' : 'ORG', }; const brandingData = await getBrandingPreference(getBrandingConfig); @@ -308,7 +353,7 @@ const AsgardeoProvider: FC> = ({ } finally { setIsBrandingLoading(false); } - }, [baseUrl, preferences?.i18n?.language]); + }, [baseUrl, preferences?.i18n?.language, config?.organizationHandle, config?.rootOrganizationHandle]); // Refetch branding function const refetchBranding = useCallback(async (): Promise => { @@ -331,6 +376,8 @@ const AsgardeoProvider: FC> = ({ hasFetchedBranding, isBrandingLoading, fetchBranding, + config?.organizationHandle, + config?.rootOrganizationHandle, ]); const signIn = async (...args: any): Promise => { @@ -416,17 +463,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 +491,7 @@ const AsgardeoProvider: FC> = ({ syncSession, }), [ - applicationId, - config?.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', + }, + }} >