From ed8f7ccc534de4802b47908e6f81de4d7daf18e2 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Sun, 6 Sep 2026 10:47:54 +0530 Subject: [PATCH] fix(nextjs): call the /o APIs for organization sessions on the server AsgardeoProvider computed the `${baseUrl}/o` base URL for sessions that belong to an organization, but only handed it to the client-side context. The server actions behind getUser/getUserProfile/updateUserProfile and createOrganization/getOrganization kept using the root base URL from the client configuration, so B2B and organization-switched sessions called the root SCIM2 and organization endpoints with an organization-scoped token and were rejected; the profile then fell back to the ID token claims. Resolve the base URL per call from the session cookie (organizationId is set from the `user_org` claim), mirroring the React SDK. Co-Authored-By: Claude Fable 5.1 --- .changeset/nextjs-organization-base-url.md | 5 + packages/nextjs/src/AsgardeoNextClient.ts | 37 +++-- .../AsgardeoNextClient.baseUrl.test.ts | 145 ++++++++++++++++++ 3 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 .changeset/nextjs-organization-base-url.md create mode 100644 packages/nextjs/src/__tests__/AsgardeoNextClient.baseUrl.test.ts diff --git a/.changeset/nextjs-organization-base-url.md b/.changeset/nextjs-organization-base-url.md new file mode 100644 index 000000000..3aae29b10 --- /dev/null +++ b/.changeset/nextjs-organization-base-url.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +Sessions that belong to an organization (a B2B sign-in or an organization switch, i.e. the ID token carries a `user_org` claim) now call the `/o` variants of the SCIM2 and organization APIs on the server, as the React SDK does. Until now `AsgardeoProvider` computed the `/o` base URL only for the client-side context, while the server actions behind the user profile, profile updates, and organization creation/lookup kept calling the root endpoints with the organization-scoped token, so those calls were rejected and the profile fell back to the ID token claims. diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 3d4426a3c..d1c431b28 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -60,8 +60,10 @@ import { import {AsgardeoNextConfig} from './models/config'; import getClientOrigin from './server/actions/getClientOrigin'; import getSessionId from './server/actions/getSessionId'; +import getSessionPayload from './server/actions/getSessionPayload'; import decorateConfigWithNextEnv from './utils/decorateConfigWithNextEnv'; import logger from './utils/logger'; +import {SessionTokenPayload} from './utils/SessionManager'; /** * Client for mplementing Asgardeo in Next.js applications. @@ -189,13 +191,32 @@ class AsgardeoNextClient exte return isInitialized; } + /** + * Resolves the base URL for the APIs that take the signed-in user's organization into account. + * + * A session that belongs to an organization (the ID token carried a `user_org` claim, for example after a + * B2B sign-in or an organization switch) has to call the `/o` variants of the SCIM and organization APIs. + * Mirrors the React SDK, which switches to `${baseUrl}/o` for the same sessions. + */ + private async resolveBaseUrl(): Promise { + const configData: AuthClientConfig = await this.asgardeo.getConfigData(); + const baseUrl: string = configData?.baseUrl as string; + + if (!baseUrl || baseUrl.endsWith('/o')) { + return baseUrl; + } + + const session: SessionTokenPayload | undefined = await getSessionPayload(); + + return session?.organizationId ? `${baseUrl}/o` : baseUrl; + } + override async getUser(userId?: string): Promise { await this.ensureInitialized(); const resolvedSessionId: string = userId || ((await getSessionId()) as string); try { - const configData: AuthClientConfig = await this.asgardeo.getConfigData(); - const baseUrl: string | undefined = configData?.baseUrl; + const baseUrl: string = await this.resolveBaseUrl(); const profile: User = await getScim2Me({ baseUrl, @@ -221,8 +242,7 @@ class AsgardeoNextClient exte await this.ensureInitialized(); try { - const configData: AuthClientConfig = await this.asgardeo.getConfigData(); - const baseUrl: string | undefined = configData?.baseUrl; + const baseUrl: string = await this.resolveBaseUrl(); const profile: User = await getScim2Me({ baseUrl, @@ -272,8 +292,7 @@ class AsgardeoNextClient exte await this.ensureInitialized(); try { - const configData: AuthClientConfig = await this.asgardeo.getConfigData(); - const baseUrl: string | undefined = configData?.baseUrl; + const baseUrl: string = await this.resolveBaseUrl(); const userProfile: User = await updateMeProfile({ baseUrl, @@ -296,8 +315,7 @@ class AsgardeoNextClient exte async createOrganization(payload: CreateOrganizationPayload, userId?: string): Promise { try { - const configData: AuthClientConfig = await this.asgardeo.getConfigData(); - const baseUrl: string = configData?.baseUrl as string; + const baseUrl: string = await this.resolveBaseUrl(); const createdOrg: Organization = await createOrganization({ baseUrl, @@ -320,8 +338,7 @@ class AsgardeoNextClient exte async getOrganization(organizationId: string, userId?: string): Promise { try { - const configData: AuthClientConfig = await this.asgardeo.getConfigData(); - const baseUrl: string = configData?.baseUrl as string; + const baseUrl: string = await this.resolveBaseUrl(); const organization: OrganizationDetails = await getOrganization({ baseUrl, diff --git a/packages/nextjs/src/__tests__/AsgardeoNextClient.baseUrl.test.ts b/packages/nextjs/src/__tests__/AsgardeoNextClient.baseUrl.test.ts new file mode 100644 index 000000000..2939907a6 --- /dev/null +++ b/packages/nextjs/src/__tests__/AsgardeoNextClient.baseUrl.test.ts @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {createOrganization, getOrganization, getSchemas, getScim2Me, updateMeProfile} from '@asgardeo/node'; +import {beforeAll, beforeEach, describe, expect, it, vi, Mock} from 'vitest'; +import AsgardeoNextClient from '../AsgardeoNextClient'; +import getAccessToken from '../server/actions/getAccessToken'; +import getSessionPayload from '../server/actions/getSessionPayload'; + +const {legacyClient} = vi.hoisted(() => { + const hoistedLegacyClient: {getConfigData: Mock; getDecodedIdToken: Mock; initialize: Mock} = { + getConfigData: vi.fn(), + getDecodedIdToken: vi.fn(), + initialize: vi.fn(), + }; + + return {legacyClient: hoistedLegacyClient}; +}); + +vi.mock('@asgardeo/node', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + // The SDK instantiates the legacy client with `new`, which an arrow function cannot serve. + // eslint-disable-next-line prefer-arrow-callback + LegacyAsgardeoNodeClient: vi.fn(function LegacyAsgardeoNodeClientMock(): unknown { + return legacyClient; + }), + createOrganization: vi.fn(), + getOrganization: vi.fn(), + getSchemas: vi.fn(), + getScim2Me: vi.fn(), + updateMeProfile: vi.fn(), +})); + +vi.mock('../server/actions/getClientOrigin', () => ({default: vi.fn(async () => 'http://localhost:3000')})); +vi.mock('../server/actions/getSessionId', () => ({default: vi.fn(async () => 'session-1')})); +vi.mock('../server/actions/getSessionPayload', () => ({default: vi.fn()})); +vi.mock('../server/actions/getAccessToken', () => ({default: vi.fn(async () => 'access-token')})); + +describe('AsgardeoNextClient base URL resolution', () => { + const rootBaseUrl: string = 'https://api.asgardeo.io/t/acme'; + const config: Record = {baseUrl: rootBaseUrl, clientId: 'client-id', clientSecret: 'client-secret'}; + + const rootSession: Record = {sessionId: 'session-1', sub: 'user-1', type: 'session'}; + const organizationSession: Record = {...rootSession, organizationId: 'org-1'}; + + const baseUrlPassedTo = (apiMock: unknown): string => + ((apiMock as Mock).mock.calls[0]?.[0] as {baseUrl: string} | undefined)?.baseUrl ?? ''; + + let client: AsgardeoNextClient; + + beforeAll(async () => { + legacyClient.getConfigData.mockResolvedValue(config); + legacyClient.initialize.mockResolvedValue(true); + + client = AsgardeoNextClient.getInstance(); + await client.initialize(config as any); + }); + + beforeEach(() => { + vi.clearAllMocks(); + + legacyClient.getConfigData.mockResolvedValue(config); + (getAccessToken as unknown as Mock).mockResolvedValue('access-token'); + (getScim2Me as unknown as Mock).mockResolvedValue({userName: 'jane'}); + (getSchemas as unknown as Mock).mockResolvedValue([]); + (updateMeProfile as unknown as Mock).mockResolvedValue({userName: 'jane'}); + (createOrganization as unknown as Mock).mockResolvedValue({id: 'org-2', name: 'Beta'}); + (getOrganization as unknown as Mock).mockResolvedValue({id: 'org-1', name: 'Acme'}); + }); + + describe('for a root organization session', () => { + beforeEach(() => { + (getSessionPayload as unknown as Mock).mockResolvedValue(rootSession); + }); + + it('calls the SCIM2 APIs on the configured base URL', async () => { + await client.getUserProfile('session-1'); + + expect(baseUrlPassedTo(getScim2Me)).toBe(rootBaseUrl); + expect(baseUrlPassedTo(getSchemas)).toBe(rootBaseUrl); + }); + + it('calls the organization APIs on the configured base URL', async () => { + await client.getOrganization('org-1', 'session-1'); + await client.createOrganization({name: 'Beta'} as any, 'session-1'); + + expect(baseUrlPassedTo(getOrganization)).toBe(rootBaseUrl); + expect(baseUrlPassedTo(createOrganization)).toBe(rootBaseUrl); + }); + }); + + describe('for an organization session', () => { + beforeEach(() => { + (getSessionPayload as unknown as Mock).mockResolvedValue(organizationSession); + }); + + it('reads and updates the user profile through the /o SCIM2 APIs', async () => { + await client.getUser('session-1'); + await client.updateUserProfile({operations: []}, 'session-1'); + + expect(baseUrlPassedTo(getScim2Me)).toBe(`${rootBaseUrl}/o`); + expect(baseUrlPassedTo(getSchemas)).toBe(`${rootBaseUrl}/o`); + expect(baseUrlPassedTo(updateMeProfile)).toBe(`${rootBaseUrl}/o`); + }); + + it('reads and creates organizations through the /o organization APIs', async () => { + await client.getOrganization('org-1', 'session-1'); + await client.createOrganization({name: 'Beta'} as any, 'session-1'); + + expect(baseUrlPassedTo(getOrganization)).toBe(`${rootBaseUrl}/o`); + expect(baseUrlPassedTo(createOrganization)).toBe(`${rootBaseUrl}/o`); + }); + + it('does not append /o twice when the configured base URL already targets an organization', async () => { + legacyClient.getConfigData.mockResolvedValue({...config, baseUrl: `${rootBaseUrl}/o`}); + + await client.getUserProfile('session-1'); + + expect(baseUrlPassedTo(getScim2Me)).toBe(`${rootBaseUrl}/o`); + }); + }); + + it('keeps using the configured base URL when there is no session cookie', async () => { + (getSessionPayload as unknown as Mock).mockResolvedValue(undefined); + + await client.getUserProfile('session-1'); + + expect(baseUrlPassedTo(getScim2Me)).toBe(rootBaseUrl); + }); +});