This commit is contained in:
sean-brydon
2025-12-14 19:14:15 +09:00
committed by GitHub
parent d81993892a
commit 68a8fade89
2 changed files with 353 additions and 115 deletions
@@ -0,0 +1,233 @@
import type { User } from "next-auth";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums";
import { ErrorCode } from "./ErrorCode";
// Mock dependencies
vi.mock("@calcom/prisma", () => ({
prisma: {
user: {
update: vi.fn(),
},
},
default: {
user: {
update: vi.fn(),
},
},
}));
const mockFindByEmailAndIncludeProfilesAndPassword = vi.fn();
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
return {
UserRepository: vi.fn().mockImplementation(() => ({
findByEmailAndIncludeProfilesAndPassword: mockFindByEmailAndIncludeProfilesAndPassword,
})),
};
});
vi.mock("./verifyPassword", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
checkRateLimitAndThrowError: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@calcom/lib/server/PiiHasher", () => ({
hashEmail: vi.fn((email: string) => `hashed_${email}`),
}));
vi.mock("@calcom/lib/totp", () => ({
totpAuthenticatorCheck: vi.fn(),
}));
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn(),
symmetricEncrypt: vi.fn(),
}));
vi.mock("@calcom/lib/auth/isPasswordValid", () => ({
isPasswordValid: vi.fn(),
}));
vi.mock("@calcom/lib/env", () => ({
isENVDev: false,
}));
vi.mock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: false,
ENABLE_PROFILE_SWITCHER: false,
WEBAPP_URL: "http://localhost:3000",
};
});
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
})),
},
}));
vi.mock("@calcom/lib/safeStringify", () => ({
safeStringify: vi.fn((obj) => JSON.stringify(obj)),
}));
vi.mock("./next-auth-custom-adapter", () => ({
default: vi.fn(() => ({
linkAccount: vi.fn(),
})),
}));
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
ProfileRepository: {
findAllProfilesForUserIncludingMovedUser: vi.fn(),
findByUpIdWithAuth: vi.fn(),
},
}));
describe("CredentialsProvider authorize", () => {
let authorizeCredentials: typeof import("./next-auth-options").authorizeCredentials;
let verifyPassword: any;
beforeEach(async () => {
vi.clearAllMocks();
mockFindByEmailAndIncludeProfilesAndPassword.mockReset();
const verifyPasswordModule = await import("./verifyPassword");
verifyPassword = verifyPasswordModule.verifyPassword;
// Import the exported authorize function directly
const authModule = await import("./next-auth-options");
authorizeCredentials = authModule.authorizeCredentials;
});
const createMockUser = (overrides: Partial<any> = {}) => ({
id: 1,
email: "test@example.com",
name: "Test User",
username: "testuser",
role: UserPermissionRole.USER,
locked: false,
identityProvider: IdentityProvider.CAL,
twoFactorEnabled: false,
twoFactorSecret: null,
backupCodes: null,
password: {
hash: "$2a$10$hashedpassword",
},
allProfiles: [
{
id: 1,
upId: "usr_123",
username: "testuser",
},
],
teams: [],
...overrides,
});
describe("Password validation", () => {
it("should throw error when user has no password hash with CAL identity provider", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.CAL,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash with Google identity provider", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.GOOGLE,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash with SAML identity provider", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.SAML,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash even with TOTP code provided", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.CAL,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
totpCode: "123456",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash with Google identity provider and TOTP code", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.GOOGLE,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
totpCode: "123456",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash with SAML identity provider and TOTP code", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.SAML,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
totpCode: "123456",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
});
});
+120 -115
View File
@@ -133,139 +133,144 @@ const checkIfUserShouldBelongToOrg = async (idP: IdentityProvider, email: string
return { orgUsername, orgId: existingOrg?.id };
};
const providers: Provider[] = [
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" },
},
async authorize(credentials): 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);
}
/**
* 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);
}
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);
}
// Locked users cannot login
if (user.locked) {
throw new Error(ErrorCode.UserAccountLocked);
}
await checkRateLimitAndThrowError({
identifier: hashEmail(user.email),
});
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);
}
// 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);
}
// 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.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);
if (!user.backupCodes) throw new Error(ErrorCode.MissingBackupCodes);
const backupCodes = JSON.parse(
symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
);
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);
// 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);
}
// 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 (!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);
}
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 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);
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;
// 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;
}
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";
};
// 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";
};
// Create a NextAuth compatible user object using our presenter
return AdapterUserPresenter.fromCalUser(user, validateRole(user.role), hasActiveTeams);
},
}),
ImpersonationProvider,
];
// Create a NextAuth compatible user object using our presenter
return AdapterUserPresenter.fromCalUser(user, validateRole(user.role), hasActiveTeams);
}
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];
if (IS_GOOGLE_LOGIN_ENABLED) {
providers.push(