Fix security issues in Better Auth migration

- Tighten login rate limit from 200/10s to 5/60s to prevent brute-force
- Encrypt OAuth tokens (access/refresh/id) in accounts table via databaseHooks
- Sync password changes to accounts.password so old passwords stop working
- Redact OAuth client secrets in server actions before returning to client
- Add trustHost config (default false) to prevent Host header poisoning
- Add audit logging for successful logins via session create hook
- Add audit logging to OAuth provider update/delete server actions
- Fix provider ID collision by appending name hash suffix to slug
- Fix nullable provider field causing incorrect hasOAuth detection
- Refuse to store plaintext secrets if encryption module fails to load

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-12 21:50:48 +02:00
parent 3a16d6e9b1
commit 66f8e32df5
5 changed files with 100 additions and 14 deletions
+47 -3
View File
@@ -5,7 +5,7 @@ import db, { sqlite } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
import { config } from "./config";
import { decryptSecret } from "./secret";
import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret";
import type { OAuthProvider } from "./models/oauth-providers";
import type { GenericOAuthConfig } from "better-auth/plugins";
@@ -82,6 +82,10 @@ function createAuth(): any {
secret: config.sessionSecret,
baseURL: config.baseUrl,
basePath: "/api/auth",
// Only trust the Host header when the operator explicitly opts in.
// baseURL already pins the canonical origin; trustHost is only needed
// behind reverse proxies that rewrite Host without setting X-Forwarded-Host.
trustHost: process.env.AUTH_TRUST_HOST === "true",
trustedOrigins: [config.baseUrl],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
advanced: {
@@ -91,8 +95,8 @@ function createAuth(): any {
} as any,
rateLimit: {
enabled: process.env.AUTH_RATE_LIMIT_ENABLED !== "false",
window: Number(process.env.AUTH_RATE_LIMIT_WINDOW ?? 10),
max: Number(process.env.AUTH_RATE_LIMIT_MAX ?? 200),
window: Number(process.env.AUTH_RATE_LIMIT_WINDOW ?? 60),
max: Number(process.env.AUTH_RATE_LIMIT_MAX ?? 5),
},
user: {
modelName: "users",
@@ -126,6 +130,46 @@ function createAuth(): any {
},
},
},
databaseHooks: {
account: {
create: {
before: async (account) => {
const data = { ...account };
if (data.accessToken) data.accessToken = encryptSecret(data.accessToken);
if (data.refreshToken) data.refreshToken = encryptSecret(data.refreshToken);
if (data.idToken) data.idToken = encryptSecret(data.idToken);
return { data };
},
},
update: {
before: async (account) => {
const data = { ...account };
if (data.accessToken && !isEncryptedSecret(data.accessToken)) data.accessToken = encryptSecret(data.accessToken);
if (data.refreshToken && !isEncryptedSecret(data.refreshToken)) data.refreshToken = encryptSecret(data.refreshToken);
if (data.idToken && !isEncryptedSecret(data.idToken)) data.idToken = encryptSecret(data.idToken);
return { data };
},
},
},
session: {
create: {
after: async (session) => {
try {
const { createAuditEvent } = await import("./models/audit");
await createAuditEvent({
userId: typeof session.userId === "string" ? Number(session.userId) : session.userId,
action: "login_success",
entityType: "session",
entityId: null,
summary: "User signed in",
});
} catch {
// Don't break auth flow if audit logging fails
}
},
},
},
},
plugins: [
username(),
genericOAuth({ config: oauthConfigs }),
+8 -4
View File
@@ -209,13 +209,17 @@ function runEnvProviderSync() {
let encryptSecret: (v: string) => string;
try {
encryptSecret = require("./secret").encryptSecret;
} catch {
encryptSecret = (v) => v;
} catch (e) {
console.error("CRITICAL: Failed to load encryption module, refusing to store plaintext secrets:", e);
return;
}
const name = config.oauth.providerName;
// Use a slug-based ID so the OAuth callback URL is predictable
const providerId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "oauth";
// Use a slug-based ID so the OAuth callback URL is predictable, with hash suffix to avoid collisions
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "oauth";
// Append a short hash of the exact name to avoid collisions (e.g. "Google!" vs "Google?")
const nameHash = Buffer.from(name).toString("base64url").slice(0, 6);
const providerId = `${slug}-${nameHash}`;
const existing = db.select().from(oauthProviders).where(eq(oauthProviders.name, name)).get();
const now = new Date().toISOString();
+9
View File
@@ -130,6 +130,15 @@ export async function updateUserPassword(userId: number, passwordHash: string):
updatedAt: now
})
.where(eq(users.id, userId));
// Also update the Better Auth credential account so the new password takes effect there too
await db
.update(accounts)
.set({
password: passwordHash,
updatedAt: now,
})
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, "credential")));
}
export async function listUsers(): Promise<User[]> {