Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions packages/nextjs/src/AsgardeoNextClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,31 +66,54 @@ import decorateConfigWithNextEnv from './utils/decorateConfigWithNextEnv';
* Client for mplementing Asgardeo in Next.js applications.
* This class provides the core functionality for managing user authentication and sessions.
*
* This class is implemented as a singleton to ensure a single instance across the application.
* This class is implemented as a multiton to support multiple independent instances across the application.
*
* @typeParam T - Configuration type that extends AsgardeoNextConfig.
*/
class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> extends AsgardeoNodeClient<T> {
private static instance: AsgardeoNextClient<any>;
private static instances: Map<number, AsgardeoNextClient<any>> = new Map();

private instanceId: number;

private asgardeo: LegacyAsgardeoNodeClient<T>;

public isInitialized: boolean = false;

private constructor() {
private constructor(instanceId: number = 0) {
super();

this.instanceId = instanceId;
this.asgardeo = new LegacyAsgardeoNodeClient();
}

/**
* Get the singleton instance of AsgardeoNextClient
* Get the instance of AsgardeoNextClient for the given instanceId.
*/
public static getInstance<T extends AsgardeoNextConfig = AsgardeoNextConfig>(): AsgardeoNextClient<T> {
if (!AsgardeoNextClient.instance) {
AsgardeoNextClient.instance = new AsgardeoNextClient<T>();
public static getInstance<T extends AsgardeoNextConfig = AsgardeoNextConfig>(instanceId: number = 0): AsgardeoNextClient<T> {
if (!AsgardeoNextClient.instances.has(instanceId)) {
AsgardeoNextClient.instances.set(instanceId, new AsgardeoNextClient<T>(instanceId));
}
return AsgardeoNextClient.instance as AsgardeoNextClient<T>;
return AsgardeoNextClient.instances.get(instanceId) as AsgardeoNextClient<T>;
}

/**
* Returns the instanceId of this client instance.
*/
public getInstanceId(): number {
return this.instanceId;
}

/**
* Returns whether an instance with the given instanceId exists.
*/
public static hasInstance(instanceId: number = 0): boolean {
return AsgardeoNextClient.instances.has(instanceId);
}

/**
* Destroys the instance with the given instanceId.
*/
public static destroyInstance(instanceId: number = 0): boolean {
return AsgardeoNextClient.instances.delete(instanceId);
}

/**
Expand Down Expand Up @@ -146,6 +169,7 @@ class AsgardeoNextClient<T extends AsgardeoNextConfig = AsgardeoNextConfig> exte
...rest,
} as any,
storage,
this.instanceId,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
getAllOrganizations,
switchOrganization,
brandingPreference,
instanceId = 0,
}: PropsWithChildren<AsgardeoClientProviderProps>) => {
const reRenderCheckRef: RefObject<boolean> = useRef(false);
const router: AppRouterInstance = useRouter();
Expand Down Expand Up @@ -151,8 +152,11 @@ const AsgardeoClientProvider: FC<PropsWithChildren<AsgardeoClientProviderProps>>
return;
}

// Check for what instance the callback is for
const callbackInstanceId: string | null = state ? state.split('_')[1] : null;

// Handle OAuth callback if code and state are present
if (code && state) {
if (code && state && callbackInstanceId === instanceId.toString()) {
setIsLoading(true);

const result: {error?: string; redirectUrl?: string; success: boolean} = await handleOAuthCallback(
Expand Down
54 changes: 37 additions & 17 deletions packages/nextjs/src/server/AsgardeoProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@

'use server';

import {BrandingPreference, AsgardeoRuntimeError, IdToken, Organization, User, UserProfile} from '@asgardeo/node';
import {
BrandingPreference,
AsgardeoRuntimeError,
IdToken,
Organization,
User,
UserProfile,
} from '@asgardeo/node';
import {AsgardeoProviderProps} from '@asgardeo/react';
import {FC, PropsWithChildren, ReactElement} from 'react';
import createOrganization from './actions/createOrganization';
Expand Down Expand Up @@ -70,9 +77,10 @@ const AsgardeoServerProvider: FC<PropsWithChildren<AsgardeoServerProviderProps>>
children,
afterSignInUrl,
afterSignOutUrl,
instanceId = 0,
..._config
}: PropsWithChildren<AsgardeoServerProviderProps>): Promise<ReactElement> => {
const asgardeoClient: AsgardeoNextClient = AsgardeoNextClient.getInstance();
const asgardeoClient: AsgardeoNextClient = AsgardeoNextClient.getInstance(instanceId);
let config: Partial<AsgardeoNextConfig> = {};

try {
Expand All @@ -97,9 +105,9 @@ const AsgardeoServerProvider: FC<PropsWithChildren<AsgardeoServerProviderProps>>
}

// Try to get session information from JWT first, then fall back to legacy
const sessionPayload: SessionTokenPayload | undefined = await getSessionPayload();
const sessionId: string = sessionPayload?.sessionId || (await getSessionId()) || '';
const signedIn: boolean = sessionPayload ? true : await isSignedIn(sessionId);
const sessionPayload: SessionTokenPayload | undefined = await getSessionPayload(instanceId);
const sessionId: string = sessionPayload?.sessionId || (await getSessionId(instanceId)) || '';
const signedIn: boolean = sessionPayload ? true : await isSignedIn(sessionId, instanceId);

let user: User = {};
let userProfile: UserProfile = {
Expand Down Expand Up @@ -138,20 +146,20 @@ const AsgardeoServerProvider: FC<PropsWithChildren<AsgardeoServerProviderProps>>
data: {user: User | null};
error: string | null;
success: boolean;
} = await getUserAction(sessionId);
} = await getUserAction(sessionId, instanceId);
const userProfileResponse: {
data: {userProfile: UserProfile};
error: string | null;
success: boolean;
} = await getUserProfileAction(sessionId);
} = await getUserProfileAction(sessionId, instanceId);
const currentOrganizationResponse: {
data: {organization?: Organization; user?: Record<string, unknown>};
error: string | null;
success: boolean;
} = await getCurrentOrganizationAction(sessionId);
} = await getCurrentOrganizationAction(sessionId, instanceId);

if (sessionId) {
myOrganizations = await getMyOrganizations({}, sessionId);
myOrganizations = await getMyOrganizations({}, sessionId, instanceId);
} else {
// eslint-disable-next-line no-console
console.warn('[AsgardeoServerProvider] No session ID available, skipping organization fetch');
Expand Down Expand Up @@ -186,29 +194,41 @@ const AsgardeoServerProvider: FC<PropsWithChildren<AsgardeoServerProviderProps>>
}
}

// Create instance-bound server actions using .bind() so React can serialize them
// as proper server action references when passed to Client Components.
const boundSignIn = signInAction.bind(null, instanceId);
const boundSignOut = signOutAction.bind(null, instanceId);
const boundSignUp = signUpAction.bind(null, instanceId);
const boundHandleOAuthCallback = handleOAuthCallbackAction.bind(null, instanceId);
const boundSwitchOrganization = switchOrganization.bind(null, instanceId);
const boundGetAllOrganizations = getAllOrganizations.bind(null, instanceId);
const boundCreateOrganization = createOrganization.bind(null, instanceId);
const boundUpdateUserProfile = updateUserProfileAction.bind(null, instanceId);

return (
<AsgardeoClientProvider
organizationHandle={config?.organizationHandle}
applicationId={config?.applicationId}
baseUrl={config?.baseUrl}
signIn={signInAction}
signOut={signOutAction}
signUp={signUpAction}
handleOAuthCallback={handleOAuthCallbackAction}
signIn={boundSignIn}
signOut={boundSignOut}
signUp={boundSignUp}
handleOAuthCallback={boundHandleOAuthCallback}
signInUrl={config?.signInUrl}
signUpUrl={config?.signUpUrl}
preferences={config?.preferences}
clientId={config?.clientId}
user={user}
currentOrganization={currentOrganization}
userProfile={userProfile}
updateProfile={updateUserProfileAction}
updateProfile={boundUpdateUserProfile}
isSignedIn={signedIn}
myOrganizations={myOrganizations}
getAllOrganizations={getAllOrganizations}
switchOrganization={switchOrganization}
getAllOrganizations={boundGetAllOrganizations}
switchOrganization={boundSwitchOrganization}
brandingPreference={brandingPreference}
createOrganization={createOrganization}
createOrganization={boundCreateOrganization}
instanceId={instanceId}
>
{children}
</AsgardeoClientProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('createOrganization (Next.js server action)', () => {
it('should create an organization successfully when a sessionId is provided', async () => {
mockClient.createOrganization.mockResolvedValueOnce(mockOrg);

const result: Organization = await createOrganization(basePayload, 'sess-123');
const result: Organization = await createOrganization(0, basePayload, 'sess-123');

expect(AsgardeoNextClient.getInstance).toHaveBeenCalledTimes(1);
expect(getSessionId).not.toHaveBeenCalled();
Expand All @@ -88,7 +88,7 @@ describe('createOrganization (Next.js server action)', () => {
it('should fall back to getSessionId when sessionId is undefined', async () => {
mockClient.createOrganization.mockResolvedValueOnce(mockOrg);

const result: Organization = await createOrganization(basePayload, undefined as unknown as string);
const result: Organization = await createOrganization(0, basePayload, undefined as unknown as string);

expect(getSessionId).toHaveBeenCalledTimes(1);
expect(mockClient.createOrganization).toHaveBeenCalledWith(basePayload, 'sess-abc');
Expand All @@ -98,7 +98,7 @@ describe('createOrganization (Next.js server action)', () => {
it('should fall back to getSessionId when sessionId is null', async () => {
mockClient.createOrganization.mockResolvedValueOnce(mockOrg);

const result: Organization = await createOrganization(basePayload, null as unknown as string);
const result: Organization = await createOrganization(0, basePayload, null as unknown as string);

expect(getSessionId).toHaveBeenCalledTimes(1);
expect(mockClient.createOrganization).toHaveBeenCalledWith(basePayload, 'sess-abc');
Expand All @@ -108,7 +108,7 @@ describe('createOrganization (Next.js server action)', () => {
it('should not call getSessionId when an empty string is passed (empty string is not nullish)', async () => {
mockClient.createOrganization.mockResolvedValueOnce(mockOrg);

const result: Organization = await createOrganization(basePayload, '');
const result: Organization = await createOrganization(0, basePayload, '');

expect(getSessionId).not.toHaveBeenCalled();
expect(mockClient.createOrganization).toHaveBeenCalledWith(basePayload, '');
Expand All @@ -124,7 +124,7 @@ describe('createOrganization (Next.js server action)', () => {
);
mockClient.createOrganization.mockRejectedValueOnce(original);

await expect(createOrganization(basePayload, 'sess-1')).rejects.toMatchObject({
await expect(createOrganization(0, basePayload, 'sess-1')).rejects.toMatchObject({
constructor: AsgardeoAPIError,
message: expect.stringContaining('Failed to create the organization: Upstream validation failed'),
statusCode: 400,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('getAllOrganizations (Next.js server action)', () => {
it('returns organizations when a sessionId is provided (no getSessionId fallback)', async () => {
mockClient.getAllOrganizations.mockResolvedValueOnce(mockResponse);

const result: AllOrganizationsApiResponse = await getAllOrganizations(baseOptions, 'sess-123');
const result: AllOrganizationsApiResponse = await getAllOrganizations(0, baseOptions, 'sess-123');

expect(AsgardeoNextClient.getInstance).toHaveBeenCalledTimes(1);
expect(getSessionId).not.toHaveBeenCalled();
Expand All @@ -82,7 +82,7 @@ describe('getAllOrganizations (Next.js server action)', () => {
it('falls back to getSessionId when sessionId is undefined', async () => {
mockClient.getAllOrganizations.mockResolvedValueOnce(mockResponse);

const result: AllOrganizationsApiResponse = await getAllOrganizations(baseOptions, undefined);
const result: AllOrganizationsApiResponse = await getAllOrganizations(0, baseOptions, undefined);

expect(getSessionId).toHaveBeenCalledTimes(1);
expect(mockClient.getAllOrganizations).toHaveBeenCalledWith(baseOptions, 'sess-abc');
Expand All @@ -92,7 +92,7 @@ describe('getAllOrganizations (Next.js server action)', () => {
it('falls back to getSessionId when sessionId is null', async () => {
mockClient.getAllOrganizations.mockResolvedValueOnce(mockResponse);

const result: AllOrganizationsApiResponse = await getAllOrganizations(baseOptions, null as unknown as string);
const result: AllOrganizationsApiResponse = await getAllOrganizations(0, baseOptions, null as unknown as string);

expect(getSessionId).toHaveBeenCalledTimes(1);
expect(mockClient.getAllOrganizations).toHaveBeenCalledWith(baseOptions, 'sess-abc');
Expand All @@ -102,7 +102,7 @@ describe('getAllOrganizations (Next.js server action)', () => {
it('does not call getSessionId for an empty string sessionId (empty string is not nullish)', async () => {
mockClient.getAllOrganizations.mockResolvedValueOnce(mockResponse);

const result: AllOrganizationsApiResponse = await getAllOrganizations(baseOptions, '');
const result: AllOrganizationsApiResponse = await getAllOrganizations(0, baseOptions, '');

expect(getSessionId).not.toHaveBeenCalled();
expect(mockClient.getAllOrganizations).toHaveBeenCalledWith(baseOptions, '');
Expand All @@ -113,7 +113,7 @@ describe('getAllOrganizations (Next.js server action)', () => {
const upstream: AsgardeoAPIError = new AsgardeoAPIError('Upstream failed', 'ORG_LIST_500', 'server', 503);
mockClient.getAllOrganizations.mockRejectedValueOnce(upstream);

await expect(getAllOrganizations(baseOptions, 'sess-x')).rejects.toMatchObject({
await expect(getAllOrganizations(0, baseOptions, 'sess-x')).rejects.toMatchObject({
constructor: AsgardeoAPIError,
message: expect.stringContaining('Failed to get all the organizations for the user: Upstream failed'),
statusCode: 503,
Expand Down
6 changes: 3 additions & 3 deletions packages/nextjs/src/server/actions/createOrganization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ import AsgardeoNextClient from '../../AsgardeoNextClient';
/**
* Server action to create an organization.
*/
const createOrganization = async (payload: CreateOrganizationPayload, sessionId: string): Promise<Organization> => {
const createOrganization = async (instanceId: number = 0, payload: CreateOrganizationPayload, sessionId: string): Promise<Organization> => {
try {
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
return await client.createOrganization(payload, sessionId ?? ((await getSessionId()) as string));
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(instanceId);
return await client.createOrganization(payload, sessionId ?? ((await getSessionId(instanceId)) as string));
} catch (error) {
throw new AsgardeoAPIError(
`Failed to create the organization: ${error instanceof Error ? error.message : String(error)}`,
Expand Down
4 changes: 2 additions & 2 deletions packages/nextjs/src/server/actions/getAccessToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import SessionManager, {SessionTokenPayload} from '../../utils/SessionManager';
*
* @returns The access token if it exists, undefined otherwise
*/
const getAccessToken = async (): Promise<string | undefined> => {
const getAccessToken = async (instanceId: number = 0): Promise<string | undefined> => {
const cookieStore: ReadonlyRequestCookies = await cookies();

const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName())?.value;
const sessionToken: string | undefined = cookieStore.get(SessionManager.getSessionCookieName(instanceId))?.value;

if (sessionToken) {
try {
Expand Down
5 changes: 3 additions & 2 deletions packages/nextjs/src/server/actions/getAllOrganizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ import AsgardeoNextClient from '../../AsgardeoNextClient';
* Server action to get organizations.
*/
const getAllOrganizations = async (
instanceId: number = 0,
options?: any,
sessionId?: string | undefined,
): Promise<AllOrganizationsApiResponse> => {
try {
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
return await client.getAllOrganizations(options, sessionId ?? ((await getSessionId()) as string));
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(instanceId);
return await client.getAllOrganizations(options, sessionId ?? ((await getSessionId(instanceId)) as string));
} catch (error) {
throw new AsgardeoAPIError(
`Failed to get all the organizations for the user: ${error instanceof Error ? error.message : String(error)}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ import AsgardeoNextClient from '../../AsgardeoNextClient';
*/
const getCurrentOrganizationAction = async (
sessionId: string,
instanceId: number = 0,
): Promise<{
data: {organization?: Organization; user?: Record<string, unknown>};
error: string | null;
success: boolean;
}> => {
try {
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(instanceId);
const organization: Organization = (await client.getCurrentOrganization(sessionId)) as Organization;
return {data: {organization}, error: null, success: true};
} catch (error) {
Expand Down
6 changes: 3 additions & 3 deletions packages/nextjs/src/server/actions/getMyOrganizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ import AsgardeoNextClient from '../../AsgardeoNextClient';
/**
* Server action to get organizations.
*/
const getMyOrganizations = async (options?: any, sessionId?: string | undefined): Promise<Organization[]> => {
const getMyOrganizations = async (options?: any, sessionId?: string | undefined, instanceId: number = 0): Promise<Organization[]> => {
try {
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(instanceId);

// Get session ID if not provided
let resolvedSessionId: string | undefined = sessionId;
if (!resolvedSessionId) {
// Import getSessionId locally to avoid circular dependencies
const {default: getSessionId} = await import('./getSessionId');
resolvedSessionId = await getSessionId();
resolvedSessionId = await getSessionId(instanceId);
}

if (!resolvedSessionId) {
Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/server/actions/getOrganizationAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ import AsgardeoNextClient from '../../AsgardeoNextClient';
const getOrganizationAction = async (
organizationId: string,
sessionId: string,
instanceId: number = 0,
): Promise<{
data: {organization?: OrganizationDetails; user?: Record<string, unknown>};
error: string | null;
success: boolean;
}> => {
try {
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance();
const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(instanceId);
const organization: OrganizationDetails = await client.getOrganization(organizationId, sessionId);
return {data: {organization}, error: null, success: true};
} catch (error) {
Expand Down
Loading
Loading