Skip to content

Commit 920320d

Browse files
authored
fix(auth): fix SAML tenant extraction and validation (calcom#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)
1 parent 66b398b commit 920320d

3 files changed

Lines changed: 38 additions & 8 deletions

File tree

packages/features/auth/lib/next-auth-options.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ type SamlIdpUser = {
284284
name: string;
285285
email_verified: boolean;
286286
profile: UserProfile;
287+
samlTenant?: string;
287288
};
288289

289290
if (IS_GOOGLE_LOGIN_ENABLED) {
@@ -349,7 +350,7 @@ if (isSAMLLoginEnabled) {
349350
locale: profile.locale,
350351
// Pass SAML tenant for domain authority checks in signIn callback
351352
samlTenant: profile.requested?.tenant,
352-
...(user ? { profile: user.allProfiles[0] } : {}),
353+
...(user && { profile: user.allProfiles[0] }),
353354
};
354355
},
355356
options: {
@@ -400,7 +401,7 @@ if (isSAMLLoginEnabled) {
400401
return null;
401402
}
402403

403-
const { id, firstName, lastName } = userInfo;
404+
const { id, firstName, lastName, requested } = userInfo;
404405
const email = userInfo.email.toLowerCase();
405406
const userRepo = new UserRepository(prisma);
406407
let user = !email ? undefined : await userRepo.findByEmailAndIncludeProfilesAndPassword({ email });
@@ -440,6 +441,8 @@ if (isSAMLLoginEnabled) {
440441
name: `${firstName} ${lastName}`.trim(),
441442
email_verified: true,
442443
profile: userProfile,
444+
// Pass SAML tenant for domain authority checks in signIn callback (IdP-initiated flow)
445+
samlTenant: requested?.tenant,
443446
};
444447
},
445448
})
@@ -817,6 +820,19 @@ export const getOptions = ({
817820

818821
log.debug("callbacks:signin", safeStringify(params));
819822

823+
// Extract samlTenant from user (credentials/saml-idp) or profile (oauth/saml)
824+
const getSamlTenant = (): string | undefined => {
825+
// Primary: user.samlTenant is set in authorize/profile callbacks (type-safe via NextAuth User extension)
826+
if (user.samlTenant) return user.samlTenant;
827+
828+
// Fallback for OAuth SAML: raw BoxyHQ profile contains requested.tenant
829+
// (NextAuth adapter doesn't pass custom fields through)
830+
if (account?.provider === "saml") {
831+
return (profile as { requested?: { tenant?: string } } | undefined)?.requested?.tenant;
832+
}
833+
return undefined;
834+
};
835+
820836
if (account?.provider === "email") {
821837
return true;
822838
}
@@ -978,7 +994,7 @@ export const getOptions = ({
978994
) {
979995
// Verify SAML IdP is authoritative before auto-merge
980996
if (idP === IdentityProvider.SAML) {
981-
const samlTenant = (user as { samlTenant?: string }).samlTenant;
997+
const samlTenant = getSamlTenant();
982998
const validation = await validateSamlAccountConversion(samlTenant, user.email, "SelfHosted→SAML");
983999
if (!validation.allowed) {
9841000
return validation.errorUrl;
@@ -1000,7 +1016,7 @@ export const getOptions = ({
10001016
) {
10011017
// Verify SAML IdP is authoritative before claiming invited user
10021018
if (idP === IdentityProvider.SAML) {
1003-
const samlTenant = (user as { samlTenant?: string }).samlTenant;
1019+
const samlTenant = getSamlTenant();
10041020
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Invite→SAML");
10051021
if (!validation.allowed) {
10061022
return validation.errorUrl;
@@ -1038,7 +1054,7 @@ export const getOptions = ({
10381054
) {
10391055
// Verify SAML IdP is authoritative before converting account
10401056
if (idP === IdentityProvider.SAML) {
1041-
const samlTenant = (user as { samlTenant?: string }).samlTenant;
1057+
const samlTenant = getSamlTenant();
10421058
const validation = await validateSamlAccountConversion(samlTenant, user.email, "CAL→SAML");
10431059
if (!validation.allowed) {
10441060
return validation.errorUrl;
@@ -1072,7 +1088,7 @@ export const getOptions = ({
10721088
idP === IdentityProvider.SAML
10731089
) {
10741090
// Verify SAML IdP is authoritative before converting account
1075-
const samlTenant = (user as { samlTenant?: string }).samlTenant;
1091+
const samlTenant = getSamlTenant();
10761092
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Google→SAML");
10771093
if (!validation.allowed) {
10781094
return validation.errorUrl;

packages/features/auth/lib/samlAccountLinking.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
22
import { OrganizationSettingsRepository } from "@calcom/features/organizations/repositories/OrganizationSettingsRepository";
3+
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
34
import logger from "@calcom/lib/logger";
45
import { prisma } from "@calcom/prisma";
56
import type { PrismaClient } from "@calcom/prisma";
67

78
import { tenantPrefix } from "../../ee/sso/lib/saml";
89

9-
const log = logger.getSubLogger({ prefix: ["samlAccountLinking"] });
10+
const log: ReturnType<typeof logger.getSubLogger> = logger.getSubLogger({ prefix: ["samlAccountLinking"] });
1011
const SAML_NOT_AUTHORITATIVE_ERROR_URL = "/auth/error?error=saml-idp-not-authoritative";
1112

1213
export function getTeamIdFromSamlTenant(tenant: string): number | null {
1314
if (!tenant.startsWith(tenantPrefix)) {
1415
return null;
1516
}
1617
const teamId = parseInt(tenant.replace(tenantPrefix, ""), 10);
17-
return isNaN(teamId) ? null : teamId;
18+
if (Number.isNaN(teamId)) {
19+
return null;
20+
}
21+
return teamId;
1822
}
1923

2024
/**
@@ -78,6 +82,15 @@ export async function validateSamlAccountConversion(
7882

7983
const samlOrgTeamId = getTeamIdFromSamlTenant(samlTenant);
8084
if (!samlOrgTeamId) {
85+
// For hosted Cal.com: tenant must be in "team-{id}" format for org SSO
86+
// For self-hosted: allow non-org tenants (admin controls the setup)
87+
if (HOSTED_CAL_FEATURES) {
88+
log.warn(`Blocking ${conversionContext} conversion - invalid tenant format for hosted`, {
89+
tenant: samlTenant,
90+
emailDomain: email.split("@")[1],
91+
});
92+
return { allowed: false, errorUrl: SAML_NOT_AUTHORITATIVE_ERROR_URL };
93+
}
8194
return { allowed: true };
8295
}
8396

packages/types/next-auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ declare module "next-auth" {
4242
role?: PrismaUser["role"] | "INACTIVE_ADMIN";
4343
locale?: string | null;
4444
profile?: UserProfile;
45+
samlTenant?: string;
4546
}
4647
}
4748

0 commit comments

Comments
 (0)