@nxgt/janus-hono 0.5.0 → 0.5.1

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,110 @@ 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', 'no-referrer'); // the URL holds the token: send it nowhere
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
+ - **Answer the `POST` with a `303`**, so that reloading the page does not
537
+ post the spent token again. The cookie that `sendSession` set is sent with
538
+ the redirect.
539
+
540
+ `confirm` spends the link on its first call, whether that call signs the
541
+ user in or is refused, and it marks the user's e-mail verified. To the user,
542
+ every `TOKEN_*` error means the same thing: ask for another link. The rare
543
+ `VERSION_CONFLICT` means the same too; it happens when the user record was
544
+ written while the e-mail was being proved.
545
+
546
+ ### With a second factor
547
+
548
+ With `janus({ secondFactor })`, a user whose factor is active gets a
549
+ **challenge** from `magicLink.confirm`, not a session. On a type with a
550
+ password, passing that answer straight to `sendSession` no longer compiles.
551
+ Narrow on `status`, and hand the challenge to the second factor's cookie:
552
+
553
+ ```ts
554
+ app.post('/sign-in/link', csrf({ origin: ORIGIN }), async (c) => {
555
+ const token = String((await c.req.parseBody()).token ?? '');
556
+ const result = await auth.magicLink.confirm(token);
557
+ if (result.status === 'secondFactor') {
558
+ setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
559
+ return c.redirect('/sign-in/second-factor', 303); // a page of yours whose form posts the app's code to /sign-in/code
560
+ }
561
+ sendSession(c, auth, result);
562
+ return c.redirect('/', 303);
563
+ });
564
+ ```
565
+
566
+ [`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail) sends
567
+ this e-mail with `mail.magicLink(issued)`, building its button with
568
+ `links.magicLink(token)`. Point `links.magicLink` at the `GET` route above.
569
+ For the lifetime and every refusal, see [`@nxgt/janus`'s sign-in link
570
+ guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/magic-link.md).
571
+
468
572
  ## A step-up
469
573
 
470
574
  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`
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.1",
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.15.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.15.0",
59
59
  "hono": "^4.13.4",
60
60
  "typescript": "^6.0.3"
61
61
  }