@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 +1 -1
- package/docs/README.md +1 -1
- package/docs/guide/routes.md +111 -0
- package/docs/roadmap.md +4 -0
- package/docs/troubleshooting.md +73 -5
- package/package.json +3 -3
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 |
|
package/docs/guide/routes.md
CHANGED
|
@@ -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`
|
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
|
}
|