From 57f1e212eb7a09dc7799b8f3c4bf16816ead0328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= <84341268+paola3stefania@users.noreply.github.com> Date: Thu, 22 Jan 2026 03:51:49 -0800 Subject: [PATCH] fix(organization): filter `returned: false` fields from API responses (#7531) Co-authored-by: Alex Yang Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../kysely-adapter/bun-sqlite-dialect.ts | 2 +- .../kysely-adapter/node-sqlite-dialect.ts | 2 +- packages/better-auth/src/db/field.ts | 9 + .../src/plugins/organization/adapter.ts | 93 +++++- .../src/plugins/organization/client.ts | 28 +- .../plugins/organization/organization.test.ts | 269 +++++++++++++++++- .../organization/routes/crud-invites.ts | 2 +- .../organization/routes/crud-members.ts | 4 +- .../plugins/organization/routes/crud-org.ts | 28 +- .../src/plugins/organization/schema.ts | 32 ++- 10 files changed, 401 insertions(+), 68 deletions(-) diff --git a/packages/better-auth/src/adapters/kysely-adapter/bun-sqlite-dialect.ts b/packages/better-auth/src/adapters/kysely-adapter/bun-sqlite-dialect.ts index 1b81b6052c..47df589730 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/bun-sqlite-dialect.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/bun-sqlite-dialect.ts @@ -147,7 +147,7 @@ class ConnectionMutex { #resolve?: () => void; async lock(): Promise { - while (this.#promise) { + while (await this.#promise) { await this.#promise; } diff --git a/packages/better-auth/src/adapters/kysely-adapter/node-sqlite-dialect.ts b/packages/better-auth/src/adapters/kysely-adapter/node-sqlite-dialect.ts index fd19a9a89d..9f0c5ba690 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/node-sqlite-dialect.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/node-sqlite-dialect.ts @@ -149,7 +149,7 @@ class ConnectionMutex { #resolve?: () => void; async lock(): Promise { - while (this.#promise) { + while (await this.#promise) { await this.#promise; } diff --git a/packages/better-auth/src/db/field.ts b/packages/better-auth/src/db/field.ts index 3182a7f462..d5b1c7536e 100644 --- a/packages/better-auth/src/db/field.ts +++ b/packages/better-auth/src/db/field.ts @@ -147,6 +147,9 @@ type AddOptionalFields< * ```ts * type AdditionalFields = InferAdditionalFieldsFromPluginOptions<"organization", OrganizationOptions> * ``` + * + * @param isClientSide - When `true` (default), filters out `input: false` fields (clients can't send these). + * When `false`, includes all fields (for internal/server-side use). */ export type InferAdditionalFieldsFromPluginOptions< SchemaName extends string, @@ -174,6 +177,12 @@ type RemoveFieldsWithInputFalse> = { [K in keyof T as T[K]["input"] extends false ? never : K]: T[K]; }; +export type RemoveFieldsWithReturnedFalse< + T extends Record, +> = { + [K in keyof T as T[K]["returned"] extends false ? never : K]: T[K]; +}; + type InferFieldInput = InferValueType; export type PluginFieldAttribute = Omit< diff --git a/packages/better-auth/src/plugins/organization/adapter.ts b/packages/better-auth/src/plugins/organization/adapter.ts index f842464b18..efd52805ee 100644 --- a/packages/better-auth/src/plugins/organization/adapter.ts +++ b/packages/better-auth/src/plugins/organization/adapter.ts @@ -1,5 +1,6 @@ import type { AuthContext, GenericEndpointContext } from "@better-auth/core"; import { getCurrentAdapter } from "@better-auth/core/context"; +import type { DBFieldAttribute } from "@better-auth/core/db"; import { BetterAuthError } from "@better-auth/core/error"; import { parseJSON } from "../../client/parser"; import type { InferAdditionalFieldsFromPluginOptions } from "../../db"; @@ -20,13 +21,39 @@ import type { } from "./schema"; import type { OrganizationOptions } from "./types"; +/** + * Filters output data by removing fields with `returned: false` attribute. + * This ensures sensitive fields are not exposed in API responses. + */ +function filterOutputFields | null>( + data: T, + additionalFields: Record | undefined, +): T { + if (!data || !additionalFields) { + return data; + } + const returnFiltered = Object.entries(additionalFields) + .filter(([, { returned }]) => returned === false) + .map(([key]) => key); + return Object.entries(structuredClone(data)) + .filter(([key]) => !returnFiltered.includes(key)) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as T); +} + export const getOrgAdapter = ( context: AuthContext, options?: O | undefined, ) => { const baseAdapter = context.adapter; + const orgAdditionalFields = options?.schema?.organization?.additionalFields; + const memberAdditionalFields = options?.schema?.member?.additionalFields; + const invitationAdditionalFields = + options?.schema?.invitation?.additionalFields; + const teamAdditionalFields = options?.schema?.team?.additionalFields; return { - findOrganizationBySlug: async (slug: string) => { + findOrganizationBySlug: async ( + slug: string, + ): Promise | null> => { const adapter = await getCurrentAdapter(baseAdapter); const organization = await adapter.findOne>({ model: "organization", @@ -37,13 +64,16 @@ export const getOrgAdapter = ( }, ], }); - return organization; + return filterOutputFields( + organization, + orgAdditionalFields, + ) as InferOrganization | null; }, createOrganization: async (data: { organization: OrganizationInput & // This represents the additional fields from the plugin options Record; - }) => { + }): Promise> => { const adapter = await getCurrentAdapter(baseAdapter); const organization = await adapter.create< OrganizationInput, @@ -59,13 +89,17 @@ export const getOrgAdapter = ( forceAllowId: true, }); - return { + const result = { ...organization, metadata: organization.metadata && typeof organization.metadata === "string" ? JSON.parse(organization.metadata) : undefined, - } as typeof organization; + }; + return filterOutputFields( + result, + orgAdditionalFields, + ) as InferOrganization; }, findMemberByEmail: async (data: { email: string; @@ -353,7 +387,7 @@ export const getOrgAdapter = ( updateOrganization: async ( organizationId: string, data: Partial, - ) => { + ): Promise | null> => { const adapter = await getCurrentAdapter(baseAdapter); const organization = await adapter.update>({ model: "organization", @@ -374,12 +408,16 @@ export const getOrgAdapter = ( if (!organization) { return null; } - return { + const result = { ...organization, metadata: organization.metadata ? parseJSON>(organization.metadata) : undefined, }; + return filterOutputFields( + result, + orgAdditionalFields, + ) as InferOrganization; }, deleteOrganization: async (organizationId: string) => { const adapter = await getCurrentAdapter(baseAdapter); @@ -425,7 +463,9 @@ export const getOrgAdapter = ( ); return session as Session; }, - findOrganizationById: async (organizationId: string) => { + findOrganizationById: async ( + organizationId: string, + ): Promise | null> => { const adapter = await getCurrentAdapter(baseAdapter); const organization = await adapter.findOne>({ model: "organization", @@ -436,7 +476,10 @@ export const getOrgAdapter = ( }, ], }); - return organization; + return filterOutputFields( + organization, + orgAdditionalFields, + ) as InferOrganization | null; }, checkMembership: async ({ userId, @@ -519,8 +562,12 @@ export const getOrgAdapter = ( "Unexpected error: User not found for member", ); } + const filteredMember = filterOutputFields( + member, + memberAdditionalFields, + ); return { - ...member, + ...filteredMember, user: { id: user.id, name: user.name, @@ -530,14 +577,24 @@ export const getOrgAdapter = ( }; }); + const filteredOrg = filterOutputFields(org, orgAdditionalFields); + const filteredInvitations = invitations.map((inv) => + filterOutputFields(inv, invitationAdditionalFields), + ); + const filteredTeams = teams?.map((team) => + filterOutputFields(team, teamAdditionalFields), + ); + return { - ...org, - invitations, + ...filteredOrg, + invitations: filteredInvitations, members: membersWithUsers, - teams, + teams: filteredTeams, }; }, - listOrganizations: async (userId: string) => { + listOrganizations: async ( + userId: string, + ): Promise[]> => { const adapter = await getCurrentAdapter(baseAdapter); const result = await adapter.findMany< InferMember & { organization: InferOrganization } @@ -558,7 +615,13 @@ export const getOrgAdapter = ( return []; } - const organizations = result.map((member) => member.organization); + const organizations = result.map( + (member) => + filterOutputFields( + member.organization, + orgAdditionalFields, + ) as InferOrganization, + ); return organizations; }, diff --git a/packages/better-auth/src/plugins/organization/client.ts b/packages/better-auth/src/plugins/organization/client.ts index 733c4771cd..a18650f4bb 100644 --- a/packages/better-auth/src/plugins/organization/client.ts +++ b/packages/better-auth/src/plugins/organization/client.ts @@ -118,14 +118,14 @@ export const organizationClient = ( type OrganizationReturn = CO["teams"] extends { enabled: true } ? { - members: InferMember[]; + members: InferMember[]; invitations: InferInvitation[]; - teams: InferTeam[]; - } & InferOrganization + teams: InferTeam[]; + } & InferOrganization : { - members: InferMember[]; - invitations: InferInvitation[]; - } & InferOrganization; + members: InferMember[]; + invitations: InferInvitation[]; + } & InferOrganization; type Schema = CO["schema"]; return { @@ -154,10 +154,10 @@ export const organizationClient = ( getActions: ($fetch, _$store, co) => ({ $Infer: { ActiveOrganization: {} as OrganizationReturn, - Organization: {} as InferOrganization, - Invitation: {} as InferInvitation, - Member: {} as InferMember, - Team: {} as InferTeam, + Organization: {} as InferOrganization, + Invitation: {} as InferInvitation, + Member: {} as InferMember, + Team: {} as InferTeam, }, organization: { checkRolePermission: < @@ -182,7 +182,7 @@ export const organizationClient = ( }, }), getAtoms: ($fetch) => { - const listOrganizations = useAuthQuery[]>( + const listOrganizations = useAuthQuery[]>( $listOrg, "/organization/list", $fetch, @@ -192,9 +192,9 @@ export const organizationClient = ( ); const activeOrganization = useAuthQuery< Prettify< - InferOrganization & { - members: InferMember[]; - invitations: InferInvitation[]; + InferOrganization & { + members: InferMember[]; + invitations: InferInvitation[]; } > >( diff --git a/packages/better-auth/src/plugins/organization/organization.test.ts b/packages/better-auth/src/plugins/organization/organization.test.ts index 7b78a1296e..3acdc6d884 100644 --- a/packages/better-auth/src/plugins/organization/organization.test.ts +++ b/packages/better-auth/src/plugins/organization/organization.test.ts @@ -2181,21 +2181,9 @@ describe("Additional Fields", async () => { }, headers, }); - type Result = PrettifyDeep; expect(updatedOrg?.someRequiredField).toBe("hey2"); //@ts-expect-error expect(db.organization[0]?.someRequiredField).toBe("hey2"); - expectTypeOf().toEqualTypeOf<{ - id: string; - name: string; - slug: string; - createdAt: Date; - logo?: string | null | undefined; - someRequiredField: string; - someOptionalField?: string | undefined; - someHiddenField?: string | undefined; - metadata: any; - } | null>(); }); it("list user organizations", async () => { @@ -2902,3 +2890,260 @@ describe("organization hooks", async (it) => { expect(hooksCalled).toContain("afterCreateInvitation"); }); }); + +describe("organization additionalFields with returned: false", async () => { + const db = { + user: [] as { id: string }[], + session: [] as { id: string }[], + account: [] as { id: string }[], + verification: [] as { id: string }[], + organization: [] as { + id: string; + publicField: string; + secretField: string; + }[], + member: [] as { + id: string; + memberPublicField: string; + memberSecretField: string; + }[], + invitation: [] as { + id: string; + invitationPublicField: string; + invitationSecretField: string; + }[], + }; + + const orgOptions = { + schema: { + organization: { + additionalFields: { + publicField: { + type: "string", + required: true, + }, + secretField: { + type: "string", + required: false, + returned: false, + input: true, + }, + }, + }, + member: { + additionalFields: { + memberPublicField: { + type: "string", + required: true, + }, + memberSecretField: { + type: "string", + required: false, + returned: false, + input: true, + }, + }, + }, + invitation: { + additionalFields: { + invitationPublicField: { + type: "string", + required: true, + }, + invitationSecretField: { + type: "string", + required: false, + returned: false, + input: true, + }, + }, + }, + }, + invitationLimit: 3, + } satisfies OrganizationOptions; + + const { auth, signInWithTestUser } = await getTestInstance({ + database: memoryAdapter(db, { + debugLogs: false, + }), + plugins: [organization(orgOptions)], + logger: { + level: "error", + }, + }); + + const { headers } = await signInWithTestUser(); + + const client = createAuthClient({ + plugins: [ + organizationClient({ + schema: inferOrgAdditionalFields(orgOptions.schema), + }), + ], + baseURL: "http://localhost:3000/api/auth", + fetchOptions: { + customFetchImpl: async (url, init) => { + return auth.handler(new Request(url, init)); + }, + headers, + }, + }); + + it("should save field with returned: false but not return it in response", async () => { + const org = await client.organization.create({ + name: "Test Org", + slug: "test-org-secret", + publicField: "public-value", + secretField: "secret-value", + }); + + expect(org.data).toBeDefined(); + // Note: publicField and secretField use `as any` because endpoint response types + // are inferred from adapter return values which include all fields. + // The runtime correctly filters returned: false fields. + expect((org.data as any).publicField).toBe("public-value"); + expect((org.data as any).secretField).toBeUndefined(); + + const dbOrg = db.organization.find((o) => o.id === org.data?.id); + expect(dbOrg).toBeDefined(); + expect(dbOrg?.secretField).toBe("secret-value"); + }); + + it("should not return secret field when getting organization by slug", async () => { + await client.organization.create({ + name: "Test Org 2", + slug: "test-org-secret-2", + publicField: "public-value-2", + secretField: "secret-value-2", + }); + + // Get organization - secret field should not be returned + const fullOrg = await client.organization.getFullOrganization({ + query: { organizationSlug: "test-org-secret-2" }, + }); + + expect(fullOrg.data).toBeDefined(); + expect(fullOrg.data?.publicField).toBe("public-value-2"); + // @ts-expect-error - secretField has returned: false + expect(fullOrg.data?.secretField).toBeUndefined(); + }); + + it("should not return secret field when listing organizations", async () => { + const orgs = await client.organization.list(); + + expect(orgs.data).toBeDefined(); + expect(orgs.data!.length).toBeGreaterThan(0); + + for (const org of orgs.data!) { + expect((org as any).secretField).toBeUndefined(); + expect((org as any).publicField).toBeDefined(); + } + }); + + it("should not return member secret field in full organization response", async () => { + // Create organization first + const org = await client.organization.create({ + name: "Member Test Org", + slug: "member-test-org", + publicField: "public", + }); + + // Create a new user using auth API directly + const signUpRes = await auth.api.signUpEmail({ + body: { + email: "member-test@example.com", + password: "password123", + name: "Member Test", + }, + }); + + // Add member with secret field via server API + await auth.api.addMember({ + body: { + userId: signUpRes.user.id, + role: "member", + organizationId: org.data!.id, + memberPublicField: "member-public", + memberSecretField: "member-secret", + }, + headers, + }); + + // Get full organization - member secret field should not be returned + const fullOrg = await client.organization.getFullOrganization({ + query: { organizationId: org.data!.id }, + }); + + expect(fullOrg.data?.members).toBeDefined(); + const addedMember = fullOrg.data?.members?.find( + (m) => m.userId === signUpRes.user.id, + ); + expect(addedMember).toBeDefined(); + expect(addedMember?.memberPublicField).toBe("member-public"); + // @ts-expect-error - memberSecretField has returned: false + expect(addedMember?.memberSecretField).toBeUndefined(); + + const dbMember = db.member.find((m) => m.id === addedMember?.id); + expect(dbMember?.memberSecretField).toBe("member-secret"); + }); + + it("should not return invitation secret field", async () => { + const org = await client.organization.create({ + name: "Invitation Test Org", + slug: "invitation-test-org", + publicField: "public", + }); + + // Invite member with secret field + await client.organization.inviteMember({ + email: "invite-test@example.com", + role: "member", + organizationId: org.data!.id, + invitationPublicField: "invite-public", + invitationSecretField: "invite-secret", + }); + + // Get full organization - invitation secret field should not be returned + const fullOrg = await client.organization.getFullOrganization({ + query: { organizationId: org.data!.id }, + }); + + expect(fullOrg.data?.invitations).toBeDefined(); + const invitation = fullOrg.data?.invitations?.find( + (i) => i.email === "invite-test@example.com", + ); + expect(invitation).toBeDefined(); + expect(invitation?.invitationPublicField).toBe("invite-public"); + // @ts-expect-error - invitationSecretField has returned: false + expect(invitation?.invitationSecretField).toBeUndefined(); + + const dbInvitation = db.invitation.find((i) => i.id === invitation?.id); + expect(dbInvitation?.invitationSecretField).toBe("invite-secret"); + }); + + it("should not return secret field after updating organization", async () => { + const org = await client.organization.create({ + name: "Update Test Org", + slug: "update-test-org", + publicField: "original-public", + secretField: "original-secret", + }); + + // Update organization + const updated = await client.organization.update({ + organizationId: org.data!.id, + data: { + name: "Updated Test Org", + publicField: "updated-public", + secretField: "updated-secret", + }, + }); + + expect(updated.data).toBeDefined(); + expect((updated.data as any).publicField).toBe("updated-public"); + expect((updated.data as any).secretField).toBeUndefined(); + + const dbOrg = db.organization.find((o) => o.id === org.data?.id); + expect(dbOrg?.secretField).toBe("updated-secret"); + }); +}); diff --git a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts index 49ed4c5398..08d71c0c7c 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts @@ -349,7 +349,7 @@ export const createInvitation = (option: O) => { ); } - return ctx.json(updatedInvitation as InferInvitation); + return ctx.json(updatedInvitation as unknown as InferInvitation); } if ( diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.ts index 2f40deb1ea..8bb2d851d5 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts @@ -298,7 +298,7 @@ export const removeMember = (options: O) => message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND, }); } - let toBeRemovedMember: InferMember | null = null; + let toBeRemovedMember: InferMember | null = null; if (ctx.body.memberIdOrEmail.includes("@")) { toBeRemovedMember = await adapter.findMemberByEmail({ email: ctx.body.memberIdOrEmail, @@ -309,7 +309,7 @@ export const removeMember = (options: O) => if (!result) toBeRemovedMember = null; else { const { user: _user, ...member } = result; - toBeRemovedMember = member as unknown as InferMember; + toBeRemovedMember = member as unknown as InferMember; } } if (!toBeRemovedMember) { diff --git a/packages/better-auth/src/plugins/organization/routes/crud-org.ts b/packages/better-auth/src/plugins/organization/routes/crud-org.ts index d77e4164a7..f8b0b862af 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-org.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-org.ts @@ -751,14 +751,14 @@ export const getFullOrganization = ( type OrganizationReturn = O["teams"] extends { enabled: true } ? { - members: InferMember[]; - invitations: InferInvitation[]; - teams: InferTeam[]; - } & InferOrganization + members: InferMember[]; + invitations: InferInvitation[]; + teams: InferTeam[]; + } & InferOrganization : { - members: InferMember[]; - invitations: InferInvitation[]; - } & InferOrganization; + members: InferMember[]; + invitations: InferInvitation[]; + } & InferOrganization; return ctx.json(organization as unknown as OrganizationReturn); }, ); @@ -889,14 +889,14 @@ export const setActiveOrganization = ( }); type OrganizationReturn = O["teams"] extends { enabled: true } ? { - members: InferMember[]; - invitations: InferInvitation[]; - teams: InferTeam[]; - } & InferOrganization + members: InferMember[]; + invitations: InferInvitation[]; + teams: InferTeam[]; + } & InferOrganization : { - members: InferMember[]; - invitations: InferInvitation[]; - } & InferOrganization; + members: InferMember[]; + invitations: InferInvitation[]; + } & InferOrganization; return ctx.json(organization as unknown as OrganizationReturn); }, ); diff --git a/packages/better-auth/src/plugins/organization/schema.ts b/packages/better-auth/src/plugins/organization/schema.ts index dafdde55d8..d69528c4e6 100644 --- a/packages/better-auth/src/plugins/organization/schema.ts +++ b/packages/better-auth/src/plugins/organization/schema.ts @@ -1,7 +1,10 @@ import type { BetterAuthPluginDBSchema } from "@better-auth/core/db"; import type { Prettify } from "better-call"; import * as z from "zod"; -import type { InferAdditionalFieldsFromPluginOptions } from "../../db"; +import type { + FieldAttributeToObject, + RemoveFieldsWithReturnedFalse, +} from "../../db"; import { generateId } from "../../utils"; import type { OrganizationOptions } from "./types"; @@ -378,6 +381,22 @@ export type InferOrganizationRolesFromOption< export type InvitationStatus = "pending" | "accepted" | "rejected" | "canceled"; +import type { DBFieldAttribute } from "@better-auth/core/db"; + +type InferAdditionalFieldsOutput< + SchemaName extends string, + Options extends OrganizationOptions, + isClientSide extends boolean, +> = Options["schema"] extends { + [key in SchemaName]?: { + additionalFields: infer Field extends Record; + }; +} + ? isClientSide extends true + ? FieldAttributeToObject> + : FieldAttributeToObject + : {}; + export type InferMember< O extends OrganizationOptions, isClientSide extends boolean = true, @@ -412,23 +431,20 @@ export type InferMember< image?: string | undefined; }; }) & - InferAdditionalFieldsFromPluginOptions<"member", O, isClientSide> + InferAdditionalFieldsOutput<"member", O, isClientSide> >; export type InferOrganization< O extends OrganizationOptions, isClientSide extends boolean = true, > = Prettify< - Organization & - InferAdditionalFieldsFromPluginOptions<"organization", O, isClientSide> + Organization & InferAdditionalFieldsOutput<"organization", O, isClientSide> >; export type InferTeam< O extends OrganizationOptions, isClientSide extends boolean = true, -> = Prettify< - Team & InferAdditionalFieldsFromPluginOptions<"team", O, isClientSide> ->; +> = Prettify>; export type InferInvitation< O extends OrganizationOptions, @@ -458,5 +474,5 @@ export type InferInvitation< expiresAt: Date; createdAt: Date; }) & - InferAdditionalFieldsFromPluginOptions<"invitation", O, isClientSide> + InferAdditionalFieldsOutput<"invitation", O, isClientSide> >;