@nxgt/janus-hono 0.5.0 → 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/README.md CHANGED
@@ -300,7 +300,7 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
300
300
 
301
301
  ## Documentation
302
302
 
303
- - [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, of a second factor and of a code sent by e-mail, guarded routes
303
+ - [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, of a second factor and of a code or a link sent by e-mail, guarded routes
304
304
  - [Troubleshooting](docs/troubleshooting.md) — by the symptom or message you see
305
305
  - [Roadmap](docs/roadmap.md) — what is next, and what is not planned
306
306
 
package/docs/README.md CHANGED
@@ -5,7 +5,7 @@ give the detail.
5
5
 
6
6
  | Page | Read it when |
7
7
  | --- | --- |
8
- | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code, sign-out and the e-mail flows as Hono routes, with one user type or several |
8
+ | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code or link, sign-out and the e-mail flows as Hono routes, with one user type or several |
9
9
  | [Guarded routes and writing tuples](guide/permissions.md) | You are guarding a route with a permission, passing a condition's context, or granting and revoking from a route |
10
10
  | [Troubleshooting](troubleshooting.md) | A route answers something you did not expect, or the compiler refuses one |
11
11
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -465,6 +465,117 @@ A user type with no password has no second factor, so its `confirm` always
465
465
  answers a session. The attempts, the lifetime and every refusal are
466
466
  [`@nxgt/janus`'s sign-in code guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md).
467
467
 
468
+ ## A link sent by e-mail
469
+
470
+ A sign-in by e-mailed link takes three routes. The first asks for a link.
471
+ The link then opens **a page that spends nothing**, and that page's button
472
+ posts the token back to the third route, which signs the user in.
473
+
474
+ Never sign in on the link's `GET`. Mail scanners open every link in an
475
+ e-mail and would spend the token before the user clicks. Sending the e-mail
476
+ is your job.
477
+
478
+ ```ts
479
+ import { JanusError, TokenError } from '@nxgt/janus';
480
+ import { sendSession } from '@nxgt/janus-hono';
481
+ import { csrf } from 'hono/csrf';
482
+
483
+ const ORIGIN = 'https://app.example';
484
+ const TOKEN = /^[A-Za-z0-9_-]{43}$/; // 32 bytes, base64url: the only thing echoed into the page
485
+
486
+ app.post('/sign-in/email/link', async (c) => {
487
+ const { email } = await c.req.json();
488
+ const issued = await auth.magicLink.request(email);
489
+ if (issued !== null) {
490
+ void mailer.send(issued.email, `${ORIGIN}/sign-in/link?token=${issued.token}`); // not awaited, so the answer's time tells nothing; in production, a queue that awaits and retries
491
+ }
492
+ return c.body(null, 202); // the same answer either way: nothing of a link reaches the visitor
493
+ });
494
+
495
+ // GET: what the e-mail links to. It renders a page and does nothing else.
496
+ app.get('/sign-in/link', (c) => {
497
+ const token = c.req.query('token') ?? '';
498
+ c.header('Referrer-Policy', 'strict-origin'); // the origin only: never the token
499
+ c.header('Cache-Control', 'no-store');
500
+ if (!TOKEN.test(token)) return c.text('This link is not valid.', 400);
501
+ return c.html(`<!doctype html><meta charset="utf-8"><title>Sign in</title>
502
+ <form method="post" action="/sign-in/link">
503
+ <input type="hidden" name="token" value="${token}">
504
+ <button>Sign in</button>
505
+ </form>`);
506
+ });
507
+
508
+ // POST: the page's button. csrf() answers 403 to a form posted from another site.
509
+ app.post('/sign-in/link', csrf({ origin: ORIGIN }), async (c) => {
510
+ const token = String((await c.req.parseBody()).token ?? '');
511
+ try {
512
+ sendSession(c, auth, await auth.magicLink.confirm(token));
513
+ return c.redirect('/', 303);
514
+ } catch (error) {
515
+ if (error instanceof TokenError || (error instanceof JanusError && error.code === 'VERSION_CONFLICT')) {
516
+ return c.redirect('/sign-in?link=expired', 303); // ask for another link
517
+ }
518
+ throw error; // USER_INACTIVE 403, STORE_FAILED 503: janusErrors()
519
+ }
520
+ });
521
+ ```
522
+
523
+ - **The request route answers `202` whoever asked.** `magicLink.request`
524
+ answers `null` for an address nobody holds and for an inactive user. With
525
+ a code, the route needs a decoy challenge. Nothing of a link reaches the
526
+ visitor, so here there is no decoy to forge. Rate-limit the route per
527
+ address and per client: every call sends an e-mail and cancels the link
528
+ sent before it.
529
+ - **The page echoes only a token of the token's shape.** Anything else
530
+ written into the HTML is a cross-site scripting hole. Use a button, not a
531
+ script that submits the form by itself: a scanner that runs scripts would
532
+ submit it too.
533
+ - **Put `csrf({ origin })` on the `POST`.** The token does not identify the
534
+ browser that asked for it. Without the check, any site could post a token
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).
543
+ - **Answer the `POST` with a `303`**, so that reloading the page does not
544
+ post the spent token again. The cookie that `sendSession` set is sent with
545
+ the redirect.
546
+
547
+ `confirm` spends the link on its first call, whether that call signs the
548
+ user in or is refused, and it marks the user's e-mail verified. To the user,
549
+ every `TOKEN_*` error means the same thing: ask for another link. The rare
550
+ `VERSION_CONFLICT` means the same too; it happens when the user record was
551
+ written while the e-mail was being proved.
552
+
553
+ ### With a second factor
554
+
555
+ With `janus({ secondFactor })`, a user whose factor is active gets a
556
+ **challenge** from `magicLink.confirm`, not a session. On a type with a
557
+ password, passing that answer straight to `sendSession` no longer compiles.
558
+ Narrow on `status`, and hand the challenge to the second factor's cookie:
559
+
560
+ ```ts
561
+ app.post('/sign-in/link', csrf({ origin: ORIGIN }), async (c) => {
562
+ const token = String((await c.req.parseBody()).token ?? '');
563
+ const result = await auth.magicLink.confirm(token);
564
+ if (result.status === 'secondFactor') {
565
+ setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
566
+ return c.redirect('/sign-in/second-factor', 303); // a page of yours whose form posts the app's code to /sign-in/code
567
+ }
568
+ sendSession(c, auth, result);
569
+ return c.redirect('/', 303);
570
+ });
571
+ ```
572
+
573
+ [`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail) sends
574
+ this e-mail with `mail.magicLink(issued)`, building its button with
575
+ `links.magicLink(token)`. Point `links.magicLink` at the `GET` route above.
576
+ For the lifetime and every refusal, see [`@nxgt/janus`'s sign-in link
577
+ guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/magic-link.md).
578
+
468
579
  ## A step-up
469
580
 
470
581
  A route a stolen session should not run alone — deleting the account,
package/docs/roadmap.md CHANGED
@@ -36,6 +36,10 @@ Nothing yet.
36
36
 
37
37
  Newest first; the [CHANGELOG](../CHANGELOG.md) holds every release.
38
38
 
39
+ - **The routes of a sign-in link, v0.5.1** — the routes guide's
40
+ [A link sent by e-mail](guide/routes.md#a-link-sent-by-e-mail): a page
41
+ that spends nothing, a `POST` behind `csrf()` that confirms, and the
42
+ second factor's challenge handed on. Needs `@nxgt/janus` 0.15.0.
39
43
  - **A throttled sign-in's `retryAfter`, v0.5.0** — `janusErrors()` and
40
44
  `bodyOf()` answer `@nxgt/janus`'s throttled `CREDENTIALS_INVALID` with
41
45
  `retryAfter` in the body and a `Retry-After` header. Needs `@nxgt/janus`
@@ -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.0",
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.14.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.14.0",
58
+ "@nxgt/janus": "^0.16.0",
59
59
  "hono": "^4.13.4",
60
60
  "typescript": "^6.0.3"
61
61
  }