@alxia/react-router 0.12.1 → 0.14.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.
Files changed (80) hide show
  1. package/README.md +79 -1
  2. package/dist/chunks/index-0tyw54ad.js +160 -0
  3. package/dist/chunks/index-0tyw54ad.js.map +15 -0
  4. package/dist/chunks/index-c3k47yw7.js +124 -0
  5. package/dist/chunks/index-c3k47yw7.js.map +14 -0
  6. package/dist/errors/api-error.d.ts +18 -0
  7. package/dist/errors/api-error.d.ts.map +1 -1
  8. package/dist/errors/api-errors.d.ts +16 -0
  9. package/dist/errors/api-errors.d.ts.map +1 -1
  10. package/dist/errors/describe.d.ts +1 -1
  11. package/dist/errors/describe.d.ts.map +1 -1
  12. package/dist/errors/index.d.ts +1 -1
  13. package/dist/errors/index.d.ts.map +1 -1
  14. package/dist/errors/index.js +51 -113
  15. package/dist/errors/index.js.map +6 -10
  16. package/dist/errors/json-actions.d.ts.map +1 -1
  17. package/dist/errors/same-site.d.ts +25 -0
  18. package/dist/errors/same-site.d.ts.map +1 -0
  19. package/dist/session/codes.d.ts +7 -0
  20. package/dist/session/codes.d.ts.map +1 -0
  21. package/dist/session/cookies.d.ts +60 -0
  22. package/dist/session/cookies.d.ts.map +1 -0
  23. package/dist/session/flow-types.d.ts +178 -0
  24. package/dist/session/flow-types.d.ts.map +1 -0
  25. package/dist/session/guard.d.ts +10 -0
  26. package/dist/session/guard.d.ts.map +1 -0
  27. package/dist/session/index.d.ts +18 -0
  28. package/dist/session/index.d.ts.map +1 -0
  29. package/dist/session/index.js +637 -0
  30. package/dist/session/index.js.map +23 -0
  31. package/dist/session/land.d.ts +26 -0
  32. package/dist/session/land.d.ts.map +1 -0
  33. package/dist/session/link-land.d.ts +18 -0
  34. package/dist/session/link-land.d.ts.map +1 -0
  35. package/dist/session/link-token.d.ts +22 -0
  36. package/dist/session/link-token.d.ts.map +1 -0
  37. package/dist/session/link-types.d.ts +124 -0
  38. package/dist/session/link-types.d.ts.map +1 -0
  39. package/dist/session/links.d.ts +14 -0
  40. package/dist/session/links.d.ts.map +1 -0
  41. package/dist/session/model/index.d.ts +7 -0
  42. package/dist/session/model/index.d.ts.map +1 -0
  43. package/dist/session/model/index.js +25 -0
  44. package/dist/session/model/index.js.map +9 -0
  45. package/dist/session/model/paths.d.ts +24 -0
  46. package/dist/session/model/paths.d.ts.map +1 -0
  47. package/dist/session/model/safe-next.d.ts +15 -0
  48. package/dist/session/model/safe-next.d.ts.map +1 -0
  49. package/dist/session/model/second-factor.d.ts +16 -0
  50. package/dist/session/model/second-factor.d.ts.map +1 -0
  51. package/dist/session/model/site-path.d.ts +10 -0
  52. package/dist/session/model/site-path.d.ts.map +1 -0
  53. package/dist/session/model/step-up-answers.d.ts +40 -0
  54. package/dist/session/model/step-up-answers.d.ts.map +1 -0
  55. package/dist/session/model/step-up-props.d.ts +21 -0
  56. package/dist/session/model/step-up-props.d.ts.map +1 -0
  57. package/dist/session/model/step-up.d.ts +48 -0
  58. package/dist/session/model/step-up.d.ts.map +1 -0
  59. package/dist/session/second-factor.d.ts +22 -0
  60. package/dist/session/second-factor.d.ts.map +1 -0
  61. package/dist/session/serialize.d.ts +34 -0
  62. package/dist/session/serialize.d.ts.map +1 -0
  63. package/dist/session/sign-in.d.ts +17 -0
  64. package/dist/session/sign-in.d.ts.map +1 -0
  65. package/dist/session/step-up-codes.d.ts +9 -0
  66. package/dist/session/step-up-codes.d.ts.map +1 -0
  67. package/dist/session/step-up-types.d.ts +69 -0
  68. package/dist/session/step-up-types.d.ts.map +1 -0
  69. package/dist/session/step-up.d.ts +17 -0
  70. package/dist/session/step-up.d.ts.map +1 -0
  71. package/dist/session/step.d.ts +20 -0
  72. package/dist/session/step.d.ts.map +1 -0
  73. package/dist/session/viewer.d.ts +33 -0
  74. package/dist/session/viewer.d.ts.map +1 -0
  75. package/docs/README.md +1 -0
  76. package/docs/guide/errors.md +45 -0
  77. package/docs/guide/session.md +955 -0
  78. package/docs/roadmap.md +27 -0
  79. package/docs/troubleshooting.md +362 -1
  80. package/package.json +15 -2
