Skip to content

Commit 9c71458

Browse files
committed
feat(auth): implement migration script for auth secret and refactor secret handling
- Added a new script `migrate-auth-secret.ts` to facilitate the migration of 2FA secrets when changing the BETTER_AUTH_SECRET. - Updated `package.json` to include a command for running the migration script. - Refactored the handling of BETTER_AUTH_SECRET to improve security by removing the hardcoded default and introducing a fallback mechanism using environment variables or Docker secrets. - Updated the authentication logic to utilize the new `betterAuthSecret` function for retrieving the secret.
1 parent 547ba2d commit 9c71458

6 files changed

Lines changed: 131 additions & 8 deletions

File tree

apps/dokploy/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
1515
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
1616
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
17+
"migrate-auth-secret": "tsx -r dotenv/config scripts/migrate-auth-secret.ts",
1718
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
1819
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
1920
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Use this command to automatically migrate the auth secret: curl -sSL https://dokploy.com/security/0.29.3.sh | bash
3+
* Migration script: re-encrypt 2FA secrets after rotating BETTER_AUTH_SECRET.
4+
*
5+
* Usage:
6+
* OLD_SECRET=<old_secret> NEW_SECRET=<new_secret> npx tsx apps/dokploy/scripts/migrate-auth-secret.ts
7+
*
8+
* Both OLD_SECRET and NEW_SECRET are required.
9+
* Run this BEFORE restarting Dokploy with the new secret.
10+
*/
11+
import { db } from "@dokploy/server/db";
12+
import { twoFactor } from "@dokploy/server/db/schema";
13+
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
14+
import { eq } from "drizzle-orm";
15+
16+
const OLD_SECRET = process.env.OLD_SECRET as string;
17+
const NEW_SECRET = process.env.NEW_SECRET as string;
18+
19+
if (!OLD_SECRET || !NEW_SECRET) {
20+
console.error(
21+
"❌ OLD_SECRET and NEW_SECRET environment variables are required.",
22+
);
23+
console.error(
24+
" Usage: OLD_SECRET=<old> NEW_SECRET=<new> npx tsx apps/dokploy/scripts/migrate-auth-secret.ts",
25+
);
26+
process.exit(1);
27+
}
28+
29+
if (OLD_SECRET === NEW_SECRET) {
30+
console.error("❌ OLD_SECRET and NEW_SECRET must be different.");
31+
process.exit(1);
32+
}
33+
34+
async function reEncrypt(
35+
value: string,
36+
oldSecret: string,
37+
newSecret: string,
38+
): Promise<string> {
39+
const plaintext = await symmetricDecrypt({ key: oldSecret, data: value });
40+
return symmetricEncrypt({ key: newSecret, data: plaintext });
41+
}
42+
43+
async function main() {
44+
console.log("🔍 Fetching 2FA records...");
45+
const records = await db.select().from(twoFactor);
46+
47+
if (records.length === 0) {
48+
console.log("✅ No 2FA records found, nothing to migrate.");
49+
return;
50+
}
51+
52+
console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`);
53+
54+
let migrated = 0;
55+
let failed = 0;
56+
57+
await db.transaction(async (tx) => {
58+
for (const record of records) {
59+
try {
60+
const [newSecret, newBackupCodes] = await Promise.all([
61+
reEncrypt(record.secret, OLD_SECRET, NEW_SECRET),
62+
reEncrypt(record.backupCodes, OLD_SECRET, NEW_SECRET),
63+
]);
64+
65+
await tx
66+
.update(twoFactor)
67+
.set({ secret: newSecret, backupCodes: newBackupCodes })
68+
.where(eq(twoFactor.id, record.id));
69+
70+
migrated++;
71+
} catch (err) {
72+
console.error(
73+
`❌ Failed to migrate record ${record.id} (userId: ${record.userId}):`,
74+
err,
75+
);
76+
failed++;
77+
throw err; // rollback the whole transaction
78+
}
79+
}
80+
});
81+
82+
console.log(`✅ Migrated ${migrated} record(s) successfully.`);
83+
84+
if (failed > 0) {
85+
console.error(
86+
`❌ ${failed} record(s) failed — transaction was rolled back.`,
87+
);
88+
process.exit(1);
89+
} else {
90+
process.exit(0);
91+
}
92+
}
93+
94+
main().catch((err) => {
95+
console.error("❌ Migration failed:", err);
96+
process.exit(1);
97+
});

packages/server/src/constants/index.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,6 @@ const getDockerConfig = (): Docker => {
8383

8484
export const docker = getDockerConfig();
8585

86-
// When not set, use the legacy default so 2FA remains working for users who
87-
// enabled it before BETTER_AUTH_SECRET was introduced.
88-
export const BETTER_AUTH_SECRET =
89-
process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789";
90-
9186
export const paths = (isServer = false) => {
9287
const BASE_PATH =
9388
isServer || process.env.NODE_ENV === "production"

packages/server/src/db/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const {
99
POSTGRES_PORT = "5432",
1010
} = process.env;
1111

12-
function readSecret(path: string): string {
12+
export function readSecret(path: string): string {
1313
try {
1414
return fs.readFileSync(path, "utf8").trim();
1515
} catch {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { readSecret } from "../db/constants";
2+
3+
const HARDCODED_LEGACY_SECRET = "better-auth-secret-123456789";
4+
5+
const { BETTER_AUTH_SECRET, BETTER_AUTH_SECRET_FILE } = process.env;
6+
7+
function resolveBetterAuthSecret(): string {
8+
if (BETTER_AUTH_SECRET) {
9+
return BETTER_AUTH_SECRET;
10+
}
11+
if (BETTER_AUTH_SECRET_FILE) {
12+
return readSecret(BETTER_AUTH_SECRET_FILE);
13+
}
14+
if (process.env.NODE_ENV !== "test") {
15+
console.warn(`
16+
⚠️ [DEPRECATED AUTH CONFIG]
17+
BETTER_AUTH_SECRET is not set via environment variable or Docker secret.
18+
Falling back to the insecure hardcoded default — this is a CRITICAL SECURITY RISK.
19+
This mode WILL BE REMOVED in a future release.
20+
21+
Please migrate to Docker Secrets:
22+
curl -sSL https://dokploy.com/security/0.29.3.sh | bash
23+
`);
24+
}
25+
return HARDCODED_LEGACY_SECRET;
26+
}
27+
28+
export const betterAuthSecret = resolveBetterAuthSecret();

packages/server/src/lib/auth.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
77
import { APIError } from "better-auth/api";
88
import { admin, organization, twoFactor } from "better-auth/plugins";
99
import { and, desc, eq } from "drizzle-orm";
10-
import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants";
10+
import { IS_CLOUD } from "../constants";
1111
import { db } from "../db";
1212
import * as schema from "../db/schema";
1313
import {
@@ -27,6 +27,7 @@ import {
2727
} from "../verification/send-verification-email";
2828
import { getPublicIpWithFallback } from "../wss/utils";
2929
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
30+
import { betterAuthSecret } from "./auth-secret";
3031

3132
const { handler, api } = betterAuth({
3233
database: drizzleAdapter(db, {
@@ -38,8 +39,9 @@ const { handler, api } = betterAuth({
3839
"/organization/create",
3940
"/organization/update",
4041
"/organization/delete",
42+
...(!IS_CLOUD ? ["/verify-email"] : []),
4143
],
42-
secret: BETTER_AUTH_SECRET,
44+
secret: betterAuthSecret,
4345
...(!IS_CLOUD
4446
? {
4547
advanced: {

0 commit comments

Comments
 (0)