Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ PASSWORD_RESET_DISABLED=1
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1

# Disable the Have-I-Been-Pwned breach check performed when a password is set (signup / reset).
# The check is an outbound HTTPS call to api.pwnedpasswords.com; if it can't be reached it fails
# open (password allowed, warning logged). Set this to 1 on air-gapped / closed-network
# deployments to skip the outbound call entirely.
# PASSWORD_HIBP_CHECK_DISABLED=1

# Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1

Expand Down
1 change: 1 addition & 0 deletions apps/web/i18n.lock
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ checksums:
auth/oauth/scopes/surveys_read: 8280264457fe7c3b759c82422052aed2
auth/oauth/scopes/surveys_write: fb128cebe62dfd5685d086d706c432c2
auth/oauth/unknown_client: 561bc5afdb90606fbc1ed97f20698ead
auth/password_compromised: d4ab0f66aaed039f956c2515328ed1c9
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
auth/signup/company_email_required: 997bf11a31512cedf4ab8acb7b4f56c3
Expand Down
4 changes: 4 additions & 0 deletions apps/web/integration/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ process.env.REDIS_URL = process.env.TEST_REDIS_URL ?? "redis://localhost:6379/15
process.env.BETTER_AUTH_SECRET ??= "integration-test-better-auth-secret-0123456789abcdef";
// Disable rate limiting in the harness — counters would accumulate in the shared Redis db across tests.
process.env.RATE_LIMITING_DISABLED ??= "1";
// Disable the HIBP breach check by default — otherwise every signup/reset in the suite would make a
// live call to api.pwnedpasswords.com (ENG-1587). The dedicated better-auth-hibp.integration.test.ts
// re-enables it for itself (with betterFetch mocked).
process.env.PASSWORD_HIBP_CHECK_DISABLED ??= "1";

// server-only is a Next.js build guard; no-op it under vitest.
vi.mock("server-only", () => ({}));
Expand Down
5 changes: 5 additions & 0 deletions apps/web/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ export const REDIS_URL = env.REDIS_URL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";

// Opt-out for the Have-I-Been-Pwned breach check (ENG-1587). Set to "1" on air-gapped /
// closed-network deployments that can't reach api.pwnedpasswords.com and want no outbound
// attempt at all. When unset the check is active but fails open on network errors.
export const PASSWORD_HIBP_CHECK_DISABLED = env.PASSWORD_HIBP_CHECK_DISABLED === "1";

export const BREVO_API_KEY = env.BREVO_API_KEY;
export const BREVO_LIST_ID = env.BREVO_LIST_ID;

