diff --git a/apps/web/app/(use-page-wrapper)/auth/error/page.tsx b/apps/web/app/(use-page-wrapper)/auth/error/page.tsx index 48cc2ced27..6beb145de3 100644 --- a/apps/web/app/(use-page-wrapper)/auth/error/page.tsx +++ b/apps/web/app/(use-page-wrapper)/auth/error/page.tsx @@ -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}` : ""); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c0b49aecab..db5d653ab2 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index ee195a4b49..4e0465bae3 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -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 diff --git a/packages/features/auth/lib/samlAccountLinking.test.ts b/packages/features/auth/lib/samlAccountLinking.test.ts new file mode 100644 index 0000000000..59cf64a478 --- /dev/null +++ b/packages/features/auth/lib/samlAccountLinking.test.ts @@ -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); + }); +}); diff --git a/packages/features/auth/lib/samlAccountLinking.ts b/packages/features/auth/lib/samlAccountLinking.ts new file mode 100644 index 0000000000..5a5f3d45a8 --- /dev/null +++ b/packages/features/auth/lib/samlAccountLinking.ts @@ -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 { + 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 }; +} diff --git a/packages/features/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index 4004ba8f46..0fea70bd5f 100644 --- a/packages/features/membership/repositories/MembershipRepository.ts +++ b/packages/features/membership/repositories/MembershipRepository.ts @@ -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 { + 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; + } } diff --git a/packages/features/organizations/repositories/OrganizationSettingsRepository.ts b/packages/features/organizations/repositories/OrganizationSettingsRepository.ts index 9e31aa20b7..c99375d24d 100644 --- a/packages/features/organizations/repositories/OrganizationSettingsRepository.ts +++ b/packages/features/organizations/repositories/OrganizationSettingsRepository.ts @@ -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 { + 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()] : []; + } }