@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.
- package/README.md +79 -1
- 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/api-errors.d.ts +16 -0
- package/dist/errors/api-errors.d.ts.map +1 -1
- package/dist/errors/describe.d.ts +1 -1
- package/dist/errors/describe.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 +51 -113
- package/dist/errors/index.js.map +6 -10
- 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 +178 -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 +637 -0
- package/dist/session/index.js.map +23 -0
- package/dist/session/land.d.ts +26 -0
- package/dist/session/land.d.ts.map +1 -0
- package/dist/session/link-land.d.ts +18 -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 +124 -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 +17 -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 +33 -0
- package/dist/session/viewer.d.ts.map +1 -0
- package/docs/README.md +1 -0
- package/docs/guide/errors.md +45 -0
- package/docs/guide/session.md +955 -0
- package/docs/roadmap.md +27 -0
- package/docs/troubleshooting.md +362 -1
- 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.
|