Expand Down
2 changes: 2 additions & 0 deletions apps/web/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ const parsedEnv = createEnv({
process.env.NODE_ENV === "test"
? z.string().optional()
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_HIBP_CHECK_DISABLED: z.enum(["1", "0"]).optional(),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().int().min(5).max(120).optional().default(30),
PRIVACY_URL: z
Expand Down Expand Up @@ -475,6 +476,7 @@ const parsedEnv = createEnv({
OIDC_ISSUER: process.env.OIDC_ISSUER,
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
REDIS_URL: process.env.REDIS_URL,
PASSWORD_HIBP_CHECK_DISABLED: process.env.PASSWORD_HIBP_CHECK_DISABLED,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: process.env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
PRIVACY_URL: process.env.PRIVACY_URL,
Expand Down
70 changes: 25 additions & 45 deletions apps/web/lib/user/password.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
import { getUserAuthenticationData, verifyUserPassword } from "./password";
import { getCredentialPasswordHash, verifyUserPassword } from "./password";

vi.mock("@formbricks/database", () => ({
prisma: {
user: {
account: {
findUnique: vi.fn(),
},
},
Expand All @@ -16,79 +16,59 @@ vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));

const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockPrismaAccountFindUnique = vi.mocked(prisma.account.findUnique);
const mockVerifyPassword = vi.mocked(verifyPassword);

describe("user password helpers", () => {
beforeEach(() => {
vi.resetAllMocks();
});

test("returns authentication data for an existing user", async () => {
const user = {
email: "user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
};
mockPrismaUserFindUnique.mockResolvedValue(user as any);
test("getCredentialPasswordHash reads the credential Account scoped to the user", async () => {
mockPrismaAccountFindUnique.mockResolvedValue({ password: "hashed-password" } as any);

await expect(getUserAuthenticationData("user-with-password")).resolves.toEqual(user);
await expect(getCredentialPasswordHash("user-1")).resolves.toBe("hashed-password");

expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
expect(mockPrismaAccountFindUnique).toHaveBeenCalledWith({
where: {
id: "user-with-password",
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
provider_providerAccountId: { provider: "credential", providerAccountId: "user-1" },
},
select: { password: true },
});
});

test("throws when authentication data is missing", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
test("getCredentialPasswordHash returns null when there is no credential Account", async () => {
mockPrismaAccountFindUnique.mockResolvedValue(null);

await expect(getCredentialPasswordHash("sso-user")).resolves.toBeNull();
});

test("getCredentialPasswordHash returns null when the credential Account has no password", async () => {
mockPrismaAccountFindUnique.mockResolvedValue({ password: null } as any);

await expect(getUserAuthenticationData("missing-user")).rejects.toThrow(ResourceNotFoundError);
await expect(getCredentialPasswordHash("user-without-hash")).resolves.toBeNull();
});

test("verifies a password against the stored hash", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "password-user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
test("verifies a password against the credential Account hash", async () => {
mockPrismaAccountFindUnique.mockResolvedValue({ password: "hashed-password" } as any);
mockVerifyPassword.mockResolvedValue(true);

await expect(verifyUserPassword("password-user", "plain-password")).resolves.toBe(true);

expect(mockVerifyPassword).toHaveBeenCalledWith("plain-password", "hashed-password");
});

test("returns false when the password does not match the stored hash", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "password-user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
test("returns false when the password does not match the credential Account hash", async () => {
mockPrismaAccountFindUnique.mockResolvedValue({ password: "hashed-password" } as any);
mockVerifyPassword.mockResolvedValue(false);

await expect(verifyUserPassword("password-user", "wrong-password")).resolves.toBe(false);

expect(mockVerifyPassword).toHaveBeenCalledWith("wrong-password", "hashed-password");
});

test("rejects password verification for users without a password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "sso-user@example.com",
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
} as any);
test("fails closed when the user has no credential Account (no password to verify)", async () => {
mockPrismaAccountFindUnique.mockResolvedValue(null);

await expect(verifyUserPassword("sso-user", "plain-password")).rejects.toThrow(InvalidInputError);

Expand Down
48 changes: 21 additions & 27 deletions apps/web/lib/user/password.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,34 @@
import "server-only";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { User } from "@formbricks/database/prisma";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";

export const getUserAuthenticationData = reactCache(
async (
userId: string
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
},
});
/**
* Returns the bcrypt password hash on the user's Better Auth `credential` Account
* (provider = "credential", providerAccountId = userId), or null when the user has no credential
* account (e.g. SSO-only users). Post-ENG-1054 the password lives here, NOT on `User.password`
* (which is null for accounts created after the cutover). Scoped strictly to the given user id.
*/
export const getCredentialPasswordHash = reactCache(async (userId: string): Promise<string | null> => {
const account = await prisma.account.findUnique({
where: {
provider_providerAccountId: { provider: "credential", providerAccountId: userId },
},
select: { password: true },
});

if (!user) {
throw new ResourceNotFoundError("user", userId);
}

return user;
}
);
return account?.password ?? null;
});

export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId);
const passwordHash = await getCredentialPasswordHash(userId);

if (!user.password) {
// Fail closed: no credential account (e.g. SSO-only user) means there is no password to verify
// against, so the operation must be rejected — never treated as a successful verification.
if (!passwordHash) {
throw new InvalidInputError("Password is not set for this user");
}

return await verifyPassword(password, user.password);
return await verifyPassword(password, passwordHash);
};
1 change: 1 addition & 0 deletions apps/web/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Dieses Passwort ist in einem Datenleck aufgetaucht. Bitte wähle ein anderes.",
"saml_connection_error": "Etwas ist schiefgelaufen. Bitte überprüfe die App-Konsole für weitere Details.",
"signup": {
"captcha_failed": "Captcha fehlgeschlagen",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "This password has appeared in a data breach. Please choose a different one.",
"saml_connection_error": "Something went wrong. Please check your app console for more details.",
"signup": {
"captcha_failed": "Captcha failed",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Esta contraseña ha aparecido en una filtración de datos. Elige otra.",
"saml_connection_error": "Algo salió mal. Por favor, consulta la consola de tu aplicación para más detalles.",
"signup": {
"captcha_failed": "Captcha fallido",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Ce mot de passe est apparu dans une fuite de données. Veuillez en choisir un autre.",
"saml_connection_error": "Quelque chose s'est mal passé. Veuillez vérifier la console de votre application pour plus de détails.",
"signup": {
"captcha_failed": "Captcha échoué",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/hu-HU.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Ez a jelszó megjelent egy adatszivárgásban. Kérjük, válassz másikat.",
"saml_connection_error": "Valami probléma történt. A további részletekért nézze meg az alkalmazás konzolját.",
"signup": {
"captcha_failed": "A captcha sikertelen",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "このパスワードはデータ漏洩で見つかりました。別のパスワードを選択してください。",
"saml_connection_error": "問題が発生しました。詳細については、アプリのコンソールを確認してください。",
"signup": {
"captcha_failed": "CAPTCHAに失敗しました",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/nl-NL.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Dit wachtwoord is aangetroffen in een datalek. Kies een ander wachtwoord.",
"saml_connection_error": "Er is iets misgegaan. Controleer uw app-console voor meer details.",
"signup": {
"captcha_failed": "Captcha is mislukt",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Esta senha apareceu em um vazamento de dados. Escolha outra.",
"saml_connection_error": "Algo deu errado. Por favor, verifica o console do app para mais detalhes.",
"signup": {
"captcha_failed": "reCAPTCHA falhou",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/pt-PT.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Esta palavra-passe apareceu numa fuga de dados. Por favor, escolha outra.",
"saml_connection_error": "Algo correu mal. Por favor, verifique a consola da aplicação para mais detalhes.",
"signup": {
"captcha_failed": "Captcha falhou",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/ro-RO.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Această parolă a apărut într-o breșă de date. Te rugăm să alegi alta.",
"saml_connection_error": "Ceva nu a mers. Vă rugăm să verificați consola aplicației pentru mai multe detalii.",
"signup": {
"captcha_failed": "Captcha eșuat",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Этот пароль был обнаружен в утечке данных. Пожалуйста, выберите другой.",
"saml_connection_error": "Что-то пошло не так. Пожалуйста, проверьте консоль приложения для получения подробностей.",
"signup": {
"captcha_failed": "Не удалось пройти капчу",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/sv-SE.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Det här lösenordet har förekommit i en dataläcka. Välj ett annat.",
"saml_connection_error": "Något gick fel. Kontrollera din appkonsol för mer information.",
"signup": {
"captcha_failed": "Captcha misslyckades",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/tr-TR.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "Bu parola bir veri ihlalinde görülmüş. Lütfen farklı bir parola seçin.",
"saml_connection_error": "Bir sorun oluştu. Daha fazla bilgi için uygulama konsolunuzu kontrol edin.",
"signup": {
"captcha_failed": "Captcha başarısız oldu",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/zh-Hans-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "该密码曾出现在数据泄露事件中,请选择其他密码。",
"saml_connection_error": "出了问题。请查看应用 控制台 以获取更多详情。",
"signup": {
"captcha_failed": "验证码 验证 失败",
Expand Down
1 change: 1 addition & 0 deletions apps/web/locales/zh-Hant-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
},
"unknown_client": "Unknown client"
},
"password_compromised": "此密碼曾出現在資料外洩事件中,請選擇其他密碼。",
"saml_connection_error": "發生錯誤。請檢查您的 app 主控台以取得更多詳細資料。",
"signup": {
"captcha_failed": "驗證碼失敗",
Expand Down
16 changes: 16 additions & 0 deletions apps/web/modules/auth/forgot-password/reset/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { APIError } from "better-auth/api";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidInputError,
InvalidPasswordResetTokenError,
OperationNotAllowedError,
PASSWORD_COMPROMISED_ERROR_CODE,
} from "@formbricks/types/errors";
import { auth } from "@/modules/auth/lib/auth";
import { resetPasswordAction } from "./actions";
Expand Down Expand Up @@ -75,6 +77,20 @@ describe("resetPasswordAction", () => {
expect(auth.api.resetPassword).toHaveBeenCalledTimes(1);
});

test("maps a compromised-password APIError to the compromised code, not the invalid-token error", async () => {
// The HIBP plugin rejects a breached password with its own APIError. This must be surfaced as the
// stable compromised code — NOT swallowed by the catch-all that maps every APIError to a bogus
// "invalid/expired token" message.
vi.mocked(auth.api.resetPassword).mockRejectedValue(
new APIError("BAD_REQUEST", { message: "breached", code: PASSWORD_COMPROMISED_ERROR_CODE })
);

const result = resetPasswordAction({ parsedInput } as any);
await expect(result).rejects.toBeInstanceOf(InvalidInputError);
await expect(result).rejects.toThrow(PASSWORD_COMPROMISED_ERROR_CODE);
expect(auth.api.resetPassword).toHaveBeenCalledTimes(1);
});

test("propagates an unexpected (non-APIError) error unchanged", async () => {
vi.mocked(auth.api.resetPassword).mockRejectedValue(new Error("Database error"));

Expand Down
Loading
Loading