diff --git a/.changeset/enforce-team-member-limit-direct-add.md b/.changeset/enforce-team-member-limit-direct-add.md new file mode 100644 index 0000000000..0dfeaf133a --- /dev/null +++ b/.changeset/enforce-team-member-limit-direct-add.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Adding a member to a team that is already at its `maximumMembersPerTeam` limit is now rejected on every path. `addMember` with a `teamId` and `add-team-member` previously skipped the limit that invitation acceptance enforced, so they could push a team over its cap. A rejected `addMember` no longer creates the organization member. diff --git a/.changeset/scim-token-constant-time.md b/.changeset/scim-token-constant-time.md new file mode 100644 index 0000000000..35eb3d97a2 --- /dev/null +++ b/.changeset/scim-token-constant-time.md @@ -0,0 +1,5 @@ +--- +"@better-auth/scim": patch +--- + +SCIM bearer tokens are now compared in constant time during request authentication, closing a timing side channel that could help an attacker recover a valid token. This applies to every storage mode: plain, hashed, encrypted, and custom. diff --git a/.changeset/sso-org-admin-domain-verification.md b/.changeset/sso-org-admin-domain-verification.md new file mode 100644 index 0000000000..37f4afb7b1 --- /dev/null +++ b/.changeset/sso-org-admin-domain-verification.md @@ -0,0 +1,5 @@ +--- +"@better-auth/sso": patch +--- + +Organization admins and owners can now request and verify domain ownership for an SSO provider their organization owns, even if another member registered it. Previously only the member who created the provider could verify its domain. diff --git a/packages/better-auth/src/plugins/organization/adapter.ts b/packages/better-auth/src/plugins/organization/adapter.ts index fa805a0564..d9579efddb 100644 --- a/packages/better-auth/src/plugins/organization/adapter.ts +++ b/packages/better-auth/src/plugins/organization/adapter.ts @@ -25,6 +25,35 @@ import type { } from "./schema"; import type { OrganizationOptions } from "./types"; +/** + * Resolves the configured per-team member cap to a concrete number for a given + * team-add. Returns `undefined` only when no cap is configured. Throws when the + * cap is a function but no session is available to evaluate it, so a sessionless + * server-side add fails closed instead of silently bypassing the limit. + */ +export async function resolveMaximumMembersPerTeam( + teams: OrganizationOptions["teams"], + context: { + teamId: string; + organizationId: string; + session: { user: User; session: Session } | null; + }, +): Promise { + const maximumMembersPerTeam = teams?.maximumMembersPerTeam; + if (maximumMembersPerTeam === undefined) return undefined; + if (typeof maximumMembersPerTeam === "number") return maximumMembersPerTeam; + if (!context.session) { + throw new BetterAuthError( + "`teams.maximumMembersPerTeam` is configured as a function but no session is available to evaluate it. Provide a session-bearing request or configure a numeric limit.", + ); + } + return await maximumMembersPerTeam({ + teamId: context.teamId, + session: context.session, + organizationId: context.organizationId, + }); +} + export const getOrgAdapter = ( context: AuthContext, options?: O | undefined, @@ -894,10 +923,15 @@ export const getOrgAdapter = ( }, /** * Adds a user to a team only when the team is below its member limit, - * reading the count and creating the membership in one transaction so - * concurrent accepts cannot both pass the check. Returns the existing - * membership unchanged (no capacity charge) when the user already - * belongs to the team. + * reading the count and creating the membership in one transaction. + * Returns the existing membership unchanged (no capacity charge) when the + * user already belongs to the team. + * + * FIXME(team-cap-race): the count-then-create is not atomic under READ + * COMMITTED, so two concurrent adds can both pass the count check and + * exceed maximumMembersPerTeam. A durable fix needs a unique constraint on + * teamMember(teamId, userId) or serializable isolation. Affects every + * caller (acceptInvitation, addMember, addTeamMember). */ addTeamMemberWithLimit: async (data: { teamId: string; 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 4a47cecd61..f13fa61183 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts @@ -9,7 +9,7 @@ import type { InferAdditionalFieldsFromPluginOptions } from "../../../db"; import { toZodSchema } from "../../../db"; import { getDate } from "../../../utils/date"; import { defaultRoles } from "../access/statement"; -import { getOrgAdapter } from "../adapter"; +import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter"; import { orgMiddleware, orgSessionMiddleware } from "../call"; import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import { hasPermission } from "../has-permission"; @@ -779,20 +779,15 @@ export const acceptInvitation = (options: O) => ); } - if ( - typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== - "undefined" - ) { - const maximumMembersPerTeam = - typeof ctx.context.orgOptions.teams.maximumMembersPerTeam === - "function" - ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({ - teamId, - session: session, - organizationId: acceptedI.organizationId, - }) - : ctx.context.orgOptions.teams.maximumMembersPerTeam; - + const maximumMembersPerTeam = await resolveMaximumMembersPerTeam( + ctx.context.orgOptions.teams, + { + teamId, + organizationId: acceptedI.organizationId, + session, + }, + ); + if (maximumMembersPerTeam !== undefined) { const result = await adapter.addTeamMemberWithLimit({ teamId, userId: session.user.id, 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 4a1df8ca48..e995c5c055 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts @@ -7,7 +7,7 @@ import { getSessionFromCtx, sessionMiddleware } from "../../../api"; import type { InferAdditionalFieldsFromPluginOptions } from "../../../db"; import { toZodSchema } from "../../../db/to-zod"; import { defaultRoles } from "../access/statement"; -import { getOrgAdapter } from "../adapter"; +import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter"; import { orgMiddleware, orgSessionMiddleware } from "../call"; import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import { hasPermission } from "../has-permission"; @@ -194,15 +194,44 @@ export const addMember = (option: O) => { } } - const createdMember = await adapter.createMember(memberData); + const maximumMembersPerTeam = teamId + ? await resolveMaximumMembersPerTeam(ctx.context.orgOptions.teams, { + teamId, + organizationId: orgId, + session, + }) + : undefined; + // Charge team capacity before creating the organization member so a full + // team rejects the request before any row is written. + // FIXME(team-add-atomicity): addTeamMemberWithLimit commits on its own + // transaction, so on adapters without isolated transactions a later + // createMember failure can orphan the teamMember row. Same residual as + // acceptInvitation; closed by the planned isolated-transaction adapter + // contract. if (teamId) { - await adapter.findOrCreateTeamMember({ - userId: user.id, - teamId, - }); + if (maximumMembersPerTeam !== undefined) { + const result = await adapter.addTeamMemberWithLimit({ + teamId, + userId: user.id, + maximumMembersPerTeam, + }); + if (result.status === "limitReached") { + throw APIError.from( + "FORBIDDEN", + ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED, + ); + } + } else { + await adapter.findOrCreateTeamMember({ + userId: user.id, + teamId, + }); + } } + const createdMember = await adapter.createMember(memberData); + // Run afterAddMember hook if (option?.organizationHooks?.afterAddMember) { await option?.organizationHooks.afterAddMember({ 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 8317dc8c77..858ee8e01d 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-team.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-team.ts @@ -10,10 +10,11 @@ import { setSessionCookie } from "../../../cookies"; import type { InferAdditionalFieldsFromPluginOptions } from "../../../db"; import { toZodSchema } from "../../../db"; import type { PrettifyDeep } from "../../../types/helper"; -import { getOrgAdapter } from "../adapter"; +import { getOrgAdapter, resolveMaximumMembersPerTeam } from "../adapter"; import { orgMiddleware, orgSessionMiddleware } from "../call"; import { ORGANIZATION_ERROR_CODES } from "../error-codes"; import { hasPermission } from "../has-permission"; +import type { TeamMember } from "../schema"; import { teamSchema } from "../schema"; import type { OrganizationOptions } from "../types"; @@ -1163,10 +1164,35 @@ export const addTeamMember = (options: O) => } } - const teamMember = await adapter.findOrCreateTeamMember({ - teamId: ctx.body.teamId, - userId: ctx.body.userId, - }); + const maximumMembersPerTeam = await resolveMaximumMembersPerTeam( + ctx.context.orgOptions.teams, + { + teamId: ctx.body.teamId, + organizationId, + session, + }, + ); + + let teamMember: TeamMember; + if (maximumMembersPerTeam !== undefined) { + const result = await adapter.addTeamMemberWithLimit({ + teamId: ctx.body.teamId, + userId: ctx.body.userId, + maximumMembersPerTeam, + }); + if (result.status === "limitReached") { + throw APIError.from( + "FORBIDDEN", + ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED, + ); + } + teamMember = result.member; + } else { + teamMember = await adapter.findOrCreateTeamMember({ + teamId: ctx.body.teamId, + userId: ctx.body.userId, + }); + } // Run afterAddTeamMember hook if (options?.organizationHooks?.afterAddTeamMember) { diff --git a/packages/better-auth/src/plugins/organization/team.test.ts b/packages/better-auth/src/plugins/organization/team.test.ts index 29384acc04..a2e81aaff1 100644 --- a/packages/better-auth/src/plugins/organization/team.test.ts +++ b/packages/better-auth/src/plugins/organization/team.test.ts @@ -1686,3 +1686,190 @@ describe("accept-invitation validates team capacity before adding the member", a expect(orgMembers.filter((m) => m.userId === userId)).toHaveLength(1); }); }); + +describe("direct team-add paths enforce maximumMembersPerTeam", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [ + organization({ + async sendInvitationEmail() {}, + teams: { + enabled: true, + maximumMembersPerTeam: 1, + }, + }), + ], + logger: { level: "error" }, + databaseHooks: { + user: { + create: { + before: async (user) => ({ + data: { ...user, emailVerified: true }, + }), + }, + }, + }, + }); + + const ctx = await auth.$context; + const owner = await signInWithTestUser(); + const org = await auth.api.createOrganization({ + headers: owner.headers, + body: { name: "Direct Add Org", slug: "direct-add-org" }, + }); + if (!org) throw new Error("failed to create organization"); + + let userCounter = 0; + const createOrgMember = async () => { + const created = await auth.api.signUpEmail({ + body: { + name: `Direct ${userCounter}`, + email: `direct-${userCounter++}@email.com`, + password: "password12345", + }, + }); + await auth.api.addMember({ + headers: owner.headers, + body: { userId: created.user.id, role: "member", organizationId: org.id }, + }); + return created.user.id; + }; + + const countTeamMembers = async (teamId: string) => + ( + await ctx.adapter.findMany<{ userId: string }>({ + model: "teamMember", + where: [{ field: "teamId", value: teamId }], + }) + ).length; + + it("rejects add-team-member once the team is at capacity", async () => { + const team = await auth.api.createTeam({ + headers: owner.headers, + body: { name: "Full Direct Team", organizationId: org.id }, + }); + const firstUserId = await createOrgMember(); + const secondUserId = await createOrgMember(); + + await auth.api.addTeamMember({ + headers: owner.headers, + body: { teamId: team.id, userId: firstUserId }, + }); + + await expect( + auth.api.addTeamMember({ + headers: owner.headers, + body: { teamId: team.id, userId: secondUserId }, + }), + ).rejects.toMatchObject({ + status: "FORBIDDEN", + body: { code: "TEAM_MEMBER_LIMIT_REACHED" }, + }); + + expect(await countTeamMembers(team.id)).toBe(1); + }); + + it("rejects addMember with a teamId over capacity without creating the org member", async () => { + const team = await auth.api.createTeam({ + headers: owner.headers, + body: { name: "Full AddMember Team", organizationId: org.id }, + }); + const seatedUserId = await createOrgMember(); + await auth.api.addTeamMember({ + headers: owner.headers, + body: { teamId: team.id, userId: seatedUserId }, + }); + + const overflowUser = await auth.api.signUpEmail({ + body: { + name: "Overflow", + email: "addmember-overflow@email.com", + password: "password12345", + }, + }); + + await expect( + auth.api.addMember({ + headers: owner.headers, + body: { + userId: overflowUser.user.id, + role: "member", + organizationId: org.id, + teamId: team.id, + }, + }), + ).rejects.toMatchObject({ + status: "FORBIDDEN", + body: { code: "TEAM_MEMBER_LIMIT_REACHED" }, + }); + + expect(await countTeamMembers(team.id)).toBe(1); + + // Capacity is charged before the organization member is created, so a + // rejected team-add never leaves an orphan org membership. + const orgMembers = await ctx.adapter.findMany<{ userId: string }>({ + model: "member", + where: [{ field: "organizationId", value: org.id }], + }); + expect(orgMembers.some((m) => m.userId === overflowUser.user.id)).toBe( + false, + ); + }); +}); + +describe("addMember fails closed when a function team cap needs a session", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [ + organization({ + async sendInvitationEmail() {}, + teams: { + enabled: true, + maximumMembersPerTeam: () => 1, + }, + }), + ], + logger: { level: "error" }, + databaseHooks: { + user: { + create: { + before: async (user) => ({ + data: { ...user, emailVerified: true }, + }), + }, + }, + }, + }); + + const owner = await signInWithTestUser(); + const org = await auth.api.createOrganization({ + headers: owner.headers, + body: { name: "Fn Cap Org", slug: "fn-cap-org" }, + }); + if (!org) throw new Error("failed to create organization"); + + it("rejects a sessionless add that cannot evaluate the function limit", async () => { + const team = await auth.api.createTeam({ + headers: owner.headers, + body: { name: "Fn Cap Team", organizationId: org.id }, + }); + const target = await auth.api.signUpEmail({ + body: { + name: "Target", + email: "fncap-target@email.com", + password: "password12345", + }, + }); + + // No headers: the cap function has no session to evaluate, so the add must + // fail closed rather than silently skip the limit. + await expect( + auth.api.addMember({ + body: { + userId: target.user.id, + role: "member", + organizationId: org.id, + teamId: team.id, + }, + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/scim/src/middlewares.ts b/packages/scim/src/middlewares.ts index 5a9822df43..8e7e2ef406 100644 --- a/packages/scim/src/middlewares.ts +++ b/packages/scim/src/middlewares.ts @@ -1,5 +1,6 @@ import { base64Url } from "@better-auth/utils/base64"; import { createAuthMiddleware } from "better-auth/api"; +import { constantTimeEqual } from "better-auth/crypto"; import { SCIMAPIError } from "./scim-error"; import { verifySCIMToken } from "./scim-tokens"; import type { SCIMOptions, SCIMProvider } from "./types"; @@ -47,7 +48,7 @@ export const authMiddlewareFactory = (opts: SCIMOptions) => }) ?? null; if (scimProvider) { - if (scimProvider.scimToken === scimToken) { + if (constantTimeEqual(scimProvider.scimToken, scimToken)) { return { authSCIMToken: scimProvider.scimToken, scimProvider }; } else { throw new SCIMAPIError("UNAUTHORIZED", { diff --git a/packages/scim/src/scim-tokens.ts b/packages/scim/src/scim-tokens.ts index 739be3581e..cfbaf4a92d 100644 --- a/packages/scim/src/scim-tokens.ts +++ b/packages/scim/src/scim-tokens.ts @@ -1,7 +1,11 @@ import { base64Url } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; import type { GenericEndpointContext } from "better-auth"; -import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto"; +import { + constantTimeEqual, + symmetricDecrypt, + symmetricEncrypt, +} from "better-auth/crypto"; import type { SCIMOptions } from "./types"; const defaultKeyHasher = async (token: string) => { @@ -48,23 +52,24 @@ export async function verifySCIMToken( scimToken: string, ): Promise { if (opts.storeSCIMToken === "encrypted") { - return ( - (await symmetricDecrypt({ + return constantTimeEqual( + await symmetricDecrypt({ key: ctx.context.secretConfig, data: storedSCIMToken, - })) === scimToken + }), + scimToken, ); } if (opts.storeSCIMToken === "hashed") { const hashedSCIMToken = await defaultKeyHasher(scimToken); - return hashedSCIMToken === storedSCIMToken; + return constantTimeEqual(hashedSCIMToken, storedSCIMToken); } if ( typeof opts.storeSCIMToken === "object" && "hash" in opts.storeSCIMToken ) { const hashedSCIMToken = await opts.storeSCIMToken.hash(scimToken); - return hashedSCIMToken === storedSCIMToken; + return constantTimeEqual(hashedSCIMToken, storedSCIMToken); } if ( typeof opts.storeSCIMToken === "object" && @@ -72,8 +77,8 @@ export async function verifySCIMToken( ) { const decryptedSCIMToken = await opts.storeSCIMToken.decrypt(storedSCIMToken); - return decryptedSCIMToken === scimToken; + return constantTimeEqual(decryptedSCIMToken, scimToken); } - return scimToken === storedSCIMToken; + return constantTimeEqual(scimToken, storedSCIMToken); } diff --git a/packages/scim/src/scim.management.test.ts b/packages/scim/src/scim.management.test.ts index 23b807c40b..dc16145b51 100644 --- a/packages/scim/src/scim.management.test.ts +++ b/packages/scim/src/scim.management.test.ts @@ -467,6 +467,40 @@ describe("SCIM provider management", () => { await expect(createUser()).resolves.toBeTruthy(); }); + it("rejects a SCIM token whose secret does not match the stored value", async () => { + const { auth, getAuthCookieHeaders } = createTestInstance({ + storeSCIMToken: "hashed", + }); + const headers = await getAuthCookieHeaders(); + + const response = await auth.api.generateSCIMToken({ + body: { providerId: "the id" }, + headers, + }); + + const [secret, ...rest] = Buffer.from(response.scimToken, "base64url") + .toString() + .split(":"); + // Tamper the secret while preserving its length, so verification cannot + // short-circuit on a length mismatch and must reject the value itself + // through the constant-time comparison. + const forgedSecret = `${secret.slice(0, -1)}${ + secret.endsWith("x") ? "y" : "x" + }`; + const forgedToken = Buffer.from( + [forgedSecret, ...rest].join(":"), + ).toString("base64url"); + + await expect( + auth.api.createSCIMUser({ + body: { userName: "the-username" }, + headers: { authorization: `Bearer ${forgedToken}` }, + }), + ).rejects.toThrowError( + expect.objectContaining({ status: "UNAUTHORIZED" }), + ); + }); + it("should generate a new scim token associated to an org", async () => { const { auth, registerOrganization, getAuthCookieHeaders } = createTestInstance(); diff --git a/packages/sso/src/domain-verification.test.ts b/packages/sso/src/domain-verification.test.ts index 118a21c1df..9a985ebfca 100644 --- a/packages/sso/src/domain-verification.test.ts +++ b/packages/sso/src/domain-verification.test.ts @@ -180,7 +180,6 @@ describe("Domain verification", async () => { expect(response.status).toBe(404); expect(await response.json()).toEqual({ message: "Provider not found", - code: "PROVIDER_NOT_FOUND", }); }); @@ -227,9 +226,7 @@ describe("Domain verification", async () => { expect(response.status).toBe(403); expect(await response.json()).toEqual({ - message: - "User must be owner of or belong to the SSO provider organization", - code: "INSUFICCIENT_ACCESS", + message: "You don't have access to this provider", }); }); @@ -262,9 +259,7 @@ describe("Domain verification", async () => { expect(response.status).toBe(403); expect(await response.json()).toEqual({ - message: - "User must be owner of or belong to the SSO provider organization", - code: "INSUFICCIENT_ACCESS", + message: "You don't have access to this provider", }); }); @@ -291,6 +286,43 @@ describe("Domain verification", async () => { }); }); + it("allows a non-creator org admin to request verification for an org-owned provider", async () => { + const { auth, getAuthHeaders, registerSSOProvider, createOrganization } = + createTestAuth(); + const ownerHeaders = await getAuthHeaders(testUser); + const org = await createOrganization("org-with-admin", ownerHeaders); + + // The org owner registers the provider; a different org admin who did + // not create it must still be able to verify its domain. + const provider = await registerSSOProvider(ownerHeaders, org?.id); + + const adminHeaders = await getAuthHeaders({ + name: "Org Admin", + email: "org-admin@test.com", + password: "password", + }); + const adminSession = await auth.api.getSession({ headers: adminHeaders }); + await auth.api.addMember({ + body: { + userId: adminSession!.user.id, + role: "admin", + organizationId: org!.id, + }, + headers: ownerHeaders, + }); + + const response = await auth.api.requestDomainVerification({ + body: { providerId: provider.providerId }, + headers: adminHeaders, + asResponse: true, + }); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ + domainVerificationToken: expect.any(String), + }); + }); + it("should fail to create a new token on an already verified domain", async () => { const { auth, getAuthHeaders, registerSSOProvider } = createTestAuth(); const headers = await getAuthHeaders(testUser); @@ -356,7 +388,6 @@ describe("Domain verification", async () => { expect(response.status).toBe(404); expect(await response.json()).toEqual({ message: "Provider not found", - code: "PROVIDER_NOT_FOUND", }); }); @@ -429,9 +460,7 @@ describe("Domain verification", async () => { expect(response.status).toBe(403); expect(await response.json()).toEqual({ - message: - "User must be owner of or belong to the SSO provider organization", - code: "INSUFICCIENT_ACCESS", + message: "You don't have access to this provider", }); }); @@ -462,9 +491,7 @@ describe("Domain verification", async () => { expect(response.status).toBe(403); expect(await response.json()).toEqual({ - message: - "User must be owner of or belong to the SSO provider organization", - code: "INSUFICCIENT_ACCESS", + message: "You don't have access to this provider", }); }); diff --git a/packages/sso/src/routes/domain-verification.ts b/packages/sso/src/routes/domain-verification.ts index 1e8b141f48..5b0773bf45 100644 --- a/packages/sso/src/routes/domain-verification.ts +++ b/packages/sso/src/routes/domain-verification.ts @@ -7,6 +7,7 @@ import { generateRandomString } from "better-auth/crypto"; import * as z from "zod"; import type { SSOOptions, SSOProvider } from "../types"; import { getHostnameFromDomain } from "../utils"; +import { checkProviderAccess } from "./providers"; const DNS_LABEL_MAX_LENGTH = 63; const DEFAULT_TOKEN_PREFIX = "better-auth-token"; @@ -52,43 +53,9 @@ export const requestDomainVerification = (options: SSOOptions) => { }, async (ctx) => { const body = ctx.body; - const provider = await ctx.context.adapter.findOne< - SSOProvider - >({ - model: "ssoProvider", - where: [{ field: "providerId", value: body.providerId }], - }); + const provider = await checkProviderAccess(ctx, body.providerId); - if (!provider) { - throw new APIError("NOT_FOUND", { - message: "Provider not found", - code: "PROVIDER_NOT_FOUND", - }); - } - - const userId = ctx.context.session.user.id; - let isOrgMember = true; - if (provider.organizationId) { - const membershipsCount = await ctx.context.adapter.count({ - model: "member", - where: [ - { field: "userId", value: userId }, - { field: "organizationId", value: provider.organizationId }, - ], - }); - - isOrgMember = membershipsCount > 0; - } - - if (provider.userId !== userId || !isOrgMember) { - throw new APIError("FORBIDDEN", { - message: - "User must be owner of or belong to the SSO provider organization", - code: "INSUFICCIENT_ACCESS", - }); - } - - if ("domainVerified" in provider && provider.domainVerified) { + if (provider.domainVerified) { throw new APIError("CONFLICT", { message: "Domain has already been verified", code: "DOMAIN_VERIFIED", @@ -158,43 +125,9 @@ export const verifyDomain = (options: SSOOptions) => { }, async (ctx) => { const body = ctx.body; - const provider = await ctx.context.adapter.findOne< - SSOProvider - >({ - model: "ssoProvider", - where: [{ field: "providerId", value: body.providerId }], - }); + const provider = await checkProviderAccess(ctx, body.providerId); - if (!provider) { - throw new APIError("NOT_FOUND", { - message: "Provider not found", - code: "PROVIDER_NOT_FOUND", - }); - } - - const userId = ctx.context.session.user.id; - let isOrgMember = true; - if (provider.organizationId) { - const membershipsCount = await ctx.context.adapter.count({ - model: "member", - where: [ - { field: "userId", value: userId }, - { field: "organizationId", value: provider.organizationId }, - ], - }); - - isOrgMember = membershipsCount > 0; - } - - if (provider.userId !== userId || !isOrgMember) { - throw new APIError("FORBIDDEN", { - message: - "User must be owner of or belong to the SSO provider organization", - code: "INSUFICCIENT_ACCESS", - }); - } - - if ("domainVerified" in provider && provider.domainVerified) { + if (provider.domainVerified) { throw new APIError("CONFLICT", { message: "Domain has already been verified", code: "DOMAIN_VERIFIED", diff --git a/packages/sso/src/routes/providers.ts b/packages/sso/src/routes/providers.ts index 153440fa00..f3e9de14ea 100644 --- a/packages/sso/src/routes/providers.ts +++ b/packages/sso/src/routes/providers.ts @@ -243,7 +243,7 @@ const getSSOProviderQuerySchema = z.object({ providerId: z.string(), }); -async function checkProviderAccess( +export async function checkProviderAccess( ctx: { context: AuthContext & { session: { user: { id: string } };