fix(auth): validate IdP authority before SAML account linking (#26005)

* fix(auth): validate IdP authority before SAML account linking

Adds verification that SAML IdP is authoritative for the email domain
before allowing account conversion

* fix(auth): deny by default on missing SAML tenant + optimize membership query

 - Block account conversion when tenant is missing (deny by default)
 - Replace JOIN + ILIKE with two indexed lookups for O(1) performance

* refactor(auth): apply data minimization to security logs
This commit is contained in:
Pedro Castro
2025-12-20 17:45:18 +00:00
committed by GitHub
parent a37805439b
commit 9ae2381755
7 changed files with 427 additions and 1 deletions
@@ -47,6 +47,8 @@ const ServerPage = async ({ searchParams }: PageProps) => {
? "SAML (like Okta)"
: "your original login method";
return t("account_managed_by_identity_provider_error", { provider: providerName });
} else if (error === "saml-idp-not-authoritative") {
return t("saml_idp_not_authoritative_error");
}
return t("error_during_login") + (error ? ` Error code: ${error}` : "");
};
@@ -4168,6 +4168,7 @@
"timestamp": "Timestamp",
"json": "JSON",
"hubspot_ignore_guests": "Do not create new records for guests added to the booking",
"saml_idp_not_authoritative_error": "This SAML identity provider is not authorized to manage your account. Please sign in with your original login method.",
"audit_logs_organization_required": "You must be part of an organization to view audit logs.",
"audit_logs_booking_not_found_or_permission_denied": "Booking not found or you do not have permission to view its audit logs.",
"audit_logs_booking_has_no_owner": "Cannot verify permissions: booking has no associated user.",
@@ -47,6 +47,7 @@ import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail";
import { ErrorCode } from "./ErrorCode";
import { dub } from "./dub";
import { validateSamlAccountConversion } from "./samlAccountLinking";
import CalComAdapter from "./next-auth-custom-adapter";
import { verifyPassword } from "./verifyPassword";
@@ -315,6 +316,10 @@ if (isSAMLLoginEnabled) {
lastName?: string;
email?: string;
locale?: string;
requested?: {
tenant?: string;
product?: string;
};
}) => {
log.debug("BoxyHQ:profile", safeStringify({ profile }));
const userRepo = new UserRepository(prisma);
@@ -329,6 +334,8 @@ if (isSAMLLoginEnabled) {
name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
email_verified: true,
locale: profile.locale,
// Pass SAML tenant for domain authority checks in signIn callback
samlTenant: profile.requested?.tenant,
...(user ? { profile: user.allProfiles[0] } : {}),
};
},
@@ -939,6 +946,15 @@ export const getOptions = ({
existingUserWithEmail.emailVerified &&
existingUserWithEmail.identityProvider !== IdentityProvider.CAL
) {
// Verify SAML IdP is authoritative before auto-merge
if (idP === IdentityProvider.SAML) {
const samlTenant = (user as { samlTenant?: string }).samlTenant;
const validation = await validateSamlAccountConversion(samlTenant, user.email, "SelfHosted→SAML");
if (!validation.allowed) {
return validation.errorUrl;
}
}
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
@@ -952,6 +968,15 @@ export const getOptions = ({
!existingUserWithEmail.emailVerified &&
!existingUserWithEmail.username
) {
// Verify SAML IdP is authoritative before claiming invited user
if (idP === IdentityProvider.SAML) {
const samlTenant = (user as { samlTenant?: string }).samlTenant;
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Invite→SAML");
if (!validation.allowed) {
return validation.errorUrl;
}
}
await prisma.user.update({
where: {
email: existingUserWithEmail.email,
@@ -981,6 +1006,15 @@ export const getOptions = ({
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
) {
// Verify SAML IdP is authoritative before converting account
if (idP === IdentityProvider.SAML) {
const samlTenant = (user as { samlTenant?: string }).samlTenant;
const validation = await validateSamlAccountConversion(samlTenant, user.email, "CAL→SAML");
if (!validation.allowed) {
return validation.errorUrl;
}
}
await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
@@ -1002,6 +1036,13 @@ export const getOptions = ({
existingUserWithEmail.identityProvider === IdentityProvider.GOOGLE &&
idP === IdentityProvider.SAML
) {
// Verify SAML IdP is authoritative before converting account
const samlTenant = (user as { samlTenant?: string }).samlTenant;
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Google→SAML");
if (!validation.allowed) {
return validation.errorUrl;
}
await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
@@ -0,0 +1,243 @@
import type { PrismaClient } from "@prisma/client";
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { OrganizationSettingsRepository } from "@calcom/features/organizations/repositories/OrganizationSettingsRepository";
import {
SamlAccountLinkingService,
getTeamIdFromSamlTenant,
validateSamlAccountConversion,
} from "./samlAccountLinking";
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
},
}));
vi.mock("@calcom/prisma", () => ({
prisma: {},
}));
const mockPrismaClient = {} as PrismaClient;
let hasAcceptedMembershipSpy: MockInstance;
let getVerifiedDomainsSpy: MockInstance;
function setupMocks(config: { verifiedDomains?: string[]; hasMembership?: boolean }) {
hasAcceptedMembershipSpy = vi
.spyOn(MembershipRepository.prototype, "hasAcceptedMembershipByEmail")
.mockResolvedValue(config.hasMembership ?? false);
getVerifiedDomainsSpy = vi
.spyOn(OrganizationSettingsRepository.prototype, "getVerifiedDomains")
.mockResolvedValue(config.verifiedDomains ?? []);
}
describe("getTeamIdFromSamlTenant", () => {
it("extracts team ID from valid tenant string", () => {
expect(getTeamIdFromSamlTenant("team-123")).toBe(123);
expect(getTeamIdFromSamlTenant("team-1")).toBe(1);
expect(getTeamIdFromSamlTenant("team-999999")).toBe(999999);
});
it("returns null for invalid tenant formats", () => {
expect(getTeamIdFromSamlTenant("")).toBeNull();
expect(getTeamIdFromSamlTenant("invalid")).toBeNull();
expect(getTeamIdFromSamlTenant("org-123")).toBeNull();
expect(getTeamIdFromSamlTenant("team-")).toBeNull();
expect(getTeamIdFromSamlTenant("team-abc")).toBeNull();
});
it("returns null for tenant with non-numeric ID", () => {
expect(getTeamIdFromSamlTenant("team-abc123")).toBeNull();
});
it("truncates decimal values (parseInt behavior)", () => {
expect(getTeamIdFromSamlTenant("team-12.5")).toBe(12);
});
});
describe("SamlAccountLinkingService.isSamlIdpAuthoritativeForEmail", () => {
let service: SamlAccountLinkingService;
const ORG_TEAM_ID = 123;
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns authoritative when email domain matches org verified domain", async () => {
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: false });
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@acme.com");
expect(result).toEqual({ authoritative: true, reason: "domain_verified" });
expect(hasAcceptedMembershipSpy).not.toHaveBeenCalled();
});
it("performs case-insensitive domain matching", async () => {
setupMocks({ verifiedDomains: ["acme.com"] });
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@ACME.COM");
expect(result).toEqual({ authoritative: true, reason: "domain_verified" });
});
it("matches subdomains of verified domain", async () => {
setupMocks({ verifiedDomains: ["acme.com"] });
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@sales.acme.com");
expect(result).toEqual({ authoritative: true, reason: "domain_verified" });
});
it("returns not authoritative when domain doesn't match and user is not member", async () => {
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: false });
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@different.com");
expect(result).toEqual({ authoritative: false, reason: "domain_mismatch" });
});
it("returns authoritative for existing org member with different domain", async () => {
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: true });
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@personal-email.com");
expect(result).toEqual({ authoritative: true, reason: "existing_member" });
});
it("skips membership check when domain matches", async () => {
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: true });
service = new SamlAccountLinkingService(mockPrismaClient);
await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@acme.com");
expect(hasAcceptedMembershipSpy).not.toHaveBeenCalled();
});
it("returns not authoritative for invalid email", async () => {
setupMocks({});
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "invalid-email");
expect(result).toEqual({ authoritative: false, reason: "invalid_email" });
expect(getVerifiedDomainsSpy).not.toHaveBeenCalled();
});
it("returns not authoritative for empty email", async () => {
setupMocks({});
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "");
expect(result).toEqual({ authoritative: false, reason: "invalid_email" });
});
it("falls back to membership when org has no verified domains", async () => {
setupMocks({ verifiedDomains: [], hasMembership: true });
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "user@any-domain.com");
expect(result).toEqual({ authoritative: true, reason: "existing_member" });
});
it("rejects when org has no verified domains and user is not member", async () => {
setupMocks({ verifiedDomains: [], hasMembership: false });
service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(ORG_TEAM_ID, "attacker@evil.com");
expect(result).toEqual({ authoritative: false, reason: "domain_mismatch" });
});
});
describe("validateSamlAccountConversion", () => {
beforeEach(() => {
vi.restoreAllMocks();
setupMocks({ verifiedDomains: [], hasMembership: false });
});
it("blocks when no SAML tenant provided (deny by default)", async () => {
const result = await validateSamlAccountConversion(undefined, "user@example.com", "CAL→SAML");
expect(result).toEqual({
allowed: false,
errorUrl: "/auth/error?error=saml-idp-not-authoritative",
});
});
it("allows when tenant is not org-based", async () => {
const result = await validateSamlAccountConversion("Cal.com", "user@example.com", "CAL→SAML");
expect(result).toEqual({ allowed: true });
});
it("blocks when IdP is not authoritative", async () => {
const result = await validateSamlAccountConversion("team-123", "attacker@evil.com", "CAL→SAML");
expect(result).toEqual({
allowed: false,
errorUrl: "/auth/error?error=saml-idp-not-authoritative",
});
});
it("allows when IdP is authoritative", async () => {
setupMocks({ verifiedDomains: ["acme.com"] });
const result = await validateSamlAccountConversion("team-123", "user@acme.com", "CAL→SAML");
expect(result).toEqual({ allowed: true });
});
});
describe("Security: SAML Account Takeover Prevention", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("blocks takeover when attacker org asserts victim's email", async () => {
setupMocks({ verifiedDomains: ["attacker-org.com"], hasMembership: false });
const result = await validateSamlAccountConversion("team-999", "victim@gmail.com", "Google→SAML");
expect(result.allowed).toBe(false);
});
it("allows SSO when org owns the email domain", async () => {
setupMocks({ verifiedDomains: ["acme.com"] });
const result = await validateSamlAccountConversion("team-100", "employee@acme.com", "CAL→SAML");
expect(result).toEqual({ allowed: true });
});
it("allows SSO for existing members with personal email", async () => {
setupMocks({ verifiedDomains: ["acme.com"], hasMembership: true });
const service = new SamlAccountLinkingService(mockPrismaClient);
const result = await service.isSamlIdpAuthoritativeForEmail(100, "contractor@personal-email.com");
expect(result.authoritative).toBe(true);
expect(result.reason).toBe("existing_member");
});
it("blocks invite takeover when attacker org claims invited user", async () => {
// Org A invites victim@gmail.com, attacker in Org B tries to claim via SAML
setupMocks({ verifiedDomains: ["attacker-org.com"], hasMembership: false });
const result = await validateSamlAccountConversion("team-999", "victim@gmail.com", "Invite→SAML");
expect(result.allowed).toBe(false);
});
});
@@ -0,0 +1,96 @@
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { OrganizationSettingsRepository } from "@calcom/features/organizations/repositories/OrganizationSettingsRepository";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import { tenantPrefix } from "../../ee/sso/lib/saml";
const log = logger.getSubLogger({ prefix: ["samlAccountLinking"] });
const SAML_NOT_AUTHORITATIVE_ERROR_URL = "/auth/error?error=saml-idp-not-authoritative";
export function getTeamIdFromSamlTenant(tenant: string): number | null {
if (!tenant.startsWith(tenantPrefix)) {
return null;
}
const teamId = parseInt(tenant.replace(tenantPrefix, ""), 10);
return isNaN(teamId) ? null : teamId;
}
/**
* Prevents account takeover via malicious SAML IdPs asserting arbitrary emails.
* IdP is authoritative when domain matches org's verified domain or user is already a member.
*/
export class SamlAccountLinkingService {
private membershipRepository: MembershipRepository;
private orgSettingsRepository: OrganizationSettingsRepository;
constructor(prismaClient: PrismaClient = prisma) {
this.membershipRepository = new MembershipRepository(prismaClient);
this.orgSettingsRepository = new OrganizationSettingsRepository(prismaClient);
}
async isSamlIdpAuthoritativeForEmail(
samlOrgTeamId: number,
email: string
): Promise<{ authoritative: boolean; reason: string }> {
const emailDomain = email.split("@")[1]?.toLowerCase();
if (!emailDomain) {
return { authoritative: false, reason: "invalid_email" };
}
const verifiedDomains = await this.orgSettingsRepository.getVerifiedDomains(samlOrgTeamId);
const domainMatches = verifiedDomains.some(
(verified) => emailDomain === verified || emailDomain.endsWith(`.${verified}`)
);
if (domainMatches) {
return { authoritative: true, reason: "domain_verified" };
}
const hasMembership = await this.membershipRepository.hasAcceptedMembershipByEmail({
email,
teamId: samlOrgTeamId,
});
if (hasMembership) {
return { authoritative: true, reason: "existing_member" };
}
return { authoritative: false, reason: "domain_mismatch" };
}
}
export type AccountConversionValidationResult =
| { allowed: true }
| { allowed: false; errorUrl: string };
export async function validateSamlAccountConversion(
samlTenant: string | undefined,
email: string,
conversionContext: string
): Promise<AccountConversionValidationResult> {
if (!samlTenant) {
// Deny by default - if tenant is missing, we cannot verify IdP authority
log.error("SAML conversion blocked - missing tenant", { emailDomain: email.split("@")[1], conversionContext });
return { allowed: false, errorUrl: SAML_NOT_AUTHORITATIVE_ERROR_URL };
}
const samlOrgTeamId = getTeamIdFromSamlTenant(samlTenant);
if (!samlOrgTeamId) {
return { allowed: true };
}
const service = new SamlAccountLinkingService(prisma);
const authority = await service.isSamlIdpAuthoritativeForEmail(samlOrgTeamId, email);
if (!authority.authoritative) {
log.warn(`Blocking ${conversionContext} conversion - IdP not authoritative`, {
emailDomain: email.split("@")[1],
samlOrgTeamId,
reason: authority.reason,
});
return { allowed: false, errorUrl: SAML_NOT_AUTHORITATIVE_ERROR_URL };
}
return { allowed: true };
}
@@ -560,4 +560,29 @@ export class MembershipRepository {
},
});
}
// Two indexed lookups instead of JOIN with ILIKE (which bypasses index)
async hasAcceptedMembershipByEmail({
email,
teamId,
}: {
email: string;
teamId: number;
}): Promise<boolean> {
const user = await this.prismaClient.user.findUnique({
where: { email: email.toLowerCase() },
select: { id: true },
});
if (!user) return false;
const membership = await this.prismaClient.membership.findUnique({
where: {
userId_teamId: { userId: user.id, teamId },
},
select: { accepted: true },
});
return membership?.accepted ?? false;
}
}
@@ -1,7 +1,7 @@
import type { PrismaClient } from "@calcom/prisma";
export class OrganizationSettingsRepository {
constructor(private prismaClient: PrismaClient) {}
constructor(private readonly prismaClient: PrismaClient) {}
async getEmailSettings(organizationId: number) {
return await this.prismaClient.organizationSettings.findUnique({
@@ -19,4 +19,22 @@ export class OrganizationSettingsRepository {
},
});
}
// Returns array for future multi-domain support
async getVerifiedDomains(organizationId: number): Promise<string[]> {
const settings = await this.prismaClient.organizationSettings.findUnique({
where: { organizationId },
select: {
isOrganizationVerified: true,
orgAutoAcceptEmail: true,
},
});
if (!settings?.isOrganizationVerified) {
return [];
}
const domain = settings.orgAutoAcceptEmail;
return domain ? [domain.toLowerCase()] : [];
}
}