@@ -0,0 +1,955 @@
1
+ # A BFF session
2
+
3
+ This page is for a React Router app that is the backend of its own pages (a
4
+ BFF) in front of a GraphQL API that signs users in with `@nxgt/janus`, and
5
+ holds the API's session token in an HttpOnly cookie of its own. The browser
6
+ never sees the token; the app sends it to the API as a bearer, on the user's
7
+ behalf.
8
+
9
+ It covers two subpaths:
10
+
11
+ - `@alxia/react-router/session`, the server side: the cookies, the sign-in
12
+ flow, the step-up and the pages a mail's link opens. It imports
13
+ `react-router` and `/errors`' files, nothing else, knows no API client and
14
+ adds no peer.
15
+ - `@alxia/react-router/session/model`, the part that imports nothing and is
16
+ safe in a component as well as in a loader: redirects that stay on the
17
+ site, the sign-in pages' paths, and the step-up's state.
18
+
19
+ The flow speaks `@nxgt/janus`'s words (`signedIn`, `secondFactor`,
20
+ `CODE_INVALID`, `via: 'email' | 'secondFactor'`) without importing it; the app
21
+ maps its API's enums once. An app that runs `@nxgt/janus` itself, with its own
22
+ database, wants [`@alxia/janus`](https://www.npmjs.com/package/@alxia/janus)
23
+ instead.
24
+
25
+ - [A complete sign-in](#a-complete-sign-in)
26
+ - [Redirects that stay on the site](#redirects-that-stay-on-the-site)
27
+ - [The sign-in pages' paths](#the-sign-in-pages-paths)
28
+ - [Cookies](#cookies)
29
+ - [Signing in](#signing-in)
30
+ - [The second factor](#the-second-factor)
31
+ - [Signing out](#signing-out)
32
+ - [Step-up](#step-up)
33
+ - [Pages a mail's link opens](#pages-a-mails-link-opens)
34
+ - [The types and the rest of `/session/model`](#the-types-and-the-rest-of-sessionmodel)
35
+
36
+ ## A complete sign-in
37
+
38
+ The app's side is three server files and four routes. `execute`, `t`,
39
+ `toAnswer`, `readViewer`, `apiMessages` and the GraphQL documents
40
+ (`SignInMutation`, `ConfirmMutation`, `RecoverMutation`, `SignOutMutation`,
41
+ `ViewerQuery`) are the app's own: the package knows no API client and no
42
+ wording. `toAnswer` maps the API's result to a `SignInAnswer` (see
43
+ [Signing in](#signing-in)).
44
+
45
+ ```ts
46
+ // app/cookies.server.ts
47
+ import { sessionCookies } from '@alxia/react-router/session';
48
+
49
+ export const cookies = sessionCookies({
50
+ session: 'my-app_session',
51
+ challenge: 'my-app_challenge',
52
+ secure: !import.meta.env.DEV,
53
+ });
54
+ ```
55
+
56
+ ```ts
57
+ // app/errors.server.ts
58
+ import { createApiErrors, jsonActions } from '@alxia/react-router/errors';
59
+
60
+ export const errors = createApiErrors<'en' | 'fr'>({
61
+ messages: apiMessages,
62
+ unavailable: (language) => t('app.error.unavailable', {}, language),
63
+ });
64
+ export const json = jsonActions({ origin: () => process.env.APP_ORIGIN });
65
+ ```
66
+
67
+ ```ts
68
+ // app/session.server.ts
69
+ import { signInFlow } from '@alxia/react-router/session';
70
+ import { SignOutMutation, execute } from './api.server';
71
+ import { cookies } from './cookies.server';
72
+ import { errors, json } from './errors.server';
73
+
74
+ export const signIn = signInFlow({
75
+ cookies,
76
+ errors,
77
+ json,
78
+ revoke: (token, request) =>
79
+ execute(SignOutMutation, {}, { token, signal: request.signal }),
80
+ attemptsLeft: (count, language) => t('auth.attempts-left', { count }, language),
81
+ });
82
+ ```
83
+
84
+ The password page. Its loader sends a live session home and clears what a
85
+ signed-out visit should not keep; its action runs the API's mutation inside
86
+ `signInStep`, which redirects where the answer leads or stays on the form with
87
+ a message:
88
+
89
+ ```tsx
90
+ // app/routes/sign-in.tsx
91
+ import { data, redirect } from 'react-router';
92
+ import { useJsonFetcher } from '@alxia/react-router/errors';
93
+ import { cookies } from '~/cookies.server';
94
+ import { json } from '~/errors.server';
95
+ import { signIn } from '~/session.server';
96
+
97
+ export async function loader({ request }: Route.LoaderArgs) {
98
+ const viewer = await signIn.viewer(request, readViewer);
99
+ // A live session goes home; a challenge left by a half-done sign-in goes too.
100
+ if (viewer) {
101
+ throw redirect(signIn.nextParam(request), {
102
+ headers: { 'Set-Cookie': cookies.destroyChallenge() },
103
+ });
104
+ }
105
+ return data({}, { headers: cookies.signInPageHeaders(viewer === null) });
106
+ }
107
+
108
+ export async function action({ context, request }: Route.ActionArgs) {
109
+ const { language } = alxiaOf(context);
110
+ // First, for a form posted before hydration: it goes back to its page.
111
+ json.requireJsonPost(request);
112
+ const input = await json.readInput(request, SignInInput);
113
+ return signIn.signInStep(
114
+ async () => toAnswer((await execute(SignInMutation, input)).signIn),
115
+ { request, language, next: signIn.nextParam(request), extra: {} },
116
+ );
117
+ }
118
+
119
+ export default function SignIn() {
120
+ const { fetcher, post, busy } = useJsonFetcher<typeof action>();
121
+ return (
122
+ <form onSubmit={(event) => { event.preventDefault(); post(formValues(event)); }}>
123
+ {/* the fields */}
124
+ {fetcher.data && 'formError' in fetcher.data ? <p>{fetcher.data.formError}</p> : null}
125
+ <button disabled={busy}>Sign in</button>
126
+ </form>
127
+ );
128
+ }
129
+ ```
130
+
131
+ The code page, reached when the answer is `secondFactor`. Its loader needs
132
+ the challenge cookie; its action posts the code against it, a recovery code
133
+ included:
134
+
135
+ ```ts
136
+ // app/routes/sign-in.second-factor.tsx
137
+ import { redirect } from 'react-router';
138
+ import { signInPath } from '@alxia/react-router/session/model';
139
+ import { cookies } from '~/cookies.server';
140
+ import { signIn } from '~/session.server';
141
+
142
+ export function loader({ request }: Route.LoaderArgs) {
143
+ if (!cookies.challengeOf(request)) {
144
+ throw redirect(signInPath(signIn.nextParam(request)));
145
+ }
146
+ return null;
147
+ }
148
+
149
+ export async function action({ context, request }: Route.ActionArgs) {
150
+ const { language } = alxiaOf(context);
151
+ const input = await signIn.readCodeInput(request);
152
+ const challenge = cookies.challengeOf(request);
153
+ if (!challenge) throw redirect(signInPath(signIn.nextParam(request)));
154
+ return signIn.signInStep(
155
+ () =>
156
+ signIn.codeStep(input, challenge, {
157
+ confirm: async (challenge, code) =>
158
+ toAnswer((await execute(ConfirmMutation, { challenge, code })).confirm),
159
+ recover: async (challenge, recoveryCode) =>
160
+ toAnswer((await execute(RecoverMutation, { challenge, recoveryCode })).recover),
161
+ }),
162
+ {
163
+ request,
164
+ language,
165
+ next: signIn.nextParam(request),
166
+ extra: { kind: input.kind },
167
+ // A refused code stays on the form with the tries left; a challenge
168
+ // that is over goes back to the password page.
169
+ refused: (error) =>
170
+ signIn.codeRefusal(error, language) ??
171
+ redirect(signInPath(signIn.nextParam(request), { query: { expired: '1' } }), {
172
+ headers: { 'Set-Cookie': cookies.destroyChallenge() },
173
+ }),
174
+ },
175
+ );
176
+ }
177
+ ```
178
+
179
+ Sign-out, and a page that needs a user:
180
+
181
+ ```ts
182
+ // app/routes/sign-out.tsx
183
+ import { redirect } from 'react-router';
184
+ import { signIn } from '~/session.server';
185
+
186
+ export const loader = () => redirect('/');
187
+
188
+ export function action({ request }: Route.ActionArgs) {
189
+ return signIn.signOut(request);
190
+ }
191
+ ```
192
+
193
+ ```ts
194
+ // app/routes/orders.tsx
195
+ import { data } from 'react-router';
196
+ import { signIn } from '~/session.server';
197
+
198
+ export async function loader({ request }: Route.LoaderArgs) {
199
+ const { viewer, headers } = await signIn.requireViewer(
200
+ request,
201
+ (token) => execute(ViewerQuery, {}, { token }).then((r) => r.viewer),
202
+ (viewer) => viewer.sessionExpiresAt,
203
+ );
204
+ return data({ viewer }, { headers });
205
+ }
206
+ ```
207
+
208
+ A request with no session, or one the API refuses, is redirected to
209
+ `/sign-in?next=%2Forders`, and the user comes back to `/orders` once signed in.
210
+ The sections below say what each call does and what it refuses.
211
+
212
+ ## Redirects that stay on the site
213
+
214
+ After a sign-in the user goes back to the page they came from, carried in
215
+ `next`. A `next` is whatever the request says, so it is checked before any
216
+ redirect: `safeNext` answers a path of this site (with its search and hash),
217
+ and `/` for anything else.
218
+
219
+ ```ts
220
+ import { safeNext } from '@alxia/react-router/session/model';
221
+
222
+ safeNext('/orders/42?tab=items'); // '/orders/42?tab=items'
223
+ safeNext('//evil.test'); // '/'
224
+ safeNext('https://evil.test/'); // '/'
225
+ safeNext('/a/..//evil.test'); // '/'
226
+ ```
227
+
228
+ It refuses a value that does not start with `/`, one that starts with `//`,
229
+ one holding a backslash, a space or a control character (a browser drops a
230
+ tab or a newline, or reads `\` as `/`), a value over 2000 characters, and
231
+ one that resolves to another site once its dot segments are applied. A
232
+ percent-encoded `//` stays a path of this site; it is not decoded.
233
+
234
+ `nextOf(request.url)` from [`@alxia/react-router/errors`](errors.md) is the
235
+ page a request is for, and `safeNext` is applied on the way back.
236
+
237
+ ## The sign-in pages' paths
238
+
239
+ `signInPath(next)` is the sign-in page's URL, coming back to `next`, and
240
+ `secondFactorPath(next)` the second-factor page's. Both go through
241
+ `safeNext`, and add no `next` for the home page.
242
+
243
+ ```ts
244
+ import {
245
+ SIGN_IN_PATHS,
246
+ secondFactorPath,
247
+ signInPath,
248
+ } from '@alxia/react-router/session/model';
249
+
250
+ signInPath('/orders'); // '/sign-in?next=%2Forders'
251
+ signInPath('/', { query: { expired: '1' } }); // '/sign-in?expired=1'
252
+ signInPath('/orders', { path: '/login' }); // '/login?next=%2Forders'
253
+ secondFactorPath('/orders'); // '/sign-in/second-factor?next=%2Forders'
254
+ ```
255
+
256
+ `SIGN_IN_PATHS` holds the defaults, `/sign-in` and `/sign-in/second-factor`.
257
+ If every `next` comes out as `/`, see
258
+ [`next` is always `/`](../troubleshooting.md#next-is-always-).
259
+
260
+ ## Cookies
261
+
262
+ `sessionCookies` names, writes, reads and removes the two cookies a sign-in
263
+ needs: the session, which holds the API's token, and the challenge, which
264
+ carries a second factor's challenge from the password page to the code page.
265
+ Both are `HttpOnly`, so a script never reads them.
266
+
267
+ ```ts
268
+ // app/cookies.server.ts
269
+ import { sessionCookies } from '@alxia/react-router/session';
270
+
271
+ export const cookies = sessionCookies({
272
+ session: 'my-app_session',
273
+ challenge: 'my-app_challenge',
274
+ secure: !import.meta.env.DEV,
275
+ });
276
+ ```
277
+
278
+ `secure` is required and is never read from the environment: pass `true`
279
+ when the app is served over HTTPS, and `false` for plain HTTP in development.
280
+ A wrong value in production would send the token over HTTP, so the package
281
+ leaves that choice to you in plain sight.
282
+
283
+ | `secure` | session cookie | challenge cookie |
284
+ | --- | --- | --- |
285
+ | `true` | `__Host-my-app_session`, `Secure` | `__Secure-my-app_challenge`, `Secure` |
286
+ | `false` | `my-app_session` | `my-app_challenge` |
287
+
288
+ Pass the base names. The package adds the prefix itself and refuses a name
289
+ that already has one.
290
+
291
+ ### What is written
292
+
293
+ The bytes are, in this order:
294
+
295
+ ```
296
+ Name=value; Max-Age=n; Path=/; HttpOnly[; Secure]; SameSite=Lax
297
+ ```
298
+
299
+ The session cookie's value is `<expiry in ms>.<token>`, percent-encoded. A
300
+ session set by an app that wrote its own cookies in this format is read as it
301
+ is, so adopting the package does not sign anyone out.
302
+
303
+ - **`__Host-`** holds only with `Secure`, `Path=/` and no `Domain`. The session
304
+ cookie sets exactly that, so a sibling subdomain cannot plant one that
305
+ shadows it.
306
+ - **The challenge** is sent to `/sign-in` only (`challengePath`) and lives at
307
+ most five minutes (`challengeSeconds`), or less when the API's challenge
308
+ ends sooner. A sibling subdomain can plant a `__Secure-` cookie (it has no
309
+ `Path` lock), which makes a code fail; it never yields a session.
310
+ - **The expiry in the value** only decides when the cookie is set again: the
311
+ API alone decides whether a session is valid. A forged expiry gains nothing.
312
+
313
+ ### Using them
314
+
315
+ ```ts
316
+ import { cookies } from './cookies.server';
317
+
318
+ // After a sign-in: the API's token and the session's end.
319
+ const headers = new Headers();
320
+ headers.append('Set-Cookie', cookies.commit(token, expiresAt));
321
+
322
+ // A request's token, to send to the API as a bearer.
323
+ const session = cookies.read(request); // { token, expiresAt } or null
324
+ const token = cookies.token(request);
325
+
326
+ // Sign-out.
327
+ headers.append('Set-Cookie', cookies.destroy());
328
+
329
+ // A session the API renewed: the cookie is set again only when the expiry moved.
330
+ // An expiry that is absent or not a date changes nothing: the cookie is kept.
331
+ const renewed = cookies.refreshed(session, viewer.sessionExpiresAt); // {} or a Set-Cookie
332
+ ```
333
+
334
+ `refreshed` never ends a session because the API sent an expiry it cannot
335
+ read: it returns `{}` for a missing or unparseable one, and the API alone
336
+ decides when a session is over.
337
+
338
+ `commit` takes the expiry as an ISO string or a `Date`, and sets `Max-Age`
339
+ to the seconds left: floored, and 0 for a past or unreadable date.
340
+
341
+ The challenge has its own three: `commitChallenge(challenge, expiresAt?)`,
342
+ `challengeOf(request)`, `destroyChallenge()`.
343
+
344
+ `cookies.names` holds the names as sent, prefixes included
345
+ (`{ session: '__Host-my-app_session', challenge: '__Secure-my-app_challenge' }`
346
+ when `secure`), for a log line, a test or a header that must name the cookie.
347
+
348
+ `signInPageHeaders(deadSession)` is what a signed-out visit of the sign-in page
349
+ sets. It clears the challenge blind: a client navigation fetches
350
+ `/sign-in.data`, which the cookie's path `/sign-in` does not match, so the
351
+ request never shows the challenge, yet a `Set-Cookie` with that path removes
352
+ it. With `deadSession`, it also removes a session cookie the API refused.
353
+
354
+ `serializeCookie(name, value, { maxAge, path?, secure })`,
355
+ `readCookie(request, name)` and `secondsUntil(expiresAt, now?)` are exported
356
+ for the cookies of your own (a language, a preference) that must be written in
357
+ the same bytes. A name that is not an RFC 6265 token, or a path with a `;` or a
358
+ control character, throws a `TypeError`; a `maxAge` that is not finite is 0.
359
+ `readCookie` returns the first non-empty cookie of that name, and `null` when
360
+ its value is not valid percent-encoding.
361
+
362
+ ### Safari and `http://localhost`
363
+
364
+ Safari (WebKit) drops a `Secure` cookie set over `http://localhost`, where
365
+ Chromium and Firefox keep it. With `secure: true` in development, sign-in
366
+ succeeds and the next request carries no session, so the app loops back to the
367
+ sign-in page. Pass `secure: !import.meta.env.DEV`: development then sets no
368
+ `Secure` and no prefix, since the prefixes require `Secure`. See
369
+ [the troubleshooting entry](../troubleshooting.md#sign-in-loops-on-httplocalhost-in-safari).
370
+
371
+ ## Signing in
372
+
373
+ `signInFlow` is the server side of the sign-in pages: where an answer leads,
374
+ the form's refusals, the second factor, the viewer and sign-out. Build it once
375
+ ([the example](#a-complete-sign-in) does, in `app/session.server.ts`), over the
376
+ app's `createApiErrors` and `jsonActions` (the same instances its other
377
+ actions use, so every body is read with the same same-origin check).
378
+ `revoke(token, request)` ends a session at the API and receives the request,
379
+ for the caller's language, address or abort signal.
380
+
381
+ `toAnswer` maps the API's words to `@nxgt/janus`'s: a
382
+ `SignInAnswer` is `{ status: 'signedIn', token, expiresAt }`,
383
+ `{ status: 'secondFactor', challenge, expiresAt? }` or `{ status: 'other' }`
384
+ (an API's `LINKED`, say, which opens no session). The default codes are
385
+ janus's, `CODE_INVALID` for a wrong code and `TOKEN_UNKNOWN`, `TOKEN_SPENT`
386
+ and `TOKEN_EXPIRED` for a challenge that is gone; an API with its own pass
387
+ `codes: { invalidCode, challengeOver }`. `paths` replaces the pages' paths
388
+ (`SIGN_IN_PATHS`), `clear` lists the `Set-Cookie` values a new session and a
389
+ sign-out also remove (a per-account preference), `unavailable` words an API
390
+ that cannot be reached (`errors.unavailableMessage` by default), and
391
+ `endedParam` is a query key, such as `'expired'`, that `signInRedirect` adds
392
+ when the request carried a session cookie, to say the session ended.
393
+
394
+ A sign-in page's action runs the API's mutation inside `signInStep`, as the
395
+ example does. The order matters, because `readInput` reads the body:
396
+ `json.requireJsonPost(request)` first (a form posted before hydration goes
397
+ back to its page), then `json.readInput` (it checks the origin and the content
398
+ type, and refuses another site with a 403), then `signInStep`, which runs the
399
+ same two checks again on the headers before it signs anyone in or revokes a
400
+ session: a native post goes back to its page, and a JSON post from another
401
+ site is a 403, even for an action that read its input some other way. It reads
402
+ headers only, never the body. Its outcomes:
403
+
404
+ - A **signed-in** answer lands only when it has a token and an `expiresAt`
405
+ that is a date still in the future; otherwise it opens no session (see
406
+ *other* below). A session set with a dead end would be gone at once.
407
+ - A **signed-in** answer redirects to `next` (through `safeNext`) with the
408
+ session cookie set and the challenge and the app's `clear()` cookies removed.
409
+ The session the browser held is revoked first (`landReplacing`): one browser
410
+ holds one session. A revoke that fails is ignored, and the old session lives
411
+ until it expires.
412
+ - A **second factor** answer redirects to the second-factor page with the
413
+ challenge in its own cookie. It is a secret: never in a URL, a log or the
414
+ loader's data.
415
+ - An **API refusal** stays on the page: `{ ...extra, formError }`, worded by
416
+ `errors`, with the API's status. An API that cannot be reached says
417
+ `unavailable`, with 503. Anything else is thrown. `refused(error)` runs
418
+ first: it returns a `Response` (a redirect), a string (a richer message) or
419
+ `undefined`.
420
+ - An **other** answer opens no session, so the step says `unavailable`. Check
421
+ an answer such as `LINKED` yourself, with `landing(answer, next)`, which is
422
+ `null` for it.
423
+
424
+ The flow also exposes what it was built with, for the steps built on it:
425
+ `signIn.cookies`, `signIn.errors` and `signIn.json`, `signIn.attemptsLeft`,
426
+ `signIn.unavailable(language)` (the `unavailable` option, else
427
+ `errors.unavailableMessage`) and `signIn.clear()` (the `clear` option's
428
+ cookies, none by default). `stepUpSteps` and `authLinks` take the flow and read
429
+ these, so the app wires `errors`, `json` and the wording once.
430
+
431
+ `signIn.nextParam(request)` is the `next` the sign-in page was opened with,
432
+ through `safeNext`. `signIn.signInRedirect(request)` is the redirect
433
+ `requireViewer` throws: to the sign-in page, back to this request's page,
434
+ with the session cookie cleared. `signIn.revokeSession(request)` ends the
435
+ request's session at the API (through `revoke`; a failure is ignored) and
436
+ answers `true` when the request carried one.
437
+
438
+ `signIn.landing(answer, next)` and `signIn.landReplacing(answer, next,
439
+ { request, revokeOn? })` are the same redirects for a step that does not go
440
+ through `signInStep` (a link a mail carries). `revokeOn` lists the statuses
441
+ that end the browser's session; `['signedIn']` by default.
442
+
443
+ ### Which steps guard themselves
444
+
445
+ Every step that signs in, out, spends or mails runs the same guard before it
446
+ reads anything: a native form post is redirected back to its page, and a JSON
447
+ post from another site is a 403. These guard themselves: `signInStep`,
448
+ `readCodeInput`, `landReplacing`, `revokeSession`, `signOut` (through
449
+ `readJson`), the step-up's `request` and `confirm`, and `linkArgs`,
450
+ `spendToken`, `landByLink`, `afterReset` and `requestLink`. `landing`,
451
+ `codeStep`, `codeRefusal`, `viewer` and `requireViewer` take no post and do not.
452
+
453
+ An app's own step (a passkey's registration, say) calls `signIn.guard(request)`
454
+ first, in a POST action, before it reads the body or calls the API. It throws
455
+ the same redirect or 403. Do not call it in a GET callback (an OAuth return, a
456
+ magic link's landing): a GET carries no JSON content type, so the guard would
457
+ redirect it to itself.
458
+
459
+ ### The viewer
460
+
461
+ ```ts
462
+ // a page that needs a user (as in the example)
463
+ const { viewer, headers } = await signIn.requireViewer(
464
+ request,
465
+ (token) => execute(ViewerQuery, {}, { token }).then((r) => r.viewer),
466
+ (viewer) => viewer.sessionExpiresAt,
467
+ );
468
+ return data({ viewer }, { headers });
469
+
470
+ // the sign-in page itself: a live session goes home
471
+ const viewer = await signIn.viewer(request, read); // the viewer, null, or undefined
472
+ ```
473
+
474
+ `requireViewer` throws `signInRedirect(request)` without a cookie, on a 401
475
+ and when `read` finds no viewer; any other failure is thrown through
476
+ `routeError`. The redirect comes back to the page and clears the cookie, and
477
+ adds `?<endedParam>=1` when the request carried one. `headers` set the cookie
478
+ again when the API moved the session's end. `viewer` answers `null` for a
479
+ 401 (a dead session: send `cookies.signInPageHeaders(true)`), and `undefined`
480
+ without a cookie or when the API cannot tell.
481
+
482
+ ## The second factor
483
+
484
+ The sign-in page's answer `secondFactor` leads to the second-factor page,
485
+ whose loader needs the challenge cookie (`cookies.challengeOf(request)`; back
486
+ to the sign-in page without one) and whose action posts the code.
487
+
488
+ [The example](#a-complete-sign-in) shows both: `codeStep` runs `confirm` for an
489
+ issued code and `recover` for a recovery code, and `refused` words a refusal
490
+ with the tries left or, when the challenge is over, sends the user back to the
491
+ password page.
492
+
493
+ `readCodeInput` takes the page's JSON post, `{ kind, code }`: `kind` is
494
+ `'code'` for an issued code (an authenticator app's, or the one mailed) and
495
+ `'recovery'` for a recovery code (`SecondFactorKind`); `code` is at most 40
496
+ characters. A native form post (before hydration) goes back to its page, a
497
+ cross-site post is a 403 even when it is JSON, and a body of another shape is a
498
+ 400.
499
+
500
+ `codeRefusal(error, language, whenNone?)` words a refused code:
501
+
502
+ | the API says | the form shows |
503
+ | --- | --- |
504
+ | `CODE_INVALID` with `attemptsLeft: 3` | the message and `attemptsLeft(3, language)` |
505
+ | `CODE_INVALID` with `attemptsLeft: 0` | `undefined`: the challenge is over, sign in again (with `whenNone`, the message and `whenNone` instead) |
506
+ | `CODE_INVALID` with no count | the message alone |
507
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED` | `undefined` |
508
+ | anything else | the message `errors` words |
509
+
510
+ The count is `attemptsLeftOf(error)` of
511
+ [`@alxia/react-router/errors`](errors.md#the-tries-a-code-leaves), which
512
+ reads janus's `extensions.attemptsLeft`.
513
+
514
+ ## Signing out
515
+
516
+ `signIn.signOut(request)` is the whole action of the example's sign-out route.
517
+
518
+ `signOut` reads the body as JSON first (same-origin only, a 403 or 415
519
+ otherwise), so another site cannot sign anyone out, and a GET signs nobody
520
+ out. It then ends the session at the API (a failure is ignored: the cookie
521
+ goes anyway), removes the session cookie, the challenge cookie and the app's
522
+ `clear()` cookies and
523
+ redirects to `${paths.signIn}?signed-out=1` (`/sign-in?signed-out=1` by
524
+ default). `signOut(request, { to })` redirects to `to` instead, after `safeNext`: a value that leaves the site goes to `/`.
525
+
526
+ ## Step-up
527
+
528
+ A step-up is a code asked for before a sensitive action: the API refuses the
529
+ mutation with `STEP_UP_REQUIRED`, the page asks for a challenge
530
+ (`requestStepUp`), the user reads the code (in the mail, or in their
531
+ authenticator app) and the page confirms it (`confirmStepUp`). `stepUpSteps`
532
+ is the server half: two steps for the route's action, whose answers
533
+ [`stepUpReducer`](#the-state-on-the-page) takes as they are.
534
+
535
+ ```ts
536
+ // app/step-up.server.ts
537
+ import { stepUpSteps } from '@alxia/react-router/session';
538
+ import { signIn } from './session.server';
539
+
540
+ export const stepUp = stepUpSteps({
541
+ // The app's signInFlow: the step-up reads its errors, its json (a step runs
542
+ // for a same-site JSON post only) and its attemptsLeft.
543
+ signIn,
544
+ spent: (language) => t('step-up.spent', {}, language), // "Ask for a new code."
545
+ locked: (language) => t('step-up.locked', {}, language), // "Wait a few minutes."
546
+ });
547
+ ```
548
+
549
+ `json.readJson`, `toVia`, `execute` and `sessionCaller` below are the app's
550
+ own helpers (its `jsonActions`, its mapping of the API's method to `via`, its
551
+ GraphQL call and its session caller), not this package's.
552
+
553
+ ```ts
554
+ // app/routes/account.step-up.tsx
555
+ import { stepUp } from '~/step-up.server';
556
+
557
+ export async function action({ context, request }: Route.ActionArgs) {
558
+ const { language } = alxiaOf(context);
559
+ json.requireJsonPost(request); // a native post goes back to its page
560
+ const caller = await sessionCaller(request);
561
+ const input = await json.readJson(request); // same-origin JSON only
562
+ if (input.intent === 'request-step-up') {
563
+ return stepUp.request(
564
+ async () => {
565
+ const { requestStepUp } = await execute(RequestStepUpMutation, {}, caller);
566
+ return { challenge: requestStepUp.challenge, via: toVia(requestStepUp.method) };
567
+ },
568
+ { request, language },
569
+ );
570
+ }
571
+ const via = input.via === 'email' || input.via === 'secondFactor' ? input.via : undefined;
572
+ return stepUp.confirm(
573
+ () =>
574
+ execute(ConfirmStepUpMutation, { challenge: input.challenge, code: input.code }, caller),
575
+ { request, language, via },
576
+ );
577
+ }
578
+ ```
579
+
580
+ `request(run, { request, language })` answers `{ ok: true,
581
+ intent: 'request-step-up', challenge, via }`. `confirm(run, { request,
582
+ language, via? })` answers `{ ok: true, intent: 'confirm-step-up' }`. Both
583
+ first run the same guard as `signInStep`: a form posted before hydration goes
584
+ back to its page, and a JSON post from another site is a 403, before `run`
585
+ is called. `via` may be `undefined`. A refusal stays on the page, with the
586
+ API's status, as `data(...)`:
587
+
588
+ ```ts no-check
589
+ type StepUpError = 'invalid' | 'expired' | 'failed' | 'throttled';
590
+
591
+ // request, refused
592
+ { ok: false, intent: 'request-step-up', error: StepUpError, message, retryAfter? }
593
+ // confirm, refused
594
+ { ok: false, intent: 'confirm-step-up', error: StepUpError, attemptsLeft?, locked, message }
595
+ ```
596
+
597
+ The answer must not be cached: `Cache-Control: private, no-store`, which
598
+ `@alxia/secure-headers`' `pageHeaders` sets.
599
+
600
+ A refused request reaches the UI as `error` (`'failed'`, or `'throttled'` for a
601
+ mail held back) with `message`, the refusal worded for the page. The reducer
602
+ keeps both: `state.message` until the next step, and `state.retryAfter` for a
603
+ throttle whose seconds the API gave.
604
+
605
+ The `via` of `confirm` is the one `request` answered, which the page holds
606
+ (the reducer's state has it). The `run` function of `confirm` takes nothing:
607
+ bind the challenge and the code in it, as above, and keep the step's `intent`
608
+ out of the API's variables.
609
+
610
+ **The challenge stays in the fetcher's answer.** It is a secret: never put it
611
+ in a URL, in a cookie, in the loader's data or in a log. The page keeps it in
612
+ the reducer's state and posts it back with the code. A page that carries it in
613
+ the loader would keep it in the browser's history and the caches.
614
+
615
+ ### What each answer leads to
616
+
617
+ This is the table of `@nxgt/janus-graphql`'s `confirmStepUp`, with what
618
+ `confirm` answers:
619
+
620
+ | the API says | status | `confirm` answers |
621
+ | --- | --- | --- |
622
+ | accepted | 200 | `{ ok: true }`: send the refused mutation again |
623
+ | `CODE_INVALID`, `attemptsLeft: 3` | 401 | `error: 'invalid'`, `attemptsLeft: 3`, `locked: false`: ask for the code again |
624
+ | `CODE_INVALID`, `attemptsLeft: 0`, `via: 'email'` | 401 | `error: 'expired'`, `locked: false`: ask for a new code |
625
+ | `CODE_INVALID`, `attemptsLeft: 0`, `via: 'secondFactor'` (an app or a recovery code) | 401 | `error: 'invalid'`, `attemptsLeft: 0`, `locked: true`, `message` ends with `locked`'s words: wait, a new code would fail the same way |
626
+ | `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_UNKNOWN`, `TOKEN_STALE` | 400 | `error: 'expired'`: ask for a new code |
627
+ | `SECOND_FACTOR_ACTIVE`, `SECOND_FACTOR_NOT_ENROLLED` | 409 | `error: 'expired'`: start over, the way to confirm changed |
628
+ | `USER_INACTIVE`, anything else | 403 or other | `error: 'failed'`, in `wording`'s words or `errors`' |
629
+ | an API it cannot reach | 503 | `error: 'failed'` |
630
+ | `request` refused with `MAIL_THROTTLED` | 429 | `error: 'throttled'`, `retryAfter` in seconds when the API sent them, `message` in `wording`'s words or `errors`' |
631
+
632
+ A request that the API refuses is `error: 'failed'`. A mail throttled
633
+ (`MAIL_THROTTLED`, 429) is `error: 'throttled'` instead, with `retryAfter`, the
634
+ whole seconds to wait, read from `extensions.params.retryAfter` or
635
+ `extensions.retryAfter` (a number or a numeric string, rounded up; with none
636
+ usable the answer is still `throttled`, without `retryAfter`). Its `message`
637
+ is worded by the `wording` option, which sees the error before `errors` does,
638
+ so it can say the wait:
639
+
640
+ ```ts
641
+ import { waitMinutes } from '@alxia/react-router/errors';
642
+
643
+ stepUpSteps({
644
+ // …
645
+ wording: (error, language) =>
646
+ error.code === 'MAIL_THROTTLED'
647
+ ? t('mail.wait', { minutes: waitMinutes(error.errors[0]?.extensions) }, language)
648
+ : undefined,
649
+ });
650
+ ```
651
+
652
+ With no try left, an app's code is the account's shared lockout (janus counts
653
+ the codes of a step-up, of a factor's regeneration and of its disabling
654
+ together): a new challenge would be refused too, so `confirm` says `locked`
655
+ and the form says to wait. A mailed code at that point is over, and asking
656
+ again works. With no `via`, `confirm` reads a mailed code.
657
+
658
+ `stepUp.isStepUpRequired(error)` is true for the API's `STEP_UP_REQUIRED`
659
+ refusal of a sensitive mutation, so an action can answer the page with the
660
+ request for a challenge instead of the error page.
661
+
662
+ ### The codes it tells apart
663
+
664
+ `codes` takes `invalidCode` (`'CODE_INVALID'`), `challengeOver` (the table's
665
+ codes that end a challenge; passing a list replaces it), `stepUpRequired`
666
+ (`'STEP_UP_REQUIRED'`) and `mailThrottled` (`'MAIL_THROTTLED'`).
667
+ `STEP_UP_OVER` is `[...CHALLENGE_OVER, …]`, so a list that adds a code of its
668
+ own can spread either. `invalidCode` in `challengeOver` is a
669
+ [`TypeError`](../troubleshooting.md#typeerror-stepupsteps-the-code--is-both-the-wrong-code-code-codesinvalidcode-and-in-codeschallengeover-a-wrong-code-that-leaves-tries-cannot-end-the-challenge).
670
+
671
+ ### Call the step-up with a caller that does not sign out on a 401
672
+
673
+ `@nxgt/janus-graphql` answers a wrong code (`CODE_INVALID`) with HTTP 401. A
674
+ caller that treats every 401 as "the session is dead" and redirects to the
675
+ sign-in page would sign the user out on the first typo. Make the caller of the
676
+ step-up's two operations leave a 401 alone, or have it redirect only when the
677
+ error's code is `UNAUTHENTICATED`.
678
+
679
+ ### The state on the page
680
+
681
+ A step-up asks for a code before a sensitive action. `stepUpReducer` holds
682
+ its state on the page, with no React: `phase` (`idle`, `requesting`, `code`,
683
+ `confirming`, `done`), the `via` that confirms it (`email` or
684
+ `secondFactor`), the `challenge` held, `attemptsLeft`, `locked`, `error`
685
+ (`invalid`, `expired`, `failed` or `throttled`), `message` (the last refusal
686
+ worded for the page), `retryAfter` (a throttle's seconds), `pending` and
687
+ `resent`. `start`, `submit`, an accepted answer and `release` clear `message`
688
+ and `retryAfter`.
689
+
690
+ `stepUpProps(state)` gives the part a code form shows (not the challenge, a
691
+ secret that stays out of the markup, and not `message`: the dialog words its
692
+ own texts from `error`, `attemptsLeft`, `locked` and `retryAfter`):
693
+
694
+ ```ts
695
+ import {
696
+ INITIAL_STEP_UP,
697
+ stepUpProps,
698
+ stepUpReducer,
699
+ } from '@alxia/react-router/session/model';
700
+
701
+ let state = stepUpReducer(INITIAL_STEP_UP, { type: 'start' });
702
+ state = stepUpReducer(state, {
703
+ type: 'requested',
704
+ answer: {
705
+ ok: true,
706
+ intent: 'request-step-up',
707
+ challenge: 'c1',
708
+ via: 'secondFactor',
709
+ },
710
+ });
711
+ state = stepUpReducer(state, {
712
+ type: 'confirmed',
713
+ answer: {
714
+ ok: false,
715
+ intent: 'confirm-step-up',
716
+ error: 'invalid',
717
+ attemptsLeft: 0,
718
+ locked: false,
719
+ },
720
+ });
721
+
722
+ stepUpProps(state);
723
+ // { via: 'secondFactor', attemptsLeft: 0, locked: true, error: 'invalid', pending: false, resent: false }
724
+ // A throttled request adds `retryAfter` (seconds) when the API said how long.
725
+ ```
726
+
727
+ With no try left, a mailed code is over (`error: 'expired'`: ask for a new
728
+ one), and an app or a recovery code is `locked`: asking again would fail
729
+ too, so the form says to wait.
730
+
731
+ ## Pages a mail's link opens
732
+
733
+ A magic link, a password reset or an invitation opens a page whose URL
734
+ carries a token. `authLinks` is the server side of those pages, built once
735
+ beside the app's `signInFlow`:
736
+
737
+ ```ts
738
+ // app/links.server.ts
739
+ import { authLinks } from '@alxia/react-router/session';
740
+ import { signIn } from './session.server';
741
+
742
+ export const links = authLinks({
743
+ // The app's signInFlow: the links use its cookies, its errors and its json
744
+ // (every body is read with the same CSRF check).
745
+ signIn,
746
+ start: async (token) => (await homeOf(token)) ?? '/', // optional
747
+ });
748
+ ```
749
+
750
+ `homeOf`, `viewerOf`, `confirmMagicLink` and `toAnswer` below are the app's
751
+ own: its GraphQL calls and its mapping of the API's enums to a `SignInAnswer`.
752
+ `ConfirmArgs` is any Standard Schema; a Zod 4 stub:
753
+
754
+ ```ts
755
+ import { z } from 'zod';
756
+
757
+ const ConfirmArgs = z.object({ token: z.string().max(200) });
758
+ ```
759
+
760
+ ```tsx
761
+ // app/routes/auth/magic-link.tsx
762
+ import { data } from 'react-router';
763
+ import { TOKEN_PAGE_HEADERS } from '@alxia/react-router/session';
764
+
765
+ const PATH = '/auth/magic-link';
766
+
767
+ export const headers = () => TOKEN_PAGE_HEADERS;
768
+
769
+ export async function loader({ request }: Route.LoaderArgs) {
770
+ // Spends nothing: a mail scanner opens links first.
771
+ return data({ signedInAs: (await viewerOf(request))?.name ?? null }, { headers: TOKEN_PAGE_HEADERS });
772
+ }
773
+
774
+ export async function action({ request }: Route.ActionArgs) {
775
+ const { token } = await links.linkArgs(ConfirmArgs, request, PATH);
776
+ const spent = await links.spendToken(() => confirmMagicLink(token), {
777
+ request,
778
+ language: 'en',
779
+ path: PATH,
780
+ states: { 'auth.errors.invalid-token': 'invalid' },
781
+ });
782
+ if (!spent.ok) return links.answerOf(spent);
783
+ return links.landByLink(toAnswer(spent.value), {
784
+ request,
785
+ language: 'en',
786
+ // The API's LINKED opens no session: end on the page's `linked` state.
787
+ other: { path: PATH, state: 'linked' },
788
+ });
789
+ }
790
+ ```
791
+
792
+ Asking for the mail, and the reset's last step:
793
+
794
+ ```ts
795
+ // app/routes/auth/sign-in-link.tsx
796
+ export async function action({ request }: Route.ActionArgs) {
797
+ json.requireJsonPost(request); // a native post goes back to its page
798
+ const { email } = await json.readInput(request, EmailArgs);
799
+ return links.requestLink(() => requestMagicLink(email), {
800
+ request,
801
+ language: 'en',
802
+ });
803
+ }
804
+
805
+ // app/routes/auth/reset-password.tsx, once the new password is set
806
+ export async function action({ request }: Route.ActionArgs) {
807
+ // ... linkArgs, spendToken ...
808
+ return links.afterReset(request); // to /sign-in?password-reset=1
809
+ }
810
+ ```
811
+
812
+ - `TOKEN_PAGE_HEADERS` is `Referrer-Policy: no-referrer` and
813
+ `X-Robots-Tag: noindex, nofollow`: no `Referer` carries the token to another
814
+ site (a font, an image), and no search engine keeps the page. Set them on
815
+ the page's document and its `.data` responses. The redirects of `ended`,
816
+ `landByLink` and `afterReset` carry them already.
817
+ - `linkArgs(schema, request, path)` first refuses a post that is not a
818
+ same-site JSON one (a form posted before hydration goes back to its page, a
819
+ cross-site post is a 403), reads the request as JSON with `json.readJson`
820
+ and checks it against any Standard Schema
821
+ (Zod 4, Valibot, ArkType; async is fine). An issue on `token` (missing, or
822
+ over the API's limit: a link cut or padded on its way) throws the redirect
823
+ to the `invalid` end state, as an unknown token does; any other issue throws
824
+ a 400. `invalidState` names another state.
825
+ - `spendToken(run, { request, language, path, states, fields })` runs the API
826
+ call that spends the token, after the same guard on `request`. It returns `{ ok: true, value }`, or what the page answers:
827
+ an API code in `states` ends the page on that state (the redirect replaces
828
+ the token's history entry); refused `fields` stay under the form fields the
829
+ page shows; any other refusal, a wait or an unreachable API, is the form's
830
+ message with the API's status. `answerOf(spent)` turns the refusal into the
831
+ action's answer. Anything that is not an API refusal is rethrown.
832
+ - `statePath(path, state)` is `path?state=…`: the token page in an end state,
833
+ with no token. `ended(path, state, headers?)` is the redirect to it, replacing
834
+ the token's history entry (React Router's `replace`), so Back never opens
835
+ the token's URL again.
836
+ - `landByLink(answer, { request, language, other? })` signs the link in. The
837
+ session the browser held, whoever's, is revoked first (one browser holds one
838
+ session), on a `secondFactor` answer as well as a `signedIn` one. A signed-in
839
+ answer whose session is still live goes to `start(token, request)`'s path
840
+ (`/` when `start` is missing, throws, or answers a path off the site), so the
841
+ redirect lands at once instead of pushing a history entry after `/`'s own
842
+ redirect. An answer that leads nowhere (a session already ended included)
843
+ is the form's `unavailable` with a 503. An `other` answer is one too, unless
844
+ `other: { path, state }` is given: an app whose API can answer "linked" (no
845
+ session opened) passes the page and its state, and the answer ends there,
846
+ keeping the browser's session.
847
+ - `afterReset(request)` follows a password reset, which signs the account out
848
+ everywhere: the browser's session is revoked and its session cookie, its
849
+ challenge cookie and the flow's `clear()` cookies are cleared, then
850
+ the page replaces the token's entry with `resetDone` (`/sign-in?password-reset=1`
851
+ by default).
852
+ - `requestLink(run, { request, language })` requests the mail. It answers
853
+ `{ sent: true }` whoever the address is, so the page does not tell which
854
+ addresses hold an account (`RequestLinkAnswer` is its shape). A wait is a
855
+ 429 with the `formError`, `throttled: true` and, when the API gave the
856
+ seconds, a typed `retryAfter` beside it; a malformed address is the `email` field's refusal.
857
+
858
+ `linkArgs`, `spendToken`, `landByLink`, `afterReset` and `requestLink` first
859
+ refuse a post that is not a same-site JSON one: the redirect a native form
860
+ post gets goes back to its page, which then posts JSON, and a JSON post from
861
+ another site is a 403, before anything is read, spent, revoked or landed.
862
+
863
+ The wait is recognised by the app's own throttle code, so pass it, as the
864
+ step-up's `codes` and `wording` do (`AuthLinksCodes` is the type of `codes`).
865
+ `wording` words every refusal of `requestLink` that is not a field's, the
866
+ wait included; returning `undefined` keeps `errors`' words:
867
+
868
+ ```ts
869
+ authLinks({
870
+ signIn,
871
+ codes: { mailThrottled: 'auth.errors.too-many-attempts' }, // 'MAIL_THROTTLED' by default
872
+ wording: (error, language) => waitMessage(error, language), // else the catalogue's
873
+ });
874
+ ```
875
+
876
+ A token is never logged, worded in an error or put in a redirect built by the
877
+ helpers: the end states and the sign-in's redirects carry a state or a path
878
+ alone, and a `TypeError` about a path prints it only up to its `?` or `#`.
879
+ `statePath`, `ended`, `linkArgs` and `spendToken` throw that `TypeError` for a
880
+ page `path` with a query, a fragment or a host (naming the call), and
881
+ `authLinks` throws one for a `resetDone` that leaves the site.
882
+
883
+ One redirect does carry the page's URL as it was: the native post (a form
884
+ submitted before hydration) that `linkArgs`, `landByLink`, `afterReset`,
885
+ `requestLink` and `spendToken` send back to the token page itself. It is
886
+ harmless because it goes only to the URL the browser already holds, on this
887
+ site, as a GET that spends nothing; the token is no further from the user than
888
+ it was in the address bar, and the page's `TOKEN_PAGE_HEADERS` still keep it
889
+ out of any `Referer`.
890
+
891
+ ## The types and the rest of `/session/model`
892
+
893
+ The rest of `@alxia/react-router/session/model`, beside `safeNext`,
894
+ `signInPath` and the step-up's reducer above:
895
+
896
+ ```ts
897
+ import {
898
+ HOME,
899
+ INITIAL_STEP_UP,
900
+ TOTP_CODE,
901
+ type SecondFactorKind,
902
+ type SignInStatus,
903
+ type SignInPaths,
904
+ SIGN_IN_PATHS,
905
+ type StepUpConfirmAnswer,
906
+ type StepUpProps,
907
+ type StepUpRequestAnswer,
908
+ type StepUpVia,
909
+ } from '@alxia/react-router/session/model';
910
+
911
+ TOTP_CODE.test('123456'); // true: six digits, nothing else
912
+ HOME; // '/', where `safeNext` falls back to
913
+ INITIAL_STEP_UP.phase; // 'idle'
914
+ SIGN_IN_PATHS.signIn; // '/sign-in'
915
+
916
+ const paths: SignInPaths = { ...SIGN_IN_PATHS, signIn: '/login' };
917
+ const kind: SecondFactorKind = 'recovery';
918
+ const status: SignInStatus = 'secondFactor';
919
+ const via: StepUpVia = 'email';
920
+ ```
921
+
922
+ - `TOTP_CODE` is the regular expression of an authenticator app's code: six digits.
923
+ - `HOME` is the path `safeNext` returns when a `next` value is unsafe or missing.
924
+ - `SIGN_IN_PATHS` and `SignInPaths` are the sign-in pages' paths and their
925
+ type: `{ signIn, secondFactor }`.
926
+ - `SecondFactorKind` is what a second-factor form sends: `'code' | 'recovery'`.
927
+ - `SignInStatus` is how a sign-in ended: `'signedIn' | 'secondFactor'`.
928
+ - `StepUpVia` is how a step-up is confirmed, `'email' | 'secondFactor'`, matching janus's vocabulary.
929
+ - `StepUpRequestAnswer` is what the server answers to a request for a challenge, with the `message` of a refusal and a throttle's `retryAfter`.
930
+ - `StepUpConfirmAnswer` is what the server answers to a code, with the `message` `stepUpSteps` words.
931
+ - `StepUpError` is `'invalid' | 'expired' | 'failed' | 'throttled'`.
932
+ - `StepUpProps` is the shape `stepUpProps` returns (`via`, `attemptsLeft`, `locked`, `error`, `retryAfter`, `pending`, `resent`); it spreads onto a step-up dialog's props.
933
+
934
+ The types of `/session`:
935
+
936
+ - `SignInAnswer` is what a step answers: `signedIn`, `secondFactor` or `other`.
937
+ - `SignInFlowOptions` is what `signInFlow` takes, `SignInFlow` what it returns.
938
+ - `SignInCodes` is its `codes` option: `invalidCode` and `challengeOver`.
939
+ - `SignInFormError<T>` is `T & { formError: string }`, a form step's answer
940
+ when it stays on the page, and `SignInStayed<T>` is `signInStep`'s
941
+ `data(...)` return for it, with the status.
942
+ - `SessionCookies`, `SessionCookiesOptions` and `CookieSession` are the cookies,
943
+ their options and a session read from a request (`{ token, expiresAt }`).
944
+ - `StepUpStepsOptions` is what `stepUpSteps` takes, `StepUpSteps` what it returns.
945
+ - `StepUpCodes` is the `codes` option.
946
+ - `StepUpStayed<T>` is a step's `data(...)` return for an answer that stays on the page.
947
+ - `StepUpCall<L>` is the `{ request, language }` each step-up step takes.
948
+ - `INVALID_CODE`, `CHALLENGE_OVER`, `STEP_UP_OVER`, `STEP_UP_REQUIRED` and
949
+ `MAIL_THROTTLED` are the default codes, so an app can extend a list
950
+ (`challengeOver: [...CHALLENGE_OVER, 'auth.errors.gone']`).
951
+ - `AuthLinksOptions` is what `authLinks` takes: `signIn`, and the optional
952
+ `codes` (`AuthLinksCodes`: `mailThrottled`), `wording`, `start`, `resetDone`
953
+ and `invalidState`. `AuthLinks` is what it returns.
954
+ - `Spent<T>` is what `spendToken` returns, `LinkStayed` is the `data(...)` a
955
+ refusal answers with, and `SpendOptions` is its second argument.