@coherent.js/forms 1.1.2 → 2.0.0-rc.0
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/README.md +162 -243
- package/dist/csrf.js +57 -0
- package/dist/csrf.js.map +7 -0
- package/dist/form-builder.js +473 -25
- package/dist/form-builder.js.map +3 -3
- package/dist/form-hydration.js +310 -221
- package/dist/form-hydration.js.map +3 -3
- package/dist/index.js +665 -552
- package/dist/index.js.map +4 -4
- package/dist/validation.js +369 -61
- package/dist/validation.js.map +3 -3
- package/dist/validators.js +381 -293
- package/dist/validators.js.map +4 -4
- package/package.json +6 -3
- package/types/csrf.d.ts +45 -0
- package/types/index.d.ts +185 -41
package/README.md
CHANGED
|
@@ -1,303 +1,222 @@
|
|
|
1
1
|
# @coherent.js/forms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server-rendered forms for Coherent.js: build a form on the server, validate
|
|
4
|
+
submissions with the same rules on both sides, and progressively enhance it in
|
|
5
|
+
the browser with `hydrateForm`.
|
|
6
|
+
|
|
7
|
+
- ESM-only, Node 22.12+
|
|
8
|
+
- The package root is isomorphic; `@coherent.js/forms/csrf` is server-only.
|
|
4
9
|
|
|
5
10
|
## Installation
|
|
6
11
|
|
|
7
12
|
```bash
|
|
8
|
-
npm install @coherent.js/forms
|
|
9
|
-
# or
|
|
10
13
|
pnpm add @coherent.js/forms
|
|
11
|
-
# or
|
|
12
|
-
yarn add @coherent.js/forms
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
##
|
|
16
|
-
|
|
17
|
-
The `@coherent.js/forms` package provides powerful form handling capabilities including:
|
|
16
|
+
## Exports
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
18
|
+
| Import path | Exports |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `@coherent.js/forms` | `FormBuilder`, `createFormBuilder`, `buildForm`, `DEFAULT_CLASS_NAMES`, `hydrateForm`, `validators`, `FormValidator`, `createValidator`, `validate`, `validateField`, `validateForm`, `registerValidator`, `composeValidators` |
|
|
21
|
+
| `@coherent.js/forms/form-builder` | `FormBuilder`, `createFormBuilder`, `buildForm`, `DEFAULT_CLASS_NAMES` |
|
|
22
|
+
| `@coherent.js/forms/hydration` | `hydrateForm` |
|
|
23
|
+
| `@coherent.js/forms/validation` | `validators`, `FormValidator`, `createValidator`, `validate` |
|
|
24
|
+
| `@coherent.js/forms/validators` | `validators`, `validateField`, `validateForm`, `createValidator`, `registerValidator`, `composeValidators` |
|
|
25
|
+
| `@coherent.js/forms/csrf` (server only) | `createCsrfToken`, `verifyCsrfToken`, `CSRF_FIELD_NAME` |
|
|
24
26
|
|
|
25
|
-
## Quick
|
|
27
|
+
## Quick start
|
|
26
28
|
|
|
27
29
|
```javascript
|
|
28
|
-
import {
|
|
29
|
-
import { validators } from '@coherent.js/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
}
|
|
30
|
+
import { render } from '@coherent.js/core';
|
|
31
|
+
import { createFormBuilder, validators } from '@coherent.js/forms';
|
|
32
|
+
|
|
33
|
+
// The form definition, shared by every request.
|
|
34
|
+
const signup = createFormBuilder({
|
|
35
|
+
action: '/signup',
|
|
36
|
+
method: 'post',
|
|
37
|
+
fields: [
|
|
38
|
+
{ name: 'email', type: 'email', label: 'Email', required: true },
|
|
39
|
+
{ name: 'password', type: 'password', label: 'Password', required: true,
|
|
40
|
+
validators: [validators.minLength(8)] }
|
|
41
|
+
]
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
form: {
|
|
57
|
-
onsubmit: handleSubmit,
|
|
58
|
-
children: [
|
|
59
|
-
{
|
|
60
|
-
input: {
|
|
61
|
-
type: 'email',
|
|
62
|
-
value: contactForm.fields.email.value,
|
|
63
|
-
oninput: (e) => contactForm.setField('email', e.target.value),
|
|
64
|
-
className: contactForm.fields.email.error ? 'error' : ''
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
span: {
|
|
69
|
-
text: contactForm.fields.email.error || '',
|
|
70
|
-
className: 'error-message'
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
textarea: {
|
|
75
|
-
value: contactForm.fields.message.value,
|
|
76
|
-
oninput: (e) => contactForm.setField('message', e.target.value),
|
|
77
|
-
className: contactForm.fields.message.error ? 'error' : ''
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
span: {
|
|
82
|
-
text: contactForm.fields.message.error || '',
|
|
83
|
-
className: 'error-message'
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
button: {
|
|
88
|
-
type: 'submit',
|
|
89
|
-
text: 'Send Message',
|
|
90
|
-
disabled: contactForm.isSubmitting
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
]
|
|
94
|
-
}
|
|
95
|
-
};
|
|
44
|
+
// GET /signup
|
|
45
|
+
const html = render(signup.buildForm());
|
|
46
|
+
|
|
47
|
+
// POST /signup — fork() gives this request its own state
|
|
48
|
+
const form = signup.fork().setValues(request.body);
|
|
49
|
+
const errors = form.validate();
|
|
50
|
+
if (Object.keys(errors).length > 0) {
|
|
51
|
+
for (const name of Object.keys(errors)) form.touch(name);
|
|
52
|
+
return render(form.buildForm()); // re-render with values and errors
|
|
96
53
|
}
|
|
97
54
|
```
|
|
98
55
|
|
|
99
|
-
|
|
56
|
+
In the browser, `hydrateForm('form[name="form"]')` reads the validation rules
|
|
57
|
+
the server rendered and validates on blur and submit.
|
|
58
|
+
|
|
59
|
+
## Validators
|
|
100
60
|
|
|
101
|
-
|
|
61
|
+
A **validator** is a function `(value, formData) => string | null`: an error
|
|
62
|
+
message, or `null` when the value passes. Every runner — `FormValidator`
|
|
63
|
+
schemas, `validateField`, `validateForm`, `FormBuilder` fields and
|
|
64
|
+
`hydrateForm` — calls validators that way.
|
|
102
65
|
|
|
103
|
-
|
|
66
|
+
Each built-in is a **factory** that returns a validator. The last argument is
|
|
67
|
+
always an optional custom message:
|
|
104
68
|
|
|
105
69
|
```javascript
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
70
|
+
import { validators, validateForm } from '@coherent.js/forms';
|
|
71
|
+
|
|
72
|
+
validateForm(
|
|
73
|
+
{ name: '', email: 'nope', age: '15' },
|
|
74
|
+
{
|
|
75
|
+
name: [validators.required('Please enter your name')],
|
|
76
|
+
email: [validators.required, validators.email()],
|
|
77
|
+
age: [validators.min(18, 'You must be 18 or older')]
|
|
110
78
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Access form values
|
|
114
|
-
console.log(form.values); // { username: '', password: '' }
|
|
115
|
-
|
|
116
|
-
// Update field values
|
|
117
|
-
form.setField('username', 'john_doe');
|
|
118
|
-
|
|
119
|
-
// Check form validity
|
|
120
|
-
console.log(form.isValid); // true/false
|
|
121
|
-
|
|
122
|
-
// Check submission status
|
|
123
|
-
console.log(form.isSubmitting); // true/false
|
|
79
|
+
);
|
|
80
|
+
// → { name: 'Please enter your name', email: 'Invalid email address', age: 'You must be 18 or older' }
|
|
124
81
|
```
|
|
125
82
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
83
|
+
- A built-in may be listed without calling it (`validators.required`); it runs
|
|
84
|
+
with its defaults. A string names a built-in or registered validator (see
|
|
85
|
+
below).
|
|
86
|
+
- Apart from `required` and `matches`, built-ins pass empty values, so combine
|
|
87
|
+
them with `required`.
|
|
88
|
+
- For direct checks, pass the value and an options object:
|
|
89
|
+
`validators.minLength('abc', { min: 5 })` returns `'Minimum length is 5'`.
|
|
90
|
+
A lone string argument is always a message, so `validators.email('a@b.c')`
|
|
91
|
+
returns a validator rather than checking `'a@b.c'`.
|
|
92
|
+
|
|
93
|
+
Built-ins: `required`, `email`, `url`, `minLength(min)`, `maxLength(max)`,
|
|
94
|
+
`min(min)`, `max(max)`, `pattern(regex)`, `matches(field)`, `match(field)`,
|
|
95
|
+
`oneOf(values)`, `custom(fn)`, `number`, `integer`, `phone`, `date`, `alpha`,
|
|
96
|
+
`alphanumeric`, `uppercase`, `fileType(accept)`, `fileSize(maxSize)`,
|
|
97
|
+
`fileExtension(extensions)`. Helpers: `compose`, `when`, `chain`, `debounce`,
|
|
98
|
+
`cancellable`, `get`.
|
|
99
|
+
|
|
100
|
+
### Accepted validator entries
|
|
101
|
+
|
|
102
|
+
A validator list — a `FormBuilder` field's `validators`, a `FormValidator`
|
|
103
|
+
schema, `validateField` / `validateForm` — accepts, in any mix:
|
|
104
|
+
|
|
105
|
+
| Entry | Example | Same as |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| A validator function | `value => value ? null : 'Required'` | — |
|
|
108
|
+
| A built-in, called | `validators.minLength(8, 'Too short')` | — |
|
|
109
|
+
| A built-in, uncalled | `validators.required` | `validators.required()` |
|
|
110
|
+
| A name | `'required'`, `'email'`, `'noShouting'` (registered) | `validators.required()` |
|
|
111
|
+
| A built-in name with arguments | `'minLength:8'` | `validators.minLength(8)` |
|
|
112
|
+
|
|
113
|
+
A string with arguments is `'name:arguments'`, read by the built-in's
|
|
114
|
+
parameter:
|
|
115
|
+
|
|
116
|
+
- a number (`minLength`, `maxLength`, `min`, `max`, `fileSize`): the number,
|
|
117
|
+
then optionally a comma and a message — `'min:18'`,
|
|
118
|
+
`'minLength:8,Use at least 8 characters'`;
|
|
119
|
+
- a field name (`matches`, `match`): `'matches:password'`, optionally
|
|
120
|
+
followed by `,message`;
|
|
121
|
+
- a list (`oneOf`, `fileType`, `fileExtension`): every comma-separated value —
|
|
122
|
+
`'oneOf:small,medium,large'`, `'fileType:image/*,.pdf'`;
|
|
123
|
+
- a regular expression (`pattern`): everything after the colon, commas
|
|
124
|
+
included — `'pattern:^[a-z]{2,8}$'` (no flags; use `validators.pattern()`
|
|
125
|
+
for flags or a message);
|
|
126
|
+
- no parameter (`required`, `email`, …): a message — `'required:Name please'`.
|
|
127
|
+
|
|
128
|
+
Registered validators take no arguments: name them alone. An unknown name,
|
|
129
|
+
or arguments that do not fit (`'minLength:abc'`), is skipped.
|
|
129
130
|
|
|
130
131
|
```javascript
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
value: '',
|
|
137
|
-
validators: [
|
|
138
|
-
validators.required('Email is required'),
|
|
139
|
-
validators.email('Please enter a valid email')
|
|
140
|
-
]
|
|
141
|
-
},
|
|
142
|
-
age: {
|
|
143
|
-
value: '',
|
|
144
|
-
validators: [
|
|
145
|
-
validators.required('Age is required'),
|
|
146
|
-
validators.min(18, 'Must be at least 18 years old')
|
|
147
|
-
]
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// Custom validator
|
|
153
|
-
const customValidator = (value) => {
|
|
154
|
-
if (value && value.length < 5) {
|
|
155
|
-
return 'Value must be at least 5 characters';
|
|
156
|
-
}
|
|
157
|
-
return null; // null means valid
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const formWithCustomValidation = createForm({
|
|
161
|
-
fields: {
|
|
162
|
-
customField: {
|
|
163
|
-
value: '',
|
|
164
|
-
validators: [customValidator]
|
|
165
|
-
}
|
|
166
|
-
}
|
|
132
|
+
createFormBuilder({
|
|
133
|
+
fields: [
|
|
134
|
+
{ name: 'password', type: 'password', validators: ['required', 'minLength:8'] },
|
|
135
|
+
{ name: 'size', validators: ['oneOf:s,m,l'] }
|
|
136
|
+
]
|
|
167
137
|
});
|
|
168
138
|
```
|
|
169
139
|
|
|
170
|
-
|
|
140
|
+
String entries are enforced on the server and rendered into `data-validators`
|
|
141
|
+
exactly like the equivalent factory call, so `hydrateForm` enforces them too.
|
|
171
142
|
|
|
172
|
-
|
|
143
|
+
Your own validators follow the same shape:
|
|
173
144
|
|
|
174
145
|
```javascript
|
|
175
|
-
|
|
176
|
-
if (!value) return null;
|
|
177
|
-
|
|
178
|
-
const response = await fetch(`/api/check-username/${value}`);
|
|
179
|
-
const exists = await response.json();
|
|
180
|
-
|
|
181
|
-
return exists ? 'Username is already taken' : null;
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const signupForm = createForm({
|
|
185
|
-
fields: {
|
|
186
|
-
username: {
|
|
187
|
-
value: '',
|
|
188
|
-
validators: [asyncValidator]
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
```
|
|
146
|
+
import { registerValidator, validators, createValidator } from '@coherent.js/forms';
|
|
193
147
|
|
|
194
|
-
|
|
148
|
+
const noShouting = value =>
|
|
149
|
+
value && value === value.toUpperCase() ? 'Please stop shouting' : null;
|
|
195
150
|
|
|
196
|
-
|
|
151
|
+
registerValidator('noShouting', noShouting); // now validators.noShouting
|
|
152
|
+
const noSpaces = createValidator(value => /\s/.test(value), 'No spaces allowed');
|
|
153
|
+
```
|
|
197
154
|
|
|
198
|
-
|
|
155
|
+
`createValidator(schema)` returns a `FormValidator`; `createValidator(fn,
|
|
156
|
+
message)` wraps a check function as above.
|
|
199
157
|
|
|
200
|
-
|
|
201
|
-
- `options.fields` - Object defining form fields and their initial state
|
|
202
|
-
- `options.onSubmit` - Optional function to handle form submission
|
|
158
|
+
### Client-side validation
|
|
203
159
|
|
|
204
|
-
|
|
160
|
+
For each field, the builder renders its validators into `data-validators` as
|
|
161
|
+
JSON (`[{"name":"minLength","args":[8]}]`), and `hydrateForm` rebuilds the
|
|
162
|
+
same rules, so the browser and the server give the same verdict and message.
|
|
163
|
+
Built-ins and registered validators (register the same name in the browser)
|
|
164
|
+
are described; anonymous functions run on the server only.
|
|
205
165
|
|
|
206
|
-
|
|
166
|
+
## Forms on a server: one state per request
|
|
207
167
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
- `isSubmitting` - Boolean indicating if form is being submitted
|
|
212
|
-
- `errors` - Object containing field errors
|
|
168
|
+
A `FormBuilder` holds the values, errors and touched state of one submission.
|
|
169
|
+
Keep the definition at module scope, and never fill that shared instance with
|
|
170
|
+
request data — the next user would see it. Either fork it per request:
|
|
213
171
|
|
|
214
|
-
|
|
172
|
+
```javascript
|
|
173
|
+
const form = signup.fork();
|
|
174
|
+
form.setValues(request.body);
|
|
175
|
+
```
|
|
215
176
|
|
|
216
|
-
|
|
217
|
-
- `validate()` - Validate all fields, returns boolean
|
|
218
|
-
- `reset()` - Reset form to initial state
|
|
219
|
-
- `submit()` - Trigger form submission
|
|
177
|
+
or render per-request state without touching the builder:
|
|
220
178
|
|
|
221
|
-
|
|
179
|
+
```javascript
|
|
180
|
+
render(signup.buildForm({ values: request.body, errors }));
|
|
181
|
+
```
|
|
222
182
|
|
|
223
|
-
|
|
183
|
+
## CSRF protection
|
|
224
184
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
import { observable } from '@coherent.js/state';
|
|
185
|
+
`@coherent.js/forms/csrf` (server only) issues stateless tokens bound to a
|
|
186
|
+
session and signed with HMAC-SHA256:
|
|
228
187
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
fields: {
|
|
232
|
-
search: { value: '' }
|
|
233
|
-
}
|
|
234
|
-
});
|
|
188
|
+
```javascript
|
|
189
|
+
import { createCsrfToken, verifyCsrfToken } from '@coherent.js/forms/csrf';
|
|
235
190
|
|
|
236
|
-
//
|
|
237
|
-
const
|
|
191
|
+
// GET: render the token as a hidden _csrf input
|
|
192
|
+
const csrfToken = createCsrfToken(process.env.CSRF_SECRET, session.id);
|
|
193
|
+
render(signup.buildForm({ csrfToken }));
|
|
238
194
|
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
searchResults.value = results;
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
});
|
|
195
|
+
// POST: reject the request unless the token matches this session
|
|
196
|
+
if (!verifyCsrfToken(request.body._csrf, process.env.CSRF_SECRET, session.id, { maxAge: 60 * 60 * 1000 })) {
|
|
197
|
+
return response.status(403).end();
|
|
198
|
+
}
|
|
247
199
|
```
|
|
248
200
|
|
|
249
|
-
##
|
|
250
|
-
|
|
251
|
-
### Login Form
|
|
201
|
+
## Hydration
|
|
252
202
|
|
|
253
203
|
```javascript
|
|
254
|
-
import {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
email: {
|
|
260
|
-
value: '',
|
|
261
|
-
validators: [
|
|
262
|
-
validators.required('Email is required'),
|
|
263
|
-
validators.email('Please enter a valid email')
|
|
264
|
-
]
|
|
265
|
-
},
|
|
266
|
-
password: {
|
|
267
|
-
value: '',
|
|
268
|
-
validators: [
|
|
269
|
-
validators.required('Password is required'),
|
|
270
|
-
validators.minLength(8, 'Password must be at least 8 characters')
|
|
271
|
-
]
|
|
272
|
-
}
|
|
273
|
-
},
|
|
274
|
-
async onSubmit(values) {
|
|
275
|
-
try {
|
|
276
|
-
const response = await fetch('/api/login', {
|
|
277
|
-
method: 'POST',
|
|
278
|
-
headers: { 'Content-Type': 'application/json' },
|
|
279
|
-
body: JSON.stringify(values)
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
if (response.ok) {
|
|
283
|
-
// Handle successful login
|
|
284
|
-
window.location.href = '/dashboard';
|
|
285
|
-
} else {
|
|
286
|
-
// Handle login error
|
|
287
|
-
throw new Error('Invalid credentials');
|
|
288
|
-
}
|
|
289
|
-
} catch (error) {
|
|
290
|
-
loginForm.setError('login', error.message);
|
|
291
|
-
}
|
|
204
|
+
import { hydrateForm } from '@coherent.js/forms/hydration';
|
|
205
|
+
|
|
206
|
+
const controller = hydrateForm('#signup', {
|
|
207
|
+
onSubmit: async (values) => {
|
|
208
|
+
await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
|
|
292
209
|
}
|
|
293
210
|
});
|
|
294
211
|
```
|
|
295
212
|
|
|
296
|
-
|
|
213
|
+
Pass the same `classNames` you gave `buildForm` if you customised them. The
|
|
214
|
+
controller exposes `validateField`, `validateForm`, `getValues`, `getErrors`,
|
|
215
|
+
`setFieldValue`, `reset` and `destroy`.
|
|
216
|
+
|
|
217
|
+
## TypeScript
|
|
297
218
|
|
|
298
|
-
|
|
299
|
-
- [@coherent.js/core](../core/README.md) - Core framework
|
|
300
|
-
- [@coherent.js/client](../client/README.md) - Client-side utilities
|
|
219
|
+
Type definitions ship with the package (`types/index.d.ts`, `types/csrf.d.ts`).
|
|
301
220
|
|
|
302
221
|
## License
|
|
303
222
|
|
package/dist/csrf.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/csrf.js
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
var CSRF_FIELD_NAME = "_csrf";
|
|
5
|
+
var TOKEN_PATTERN = /^([0-9a-z]{1,13})\.([A-Za-z0-9_-]{16,64})\.([A-Za-z0-9_-]{43})$/;
|
|
6
|
+
var CLOCK_SKEW_MS = 6e4;
|
|
7
|
+
function assertSecret(secret) {
|
|
8
|
+
const usable = typeof secret === "string" && secret.length > 0 || secret instanceof Uint8Array && secret.length > 0;
|
|
9
|
+
if (!usable) {
|
|
10
|
+
throw new TypeError("A CSRF secret (non-empty string or Buffer) is required");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function assertSessionId(sessionId) {
|
|
14
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
15
|
+
throw new TypeError("A CSRF token must be bound to a non-empty session id string");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function sign(secret, issuedAt, nonce, sessionId) {
|
|
19
|
+
return createHmac("sha256", secret).update(`${issuedAt}.${nonce}.${sessionId}`).digest("base64url");
|
|
20
|
+
}
|
|
21
|
+
function createCsrfToken(secret, sessionId, options = {}) {
|
|
22
|
+
assertSecret(secret);
|
|
23
|
+
assertSessionId(sessionId);
|
|
24
|
+
const issuedAt = Math.floor(options.now ?? Date.now()).toString(36);
|
|
25
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
26
|
+
return `${issuedAt}.${nonce}.${sign(secret, issuedAt, nonce, sessionId)}`;
|
|
27
|
+
}
|
|
28
|
+
function verifyCsrfToken(token, secret, sessionId, options = {}) {
|
|
29
|
+
assertSecret(secret);
|
|
30
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return false;
|
|
31
|
+
if (typeof token !== "string") return false;
|
|
32
|
+
const match = TOKEN_PATTERN.exec(token);
|
|
33
|
+
if (!match) return false;
|
|
34
|
+
const [, issuedAt, nonce, mac] = match;
|
|
35
|
+
const expected = Buffer.from(sign(secret, issuedAt, nonce, sessionId));
|
|
36
|
+
const actual = Buffer.from(mac);
|
|
37
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (options.maxAge !== void 0) {
|
|
41
|
+
const age = (options.now ?? Date.now()) - parseInt(issuedAt, 36);
|
|
42
|
+
if (!(age >= -CLOCK_SKEW_MS && age <= options.maxAge)) return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
var csrf_default = {
|
|
47
|
+
CSRF_FIELD_NAME,
|
|
48
|
+
createCsrfToken,
|
|
49
|
+
verifyCsrfToken
|
|
50
|
+
};
|
|
51
|
+
export {
|
|
52
|
+
CSRF_FIELD_NAME,
|
|
53
|
+
createCsrfToken,
|
|
54
|
+
csrf_default as default,
|
|
55
|
+
verifyCsrfToken
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=csrf.js.map
|
package/dist/csrf.js.map
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/csrf.js"],
|
|
4
|
+
"sourcesContent": ["/**\n * Coherent.js Forms - CSRF tokens (server only)\n *\n * Stateless, session-bound CSRF tokens signed with HMAC-SHA256. Import from\n * `@coherent.js/forms/csrf` on the server; the package root stays free of\n * `node:crypto` so it keeps working in the browser.\n *\n * ```js\n * import { createCsrfToken, verifyCsrfToken } from '@coherent.js/forms/csrf';\n *\n * // GET: render the form with a token for this session\n * const csrfToken = createCsrfToken(process.env.CSRF_SECRET, req.session.id);\n * render(form.buildForm({ csrfToken })); // adds <input type=\"hidden\" name=\"_csrf\">\n *\n * // POST: reject the request unless the token matches the session\n * if (!verifyCsrfToken(req.body._csrf, process.env.CSRF_SECRET, req.session.id, { maxAge: 3_600_000 })) {\n * return res.status(403).end();\n * }\n * ```\n *\n * A token is `<issuedAt>.<nonce>.<mac>`, where the MAC covers the issue time,\n * the nonce and the session id, so a token minted for one session is rejected\n * for any other, and `maxAge` bounds how long it stays valid.\n *\n * @module forms/csrf\n */\n\nimport { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';\nimport { Buffer } from 'node:buffer';\n\n/** Default name of the hidden field the form builder renders. */\nexport const CSRF_FIELD_NAME = '_csrf';\n\nconst TOKEN_PATTERN = /^([0-9a-z]{1,13})\\.([A-Za-z0-9_-]{16,64})\\.([A-Za-z0-9_-]{43})$/;\n\nconst CLOCK_SKEW_MS = 60_000;\n\nfunction assertSecret(secret) {\n const usable = (typeof secret === 'string' && secret.length > 0) ||\n (secret instanceof Uint8Array && secret.length > 0);\n if (!usable) {\n throw new TypeError('A CSRF secret (non-empty string or Buffer) is required');\n }\n}\n\nfunction assertSessionId(sessionId) {\n if (typeof sessionId !== 'string' || sessionId.length === 0) {\n throw new TypeError('A CSRF token must be bound to a non-empty session id string');\n }\n}\n\nfunction sign(secret, issuedAt, nonce, sessionId) {\n return createHmac('sha256', secret)\n .update(`${issuedAt}.${nonce}.${sessionId}`)\n .digest('base64url');\n}\n\n/**\n * Create a CSRF token bound to `sessionId`.\n *\n * @param {string|Buffer} secret - Server-side signing secret\n * @param {string} sessionId - The session the token belongs to\n * @param {{ now?: number }} [options] - `now` (ms) overrides the issue time\n * @returns {string} The token\n */\nexport function createCsrfToken(secret, sessionId, options = {}) {\n assertSecret(secret);\n assertSessionId(sessionId);\n\n const issuedAt = Math.floor(options.now ?? Date.now()).toString(36);\n const nonce = randomBytes(16).toString('base64url');\n return `${issuedAt}.${nonce}.${sign(secret, issuedAt, nonce, sessionId)}`;\n}\n\n/**\n * Check a submitted CSRF token. Returns `false` (never throws) for a missing,\n * malformed, forged, expired or other-session token.\n *\n * @param {unknown} token - The submitted token (e.g. `req.body._csrf`)\n * @param {string|Buffer} secret - The secret the token was created with\n * @param {string} sessionId - The current request's session id\n * @param {{ maxAge?: number, now?: number }} [options] - `maxAge` in ms\n * @returns {boolean} Whether the token is valid for this session\n */\nexport function verifyCsrfToken(token, secret, sessionId, options = {}) {\n assertSecret(secret);\n if (typeof sessionId !== 'string' || sessionId.length === 0) return false;\n if (typeof token !== 'string') return false;\n\n const match = TOKEN_PATTERN.exec(token);\n if (!match) return false;\n const [, issuedAt, nonce, mac] = match;\n\n const expected = Buffer.from(sign(secret, issuedAt, nonce, sessionId));\n const actual = Buffer.from(mac);\n if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {\n return false;\n }\n\n if (options.maxAge !== undefined) {\n const age = (options.now ?? Date.now()) - parseInt(issuedAt, 36);\n // A minute of tolerance for clocks that differ between servers.\n if (!(age >= -CLOCK_SKEW_MS && age <= options.maxAge)) return false;\n }\n\n return true;\n}\n\nexport default {\n CSRF_FIELD_NAME,\n createCsrfToken,\n verifyCsrfToken\n};\n"],
|
|
5
|
+
"mappings": ";AA2BA,SAAS,YAAY,aAAa,uBAAuB;AACzD,SAAS,cAAc;AAGhB,IAAM,kBAAkB;AAE/B,IAAM,gBAAgB;AAEtB,IAAM,gBAAgB;AAEtB,SAAS,aAAa,QAAQ;AAC5B,QAAM,SAAU,OAAO,WAAW,YAAY,OAAO,SAAS,KAC3D,kBAAkB,cAAc,OAAO,SAAS;AACnD,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,UAAU,wDAAwD;AAAA,EAC9E;AACF;AAEA,SAAS,gBAAgB,WAAW;AAClC,MAAI,OAAO,cAAc,YAAY,UAAU,WAAW,GAAG;AAC3D,UAAM,IAAI,UAAU,6DAA6D;AAAA,EACnF;AACF;AAEA,SAAS,KAAK,QAAQ,UAAU,OAAO,WAAW;AAChD,SAAO,WAAW,UAAU,MAAM,EAC/B,OAAO,GAAG,QAAQ,IAAI,KAAK,IAAI,SAAS,EAAE,EAC1C,OAAO,WAAW;AACvB;AAUO,SAAS,gBAAgB,QAAQ,WAAW,UAAU,CAAC,GAAG;AAC/D,eAAa,MAAM;AACnB,kBAAgB,SAAS;AAEzB,QAAM,WAAW,KAAK,MAAM,QAAQ,OAAO,KAAK,IAAI,CAAC,EAAE,SAAS,EAAE;AAClE,QAAM,QAAQ,YAAY,EAAE,EAAE,SAAS,WAAW;AAClD,SAAO,GAAG,QAAQ,IAAI,KAAK,IAAI,KAAK,QAAQ,UAAU,OAAO,SAAS,CAAC;AACzE;AAYO,SAAS,gBAAgB,OAAO,QAAQ,WAAW,UAAU,CAAC,GAAG;AACtE,eAAa,MAAM;AACnB,MAAI,OAAO,cAAc,YAAY,UAAU,WAAW,EAAG,QAAO;AACpE,MAAI,OAAO,UAAU,SAAU,QAAO;AAEtC,QAAM,QAAQ,cAAc,KAAK,KAAK;AACtC,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,CAAC,EAAE,UAAU,OAAO,GAAG,IAAI;AAEjC,QAAM,WAAW,OAAO,KAAK,KAAK,QAAQ,UAAU,OAAO,SAAS,CAAC;AACrE,QAAM,SAAS,OAAO,KAAK,GAAG;AAC9B,MAAI,OAAO,WAAW,SAAS,UAAU,CAAC,gBAAgB,QAAQ,QAAQ,GAAG;AAC3E,WAAO;AAAA,EACT;AAEA,MAAI,QAAQ,WAAW,QAAW;AAChC,UAAM,OAAO,QAAQ,OAAO,KAAK,IAAI,KAAK,SAAS,UAAU,EAAE;AAE/D,QAAI,EAAE,OAAO,CAAC,iBAAiB,OAAO,QAAQ,QAAS,QAAO;AAAA,EAChE;AAEA,SAAO;AACT;AAEA,IAAO,eAAQ;AAAA,EACb;AAAA,EACA;AAAA,EACA;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|