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
2 changes: 1 addition & 1 deletion apps/web/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ PORT=3002
QUICK_CREATE_ENABLED=true
RATE_LIMIT_ENABLED=false
SECRET_PASSWORD=abcdef1234567890abcdef1234567890
SMTP_HOST=localhost
SMTP_HOST=0.0.0.0
SMTP_PORT=1025
SMTP_REJECT_UNAUTHORIZED=false
SUPPORT_EMAIL=support@rallly.co
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"@vercel/functions": "^3.3.6",
"ai": "^6.0.87",
"arctic": "^3.7.0",
"better-auth": "^1.4.7",
"better-auth": "^1.6.0",
"calendar-link": "^2.6.0",
"class-variance-authority": "^0.7.1",
"color-hash": "^2.0.2",
Expand Down
105 changes: 58 additions & 47 deletions apps/web/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { absoluteUrl } from "@rallly/utils/absolute-url";
import type { BetterAuthPlugin } from "better-auth";
import { APIError, betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { createAuthMiddleware } from "better-auth/api";
import {
admin,
anonymous,
captcha,
createAuthMiddleware,
emailOTP,
genericOAuth,
lastLoginMethod,
Expand All @@ -35,63 +35,74 @@ const baseURL = absoluteUrl("/api/better-auth");

const logger = createLogger("auth");

const plugins: BetterAuthPlugin[] = [];

if (env.TURNSTILE_SECRET_KEY) {
plugins.push(
captcha({
provider: "cloudflare-turnstile",
secretKey: env.TURNSTILE_SECRET_KEY,
endpoints: ["/sign-up/email"],
}),
if (env.OIDC_ISSUER_URL) {
logger.info(
"OIDC_ISSUER_URL is no longer used. You can remove it from your environment variables.",
);
}

if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_DISCOVERY_URL) {
if (env.OIDC_ISSUER_URL) {
logger.info(
"OIDC_ISSUER_URL is no longer used. You can remove it from your environment variables.",
);
}
plugins.push(
genericOAuth({
config: [
{
providerId: "oidc",
discoveryUrl: env.OIDC_DISCOVERY_URL,
clientId: env.OIDC_CLIENT_ID,
clientSecret: env.OIDC_CLIENT_SECRET,
scopes: ["openid", "profile", "email"],
redirectURI: absoluteUrl("/api/auth/callback/oidc"),
pkce: true,
mapProfileToUser(profile) {
return {
name: getValueByPath(profile, env.OIDC_NAME_CLAIM_PATH) as string,
email: getValueByPath(
profile,
env.OIDC_EMAIL_CLAIM_PATH,
) as string,
image: getValueByPath(
profile,
env.OIDC_PICTURE_CLAIM_PATH,
) as string,
};
},
},
],
}),
);
}
// Conditional plugins are typed as BetterAuthPlugin[] — they don't add user
// fields so losing their specific types doesn't affect session type inference.
const conditionalPlugins: BetterAuthPlugin[] = [
...(env.TURNSTILE_SECRET_KEY
? [
captcha({
provider: "cloudflare-turnstile",
secretKey: env.TURNSTILE_SECRET_KEY,
endpoints: ["/sign-up/email"],
}),
]
: []),
...(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_DISCOVERY_URL
? [
genericOAuth({
config: [
{
providerId: "oidc",
discoveryUrl: env.OIDC_DISCOVERY_URL,
clientId: env.OIDC_CLIENT_ID,
clientSecret: env.OIDC_CLIENT_SECRET,
scopes: ["openid", "profile", "email"],
redirectURI: absoluteUrl("/api/auth/callback/oidc"),
pkce: true,
mapProfileToUser(profile) {
return {
name: getValueByPath(
profile,
env.OIDC_NAME_CLAIM_PATH,
) as string,
email: getValueByPath(
profile,
env.OIDC_EMAIL_CLAIM_PATH,
) as string,
image: getValueByPath(
profile,
env.OIDC_PICTURE_CLAIM_PATH,
) as string,
};
},
},
],
}),
]
: []),
];

export const authLib = betterAuth({
appName: "Rallly",
appName: env.APP_NAME,
secret: env.SECRET_PASSWORD,
experimental: {
joins: true,
},
emailAndPassword: {
enabled: env.EMAIL_LOGIN_ENABLED !== "false",
requireEmailVerification: true,
onExistingUserSignUp: async ({ user }, request) => {
await authLib.api.sendVerificationOTP({
body: { email: user.email, type: "email-verification" },
request,
});
},
Comment on lines 97 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/lib/auth.ts | head -120 | tail -30

Repository: lukevella/rallly

Length of output: 1162


🏁 Script executed:

rg "customSyntheticUser" --type ts --type tsx

Repository: lukevella/rallly

Length of output: 87


🏁 Script executed:

rg "Better Auth" --type ts --type tsx -i

Repository: lukevella/rallly

Length of output: 87


🏁 Script executed:

fd "auth" apps/web/src --type f

Repository: lukevella/rallly

Length of output: 274


🏁 Script executed:

cat -n apps/web/src/lib/auth.ts | head -200

Repository: lukevella/rallly

Length of output: 7882


🏁 Script executed:

rg "admin" apps/web/src/lib/auth.ts -i -A 3 -B 3

Repository: lukevella/rallly

Length of output: 499


🏁 Script executed:

rg "schema\|Schema" apps/web/src/lib/auth.ts -A 3 -B 1

Repository: lukevella/rallly

Length of output: 42


🏁 Script executed:

rg "user.*extend\|extend.*user" apps/web/src -i

Repository: lukevella/rallly

Length of output: 42


🏁 Script executed:

cat -n apps/web/src/lib/auth.ts | tail -100

Repository: lukevella/rallly

Length of output: 3291


🏁 Script executed:

grep -n "customSyntheticUser" apps/web/src/lib/auth.ts

Repository: lukevella/rallly

Length of output: 42


🏁 Script executed:

cat -n apps/web/src/lib/auth.ts | sed -n '198,250p'

Repository: lukevella/rallly

Length of output: 1620


🌐 Web query:

Better Auth customSyntheticUser admin plugin requireEmailVerification

💡 Result:

When you enable email enumeration protection—which happens if emailAndPassword.requireEmailVerification: true (or autoSignIn: false)—Better Auth may return a synthetic (fake) user shape on sign-up. If you’re using the admin plugin, you must extend that synthetic user to include the admin plugin’s added user fields (role, banned, banReason, banExpires), otherwise you’ll hit missing-field/type/schema issues. [1]

import { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins";

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    customSyntheticUser: ({ coreFields, additionalFields, id }) => ({
      ...coreFields,

      // Admin plugin fields (in schema order)
      role: "user",        // or your configured defaultRole
      banned: false,
      banReason: null,
      banExpires: null,

      ...additionalFields,
      id,
    }),
  },
  plugins: [admin()],
});

customSyntheticUser is specifically intended for this case (“Use when plugins add fields to the user table”). [2]

Sources: [1] [2]


🌐 Web query:

Better Auth 1.5 enumeration-safe sign-up customSyntheticUser

💡 Result:

In Better Auth v1.5, when you enable email enumeration protection (e.g. requireEmailVerification: true or autoSignIn: false), the sign-up endpoint may return a synthetic (fake) user response so the client can’t tell whether an email already exists. If you use plugins that add columns to user, you must use customSyntheticUser to include those plugin fields in the synthetic response (otherwise you’ll get missing-field/type/runtime issues). [1][2]

Example (Admin plugin adds role, banned, banReason, banExpires): [1]

import { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins";

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true, // (or autoSignIn: false)
    customSyntheticUser: ({ coreFields, additionalFields, id }) => ({
      ...coreFields,
      // Admin plugin fields (in schema order)
      role: "user",      // or your admin({ defaultRole: ... })
      banned: false,
      banReason: null,
      banExpires: null,

      // any other fields added via Better Auth "additionalFields"
      ...additionalFields,
      id,
    }),
  },
  plugins: [admin()],
});

If you have other plugins/custom fields, add their user-table fields into customSyntheticUser the same way (so the synthetic response matches your user schema). [2]


Add customSyntheticUser to the emailAndPassword block.

With requireEmailVerification: true enabled and the admin plugin active, Better Auth returns a synthetic user on sign-up to prevent email enumeration. You must include customSyntheticUser to populate the admin plugin's fields (role, banned, banReason, banExpires) plus your custom additionalFields (timeZone, locale) in the synthetic response. Without it, the synthetic user will be missing these fields, causing schema mismatches and potential runtime errors.

Example fix:
emailAndPassword: {
  enabled: env.EMAIL_LOGIN_ENABLED !== "false",
  requireEmailVerification: true,
  customSyntheticUser: ({ coreFields, additionalFields, id }) => ({
    ...coreFields,
    role: "user",
    banned: false,
    banReason: null,
    banExpires: null,
    ...additionalFields,
    id,
  }),
  onExistingUserSignUp: async ({ user }, request) => {
    // ...
  },
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/lib/auth.ts` around lines 97 - 105, The emailAndPassword config
enables requireEmailVerification but lacks customSyntheticUser, so synthetic
sign-up responses are missing admin and additionalFields and cause schema
mismatches; add a customSyntheticUser handler on the emailAndPassword object
(near requireEmailVerification and onExistingUserSignUp) that returns
{...coreFields, role: "user", banned: false, banReason: null, banExpires: null,
...additionalFields, id} (ensuring additionalFields like timeZone and locale are
included) to populate role, banned, banReason, banExpires and your custom
fields.

sendResetPassword: async ({ user, url }) => {
const locale =
"locale" in user ? (user.locale as string) : await getLocale();
Expand All @@ -118,7 +129,6 @@ export const authLib = betterAuth({
transaction: false, // when set to true, there is an issue where the after() hook is called before the user is actually created in the database
}),
plugins: [
...plugins,
admin(),
anonymous({
emailDomainName: "rallly.co",
Expand Down Expand Up @@ -164,6 +174,7 @@ export const authLib = betterAuth({
}
},
}),
...conditionalPlugins,
],
socialProviders: {
google:
Expand Down
16 changes: 12 additions & 4 deletions apps/web/tests/authentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ test.describe.serial(() => {
});

test.describe("Existing User", () => {
test("can't register with the same email", async ({ page }) => {
test("sends OTP to existing user when registering with existing email", async ({
page,
}) => {
await page.goto("/register");

await page.getByText("Create Your Account").waitFor();
Expand All @@ -53,9 +55,15 @@ test.describe.serial(() => {

await page.getByRole("button", { name: "Continue", exact: true }).click();

await expect(
page.getByText("A user with that email already exists"),
).toBeVisible();
// Should show the verification code prompt (not an error) to prevent email enumeration
await page.getByRole("heading", { name: "Finish Logging In" }).waitFor();

// The existing user should have received a sign-in OTP
const code = await getCode(testUserEmail);
await page.getByPlaceholder("Enter your 6-digit code").fill(code);

// Existing user should be signed in
await expect(page.getByText("Test User")).toBeVisible();
});

test("can login with password", async ({ page }) => {
Expand Down
Loading
Loading