@alxia/react-router 0.13.0 → 0.15.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 +78 -0
- package/dist/chunks/index-0tyw54ad.js +160 -0
- package/dist/chunks/index-0tyw54ad.js.map +15 -0
- package/dist/chunks/index-c3k47yw7.js +124 -0
- package/dist/chunks/index-c3k47yw7.js.map +14 -0
- package/dist/errors/api-error.d.ts +18 -0
- package/dist/errors/api-error.d.ts.map +1 -1
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +45 -110
- package/dist/errors/index.js.map +4 -8
- package/dist/errors/json-actions.d.ts.map +1 -1
- package/dist/errors/same-site.d.ts +25 -0
- package/dist/errors/same-site.d.ts.map +1 -0
- package/dist/session/codes.d.ts +7 -0
- package/dist/session/codes.d.ts.map +1 -0
- package/dist/session/cookies.d.ts +60 -0
- package/dist/session/cookies.d.ts.map +1 -0
- package/dist/session/flow-types.d.ts +194 -0
- package/dist/session/flow-types.d.ts.map +1 -0
- package/dist/session/guard.d.ts +10 -0
- package/dist/session/guard.d.ts.map +1 -0
- package/dist/session/index.d.ts +18 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +648 -0
- package/dist/session/index.js.map +23 -0
- package/dist/session/land.d.ts +28 -0
- package/dist/session/land.d.ts.map +1 -0
- package/dist/session/link-land.d.ts +20 -0
- package/dist/session/link-land.d.ts.map +1 -0
- package/dist/session/link-token.d.ts +22 -0
- package/dist/session/link-token.d.ts.map +1 -0
- package/dist/session/link-types.d.ts +128 -0
- package/dist/session/link-types.d.ts.map +1 -0
- package/dist/session/links.d.ts +14 -0
- package/dist/session/links.d.ts.map +1 -0
- package/dist/session/model/index.d.ts +7 -0
- package/dist/session/model/index.d.ts.map +1 -0
- package/dist/session/model/index.js +25 -0
- package/dist/session/model/index.js.map +9 -0
- package/dist/session/model/paths.d.ts +24 -0
- package/dist/session/model/paths.d.ts.map +1 -0
- package/dist/session/model/safe-next.d.ts +15 -0
- package/dist/session/model/safe-next.d.ts.map +1 -0
- package/dist/session/model/second-factor.d.ts +16 -0
- package/dist/session/model/second-factor.d.ts.map +1 -0
- package/dist/session/model/site-path.d.ts +10 -0
- package/dist/session/model/site-path.d.ts.map +1 -0
- package/dist/session/model/step-up-answers.d.ts +40 -0
- package/dist/session/model/step-up-answers.d.ts.map +1 -0
- package/dist/session/model/step-up-props.d.ts +21 -0
- package/dist/session/model/step-up-props.d.ts.map +1 -0
- package/dist/session/model/step-up.d.ts +48 -0
- package/dist/session/model/step-up.d.ts.map +1 -0
- package/dist/session/second-factor.d.ts +22 -0
- package/dist/session/second-factor.d.ts.map +1 -0
- package/dist/session/serialize.d.ts +34 -0
- package/dist/session/serialize.d.ts.map +1 -0
- package/dist/session/sign-in.d.ts +18 -0
- package/dist/session/sign-in.d.ts.map +1 -0
- package/dist/session/step-up-codes.d.ts +9 -0
- package/dist/session/step-up-codes.d.ts.map +1 -0
- package/dist/session/step-up-types.d.ts +69 -0
- package/dist/session/step-up-types.d.ts.map +1 -0
- package/dist/session/step-up.d.ts +17 -0
- package/dist/session/step-up.d.ts.map +1 -0
- package/dist/session/step.d.ts +20 -0
- package/dist/session/step.d.ts.map +1 -0
- package/dist/session/viewer.d.ts +34 -0
- package/dist/session/viewer.d.ts.map +1 -0
- package/docs/README.md +1 -0
- package/docs/guide/errors.md +19 -0
- package/docs/guide/session.md +1002 -0
- package/docs/roadmap.md +27 -0
- package/docs/troubleshooting.md +390 -1
- package/package.json +15 -2
package/docs/roadmap.md
CHANGED
|
@@ -15,6 +15,9 @@ Nothing planned.
|
|
|
15
15
|
|
|
16
16
|
## Later
|
|
17
17
|
|
|
18
|
+
- **A signed device cookie for BFFs.** A cookie the app signs and the
|
|
19
|
+
browser keeps across sessions, so a sign-in can tell a known device from a
|
|
20
|
+
new one.
|
|
18
21
|
- **Hreflang alternates** for an app with one URL per language. Today the
|
|
19
22
|
language is negotiated on a single URL, so there is no alternate to link.
|
|
20
23
|
- **`service-worker.js` served revalidated with no setup.** Today the app
|
|
@@ -57,6 +60,30 @@ Nothing planned.
|
|
|
57
60
|
|
|
58
61
|
## Shipped
|
|
59
62
|
|
|
63
|
+
### 0.14.0
|
|
64
|
+
|
|
65
|
+
- **`@alxia/react-router/session` and `/session/model`: a BFF session for a
|
|
66
|
+
janus API.** An app that is the backend of its own pages and calls a GraphQL
|
|
67
|
+
API that signs users in with `@nxgt/janus` gets its session from one place:
|
|
68
|
+
`sessionCookies` (the session and the second-factor challenge, in an
|
|
69
|
+
HttpOnly cookie), `signInFlow` (where a sign-in lands, the second factor,
|
|
70
|
+
the viewer, sign-out), `stepUpSteps` (the code asked before a sensitive
|
|
71
|
+
action), `authLinks` and `TOKEN_PAGE_HEADERS` (the pages a mail's link
|
|
72
|
+
opens). `/session/model` imports nothing and is safe in a component: `safeNext`
|
|
73
|
+
and `signInPath` (redirects that stay on the site), `TOTP_CODE`, and the
|
|
74
|
+
step-up's `stepUpReducer` and `stepUpProps`. Every step that signs in, out, spends or mails refuses a cross-site post. `/errors` gains `attemptsLeftOf`.
|
|
75
|
+
See the [session guide](guide/session.md).
|
|
76
|
+
|
|
77
|
+
### 0.13.0
|
|
78
|
+
|
|
79
|
+
- **`reword` in `createApiErrors`.** An optional
|
|
80
|
+
`reword(text, error, { code, values, language })` words a whole family of
|
|
81
|
+
codes once, such as adding a suffix to every `*.errors.referenced` refusal.
|
|
82
|
+
It runs on the final text of an API error, whatever produced it (the
|
|
83
|
+
catalogue, the API's message or `byCode`), and does not apply to `refused`,
|
|
84
|
+
`unavailable` or the entries of `extensions.fields`. See the
|
|
85
|
+
[errors guide](guide/errors.md).
|
|
86
|
+
|
|
60
87
|
### 0.12.0
|
|
61
88
|
|
|
62
89
|
- **`@alxia/react-router/document` and `/entry`: the boot of a page.**
|
package/docs/troubleshooting.md
CHANGED
|
@@ -76,6 +76,21 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
76
76
|
- [`<api>: <operation> (request <id>) answered <status> without GraphQL`](#api-operation-request-id-answered-status-without-graphql)
|
|
77
77
|
- [`<api>: <operation> (request <id>) unreachable`, `timeout` or `invalid-response`](#api-operation-request-id-unreachable-timeout-or-invalid-response)
|
|
78
78
|
- [`<api>: <operation> (request <id>) aborted`](#api-operation-request-id-aborted)
|
|
79
|
+
- [`TypeError: sessionCookies(): the … name "…" is not a cookie name (an RFC 6265 token: …). Pass a name such as "my-app_session".`](#typeerror-sessioncookies-the--name--is-not-a-cookie-name-an-rfc-6265-token--pass-a-name-such-as-my-app_session)
|
|
80
|
+
- [`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)
|
|
81
|
+
- [`TypeError: sessionCookies(): challengeSeconds must be a positive integer; got ….`](#typeerror-sessioncookies-challengeseconds-must-be-a-positive-integer-got-)
|
|
82
|
+
- [`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-)
|
|
83
|
+
- [`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-)
|
|
84
|
+
- [`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-)
|
|
85
|
+
- [`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)
|
|
86
|
+
- [`TypeError: signInFlow(): the code "…" is both the wrong-code code (codes.invalidCode) and in codes.challengeOver. A wrong code that leaves tries cannot end the challenge.`](#typeerror-signinflow-the-code--is-both-the-wrong-code-code-codesinvalidcode-and-in-codeschallengeover-a-wrong-code-that-leaves-tries-cannot-end-the-challenge)
|
|
87
|
+
- [`TypeError: serializeCookie(): the name "…" is not a cookie name (an RFC 6265 token).`](#typeerror-serializecookie-the-name--is-not-a-cookie-name-an-rfc-6265-token)
|
|
88
|
+
- [`TypeError: serializeCookie(): the path "…" is not a cookie path: it must start with "/" and hold no ";" or control character.`](#typeerror-serializecookie-the-path--is-not-a-cookie-path-it-must-start-with--and-hold-no--or-control-character)
|
|
89
|
+
- [`TypeError: statePath(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got ….`](#typeerror-statepath-a-token-pages-path-must-be-a-path-of-this-site-with-no-query-or-fragment-starting-with-a-single--got-)
|
|
90
|
+
- [`TypeError: linkArgs(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got ….`](#typeerror-linkargs-a-token-pages-path-must-be-a-path-of-this-site-with-no-query-or-fragment-starting-with-a-single--got-)
|
|
91
|
+
- [`TypeError: spendToken(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got ….`](#typeerror-spendtoken-a-token-pages-path-must-be-a-path-of-this-site-with-no-query-or-fragment-starting-with-a-single--got-)
|
|
92
|
+
- [`TypeError: authLinks(): resetDone must be a path of this site, starting with a single "/"; got ….`](#typeerror-authlinks-resetdone-must-be-a-path-of-this-site-starting-with-a-single--got-)
|
|
93
|
+
- [`Type '…' is not assignable to type 'undefined'` passing a context to a step](#type--is-not-assignable-to-type-undefined-passing-a-context-to-a-step)
|
|
79
94
|
|
|
80
95
|
**Types**
|
|
81
96
|
|
|
@@ -118,6 +133,10 @@ a loader, a message React Router or the browser prints, or an error from
|
|
|
118
133
|
- [An action shows the error page](#an-action-shows-the-error-page)
|
|
119
134
|
- [Behind a TLS proxy every action answers 403](#behind-a-tls-proxy-every-action-answers-403)
|
|
120
135
|
- [Right after start, refusals read in the API's words](#right-after-start-refusals-read-in-the-apis-words)
|
|
136
|
+
- [`next` is always `/`](#next-is-always-)
|
|
137
|
+
- [Sign-in loops on `http://localhost` in Safari](#sign-in-loops-on-httplocalhost-in-safari)
|
|
138
|
+
- [A `__Host-` cookie is dropped](#a-__host--cookie-is-dropped)
|
|
139
|
+
- [A token page's token shows in a Referer or a search result](#a-token-pages-token-shows-in-a-referer-or-a-search-result)
|
|
121
140
|
|
|
122
141
|
## Thrown or printed
|
|
123
142
|
|
|
@@ -1320,7 +1339,11 @@ shows.
|
|
|
1320
1339
|
### `Cross-site request refused`
|
|
1321
1340
|
|
|
1322
1341
|
**When:** an action reads its body with `readJson` or `readInput` (from
|
|
1323
|
-
`jsonActions()`),
|
|
1342
|
+
`jsonActions()`), or runs one of `/session`'s steps (`signInStep`,
|
|
1343
|
+
`readCodeInput`, `linkArgs`, `spendToken`, `landByLink`, `afterReset`,
|
|
1344
|
+
`requestLink`, the step-up's `request` and `confirm`, `landReplacing`,
|
|
1345
|
+
`revokeSession`, `signIn.guard`), and the page's post answers 403 with this
|
|
1346
|
+
text. The session steps refuse before they read the body.
|
|
1324
1347
|
|
|
1325
1348
|
**Why:** the request carried an `Origin` other than the app's own, or a
|
|
1326
1349
|
`Sec-Fetch-Site` that is neither `same-origin` nor `none`. That is what a
|
|
@@ -1576,6 +1599,198 @@ sees the 503.
|
|
|
1576
1599
|
**Fix:** none. To keep it out of the logs, test `request.signal.aborted` in
|
|
1577
1600
|
your own `catch` before calling `apiCallFailure`.
|
|
1578
1601
|
|
|
1602
|
+
### `TypeError: sessionCookies(): the … name "…" is not a cookie name (an RFC 6265 token: …). Pass a name such as "my-app_session".`
|
|
1603
|
+
|
|
1604
|
+
```
|
|
1605
|
+
TypeError: sessionCookies(): the session name "Bad Name" is not a cookie name (an RFC 6265 token: letters, digits and ! # $ % & ' * + - . ^ _ ` | ~). Pass a name such as "my-app_session".
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
**When:** the server starts, or the module that calls `sessionCookies` is imported.
|
|
1609
|
+
|
|
1610
|
+
**Why:** `session` or `challenge` is empty or holds a character a cookie name cannot, such as a space, a semicolon, a comma or `=`. The message says which of the two (`session` or `challenge`) it is. A name from an environment variable that was not set is the usual cause.
|
|
1611
|
+
|
|
1612
|
+
**Fix:** pass a plain name, such as the app's name and the role:
|
|
1613
|
+
|
|
1614
|
+
```ts
|
|
1615
|
+
import { sessionCookies } from '@alxia/react-router/session';
|
|
1616
|
+
|
|
1617
|
+
export const cookies = sessionCookies({
|
|
1618
|
+
session: 'my-app_session',
|
|
1619
|
+
challenge: 'my-app_challenge',
|
|
1620
|
+
secure: !import.meta.env.DEV,
|
|
1621
|
+
});
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
### `TypeError: sessionCookies(): the … name "…" already carries a prefix. Pass the base name: the package adds __Host- (session) or __Secure- (challenge) when secure is true.`
|
|
1625
|
+
|
|
1626
|
+
```
|
|
1627
|
+
TypeError: sessionCookies(): the session name "__Host-my-app_session" already carries a prefix. Pass the base name: the package adds __Host- (session) or __Secure- (challenge) when secure is true.
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
**When:** the server starts, or the module that calls `sessionCookies` is imported.
|
|
1631
|
+
|
|
1632
|
+
**Why:** the name starts with `__Host-` or `__Secure-`. The package adds the prefix itself when `secure` is `true` and drops it when `secure` is `false` (development over plain HTTP, where a prefixed cookie would be refused); a name that carries it would be doubled or stuck in development.
|
|
1633
|
+
|
|
1634
|
+
**Fix:** pass the base name: `'my-app_session'`, not `'__Host-my-app_session'`. The name the browser sees is in `cookies.names`. If the app reads or clears the cookie elsewhere by its full name, read it from `cookies.names.session`.
|
|
1635
|
+
|
|
1636
|
+
### `TypeError: sessionCookies(): challengeSeconds must be a positive integer; got ….`
|
|
1637
|
+
|
|
1638
|
+
```
|
|
1639
|
+
TypeError: sessionCookies(): challengeSeconds must be a positive integer; got 0.
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
**When:** the server starts, or the module that calls `sessionCookies` is imported.
|
|
1643
|
+
|
|
1644
|
+
**Why:** `challengeSeconds`, the most the second-factor challenge's cookie lives, is `0`, negative, fractional or `NaN` (a lifetime read from an environment variable that is empty gives `NaN`). With `0` the browser would drop the cookie at once and the code page would never find its challenge.
|
|
1645
|
+
|
|
1646
|
+
**Fix:** pass whole seconds above 0, or leave it out for the default of 300, the lifetime of an `@nxgt/janus` challenge. A longer value gains nothing: the cookie also ends with the challenge itself.
|
|
1647
|
+
|
|
1648
|
+
### `TypeError: sessionCookies(): challengePath must start with "/" and hold no ";" or control character; got ….`
|
|
1649
|
+
|
|
1650
|
+
```
|
|
1651
|
+
TypeError: sessionCookies(): challengePath must start with "/" and hold no ";" or control character; got "sign-in".
|
|
1652
|
+
```
|
|
1653
|
+
|
|
1654
|
+
**When:** the server starts, or the module that calls `sessionCookies` is imported.
|
|
1655
|
+
|
|
1656
|
+
**Why:** `challengePath`, where the browser sends the challenge cookie, is a relative path such as `'sign-in'` (a cookie's `Path` must be absolute, or the browser ignores it), or it holds a `;` or a control character, which would open another attribute of the `Set-Cookie` header.
|
|
1657
|
+
|
|
1658
|
+
**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.
|
|
1659
|
+
|
|
1660
|
+
### `TypeError: signInFlow(): paths.… must be a path of this site with no query or fragment, starting with a single "/"; got ….`
|
|
1661
|
+
|
|
1662
|
+
```
|
|
1663
|
+
TypeError: signInFlow(): paths.secondFactor must be a path of this site with no query or fragment, starting with a single "/"; got "sign-in/code".
|
|
1664
|
+
```
|
|
1665
|
+
|
|
1666
|
+
**When:** the server starts, or the module that calls `signInFlow` is imported.
|
|
1667
|
+
|
|
1668
|
+
**Why:** `paths.signIn` or `paths.secondFactor` (the message names which) is relative, empty, starts with `//` or `/\`, which a redirect reads as another site, or holds a query (`?`) or a fragment (`#`), which the flow appends to (`?next=`, `?signed-out=1`). The flow redirects to these paths.
|
|
1669
|
+
|
|
1670
|
+
**Fix:** pass the page's path with its leading slash, or leave `paths` out for `/sign-in` and `/sign-in/second-factor`:
|
|
1671
|
+
|
|
1672
|
+
```ts
|
|
1673
|
+
import { signInFlow } from '@alxia/react-router/session';
|
|
1674
|
+
|
|
1675
|
+
export const signIn = signInFlow({
|
|
1676
|
+
// …
|
|
1677
|
+
paths: { secondFactor: '/sign-in/code' },
|
|
1678
|
+
});
|
|
1679
|
+
```
|
|
1680
|
+
|
|
1681
|
+
### `TypeError: signInPath(): options.path must be a path of this site with no query or fragment, starting with a single "/"; got ….`
|
|
1682
|
+
|
|
1683
|
+
```
|
|
1684
|
+
TypeError: signInPath(): options.path must be a path of this site with no query or fragment, starting with a single "/"; got "//evil.test".
|
|
1685
|
+
```
|
|
1686
|
+
|
|
1687
|
+
**When:** the app calls `signInPath(next, { path })`, in a loader or at startup.
|
|
1688
|
+
|
|
1689
|
+
**Why:** `path` replaces `/sign-in` and the function adds `?next=…` to it, so it must be a path of this site: relative, empty, starting with `//` or `/\` (which a redirect reads as another site), or holding a query (`?`) or a fragment (`#`) is refused. The message prints the path only up to its `?` or `#`.
|
|
1690
|
+
|
|
1691
|
+
**Fix:** pass the page's path with its leading slash, and put parameters in `query`:
|
|
1692
|
+
|
|
1693
|
+
```ts
|
|
1694
|
+
import { signInPath } from '@alxia/react-router/session/model';
|
|
1695
|
+
|
|
1696
|
+
signInPath('/orders', { path: '/login', query: { expired: '1' } });
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
### `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.`
|
|
1700
|
+
|
|
1701
|
+
```
|
|
1702
|
+
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.
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
**When:** the server starts, or the module that calls `signInFlow` is imported.
|
|
1706
|
+
|
|
1707
|
+
**Why:** every step of the flow runs the same guard first (a native form post goes back to its page, a JSON post from another site is a 403). The guard's rule lives on the instance `jsonActions()` returns, under a registered symbol. A hand-made object with `readJson`, `requireJsonPost` and `readInput` has none, so the steps could not refuse another site.
|
|
1708
|
+
|
|
1709
|
+
**Fix:** pass the app's `jsonActions({ origin })` instance, or a spread copy of it, which keeps the rule:
|
|
1710
|
+
|
|
1711
|
+
```ts
|
|
1712
|
+
import { jsonActions } from '@alxia/react-router/errors';
|
|
1713
|
+
|
|
1714
|
+
export const json = jsonActions({ origin: () => process.env.APP_ORIGIN });
|
|
1715
|
+
```
|
|
1716
|
+
|
|
1717
|
+
### `TypeError: signInFlow(): the code "…" is both the wrong-code code (codes.invalidCode) and in codes.challengeOver. A wrong code that leaves tries cannot end the challenge.`
|
|
1718
|
+
|
|
1719
|
+
```
|
|
1720
|
+
TypeError: signInFlow(): the code "auth.errors.invalid-code" is both the wrong-code code (codes.invalidCode) and in codes.challengeOver. A wrong code that leaves tries cannot end the challenge.
|
|
1721
|
+
```
|
|
1722
|
+
|
|
1723
|
+
**When:** the server starts, or the module that calls `signInFlow` is imported.
|
|
1724
|
+
|
|
1725
|
+
**Why:** `codes.invalidCode`, the refusal of a wrong code, also appears in `codes.challengeOver`, the codes that end the challenge. The flow could not tell whether a refused code leaves tries or sends the user back to the password.
|
|
1726
|
+
|
|
1727
|
+
**Fix:** list in `challengeOver` the codes of a challenge that is gone (unknown, spent, expired) and keep the wrong-code code out of it:
|
|
1728
|
+
|
|
1729
|
+
```ts
|
|
1730
|
+
signInFlow({
|
|
1731
|
+
// …
|
|
1732
|
+
codes: {
|
|
1733
|
+
invalidCode: 'auth.errors.invalid-code',
|
|
1734
|
+
challengeOver: ['auth.errors.invalid-token'],
|
|
1735
|
+
},
|
|
1736
|
+
});
|
|
1737
|
+
```
|
|
1738
|
+
|
|
1739
|
+
### `TypeError: serializeCookie(): the name "…" is not a cookie name (an RFC 6265 token).`
|
|
1740
|
+
|
|
1741
|
+
```
|
|
1742
|
+
TypeError: serializeCookie(): the name "a b" is not a cookie name (an RFC 6265 token).
|
|
1743
|
+
```
|
|
1744
|
+
|
|
1745
|
+
**When:** the app calls `serializeCookie` for a cookie of its own (a language, a preference).
|
|
1746
|
+
|
|
1747
|
+
**Why:** the name is empty or holds a space, `;`, `=`, a control character or another character a cookie name cannot. Written as it is, it would break the `Set-Cookie` header or open another attribute, so the package refuses it. `sessionCookies` checks its names the same way, with its own messages.
|
|
1748
|
+
|
|
1749
|
+
**Fix:** use a plain name, letters, digits and `- _ .`:
|
|
1750
|
+
|
|
1751
|
+
```ts
|
|
1752
|
+
import { serializeCookie } from '@alxia/react-router/session';
|
|
1753
|
+
|
|
1754
|
+
const header = serializeCookie('my-app_language', 'fr', {
|
|
1755
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
1756
|
+
secure: !import.meta.env.DEV,
|
|
1757
|
+
});
|
|
1758
|
+
```
|
|
1759
|
+
|
|
1760
|
+
### `TypeError: serializeCookie(): the path "…" is not a cookie path: it must start with "/" and hold no ";" or control character.`
|
|
1761
|
+
|
|
1762
|
+
```
|
|
1763
|
+
TypeError: serializeCookie(): the path "sign-in" is not a cookie path: it must start with "/" and hold no ";" or control character.
|
|
1764
|
+
```
|
|
1765
|
+
|
|
1766
|
+
**When:** the app calls `serializeCookie` with a `path`, or `sessionCookies` builds its challenge cookie (that one reports a `challengePath` error instead).
|
|
1767
|
+
|
|
1768
|
+
**Why:** the `path` is relative, or it holds a `;` or a control character such as a line break, which would end the attribute and let the rest of the string set another one. A path built from a request's URL is the usual source.
|
|
1769
|
+
|
|
1770
|
+
**Fix:** pass a path of your own with its leading slash, and never one taken from a request. Leave `path` out for the default, `/`.
|
|
1771
|
+
|
|
1772
|
+
### `TypeError: stepUpSteps(): the code "…" is both the wrong-code code (codes.invalidCode) and in codes.challengeOver. A wrong code that leaves tries cannot end the challenge.`
|
|
1773
|
+
|
|
1774
|
+
```
|
|
1775
|
+
TypeError: stepUpSteps(): the code "auth.errors.invalid-code" is both the wrong-code code (codes.invalidCode) and in codes.challengeOver. A wrong code that leaves tries cannot end the challenge.
|
|
1776
|
+
```
|
|
1777
|
+
|
|
1778
|
+
**When:** the server starts, or the module that calls `stepUpSteps` is imported.
|
|
1779
|
+
|
|
1780
|
+
**Why:** `codes.invalidCode`, the refusal of a wrong code, also appears in `codes.challengeOver`, the codes that end the challenge. A wrong code leaves tries (`attemptsLeft`) and the step could not tell it from a challenge that is gone. Passing `challengeOver` replaces the default list, so a list copied from a sign-in's codes is the usual cause.
|
|
1781
|
+
|
|
1782
|
+
**Fix:** list in `challengeOver` the codes of a challenge that is gone and keep the wrong-code code out of it:
|
|
1783
|
+
|
|
1784
|
+
```ts
|
|
1785
|
+
stepUpSteps({
|
|
1786
|
+
// …
|
|
1787
|
+
codes: {
|
|
1788
|
+
invalidCode: 'auth.errors.invalid-code',
|
|
1789
|
+
challengeOver: ['auth.errors.invalid-token', 'auth.errors.second-factor-state'],
|
|
1790
|
+
},
|
|
1791
|
+
});
|
|
1792
|
+
```
|
|
1793
|
+
|
|
1579
1794
|
## Types
|
|
1580
1795
|
|
|
1581
1796
|
### `Property 'alxia' does not exist on type 'Readonly<RouterContextProvider>'`
|
|
@@ -2325,3 +2540,177 @@ catalogue.warm();
|
|
|
2325
2540
|
|
|
2326
2541
|
Raise `firstReadWaitMs` only if the page should wait longer for the first
|
|
2327
2542
|
words; it holds every cold request that long when the API hangs.
|
|
2543
|
+
|
|
2544
|
+
### `next` is always `/`
|
|
2545
|
+
|
|
2546
|
+
After signing in, the user lands on the home page, whatever page they came
|
|
2547
|
+
from, and `signInPath(next)` returns `/sign-in` with no `next`.
|
|
2548
|
+
|
|
2549
|
+
**Why:** `safeNext` refuses the value and falls back to `/`. It takes a path
|
|
2550
|
+
of this site only, so these are refused: a value that does not start with
|
|
2551
|
+
`/`, one that starts with `//` or holds a `\`, a space, a tab or a newline,
|
|
2552
|
+
a full URL (`https://…`), a value that resolves to another site once its
|
|
2553
|
+
`..` segments are applied, and one over 2000 characters. The usual cause is a
|
|
2554
|
+
`next` built from the full URL (`request.url`, `location.href`), or one
|
|
2555
|
+
that was percent-decoded twice.
|
|
2556
|
+
|
|
2557
|
+
**Fix:** pass the path and search alone:
|
|
2558
|
+
|
|
2559
|
+
```ts
|
|
2560
|
+
import { signInPath } from '@alxia/react-router/session/model';
|
|
2561
|
+
|
|
2562
|
+
const url = new URL(request.url);
|
|
2563
|
+
throw redirect(signInPath(`${url.pathname}${url.search}`));
|
|
2564
|
+
```
|
|
2565
|
+
|
|
2566
|
+
[Redirects that stay on the site](guide/session.md#redirects-that-stay-on-the-site)
|
|
2567
|
+
lists what is refused.
|
|
2568
|
+
|
|
2569
|
+
### Sign-in loops on `http://localhost` in Safari
|
|
2570
|
+
|
|
2571
|
+
Signing in works in Chromium and Firefox. In Safari the app signs in, redirects, and lands back on the sign-in page, with no error.
|
|
2572
|
+
|
|
2573
|
+
**Why:** Safari (WebKit) drops a `Secure` cookie set over `http://localhost`, where the other browsers keep it. The session cookie never arrives, so the next request has no session.
|
|
2574
|
+
|
|
2575
|
+
**Fix:** pass `secure: false` in development. `secure: !import.meta.env.DEV` does it, and also drops the `__Host-` and `__Secure-` prefixes, which require `Secure`:
|
|
2576
|
+
|
|
2577
|
+
```ts
|
|
2578
|
+
import { sessionCookies } from '@alxia/react-router/session';
|
|
2579
|
+
|
|
2580
|
+
export const cookies = sessionCookies({
|
|
2581
|
+
session: 'my-app_session',
|
|
2582
|
+
challenge: 'my-app_challenge',
|
|
2583
|
+
secure: !import.meta.env.DEV,
|
|
2584
|
+
});
|
|
2585
|
+
```
|
|
2586
|
+
|
|
2587
|
+
Never write `secure: false` for a deployed app: the token would travel over HTTP.
|
|
2588
|
+
|
|
2589
|
+
### A `__Host-` cookie is dropped
|
|
2590
|
+
|
|
2591
|
+
The browser's cookie panel shows no `__Host-` session cookie after a sign-in, or the response's `Set-Cookie` is ignored.
|
|
2592
|
+
|
|
2593
|
+
**Why:** a browser accepts a `__Host-` cookie only when it is `Secure`, has `Path=/` and no `Domain`, and the page is served over HTTPS (or `localhost` in Chromium and Firefox, never in Safari). The cookie the package writes always has `Path=/` and no `Domain`, so one of the others is wrong:
|
|
2594
|
+
|
|
2595
|
+
- the app is served over plain HTTP, such as a Docker image or a preview opened by its IP address, while `secure` is `true`;
|
|
2596
|
+
- a proxy in front of the app rewrites or strips `Set-Cookie`, or adds a `Domain` attribute;
|
|
2597
|
+
- another `Set-Cookie` header of the same name, set by the app, overwrites it.
|
|
2598
|
+
|
|
2599
|
+
**Fix:** serve the app over HTTPS, and keep `secure` `true` only there. Read `cookies.names.session` for the exact name, check the response header in the network panel, and make the proxy pass `Set-Cookie` as it is. In development over HTTP, `secure: !import.meta.env.DEV` sets an unprefixed cookie instead.
|
|
2600
|
+
|
|
2601
|
+
### A wrong step-up code signs the user out
|
|
2602
|
+
|
|
2603
|
+
The user types a wrong code in the step-up dialog and lands on the sign-in page, instead of seeing "wrong code".
|
|
2604
|
+
|
|
2605
|
+
**Why:** `@nxgt/janus-graphql` answers `CODE_INVALID` with HTTP 401. A caller that redirects to the sign-in page on any 401 takes the wrong code for a dead session, before `stepUp.confirm` sees the error.
|
|
2606
|
+
|
|
2607
|
+
**Fix:** in the app's caller, redirect to sign-in only for a session 401 (`UNAUTHENTICATED`), never for a step-up's `CODE_INVALID`. [Step-up](guide/session.md#call-the-step-up-with-a-caller-that-does-not-sign-out-on-a-401) has the full section.
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
### `TypeError: statePath(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got ….`
|
|
2611
|
+
|
|
2612
|
+
```
|
|
2613
|
+
TypeError: statePath(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got "/auth/magic-link?…".
|
|
2614
|
+
```
|
|
2615
|
+
|
|
2616
|
+
`statePath` throws it for its own `path`, and so do `ended()` and the `other.path` of `landByLink()`, which build their redirect with `statePath`: their error says `statePath(): …`, not their own name. The message names the call that was made.
|
|
2617
|
+
|
|
2618
|
+
**Why:** the page's `path` is the page without its token: the helpers add `?state=…` to it, and a redirect must stay on the site. A path with a query (`/auth/magic-link?token=…`), a fragment, a host or a second leading `/` is refused, which also keeps the token out of the redirect. The message prints the path only up to its `?` or `#`, so the token never reaches a log.
|
|
2619
|
+
|
|
2620
|
+
**Fix:** pass the route's own path, as a constant:
|
|
2621
|
+
|
|
2622
|
+
```ts
|
|
2623
|
+
const PATH = '/auth/magic-link';
|
|
2624
|
+
await links.spendToken(run, { request, language, path: PATH, states });
|
|
2625
|
+
```
|
|
2626
|
+
|
|
2627
|
+
### `TypeError: linkArgs(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got ….`
|
|
2628
|
+
|
|
2629
|
+
```
|
|
2630
|
+
TypeError: linkArgs(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got "/auth/magic-link?…".
|
|
2631
|
+
```
|
|
2632
|
+
|
|
2633
|
+
`linkArgs` throws it for the `path` it is given, before it reads the body. The message names the call that was made.
|
|
2634
|
+
|
|
2635
|
+
**Why:** the page's `path` is the page without its token: the helpers add `?state=…` to it, and a redirect must stay on the site. A path with a query (`/auth/magic-link?token=…`), a fragment, a host or a second leading `/` is refused, which also keeps the token out of the redirect. The message prints the path only up to its `?` or `#`, so the token never reaches a log.
|
|
2636
|
+
|
|
2637
|
+
**Fix:** pass the route's own path, as a constant:
|
|
2638
|
+
|
|
2639
|
+
```ts
|
|
2640
|
+
const PATH = '/auth/magic-link';
|
|
2641
|
+
await links.spendToken(run, { request, language, path: PATH, states });
|
|
2642
|
+
```
|
|
2643
|
+
|
|
2644
|
+
### `TypeError: spendToken(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got ….`
|
|
2645
|
+
|
|
2646
|
+
```
|
|
2647
|
+
TypeError: spendToken(): a token page's path must be a path of this site with no query or fragment, starting with a single "/"; got "/auth/magic-link?…".
|
|
2648
|
+
```
|
|
2649
|
+
|
|
2650
|
+
`spendToken` throws it for the `path` of its options, before `run` is called. The message names the call that was made.
|
|
2651
|
+
|
|
2652
|
+
**Why:** the page's `path` is the page without its token: the helpers add `?state=…` to it, and a redirect must stay on the site. A path with a query (`/auth/magic-link?token=…`), a fragment, a host or a second leading `/` is refused, which also keeps the token out of the redirect. The message prints the path only up to its `?` or `#`, so the token never reaches a log.
|
|
2653
|
+
|
|
2654
|
+
**Fix:** pass the route's own path, as a constant:
|
|
2655
|
+
|
|
2656
|
+
```ts
|
|
2657
|
+
const PATH = '/auth/magic-link';
|
|
2658
|
+
await links.spendToken(run, { request, language, path: PATH, states });
|
|
2659
|
+
```
|
|
2660
|
+
|
|
2661
|
+
### `TypeError: authLinks(): resetDone must be a path of this site, starting with a single "/"; got ….`
|
|
2662
|
+
|
|
2663
|
+
`authLinks` throws when the app starts.
|
|
2664
|
+
|
|
2665
|
+
**Why:** `resetDone` is where a password reset leads, so it goes through the
|
|
2666
|
+
same check as a sign-in's `next`: a URL, `//host` or a path without its
|
|
2667
|
+
leading `/` is refused.
|
|
2668
|
+
|
|
2669
|
+
**Fix:** pass a path of the app, with a query if it needs one:
|
|
2670
|
+
|
|
2671
|
+
```ts
|
|
2672
|
+
authLinks({ signIn, resetDone: '/sign-in?password-reset=1' });
|
|
2673
|
+
```
|
|
2674
|
+
|
|
2675
|
+
### `Type '…' is not assignable to type 'undefined'` passing a context to a step
|
|
2676
|
+
|
|
2677
|
+
`tsc` refuses `signIn.signOut(request, { context: alxiaOf(context) })`, or the
|
|
2678
|
+
`context` of `revokeSession`, `landReplacing`, `signInStep`, `landByLink` or
|
|
2679
|
+
`afterReset`, with:
|
|
2680
|
+
|
|
2681
|
+
```text
|
|
2682
|
+
Type 'AlxiaContextOf<…>' is not assignable to type 'undefined'.
|
|
2683
|
+
```
|
|
2684
|
+
|
|
2685
|
+
**Why.** The flow was built with `signInFlow<Lang>(…)`. Giving one type
|
|
2686
|
+
argument stops TypeScript from inferring the second, `C`, the caller context,
|
|
2687
|
+
which then keeps its default, `undefined`. Every step's `context` is then
|
|
2688
|
+
typed `undefined`.
|
|
2689
|
+
|
|
2690
|
+
**Fix.** Write both type arguments, or none, and let `L` and `C` be inferred
|
|
2691
|
+
from `errors` and the annotated third parameter of `revoke`:
|
|
2692
|
+
|
|
2693
|
+
```ts
|
|
2694
|
+
type Caller = AlxiaContextOf;
|
|
2695
|
+
|
|
2696
|
+
export const signIn = signInFlow<Lang, Caller>({
|
|
2697
|
+
// …
|
|
2698
|
+
revoke: (token, request, caller) => revokeAtApi(token, caller),
|
|
2699
|
+
});
|
|
2700
|
+
```
|
|
2701
|
+
|
|
2702
|
+
### A token page's token shows in a Referer or a search result
|
|
2703
|
+
|
|
2704
|
+
The token page's URL appears in another site's logs, or a search engine lists
|
|
2705
|
+
it.
|
|
2706
|
+
|
|
2707
|
+
**Why:** the page answered without `TOKEN_PAGE_HEADERS`, or only for its
|
|
2708
|
+
document and not its `.data` response.
|
|
2709
|
+
|
|
2710
|
+
**Fix:** set the headers on the route:
|
|
2711
|
+
|
|
2712
|
+
```ts
|
|
2713
|
+
import { TOKEN_PAGE_HEADERS } from '@alxia/react-router/session';
|
|
2714
|
+
|
|
2715
|
+
export const headers = () => TOKEN_PAGE_HEADERS;
|
|
2716
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
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 page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
|
|
3
|
+
"version": "0.15.0",
|
|
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`) and what it shares with the page (`/session/model`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
"import": "./dist/errors/index.js",
|
|
58
58
|
"default": "./dist/errors/index.js"
|
|
59
59
|
},
|
|
60
|
+
"./session": {
|
|
61
|
+
"types": "./dist/session/index.d.ts",
|
|
62
|
+
"import": "./dist/session/index.js",
|
|
63
|
+
"default": "./dist/session/index.js"
|
|
64
|
+
},
|
|
60
65
|
"./pwa/build": {
|
|
61
66
|
"types": "./dist/pwa/build/index.d.ts",
|
|
62
67
|
"import": "./dist/pwa/build/index.js",
|
|
@@ -67,6 +72,11 @@
|
|
|
67
72
|
"import": "./dist/pwa/worker/index.js",
|
|
68
73
|
"default": "./dist/pwa/worker/index.js"
|
|
69
74
|
},
|
|
75
|
+
"./session/model": {
|
|
76
|
+
"types": "./dist/session/model/index.d.ts",
|
|
77
|
+
"import": "./dist/session/model/index.js",
|
|
78
|
+
"default": "./dist/session/model/index.js"
|
|
79
|
+
},
|
|
70
80
|
"./package.json": "./package.json"
|
|
71
81
|
},
|
|
72
82
|
"repository": {
|
|
@@ -91,6 +101,8 @@
|
|
|
91
101
|
"src/pwa/react/index.ts",
|
|
92
102
|
"src/pwa/server/index.ts",
|
|
93
103
|
"src/errors/index.ts",
|
|
104
|
+
"src/session/model/index.ts",
|
|
105
|
+
"src/session/index.ts",
|
|
94
106
|
"src/document/index.ts",
|
|
95
107
|
"src/entry/index.ts",
|
|
96
108
|
"src/pwa/worker/index.ts",
|
|
@@ -103,6 +115,7 @@
|
|
|
103
115
|
"@alxia/core": "^0.15.1",
|
|
104
116
|
"@alxia/openapi": "^0.6.10",
|
|
105
117
|
"@nxgt/graphql-client": "^0.4.0",
|
|
118
|
+
"@nxgt/janus": "^0.29.0",
|
|
106
119
|
"@react-router/dev": "^8.5.0",
|
|
107
120
|
"@types/bun": "^1.4.2",
|
|
108
121
|
"@types/react": "^19.3.0",
|