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
10 changes: 9 additions & 1 deletion apps/web/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { auth } from "@/modules/auth/lib/auth";
import { createAuthPathLabeller } from "@/modules/auth/lib/better-auth-path-label";
import { runWithBetterAuthRequestContext } from "@/modules/auth/lib/better-auth-request-context";
import { runWithEmailVerificationRequestContext } from "@/modules/auth/lib/email-verification-request-context";
import { mapLegacySsoCallbackRequest } from "@/modules/auth/lib/legacy-sso-callback";
import { normalizeDcrRequest } from "@/modules/auth/lib/mcp-dcr-application-type";
import { runWithSsoRequestContext } from "@/modules/ee/sso/lib/sso-request-context";
Expand Down Expand Up @@ -63,7 +64,14 @@ const handler = async (request: Request): Promise<Response> => {
const mappedRequest = await normalizeDcrRequest(mapLegacySsoCallbackRequest(request));
return runWithBetterAuthRequestContext(
{ path: labelAuthPath(mappedRequest.url), method: mappedRequest.method },
() => runWithSsoRequestContext(() => auth.handler(mappedRequest))
() =>
runWithSsoRequestContext(() =>
// ENG-2562: carries "this request just verified an email" from Better Auth's
// `afterEmailVerification` hook to the `hooks.after` chain, which is where the session can
// actually be minted. Innermost because it is the narrowest scope of the three — one endpoint,
// not the whole handler.
runWithEmailVerificationRequestContext(() => auth.handler(mappedRequest))
)
);
};

