Skip to content

Commit ade4b8d

Browse files
authored
Merge pull request #1749 from malko/template-helpers
Template helpers: Enhanced JWT generation
2 parents 0bf4e55 + f49a67f commit ade4b8d

File tree

4 files changed

+361
-9
lines changed

4 files changed

+361
-9
lines changed

apps/dokploy/__test__/templates/config.template.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,35 @@ describe("processTemplate", () => {
5151
expect(result.domains).toHaveLength(0);
5252
expect(result.mounts).toHaveLength(0);
5353
});
54+
55+
it("should allow creation of real jwt secret", () => {
56+
const template: CompleteTemplate = {
57+
metadata: {} as any,
58+
variables: {
59+
jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
60+
anon_payload: JSON.stringify({
61+
role: "tester",
62+
iss: "dockploy",
63+
iat: "${timestamps:2025-01-01T00:00:00Z}",
64+
exp: "${timestamps:2030-01-01T00:00:00Z}",
65+
}),
66+
anon_key: "${jwt:jwt_secret:anon_payload}",
67+
},
68+
config: {
69+
domains: [],
70+
env: {
71+
ANON_KEY: "${anon_key}",
72+
},
73+
},
74+
};
75+
const result = processTemplate(template, mockSchema);
76+
expect(result.envs).toHaveLength(1);
77+
expect(result.envs).toContain(
78+
"ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
79+
);
80+
expect(result.mounts).toHaveLength(0);
81+
expect(result.domains).toHaveLength(0);
82+
});
5483
});
5584

