@nxgt/janus 0.5.0 → 0.6.0

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.
@@ -74,6 +74,13 @@ How the messages are shaped:
74
74
  - [`<call>: the <type> type does not sign in with a password, so it has no second factor`](#call-the-type-type-does-not-sign-in-with-a-password-so-it-has-no-second-factor)
75
75
  - `TOKEN_*`, `USER_INACTIVE` and `VERSION_CONFLICT` from `secondFactor.confirm`: in their entries above.
76
76
 
77
+ **Sign-in codes**
78
+ - [`TOKEN_STALE` — `<call>: the code was sent to an e-mail the user no longer has`](#token_stale--call-the-code-was-sent-to-an-e-mail-the-user-no-longer-has)
79
+ - [The code from an earlier e-mail is refused with `CODE_INVALID`](#the-code-from-an-earlier-e-mail-is-refused-with-code_invalid)
80
+ - [`signInCode.request` answers `null` for a user who exists](#signincoderequest-answers-null-for-a-user-who-exists)
81
+ - [`TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`](#ts2339-property-signincode-does-not-exist-on-type-typeapi)
82
+ - `CODE_INVALID`, `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `USER_INACTIVE` and `VERSION_CONFLICT` from `signInCode.confirm`, and `TS2339` on its `token`: in their entries above.
83
+
77
84
  **Permissions**
78
85
  - [`PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`](#permission_depth--can-checking-typepermission-crossed-more-than-n-relations-without-an-answer)
79
86
  - [`permissions: this model was not made by defineModel() …`](#permissions-this-model-was-not-made-by-definemodel--pass-what-definemodel-answered)
@@ -291,7 +298,7 @@ Also `janus: "<name>" cannot name a user type — janus() answers a method of th
291
298
 
292
299
  ### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
293
300
 
294
- The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword` and `secondFactor.challenge`. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
301
+ The same for `session.renewAfter`, `tokens.verifyEmail`, `tokens.resetPassword`, `tokens.signInCode` and `secondFactor.challenge`. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
295
302
 
296
303
  **When:** `janus({...})`.
297
304
  **Why:** a duration is a number of milliseconds, or a number followed by one unit. `'30 m'` compiles — TypeScript's `${number}` accepts the space — and is refused here.
@@ -492,8 +499,8 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
492
499
 
493
500
  ### `USER_INACTIVE` — `<call>: the user is inactive`
494
501
 
495
- **When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm`, for a user set inactive after `signIn` asked for a code.
496
- **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive. On `secondFactor.confirm`, the challenge is spent: reactivating the user does not revive it.
502
+ **When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm`, for a user set inactive after `signIn` asked for a code, and `signInCode.confirm`, for a user set inactive after the code was sent.
503
+ **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive — and after the code, so only somebody who read the e-mail does: `signInCode.request` answers `null` for an inactive user, as for nobody. On either `confirm`, the challenge is spent: reactivating the user does not revive it.
497
504
  **Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
498
505
 
499
506
  ### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
@@ -526,6 +533,28 @@ code. To give slower visitors more time:
526
533
  janus({ ..., secondFactor: { issuer: 'Acme', keys, challenge: '10m' } });
527
534
  ```
528
535
 
536
+ **On `signInCode.confirm`**, the messages name the challenge
537
+ `signInCode.request` answered: `signInCode.confirm: no such challenge`,
538
+ `signInCode.confirm: the challenge was already used`,
539
+ `signInCode.confirm: the challenge has expired`.
540
+
541
+ **When:** `signInCode.confirm(challenge, code)`.
542
+ **Why:** a challenge lives ten minutes and takes five codes. It is spent by
543
+ the code that signs the user in, by the fifth wrong code, and by a refusal
544
+ that ends it (`TOKEN_STALE`, `USER_INACTIVE`). `TOKEN_UNKNOWN` also covers a
545
+ challenge whose user was deleted, one confirmed through another user type's
546
+ `signInCode`, the decoy challenge of a `request` that answered `null`, and
547
+ the two arguments swapped. Another type's `confirm` compares no code and
548
+ spends nothing, but it has already cost one of the challenge's five
549
+ attempts: the attempt is counted before the type is known. The challenge is
550
+ left for its own type, with one attempt fewer.
551
+ **Fix:** answer 400 and offer to send a new code. To give slower inboxes
552
+ more time:
553
+
554
+ ```ts
555
+ janus({ ..., tokens: { signInCode: '15m' } });
556
+ ```
557
+
529
558
  ### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
530
559
 
531
560
  **When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail.
@@ -582,7 +611,8 @@ const page = await access.list(user, 'view', 'record', {
582
611
 
583
612
  ## Second factor
584
613
 
585
- Every entry here needs `janus({ ..., secondFactor })`. The messages start with
614
+ Every entry here needs `janus({ ..., secondFactor })`, but for the
615
+ `signInCode.confirm` paragraph of `CODE_INVALID`. The messages start with
586
616
  `secondFactor.enroll`, `secondFactor.activate`, `secondFactor.confirm` or
587
617
  `signIn` — prefixed by the type with several user types:
588
618
  `staff.secondFactor.confirm: …`. `secondFactor.confirm` also rejects with
@@ -595,7 +625,7 @@ each of those entries has a paragraph for it.
595
625
 
596
626
  The same for `session` and `user`.
597
627
 
598
- **When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor`.
628
+ **When:** `tsc`, wherever `signIn`'s answer is read, once `janus()` is given a `secondFactor` — and `signInCode.confirm`'s, on a user type with a password.
599
629
  **Why:** `signIn` then answers one of two shapes: `{ status: 'signedIn', user, session, token }`, or `{ status: 'secondFactor', challenge, expiresAt }` for a user whose second factor is active — the password alone opens no session for them. Without `secondFactor`, `signIn` still answers a session.
600
630
  **Fix:** switch on `status`:
601
631
 
@@ -613,11 +643,25 @@ const signedIn = await auth.secondFactor.confirm(challenge, code); // { status:
613
643
 
614
644
  ### `CODE_INVALID` — `<call>: the code does not match, or was already used`
615
645
 
616
- `TokenError`, carrying `attemptsLeft` on `secondFactor.confirm`.
646
+ `TokenError`. The same message answers three calls; there is a paragraph
647
+ for each below.
648
+
649
+ **When:** `secondFactor.activate`, `secondFactor.confirm` or
650
+ `signInCode.confirm`, with a code that does not match.
651
+
652
+ **`secondFactor.activate(user, code)`**
653
+ **Why:** the code is wrong, or it was accepted before: a code is accepted
654
+ once. There is no challenge here: no `attemptsLeft`, and nothing is spent.
655
+ **Fix:** let the user try the next code the app shows. If it is the one on
656
+ screen, see [the next entry](#the-code-the-authenticator-app-shows-is-refused-with-code_invalid).
617
657
 
618
- **When:** `secondFactor.activate(user, code)` or `secondFactor.confirm(challenge, code)`.
619
- **Why:** the code is wrong, or it was accepted before: a code is accepted once. On `confirm`, every call costs one of the challenge's five attempts, counted before anything is checked. `attemptsLeft` is what remains; at `0`, the challenge is spent and the next `confirm` is `TOKEN_SPENT`. On `activate` there is no challenge, no `attemptsLeft`, and nothing is spent: the user tries the next code.
620
- **Fix:** answer 401 with `attemptsLeft`, and send the visitor back to sign in once it is `0`:
658
+ **`secondFactor.confirm(challenge, code)`**
659
+ **Why:** the code is wrong, or it was accepted before. Every call costs one of
660
+ the challenge's five attempts, counted before anything is checked. The error
661
+ carries `attemptsLeft`, what remains; at `0`, the challenge is spent and the
662
+ next `confirm` is `TOKEN_SPENT`.
663
+ **Fix:** answer 401 with `attemptsLeft`, and send the visitor back to sign in
664
+ once it is `0`:
621
665
 
622
666
  ```ts
623
667
  import { TokenError } from '@nxgt/janus';
@@ -632,7 +676,34 @@ try {
632
676
  }
633
677
  ```
634
678
 
635
- If the code the user typed is the one their app shows, see the next entry.
679
+ **`signInCode.confirm(challenge, code)`** — the e-mailed code.
680
+ **Why:** the code is not the one sent with this challenge, or is not six
681
+ digits — a space, a dash, a code pasted with its label. The error carries
682
+ `attemptsLeft` and the `userId` of the user the code was sent to. Every call
683
+ costs one of the challenge's five attempts, counted before the code is
684
+ compared — a call through another user type's `signInCode` too, although it
685
+ answers `TOKEN_UNKNOWN`. At `0` the challenge is spent, and the next
686
+ `confirm` is `TOKEN_SPENT`, even with the right code. Codes sent at once past
687
+ the fifth attempt are all refused, the right one included.
688
+ **Fix:** answer 401 with `attemptsLeft`, and request a new code once it is
689
+ `0`. Strip what the visitor may have typed around the digits before calling
690
+ `confirm`:
691
+
692
+ ```ts
693
+ import { TokenError } from '@nxgt/janus';
694
+
695
+ try {
696
+ return await auth.signInCode.confirm(challenge, code.replace(/\D/g, ''));
697
+ } catch (error) {
698
+ if (error instanceof TokenError && error.code === 'CODE_INVALID') {
699
+ return Response.json({ code: error.code, attemptsLeft: error.attemptsLeft }, { status: 401 });
700
+ }
701
+ throw error;
702
+ }
703
+ ```
704
+
705
+ If the visitor typed the code from the e-mail correctly, see
706
+ [the code from an earlier e-mail](#the-code-from-an-earlier-e-mail-is-refused-with-code_invalid).
636
707
 
637
708
  ### The code the authenticator app shows is refused with `CODE_INVALID`
638
709
 
@@ -741,6 +812,89 @@ A `TypeError`.
741
812
 
742
813
  ---
743
814
 
815
+ ## Sign-in codes
816
+
817
+ The messages start with `signInCode.confirm` — prefixed by the type with
818
+ several user types: `patient.signInCode.confirm: …`. `signInCode.request`
819
+ throws nothing but `STORE_FAILED`: an e-mail it cannot sign in is `null`.
820
+ `signInCode.confirm` also rejects with
821
+ [`CODE_INVALID`](#code_invalid--call-the-code-does-not-match-or-was-already-used),
822
+ [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired),
823
+ [`USER_INACTIVE`](#user_inactive--call-the-user-is-inactive) and
824
+ [`VERSION_CONFLICT`](#version_conflict--call-expected-version-n-found-m);
825
+ each of those entries has a paragraph for it.
826
+ [The sign-in code guide](guide/sign-in-code.md) has the whole flow.
827
+
828
+ ### `TOKEN_STALE` — `<call>: the code was sent to an e-mail the user no longer has`
829
+
830
+ `TokenError`, carrying the `userId`.
831
+
832
+ **When:** `signInCode.confirm`, after the user's e-mail was changed — by
833
+ `update`, or a patch naming the e-mail field — since the code was sent.
834
+ **Why:** the code proves an address, and signing in with it would mark as
835
+ verified an address the user no longer holds. The challenge is spent.
836
+ **Fix:** answer 400, and request a new code: it goes to the current address.
837
+
838
+ ```ts
839
+ const issued = await auth.signInCode.request(currentEmail);
840
+ ```
841
+
842
+ ### The code from an earlier e-mail is refused with `CODE_INVALID`
843
+
844
+ **When:** the visitor asked for a code twice — pressed "send again", or
845
+ opened the form in two tabs — and typed the code of the first e-mail.
846
+ **Why:** every `request` issues a new code **with its own challenge**, and a
847
+ code is checked against the challenge it was sent with. The cookie or the
848
+ form field now holds the second challenge, so the first code does not
849
+ match it — and costs an attempt.
850
+ **Fix:** tell the visitor that only the last code sent works, and put the
851
+ time it was sent in the e-mail's subject or text so they can tell the
852
+ e-mails apart. The earlier challenge is not revoked: it still expires on its
853
+ own.
854
+
855
+ ### `signInCode.request` answers `null` for a user who exists
856
+
857
+ **When:** `signInCode.request(email)`, for an address you can see in the
858
+ database.
859
+ **Why**, in the order to check:
860
+
861
+ 1. **The user is inactive.** An inactive user gets no code, and the answer
862
+ is the same as for nobody.
863
+ 2. **It is another user type.** `clinic.patient.signInCode.request` looks
864
+ among patients only; the same address may hold a user of another type.
865
+ 3. **The address is not the type's e-mail field.** A type whose `email`
866
+ option names `contact` is looked up by `contact`. A login that looks like
867
+ an e-mail — a `username` of `ada@example.com` — is not an e-mail, and is
868
+ `null` too.
869
+
870
+ The e-mail is trimmed and lowercased before the lookup, so case and
871
+ surrounding spaces are never the cause.
872
+ **Fix:** `setActive(user, true)`; call the right type's `signInCode`; name
873
+ the field with `email: 'contact'`. Do not tell the visitor which case it
874
+ was — the route answers the same page either way.
875
+
876
+ ### `TS2339: Property 'signInCode' does not exist on type 'TypeApi<…>'.`
877
+
878
+ Also `Property 'signInCode' does not exist on type 'Janus<…>'.`, with the
879
+ single-type form, `janus({ user })`.
880
+
881
+ **When:** `tsc`, on `auth.signInCode` or `clinic.<type>.signInCode`.
882
+ **Why:** the user type has no e-mail: no field called `email`, and no
883
+ `email` option naming one. A code has nowhere to be sent, so the flow is
884
+ absent from the type.
885
+ **Fix:** name the field that holds the e-mail:
886
+
887
+ ```ts
888
+ janus({
889
+ users: {
890
+ staff: { schema: Staff, password: { login: 'username' }, email: 'workEmail' },
891
+ },
892
+ ...
893
+ });
894
+ ```
895
+
896
+ ---
897
+
744
898
  ## Permissions
745
899
 
746
900
  ### `PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",