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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -42,6 +42,7 @@ declare module "next-auth" {
|
||||
role?: PrismaUser["role"] | "INACTIVE_ADMIN";
|
||||
locale?: string | null;
|
||||
profile?: UserProfile;
|
||||
samlTenant?: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user