@nxgt/janus-hono 0.3.0 → 0.3.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
@@ -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 a `CODE_INVALID` from `secondFactor.confirm` — what the client can act on, and nothing else |
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 second factor, guarded routes
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 |
@@ -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 |
@@ -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)
@@ -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)
@@ -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.0",
3
+ "version": "0.3.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.5.0",
52
+ "@nxgt/janus": "^0.6.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.5.0",
58
+ "@nxgt/janus": "^0.6.0",
59
59
  "hono": "^4.13.4",
60
60
  "typescript": "^6.0.3"
61
61
  }