Files
calendar/packages/features/auth/lib/samlAccountLinking.test.ts
T
Pedro CastroandGitHub 9ae2381755 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
2025-12-20 17:45:18 +00:00

244 lines
8.8 KiB
TypeScript

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);
});
});