@usequark/quark-core 2.7.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth/index.js +1 -0
- package/src/auth/tokens.js +49 -0
- package/src/auth/tokens.test.js +42 -0
- package/src/email-templates.js +48 -0
- package/src/email-templates.test.js +65 -1
package/package.json
CHANGED
package/src/auth/index.js
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @usequark/quark-core - Verification Token Helpers
|
|
5
|
+
*
|
|
6
|
+
* The primitives behind email-verification and password-reset links, kept in
|
|
7
|
+
* one place so both flows agree on how a token is minted and how it is stored.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 32 bytes of entropy, rendered as 64 hex characters. */
|
|
11
|
+
export const DEFAULT_TOKEN_BYTES = 32;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generates a cryptographically random token for a one-time link.
|
|
15
|
+
*
|
|
16
|
+
* `randomBytes` is CSPRNG-backed — unlike `Math.random` or a timestamp, its
|
|
17
|
+
* output cannot be predicted from a sample. The token is the entire bearer
|
|
18
|
+
* credential in the emailed URL, so predictability is the whole attack.
|
|
19
|
+
*
|
|
20
|
+
* Hex rather than base64url because the token is both placed in a URL query
|
|
21
|
+
* string and stored in a database column; hex needs no percent-encoding and
|
|
22
|
+
* survives a copy/paste through an email client without case surprises.
|
|
23
|
+
*
|
|
24
|
+
* @param {number} [bytes=DEFAULT_TOKEN_BYTES] - Entropy in bytes
|
|
25
|
+
* @returns {string} Hex-encoded token
|
|
26
|
+
*/
|
|
27
|
+
export function generateToken(bytes = DEFAULT_TOKEN_BYTES) {
|
|
28
|
+
return crypto.randomBytes(bytes).toString("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hashes a token for storage.
|
|
33
|
+
*
|
|
34
|
+
* The database is not the place to keep a credential that grants account
|
|
35
|
+
* access: a leaked dump, a backup, or a read-only reporting replica would hand
|
|
36
|
+
* an attacker a working reset link for every pending request. What is stored is
|
|
37
|
+
* the SHA-256 digest, so a leaked row cannot be redeemed. The emailed, raw
|
|
38
|
+
* token is the only copy that can be.
|
|
39
|
+
*
|
|
40
|
+
* A plain SHA-256 is sufficient here, unlike for passwords: the input is 256
|
|
41
|
+
* bits of uniform randomness, so brute-forcing the preimage is infeasible and
|
|
42
|
+
* there is nothing for a slow, salted KDF to defend against.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} token - Raw token
|
|
45
|
+
* @returns {string} Hex-encoded SHA-256 digest
|
|
46
|
+
*/
|
|
47
|
+
export function hashToken(token) {
|
|
48
|
+
return crypto.createHash("sha256").update(String(token)).digest("hex");
|
|
49
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { DEFAULT_TOKEN_BYTES, generateToken, hashToken } from "./tokens.js";
|
|
4
|
+
|
|
5
|
+
describe("generateToken", () => {
|
|
6
|
+
test("returns a hex string of 2 characters per byte", () => {
|
|
7
|
+
const token = generateToken();
|
|
8
|
+
assert.match(token, /^[0-9a-f]+$/);
|
|
9
|
+
assert.strictEqual(token.length, DEFAULT_TOKEN_BYTES * 2);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("honours a custom byte length", () => {
|
|
13
|
+
assert.strictEqual(generateToken(16).length, 32);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("does not repeat across calls", () => {
|
|
17
|
+
// A token used as a one-time link must not collide with another. 32 bytes
|
|
18
|
+
// makes a repeat effectively impossible; a regression to a counter, a
|
|
19
|
+
// timestamp, or a constant would surface here immediately.
|
|
20
|
+
const tokens = new Set(Array.from({ length: 100 }, () => generateToken()));
|
|
21
|
+
assert.strictEqual(tokens.size, 100);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("hashToken", () => {
|
|
26
|
+
test("is deterministic", () => {
|
|
27
|
+
assert.strictEqual(hashToken("abc"), hashToken("abc"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("produces a 64-character hex digest", () => {
|
|
31
|
+
assert.match(hashToken("abc"), /^[0-9a-f]{64}$/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("separates different inputs", () => {
|
|
35
|
+
assert.notStrictEqual(hashToken("abc"), hashToken("abd"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("does not embed the raw token", () => {
|
|
39
|
+
const token = "super-secret-reset-token";
|
|
40
|
+
assert.ok(!hashToken(token).includes(token));
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/email-templates.js
CHANGED
|
@@ -132,3 +132,51 @@ export function passwordResetEmail(data) {
|
|
|
132
132
|
|
|
133
133
|
return { subject: `Reset your ${appName} password`, html, text };
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Email verification email - sent to confirm ownership of an address
|
|
138
|
+
*
|
|
139
|
+
* @param {{ name?: string, verifyUrl: string, appName?: string, expiresIn?: string }} data
|
|
140
|
+
* @returns {{ subject: string, html: string, text: string }}
|
|
141
|
+
*/
|
|
142
|
+
export function emailVerificationEmail(data) {
|
|
143
|
+
if (!data?.verifyUrl) {
|
|
144
|
+
throw new Error("verifyUrl is required for email verification email");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const name = data.name ? escapeHtml(data.name) : "there";
|
|
148
|
+
const appName = data.appName || "Quark";
|
|
149
|
+
const expiresIn = data.expiresIn || "24 hours";
|
|
150
|
+
|
|
151
|
+
const html = layout(`
|
|
152
|
+
<h1 style="margin:0 0 16px;font-size:24px;color:#18181b">Confirm your email</h1>
|
|
153
|
+
<p style="color:#3f3f46;line-height:1.6;margin:0 0 16px">
|
|
154
|
+
Hi ${name}, confirm this address to finish setting up your ${escapeHtml(appName)} account.
|
|
155
|
+
</p>
|
|
156
|
+
<p style="text-align:center;margin:24px 0">
|
|
157
|
+
<a href="${escapeHtml(data.verifyUrl)}" style="display:inline-block;padding:12px 24px;background:#18181b;color:#fff;text-decoration:none;border-radius:6px;font-weight:500">
|
|
158
|
+
Verify email
|
|
159
|
+
</a>
|
|
160
|
+
</p>
|
|
161
|
+
<p style="color:#71717a;font-size:14px;margin:0 0 8px">
|
|
162
|
+
This link will expire in ${escapeHtml(expiresIn)}.
|
|
163
|
+
</p>
|
|
164
|
+
<p style="color:#71717a;font-size:14px;margin:0">
|
|
165
|
+
If you didn't create this account, you can safely ignore this email — no one can sign in as you until the address is confirmed.
|
|
166
|
+
</p>
|
|
167
|
+
`);
|
|
168
|
+
|
|
169
|
+
const text = [
|
|
170
|
+
`Confirm your email`,
|
|
171
|
+
"",
|
|
172
|
+
`Hi ${data.name || "there"}, confirm this address to finish setting up your ${appName} account.`,
|
|
173
|
+
"",
|
|
174
|
+
`Verify email: ${data.verifyUrl}`,
|
|
175
|
+
"",
|
|
176
|
+
`This link will expire in ${expiresIn}.`,
|
|
177
|
+
"",
|
|
178
|
+
"If you didn't create this account, you can safely ignore this email — no one can sign in as you until the address is confirmed.",
|
|
179
|
+
].join("\n");
|
|
180
|
+
|
|
181
|
+
return { subject: `Confirm your ${appName} email`, html, text };
|
|
182
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
emailVerificationEmail,
|
|
5
|
+
passwordResetEmail,
|
|
6
|
+
welcomeEmail,
|
|
7
|
+
} from "./email-templates.js";
|
|
4
8
|
|
|
5
9
|
test("welcomeEmail", async (t) => {
|
|
6
10
|
await t.test("returns subject, html, and text", () => {
|
|
@@ -122,3 +126,63 @@ test("passwordResetEmail", async (t) => {
|
|
|
122
126
|
assert.ok(result.html.includes("</html>"));
|
|
123
127
|
});
|
|
124
128
|
});
|
|
129
|
+
|
|
130
|
+
test("emailVerificationEmail", async (t) => {
|
|
131
|
+
const validArgs = {
|
|
132
|
+
verifyUrl: "https://app.test/api/auth/verify-email?token=abc123",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
await t.test("returns subject, html, and text", () => {
|
|
136
|
+
const result = emailVerificationEmail(validArgs);
|
|
137
|
+
assert.ok(result.subject);
|
|
138
|
+
assert.ok(result.html);
|
|
139
|
+
assert.ok(result.text);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t.test("throws when verifyUrl is missing", () => {
|
|
143
|
+
assert.throws(() => emailVerificationEmail({}), /verifyUrl is required/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await t.test("includes verify URL in html and text", () => {
|
|
147
|
+
const result = emailVerificationEmail(validArgs);
|
|
148
|
+
assert.ok(result.html.includes(validArgs.verifyUrl));
|
|
149
|
+
assert.ok(result.text.includes(validArgs.verifyUrl));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await t.test("uses default app name", () => {
|
|
153
|
+
const result = emailVerificationEmail(validArgs);
|
|
154
|
+
assert.ok(result.subject.includes("Quark"));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await t.test("uses custom app name", () => {
|
|
158
|
+
const result = emailVerificationEmail({ ...validArgs, appName: "MyApp" });
|
|
159
|
+
assert.ok(result.subject.includes("MyApp"));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await t.test("includes user name when provided", () => {
|
|
163
|
+
const result = emailVerificationEmail({ ...validArgs, name: "Bob" });
|
|
164
|
+
assert.ok(result.html.includes("Bob"));
|
|
165
|
+
assert.ok(result.text.includes("Bob"));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await t.test("uses default expiry time", () => {
|
|
169
|
+
const result = emailVerificationEmail(validArgs);
|
|
170
|
+
assert.ok(result.html.includes("24 hours"));
|
|
171
|
+
assert.ok(result.text.includes("24 hours"));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await t.test("escapes HTML in user name", () => {
|
|
175
|
+
const result = emailVerificationEmail({
|
|
176
|
+
...validArgs,
|
|
177
|
+
name: '<img src=x onerror="alert(1)">',
|
|
178
|
+
});
|
|
179
|
+
assert.ok(!result.html.includes("<img"));
|
|
180
|
+
assert.ok(result.html.includes("<img"));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await t.test("wraps content in html layout", () => {
|
|
184
|
+
const result = emailVerificationEmail(validArgs);
|
|
185
|
+
assert.ok(result.html.includes("<!DOCTYPE html>"));
|
|
186
|
+
assert.ok(result.html.includes("</html>"));
|
|
187
|
+
});
|
|
188
|
+
});
|