mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-19 04:08:43 -05:00
fix: enforce team capacity, constant-time SCIM tokens, and org-admin SSO domain verification (#10002)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<number | undefined> {
|
||||
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 = <O extends OrganizationOptions>(
|
||||
context: AuthContext,
|
||||
options?: O | undefined,
|
||||
@@ -894,10 +923,15 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
},
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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 = <O extends OrganizationOptions>(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,
|
||||
|
||||
@@ -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 = <O extends OrganizationOptions>(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({
|
||||
|
||||
@@ -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 = <O extends OrganizationOptions>(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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<SSOOptions>
|
||||
>({
|
||||
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<SSOOptions>
|
||||
>({
|
||||
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",
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user