* 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
97 lines
3.4 KiB
TypeScript
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 };
|
|
}
|