@usequark/quark-core 2.6.1 → 2.6.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-core",
3
- "version": "2.6.1",
3
+ "version": "2.6.3",
4
4
  "description": "Core utilities for Quark apps: auth, BullMQ queues, Redis, email, storage, rate limiting, logging, and error handling",
5
5
  "keywords": [
6
6
  "quark",
@@ -32,14 +32,18 @@
32
32
  "./admin": "./src/admin.js",
33
33
  "./auth": "./src/auth/index.js",
34
34
  "./auth/middleware": "./src/auth/middleware.js",
35
+ "./csrf-client": "./src/csrf-client.js",
35
36
  "./db": "./src/db.js",
36
37
  "./email": "./src/email.js",
38
+ "./email-templates": "./src/email-templates.js",
37
39
  "./errors": "./src/errors.js",
38
40
  "./health": "./src/health.js",
39
41
  "./locale": "./src/locale.js",
40
42
  "./logger": "./src/logger.js",
41
43
  "./metrics": "./src/metrics.js",
44
+ "./multipart": "./src/multipart.js",
42
45
  "./queue": "./src/queue/index.js",
46
+ "./redis": "./src/redis.js",
43
47
  "./sms": "./src/sms.js",
44
48
  "./storage": "./src/storage.js",
45
49
  "./storage/s3": "./src/storage-s3.js",
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @usequark/quark-core - CSRF Client Helpers
3
+ * Browser-safe companion to `csrf.js`, for the "use client" side of the
4
+ * double-submit handshake.
5
+ *
6
+ * ## Why this is a separate module
7
+ *
8
+ * `csrf.js` imports `node:crypto`. A client component cannot reach this module
9
+ * through the `@usequark/quark-core` barrel without dragging that Node builtin
10
+ * into the browser bundle, which fails the build. This file therefore imports
11
+ * nothing at all, and is published as its own subpath export:
12
+ *
13
+ * ```js
14
+ * import { getCsrfToken } from "@usequark/quark-core/csrf-client";
15
+ * ```
16
+ */
17
+
18
+ /** Endpoint that mints a token and sets the matching cookie. */
19
+ const DEFAULT_ENDPOINT = "/api/csrf";
20
+
21
+ /**
22
+ * Cached tokens are treated as expired after this long.
23
+ *
24
+ * `/api/csrf` writes the cookie with a one-hour `maxAge`. A token cached past
25
+ * that point outlives the cookie it has to match, and the next write fails
26
+ * with "CSRF token not found" — a confusing message for what is really a stale
27
+ * token. Refetching ahead of the cookie's own expiry keeps the two in step.
28
+ */
29
+ const TOKEN_TTL_MS = 50 * 60 * 1000;
30
+
31
+ /** The cached token, and the timestamp at which it stops being usable. */
32
+ let cachedToken = null;
33
+ let cachedUntil = 0;
34
+
35
+ /**
36
+ * The in-flight request, shared so that N callers racing on a cold cache make
37
+ * one HTTP request rather than N. Reset when it settles.
38
+ */
39
+ let inflight = null;
40
+
41
+ /**
42
+ * Bumped by `clearCsrfToken`. A request that started before the clear must not
43
+ * repopulate the cache with the token the caller just threw away.
44
+ */
45
+ let generation = 0;
46
+
47
+ /**
48
+ * Drops any cached token, forcing the next `getCsrfToken` to hit the endpoint.
49
+ *
50
+ * Only needed when the server has rotated the cookie underneath the client — a
51
+ * sign-out that clears cookies, or a 401 from a write that suggests the cached
52
+ * token and the cookie no longer agree. Ordinary expiry needs no call: the
53
+ * cache refetches itself.
54
+ *
55
+ * @returns {void}
56
+ */
57
+ export function clearCsrfToken() {
58
+ generation++;
59
+ cachedToken = null;
60
+ cachedUntil = 0;
61
+ inflight = null;
62
+ }
63
+
64
+ /**
65
+ * Returns a CSRF token, fetching one if the cache is cold or stale.
66
+ *
67
+ * The caller must send the token in the `x-csrf-token` header of a mutating
68
+ * request; `/api/csrf` has also set the matching `httpOnly` cookie, and the
69
+ * server compares the two. Because that cookie is `SameSite=Strict`, a
70
+ * cross-site request never carries it and is rejected before the handler runs —
71
+ * which is why this token has to be obtainable *before* authentication.
72
+ *
73
+ * @param {Object} [options]
74
+ * @param {string} [options.endpoint] - Token endpoint (default `/api/csrf`)
75
+ * @param {() => number} [options.now] - Clock override, for tests
76
+ * @returns {Promise<string>} The token to send as `x-csrf-token`
77
+ * @throws {Error} If the endpoint fails or returns no token
78
+ */
79
+ export async function getCsrfToken(options = {}) {
80
+ const { endpoint = DEFAULT_ENDPOINT, now = Date.now } = options;
81
+
82
+ if (cachedToken && now() < cachedUntil) {
83
+ return cachedToken;
84
+ }
85
+
86
+ if (inflight) {
87
+ return inflight;
88
+ }
89
+
90
+ const startedAt = generation;
91
+
92
+ const request = (async () => {
93
+ const response = await fetch(endpoint, {
94
+ credentials: "same-origin",
95
+ headers: { accept: "application/json" },
96
+ cache: "no-store",
97
+ });
98
+
99
+ if (!response.ok) {
100
+ throw new Error(
101
+ `Could not fetch a CSRF token from ${endpoint}: ${response.status}`,
102
+ );
103
+ }
104
+
105
+ const body = await response.json();
106
+ if (!body?.csrfToken) {
107
+ throw new Error(`${endpoint} did not return a csrfToken`);
108
+ }
109
+
110
+ if (startedAt === generation) {
111
+ cachedToken = body.csrfToken;
112
+ cachedUntil = now() + TOKEN_TTL_MS;
113
+ }
114
+
115
+ return body.csrfToken;
116
+ })();
117
+
118
+ inflight = request;
119
+
120
+ // Both settle paths release the slot. `then(cleanup, cleanup)` rather than
121
+ // `finally`, which would leave the returned promise's rejection unhandled.
122
+ const cleanup = () => {
123
+ if (inflight === request) {
124
+ inflight = null;
125
+ }
126
+ };
127
+ request.then(cleanup, cleanup);
128
+
129
+ return request;
130
+ }
@@ -0,0 +1,200 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, mock, test } from "node:test";
3
+
4
+ import { clearCsrfToken, getCsrfToken } from "./csrf-client.js";
5
+
6
+ // The module reads the global `fetch` at call time and caches in module scope,
7
+ // so every test drives both: a stubbed transport and an explicit clock.
8
+
9
+ // Advances freely; `getCsrfToken` only ever compares against this.
10
+ let clock;
11
+ /** Every request the client made, as `{ endpoint, init }`. */
12
+ let requests;
13
+ /** Queue of responses/errors to hand back, one per fetch. */
14
+ let responses;
15
+
16
+ /** A JSON response shaped like `/api/csrf`. */
17
+ function jsonResponse(body, status = 200) {
18
+ return new Response(JSON.stringify(body), {
19
+ status,
20
+ headers: { "content-type": "application/json" },
21
+ });
22
+ }
23
+
24
+ beforeEach(() => {
25
+ clock = 1_000_000;
26
+ requests = [];
27
+ responses = [];
28
+
29
+ mock.method(globalThis, "fetch", async (endpoint, init) => {
30
+ requests.push({ endpoint, init });
31
+ const next = responses.shift();
32
+ if (!next) {
33
+ throw new Error("test made an unscheduled fetch");
34
+ }
35
+ if (next instanceof Error) throw next;
36
+ return next;
37
+ });
38
+
39
+ clearCsrfToken();
40
+ });
41
+
42
+ afterEach(() => {
43
+ clearCsrfToken();
44
+ mock.restoreAll();
45
+ });
46
+
47
+ test("returns the token the endpoint issued", async () => {
48
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
49
+
50
+ const token = await getCsrfToken({ now: () => clock });
51
+
52
+ assert.equal(token, "token-abc");
53
+ assert.equal(requests.length, 1);
54
+ assert.equal(requests[0].endpoint, "/api/csrf");
55
+ });
56
+
57
+ test("asks for the cookie alongside the token", async () => {
58
+ // The whole scheme is double-submit: the server compares the header against
59
+ // the `csrf_token` cookie. A request that skipped credentials would leave
60
+ // the browser without the cookie and every later write would fail.
61
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
62
+
63
+ await getCsrfToken({ now: () => clock });
64
+
65
+ assert.equal(requests[0].init.credentials, "same-origin");
66
+ assert.equal(requests[0].init.cache, "no-store");
67
+ });
68
+
69
+ test("a second call reuses the cached token instead of refetching", async () => {
70
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
71
+
72
+ const first = await getCsrfToken({ now: () => clock });
73
+ const second = await getCsrfToken({ now: () => clock });
74
+
75
+ assert.equal(second, first);
76
+ assert.equal(requests.length, 1);
77
+ });
78
+
79
+ test("concurrent callers share one request", async () => {
80
+ // Three components mounting at once on a cold cache must not each mint a
81
+ // token; a burst of `/api/csrf` hits is also what a rate limiter would see.
82
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
83
+
84
+ const tokens = await Promise.all([
85
+ getCsrfToken({ now: () => clock }),
86
+ getCsrfToken({ now: () => clock }),
87
+ getCsrfToken({ now: () => clock }),
88
+ ]);
89
+
90
+ assert.deepEqual(tokens, ["token-abc", "token-abc", "token-abc"]);
91
+ assert.equal(requests.length, 1);
92
+ });
93
+
94
+ test("refetches once the cached token has aged out", async () => {
95
+ responses.push(jsonResponse({ csrfToken: "first" }));
96
+ assert.equal(await getCsrfToken({ now: () => clock }), "first");
97
+
98
+ // The cookie this token must match is written with a one-hour maxAge, so a
99
+ // token still being served at that point is a token with no cookie behind it.
100
+ clock += 51 * 60 * 1000;
101
+ responses.push(jsonResponse({ csrfToken: "second" }));
102
+
103
+ assert.equal(await getCsrfToken({ now: () => clock }), "second");
104
+ assert.equal(requests.length, 2);
105
+ });
106
+
107
+ test("keeps serving the cached token right up to its expiry", async () => {
108
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
109
+ await getCsrfToken({ now: () => clock });
110
+
111
+ clock += 49 * 60 * 1000;
112
+ assert.equal(await getCsrfToken({ now: () => clock }), "token-abc");
113
+ assert.equal(requests.length, 1, "refetched well before the cache expired");
114
+ });
115
+
116
+ test("clearCsrfToken forces the next call back to the endpoint", async () => {
117
+ responses.push(jsonResponse({ csrfToken: "first" }));
118
+ await getCsrfToken({ now: () => clock });
119
+
120
+ clearCsrfToken();
121
+ responses.push(jsonResponse({ csrfToken: "second" }));
122
+
123
+ assert.equal(await getCsrfToken({ now: () => clock }), "second");
124
+ assert.equal(requests.length, 2);
125
+ });
126
+
127
+ test("a request already in flight does not resurrect a cleared token", async () => {
128
+ // The clear happens while the fetch is outstanding. Caching its response
129
+ // anyway would hand back the very token the caller discarded — and leave the
130
+ // cache holding a token whose cookie may since have been rotated away.
131
+ let release;
132
+ responses.push(
133
+ new Promise((resolve) => {
134
+ release = () => resolve(jsonResponse({ csrfToken: "stale" }));
135
+ }),
136
+ );
137
+
138
+ const pending = getCsrfToken({ now: () => clock });
139
+ clearCsrfToken();
140
+ release();
141
+ await pending;
142
+
143
+ responses.push(jsonResponse({ csrfToken: "fresh" }));
144
+ assert.equal(await getCsrfToken({ now: () => clock }), "fresh");
145
+ });
146
+
147
+ test("reports the status when the endpoint fails", async () => {
148
+ responses.push(jsonResponse({ error: "Unauthorized" }, 401));
149
+
150
+ await assert.rejects(
151
+ () => getCsrfToken({ now: () => clock }),
152
+ /Could not fetch a CSRF token from \/api\/csrf: 401/,
153
+ );
154
+ });
155
+
156
+ test("rejects a 200 that carries no token", async () => {
157
+ // A proxy that answers 200 with an error page, or a route that changed shape,
158
+ // would otherwise cache `undefined` and fail every later write with a
159
+ // confusing mismatch instead of here.
160
+ responses.push(jsonResponse({ error: "nope" }));
161
+
162
+ await assert.rejects(
163
+ () => getCsrfToken({ now: () => clock }),
164
+ /did not return a csrfToken/,
165
+ );
166
+ });
167
+
168
+ test("a failed fetch does not poison the cache", async () => {
169
+ responses.push(new Error("network down"));
170
+ await assert.rejects(() => getCsrfToken({ now: () => clock }));
171
+
172
+ responses.push(jsonResponse({ csrfToken: "recovered" }));
173
+ assert.equal(await getCsrfToken({ now: () => clock }), "recovered");
174
+ assert.equal(requests.length, 2);
175
+ });
176
+
177
+ test("a failed shared request rejects every waiter, once", async () => {
178
+ responses.push(jsonResponse({ error: "Unauthorized" }, 500));
179
+
180
+ const results = await Promise.allSettled([
181
+ getCsrfToken({ now: () => clock }),
182
+ getCsrfToken({ now: () => clock }),
183
+ ]);
184
+
185
+ assert.deepEqual(
186
+ results.map((r) => r.status),
187
+ ["rejected", "rejected"],
188
+ );
189
+ // The rejection has to be handled on the shared promise's own branch too, or
190
+ // it surfaces as an unhandled rejection rather than at the await site.
191
+ assert.equal(requests.length, 1);
192
+ });
193
+
194
+ test("honours a custom endpoint", async () => {
195
+ responses.push(jsonResponse({ csrfToken: "token-abc" }));
196
+
197
+ await getCsrfToken({ endpoint: "/api/csrf-token", now: () => clock });
198
+
199
+ assert.equal(requests[0].endpoint, "/api/csrf-token");
200
+ });
package/src/csrf.js CHANGED
@@ -56,7 +56,14 @@ export function validateCsrfToken(request, sessionToken) {
56
56
  * 2. Compare it against the `X-CSRF-Token` (or `CSRF-Token`) request header
57
57
  * that the client attaches to every mutating request.
58
58
  *
59
- * NextAuth already handles CSRF for /api/auth/* routes, so those are skipped.
59
+ * NextAuth needs no exemption here. It validates its own CSRF token inside
60
+ * `handlers.POST`, and `app/api/auth/[...nextauth]/route.js` exports that
61
+ * handler directly — it is never wrapped in `withCsrfProtection`, so a request
62
+ * to `/api/auth/signin` or `/api/auth/callback/credentials` never reaches this
63
+ * function. The blanket `/api/auth/` skip that used to sit here was therefore
64
+ * protecting nothing, while silently disarming every hand-written route filed
65
+ * under that prefix — `/api/auth/register` included, whose wrapper had never
66
+ * run a check in its life.
60
67
  *
61
68
  * @param {Request} request - Next.js Request object
62
69
  * @returns {void}
@@ -64,17 +71,14 @@ export function validateCsrfToken(request, sessionToken) {
64
71
  */
65
72
  export function requireCsrfToken(request) {
66
73
  const method = request.method;
67
- const path = new URL(request.url).pathname;
68
74
 
69
75
  // Skip CSRF check for:
70
76
  // - Safe methods (GET, HEAD, OPTIONS)
71
- // - NextAuth routes (they have their own CSRF protection)
72
77
  // - Bearer-authenticated requests (e.g. mobile app) - CSRF exploits ambient
73
78
  // cookie credentials; an Authorization header is not sent automatically
74
79
  // by browsers, so cross-site forgery is not possible.
75
80
  if (
76
81
  ["GET", "HEAD", "OPTIONS"].includes(method) ||
77
- path.startsWith("/api/auth/") ||
78
82
  request.headers.get("authorization")?.startsWith("Bearer ")
79
83
  ) {
80
84
  return;
package/src/csrf.test.js CHANGED
@@ -98,17 +98,58 @@ test("CSRF Module", async (t) => {
98
98
  });
99
99
  });
100
100
 
101
- await t.test("requireCsrfToken skips NextAuth routes", () => {
101
+ await t.test(
102
+ "requireCsrfToken no longer skips /api/auth/ paths",
103
+ async () => {
104
+ // The exemption existed for NextAuth, and it was costing more than it
105
+ // bought: every hand-written route filed under that prefix inherited it.
106
+ // `/api/auth/register` is the one that mattered — its wrapper was inert.
107
+ // NextAuth itself is unaffected because `app/api/auth/[...nextauth]/route.js`
108
+ // exports `handlers.POST` directly and never calls `withCsrfProtection`,
109
+ // so no request to `/api/auth/signin` reaches this function at all.
110
+ const request = mockRequest({
111
+ method: "POST",
112
+ url: "http://localhost/api/auth/register",
113
+ });
114
+
115
+ assert.throws(() => requireCsrfToken(request), /CSRF token not found/);
116
+ },
117
+ );
118
+
119
+ await t.test("a NextAuth callback path is not exempted either", async () => {
120
+ // Guards the reasoning above: if a NextAuth request ever did reach this
121
+ // function, it would be rejected. That is correct, because it cannot
122
+ // happen - and if it ever starts happening, this fails loudly instead of
123
+ // silently relying on an exemption that no longer exists.
102
124
  const request = mockRequest({
103
125
  method: "POST",
104
- url: "http://localhost/api/auth/signin",
126
+ url: "http://localhost/api/auth/callback/credentials",
105
127
  });
106
128
 
107
- assert.doesNotThrow(() => {
108
- requireCsrfToken(request);
109
- });
129
+ assert.throws(() => requireCsrfToken(request), /CSRF token not found/);
110
130
  });
111
131
 
132
+ await t.test(
133
+ "a hand-written /api/auth/ route with a valid token passes",
134
+ async () => {
135
+ // The fix must narrow the exemption, not break the route. A matching
136
+ // cookie/header pair is all `/api/auth/register` ever needed.
137
+ const token = generateCsrfToken();
138
+ const request = mockRequest({
139
+ method: "POST",
140
+ url: "http://localhost/api/auth/register",
141
+ headers: {
142
+ "x-csrf-token": token,
143
+ cookie: `csrf_token=${encodeURIComponent(token)}`,
144
+ },
145
+ });
146
+
147
+ assert.doesNotThrow(() => {
148
+ requireCsrfToken(request);
149
+ });
150
+ },
151
+ );
152
+
112
153
  await t.test("requireCsrfToken skips Bearer-authenticated requests", () => {
113
154
  const request = mockRequest({
114
155
  method: "POST",
@@ -7,6 +7,7 @@ import * as admin from "@usequark/quark-core/admin";
7
7
  import * as auth from "@usequark/quark-core/auth";
8
8
  import * as authMiddleware from "@usequark/quark-core/auth/middleware";
9
9
  import * as coreLight from "@usequark/quark-core/core";
10
+ import * as csrfClient from "@usequark/quark-core/csrf-client";
10
11
  import * as db from "@usequark/quark-core/db";
11
12
  import * as email from "@usequark/quark-core/email";
12
13
  import * as errors from "@usequark/quark-core/errors";
@@ -111,6 +112,42 @@ test("subpath './logger' exports the browser-safe logger", () => {
111
112
  assert.equal(typeof logger.createLogger, "function");
112
113
  });
113
114
 
115
+ test("subpath './csrf-client' exports the browser-safe CSRF client", () => {
116
+ // Importable from a `"use client"` component. It must stay off the barrels
117
+ // (`.` and `./core`) because those re-export `csrf.js`, which imports
118
+ // `node:crypto` — reaching the client helper through either of them would
119
+ // put a Node builtin in the browser bundle and fail the build.
120
+ assert.equal(typeof csrfClient.getCsrfToken, "function");
121
+ assert.equal(typeof csrfClient.clearCsrfToken, "function");
122
+ assert.equal(core.getCsrfToken, undefined);
123
+ assert.equal(coreLight.getCsrfToken, undefined);
124
+ });
125
+
126
+ test("subpath './csrf-client' pulls in no Node builtins", async () => {
127
+ // The hazard is transitive, so checking this module's own source is not
128
+ // enough: assert the whole graph it loads stays free of `node:` imports.
129
+ const loaded = new Set();
130
+ const pending = ["./csrf-client.js"];
131
+
132
+ while (pending.length > 0) {
133
+ const relative = pending.pop();
134
+ const url = new URL(relative, import.meta.url);
135
+ if (loaded.has(url.href)) continue;
136
+ loaded.add(url.href);
137
+
138
+ const source = readFileSync(url, "utf-8");
139
+ assert.doesNotMatch(
140
+ source,
141
+ /from\s+["']node:/,
142
+ `${relative} imports a Node builtin`,
143
+ );
144
+
145
+ for (const match of source.matchAll(/from\s+["'](\.[^"']+)["']/g)) {
146
+ pending.push(new URL(match[1], url).href);
147
+ }
148
+ }
149
+ });
150
+
114
151
  test("subpath './locale' exports locale utilities", () => {
115
152
  assert.equal(typeof locale.getDefaultLocale, "function");
116
153
  assert.equal(typeof locale.getSupportedLocales, "function");