Files
calendar/packages/features/auth/lib/samlAccountLinking.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

97 lines
3.4 KiB
TypeScript

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