Expand Down
2 changes: 2 additions & 0 deletions apps/web/i18n.lock
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ checksums:
auth/last_used: 9049bf77c57c92b7a5a760ba2a40128c
auth/login/backup_code: 870711a6df11ae55366d018255b466cf
auth/login/create_an_account: bc53a57281d2701563f94f3169518028
auth/login/email_verified_sign_in_description: 91d4c968d2e1a8e576755bc4a4724054
auth/login/email_verified_sign_in_title: f960899104c4e22c27ab699272b2bbc1
auth/login/enter_your_backup_code: f2a6905ea0e8d2b4093b5773d72df88b
auth/login/enter_your_two_factor_authentication_code: 07a020191cd54ee4a0aa1aa4ddc166bf
auth/login/forgot_your_password: 0baf970b4598f1af36623b714410dfae
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Backup-Code",
"create_an_account": "Konto erstellen",
"email_verified_sign_in_description": "Deine E-Mail-Adresse ist bestätigt. Melde dich an, um fortzufahren. Falls du dieses Konto nicht erstellt hast, setze stattdessen das Passwort zurück.",
"email_verified_sign_in_title": "E-Mail bestätigt",
"enter_your_backup_code": "Gib deinen Backup-Code ein",
"enter_your_two_factor_authentication_code": "Gib deinen Zwei-Faktor-Authentifizierungscode ein",
"forgot_your_password": "Passwort vergessen?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Backup code",
"create_an_account": "Create an account",
"email_verified_sign_in_description": "Your email address is confirmed. Sign in to continue. If you didn't create this account, reset the password instead.",
"email_verified_sign_in_title": "Email verified",
"enter_your_backup_code": "Enter your backup code",
"enter_your_two_factor_authentication_code": "Enter your two-factor authentication code",
"forgot_your_password": "Forgot your password?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Código de respaldo",
"create_an_account": "Crear una cuenta",
"email_verified_sign_in_description": "Tu dirección de correo electrónico está confirmada. Inicia sesión para continuar. Si no has creado esta cuenta, restablece la contraseña en su lugar.",
"email_verified_sign_in_title": "Correo electrónico verificado",
"enter_your_backup_code": "Introduce tu código de respaldo",
"enter_your_two_factor_authentication_code": "Introduce tu código de autenticación de dos factores",
"forgot_your_password": "¿Has olvidado tu contraseña?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Code de sauvegarde",
"create_an_account": "Créer un compte",
"email_verified_sign_in_description": "Votre adresse e-mail est confirmée. Connectez-vous pour continuer. Si vous n'avez pas créé ce compte, réinitialisez plutôt le mot de passe.",
"email_verified_sign_in_title": "E-mail vérifié",
"enter_your_backup_code": "Entrez votre code de sauvegarde",
"enter_your_two_factor_authentication_code": "Entrez votre code d'authentification à deux facteurs.",
"forgot_your_password": "Mot de passe oublié ?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/hu-HU.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Visszaszerzési kód",
"create_an_account": "Fiók létrehozása",
"email_verified_sign_in_description": "Az e-mail-címe megerősítve. Jelentkezzen be a folytatáshoz. Ha nem Ön hozta létre ezt a fiókot, ehelyett állítsa vissza a jelszót.",
"email_verified_sign_in_title": "E-mail megerősítve",
"enter_your_backup_code": "Visszaszerzési kód megadása",
"enter_your_two_factor_authentication_code": "Kétfaktoros hitelesítési kód megadása",
"forgot_your_password": "Elfelejtette a jelszavát?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "バックアップコード",
"create_an_account": "アカウントを作成",
"email_verified_sign_in_description": "メールアドレスが確認されました。続行するにはログインしてください。このアカウントに心当たりがない場合は、代わりにパスワードをリセットしてください。",
"email_verified_sign_in_title": "メールアドレスを確認しました",
"enter_your_backup_code": "バックアップコードを入力してください",
"enter_your_two_factor_authentication_code": "二段階認証コードを入力してください",
"forgot_your_password": "パスワードを忘れてしまいましたか?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/nl-NL.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Back-upcode",
"create_an_account": "Maak een account aan",
"email_verified_sign_in_description": "Uw e-mailadres is bevestigd. Log in om door te gaan. Als u dit account niet hebt aangemaakt, stel dan in plaats daarvan het wachtwoord opnieuw in.",
"email_verified_sign_in_title": "E-mail geverifieerd",
"enter_your_backup_code": "Voer uw back-upcode in",
"enter_your_two_factor_authentication_code": "Voer uw tweefactorauthenticatiecode in",
"forgot_your_password": "Wachtwoord vergeten?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Código de backup",
"create_an_account": "Cria uma conta",
"email_verified_sign_in_description": "Seu endereço de e-mail foi confirmado. Faça login para continuar. Se você não criou esta conta, redefina a senha em vez disso.",
"email_verified_sign_in_title": "E-mail verificado",
"enter_your_backup_code": "Digite seu código de backup",
"enter_your_two_factor_authentication_code": "Digite seu código de autenticação de dois fatores",
"forgot_your_password": "Esqueceu sua senha?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/pt-PT.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Código de backup",
"create_an_account": "Criar uma conta",
"email_verified_sign_in_description": "O seu endereço de e-mail foi confirmado. Inicie sessão para continuar. Se não foi você que criou esta conta, redefina antes a palavra-passe.",
"email_verified_sign_in_title": "E-mail verificado",
"enter_your_backup_code": "Introduza o seu código de backup",
"enter_your_two_factor_authentication_code": "Introduza o seu código de autenticação de dois fatores",
"forgot_your_password": "Esqueceu-se da sua palavra-passe?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/ro-RO.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Cod de rezervă",
"create_an_account": "Creează un cont",
"email_verified_sign_in_description": "Adresa ta de e-mail este confirmată. Autentifică-te pentru a continua. Dacă nu tu ai creat acest cont, resetează parola în schimb.",
"email_verified_sign_in_title": "E-mail verificat",
"enter_your_backup_code": "Introduceți codul de rezervă",
"enter_your_two_factor_authentication_code": "Introduceți codul dvs. de autentificare în doi pași",
"forgot_your_password": "Ai uitat parola?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Резервный код",
"create_an_account": "Создать аккаунт",
"email_verified_sign_in_description": "Ваш адрес электронной почты подтверждён. Войдите, чтобы продолжить. Если вы не создавали эту учётную запись, вместо этого сбросьте пароль.",
"email_verified_sign_in_title": "Электронная почта подтверждена",
"enter_your_backup_code": "Введите резервный код",
"enter_your_two_factor_authentication_code": "Введите код двухфакторной аутентификации",
"forgot_your_password": "Забыли пароль?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/sv-SE.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Reservkod",
"create_an_account": "Skapa ett konto",
"email_verified_sign_in_description": "Din e-postadress är bekräftad. Logga in för att fortsätta. Om du inte har skapat det här kontot, återställ lösenordet i stället.",
"email_verified_sign_in_title": "E-post verifierad",
"enter_your_backup_code": "Ange din reservkod",
"enter_your_two_factor_authentication_code": "Ange din tvåfaktorsautentiseringskod",
"forgot_your_password": "Glömt ditt lösenord?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/tr-TR.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "Yedek kod",
"create_an_account": "Hesap oluştur",
"email_verified_sign_in_description": "E-posta adresiniz doğrulandı. Devam etmek için giriş yapın. Bu hesabı siz oluşturmadıysanız bunun yerine şifreyi sıfırlayın.",
"email_verified_sign_in_title": "E-posta doğrulandı",
"enter_your_backup_code": "Yedek kodunuzu girin",
"enter_your_two_factor_authentication_code": "İki faktörlü doğrulama kodunuzu girin",
"forgot_your_password": "Şifrenizi mi unuttunuz?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/zh-Hans-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "备用代码",
"create_an_account": "创建一个帐户",
"email_verified_sign_in_description": "您的电子邮件地址已确认。请登录以继续。如果这个账户不是您创建的,请改为重置密码。",
"email_verified_sign_in_title": "邮箱已验证",
"enter_your_backup_code": "输入 你的 备用代码",
"enter_your_two_factor_authentication_code": "输入 你的 双因素 认证 代码",
"forgot_your_password": "忘记你的密码?",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/locales/zh-Hant-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"login": {
"backup_code": "備份碼",
"create_an_account": "建立帳戶",
"email_verified_sign_in_description": "您的電子郵件地址已確認。請登入以繼續。如果這個帳戶不是您建立的,請改為重設密碼。",
"email_verified_sign_in_title": "電子郵件已驗證",
"enter_your_backup_code": "輸入您的備份碼",
"enter_your_two_factor_authentication_code": "輸入您的雙重驗證碼",
"forgot_your_password": "忘記密碼?",
Expand Down
24 changes: 23 additions & 1 deletion apps/web/modules/auth/lib/after-auth-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import {
} from "@/modules/ee/sso/lib/better-auth-hooks";
import { runAfterAuthHooks } from "./after-auth-hooks";
import { auditFailedAuthAfter } from "./better-auth-observability";
import { twoFactorBackfillAfterHandler } from "./better-auth-two-factor-backfill";
import { verificationAutoSignInAfterHandler } from "./better-auth-verification-autosignin";

vi.mock("@/modules/ee/sso/lib/better-auth-hooks", () => ({
ssoRecoveryAfterHandler: vi.fn(),
blockedSignupDomainRedirectAfterHandler: vi.fn(),
}));
vi.mock("./better-auth-observability", () => ({ auditFailedAuthAfter: vi.fn() }));
vi.mock("./better-auth-two-factor-backfill", () => ({ twoFactorBackfillAfterHandler: vi.fn() }));
vi.mock("./better-auth-verification-autosignin", () => ({
verificationAutoSignInAfterHandler: vi.fn(),
}));

describe("runAfterAuthHooks", () => {
test("records the failed-auth audit before the personal-email redirect handler (which throws)", async () => {
Expand All @@ -21,6 +27,12 @@ describe("runAfterAuthHooks", () => {
vi.mocked(auditFailedAuthAfter).mockImplementation(async () => {
calls.push("audit");
});
vi.mocked(twoFactorBackfillAfterHandler).mockImplementation(async () => {
calls.push("two-factor-backfill");
});
vi.mocked(verificationAutoSignInAfterHandler).mockImplementation(async () => {
calls.push("verification-auto-sign-in");
});
vi.mocked(blockedSignupDomainRedirectAfterHandler).mockImplementation(async () => {
calls.push("redirect");
throw new Error("ctx.redirect"); // mirrors the real handler's ctx.redirect throw
Expand All @@ -30,6 +42,16 @@ describe("runAfterAuthHooks", () => {
// Pins the intended order: the audit runs before the redirect throw. Future-proofing today
// (auditFailedAuthAfter only records /sign-in/email, so an SSO /callback rejection no-ops it either
// way), but locks the contract for when the failed-auth audit is extended to SSO callback paths.
expect(calls).toEqual(["recovery", "audit", "redirect"]);
//
// The ENG-2562 auto-sign-in also has to sit ahead of that throw: it is the only handler here that
// GRANTS something, and a redirect thrown before it would silently cost a legitimate same-browser
// sign-up its session on any request that hit both.
expect(calls).toEqual([
"recovery",
"audit",
"two-factor-backfill",
"verification-auto-sign-in",
"redirect",
]);
});
});
5 changes: 5 additions & 0 deletions apps/web/modules/auth/lib/after-auth-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@/modules/ee/sso/lib/better-auth-hooks";
import { auditFailedAuthAfter } from "./better-auth-observability";
import { twoFactorBackfillAfterHandler } from "./better-auth-two-factor-backfill";
import { verificationAutoSignInAfterHandler } from "./better-auth-verification-autosignin";

/**
* Composed Better Auth `hooks.after` chain. Ordering rationale: `auditFailedAuthAfter` runs before
Expand All @@ -23,5 +24,9 @@ export const runAfterAuthHooks = async (ctx: AuthHookContext): Promise<void> =>
// ENG-1824: heal legacy 2FA enrollments (no `TwoFactor` row) on successful password sign-in, before
// the 2FA challenge. Only touches `/sign-in/email`, so it's unaffected by the SSO-only redirect below.
await twoFactorBackfillAfterHandler(ctx);
// ENG-2562: mint the post-verification session only for the browser that signed up. Only touches
// `/verify-email`, so it is order-independent with respect to the SSO redirects above — placed last
// because it is the only handler here that adds a session rather than inspecting one.
await verificationAutoSignInAfterHandler(ctx);
await blockedSignupDomainRedirectAfterHandler(ctx);
};
38 changes: 38 additions & 0 deletions apps/web/modules/auth/lib/auth-cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import "server-only";
import { env } from "@/lib/env";

/**
* Cookie/token settings shared by `auth.ts` and the sign-up intent cookie (ENG-2562).
*
* Extracted rather than exported from `auth.ts` to keep the import graph acyclic: `signup-intent.ts`
* is reached from `auth.ts` (→ `after-auth-hooks.ts` → `better-auth-verification-autosignin.ts`), so
* importing `auth.ts` back from there would close a cycle. A leaf module both sides can import is the
* cheap way out — the same reason `session-revocation.ts` reaches for `auth` dynamically.
*/

/**
* `__Secure-`/Secure cookies require HTTPS — on http://localhost the browser drops them and the session
* can't persist. Gate on the configured URL scheme (parity with NextAuth's URL-based useSecureCookies
* default) instead of hardcoding true, so local/dev over http works.
*
* WEBAPP_URL is part of the chain because all three vars are optional: a deployment that sets only
* WEBAPP_URL=https://… — the primary documented variable — would otherwise fall through to "" and serve
* the session cookie without `Secure`, letting a downgrade to plaintext HTTP leak it.
*/
export const USE_SECURE_COOKIES = (
env.BETTER_AUTH_URL ??
env.NEXTAUTH_URL ??
env.WEBAPP_URL ??
""
).startsWith("https://");

/**
* Lifetime of a verification link, and therefore of the sign-up intent cookie that has to outlive it.
*
* One constant on purpose: the intent cookie exists to answer "is the browser presenting this
* verification link the one that registered?", so a cookie that died before the link it is paired with
* would silently withhold the session from a legitimate same-browser sign-up — the exact UX ENG-1746
* added and this fix is meant to preserve. Consumed by `emailVerification.expiresIn` in `auth.ts` and
* by `issueSignupIntentCookie`.
*/
export const EMAIL_VERIFICATION_TTL_SECONDS = 60 * 60;
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,16 @@ describe("Better Auth email verification (real Postgres)", () => {

const verifiedUser = await prisma.user.findUnique({ where: { email: "verify@example.com" } });
expect(verifiedUser?.emailVerified).toBe(true);
// autoSignInAfterVerification (ENG-1746): consuming the link establishes a session so the user
// lands in the app already logged in instead of bouncing to /auth/login...
expect(await prisma.session.count()).toBe(1);
// ...and that session is captured in the signedIn audit trail, tagged as a credential-account
// ("password") sign-in via the /verify-email allow-list entry in getSignInAuthMethod.
expect(queueAuditEventBackground).toHaveBeenCalledTimes(1);
expect(queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
userId: verifiedUser?.id,
newObject: expect.objectContaining({ authMethod: "password" }),
})
);
// ENG-2562: consuming the link verifies the address but does NOT establish a session. This row
// used to assert 1 — the ENG-1746 auto-sign-in — and that is precisely the pre-hijacking defect:
// whoever clicks the link is not necessarily whoever chose the password, so handing the clicker a
// session signs a victim into an attacker's account. The session is now minted only against a
// sign-up intent cookie, which a server-side `auth.api.verifyEmail` has no way to carry (there is
// no browser here) — see better-auth-verification-autosignin.integration.test.ts for the granted
// path driven through the real hook chain.
expect(await prisma.session.count()).toBe(0);
// No session means no `signedIn` audit either.
expect(queueAuditEventBackground).not.toHaveBeenCalled();
// afterEmailVerification re-homes the createBrevoCustomer side effect (fire-and-forget)
expect(brevo.createBrevoCustomer).toHaveBeenCalledWith({
id: verifiedUser?.id,
Expand All @@ -90,13 +87,13 @@ describe("Better Auth email verification (real Postgres)", () => {
expect(capturePostHogEvent).toHaveBeenCalledWith(verifiedUser?.id, "user_email_confirmed");

// verify-email is a stateless signed JWT (no getAndDelete), so re-verifying is idempotent:
// the already-verified branch returns { status: true, user: null } and creates no second session
// the already-verified branch returns { status: true, user: null } and creates no session
const replay = await auth.api.verifyEmail({ query: { token } });
expect(replay).toMatchObject({ status: true, user: null });
expect(await prisma.session.count()).toBe(1);
// afterEmailVerification fires once per user — the replay must NOT re-emit the event or the audit
expect(await prisma.session.count()).toBe(0);
// afterEmailVerification fires once per user — the replay must NOT re-emit the event
expect(capturePostHogEvent).toHaveBeenCalledTimes(1);
expect(queueAuditEventBackground).toHaveBeenCalledTimes(1);
expect(queueAuditEventBackground).not.toHaveBeenCalled();
});
});

Expand Down
Loading
Loading