From 68a8fade89e61a419775831690927abe11915796 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Sun, 14 Dec 2025 10:14:15 +0000 Subject: [PATCH] add tests (#25566) --- .../auth/lib/next-auth-options.test.ts | 233 +++++++++++++++++ .../features/auth/lib/next-auth-options.ts | 235 +++++++++--------- 2 files changed, 353 insertions(+), 115 deletions(-) create mode 100644 packages/features/auth/lib/next-auth-options.test.ts diff --git a/packages/features/auth/lib/next-auth-options.test.ts b/packages/features/auth/lib/next-auth-options.test.ts new file mode 100644 index 0000000000..bc2d3b2cc9 --- /dev/null +++ b/packages/features/auth/lib/next-auth-options.test.ts @@ -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(); + 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 = {}) => ({ + 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); + }); + }); +}); diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index e4ee24a9f7..4c28baebe0 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -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 { - 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 { + 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(