diff --git a/biome.json b/biome.json index ce1eb7a28d22e4..9b305aa059f7c1 100644 --- a/biome.json +++ b/biome.json @@ -121,7 +121,6 @@ "noImgElement": "warn" }, "suspicious": { - "noReactSpecificProps": "warn", "noDoubleEquals": "warn", "noAssignInExpressions": "warn", "noExplicitAny": "warn", diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 449d17a7e9a82d..4ab888eed4d54d 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -284,6 +284,7 @@ type SamlIdpUser = { name: string; email_verified: boolean; profile: UserProfile; + samlTenant?: string; }; if (IS_GOOGLE_LOGIN_ENABLED) { @@ -349,7 +350,7 @@ if (isSAMLLoginEnabled) { locale: profile.locale, // Pass SAML tenant for domain authority checks in signIn callback samlTenant: profile.requested?.tenant, - ...(user ? { profile: user.allProfiles[0] } : {}), + ...(user && { profile: user.allProfiles[0] }), }; }, options: { @@ -400,7 +401,7 @@ if (isSAMLLoginEnabled) { return null; } - const { id, firstName, lastName } = userInfo; + const { id, firstName, lastName, requested } = userInfo; const email = userInfo.email.toLowerCase(); const userRepo = new UserRepository(prisma); let user = !email ? undefined : await userRepo.findByEmailAndIncludeProfilesAndPassword({ email }); @@ -440,6 +441,8 @@ if (isSAMLLoginEnabled) { name: `${firstName} ${lastName}`.trim(), email_verified: true, profile: userProfile, + // Pass SAML tenant for domain authority checks in signIn callback (IdP-initiated flow) + samlTenant: requested?.tenant, }; }, }) @@ -817,6 +820,19 @@ export const getOptions = ({ log.debug("callbacks:signin", safeStringify(params)); + // Extract samlTenant from user (credentials/saml-idp) or profile (oauth/saml) + const getSamlTenant = (): string | undefined => { + // Primary: user.samlTenant is set in authorize/profile callbacks (type-safe via NextAuth User extension) + if (user.samlTenant) return user.samlTenant; + + // Fallback for OAuth SAML: raw BoxyHQ profile contains requested.tenant + // (NextAuth adapter doesn't pass custom fields through) + if (account?.provider === "saml") { + return (profile as { requested?: { tenant?: string } } | undefined)?.requested?.tenant; + } + return undefined; + }; + if (account?.provider === "email") { return true; } @@ -978,7 +994,7 @@ export const getOptions = ({ ) { // Verify SAML IdP is authoritative before auto-merge if (idP === IdentityProvider.SAML) { - const samlTenant = (user as { samlTenant?: string }).samlTenant; + const samlTenant = getSamlTenant(); const validation = await validateSamlAccountConversion(samlTenant, user.email, "SelfHosted→SAML"); if (!validation.allowed) { return validation.errorUrl; @@ -1000,7 +1016,7 @@ export const getOptions = ({ ) { // Verify SAML IdP is authoritative before claiming invited user if (idP === IdentityProvider.SAML) { - const samlTenant = (user as { samlTenant?: string }).samlTenant; + const samlTenant = getSamlTenant(); const validation = await validateSamlAccountConversion(samlTenant, user.email, "Invite→SAML"); if (!validation.allowed) { return validation.errorUrl; @@ -1038,7 +1054,7 @@ export const getOptions = ({ ) { // Verify SAML IdP is authoritative before converting account if (idP === IdentityProvider.SAML) { - const samlTenant = (user as { samlTenant?: string }).samlTenant; + const samlTenant = getSamlTenant(); const validation = await validateSamlAccountConversion(samlTenant, user.email, "CAL→SAML"); if (!validation.allowed) { return validation.errorUrl; @@ -1072,7 +1088,7 @@ export const getOptions = ({ idP === IdentityProvider.SAML ) { // Verify SAML IdP is authoritative before converting account - const samlTenant = (user as { samlTenant?: string }).samlTenant; + const samlTenant = getSamlTenant(); const validation = await validateSamlAccountConversion(samlTenant, user.email, "Google→SAML"); if (!validation.allowed) { return validation.errorUrl; diff --git a/packages/features/auth/lib/samlAccountLinking.ts b/packages/features/auth/lib/samlAccountLinking.ts index 5a5f3d45a8e94e..0e2a339e8a3a4d 100644 --- a/packages/features/auth/lib/samlAccountLinking.ts +++ b/packages/features/auth/lib/samlAccountLinking.ts @@ -1,12 +1,13 @@ 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 = logger.getSubLogger({ prefix: ["samlAccountLinking"] }); +const log: ReturnType = logger.getSubLogger({ prefix: ["samlAccountLinking"] }); const SAML_NOT_AUTHORITATIVE_ERROR_URL = "/auth/error?error=saml-idp-not-authoritative"; export function getTeamIdFromSamlTenant(tenant: string): number | null { @@ -14,7 +15,10 @@ export function getTeamIdFromSamlTenant(tenant: string): number | null { return null; } const teamId = parseInt(tenant.replace(tenantPrefix, ""), 10); - return isNaN(teamId) ? null : teamId; + if (Number.isNaN(teamId)) { + return null; + } + return teamId; } /** @@ -78,6 +82,15 @@ export async function validateSamlAccountConversion( 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 }; } diff --git a/packages/types/next-auth.d.ts b/packages/types/next-auth.d.ts index 769858fea7c88c..faaff924ae79aa 100644 --- a/packages/types/next-auth.d.ts +++ b/packages/types/next-auth.d.ts @@ -42,6 +42,7 @@ declare module "next-auth" { role?: PrismaUser["role"] | "INACTIVE_ADMIN"; locale?: string | null; profile?: UserProfile; + samlTenant?: string; } }