Files
calendar/packages/features/auth/lib/samlAccountLinking.ts
T
98b6d63164 refactor: apply biome formatting to packages/features (#27844)
* refactor: apply biome formatting to packages/features (batch 1 - small subdirs)

Format small subdirectories in packages/features: di, flags, holidays, oauth,
settings, users, assignment-reason, selectedCalendar, hashedLink, host, form,
form-builder, availability, data-table, pbac, schedules, troubleshooter,
eventtypes, calendar-subscription, and root-level files.

Also includes straggler apps/web BookEventForm.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 2 - medium subdirs)

Format medium subdirectories in packages/features: auth, credentials,
calendars, routing-forms, routing-trace, attributes, watchlist, calAIPhone,
tasker, and webhooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 3 - bookings + insights)

Format bookings and insights subdirectories in packages/features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 4 - ee)

Format packages/features/ee subdirectory covering billing, workflows,
organizations, teams, managed-event-types, round-robin, dsync,
integration-attribute-sync, and payments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 5 - booking-audit part 1)

Format booking-audit di, actions, common, dto, repository, and types
subdirectories in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 6 - booking-audit part 2)

Format booking-audit service subdirectory in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:47:14 +01:00

111 lines
4.0 KiB
TypeScript

import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { OrganizationSettingsRepository } from "@calcom/features/organizations/repositories/OrganizationSettingsRepository";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
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: ReturnType<typeof logger.getSubLogger> = 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);
if (Number.isNaN(teamId)) {
return null;
}
return 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) {
// For hosted Cal.com: tenant must be in "team-{id}" format for org SSO
// For self-hosted: allow non-org tenants (admin controls the setup)
if (HOSTED_CAL_FEATURES) {
log.warn(`Blocking ${conversionContext} conversion - invalid tenant format for hosted`, {
tenant: samlTenant,
emailDomain: email.split("@")[1],
});
return { allowed: false, errorUrl: SAML_NOT_AUTHORITATIVE_ERROR_URL };
}
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 };
}