@nxgt/janus-hono 0.7.1 → 0.7.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/README.md +1 -1
- package/docs/guide/routes.md +71 -29
- package/package.json +3 -3
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 or link, the device cookie of a sign-in from a new device, 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, accepting an invitation, the device cookie of a sign-in from a new device, 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 |
|
package/docs/guide/routes.md
CHANGED
|
@@ -379,22 +379,25 @@ the **challenge** in a cookie scoped to the second, which takes the code and
|
|
|
379
379
|
opens the session. Sending the e-mail is yours.
|
|
380
380
|
|
|
381
381
|
```ts
|
|
382
|
-
import { randomBytes } from 'node:crypto';
|
|
383
382
|
import { sendSession } from '@nxgt/janus-hono';
|
|
384
383
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
|
385
384
|
|
|
386
385
|
const CODE_CHALLENGE = 'sign-in-code';
|
|
387
386
|
const codeScope = { path: '/sign-in/email', httpOnly: true, secure: true, sameSite: 'Strict' } as const;
|
|
388
387
|
|
|
388
|
+
/** Runs `work` off the request, and reports what it throws: nobody else will see it. */
|
|
389
|
+
const offRequest = (work: () => Promise<unknown>): void => void work().catch(reportError);
|
|
390
|
+
|
|
389
391
|
app.post('/sign-in/email', async (c) => {
|
|
390
392
|
const { email } = await c.req.json();
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
393
|
+
// counted, nobody looked up, a challenge minted: MAIL_THROTTLED → 429 by janusErrors()
|
|
394
|
+
const pending = await auth.signInCode.prepare(email);
|
|
395
|
+
offRequest(async () => {
|
|
396
|
+
const issued = await pending.send(); // the lookup and the code, off the visitor's request
|
|
397
|
+
if (issued !== null) await mailer.send(issued.email, `Your sign-in code: ${issued.code}`); // the code only
|
|
398
|
+
});
|
|
399
|
+
// the same answer either way: a challenge for everybody, TOKEN_UNKNOWN when nobody holds the e-mail
|
|
400
|
+
setCookie(c, CODE_CHALLENGE, pending.challenge, { ...codeScope, maxAge: 600 });
|
|
398
401
|
return c.json({ next: 'code' }, 202);
|
|
399
402
|
});
|
|
400
403
|
|
|
@@ -409,17 +412,20 @@ app.post('/sign-in/email/code', async (c) => {
|
|
|
409
412
|
```
|
|
410
413
|
|
|
411
414
|
**The request route answers the same whoever asked** — the same status, the
|
|
412
|
-
same body, and a cookie either way. `signInCode.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
`
|
|
418
|
-
|
|
419
|
-
each address — five requests per 10 minutes, then `MAIL_THROTTLED
|
|
420
|
-
`janusErrors()` answers 429 with
|
|
421
|
-
works); rate-limit the route per
|
|
422
|
-
many addresses.
|
|
415
|
+
same body, and a cookie either way, in the same time. `signInCode.prepare`
|
|
416
|
+
(`@nxgt/janus` 0.20) counts the address and mints the challenge **without
|
|
417
|
+
looking anybody up**, so it makes the same store calls for any address;
|
|
418
|
+
`send()`, which looks the address up and issues the code under that
|
|
419
|
+
challenge, runs after the answer. For an address nobody holds `send()`
|
|
420
|
+
answers `null`, and the challenge confirms as `TOKEN_UNKNOWN`. `maxAge: 600`
|
|
421
|
+
is the default ten minutes of `tokens.signInCode`. `@nxgt/janus` throttles
|
|
422
|
+
each address — five requests per 10 minutes, then `MAIL_THROTTLED` from
|
|
423
|
+
`prepare`, in the visitor's request, which `janusErrors()` answers 429 with
|
|
424
|
+
`Retry-After` (the last code sent still works); rate-limit the route per
|
|
425
|
+
client yourself, since one client can ask for many addresses. `send()` runs
|
|
426
|
+
once: a second call is a `TypeError`, so a retry prepares again. With
|
|
427
|
+
`signInCode.request(email)` instead, the route would wait for the lookup —
|
|
428
|
+
its time would tell — and would set a random decoy challenge for `null`.
|
|
423
429
|
|
|
424
430
|
The code route still tells a decoy apart: a wrong code against it is 400
|
|
425
431
|
`TOKEN_UNKNOWN`, against a real challenge 401 `CODE_INVALID` with
|
|
@@ -488,6 +494,8 @@ A sign-in by e-mailed link takes three routes. The first asks for a link.
|
|
|
488
494
|
The link then opens **a page that spends nothing**, and that page's button
|
|
489
495
|
posts the token back to the third route, which signs the user in.
|
|
490
496
|
|
|
497
|
+
`offRequest` is the helper of [a code sent by e-mail](#a-code-sent-by-e-mail).
|
|
498
|
+
|
|
491
499
|
Never sign in on the link's `GET`. Mail scanners open every link in an
|
|
492
500
|
e-mail and would spend the token before the user clicks. Sending the e-mail
|
|
493
501
|
is your job.
|
|
@@ -502,10 +510,12 @@ const TOKEN = /^[A-Za-z0-9_-]{43}$/; // 32 bytes, base64url: the only thing echo
|
|
|
502
510
|
|
|
503
511
|
app.post('/sign-in/email/link', async (c) => {
|
|
504
512
|
const { email } = await c.req.json();
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
513
|
+
const pending = await auth.magicLink.prepare(email); // counted, nobody looked up: MAIL_THROTTLED → 429
|
|
514
|
+
offRequest(async () => { // offRequest: see A code sent by e-mail (#a-code-sent-by-e-mail)
|
|
515
|
+
// the lookup and the token, after the answer, so its time tells nothing; in production, a queue
|
|
516
|
+
const issued = await pending.send();
|
|
517
|
+
if (issued !== null) await mailer.send(issued.email, `${ORIGIN}/sign-in/link?token=${issued.token}`);
|
|
518
|
+
});
|
|
509
519
|
return c.body(null, 202); // the same answer either way: nothing of a link reaches the visitor
|
|
510
520
|
});
|
|
511
521
|
|
|
@@ -593,6 +603,36 @@ this e-mail with `mail.magicLink(issued)`, building its button with
|
|
|
593
603
|
For the lifetime and every refusal, see [`@nxgt/janus`'s sign-in link
|
|
594
604
|
guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/magic-link.md).
|
|
595
605
|
|
|
606
|
+
## Accepting an invitation
|
|
607
|
+
|
|
608
|
+
An invitation is the application's own proof: it issued the token, and the
|
|
609
|
+
person followed it. `auth.signInProven(userId)` signs them in on that proof
|
|
610
|
+
— no e-mail, no mail-throttle count, no link spent — and answers what
|
|
611
|
+
`signIn` answers, so `sendSession` and the second factor work as above.
|
|
612
|
+
There is no route of this package for it: the proof is yours to check, and
|
|
613
|
+
the id must come from your invitation's record, **never from the request**.
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
app.post('/invitation/accept', csrf({ origin: ORIGIN }), async (c) => {
|
|
617
|
+
const token = String((await c.req.parseBody()).token ?? '');
|
|
618
|
+
const invitation = await invitations.spend(hash(token), new Date()); // yours: once, unexpired
|
|
619
|
+
if (invitation === null) return c.json({ code: 'INVITATION_INVALID' }, 404);
|
|
620
|
+
|
|
621
|
+
const result = await auth.signInProven(invitation.userId, { device: deviceOf(c) });
|
|
622
|
+
if (result.status === 'secondFactor') {
|
|
623
|
+
setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
|
|
624
|
+
return c.redirect('/sign-in/second-factor', 303);
|
|
625
|
+
}
|
|
626
|
+
sendSession(c, auth, result);
|
|
627
|
+
return c.redirect('/', 303);
|
|
628
|
+
});
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Not `magicLink.request` then `confirm` at once: `request` counts against
|
|
632
|
+
the mail throttle per address, so five anonymous requests for the invitee's
|
|
633
|
+
address would answer this route 429. See [`@nxgt/janus`'s
|
|
634
|
+
sessions guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sessions.md#signing-in-on-your-own-proof--signinproven).
|
|
635
|
+
|
|
596
636
|
## The device cookie
|
|
597
637
|
|
|
598
638
|
With `janus({ devices })`, every sign-in can tell the user it came from a
|
|
@@ -771,7 +811,8 @@ cookie drops it too.
|
|
|
771
811
|
|
|
772
812
|
## E-mail verification and password reset
|
|
773
813
|
|
|
774
|
-
Sending the e-mail is yours; the one-time token goes in a link.
|
|
814
|
+
Sending the e-mail is yours; the one-time token goes in a link. `offRequest`
|
|
815
|
+
is the helper of [a code sent by e-mail](#a-code-sent-by-e-mail).
|
|
775
816
|
|
|
776
817
|
```ts
|
|
777
818
|
app.post('/verify-email', session(auth, { required: true }), async (c) => {
|
|
@@ -788,11 +829,12 @@ app.post('/verify-email/confirm', async (c) => {
|
|
|
788
829
|
|
|
789
830
|
app.post('/reset-password', async (c) => {
|
|
790
831
|
const { email } = await c.req.json();
|
|
791
|
-
const
|
|
792
|
-
|
|
793
|
-
await
|
|
794
|
-
|
|
795
|
-
|
|
832
|
+
const pending = await auth.resetPassword.prepare(email); // counted, nobody looked up: MAIL_THROTTLED → 429
|
|
833
|
+
offRequest(async () => { // offRequest: see A code sent by e-mail (#a-code-sent-by-e-mail)
|
|
834
|
+
const issued = await pending.send(); // the lookup and the link, off the visitor's request
|
|
835
|
+
if (issued !== null) await mailer.send(issued.email, `https://app.test/reset?token=${issued.token}`);
|
|
836
|
+
});
|
|
837
|
+
return c.body(null, 202); // the same answer, in the same time, either way: never say which e-mails exist
|
|
796
838
|
});
|
|
797
839
|
|
|
798
840
|
app.post('/reset-password/confirm', async (c) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus-hono",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.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.20.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.20.0",
|
|
59
59
|
"hono": "^4.13.4",
|
|
60
60
|
"typescript": "^6.0.3"
|
|
61
61
|
}
|