Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@
"noImgElement": "warn"
},
"suspicious": {
"noReactSpecificProps": "warn",
"noDoubleEquals": "warn",
"noAssignInExpressions": "warn",
"noExplicitAny": "warn",
Expand Down
28 changes: 22 additions & 6 deletions packages/features/auth/lib/next-auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ type SamlIdpUser = {
name: string;
email_verified: boolean;
profile: UserProfile;
samlTenant?: string;
};

if (IS_GOOGLE_LOGIN_ENABLED) {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
};
},
})
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 15 additions & 2 deletions packages/features/auth/lib/samlAccountLinking.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
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 {
if (!tenant.startsWith(tenantPrefix)) {
return null;
}
const teamId = parseInt(tenant.replace(tenantPrefix, ""), 10);
return isNaN(teamId) ? null : teamId;
if (Number.isNaN(teamId)) {
return null;
}
return teamId;
}

/**
Expand Down Expand Up @@ -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 };
}

Expand Down
1 change: 1 addition & 0 deletions packages/types/next-auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ declare module "next-auth" {
role?: PrismaUser["role"] | "INACTIVE_ADMIN";
locale?: string | null;
profile?: UserProfile;
samlTenant?: string;
}
}

Expand Down
Loading