diff --git a/.changeset/tidy-maps-cheer.md b/.changeset/tidy-maps-cheer.md new file mode 100644 index 0000000000..d5ebb2b6ab --- /dev/null +++ b/.changeset/tidy-maps-cheer.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Fix `organization.setActiveTeam` so it only accepts teams from the current active organization. diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index d1046f4c39..b24ae72e8c 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -1860,15 +1860,16 @@ Delete a team from an organization: #### Set Active Team -Sets the given team as the current active team. If `teamId` is `null` the current active team is unset. +Sets the given team as the current active team for the current active organization. If `teamId` is `null` the current active team is unset. ```ts type setActiveTeam = { /** * The team ID of the team to set as the current active team. + * The team must belong to the current active organization. */ - teamId?: string = "team-id" + teamId?: string | null = "team-id" } ``` diff --git a/packages/better-auth/src/plugins/organization/routes/crud-team.ts b/packages/better-auth/src/plugins/organization/routes/crud-team.ts index d8aa76e9e6..9cb96abaaf 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-team.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-team.ts @@ -679,7 +679,8 @@ export const setActiveTeam = (options: O) => use: [orgSessionMiddleware, orgMiddleware], metadata: { openapi: { - description: "Set the active team", + description: + "Set the active team for the current active organization", responses: { "200": { description: "Success", @@ -734,7 +735,19 @@ export const setActiveTeam = (options: O) => teamId = ctx.body.teamId; } - const team = await adapter.findTeamById({ teamId }); + const activeOrganizationId = session.session.activeOrganizationId; + + if (!activeOrganizationId) { + throw APIError.from( + "BAD_REQUEST", + ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, + ); + } + + const team = await adapter.findTeamById({ + teamId, + organizationId: activeOrganizationId, + }); if (!team) { throw APIError.from( diff --git a/packages/better-auth/src/plugins/organization/team.test.ts b/packages/better-auth/src/plugins/organization/team.test.ts index 18c9d8af3a..01835e86e7 100644 --- a/packages/better-auth/src/plugins/organization/team.test.ts +++ b/packages/better-auth/src/plugins/organization/team.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { createAuthClient } from "../../client"; import { setCookieToHeader } from "../../cookies"; import { getTestInstance } from "../../test-utils/test-instance"; +import { isAPIError } from "../../utils/is-api-error"; import { organizationClient } from "./client"; +import { ORGANIZATION_ERROR_CODES } from "./error-codes"; import { organization } from "./organization"; describe("team", async () => { @@ -500,6 +502,260 @@ describe("team", async () => { }); }); +/** + * @see https://github.com/better-auth/better-auth/issues/9237 + */ +describe("setActiveTeam org scoping", async () => { + const { auth, db, signInWithTestUser } = await getTestInstance({ + plugins: [ + organization({ + async sendInvitationEmail() {}, + teams: { + enabled: true, + }, + }), + ], + logger: { + level: "error", + }, + }); + + const { headers } = await signInWithTestUser(); + const client = createAuthClient({ + plugins: [ + organizationClient({ + teams: { + enabled: true, + }, + }), + ], + baseURL: "http://localhost:3000/api/auth", + fetchOptions: { + customFetchImpl: async (url, init) => { + return auth.handler(new Request(url, init)); + }, + }, + }); + + let activeOrganizationId: string; + let scopedTeamId: string; + let outOfScopeOrganizationId: string; + let outOfScopeTeamId: string; + let sessionToken: string; + let userId: string; + + beforeAll(async () => { + const session = await client.getSession({ + fetchOptions: { headers }, + }); + userId = session.data?.user.id as string; + sessionToken = session.data?.session.token as string; + + const firstOrganization = await client.organization.create({ + name: "Scoped Org One", + slug: "scoped-org-one", + fetchOptions: { headers }, + }); + activeOrganizationId = firstOrganization.data?.id as string; + expect(activeOrganizationId).toBeDefined(); + + const activeOrganizationTeam = await client.organization.createTeam( + { + name: "Scoped Team One", + organizationId: activeOrganizationId, + }, + { + headers, + }, + ); + scopedTeamId = activeOrganizationTeam.data?.id as string; + expect(scopedTeamId).toBeDefined(); + + await auth.api.addTeamMember({ + headers, + body: { + userId, + teamId: scopedTeamId, + organizationId: activeOrganizationId, + }, + }); + + const secondOrganization = await client.organization.create({ + name: "Scoped Org Two", + slug: "scoped-org-two", + keepCurrentActiveOrganization: true, + fetchOptions: { headers }, + }); + outOfScopeOrganizationId = secondOrganization.data?.id as string; + expect(outOfScopeOrganizationId).toBeDefined(); + + const outOfScopeTeam = await client.organization.createTeam( + { + name: "Scoped Team Two", + organizationId: outOfScopeOrganizationId, + }, + { + headers, + }, + ); + outOfScopeTeamId = outOfScopeTeam.data?.id as string; + expect(outOfScopeTeamId).toBeDefined(); + + await auth.api.addTeamMember({ + headers, + body: { + userId, + teamId: outOfScopeTeamId, + organizationId: outOfScopeOrganizationId, + }, + }); + + const setActiveOrganization = await client.organization.setActive({ + organizationId: activeOrganizationId, + fetchOptions: { headers }, + }); + expect(setActiveOrganization.error).toBeNull(); + + const setScopedTeam = await client.organization.setActiveTeam( + { + teamId: scopedTeamId, + }, + { + headers, + }, + ); + expect(setScopedTeam.error).toBeNull(); + expect(setScopedTeam.data?.id).toBe(scopedTeamId); + + const sessionAfterScopedTeam = await client.getSession({ + fetchOptions: { headers }, + }); + expect( + (sessionAfterScopedTeam.data?.session as any).activeOrganizationId, + ).toBe(activeOrganizationId); + expect((sessionAfterScopedTeam.data?.session as any).activeTeamId).toBe( + scopedTeamId, + ); + }); + + it("should reject teams outside the active organization and preserve the active team", async () => { + const setOutOfScopeTeam = await client.organization.setActiveTeam( + { + teamId: outOfScopeTeamId, + }, + { + headers, + }, + ); + + expect(setOutOfScopeTeam.error?.status).toBe(400); + expect(setOutOfScopeTeam.error?.code).toContain( + ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND.code, + ); + expect(setOutOfScopeTeam.data).toBeNull(); + + const sessionAfterRejectedTeam = await client.getSession({ + fetchOptions: { headers }, + }); + expect( + (sessionAfterRejectedTeam.data?.session as any).activeOrganizationId, + ).toBe(activeOrganizationId); + expect((sessionAfterRejectedTeam.data?.session as any).activeTeamId).toBe( + scopedTeamId, + ); + }); + + it("should reject refreshing the current active team when the session org changes externally", async () => { + await db.update({ + model: "session", + where: [ + { + field: "token", + value: sessionToken, + }, + ], + update: { + activeOrganizationId: outOfScopeOrganizationId, + }, + }); + + let error: unknown = null; + await auth.api + .setActiveTeam({ + body: {}, + headers, + }) + .catch((e) => { + error = e; + }); + + expect(error).not.toBeNull(); + expect(isAPIError(error)).toBeTruthy(); + if (!isAPIError(error)) { + throw new Error("Expected setActiveTeam to throw an APIError"); + } + expect(error.message).toBe(ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND.message); + + const sessionAfterRefreshAttempt = await client.getSession({ + fetchOptions: { headers }, + }); + expect( + (sessionAfterRefreshAttempt.data?.session as any).activeOrganizationId, + ).toBe(outOfScopeOrganizationId); + expect((sessionAfterRefreshAttempt.data?.session as any).activeTeamId).toBe( + scopedTeamId, + ); + + await db.update({ + model: "session", + where: [ + { + field: "token", + value: sessionToken, + }, + ], + update: { + activeOrganizationId: activeOrganizationId, + }, + }); + }); + + it("should require an active organization before setting the active team", async () => { + expect(activeOrganizationId).toBeDefined(); + expect(scopedTeamId).toBeDefined(); + expect(userId).toBeDefined(); + + const unsetActiveOrganization = await client.organization.setActive({ + organizationId: null, + fetchOptions: { headers }, + }); + expect(unsetActiveOrganization.error).toBeNull(); + + const result = await client.organization.setActiveTeam( + { + teamId: scopedTeamId, + }, + { + headers, + }, + ); + + expect(result.error?.status).toBe(400); + expect(result.error?.code).toContain( + ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION.code, + ); + expect(result.data).toBeNull(); + + const sessionAfterMissingOrganization = await client.getSession({ + fetchOptions: { headers }, + }); + expect( + (sessionAfterMissingOrganization.data?.session as any) + .activeOrganizationId, + ).toBeNull(); + }); +}); + describe("multi team support", async () => { const { auth, signInWithTestUser } = await getTestInstance( {