@nxgt/janus-hono 0.5.1 → 0.5.2
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/docs/guide/routes.md +8 -1
- package/docs/troubleshooting.md +73 -5
- package/package.json +3 -3
package/docs/guide/routes.md
CHANGED
|
@@ -495,7 +495,7 @@ app.post('/sign-in/email/link', async (c) => {
|
|
|
495
495
|
// GET: what the e-mail links to. It renders a page and does nothing else.
|
|
496
496
|
app.get('/sign-in/link', (c) => {
|
|
497
497
|
const token = c.req.query('token') ?? '';
|
|
498
|
-
c.header('Referrer-Policy', '
|
|
498
|
+
c.header('Referrer-Policy', 'strict-origin'); // the origin only: never the token
|
|
499
499
|
c.header('Cache-Control', 'no-store');
|
|
500
500
|
if (!TOKEN.test(token)) return c.text('This link is not valid.', 400);
|
|
501
501
|
return c.html(`<!doctype html><meta charset="utf-8"><title>Sign in</title>
|
|
@@ -533,6 +533,13 @@ app.post('/sign-in/link', csrf({ origin: ORIGIN }), async (c) => {
|
|
|
533
533
|
- **Put `csrf({ origin })` on the `POST`.** The token does not identify the
|
|
534
534
|
browser that asked for it. Without the check, any site could post a token
|
|
535
535
|
for an account it controls and sign its visitor into that account.
|
|
536
|
+
- **Send the page with `Referrer-Policy: strict-origin`.** A `Referer` then
|
|
537
|
+
holds the origin only — never the path and the token in its query — so
|
|
538
|
+
nothing the page loads learns the token. Not `no-referrer`: with it the
|
|
539
|
+
browser posts the form with `Origin: null`, and wherever `csrf()` falls
|
|
540
|
+
back to the `Origin` — no `Sec-Fetch-Site` on plain HTTP off `localhost`,
|
|
541
|
+
or from an older browser — the button's own `POST` answers
|
|
542
|
+
[403](../troubleshooting.md#403-forbidden-on-post-sign-inlink).
|
|
536
543
|
- **Answer the `POST` with a `303`**, so that reloading the page does not
|
|
537
544
|
post the spent token again. The cookie that `sendSession` set is sent with
|
|
538
545
|
the redirect.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -35,6 +35,7 @@ for what causes each.
|
|
|
35
35
|
- [`400 {"code":"HASH_UNSUPPORTED"}` on sign-in](#400-codehash_unsupported-on-sign-in)
|
|
36
36
|
- [`401 {"code":"CODE_INVALID","attemptsLeft":<n>}` on the code form](#401-codecode_invalidattemptsleftn-on-the-code-form) — a second factor's, or the code sent by e-mail
|
|
37
37
|
- [`401 {"code":"CREDENTIALS_INVALID","retryAfter":<n>}` with the right password](#401-codecredentials_invalidretryaftern-with-the-right-password)
|
|
38
|
+
- [`403 Forbidden` on `POST /sign-in/link`](#403-forbidden-on-post-sign-inlink) — Hono's `csrf()`, not `janusErrors()`
|
|
38
39
|
- [`409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`](#409-codesecond_factor_active-or-409-codesecond_factor_not_enrolled)
|
|
39
40
|
- [The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back)
|
|
40
41
|
- [A bearer client holds an expiry earlier than the session's](#a-bearer-client-holds-an-expiry-earlier-than-the-sessions)
|
|
@@ -156,15 +157,19 @@ const app = new Hono().use(session(auth), provide({ auth, access }));
|
|
|
156
157
|
|
|
157
158
|
Also: `Type 'SecondFactorRequired' is missing the following properties …: token, session, user`.
|
|
158
159
|
|
|
159
|
-
**When:** `sendSession(c, auth, await auth.signIn(...))`,
|
|
160
|
-
|
|
160
|
+
**When:** `sendSession(c, auth, await auth.signIn(...))`,
|
|
161
|
+
`sendSession(c, auth, await auth.magicLink.confirm(token))` or
|
|
162
|
+
`sendSession(c, auth, await auth.signInCode.confirm(challenge, code))`, once
|
|
163
|
+
`janus()` is given a `secondFactor`, for a user type with a password.
|
|
161
164
|
|
|
162
|
-
**Why:**
|
|
165
|
+
**Why:** each of the three then answers a session, or `{ status: 'secondFactor',
|
|
163
166
|
challenge, expiresAt, userId }` for a user whose second factor is active — and a
|
|
164
|
-
challenge is no session to send.
|
|
167
|
+
challenge is no session to send. A link or a code sent by e-mail proves the
|
|
168
|
+
e-mail, not the factor. A user type with no password has no second factor, so
|
|
169
|
+
its `confirm` answers a session and compiles as it is.
|
|
165
170
|
|
|
166
171
|
**Fix:** switch on `status`, and send the session `secondFactor.confirm`
|
|
167
|
-
answers on the next request.
|
|
172
|
+
answers on the next request. For the password form:
|
|
168
173
|
|
|
169
174
|
```ts
|
|
170
175
|
app.post('/sign-in', async (c) => {
|
|
@@ -182,6 +187,23 @@ app.post('/sign-in/code', async (c) => {
|
|
|
182
187
|
});
|
|
183
188
|
```
|
|
184
189
|
|
|
190
|
+
`magicLink.confirm` and `signInCode.confirm` narrow the same way. They hand
|
|
191
|
+
the challenge on in a cookie, which the guide's `/sign-in/code` reads instead of
|
|
192
|
+
the body. `CHALLENGE`, `scope` and that route are under
|
|
193
|
+
[A second factor](guide/routes.md#a-second-factor). The link route redirects, as below. The code route answers
|
|
194
|
+
`c.json({ next: 'secondFactor' })` instead. See "With a second factor" under
|
|
195
|
+
[a code sent by e-mail](guide/routes.md#with-a-second-factor) and under
|
|
196
|
+
[a link sent by e-mail](guide/routes.md#with-a-second-factor-1).
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const result = await auth.magicLink.confirm(token);
|
|
200
|
+
if (result.status === 'secondFactor') {
|
|
201
|
+
setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
|
|
202
|
+
return c.redirect('/sign-in/second-factor', 303); // a page whose form posts the code to /sign-in/code
|
|
203
|
+
}
|
|
204
|
+
sendSession(c, auth, result);
|
|
205
|
+
```
|
|
206
|
+
|
|
185
207
|
## Runtime
|
|
186
208
|
|
|
187
209
|
### `TypeError: permission(): c.var.object is already set`
|
|
@@ -452,6 +474,52 @@ To change the limit, `janus({ signIn: { throttle: { attempts, window } } })`;
|
|
|
452
474
|
in a test, advance the clock past `retryAfter`, or wire
|
|
453
475
|
`signIn: { throttle: false }`.
|
|
454
476
|
|
|
477
|
+
### `403 Forbidden` on `POST /sign-in/link`
|
|
478
|
+
|
|
479
|
+
The body is the text `Forbidden`, not a `{ code }`. It is not a `JanusError`:
|
|
480
|
+
`csrf()` threw it, and `janusErrors()` hands it to Hono's default response, or
|
|
481
|
+
to your `fallback`.
|
|
482
|
+
|
|
483
|
+
**When:** the button of the page the e-mailed link opens, on the route
|
|
484
|
+
guarded by `csrf({ origin: ORIGIN })` — see
|
|
485
|
+
[a link sent by e-mail](guide/routes.md#a-link-sent-by-e-mail).
|
|
486
|
+
|
|
487
|
+
**Why:** Hono's `csrf()` lets a form post through when the browser sends
|
|
488
|
+
`Sec-Fetch-Site: same-origin`, or else an `Origin` header equal to `origin`,
|
|
489
|
+
character for character. Neither held:
|
|
490
|
+
|
|
491
|
+
- **The page and the route are on two origins.** The page may be served by a
|
|
492
|
+
development server on another port, or on `www.` while the route is on the
|
|
493
|
+
bare host. The browser then sends `same-site` or `cross-site`, and the
|
|
494
|
+
`Origin` decides.
|
|
495
|
+
- **The browser sends no `Sec-Fetch-Site`.** It sends none to plain `http://`
|
|
496
|
+
on a host other than `localhost` or a loopback address, and an older browser
|
|
497
|
+
sends none at all. The `Origin` decides here too.
|
|
498
|
+
- **The `Origin` is `null`.** A page sent with `Referrer-Policy: no-referrer`
|
|
499
|
+
makes the browser send `Origin: null` for every form it posts. So whenever
|
|
500
|
+
the `Origin` decides, the answer is 403, whatever `ORIGIN` holds.
|
|
501
|
+
- **`ORIGIN` is not what the browser shows.** Check for `http` against
|
|
502
|
+
`https`, a missing or extra port, or a trailing `/`. Leaving `origin` out does
|
|
503
|
+
not help behind a proxy: `csrf()` then compares with the URL the app sees,
|
|
504
|
+
which is the proxy's `http://` upstream, not the public one.
|
|
505
|
+
|
|
506
|
+
**Fix:** serve the page and the route on one origin, over HTTPS or on
|
|
507
|
+
`localhost` in development. Redirect `www.` to the bare host, and post the form
|
|
508
|
+
to a relative `action`. Set `ORIGIN` to the scheme, host and port the address
|
|
509
|
+
bar shows. Send the page with `strict-origin`, as
|
|
510
|
+
[the guide](guide/routes.md#a-link-sent-by-e-mail) does, never
|
|
511
|
+
`no-referrer`. A `Referer` then holds the origin only, never the path and its
|
|
512
|
+
token, and the form carries its real `Origin`.
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
const ORIGIN = process.env.ORIGIN ?? 'https://app.example'; // what the address bar shows, no trailing slash
|
|
516
|
+
|
|
517
|
+
// on the GET page: the token's URL still reaches nobody, and Origin is no longer null
|
|
518
|
+
c.header('Referrer-Policy', 'strict-origin');
|
|
519
|
+
|
|
520
|
+
app.post('/sign-in/link', csrf({ origin: ORIGIN }), handler);
|
|
521
|
+
```
|
|
522
|
+
|
|
455
523
|
### `409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`
|
|
456
524
|
|
|
457
525
|
**When:** a route calling `secondFactor.enroll` or `secondFactor.activate`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus-hono",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Hono middleware for @nxgt/janus: the signed-in user on every request, the session cookie, routes guarded by a permission, and every error as its status",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,13 +49,13 @@
|
|
|
49
49
|
]
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@nxgt/janus": "^0.
|
|
52
|
+
"@nxgt/janus": "^0.16.0",
|
|
53
53
|
"@types/bun": "^1.4.2",
|
|
54
54
|
"hono": "^4.13.9",
|
|
55
55
|
"zod": "^4.6.5"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@nxgt/janus": "^0.
|
|
58
|
+
"@nxgt/janus": "^0.16.0",
|
|
59
59
|
"hono": "^4.13.4",
|
|
60
60
|
"typescript": "^6.0.3"
|
|
61
61
|
}
|