5685
describe("domains processing", () => {
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import type { Schema } from "@dokploy/server/templates";
2+
import { processValue } from "@dokploy/server/templates/processors";
3+
import { describe, expect, it } from "vitest";
4+
5+
describe("helpers functions", () => {
6+
// Mock schema for testing
7+
const mockSchema: Schema = {
8+
projectName: "test",
9+
serverIp: "127.0.0.1",
10+
};
11+
// some helpers to test jwt
12+
type JWTParts = [string, string, string];
13+
const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
14+
const jwtBase64Decode = (str: string) => {
15+
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
16+
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
17+
const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8");
18+
return JSON.parse(decoded);
19+
};
20+
const jwtCheckHeader = (jwtHeader: string) => {
21+
const decodedHeader = jwtBase64Decode(jwtHeader);
22+
expect(decodedHeader).toHaveProperty("alg");
23+
expect(decodedHeader).toHaveProperty("typ");
24+
expect(decodedHeader.alg).toEqual("HS256");
25+
expect(decodedHeader.typ).toEqual("JWT");
26+
};
27+
28+
describe("${domain}", () => {
29+
it("should generate a random domain", () => {
30+
const domain = processValue("${domain}", {}, mockSchema);
31+
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
32+
expect(
33+
domain.endsWith(
34+
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
35+
),
36+
).toBeTruthy();
37+
});
38+
});
39+
40+
describe("${base64}", () => {
41+
it("should generate a base64 string", () => {
42+
const base64 = processValue("${base64}", {}, mockSchema);
43+
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
44+
});
45+
it.each([
46+
[4, 8],
47+
[8, 12],
48+
[16, 24],
49+
[32, 44],
50+
[64, 88],
51+
[128, 172],
52+
])(
53+
"should generate a base64 string from parameter %d bytes length",
54+
(length, finalLength) => {
55+
const base64 = processValue(`\${base64:${length}}`, {}, mockSchema);
56+
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
57+
expect(base64.length).toBe(finalLength);
58+
},
59+
);
60+
});
61+
62+
describe("${password}", () => {
63+
it("should generate a password string", () => {
64+
const password = processValue("${password}", {}, mockSchema);
65+
expect(password).toMatch(/^[A-Za-z0-9]+$/);
66+
});
67+
it.each([6, 8, 12, 16, 32])(
68+
"should generate a password string respecting parameter %d length",
69+
(length) => {
70+
const password = processValue(`\${password:${length}}`, {}, mockSchema);
71+
expect(password).toMatch(/^[A-Za-z0-9]+$/);
72+
expect(password.length).toBe(length);
73+
},
74+
);
75+
});
76+
77+
describe("${hash}", () => {
78+
it("should generate a hash string", () => {
79+
const hash = processValue("${hash}", {}, mockSchema);
80+
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
81+
});
82+
it.each([6, 8, 12, 16, 32])(
83+
"should generate a hash string respecting parameter %d length",
84+
(length) => {
85+
const hash = processValue(`\${hash:${length}}`, {}, mockSchema);
86+
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
87+
expect(hash.length).toBe(length);
88+
},
89+
);
90+
});
91+
92+
describe("${uuid}", () => {
93+
it("should generate a UUID string", () => {
94+
const uuid = processValue("${uuid}", {}, mockSchema);
95+
expect(uuid).toMatch(
96+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
97+
);
98+
});
99+
});
100+
101+
describe("${timestamp}", () => {
102+
it("should generate a timestamp string in milliseconds", () => {
103+
const timestamp = processValue("${timestamp}", {}, mockSchema);
104+
const nowLength = Math.floor(Date.now()).toString().length;
105+
expect(timestamp).toMatch(/^\d+$/);
106+
expect(timestamp.length).toBe(nowLength);
107+
});
108+
});
109+
describe("${timestampms}", () => {
110+
it("should generate a timestamp string in milliseconds", () => {
111+
const timestamp = processValue("${timestampms}", {}, mockSchema);
112+
const nowLength = Date.now().toString().length;
113+
expect(timestamp).toMatch(/^\d+$/);
114+
expect(timestamp.length).toBe(nowLength);
115+
});
116+
it("should generate a timestamp string in milliseconds from parameter", () => {
117+
const timestamp = processValue(
118+
"${timestampms:2025-01-01}",
119+
{},
120+
mockSchema,
121+
);
122+
expect(timestamp).toEqual("1735689600000");
123+
});
124+
});
125+
describe("${timestamps}", () => {
126+
it("should generate a timestamp string in seconds", () => {
127+
const timestamps = processValue("${timestamps}", {}, mockSchema);
128+
const nowLength = Math.floor(Date.now() / 1000).toString().length;
129+
expect(timestamps).toMatch(/^\d+$/);
130+
expect(timestamps.length).toBe(nowLength);
131+
});
132+
it("should generate a timestamp string in seconds from parameter", () => {
133+
const timestamps = processValue(
134+
"${timestamps:2025-01-01}",
135+
{},
136+
mockSchema,
137+
);
138+
expect(timestamps).toEqual("1735689600");
139+
});
140+
});
141+
142+
describe("${randomPort}", () => {
143+
it("should generate a random port string", () => {
144+
const randomPort = processValue("${randomPort}", {}, mockSchema);
145+
expect(randomPort).toMatch(/^\d+$/);
146+
expect(Number(randomPort)).toBeLessThan(65536);
147+
});
148+
});
149+
150+
describe("${username}", () => {
151+
it("should generate a username string", () => {
152+
const username = processValue("${username}", {}, mockSchema);
153+
expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/);
154+
});
155+
});
156+
157+
describe("${email}", () => {
158+
it("should generate an email string", () => {
159+
const email = processValue("${email}", {}, mockSchema);
160+
expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/);
161+
});
162+
});
163+
164+
describe("${jwt}", () => {
165+
it("should generate a JWT string", () => {
166+
const jwt = processValue("${jwt}", {}, mockSchema);
167+
expect(jwt).toMatch(jwtMatchExp);
168+
const parts = jwt.split(".") as JWTParts;
169+
const decodedPayload = jwtBase64Decode(parts[1]);
170+
jwtCheckHeader(parts[0]);
171+
expect(decodedPayload).toHaveProperty("iat");
172+
expect(decodedPayload).toHaveProperty("iss");
173+
expect(decodedPayload).toHaveProperty("exp");
174+
expect(decodedPayload.iss).toEqual("dokploy");
175+
});
176+
it.each([6, 8, 12, 16, 32])(
177+
"should generate a random hex string from parameter %d byte length",
178+
(length) => {
179+
const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema);
180+
expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/);
181+
expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length
182+
expect(jwt.length).toBeLessThanOrEqual(length * 2);
183+
},
184+
);
185+
});
186+
describe("${jwt:secret}", () => {
187+
it("should generate a JWT string respecting parameter secret from variable", () => {
188+
const jwt = processValue(
189+
"${jwt:secret}",
190+
{ secret: "mysecret" },
191+
mockSchema,
192+
);
193+
expect(jwt).toMatch(jwtMatchExp);
194+
const parts = jwt.split(".") as JWTParts;
195+
const decodedPayload = jwtBase64Decode(parts[1]);
196+
jwtCheckHeader(parts[0]);
197+
expect(decodedPayload).toHaveProperty("iat");
198+
expect(decodedPayload).toHaveProperty("iss");
199+
expect(decodedPayload).toHaveProperty("exp");
200+
expect(decodedPayload.iss).toEqual("dokploy");
201+
});
202+
});
203+
describe("${jwt:secret:payload}", () => {
204+
it("should generate a JWT string respecting parameters secret and payload from variables", () => {
205+
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
206+
const expiry = iat + 3600;
207+
const jwt = processValue(
208+
"${jwt:secret:payload}",
209+
{
210+
secret: "mysecret",
211+
payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`,
212+
},
213+
mockSchema,
214+
);
215+
expect(jwt).toMatch(jwtMatchExp);
216+
const parts = jwt.split(".") as JWTParts;
217+
jwtCheckHeader(parts[0]);
218+
const decodedPayload = jwtBase64Decode(parts[1]);
219+
expect(decodedPayload).toHaveProperty("iat");
220+
expect(decodedPayload.iat).toEqual(iat);
221+
expect(decodedPayload).toHaveProperty("iss");
222+
expect(decodedPayload.iss).toEqual("test-issuer");
223+
expect(decodedPayload).toHaveProperty("exp");
224+
expect(decodedPayload.exp).toEqual(expiry);
225+
expect(decodedPayload).toHaveProperty("customprop");
226+
expect(decodedPayload.customprop).toEqual("customvalue");
227+
expect(jwt).toEqual(
228+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
229+
);
230+
});
231+
});
232+
});

packages/server/src/templates/index.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { randomBytes } from "node:crypto";
1+
import { randomBytes, createHmac } from "node:crypto";
22
import { existsSync } from "node:fs";
33
import { mkdir, readFile, writeFile } from "node:fs/promises";
44
import { join } from "node:path";
@@ -24,6 +24,12 @@ export interface Template {
2424
domains: DomainSchema[];
2525
}
2626

27+
export interface GenerateJWTOptions {
28+
length?: number;
29+
secret?: string;
30+
payload?: Record<string, unknown> | undefined;
31+
}
32+
2733
export const generateRandomDomain = ({
2834
serverIp,
2935
projectName,
@@ -61,8 +67,48 @@ export function generateBase64(bytes = 32): string {
6167
return randomBytes(bytes).toString("base64");
6268
}
6369

64-
export function generateJwt(length = 256): string {
65-
return randomBytes(length).toString("hex");
70+
function safeBase64(str: string): string {
71+
return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
72+
}
73+
function objToJWTBase64(obj: any): string {
74+
return safeBase64(
75+
Buffer.from(JSON.stringify(obj), "utf8").toString("base64"),
76+
);
77+
}
78+
79+
export function generateJwt(options: GenerateJWTOptions = {}): string {
80+
let { length, secret, payload = {} } = options;
81+
if (length) {
82+
return randomBytes(length).toString("hex");
83+
}
84+
const encodedHeader = objToJWTBase64({
85+
alg: "HS256",
86+
typ: "JWT",
87+
});
88+
if (!payload.iss) {
89+
payload.iss = "dokploy";
90+
}
91+
if (!payload.iat) {
92+
payload.iat = Math.floor(Date.now() / 1000);
93+
}
94+
if (!payload.exp) {
95+
payload.exp = Math.floor(new Date("2030-01-01T00:00:00Z").getTime() / 1000);
96+
}
97+
const encodedPayload = objToJWTBase64({
98+
iat: Math.floor(Date.now() / 1000),
99+
exp: Math.floor(new Date("2030-01-01T00:00:00Z").getTime() / 1000),
100+
...payload,
101+
});
102+
if (!secret) {
103+
secret = randomBytes(32).toString("hex");
104+
}
105+
const signature = safeBase64(
106+
createHmac("SHA256", secret)
107+
.update(`${encodedHeader}.${encodedPayload}`)
108+
.digest("base64"),
109+
);
110+
111+
return `${encodedHeader}.${encodedPayload}.${signature}`;
66112
}
67113

68114
/**

0 commit comments

Comments
 (0)