@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.
@@ -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', 'no-referrer'); // the URL holds the token: send it nowhere
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.
@@ -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(...))`, once `janus()` is
160
- given a `secondFactor`.
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:** `signIn` then answers a session, or `{ status: 'secondFactor',
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.1",
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.15.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.15.0",
58
+ "@nxgt/janus": "^0.16.0",
59
59
  "hono": "^4.13.4",
60
60
  "typescript": "^6.0.3"
61
61
  }