fix(auth): fix SAML tenant extraction and validation (#26482)

- Extract tenant from userInfo in saml-idp (IdP-initiated)
- Add profile.requested?.tenant fallback for OAuth (SP-initiated)
- Block invalid tenant format in hosted (security)
This commit is contained in:
Pedro Castro
2026-01-05 18:52:48 +00:00
committed by GitHub
parent 66b398b4ed
commit 920320d49b
3 changed files with 38 additions and 8 deletions
@@ -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;
@@ -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<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 {
@@ -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 };
}
+1
View File
@@ -42,6 +42,7 @@ declare module "next-auth" {
role?: PrismaUser["role"] | "INACTIVE_ADMIN";
locale?: string | null;
profile?: UserProfile;
samlTenant?: string;
}
}