@nxgt/janus-hono 0.3.0 → 0.3.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 +2 -2
- package/docs/README.md +1 -1
- package/docs/guide/routes.md +113 -1
- package/docs/troubleshooting.md +19 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ declarations import without extensions, so `nodenext` is not supported.
|
|
|
51
51
|
| `signOut(c, auth)` | Revokes the session the request presents and clears the cookie, whatever the answer. `false` when the request presented no session, or an unknown one |
|
|
52
52
|
| `janusErrors({ report?, fallback? })` | An `app.onError` handler: every `JanusError` answered with `statusOf(code)` and `bodyOf(error)`; anything else to `fallback`, or to Hono's own handling. `report(error, c)` sees every one answered 5xx first — `STORE_FAILED` and the like, for your logs |
|
|
53
53
|
| `statusOf(code)` | The status a code deserves: `STORE_FAILED` 503, `CREDENTIALS_INVALID` and `CODE_INVALID` 401, `USER_INACTIVE` 403, `LOGIN_TAKEN`, `SECOND_FACTOR_NOT_ENROLLED` and `SECOND_FACTOR_ACTIVE` 409, … Exhaustive over `JanusErrorCode` |
|
|
54
|
-
| `bodyOf(error)` | `{ code }`, plus `issues` for `USER_INVALID`, `minLength` for `PASSWORD_TOO_SHORT` and `attemptsLeft` for
|
|
54
|
+
| `bodyOf(error)` | `{ code }`, plus `issues` for `USER_INVALID`, `minLength` for `PASSWORD_TOO_SHORT` and `attemptsLeft` for any `CODE_INVALID` that carries it — `secondFactor.confirm`'s and `signInCode.confirm`'s — what the client can act on, and nothing else |
|
|
55
55
|
| `SessionOptions<Type>` | `{ type?, required? }`, the options of `session()` — for a wrapper of your own |
|
|
56
56
|
| `SessionEnv<typeof auth, Type?, Required?>` | The `Env` `session()` sets, for `new Hono<SessionEnv<typeof auth>>()` |
|
|
57
57
|
| `UserOfAuth<typeof auth>` | The users an instance knows, as a union narrowed by `user.type` |
|
|
@@ -223,7 +223,7 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
|
|
|
223
223
|
|
|
224
224
|
## Documentation
|
|
225
225
|
|
|
226
|
-
- [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in and of a
|
|
226
|
+
- [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
|
|
227
227
|
- [Troubleshooting](docs/troubleshooting.md) — by the symptom or message you see
|
|
228
228
|
- [Roadmap](docs/roadmap.md) — what is next, and what is not planned
|
|
229
229
|
|
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, 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, 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
|
@@ -142,6 +142,9 @@ With `secondFactor` configured, `signIn` may answer a challenge instead of a
|
|
|
142
142
|
session: narrow on `status` before `sendSession` — see
|
|
143
143
|
[A second factor](#a-second-factor).
|
|
144
144
|
|
|
145
|
+
To sign in with no password, by a code sent to the user's e-mail, see
|
|
146
|
+
[A code sent by e-mail](#a-code-sent-by-e-mail).
|
|
147
|
+
|
|
145
148
|
The refusals need no `try`: `janusErrors()` answers them.
|
|
146
149
|
|
|
147
150
|
| Thrown | Answered |
|
|
@@ -168,7 +171,7 @@ return c.json({
|
|
|
168
171
|
## A second factor
|
|
169
172
|
|
|
170
173
|
With `janus({ secondFactor })`, `signIn` answers either a session or a
|
|
171
|
-
**challenge** — `{ status: 'secondFactor', challenge, expiresAt }` — and
|
|
174
|
+
**challenge** — `{ status: 'secondFactor', challenge, expiresAt, userId }` — and
|
|
172
175
|
`sendSession` only takes the first. **Narrow on `status` before
|
|
173
176
|
`sendSession`**: passing `signIn`'s answer straight in no longer compiles,
|
|
174
177
|
because a challenge has no `token` and no `session`.
|
|
@@ -264,6 +267,114 @@ app.delete('/account/second-factor', session(auth, { required: true }), async (c
|
|
|
264
267
|
The key rotation, the attempts and the replay rules are
|
|
265
268
|
[`@nxgt/janus`'s second factor guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/second-factor.md).
|
|
266
269
|
|
|
270
|
+
## A code sent by e-mail
|
|
271
|
+
|
|
272
|
+
A sign-in by e-mailed code is two routes, built like
|
|
273
|
+
[the second factor's](#a-second-factor): the first asks for a code and keeps
|
|
274
|
+
the **challenge** in a cookie scoped to the second, which takes the code and
|
|
275
|
+
opens the session. Sending the e-mail is yours.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import { randomBytes } from 'node:crypto';
|
|
279
|
+
import { sendSession } from '@nxgt/janus-hono';
|
|
280
|
+
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
|
281
|
+
|
|
282
|
+
const CODE_CHALLENGE = 'sign-in-code';
|
|
283
|
+
const codeScope = { path: '/sign-in/email', httpOnly: true, secure: true, sameSite: 'Strict' } as const;
|
|
284
|
+
|
|
285
|
+
app.post('/sign-in/email', async (c) => {
|
|
286
|
+
const { email } = await c.req.json();
|
|
287
|
+
const issued = await auth.signInCode.request(email);
|
|
288
|
+
if (issued !== null) {
|
|
289
|
+
void mailer.send(issued.email, `Your sign-in code: ${issued.code}`); // the code only; not awaited
|
|
290
|
+
}
|
|
291
|
+
// the same answer either way: a decoy challenge when nobody holds the e-mail
|
|
292
|
+
const challenge = issued?.challenge ?? randomBytes(32).toString('base64url');
|
|
293
|
+
setCookie(c, CODE_CHALLENGE, challenge, { ...codeScope, maxAge: 600 });
|
|
294
|
+
return c.json({ next: 'code' }, 202);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
app.post('/sign-in/email/code', async (c) => {
|
|
298
|
+
const { code } = await c.req.json();
|
|
299
|
+
const challenge = getCookie(c, CODE_CHALLENGE);
|
|
300
|
+
if (challenge === undefined) return c.json({ code: 'TOKEN_UNKNOWN' }, 400);
|
|
301
|
+
const user = sendSession(c, auth, await auth.signInCode.confirm(challenge, code));
|
|
302
|
+
deleteCookie(c, CODE_CHALLENGE, codeScope);
|
|
303
|
+
return c.json({ id: user.id });
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**The request route answers the same whoever asked** — the same status, the
|
|
308
|
+
same body, and a cookie either way. `signInCode.request` answers `null` for
|
|
309
|
+
an address nobody holds, and a route that answered differently, or set no
|
|
310
|
+
cookie, would tell anyone which addresses have an account. The decoy is 32
|
|
311
|
+
random bytes, the shape of a real challenge; confirming it is
|
|
312
|
+
`TOKEN_UNKNOWN`. `maxAge: 600` is the default ten minutes of
|
|
313
|
+
`tokens.signInCode`, written out so both cookies match. Not awaiting the
|
|
314
|
+
mailer keeps the answer's time from telling either. Rate-limit this route
|
|
315
|
+
per address and per client: every call sends an e-mail.
|
|
316
|
+
|
|
317
|
+
The code route still tells a decoy apart: a wrong code against it is 400
|
|
318
|
+
`TOKEN_UNKNOWN`, against a real challenge 401 `CODE_INVALID` with
|
|
319
|
+
`attemptsLeft`. Where your users' addresses must stay secret, answer every
|
|
320
|
+
refusal of that route alike, before `janusErrors()` sees it:
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
import { JanusError } from '@nxgt/janus';
|
|
324
|
+
|
|
325
|
+
app.onError((error, c) =>
|
|
326
|
+
error instanceof JanusError && error.code !== 'STORE_FAILED' && c.req.path === '/sign-in/email/code'
|
|
327
|
+
? c.json({ code: 'CODE_INVALID' }, 401) // no attemptsLeft: it would tell a real challenge from a decoy
|
|
328
|
+
: answer(error, c),
|
|
329
|
+
);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
with `answer` the `janusErrors()` of [Wiring](#wiring).
|
|
333
|
+
|
|
334
|
+
The code goes in the e-mail, **the challenge never does**: not in the
|
|
335
|
+
e-mail, not in a link, not in a log.
|
|
336
|
+
|
|
337
|
+
A wrong code leaves the cookie in place, so the visitor types it again.
|
|
338
|
+
`janusErrors()` answers what `confirm` throws, with the statuses above:
|
|
339
|
+
|
|
340
|
+
| Thrown | Answered |
|
|
341
|
+
| --- | --- |
|
|
342
|
+
| `CODE_INVALID` | 401 `{ code, attemptsLeft }` — the attempts the challenge has left, `0` once the fifth wrong code spent it |
|
|
343
|
+
| `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED` | 400 `{ code }` — offer to send a new code |
|
|
344
|
+
| `TOKEN_STALE` | 400 `{ code }` — the e-mail changed since the code was sent: send a new one |
|
|
345
|
+
| `USER_INACTIVE` | 403 `{ code }` |
|
|
346
|
+
|
|
347
|
+
`confirm` marks the user's e-mail verified: the code reached the inbox.
|
|
348
|
+
|
|
349
|
+
### With a second factor
|
|
350
|
+
|
|
351
|
+
With `janus({ secondFactor })`, a user whose factor is active gets a
|
|
352
|
+
**challenge** from `signInCode.confirm`, not a session — the code proves the
|
|
353
|
+
e-mail, not the factor — and passing its answer straight to `sendSession`
|
|
354
|
+
no longer compiles on a type with a password. Narrow on `status`, and hand
|
|
355
|
+
the challenge to the second factor's cookie, so
|
|
356
|
+
[`/sign-in/code`](#a-second-factor) takes it from there:
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
app.post('/sign-in/email/code', async (c) => {
|
|
360
|
+
const { code } = await c.req.json();
|
|
361
|
+
const challenge = getCookie(c, CODE_CHALLENGE);
|
|
362
|
+
if (challenge === undefined) return c.json({ code: 'TOKEN_UNKNOWN' }, 400);
|
|
363
|
+
const result = await auth.signInCode.confirm(challenge, code);
|
|
364
|
+
deleteCookie(c, CODE_CHALLENGE, codeScope);
|
|
365
|
+
if (result.status === 'secondFactor') {
|
|
366
|
+
setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
|
|
367
|
+
return c.json({ next: 'secondFactor' });
|
|
368
|
+
}
|
|
369
|
+
const user = sendSession(c, auth, result);
|
|
370
|
+
return c.json({ id: user.id });
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
A user type with no password has no second factor, so its `confirm` always
|
|
375
|
+
answers a session. The attempts, the lifetime and every refusal are
|
|
376
|
+
[`@nxgt/janus`'s sign-in code guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md).
|
|
377
|
+
|
|
267
378
|
## Sign-out
|
|
268
379
|
|
|
269
380
|
```ts
|
|
@@ -343,5 +454,6 @@ as staff replaces the patient's cookie.
|
|
|
343
454
|
- [`@nxgt/janus` — sessions](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sessions.md) — lifespans, renewal, the cookie's attributes
|
|
344
455
|
- [`@nxgt/janus` — errors](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md) — every code
|
|
345
456
|
- [`@nxgt/janus` — the second factor](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/second-factor.md) — keys, states, attempts
|
|
457
|
+
- [`@nxgt/janus` — sign-in codes](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md) — requesting, the challenge, attempts, passwordless types
|
|
346
458
|
- [Guarded routes and writing tuples](permissions.md) — `permission()`, `provide()`
|
|
347
459
|
- [Troubleshooting](../troubleshooting.md)
|
package/docs/troubleshooting.md
CHANGED
|
@@ -29,7 +29,7 @@ for what causes each.
|
|
|
29
29
|
- [`401` with an empty body, for a signed-in user](#401-with-an-empty-body-for-a-signed-in-user)
|
|
30
30
|
- [`503 {"code":"STORE_FAILED"}` on every route](#503-codestore_failed-on-every-route)
|
|
31
31
|
- [`400 {"code":"HASH_UNSUPPORTED"}` on sign-in](#400-codehash_unsupported-on-sign-in)
|
|
32
|
-
- [`401 {"code":"CODE_INVALID","attemptsLeft":<n>}` on the code form](#401-codecode_invalidattemptsleftn-on-the-code-form)
|
|
32
|
+
- [`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
|
|
33
33
|
- [`409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`](#409-codesecond_factor_active-or-409-codesecond_factor_not_enrolled)
|
|
34
34
|
- [The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back)
|
|
35
35
|
- [A bearer client holds an expiry earlier than the session's](#a-bearer-client-holds-an-expiry-earlier-than-the-sessions)
|
|
@@ -155,7 +155,7 @@ Also: `Type 'SecondFactorRequired' is missing the following properties …: toke
|
|
|
155
155
|
given a `secondFactor`.
|
|
156
156
|
|
|
157
157
|
**Why:** `signIn` then answers a session, or `{ status: 'secondFactor',
|
|
158
|
-
challenge, expiresAt }` for a user whose second factor is active — and a
|
|
158
|
+
challenge, expiresAt, userId }` for a user whose second factor is active — and a
|
|
159
159
|
challenge is no session to send.
|
|
160
160
|
|
|
161
161
|
**Fix:** switch on `status`, and send the session `secondFactor.confirm`
|
|
@@ -318,7 +318,8 @@ Also `401 {"code":"CODE_INVALID"}`, with no `attemptsLeft`, from
|
|
|
318
318
|
|
|
319
319
|
**When:** a route calling `secondFactor.confirm(challenge, code)` or
|
|
320
320
|
`secondFactor.activate(user, code)`, with a code that does not match or was
|
|
321
|
-
already used.
|
|
321
|
+
already used — or a route calling `signInCode.confirm(challenge, code)`, the
|
|
322
|
+
code sent by e-mail, with a code that does not match or is not six digits.
|
|
322
323
|
|
|
323
324
|
**Why:** `janusErrors()` answers a wrong code 401, like a wrong password. On
|
|
324
325
|
`confirm`, `attemptsLeft` is what the challenge has left of its five attempts;
|
|
@@ -327,6 +328,9 @@ at `0` it is spent, and the next call with it answers
|
|
|
327
328
|
is a clock off by more than about 30 seconds, or a code used already — see
|
|
328
329
|
[`@nxgt/janus`'s troubleshooting](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/troubleshooting.md#the-code-the-authenticator-app-shows-is-refused-with-code_invalid).
|
|
329
330
|
|
|
331
|
+
On `signInCode.confirm`, `attemptsLeft` counts the same five attempts, and
|
|
332
|
+
at `0` the challenge is spent too: the right code no longer works with it.
|
|
333
|
+
|
|
330
334
|
**Fix:** read `attemptsLeft` in the client: ask again while it is above `0`,
|
|
331
335
|
and go back to the sign-in form once it is `0`.
|
|
332
336
|
|
|
@@ -339,6 +343,18 @@ if (response.status === 401) {
|
|
|
339
343
|
}
|
|
340
344
|
```
|
|
341
345
|
|
|
346
|
+
For the code sent by e-mail, request a new code once it is `0` — a new
|
|
347
|
+
challenge, with five attempts of its own:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
const response = await fetch('/sign-in/email/code', { method: 'POST', body: JSON.stringify({ code }) });
|
|
351
|
+
if (response.status === 401) {
|
|
352
|
+
const { attemptsLeft } = await response.json();
|
|
353
|
+
if (attemptsLeft === 0) showEmailForm(); // POST /sign-in/email again: a new code
|
|
354
|
+
else showCodeForm(`Wrong code — ${attemptsLeft} attempts left`);
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
342
358
|
### `409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`
|
|
343
359
|
|
|
344
360
|
**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.3.
|
|
3
|
+
"version": "0.3.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.7.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.7.0",
|
|
59
59
|
"hono": "^4.13.4",
|
|
60
60
|
"typescript": "^6.0.3"
|
|
61
61
|
}
|