@usequark/quark-core 2.6.1 → 2.6.2
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 +2 -1
- package/src/csrf-client.js +130 -0
- package/src/csrf-client.test.js +200 -0
- package/src/csrf.js +8 -4
- package/src/csrf.test.js +46 -5
- package/src/exports.test.js +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequark/quark-core",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
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,6 +32,7 @@
|
|
|
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",
|
|
37
38
|
"./errors": "./src/errors.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
|
|
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(
|
|
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/
|
|
126
|
+
url: "http://localhost/api/auth/callback/credentials",
|
|
105
127
|
});
|
|
106
128
|
|
|
107
|
-
assert.
|
|
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",
|
package/src/exports.test.js
CHANGED
|
@@ -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");
|