Skip to content
4 changes: 4 additions & 0 deletions packages/javascript/src/constants/OIDCRequestConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
},

Expand Down
6 changes: 4 additions & 2 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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,
Expand Down
91 changes: 88 additions & 3 deletions packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,73 @@ export type SignOutOptions = Record<string, unknown>;
*/
export type SignUpOptions = Record<string, unknown>;

/**
* 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: '<resolved>' }}`
* 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<T = unknown> extends WithPreferences {
/**
* Optional URL where the authorization server should redirect after authentication.
Expand All @@ -78,11 +145,20 @@ export interface BaseConfig<T = unknown> 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;

/**
Expand Down Expand Up @@ -197,6 +273,15 @@ export interface BaseConfig<T = unknown> 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: '<resolved>' }}`
* into authentication requests.
* If no strategy is specified, the SDK will use the baseUrl to derive the organization handle.
*/
organizationDiscovery?: OrganizationDiscovery;
}

export interface WithPreferences {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,141 +16,141 @@
* 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);

Check failure on line 65 in packages/javascript/src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts

View workflow job for this annotation

GitHub Actions / 👾 Unit Test (TESTING) (lts/*)

src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts > deriveRootOrganizationHandleFromBaseUrl > Invalid URLs - Custom Domains > should throw error for custom domain without asgardeo.io

AssertionError: expected function to throw an error, but it didn't ❯ src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts:65:10

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

Check failure on line 75 in packages/javascript/src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts

View workflow job for this annotation

GitHub Actions / 👾 Unit Test (TESTING) (lts/*)

src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts > deriveRootOrganizationHandleFromBaseUrl > Invalid URLs - Custom Domains > should throw error for URLs without /t/ pattern

AssertionError: expected function to throw an error, but it didn't ❯ src/utils/__tests__/deriveRootOrganizationHandleFromBaseUrl.test.ts:75:10

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

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

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');
}
});
Expand Down
Loading
Loading