@alxia/react-router 0.36.1 → 0.37.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 +6 -4
- package/dist/chunks/{index-rgy503w9.js → index-2swe47m6.js} +41 -3
- package/dist/chunks/{index-rgy503w9.js.map → index-2swe47m6.js.map} +5 -4
- package/dist/errors/call-failure.d.ts +1 -1
- package/dist/errors/call-failure.d.ts.map +1 -1
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +18 -2
- package/dist/errors/index.js.map +3 -3
- package/dist/errors/problem-error.d.ts +50 -0
- package/dist/errors/problem-error.d.ts.map +1 -0
- package/dist/errors/route-error.d.ts +3 -1
- package/dist/errors/route-error.d.ts.map +1 -1
- package/dist/session/cookies.d.ts +7 -0
- package/dist/session/cookies.d.ts.map +1 -1
- package/dist/session/index.d.ts +2 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +89 -7
- package/dist/session/index.js.map +6 -5
- package/dist/session/serialize.d.ts +9 -3
- package/dist/session/serialize.d.ts.map +1 -1
- package/dist/session/signed.d.ts +44 -0
- package/dist/session/signed.d.ts.map +1 -0
- package/docs/README.md +1 -1
- package/docs/guide/errors.md +32 -1
- package/docs/guide/session.md +50 -1
- package/docs/troubleshooting.md +40 -0
- package/package.json +1 -1
package/docs/guide/session.md
CHANGED
|
@@ -285,6 +285,11 @@ when the app is served over HTTPS, and `false` for plain HTTP in development.
|
|
|
285
285
|
A wrong value in production would send the token over HTTP, so the package
|
|
286
286
|
leaves that choice to you in plain sight.
|
|
287
287
|
|
|
288
|
+
`sameSite` (`'lax'` by default, `'strict'` or `'none'`) sets the `SameSite`
|
|
289
|
+
attribute of every cookie written, `destroy` and the challenge included. Use
|
|
290
|
+
`'strict'` for an app that nobody reaches from a link on another site while
|
|
291
|
+
signed in; `'none'` needs `secure: true` and throws a `TypeError` without it.
|
|
292
|
+
|
|
288
293
|
| `secure` | session cookie | challenge cookie |
|
|
289
294
|
| --- | --- | --- |
|
|
290
295
|
| `true` | `__Host-my-app_session`, `Secure` | `__Secure-my-app_challenge`, `Secure` |
|
|
@@ -301,6 +306,8 @@ The bytes are, in this order:
|
|
|
301
306
|
Name=value; Max-Age=n; Path=/; HttpOnly[; Secure]; SameSite=Lax
|
|
302
307
|
```
|
|
303
308
|
|
|
309
|
+
(`SameSite=Strict` or `None` with the `sameSite` option.)
|
|
310
|
+
|
|
304
311
|
The session cookie's value is `<expiry in ms>.<token>`, percent-encoded. A
|
|
305
312
|
session set by an app that wrote its own cookies in this format is read as it
|
|
306
313
|
is, so adopting the package does not sign anyone out.
|
|
@@ -357,7 +364,7 @@ sets. It clears the challenge blind: a client navigation fetches
|
|
|
357
364
|
request never shows the challenge, yet a `Set-Cookie` with that path removes
|
|
358
365
|
it. With `deadSession`, it also removes a session cookie the API refused.
|
|
359
366
|
|
|
360
|
-
`serializeCookie(name, value, { maxAge, path?, secure })`,
|
|
367
|
+
`serializeCookie(name, value, { maxAge, path?, secure, sameSite? })`,
|
|
361
368
|
`readCookie(request, name)` and `secondsUntil(expiresAt, now?)` are exported
|
|
362
369
|
for the cookies of your own (a language, a preference) that must be written in
|
|
363
370
|
the same bytes. A name that is not an RFC 6265 token, or a path with a `;` or a
|
|
@@ -365,6 +372,48 @@ control character, throws a `TypeError`; a `maxAge` that is not finite is 0.
|
|
|
365
372
|
`readCookie` returns the first non-empty cookie of that name, and `null` when
|
|
366
373
|
its value is not valid percent-encoding.
|
|
367
374
|
|
|
375
|
+
### A signed session without Janus
|
|
376
|
+
|
|
377
|
+
An app with one local operator, whose password the server checks itself, has
|
|
378
|
+
no API token to keep. `signedSession` writes a stateless cookie instead: the
|
|
379
|
+
value is `<expiresAt ms>.<nonce>.<mac>`, the MAC an HMAC-SHA256 of
|
|
380
|
+
`<expiresAt>.<nonce>`, so the expiry cannot be moved without the secret.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
// app/operator.server.ts
|
|
384
|
+
import { signedSession } from '@alxia/react-router/session';
|
|
385
|
+
|
|
386
|
+
export const operator = signedSession({
|
|
387
|
+
secret: process.env.SESSION_SECRET ?? '',
|
|
388
|
+
name: 'my-app_operator',
|
|
389
|
+
maxAge: 8 * 60 * 60,
|
|
390
|
+
secure: !import.meta.env.DEV,
|
|
391
|
+
sameSite: 'strict',
|
|
392
|
+
});
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
// in the sign-in action, once the password was checked
|
|
397
|
+
const headers = new Headers();
|
|
398
|
+
headers.append('Set-Cookie', await operator.commit());
|
|
399
|
+
|
|
400
|
+
// in a loader
|
|
401
|
+
if (!(await operator.valid(request))) throw redirect('/sign-in');
|
|
402
|
+
|
|
403
|
+
// sign-out
|
|
404
|
+
headers.append('Set-Cookie', operator.destroy());
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
The cookie is `HttpOnly`, `Path=/`, has no `Domain`, is named `__Host-…` when
|
|
408
|
+
`secure`, and its `Max-Age` is `maxAge`. `commit` and `valid` are async (Web Crypto, so the subpath keeps
|
|
409
|
+
importing `react-router` alone). `valid` resolves to `false`, and never
|
|
410
|
+
throws, for a missing cookie, a malformed value, an expiry that is not digits,
|
|
411
|
+
a MAC of the wrong length or that does not match (compared in constant time)
|
|
412
|
+
and an expiry in the past. `secret` is at least 32 characters, from the
|
|
413
|
+
environment; changing it signs everyone out. The session is not revocable
|
|
414
|
+
before its expiry: keep `maxAge` short, and check the password again for
|
|
415
|
+
anything sensitive.
|
|
416
|
+
|
|
368
417
|
### Safari and `http://localhost`
|
|
369
418
|
|
|
370
419
|
Safari (WebKit) drops a `Secure` cookie set over `http://localhost`, where
|
package/docs/troubleshooting.md
CHANGED
|
@@ -82,6 +82,10 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
82
82
|
- [`TypeError: sessionCookies(): the … name "…" already carries a prefix. Pass the base name: the package adds __Host- (session) or __Secure- (challenge) when secure is true.`](#typeerror-sessioncookies-the--name--already-carries-a-prefix-pass-the-base-name-the-package-adds-__host--session-or-__secure--challenge-when-secure-is-true)
|
|
83
83
|
- [`TypeError: sessionCookies(): challengeSeconds must be a positive integer; got ….`](#typeerror-sessioncookies-challengeseconds-must-be-a-positive-integer-got-)
|
|
84
84
|
- [`TypeError: sessionCookies(): challengePath must start with "/" and hold no ";" or control character; got ….`](#typeerror-sessioncookies-challengepath-must-start-with--and-hold-no--or-control-character-got-)
|
|
85
|
+
- [`TypeError: sessionCookies(): sameSite must be "lax", "strict" or "none"; got ….`](#typeerror-sessioncookies-samesite-must-be-lax-strict-or-none-got-)
|
|
86
|
+
- [`TypeError: sessionCookies(): sameSite "none" needs secure: true, since a browser drops a SameSite=None cookie without Secure.`](#typeerror-sessioncookies-samesite-none-needs-secure-true-since-a-browser-drops-a-samesitenone-cookie-without-secure)
|
|
87
|
+
- [`TypeError: signedSession(): secret must be a string of at least 32 characters.`](#typeerror-signedsession-secret-must-be-a-string-of-at-least-32-characters)
|
|
88
|
+
- [`TypeError: signedSession(): the name "…" must be an RFC 6265 token without a __Host- or __Secure- prefix; …` or `maxAge must be a positive integer of seconds; got ….`](#typeerror-signedsession-the-name--must-be-an-rfc-6265-token-without-a-__host--or-__secure--prefix--or-maxage-must-be-a-positive-integer-of-seconds-got-)
|
|
85
89
|
- [`TypeError: signInFlow(): paths.… must be a path of this site with no query or fragment, starting with a single "/"; got ….`](#typeerror-signinflow-paths-must-be-a-path-of-this-site-with-no-query-or-fragment-starting-with-a-single--got-)
|
|
86
90
|
- [`TypeError: signInPath(): options.path must be a path of this site with no query or fragment, starting with a single "/"; got ….`](#typeerror-signinpath-optionspath-must-be-a-path-of-this-site-with-no-query-or-fragment-starting-with-a-single--got-)
|
|
87
91
|
- [`TypeError: signInFlow(): json must come from jsonActions(), which carries the site check every step runs; this object has none (a hand-made double?). Pass the app's jsonActions({ origin }) instance, or a spread copy of it.`](#typeerror-signinflow-json-must-come-from-jsonactions-which-carries-the-site-check-every-step-runs-this-object-has-none-a-hand-made-double-pass-the-apps-jsonactions-origin--instance-or-a-spread-copy-of-it)
|
|
@@ -1822,6 +1826,42 @@ TypeError: sessionCookies(): challengePath must start with "/" and hold no ";" o
|
|
|
1822
1826
|
|
|
1823
1827
|
**Fix:** pass the path of the sign-in pages with its leading slash. The default is `'/sign-in'`, which covers `/sign-in` and the pages under it; not `/sign-in/code` alone, since a client navigation fetches `/sign-in/code.data`, which that path would not match.
|
|
1824
1828
|
|
|
1829
|
+
### `TypeError: sessionCookies(): sameSite must be "lax", "strict" or "none"; got ….`
|
|
1830
|
+
|
|
1831
|
+
```
|
|
1832
|
+
TypeError: sessionCookies(): sameSite must be "lax", "strict" or "none"; got "sometimes".
|
|
1833
|
+
```
|
|
1834
|
+
|
|
1835
|
+
**When:** the module that calls `sessionCookies`, `signedSession` or `serializeCookie` is imported (the message starts with that function's name).
|
|
1836
|
+
|
|
1837
|
+
**Why:** `sameSite` is one of the three values, in lower case.
|
|
1838
|
+
|
|
1839
|
+
**Fix:** pass `'lax'` (the default), `'strict'` or `'none'`.
|
|
1840
|
+
|
|
1841
|
+
### `TypeError: sessionCookies(): sameSite "none" needs secure: true, since a browser drops a SameSite=None cookie without Secure.`
|
|
1842
|
+
|
|
1843
|
+
**When:** `sameSite: 'none'` with `secure: false` (development over plain HTTP).
|
|
1844
|
+
|
|
1845
|
+
**Why:** a browser drops a `SameSite=None` cookie that is not `Secure`.
|
|
1846
|
+
|
|
1847
|
+
**Fix:** use `'lax'` or `'strict'` in development, for example `sameSite: import.meta.env.DEV ? 'lax' : 'none'`.
|
|
1848
|
+
|
|
1849
|
+
### `TypeError: signedSession(): secret must be a string of at least 32 characters.`
|
|
1850
|
+
|
|
1851
|
+
**When:** the module that calls `signedSession` is imported, often because the secret's environment variable is unset and `?? ''` gave an empty string.
|
|
1852
|
+
|
|
1853
|
+
**Why:** an HMAC key shorter than 32 characters is guessable.
|
|
1854
|
+
|
|
1855
|
+
**Fix:** set the variable to a random value of 32 characters or more (`openssl rand -base64 32`). Changing it later signs every operator out.
|
|
1856
|
+
|
|
1857
|
+
### `TypeError: signedSession(): the name "…" must be an RFC 6265 token without a __Host- or __Secure- prefix; …` or `maxAge must be a positive integer of seconds; got ….`
|
|
1858
|
+
|
|
1859
|
+
**When:** the module that calls `signedSession` is imported.
|
|
1860
|
+
|
|
1861
|
+
**Why:** the name is not a cookie name or already carries a prefix (the package adds `__Host-` when `secure`), or `maxAge` is not a positive whole number of seconds.
|
|
1862
|
+
|
|
1863
|
+
**Fix:** pass a base name such as `'my-app_operator'` and `maxAge: 8 * 60 * 60`.
|
|
1864
|
+
|
|
1825
1865
|
### `TypeError: signInFlow(): paths.… must be a path of this site with no query or fragment, starting with a single "/"; got ….`
|
|
1826
1866
|
|
|
1827
1867
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|