Files
calendar/packages/features/auth/lib/next-auth-options.ts
T
Anik Dhabal BabuGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>bot_apk
f86767c125 fix: correct admin password banner message and auto-sign-out after 2FA enable (#28129)
* fix: correct admin password banner message to require both password length and 2FA

The banner message incorrectly used 'or' implying only one condition was needed,
but the code requires BOTH a password of at least 15 characters AND 2FA enabled.

Updated the message to clearly state both requirements and added a hint that
users need to log out and log back in after updating their security settings.

Fixes #9527

Co-Authored-By: unknown <>

* fix: auto-sign-out INACTIVE_ADMIN users after enabling 2FA

When an INACTIVE_ADMIN user enables 2FA, automatically sign them out so
they can log back in with refreshed session role, dismissing the banner.
This matches the existing behavior for password changes.

Co-Authored-By: unknown <>

* feat: add dynamic admin banner message based on inactiveAdminReason

Co-Authored-By: unknown <>

* Remove and add various localization strings

* Update common.json

* Add cookie consent checkbox message and remove entries

* test: add unit tests for AdminPasswordBanner and inactiveAdminReason logic

Co-Authored-By: unknown <>

* fix: add expires field to session mock to fix type check

Co-Authored-By: unknown <>

* fix: wrap CALENDSO_ENCRYPTION_KEY mutations in try/finally to prevent env state leaks

Addresses Cubic AI review feedback (confidence 9/10): when a test fails
early, the CALENDSO_ENCRYPTION_KEY env var was not being restored, which
could leak state into subsequent tests. Wrapped the env mutation in
try/finally blocks to guarantee cleanup.

Co-Authored-By: bot_apk <apk@cognition.ai>

* fix: add missing 'expires' field to buildSession in AdminPasswordBanner test

The Session type requires 'expires' to be a string, but buildSession was
not providing it, causing a type error caught by CI type-check.

Co-Authored-By: bot_apk <apk@cognition.ai>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: bot_apk <apk@cognition.ai>
2026-03-10 12:46:31 -03:00

1534 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
import { updateProfilePhotoMicrosoft } from "@calcom/app-store/_utils/oauth/updateProfilePhotoMicrosoft";
import { createGoogleCalendarServiceWithGoogleType } from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { getIdentityProvider } from "@calcom/features/auth/lib/identityProviders";
import {
OUTLOOK_CLIENT_ID,
OUTLOOK_CLIENT_SECRET,
OUTLOOK_LOGIN_ENABLED,
} from "@calcom/features/auth/lib/outlook";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { buildCredentialCreateData } from "@calcom/features/credentials/services/CredentialDataService";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg";
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { isPasswordValid } from "@calcom/lib/auth/isPasswordValid";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import {
ENABLE_PROFILE_SWITCHER,
GOOGLE_CALENDAR_SCOPES,
GOOGLE_OAUTH_SCOPES,
HOSTED_CAL_FEATURES,
IS_CALCOM,
IS_TEAM_BILLING_ENABLED,
MICROSOFT_CALENDAR_SCOPES,
WEBAPP_URL,
} from "@calcom/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies";
import { isENVDev } from "@calcom/lib/env";
import logger from "@calcom/lib/logger";
import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import slugify from "@calcom/lib/slugify";
import type { TrackingData } from "@calcom/lib/tracking";
import prisma from "@calcom/prisma";
import type { Membership, Team } from "@calcom/prisma/client";
import { CreationSource, IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile";
import { calendar_v3 } from "@googleapis/calendar";
import { waitUntil } from "@vercel/functions";
import { OAuth2Client } from "googleapis-common";
import type { Account, AuthOptions, Profile, Session, User } from "next-auth";
import type { AdapterUser } from "next-auth/adapters";
import type { JWT } from "next-auth/jwt";
import { encode } from "next-auth/jwt";
import AzureADProvider from "next-auth/providers/azure-ad";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
import type { Provider } from "next-auth/providers/index";
import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail";
import { dub } from "./dub";
import { ErrorCode } from "./ErrorCode";
import CalComAdapter from "./next-auth-custom-adapter";
import { validateSamlAccountConversion } from "./samlAccountLinking";
import { verifyPassword } from "./verifyPassword";
type UserWithProfiles = NonNullable<
Awaited<ReturnType<UserRepository["findByEmailAndIncludeProfilesAndPassword"]>>
>;
interface ExtendedOAuthProfile extends Profile {
email_verified?: boolean; // Google/OIDC standard
xms_edov?: boolean | string | number; // Azure AD specific
}
// This adapts our internal user model to what NextAuth expects
// NextAuth core requires id to be a string, so we handle that here
const AdapterUserPresenter = {
fromCalUser: (
user: UserWithProfiles,
role: UserPermissionRole | "INACTIVE_ADMIN",
hasActiveTeams: boolean
) => ({
...user,
role: role as UserPermissionRole,
belongsToActiveTeam: hasActiveTeams,
profile: user.allProfiles[0],
}),
};
// Account presenter to handle linkAccount calls
const AdapterAccountPresenter = {
fromCalAccount: (account: Account, userId: number, providerEmail: string) => {
return {
...account,
userId: String(userId), // Convert userId to string for Next Auth
providerEmail,
// Ensure these required fields are present
provider: account.provider,
providerAccountId: account.providerAccountId,
type: account.type,
};
},
};
const log = logger.getSubLogger({ prefix: ["next-auth-options"] });
const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "{}";
const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } =
JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {};
const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true";
const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED);
const ORGANIZATIONS_AUTOLINK =
process.env.ORGANIZATIONS_AUTOLINK === "1" || process.env.ORGANIZATIONS_AUTOLINK === "true";
const usernameSlug = (username: string) => `${slugify(username)}-${randomString(6).toLowerCase()}`;
const getDomainFromEmail = (email: string): string => email.split("@")[1];
const loginWithTotp = async (email: string) =>
`/auth/login?totp=${encodeURIComponent(await (await import("./signJwt")).default({ email }))}`;
type UserTeams = {
teams: (Membership & {
team: Pick<Team, "metadata">;
})[];
};
export const checkIfUserBelongsToActiveTeam = <T extends UserTeams>(user: T) =>
user.teams.some((m: { team: { metadata: unknown } }) => {
if (!IS_TEAM_BILLING_ENABLED) {
return true;
}
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
return metadata.success && metadata.data?.subscriptionId;
});
const checkIfUserShouldBelongToOrg = async (idP: IdentityProvider, email: string) => {
const [orgUsername, apexDomain] = email.split("@");
if (!ORGANIZATIONS_AUTOLINK || (idP !== "GOOGLE" && idP !== "AZUREAD"))
return { orgUsername, orgId: undefined };
const existingOrg = await prisma.team.findFirst({
where: {
organizationSettings: {
isOrganizationVerified: true,
orgAutoAcceptEmail: apexDomain,
},
},
select: {
id: true,
},
});
return { orgUsername, orgId: existingOrg?.id };
};
/**
* Authorize function for credentials provider
* Extracted for testability
*/
export async function authorizeCredentials(
credentials: Record<"email" | "password" | "totpCode" | "backupCode", string> | undefined
): Promise<User | null> {
log.debug("CredentialsProvider:credentials:authorize", safeStringify({ credentials }));
if (!credentials) {
console.error(`For some reason credentials are missing`);
throw new Error(ErrorCode.InternalServerError);
}
const userRepo = new UserRepository(prisma);
const user = await userRepo.findByEmailAndIncludeProfilesAndPassword({
email: credentials.email,
});
// Don't leak information about it being username or password that is invalid
if (!user) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
// Locked users cannot login
if (user.locked) {
throw new Error(ErrorCode.UserAccountLocked);
}
await checkRateLimitAndThrowError({
identifier: hashEmail(user.email),
});
// Users without a password must use their identity provider (Google/SAML) to login
if (!user.password?.hash) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
// Always verify password for users who have one
const isCorrectPassword = await verifyPassword(credentials.password, user.password.hash);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
if (user.twoFactorEnabled && credentials.backupCode) {
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with backup code login.");
throw new Error(ErrorCode.InternalServerError);
}
if (!user.backupCodes) throw new Error(ErrorCode.MissingBackupCodes);
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
// check if user-supplied code matches one
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
if (index === -1) throw new Error(ErrorCode.IncorrectBackupCode);
// delete verified backup code and re-encrypt remaining
backupCodes[index] = null;
await prisma.user.update({
where: {
id: user.id,
},
data: {
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
},
});
} else if (user.twoFactorEnabled) {
if (!credentials.totpCode) {
throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
const isValidToken = (await import("@calcom/lib/totp")).totpAuthenticatorCheck(
credentials.totpCode,
secret
);
if (!isValidToken) {
throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
}
// Check if the user you are logging into has any active teams
const hasActiveTeams = checkIfUserBelongsToActiveTeam(user);
// authentication success- but does it meet the minimum password requirements?
const validateRole = (role: UserPermissionRole) => {
// User's role is not "ADMIN"
if (role !== UserPermissionRole.ADMIN) return role;
// User's identity provider is not "CAL"
if (user.identityProvider !== IdentityProvider.CAL) return role;
if (process.env.NEXT_PUBLIC_IS_E2E) {
console.warn("E2E testing is enabled, skipping password and 2FA requirements for Admin");
return role;
}
// User's password is valid and two-factor authentication is enabled
if (isPasswordValid(credentials.password, false, true) && user.twoFactorEnabled) return role;
// Code is running in a development environment
if (isENVDev) return role;
// By this point it is an ADMIN without valid security conditions
return "INACTIVE_ADMIN";
};
const role = validateRole(user.role);
const baseUser = AdapterUserPresenter.fromCalUser(user, role, hasActiveTeams);
if (role === "INACTIVE_ADMIN") {
const passwordValid = isPasswordValid(credentials.password, false, true);
const has2FA = user.twoFactorEnabled;
let reason: "both" | "password" | "2fa";
if (!passwordValid && !has2FA) {
reason = "both";
} else if (!passwordValid) {
reason = "password";
} else {
reason = "2fa";
}
return { ...baseUser, inactiveAdminReason: reason };
}
return baseUser;
}
export const CalComCredentialsProvider = CredentialsProvider({
id: "credentials",
name: "Cal.com",
type: "credentials",
credentials: {
email: {
label: "Email Address",
type: "email",
placeholder: "john.doe@example.com",
},
password: {
label: "Password",
type: "password",
placeholder: "Your super secure password",
},
totpCode: {
label: "Two-factor Code",
type: "input",
placeholder: "Code from authenticator app",
},
backupCode: {
label: "Backup Code",
type: "input",
placeholder: "Two-factor backup code",
},
},
authorize: authorizeCredentials,
});
const providers: Provider[] = [CalComCredentialsProvider, ImpersonationProvider];
type SamlIdpUser = {
id: number;
userId: number;
firstName: string;
lastName: string;
email: string;
name: string;
email_verified: boolean;
profile: UserProfile;
samlTenant?: string;
};
if (IS_GOOGLE_LOGIN_ENABLED) {
providers.push(
GoogleProvider({
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
authorization: {
params: {
scope: [...GOOGLE_OAUTH_SCOPES, ...GOOGLE_CALENDAR_SCOPES].join(" "),
access_type: "offline",
prompt: "consent",
},
},
})
);
}
if (isSAMLLoginEnabled) {
providers.push({
id: "saml",
name: "BoxyHQ",
type: "oauth",
version: "2.0",
checks: ["pkce", "state"],
authorization: {
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
params: {
scope: "",
response_type: "code",
provider: "saml",
},
},
token: {
url: `${WEBAPP_URL}/api/auth/saml/token`,
params: { grant_type: "authorization_code" },
},
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
profile: async (profile: {
id?: number;
firstName?: string;
lastName?: string;
email?: string;
locale?: string;
requested?: {
tenant?: string;
product?: string;
};
}) => {
log.debug("BoxyHQ:profile", safeStringify({ profile }));
if (!profile.email) {
log.warn("saml:profile - email missing from IdP response", {
hasFirstName: !!profile.firstName,
hasLastName: !!profile.lastName,
tenant: profile.requested?.tenant,
});
}
const userRepo = new UserRepository(prisma);
const user = await userRepo.findByEmailAndIncludeProfilesAndPassword({
email: profile.email || "",
});
return {
id: profile.id || 0,
firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || "",
name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
email_verified: true,
locale: profile.locale,
// Pass SAML tenant for domain authority checks in signIn callback
samlTenant: profile.requested?.tenant,
...(user && { profile: user.allProfiles[0] }),
};
},
options: {
clientId: "dummy",
clientSecret: clientSecretVerifier,
},
allowDangerousEmailAccountLinking: true,
});
// Idp initiated login
providers.push(
CredentialsProvider({
id: "saml-idp",
name: "IdP Login",
credentials: {
code: {},
},
async authorize(credentials): Promise<SamlIdpUser | null> {
log.debug("CredentialsProvider:saml-idp:authorize", safeStringify({ credentials }));
if (!credentials) {
log.warn("saml-idp:authorize - missing credentials object");
return null;
}
const { code } = credentials;
if (!code) {
log.warn("saml-idp:authorize - missing code in credentials");
return null;
}
const { oauthController } = await (await import("@calcom/features/ee/sso/lib/jackson")).default();
// Fetch access token
const { access_token } = await oauthController.token({
code,
grant_type: "authorization_code",
redirect_uri: `${process.env.NEXTAUTH_URL}`,
client_id: "dummy",
client_secret: clientSecretVerifier,
});
if (!access_token) {
log.warn("saml-idp:authorize - failed to obtain access_token from oauthController.token");
return null;
}
// Fetch user info
const userInfo = await oauthController.userInfo(access_token);
if (!userInfo) {
log.warn("saml-idp:authorize - failed to obtain userInfo from oauthController.userInfo");
return null;
}
const { id, firstName, lastName, requested } = userInfo;
const email = userInfo.email.toLowerCase();
const userRepo = new UserRepository(prisma);
let user = !email ? undefined : await userRepo.findByEmailAndIncludeProfilesAndPassword({ email });
if (!user) {
const hostedCal = Boolean(HOSTED_CAL_FEATURES);
if (hostedCal && email) {
const domain = getDomainFromEmail(email);
const organizationRepository = getOrganizationRepository();
const org = await organizationRepository.getVerifiedOrganizationByAutoAcceptEmailDomain(domain);
if (org) {
const createUsersAndConnectToOrgProps = {
emailsToCreate: [email],
identityProvider: IdentityProvider.SAML,
identityProviderId: email,
};
await createUsersAndConnectToOrg({
createUsersAndConnectToOrgProps,
org,
});
user = await userRepo.findByEmailAndIncludeProfilesAndPassword({
email: email,
});
}
}
if (!user) {
log.warn("saml-idp:authorize - user not found and could not be auto-provisioned", {
emailDomain: email.split("@")[1],
hostedCal: Boolean(HOSTED_CAL_FEATURES),
});
throw new Error(ErrorCode.UserNotFound);
}
}
const [userProfile] = user?.allProfiles ?? [];
return {
// This `id` is actually email as sent by the saml configuration of NameId=email
// Instead of changing it, we introduce a new userId field to the object
// Also, another reason to not touch it is that setting to to user.id starts breaking the saml-idp flow with an uncaught error something related to that it is expected to be a string
id: id as unknown as number,
userId: user.id,
firstName,
lastName,
email,
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,
};
},
})
);
}
if (OUTLOOK_LOGIN_ENABLED && OUTLOOK_CLIENT_ID && OUTLOOK_CLIENT_SECRET) {
providers.push(
AzureADProvider({
clientId: OUTLOOK_CLIENT_ID,
clientSecret: OUTLOOK_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
authorization: {
params: {
scope: ["openid", "profile", "email", ...MICROSOFT_CALENDAR_SCOPES].join(" "),
prompt: "consent",
},
},
// Azure AD returns base64-encoded picture data (~9KB) that bloats the JWT cookie.
// we exclude it here and fetch the profile photo separately via Microsoft Graph API.
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
};
},
})
);
}
providers.push(
EmailProvider({
type: "email",
maxAge: 10 * 60 * 60, // Magic links are valid for 10 min only
// Here we setup the sendVerificationRequest that calls the email template with the identifier (email) and token to verify.
sendVerificationRequest: async (props) => (await import("./sendVerificationRequest")).default(props),
})
);
function isNumber(n: string) {
return !isNaN(parseFloat(n)) && !isNaN(+n);
}
const calcomAdapter = CalComAdapter(prisma);
export const getOptions = ({
getDubId,
getTrackingData,
}: {
/** so we can extract the Dub cookie in both pages and app routers */
getDubId: () => string | undefined;
/** Ad tracking data for Stripe customer metadata */
getTrackingData: () => TrackingData;
}): AuthOptions => ({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
adapter: calcomAdapter,
session: {
strategy: "jwt",
},
jwt: {
// decorate the native JWT encode function
// Impl. detail: We don't pass through as this function is called with encode/decode functions.
encode: async ({ token, maxAge, secret }) => {
log.debug("jwt:encode", safeStringify({ token, maxAge }));
if (token?.sub && isNumber(token.sub)) {
const user = await prisma.user.findFirst({
where: { id: Number(token.sub) },
select: { metadata: true },
});
// if no user is found, we still don't want to crash here.
if (user) {
const metadata = userMetadata.parse(user.metadata);
if (metadata?.sessionTimeout) {
maxAge = metadata.sessionTimeout * 60;
}
}
}
return encode({ secret, token, maxAge });
},
},
cookies: defaultCookies(WEBAPP_URL?.startsWith("https://")),
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
error: "/auth/error", // Error code passed in query string as ?error=
verifyRequest: "/auth/verify",
// newUser: "/auth/new", // New users will be directed here on first sign in (leave the property out if not of interest)
},
providers,
callbacks: {
async jwt({
// Always available but with a little difference in value
token,
// Available only in case of signIn, signUp or useSession().update call.
trigger,
// Available when useSession().update is called. The value will be the POST data
session,
// Available only in the first call once the user signs in. Not available in subsequent calls
user,
// Available only in the first call once the user signs in. Not available in subsequent calls
account,
}) {
log.debug("callbacks:jwt", safeStringify({ token, user, account, trigger, session }));
// The data available in 'session' depends on what data was supplied in update method call of session
if (trigger === "update") {
return {
...token,
profileId: session?.profileId ?? token.profileId ?? null,
upId: session?.upId ?? token.upId ?? null,
locale: session?.locale ?? token.locale ?? "en",
name: session?.name ?? token.name,
username: session?.username ?? token.username,
email: session?.email ?? token.email,
} as JWT;
}
const autoMergeIdentities = async () => {
const existingUser = await prisma.user.findFirst({
where: { email: token.email! },
select: {
id: true,
username: true,
avatarUrl: true,
name: true,
email: true,
role: true,
locale: true,
movedToProfileId: true,
teams: {
include: {
team: {
select: {
id: true,
metadata: true,
},
},
},
},
},
});
if (!existingUser) {
return token;
}
// Check if the existingUser has any active teams
const belongsToActiveTeam = checkIfUserBelongsToActiveTeam(existingUser);
const { teams: _teams, ...existingUserWithoutTeamsField } = existingUser;
const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(existingUser);
log.debug(
"callbacks:jwt:autoMergeIdentities",
safeStringify({
allProfiles,
})
);
const { upId } = determineProfile({ profiles: allProfiles, token });
const profile = await ProfileRepository.findByUpIdWithAuth(upId, existingUser.id);
if (!profile) {
throw new Error("Profile not found");
}
const profileOrg = profile?.organization;
let orgRole: MembershipRole | undefined;
// Get users role of org
if (profileOrg) {
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
teamId: profileOrg.id,
userId: existingUser.id,
},
},
});
orgRole = membership?.role;
}
const autoMergeJwt = {
...existingUserWithoutTeamsField,
...token,
profileId: profile.id,
upId,
belongsToActiveTeam,
orgAwareUsername: profileOrg ? profile.username : existingUser.username,
// All organizations in the token would be too big to store. It breaks the sessions request.
// So, we just set the currently switched organization only here.
// platform org user don't need profiles nor domains
org:
profileOrg && !profileOrg.isPlatform
? {
id: profileOrg.id,
name: profileOrg.name,
slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "",
logoUrl: profileOrg.logoUrl,
fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""),
domainSuffix: subdomainSuffix(),
role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg
}
: null,
} as JWT;
return autoMergeJwt;
};
if (!user) {
return await autoMergeIdentities();
}
if (!account) {
return token;
}
if (account.type === "credentials") {
log.debug("callbacks:jwt:accountType:credentials", safeStringify({ account }));
// return token if credentials,saml-idp
if (account.provider === "saml-idp") {
const samlIdpUser = user as SamlIdpUser;
const updatedToken = {
...token,
// Server Session explicitly requires sub to be userId. So, override what is set by BoxyHQ
sub: samlIdpUser.userId.toString(),
upId: samlIdpUser.profile?.upId ?? token.upId ?? null,
} as JWT;
return updatedToken;
}
// any other credentials, add user info
return {
...token,
id: user.id,
name: user.name,
username: user.username,
orgAwareUsername: user?.org ? user.profile?.username : user.username,
email: user.email,
role: user.role,
impersonatedBy: user.impersonatedBy,
belongsToActiveTeam: user?.belongsToActiveTeam,
org: user?.org,
locale: user?.locale,
profileId: user.profile?.id ?? token.profileId ?? null,
upId: user.profile?.upId ?? token.upId ?? null,
inactiveAdminReason: user.inactiveAdminReason,
} as JWT;
}
// The arguments above are from the provider so we need to look up the
// user based on those values in order to construct a JWT.
if (account.type === "oauth") {
log.debug("callbacks:jwt:accountType:oauth", safeStringify({ account }));
if (!account.provider || !account.providerAccountId) {
return {
...token,
upId: user.profile?.upId ?? token.upId ?? null,
} as JWT;
}
const idP = getIdentityProvider(account.provider);
if (!idP) {
log.warn("callbacks:jwt:accountType:oauth - unknown provider, falling back to auto-merge", {
provider: account.provider,
});
return await autoMergeIdentities();
}
const existingUser = await prisma.user.findFirst({
where: {
AND: [
{
identityProvider: idP,
},
{
identityProviderId: account.providerAccountId,
},
],
},
});
if (!existingUser) {
return await autoMergeIdentities();
}
const grantedScopes = account.scope?.split(" ") ?? [];
if (
account.provider === "google" &&
!(await CredentialRepository.findFirstByAppIdAndUserId({
userId: Number(user.id),
appId: "google-calendar",
})) &&
GOOGLE_CALENDAR_SCOPES.every((scope) => grantedScopes.includes(scope))
) {
// Installing Google Calendar by default
const credentialkey = {
access_token: account.access_token,
refresh_token: account.refresh_token,
id_token: account.id_token,
token_type: account.token_type,
expires_at: account.expires_at,
};
const gcalCredentialData = buildCredentialCreateData({
userId: Number(user.id),
key: credentialkey,
appId: "google-calendar",
type: "google_calendar",
});
const gcalCredential = await CredentialRepository.create(gcalCredentialData);
const gCalService = createGoogleCalendarServiceWithGoogleType({
...gcalCredential,
user: null,
delegatedTo: null,
});
if (
!(await CredentialRepository.findFirstByUserIdAndType({
userId: Number(user.id),
type: "google_video",
}))
) {
const googleMeetCredentialData = buildCredentialCreateData({
type: "google_video",
key: {},
userId: Number(user.id),
appId: "google-meet",
});
await CredentialRepository.create(googleMeetCredentialData);
}
const oAuth2Client = new OAuth2Client(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
oAuth2Client.setCredentials(credentialkey);
const calendar = new calendar_v3.Calendar({
auth: oAuth2Client,
});
const primaryCal = await gCalService.getPrimaryCalendar(calendar);
if (primaryCal?.id) {
await gCalService.createSelectedCalendar({
externalId: primaryCal.id,
userId: Number(user.id),
});
}
await updateProfilePhotoGoogle(oAuth2Client, Number(user.id));
}
// Installing Outlook Calendar by default for Microsoft/Azure AD sign-in
// Note: offline_access is requested but not returned in scope list by Microsoft
const microsoftCalendarScopesToCheck = MICROSOFT_CALENDAR_SCOPES.filter(
(scope) => scope !== "offline_access"
);
if (
account.provider === "azure-ad" &&
!(await CredentialRepository.findFirstByAppIdAndUserId({
userId: Number(user.id),
appId: "office365-calendar",
})) &&
microsoftCalendarScopesToCheck.every((scope) => grantedScopes.includes(scope))
) {
const credentialKey = {
access_token: account.access_token,
refresh_token: account.refresh_token,
email: user.email,
// convert seconds to milliseconds — OAuthManager compares expiry_date against Date.now()
expiry_date: account.expires_at ? account.expires_at * 1000 : undefined,
};
const outlookCredentialData = buildCredentialCreateData({
userId: Number(user.id),
key: credentialKey,
appId: "office365-calendar",
type: "office365_calendar",
});
const outlookCredential = await CredentialRepository.create(outlookCredentialData);
// Fetch default calendar from Microsoft Graph API
try {
const calendarResponse = await fetch(
"https://graph.microsoft.com/v1.0/me/calendars?$select=id,isDefaultCalendar",
{
headers: {
Authorization: `Bearer ${account.access_token}`,
"Content-Type": "application/json",
},
}
);
if (calendarResponse.ok) {
const calendarData = await calendarResponse.json();
const defaultCalendar = calendarData.value?.find(
(cal: { isDefaultCalendar?: boolean }) => cal.isDefaultCalendar
);
if (defaultCalendar?.id) {
await prisma.selectedCalendar.create({
data: {
userId: Number(user.id),
integration: "office365_calendar",
externalId: defaultCalendar.id,
credentialId: outlookCredential.id,
},
});
}
}
} catch (error) {
log.error("Failed to fetch default calendar for Microsoft sign-in", error);
}
// Update profile photo for Microsoft/Azure AD sign-in
if (account.access_token) {
await updateProfilePhotoMicrosoft(account.access_token, Number(user.id));
}
} else if (account.provider === "azure-ad" && account.access_token) {
// Update profile photo even if calendar wasn't installed
await updateProfilePhotoMicrosoft(account.access_token, Number(user.id));
}
const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(existingUser);
const { upId } = determineProfile({ profiles: allProfiles, token });
log.debug("callbacks:jwt:accountType:oauth:existingUser", safeStringify({ userId: existingUser.id, upId }));
return {
...token,
upId,
id: existingUser.id,
name: existingUser.name,
username: existingUser.username,
email: existingUser.email,
avatarUrl: existingUser.avatarUrl,
role: existingUser.role,
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
impersonatedBy: token.impersonatedBy,
org: token?.org,
orgAwareUsername: token.orgAwareUsername,
locale: existingUser.locale,
} as JWT;
}
if (account.type === "email") {
return await autoMergeIdentities();
}
log.warn(
"callbacks:jwt - unknown account type",
safeStringify({
accountType: account.type,
accountProvider: account.provider,
})
);
return token;
},
async session({ session, token, user }) {
log.debug("callbacks:session - Session callback called", safeStringify({ session, token, user }));
const deploymentRepo = new DeploymentRepository(prisma);
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
const hasValidLicense = await licenseKeyService.checkLicense();
const profileId = token.profileId;
const calendsoSession: Session = {
...session,
profileId,
upId: token.upId || session.upId,
hasValidLicense,
user: {
...session.user,
id: token.id as number,
name: token.name,
username: token.username as string,
orgAwareUsername: token.orgAwareUsername,
role: token.role as UserPermissionRole,
impersonatedBy: token.impersonatedBy,
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
org: token?.org,
locale: token.locale,
inactiveAdminReason: token.inactiveAdminReason,
},
};
return calendsoSession;
},
async signIn(params: {
user: User | AdapterUser;
account: Account | null;
profile?: Profile;
email?: { verificationRequest?: boolean };
credentials?: Record<string, unknown>;
}): Promise<boolean | string> {
const { user, account, profile } = params;
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;
}
// In this case we've already verified the credentials in the authorize
// callback so we can sign the user in.
// Only if provider is not saml-idp
if (account?.provider !== "saml-idp") {
if (account?.type === "credentials") {
return true;
}
if (account?.type !== "oauth") {
log.warn("callbacks:signIn - unsupported account type for non-saml-idp provider", {
accountType: account?.type,
provider: account?.provider,
});
return false;
}
}
if (!user.email) {
log.warn("callbacks:signIn - user email is missing", {
provider: account?.provider,
});
return false;
}
if (!user.name) {
log.warn("callbacks:signIn - user name is missing", {
emailDomain: user.email.split("@")[1],
provider: account?.provider,
});
return false;
}
if (account?.provider) {
const idP = getIdentityProvider(account.provider);
if (!idP) {
log.warn("callbacks:signIn - unknown provider, rejecting login", {
provider: account.provider,
});
return "/auth/error?error=unknown-provider";
}
// Use optional chaining for safety, especially with AdapterUser potentially having different structure initially.
const isEmailVerified = user.emailVerified || (profile as ExtendedOAuthProfile)?.email_verified;
// For Azure AD, check xms_edov (Email Domain Owner Verified) claim
// xms_edov returns inconsistent types: boolean for work/school, string "1" for personal accounts
const xmsEdov = (profile as ExtendedOAuthProfile)?.xms_edov;
const isAzureEmailDomainVerified =
xmsEdov === true || xmsEdov === "true" || xmsEdov === "1" || xmsEdov === 1;
// Azure AD never sets email_verified in the token profile, so isEmailVerified is always
// falsy for AZUREAD logins. Use isAzureEmailDomainVerified (xms_edov) as the equivalent
// proof of ownership so the auto-merge path treats Azure AD the same as other verified IdPs.
const isVerified =
isEmailVerified || (idP === IdentityProvider.AZUREAD && isAzureEmailDomainVerified);
if (idP === IdentityProvider.AZUREAD && !isAzureEmailDomainVerified) {
log.error(
"Azure AD email domain not verified (xms_edov claim)",
safeStringify({ emailDomain: user.email?.split("@")[1], xmsEdov })
);
return "/auth/error?error=unverified-email";
}
if (!isEmailVerified && idP !== IdentityProvider.AZUREAD) {
log.error("Attention: SAML/Google User email is not verified in the IdP", safeStringify({ user }));
return "/auth/error?error=unverified-email";
}
let existingUser = await prisma.user.findFirst({
include: {
password: {
select: {
hash: true,
},
},
accounts: {
where: {
provider: account.provider,
},
},
},
where: {
identityProvider: idP,
identityProviderId: {
equals: account.providerAccountId,
mode: "insensitive",
},
},
});
/* --- START FIX LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
if (!existingUser) {
existingUser = await prisma.user.findFirst({
include: {
password: {
select: {
hash: true,
},
},
accounts: {
where: {
provider: account.provider,
},
},
},
where: {
identityProvider: idP,
identityProviderId: String(user.id),
},
});
if (existingUser) {
await prisma.user.update({
where: {
id: existingUser?.id,
},
data: {
identityProviderId: account.providerAccountId,
},
});
}
}
/* --- END FIXES LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
if (existingUser) {
// In this case there's an existing user and their email address
// hasn't changed since they last logged in.
if (existingUser.email === user.email) {
try {
// If old user without Account entry we link their google account
if (existingUser.accounts.length === 0) {
const linkAccountWithUserData = AdapterAccountPresenter.fromCalAccount(
account,
existingUser.id,
user.email
);
await calcomAdapter.linkAccount(linkAccountWithUserData);
}
} catch (error) {
if (error instanceof Error) {
log.error("Error while linking account of already existing user", safeStringify(error));
}
}
if (existingUser.twoFactorEnabled && existingUser.identityProvider === idP) {
return loginWithTotp(existingUser.email);
} else {
return true;
}
}
// If the email address doesn't match, check if an account already exists
// with the new email address. If it does, for now we return an error. If
// not, update the email of their account and log them in.
const userWithNewEmail = await prisma.user.findFirst({
where: { email: user.email },
});
if (!userWithNewEmail) {
await prisma.user.update({
where: { id: existingUser.id },
data: { email: user.email },
});
if (existingUser.twoFactorEnabled) {
return loginWithTotp(existingUser.email);
} else {
return true;
}
} else {
return "/auth/error?error=new-email-conflict";
}
}
// If there's no existing user for this identity provider and id, create
// a new account. If an account already exists with the incoming email
// address return an error for now.
const existingUserWithEmail = await prisma.user.findFirst({
where: {
email: {
equals: user.email,
mode: "insensitive",
},
},
include: {
password: {
select: {
hash: true,
},
},
},
});
if (existingUserWithEmail) {
// if self-hosted then we can allow auto-merge of identity providers if email is verified
if (!hostedCal && isVerified && existingUserWithEmail.identityProvider !== IdentityProvider.CAL) {
// Verify SAML IdP is authoritative before auto-merge
if (idP === IdentityProvider.SAML) {
const samlTenant = getSamlTenant();
const validation = await validateSamlAccountConversion(
samlTenant,
user.email,
"SelfHosted→SAML"
);
if (!validation.allowed) {
return validation.errorUrl;
}
}
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
}
// check if user was invited
if (
!existingUserWithEmail.password?.hash &&
!existingUserWithEmail.emailVerified &&
!existingUserWithEmail.username
) {
// Verify SAML IdP is authoritative before claiming invited user
if (idP === IdentityProvider.SAML) {
const samlTenant = getSamlTenant();
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Invite→SAML");
if (!validation.allowed) {
return validation.errorUrl;
}
}
await prisma.user.update({
where: {
email: existingUserWithEmail.email,
},
data: {
// update the email to the IdP email
email: user.email,
// Slugify the incoming name and append a few random characters to
// prevent conflicts for users with the same name.
username: getOrgUsernameFromEmail(user.email, getDomainFromEmail(user.email)),
emailVerified: new Date(Date.now()),
name: user.name,
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
}
// User signs up with email/password and then tries to login with Google/SAML/AzureAD using the same email
if (
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
(idP === IdentityProvider.GOOGLE ||
idP === IdentityProvider.SAML ||
idP === IdentityProvider.AZUREAD)
) {
// Prevent account pre-hijacking: block OAuth linking for unverified accounts
if (!existingUserWithEmail.emailVerified) {
return "/auth/error?error=unverified-email";
}
// Verify SAML IdP is authoritative before converting account
if (idP === IdentityProvider.SAML) {
const samlTenant = getSamlTenant();
const validation = await validateSamlAccountConversion(samlTenant, user.email, "CAL→SAML");
if (!validation.allowed) {
return validation.errorUrl;
}
}
await prisma.user.update({
where: { email: existingUserWithEmail.email },
data: {
email: user.email.toLowerCase(),
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
log.error(`Userid ${user.id} already exists with CAL identity provider`);
return `/auth/error?error=wrong-provider&provider=${existingUserWithEmail.identityProvider}`;
} else if (
existingUserWithEmail.identityProvider === IdentityProvider.GOOGLE &&
(idP === IdentityProvider.SAML || idP === IdentityProvider.AZUREAD)
) {
// Verify SAML IdP is authoritative before converting account
if (idP === IdentityProvider.SAML) {
const samlTenant = getSamlTenant();
const validation = await validateSamlAccountConversion(samlTenant, user.email, "Google→SAML");
if (!validation.allowed) {
return validation.errorUrl;
}
}
await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
email: user.email.toLowerCase(),
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
} else if (
existingUserWithEmail.identityProvider === IdentityProvider.AZUREAD &&
(idP === IdentityProvider.SAML || idP === IdentityProvider.GOOGLE)
) {
// Verify SAML IdP is authoritative before converting account
if (idP === IdentityProvider.SAML) {
const samlTenant = getSamlTenant();
const validation = await validateSamlAccountConversion(samlTenant, user.email, "AzureAD→SAML");
if (!validation.allowed) {
return validation.errorUrl;
}
}
await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
email: user.email.toLowerCase(),
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
}
log.error(`Userid ${user.id} trying to login with the wrong provider`, {
userId: user.id,
account: {
providerAccountId: account?.providerAccountId,
type: account?.type,
provider: account?.provider,
},
});
return `/auth/error?error=wrong-provider&provider=${existingUserWithEmail.identityProvider}`;
}
// Associate with organization if enabled by flag and idP is Google or Azure AD
const { orgUsername, orgId } = await checkIfUserShouldBelongToOrg(idP, user.email);
try {
const newUsername = orgId ? slugify(orgUsername) : usernameSlug(user.name);
const newUser = await prisma.user.create({
data: {
// Slugify the incoming name and append a few random characters to
// prevent conflicts for users with the same name.
username: newUsername,
emailVerified: new Date(Date.now()),
name: user.name,
...(user.image && { avatarUrl: user.image }),
email: user.email!,
identityProvider: idP,
identityProviderId: account.providerAccountId,
...(orgId && {
verified: true,
organization: { connect: { id: orgId } },
teams: {
create: {
role: MembershipRole.MEMBER,
accepted: true,
team: { connect: { id: orgId } },
},
},
}),
creationSource: CreationSource.WEBAPP,
},
});
const linkAccountNewUserData = AdapterAccountPresenter.fromCalAccount(
account,
newUser.id,
user.email
);
await calcomAdapter.linkAccount(linkAccountNewUserData);
// Update profile photo for new Microsoft/Azure AD users
if (account.provider === "azure-ad" && account.access_token) {
await updateProfilePhotoMicrosoft(account.access_token, newUser.id);
}
waitUntil(
(async () => {
try {
const tracking = getTrackingData();
const billingService = getBillingProviderService();
const customer = await billingService.createCustomer({
email: newUser.email,
metadata: {
email: newUser.email,
username: newUser.username ?? newUsername,
...(tracking.googleAds?.gclid && {
gclid: tracking.googleAds.gclid,
campaignId: tracking.googleAds.campaignId,
}),
...(tracking.linkedInAds?.liFatId && {
liFatId: tracking.linkedInAds.liFatId,
linkedInCampaignId: tracking.linkedInAds.campaignId,
}),
...(tracking.utmData && tracking.utmData),
},
});
await prisma.user.update({
where: { id: newUser.id },
data: {
metadata: {
stripeCustomerId: customer.stripeCustomerId,
},
},
});
} catch (err) {
log.error("Failed to create Stripe customer with tracking", err);
}
})()
);
if (newUser.twoFactorEnabled) {
return loginWithTotp(newUser.email);
} else {
return true;
}
} catch (err) {
log.error("Error creating a new user", err);
return `/auth/error?error=user-creation-error`;
}
}
log.warn("callbacks:signIn - no matching provider or condition, denying access", {
provider: account?.provider,
accountType: account?.type,
});
return false;
},
/**
* Used to handle the navigation right after successful login or logout
*/
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
// Allows callback URLs on the same domain
else if (new URL(url).hostname === new URL(WEBAPP_URL).hostname) return url;
return baseUrl;
},
},
events: {
async signIn(message) {
/* only run this code if:
- it's a hosted cal account
- DUB_API_KEY is configured
- it's a new user
*/
const user = message.user as User & {
username: string;
createdDate: string;
};
// check if the user was created in the last 10 minutes
// this is a workaround in the future once we move to use the Account model in the DB
// we should use NextAuth's isNewUser flag instead: https://next-auth.js.org/configuration/events#signin
const isNewUser = new Date(user.createdDate) > new Date(Date.now() - 10 * 60 * 1000);
if ((isENVDev || IS_CALCOM) && isNewUser) {
if (process.env.DUB_API_KEY) {
const clickId = getDubId();
// check if there's a clickId (dub_id) cookie set by @dub/analytics
if (clickId) {
// here we use waitUntil meaning this code will run async to not block the main thread
waitUntil(
// if so, send a lead event to Dub
// @see https://d.to/conversions/next-auth
dub.track.lead({
clickId,
eventName: "Sign Up",
externalId: user.id.toString(),
customerName: user.name,
customerEmail: user.email,
customerAvatar: user.image,
})
);
}
}
}
},
},
});
/**
* Identifies the profile the user should be logged into.
*/
const determineProfile = ({
token,
profiles,
}: {
token: JWT;
profiles: { id: number | null; upId: string }[];
}) => {
// If profile switcher is disabled, we can only show the first profile.
if (!ENABLE_PROFILE_SWITCHER) {
return profiles[0];
}
if (token.upId) {
// Otherwise use what's in the token
return { id: token.profileId ?? null, upId: token.upId as string };
}
// If there is just one profile it has to be the one we want to log into.
return profiles[0];
};