Skip to content

Conversation

@everettbu
Copy link

Test 3

Co-authored-by: Peer Richelsen <[email protected]>
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Greptile Summary

This PR implements backup codes for two-factor authentication (2FA), a critical security feature that provides users with recovery access when their primary TOTP device is unavailable. The implementation spans multiple layers:

Database Changes: Adds a nullable backupCodes field to the User model in Prisma schema with corresponding migration, allowing encrypted storage of recovery codes.

Authentication Flow: Enhances the login process to support backup code entry as an alternative to TOTP codes. The login page now includes a toggle between standard 2FA and backup code modes, with proper state management and error handling for backup code scenarios.

2FA Setup Enhancement: Modifies the 2FA setup flow to generate 10 cryptographically secure backup codes during TOTP setup, encrypt them for database storage, and display them to users with download/copy functionality in a new setup step.

2FA Management: Updates the disable 2FA flow to accept backup codes as an alternative authentication method, allowing users to disable 2FA even without access to their authenticator app.

Security Implementation: Uses AES256 encryption for backup code storage, implements one-time use patterns where backup codes are consumed after validation, and includes proper error handling with new error codes (IncorrectBackupCode, MissingBackupCodes).

UI Components: Introduces a new BackupCode component for backup code input and updates existing components to support the toggle between TOTP and backup code authentication methods.

The feature integrates seamlessly with the existing 2FA infrastructure while following security best practices for backup code implementation.

Confidence score: 3/5

• This PR introduces significant authentication changes with some implementation concerns that need attention before merging
• The core backup codes functionality is well-implemented, but there are naming inconsistencies, validation issues, and potential security concerns in the BackupCode component and authentication flow
• Files needing more attention: apps/web/components/auth/BackupCode.tsx (function naming mismatch, validation issues), packages/features/auth/lib/next-auth-options.ts (error handling, input validation), apps/web/pages/auth/login.tsx (state management complexity)

15 files reviewed, 9 comments

Edit Code Review Bot Settings | Greptile

</div>

{twoFactorRequired && <TwoFactor center />}
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Complex conditional rendering logic - consider extracting to a helper function for better readability

Comment on lines +139 to +141
const backupCodes = JSON.parse(
symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: JSON.parse could throw if backup codes are corrupted - should wrap in try-catch block

Suggested change
const backupCodes = JSON.parse(
symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
);
let backupCodes;
try {
backupCodes = JSON.parse(
symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
);
} catch (error) {
console.error("Failed to parse backup codes", error);
throw new Error(ErrorCode.InternalServerError);
}

Comment on lines +50 to 53
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: This FIXME indicates a real test reliability issue. The isChecked() assertion may pass incorrectly. Consider using the same pattern as line 127 with data-state="checked".

Suggested change
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();

},

async disable(password: string, code: string) {
async disable(password: string, code: string, backupCode: string) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: The backupCode parameter should be optional since users may use either TOTP code or backup code, not both. Consider making it optional: backupCode?: string

return fetch("/api/auth/two-factor/totp/disable", {
method: "POST",
body: JSON.stringify({ password, code }),
body: JSON.stringify({ password, code, backupCode }),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Sending empty/undefined backup codes in the request body may cause issues. Consider only including non-empty values in the JSON payload.

import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, TextField } from "@calcom/ui";

export default function TwoFactor({ center = true }) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Function name 'TwoFactor' doesn't match the component's purpose or file name. Should be 'BackupCode' or similar.

Suggested change
export default function TwoFactor({ center = true }) {
export default function BackupCode({ center = true }) {

Comment on lines +22 to +23
minLength={10} // without dash
maxLength={11} // with dash
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Validation logic is flawed. If backup codes are 'XXXXX-XXXXX' format (11 chars), minLength of 10 allows invalid shorter inputs. Consider using exact length validation or regex pattern.

if (user.twoFactorEnabled && req.body.backupCode) {
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with backup code login.");
throw new Error(ErrorCode.InternalServerError);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Error message mentions 'backup code login' but this is a disable endpoint, not login

Suggested change
throw new Error(ErrorCode.InternalServerError);
console.error("Missing encryption key; cannot proceed with backup code verification.");

setBackupCodes(body.backupCodes);

// create backup codes download url
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: calls formatBackupCode before it's defined (line 163), causing a reference error

Suggested change
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {

@github-actions
Copy link
Contributor

Thank you for following the naming conventions! 🙏

@github-actions
Copy link
Contributor

This PR is being marked as stale due to inactivity.

@GitHoobar
Copy link

Review Summary

🏷️ Draft Comments (18)

Skipped posting 18 draft comments that were valid but scored below your review threshold (>=13/15). Feel free to update them here.

apps/web/components/auth/BackupCode.tsx (1)

22-23: minLength={10} and maxLength={11} on the backup code input do not account for possible user input with or without dashes, potentially rejecting valid codes or accepting invalid ones.

📊 Impact Scores:

  • Production Impact: 3/5
  • Fix Specificity: 2/5
  • Urgency Impact: 2/5
  • Total Score: 7/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/components/auth/BackupCode.tsx, lines 22-23, the backup code input uses minLength=10 (without dash) and maxLength=11 (with dash), which can cause valid codes to be rejected or invalid ones to be accepted. Update both minLength and maxLength to 11 to ensure only codes of the correct format (including the dash) are accepted.

apps/web/components/auth/TwoFactor.tsx (1)

20-23: methods.setValue is used inside useEffect but methods is not included in the dependency array, which can cause stale closure bugs if the form context changes.

📊 Impact Scores:

  • Production Impact: 2/5
  • Fix Specificity: 5/5
  • Urgency Impact: 2/5
  • Total Score: 9/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/components/auth/TwoFactor.tsx, lines 20-23, the useEffect hook uses `methods` from useFormContext but does not include it in the dependency array. This can cause stale closure bugs if the form context changes. Please add `methods` to the dependency array so it reads `[value, methods]`.

apps/web/components/settings/DisableTwoFactorModal.tsx (2)

50-86: handleDisable does not validate that either totpCode or backupCode is provided, allowing submission with both empty, which will always fail and may confuse users.

📊 Impact Scores:

  • Production Impact: 4/5
  • Fix Specificity: 5/5
  • Urgency Impact: 3/5
  • Total Score: 12/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/components/settings/DisableTwoFactorModal.tsx, lines 50-86, the `handleDisable` function does not check that at least one of `totpCode` or `backupCode` is provided before submitting. This allows the form to be submitted with both empty, resulting in a guaranteed failure and poor user experience. Add a check at the start of `handleDisable` to set an error and return if both are empty, e.g.:

if (!totpCode && !backupCode) {
  setErrorMessage(t("2fa_or_backup_required"));
  return;
}

Insert this after the `isDisabling` check and before setting `isDisabling` to true.

82-82: console.error in production code can cause significant performance issues if triggered frequently, especially in high-traffic environments or error loops.

📊 Impact Scores:

  • Production Impact: 2/5
  • Fix Specificity: 2/5
  • Urgency Impact: 2/5
  • Total Score: 6/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/components/settings/DisableTwoFactorModal.tsx, line 82, remove or replace the `console.error` statement with a production-appropriate logging mechanism. Console statements in production can cause performance issues and should be avoided. Comment out or replace the line as appropriate.

apps/web/components/settings/EnableTwoFactorModal.tsx (4)

135-135: onEnable() is not called after successful 2FA enable, so parent is not notified and modal may not close or update as expected.

📊 Impact Scores:

  • Production Impact: 3/5
  • Fix Specificity: 5/5
  • Urgency Impact: 3/5
  • Total Score: 11/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/components/settings/EnableTwoFactorModal.tsx, on line 135, after setting the step to DisplayBackupCodes, the function does not call onEnable(). This means the parent component is not notified that 2FA was enabled, which can cause the modal to remain open or the UI to not update. Please add a call to onEnable() immediately after setStep(SetupStep.DisplayBackupCodes); to ensure correct behavior.

71-75: backupCodes and backupCodesUrl are not reset when modal is closed, causing stale codes to be shown if modal is reopened.

📊 Impact Scores:

  • Production Impact: 3/5
  • Fix Specificity: 5/5
  • Urgency Impact: 2/5
  • Total Score: 10/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/components/settings/EnableTwoFactorModal.tsx, lines 71-75, the resetState function does not clear backupCodes or revoke the backupCodesUrl. This can cause stale backup codes to be displayed if the modal is reopened. Please update resetState to also setBackupCodes([]), revoke the backupCodesUrl if set, and setBackupCodesUrl("") to ensure state is fully reset.

276-282: backupCodes are copied to clipboard and downloadable as plaintext without any user confirmation or warning, risking exposure of sensitive 2FA backup codes to clipboard hijacking or accidental sharing.

📊 Impact Scores:

  • Production Impact: 3/5
  • Fix Specificity: 5/5
  • Urgency Impact: 3/5
  • Total Score: 11/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/components/settings/EnableTwoFactorModal.tsx, lines 276-282, the backup codes are copied to the clipboard without any user confirmation, which could lead to accidental exposure of sensitive 2FA codes. Add a confirmation dialog (e.g., window.confirm) before copying the codes to the clipboard, and only proceed if the user confirms. Ensure the prompt uses a translatable string key like 'are_you_sure_copy_backup_codes_clipboard'.

283-286: backupCodesUrl allows direct download of plaintext backup codes without any warning or confirmation, risking unintentional exposure if the file is saved insecurely or shared.

📊 Impact Scores:

  • Production Impact: 2/5
  • Fix Specificity: 3/5
  • Urgency Impact: 2/5
  • Total Score: 7/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/components/settings/EnableTwoFactorModal.tsx, lines 283-286, the backup codes are downloadable as plaintext without any user warning or confirmation, which could lead to accidental exposure. Add a confirmation dialog (e.g., window.confirm) before allowing the download, using a translatable string key like 'are_you_sure_download_backup_codes'. Prevent the download if the user cancels.

apps/web/components/settings/TwoFactorAuthAPI.ts (1)

22-30: disable now requires backupCode as a parameter, but existing callers not updated will cause runtime errors due to missing argument (undefined sent to API).

📊 Impact Scores:

  • Production Impact: 4/5
  • Fix Specificity: 3/5
  • Urgency Impact: 4/5
  • Total Score: 11/15

🤖 AI Agent Prompt (Copy & Paste Ready):

Check all usages of `TwoFactorAuthAPI.disable` in the codebase. Update all call sites to provide the new required `backupCode` argument. If a backup code is not available, ensure the function is called with a valid string or refactor the function to make `backupCode` optional if appropriate. File: apps/web/components/settings/TwoFactorAuthAPI.ts, lines 22-30. Problem: The function signature now requires a third argument, which will break existing callers and cause runtime errors.

apps/web/pages/api/auth/two-factor/totp/disable.ts (4)

61-61: req.body.backupCode.replaceAll("-", "") will throw if backupCode is not a string, causing a runtime crash.

📊 Impact Scores:

  • Production Impact: 4/5
  • Fix Specificity: 5/5
  • Urgency Impact: 3/5
  • Total Score: 12/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/pages/api/auth/two-factor/totp/disable.ts, lines 61-61, the code calls replaceAll on req.body.backupCode without checking its type. If backupCode is not a string, this will throw and crash the API. Add a type check for string and return a 400 error with ErrorCode.IncorrectBackupCode if not.

58-58: JSON.parse(symmetricDecrypt(user.backupCodes, ...)) is called on every request, which can be expensive if backup codes are numerous or requests are frequent; consider caching decrypted/parsed codes in user session or a fast-access store.

📊 Impact Scores:

  • Production Impact: 2/5
  • Fix Specificity: 2/5
  • Urgency Impact: 2/5
  • Total Score: 6/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/pages/api/auth/two-factor/totp/disable.ts, line 58, the code decrypts and parses backup codes on every request: `const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));`. This can be expensive if backup codes are large or requests are frequent. Refactor to cache decrypted/parsed backup codes in the user session or a fast-access store to avoid repeated decryption and parsing for the same user within a session.

11-114: The handler function is large and contains multiple nested conditionals, making it difficult to maintain and extend; refactor into smaller functions for each major step (auth, password check, 2FA, backup code, update).

📊 Impact Scores:

  • Production Impact: 2/5
  • Fix Specificity: 3/5
  • Urgency Impact: 2/5
  • Total Score: 7/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/pages/api/auth/two-factor/totp/disable.ts, lines 11-114, the main handler function is large and contains deeply nested logic for authentication, password verification, 2FA, backup code handling, and user updates. This complexity makes the code hard to maintain and extend. Refactor the handler by extracting major steps (authentication, password check, 2FA/backup code validation, user update) into well-named helper functions, each with a single responsibility, to improve maintainability and readability.

41-100: No rate limiting or brute-force protection is present for backup code or TOTP attempts, allowing attackers to repeatedly guess codes and potentially bypass 2FA.

📊 Impact Scores:

  • Production Impact: 4/5
  • Fix Specificity: 2/5
  • Urgency Impact: 4/5
  • Total Score: 10/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/pages/api/auth/two-factor/totp/disable.ts, lines 41-100, there is no rate limiting or brute-force protection for password, backup code, or TOTP code attempts. This allows attackers to repeatedly guess codes and potentially bypass 2FA. Implement per-user and/or per-IP rate limiting for these endpoints to prevent brute-force attacks.

apps/web/pages/auth/login.tsx (1)

317-318: prisma.user.count() is called on every login page request, causing a full table scan and potential performance bottleneck as user table grows.

📊 Impact Scores:

  • Production Impact: 3/5
  • Fix Specificity: 5/5
  • Urgency Impact: 2/5
  • Total Score: 10/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/pages/auth/login.tsx, lines 317-318, the code calls `prisma.user.count()` to check if any users exist, which causes a full table scan and can degrade performance as the user table grows. Replace this with `prisma.user.findFirst({ select: { id: true } })` and check for existence instead, to avoid scanning the entire table.

apps/web/playwright/login.2fa.e2e.ts (1)

64-68: prisma is used to query user data but is never defined or imported, causing a ReferenceError and test failure at runtime.

📊 Impact Scores:

  • Production Impact: 5/5
  • Fix Specificity: 3/5
  • Urgency Impact: 4/5
  • Total Score: 12/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In apps/web/playwright/login.2fa.e2e.ts, lines 64-68, the code uses `prisma` to query user data, but `prisma` is not defined or imported, causing a ReferenceError at runtime. Update the code to use `users.prisma` (assuming the test fixture provides access to Prisma via the users object), so the query works as intended and the test does not crash.

packages/features/auth/lib/next-auth-options.ts (1)

139-141: backupCodes are decrypted and parsed from user input without schema validation, allowing an attacker to inject malicious JSON or unexpected data structures, potentially bypassing 2FA or causing code execution if the data is later used unsafely.

📊 Impact Scores:

  • Production Impact: 4/5
  • Fix Specificity: 3/5
  • Urgency Impact: 4/5
  • Total Score: 11/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In packages/features/auth/lib/next-auth-options.ts, lines 139-141, the code parses decrypted backup codes JSON without validating its structure, which could allow attackers to inject malicious data and potentially bypass 2FA. Update this block to strictly validate that the parsed value is an array of strings, and throw an error if not. Replace the current code with a try/catch that checks the type and structure before proceeding.

packages/prisma/schema.prisma (1)

205-205: backupCodes field in the User model is defined as a single String?, which cannot store multiple backup codes as required for 2FA backup functionality; this will cause loss of codes and incorrect behavior.

📊 Impact Scores:

  • Production Impact: 4/5
  • Fix Specificity: 5/5
  • Urgency Impact: 3/5
  • Total Score: 12/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In packages/prisma/schema.prisma, line 205, the `backupCodes` field in the `User` model is currently defined as `String?`, which only allows a single string value. For 2FA backup codes, this should be an array to store multiple codes. Change the field type to `String[]` to correctly support multiple backup codes.

packages/ui/components/form/inputs/Input.tsx (1)

129-131: TextAreaField re-computes t(props.name + "_placeholder") twice per render, causing unnecessary repeated string concatenation and translation lookup for every render, which can be expensive if t is a costly function or in large forms.

📊 Impact Scores:

  • Production Impact: 2/5
  • Fix Specificity: 5/5
  • Urgency Impact: 2/5
  • Total Score: 9/15

🤖 AI Agent Prompt (Copy & Paste Ready):

In packages/ui/components/form/inputs/Input.tsx, lines 129-131, the code recomputes `t(props.name + "_placeholder")` twice per render, which is inefficient if `t` is expensive or in large forms. Refactor to compute the translation once and reuse the result. Replace the repeated calls with a single variable assignment as shown.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants