@nxgt/janus 0.15.0 → 0.16.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.
package/README.md CHANGED
@@ -589,6 +589,10 @@ every user type with a password.
589
589
  and key the recovery codes' hashes. The first seals and every key opens, so
590
590
  keys rotate: put the new one first, keep the old one until no secret is
591
591
  sealed and no recovery code hashed with it.
592
+ - **The first proof of an e-mail by a sign-in code or link removes the
593
+ factor too**, active or waiting, with its recovery codes: whoever enrolled
594
+ it had not proved the address. Send `verifyEmail` before offering
595
+ `enroll`, and an owner's factor is never dropped this way.
592
596
  - **Asking for a password or a code before `enroll`, `disable` or
593
597
  `regenerateRecoveryCodes` is your policy**, not the library's — a recent
594
598
  `session.authenticatedAt` is one rule. `regenerateRecoveryCodes` asks for
@@ -632,10 +636,19 @@ A six-digit code sent to the user's e-mail signs them in, with no password.
632
636
  attempts: a wrong code is `CODE_INVALID` with `attemptsLeft`, and the fifth
633
637
  spends it. An e-mail changed since is `TOKEN_STALE`; a user deactivated
634
638
  since is `USER_INACTIVE`.
635
- - **An active second factor is still asked for**: with `secondFactor`
636
- configured, `confirm` answers `SignInResult` on a type with a password, and
637
- a user whose factor is active gets a challenge for `secondFactor.confirm`
638
- — switch on `status`, as after `signIn`.
639
+ - **An e-mail proved for the first time drops the password and the second
640
+ factor, and signs out every session** before the new one opens, as
641
+ `resetPassword.confirm` does for the password: whoever registered the
642
+ address without holding its inbox keeps nothing, and the owner is asked
643
+ for no factor they never set. `user.passwordChanged` follows
644
+ `user.emailVerified` when a password was dropped, then
645
+ `user.secondFactorDisabled` when an active factor was. An e-mail already
646
+ verified changes nothing.
647
+ - **An active second factor is still asked for** on an e-mail already
648
+ verified: with `secondFactor` configured, `confirm` answers `SignInResult`
649
+ on a type with a password, and a user whose factor is active gets a
650
+ challenge for `secondFactor.confirm` — switch on `status`, as after
651
+ `signIn`.
639
652
 
640
653
  [The sign-in code guide](docs/guide/sign-in-code.md) has the request that
641
654
  tells nobody who exists, the challenge in a cookie, every error, and a test.
@@ -667,8 +680,10 @@ to type.
667
680
  - **`confirm(token)`** spends the token in one conditional write — of two
668
681
  confirmations at once, one signs in — marks the e-mail verified and opens
669
682
  the session. A link lives `'15m'` (`tokens.magicLink`); there are no
670
- attempts, since there is nothing to guess. `TOKEN_STALE`, `USER_INACTIVE`
671
- and an active second factor are handled as for a code.
683
+ attempts, since there is nothing to guess. `TOKEN_STALE`, `USER_INACTIVE`,
684
+ an active second factor and **an e-mail proved for the first time — the
685
+ password and the second factor dropped, every session signed out** — are
686
+ handled as for a code.
672
687
  - **Not ended by a password write, nor limited by the sign-in throttle**:
673
688
  the password proves nothing a link does, and the throttle counts
674
689
  passwords. Rate-limit `request` per address and per client.
@@ -744,10 +759,10 @@ await auth.signUp({ email, password }); // the listener has the event before thi
744
759
  | `user.created` | `create`, `signUp` |
745
760
  | `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm`, `magicLink.confirm` and `signInCode.confirm`, whose link or code proves the e-mail too — never for an e-mail already verified |
746
761
  | `user.passwordReset` | `resetPassword.confirm` |
747
- | `user.passwordChanged` | `changePassword`, `setPassword` — never a reset, which is `user.passwordReset` alone |
762
+ | `user.passwordChanged` | `changePassword`, `setPassword`; `signInCode.confirm` and `magicLink.confirm` when their first proof of the e-mail dropped a password — never a reset, which is `user.passwordReset` alone |
748
763
  | `user.emailChanged` | `update`, when it changed the e-mail — carrying `formerEmail`, the address before (`null` for none) |
749
764
  | `user.secondFactorEnabled` | `secondFactor.activate`, once the factor is active — not `enroll`, which leaves it waiting |
750
- | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an active factor — never for a user who had none, or one still waiting |
765
+ | `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an active factor; `signInCode.confirm` and `magicLink.confirm` when their first proof of the e-mail removed one, after `user.emailVerified` and any `user.passwordChanged` — never for a user who had none, or one still waiting |
751
766
  | `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes` — not `activate`, whose codes come with `user.secondFactorEnabled` |
752
767
  | `user.recoveryCodeUsed` | `secondFactor.recover`, once the recovery code is spent: a sign-in without the user's phone |
753
768
  | `user.deleted` | `delete`, once — a replay that deletes nobody sends nothing |
@@ -1024,19 +1039,27 @@ the user's click answers `TOKEN_SPENT`. Link to a page that spends nothing,
1024
1039
  whose button posts the token — and echo into that page only a token of the
1025
1040
  token's shape. Refuse that `POST` from another site — check its `Origin`,
1026
1041
  or `Sec-Fetch-Site: same-origin` — or any page can sign a visitor into an
1027
- account whose link it holds. Whoever opens the link signs in, on the device
1042
+ account whose link it holds. Send that page with
1043
+ `Referrer-Policy: strict-origin`, not `no-referrer`, which makes its form's
1044
+ `Origin` `null` and the check refuse its own button. Whoever opens the link signs in, on the device
1028
1045
  that opened it: where the sign-in must complete in the browser that asked,
1029
1046
  send a code.
1030
1047
 
1031
- **A link or a code proves an e-mail someone else may have registered.**
1032
- `signUp` does not wait for the address to be proved, and `signIn` does not
1033
- ask whether it was: whoever registered the address with a password of theirs
1034
- keeps that password, and their session, after its owner signs in by link or
1035
- code and the e-mail turns verified. Where a password signs in before the
1036
- e-mail is verified, require `verifyEmail` before `signIn` — or, on the
1037
- `user.emailVerified` those confirmations send, sign the user out everywhere
1038
- — the listener runs before the new session opens — and have them set a
1039
- password of their own.
1048
+ **The first sign-in by link or code drops the password and the second
1049
+ factor.** `signUp` does not wait for the address to be proved, so anyone can
1050
+ register somebody else's e-mail with a password of theirs, and enrol a second
1051
+ factor on their own phone. When `signInCode.confirm` or `magicLink.confirm`
1052
+ proves an e-mail never verified, it drops the password and the second factor
1053
+ — active or waiting, with its recovery codes — and signs out every session
1054
+ before opening its own — so a user who signed
1055
+ up, never verified, and then signs in by code has no password afterwards,
1056
+ and `signIn` answers `CREDENTIALS_INVALID` (`reason: 'noPassword'`). Offer
1057
+ `setPassword` after such a sign-in, or send `verifyEmail` at sign-up. An
1058
+ e-mail changed by `update` is unverified again, and its first proof by link
1059
+ or code drops both too. **So does a user who enrolled a factor before
1060
+ proving their own e-mail**: they sign in with no challenge, and enrol again
1061
+ — offer `secondFactor.enroll` after such a sign-in, or send `verifyEmail`
1062
+ before letting anyone enrol. An e-mail already verified keeps both.
1040
1063
 
1041
1064
  **Every `janus()` that signs users in needs the same `secondFactor`.** An
1042
1065
  instance without keys never signs in a user whose factor is active: `signIn`
@@ -28,8 +28,13 @@ export declare function keepOnlyLatest(context: Context, userId: string, kind: T
28
28
  * since it was sent and an inactive user, proves the e-mail — it reached the
29
29
  * inbox — and finishes as a password would.
30
30
  *
31
- * The proof is written under the version read, so an address changed since
32
- * is not the one proved: a `VERSION_CONFLICT` then, with the token spent.
31
+ * **An e-mail proved for the first time drops the password and the second
32
+ * factor, and revokes every session** before the new one opens
33
+ * (`./first-proof`): whoever signed up with the address without holding its
34
+ * inbox keeps nothing, and the owner is asked for no factor they never set.
35
+ * An e-mail already proved changes nothing. The proof is written under the
36
+ * version read, so an address changed since is not the one proved: a
37
+ * `VERSION_CONFLICT` then, with the token spent.
33
38
  */
34
39
  export declare function finishEmailSignIn(context: Context, type: ResolvedType, finish: Finish, signIn: {
35
40
  readonly user: UserRecord;
@@ -1 +1 @@
1
- {"version":3,"file":"email-sign-in.d.ts","sourceRoot":"","sources":["../../src/auth/email-sign-in.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,EACN,KAAK,OAAO,EACZ,KAAK,OAAO,EAGZ,MAAM,WAAW,CAAC;AAGnB,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAEvE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,uFAAuF;AACvF,MAAM,MAAM,MAAM,GAAG,CACpB,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,KACT,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC;AAEpC;;;;GAIG;AACH,wBAAsB,YAAY,CACjC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,YAAY,EAClB,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAG5B;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CACnC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACtC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE;IACP,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;CAChC,GACC,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAqBhC"}
1
+ {"version":3,"file":"email-sign-in.d.ts","sourceRoot":"","sources":["../../src/auth/email-sign-in.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,EAAE,KAAK,OAAO,EAAE,KAAK,OAAO,EAAiB,MAAM,WAAW,CAAC;AAGtE,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAEvE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,uFAAuF;AACvF,MAAM,MAAM,MAAM,GAAG,CACpB,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,KACT,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC;AAEpC;;;;GAIG;AACH,wBAAsB,YAAY,CACjC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,YAAY,EAClB,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAG5B;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CACnC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACtC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE;IACP,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;CAChC,GACC,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAYhC"}
@@ -9,7 +9,9 @@ import type { Context } from './context';
9
9
  * `signInCode.confirm`, which prove the e-mail too; never for an e-mail
10
10
  * already verified;
11
11
  * - `user.passwordReset` — by `resetPassword.confirm`;
12
- * - `user.passwordChanged` — by `changePassword` and `setPassword`; never by a
12
+ * - `user.passwordChanged` — by `changePassword` and `setPassword`, and by
13
+ * `signInCode.confirm` and `magicLink.confirm` when their first proof of
14
+ * the e-mail dropped a password, after `user.emailVerified`; never by a
13
15
  * reset, which is `user.passwordReset` alone;
14
16
  * - `user.emailChanged` — by `update`, when it changed the e-mail — compared
15
17
  * normalised, the same test that makes the new one unverified. The one
@@ -17,7 +19,10 @@ import type { Context } from './context';
17
19
  * - `user.secondFactorEnabled` — by `secondFactor.activate`, once the factor
18
20
  * is active: not by `enroll`, which leaves it waiting for its first code;
19
21
  * - `user.secondFactorDisabled` — by `secondFactor.disable`, when it removed
20
- * an active factor; never for a user who had none, or one still waiting;
22
+ * an active factor, and by `signInCode.confirm` and `magicLink.confirm`
23
+ * when their first proof of the e-mail removed one, after
24
+ * `user.emailVerified` and any `user.passwordChanged`; never for a user who
25
+ * had none, or one still waiting;
21
26
  * - `user.recoveryCodesRegenerated` — by `secondFactor.regenerateRecoveryCodes`:
22
27
  * the codes the user held stopped working. Not by `activate`, whose codes
23
28
  * come with `user.secondFactorEnabled`;
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/auth/events.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,EAAU,MAAM,WAAW,CAAC;AAC5C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,MAAM,aAAa,GACtB,cAAc,GACd,oBAAoB,GACpB,oBAAoB,GACpB,sBAAsB,GACtB,mBAAmB,GACnB,0BAA0B,GAC1B,2BAA2B,GAC3B,+BAA+B,GAC/B,uBAAuB,GACvB,cAAc,CAAC;AAElB;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACzB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC;IAChB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC;IACpB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED,wEAAwE;AACxE,MAAM,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE3E,oFAAoF;AACpF,wBAAgB,aAAa,CAC5B,MAAM,EAAE,OAAO,EACf,KAAK,EAAE,MAAM,GACX,iBAAiB,GAAG,IAAI,CAQ1B;AAED;;;;;;GAMG;AACH,wBAAsB,IAAI,CACzB,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,aAAa,EACnB,IAAI,EAAE;IAAE,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAChD,UAAU,EAAE,IAAI,EAChB,MAAM,GAAE,WAAgB,GACtB,OAAO,CAAC,IAAI,CAAC,CAqBf"}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/auth/events.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,EAAU,MAAM,WAAW,CAAC;AAC5C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,MAAM,aAAa,GACtB,cAAc,GACd,oBAAoB,GACpB,oBAAoB,GACpB,sBAAsB,GACtB,mBAAmB,GACnB,0BAA0B,GAC1B,2BAA2B,GAC3B,+BAA+B,GAC/B,uBAAuB,GACvB,cAAc,CAAC;AAElB;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACzB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC;IAChB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC;IACpB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED,wEAAwE;AACxE,MAAM,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE3E,oFAAoF;AACpF,wBAAgB,aAAa,CAC5B,MAAM,EAAE,OAAO,EACf,KAAK,EAAE,MAAM,GACX,iBAAiB,GAAG,IAAI,CAQ1B;AAED;;;;;;GAMG;AACH,wBAAsB,IAAI,CACzB,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,aAAa,EACnB,IAAI,EAAE;IAAE,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAChD,UAAU,EAAE,IAAI,EAChB,MAAM,GAAE,WAAgB,GACtB,OAAO,CAAC,IAAI,CAAC,CAqBf"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The first proof of an e-mail by a sign-in code or a sign-in link: the
3
+ * address reached an inbox no one had proved before, so whatever was set on
4
+ * the account before that proof is not the inbox holder's to keep.
5
+ *
6
+ * Anybody can sign up with somebody else's e-mail and a password of their
7
+ * own — and enrol a second factor on their own phone. Without this, the real
8
+ * holder signing in by e-mail later would verify the address and leave that
9
+ * password, that factor and their sessions working: locked out by a factor
10
+ * they never set, or signed in beside someone who still holds the account.
11
+ * So the write that proves the address also drops the password and the
12
+ * second factor with its recovery codes, and every session is revoked before
13
+ * the new one opens: the same ending as `resetPassword.confirm`, which
14
+ * proves the address too, plus the factor it keeps.
15
+ */
16
+ import type { ResolvedType } from './config';
17
+ import { type Context } from './context';
18
+ import type { UserRecord } from './port/types';
19
+ /**
20
+ * Proves `user`'s e-mail, never proved before, under the version read — an
21
+ * address changed since is not the one proved: a `VERSION_CONFLICT` then.
22
+ * Drops the password and the second factor — active or waiting, and its
23
+ * recovery codes with it — in the same write, and revokes every session and
24
+ * spends what the password opened — reset links and second-factor
25
+ * challenges — **both before the write and after it**.
26
+ *
27
+ * Before, so an outage there leaves the e-mail unproved and the owner's next
28
+ * code or link runs this again; after, so a sign-in landing in between is
29
+ * ended too. An outage after the write is the one that leaves a session live.
30
+ *
31
+ * `user.emailVerified` is sent, then `user.passwordChanged` when a password
32
+ * was dropped and `user.secondFactorDisabled` when an active factor was —
33
+ * the rule of `secondFactor.disable` — from a `finally`, so an outage ending
34
+ * the sessions still reports the write, which a retry would not make again.
35
+ */
36
+ export declare function proveFirstEmail(context: Context, type: ResolvedType, user: UserRecord, where: string): Promise<UserRecord>;
37
+ //# sourceMappingURL=first-proof.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"first-proof.d.ts","sourceRoot":"","sources":["../../src/auth/first-proof.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,EAAE,KAAK,OAAO,EAAa,MAAM,WAAW,CAAC;AAGpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG/C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CACpC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE,UAAU,EAChB,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,UAAU,CAAC,CAwCrB"}
@@ -51,7 +51,10 @@ export interface SignInCodeApi<U, Answer = SignedIn<U>> {
51
51
  request(email: string): Promise<IssuedCode<U> | null>;
52
52
  /**
53
53
  * Checks the code against its challenge, marks the e-mail verified —
54
- * the code reached the inbox — and signs the user in.
54
+ * the code reached the inbox — and signs the user in. **An e-mail
55
+ * proved for the first time drops the password and the second factor,
56
+ * and signs out every session** before the new one opens, as for a
57
+ * link. An e-mail already verified changes nothing.
55
58
  *
56
59
  * A challenge takes **five attempts**: a code that does not match is
57
60
  * `CODE_INVALID` with `attemptsLeft`, and the fifth spends it. An
@@ -1 +1 @@
1
- {"version":3,"file":"email-flows.d.ts","sourceRoot":"","sources":["../../../src/auth/types/email-flows.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAgB,MAAM,WAAW,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,0EAA0E;AAC1E,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,iCAAiC;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;CACzB;AAED,oFAAoF;AACpF,MAAM,WAAW,UAAU,CAAC,CAAC;IAC5B,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uCAAuC;IACvC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;CACjB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC;IACrD,QAAQ,CAAC,UAAU,EAAE;QACpB;;;;;;;;;;WAUG;QACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;QACtD;;;;;;;;;WASG;QACH,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;KAC1D,CAAC;CACF;AAED,uDAAuD;AACvD,MAAM,WAAW,cAAc,CAAC,CAAC;IAChC,QAAQ,CAAC,WAAW,EAAE;QACrB,yEAAyE;QACzE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;QAC1C;;;WAGG;QACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;KACnC,CAAC;CACF;AAED,sEAAsE;AACtE,MAAM,WAAW,gBAAgB,CAAC,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE;QACvB;;;;;WAKG;QACH,OAAO,CACN,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,CAAC,WAAW,GAAG;YAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;SAAE,CAAC,GAAG,IAAI,CAAC,CAAC;QACxD;;;;;;WAMG;QACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACF"}
1
+ {"version":3,"file":"email-flows.d.ts","sourceRoot":"","sources":["../../../src/auth/types/email-flows.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAgB,MAAM,WAAW,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,0EAA0E;AAC1E,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,iCAAiC;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;CACzB;AAED,oFAAoF;AACpF,MAAM,WAAW,UAAU,CAAC,CAAC;IAC5B,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uCAAuC;IACvC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;CACjB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC;IACrD,QAAQ,CAAC,UAAU,EAAE;QACpB;;;;;;;;;;WAUG;QACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;QACtD;;;;;;;;;;;;WAYG;QACH,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;KAC1D,CAAC;CACF;AAED,uDAAuD;AACvD,MAAM,WAAW,cAAc,CAAC,CAAC;IAChC,QAAQ,CAAC,WAAW,EAAE;QACrB,yEAAyE;QACzE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;QAC1C;;;WAGG;QACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;KACnC,CAAC;CACF;AAED,sEAAsE;AACtE,MAAM,WAAW,gBAAgB,CAAC,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE;QACvB;;;;;WAKG;QACH,OAAO,CACN,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,CAAC,WAAW,GAAG;YAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;SAAE,CAAC,GAAG,IAAI,CAAC,CAAC;QACxD;;;;;;WAMG;QACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;KACrD,CAAC;CACF"}
@@ -31,7 +31,11 @@ export interface MagicLinkApi<U, Answer = SignedIn<U>> {
31
31
  }) | null>;
32
32
  /**
33
33
  * Spends the link's token, marks the e-mail verified — the link reached
34
- * the inbox — and signs the user in.
34
+ * the inbox — and signs the user in. **An e-mail proved for the first
35
+ * time drops the password and the second factor, and signs out every
36
+ * session** before the new one opens: whoever registered the address
37
+ * without its inbox keeps nothing, and no factor is asked for. An
38
+ * e-mail already verified changes nothing.
35
39
  *
36
40
  * **Call it from a `POST`**, never from the `GET` of the link: a mail
37
41
  * scanner that opens the link would spend it. An unknown, spent or
@@ -1 +1 @@
1
- {"version":3,"file":"magic-link.d.ts","sourceRoot":"","sources":["../../../src/auth/types/magic-link.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC;IACpD,QAAQ,CAAC,SAAS,EAAE;QACnB;;;;;;;;;;WAUG;QACH,OAAO,CACN,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,CAAC,WAAW,GAAG;YAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;SAAE,CAAC,GAAG,IAAI,CAAC,CAAC;QACxD;;;;;;;;;;WAUG;QACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;KACxC,CAAC;CACF"}
1
+ {"version":3,"file":"magic-link.d.ts","sourceRoot":"","sources":["../../../src/auth/types/magic-link.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC;IACpD,QAAQ,CAAC,SAAS,EAAE;QACnB;;;;;;;;;;WAUG;QACH,OAAO,CACN,KAAK,EAAE,MAAM,GACX,OAAO,CAAC,CAAC,WAAW,GAAG;YAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;SAAE,CAAC,GAAG,IAAI,CAAC,CAAC;QACxD;;;;;;;;;;;;;;WAcG;QACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;KACxC,CAAC;CACF"}
package/dist/index.js CHANGED
@@ -1200,6 +1200,140 @@ function emailFlows(context, type, at) {
1200
1200
  resetPassword: resetPasswordFlows(context, type, at)
1201
1201
  };
1202
1202
  }
1203
+ // src/auth/totp.ts
1204
+ import { createHmac, randomBytes as randomBytes4 } from "node:crypto";
1205
+ var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
1206
+ var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1207
+ function mintTotpSecret() {
1208
+ return toBase32(randomBytes4(20));
1209
+ }
1210
+ function toBase32(bytes) {
1211
+ let bits = 0;
1212
+ let value = 0;
1213
+ let out = "";
1214
+ for (const byte of bytes) {
1215
+ value = value << 8 | byte;
1216
+ bits += 8;
1217
+ while (bits >= 5) {
1218
+ out += BASE32[value >>> bits - 5 & 31];
1219
+ bits -= 5;
1220
+ }
1221
+ }
1222
+ if (bits > 0)
1223
+ out += BASE32[value << 5 - bits & 31];
1224
+ return out;
1225
+ }
1226
+ function fromBase32(text) {
1227
+ let bits = 0;
1228
+ let value = 0;
1229
+ const out = [];
1230
+ for (const char of text) {
1231
+ const index = BASE32.indexOf(char);
1232
+ if (index === -1)
1233
+ throw new TypeError("totp: the secret is not base32");
1234
+ value = value << 5 | index;
1235
+ bits += 5;
1236
+ if (bits >= 8) {
1237
+ out.push(value >>> bits - 8 & 255);
1238
+ bits -= 8;
1239
+ }
1240
+ }
1241
+ return Buffer.from(out);
1242
+ }
1243
+ var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
1244
+ function codeAt(secret, step) {
1245
+ const counter = Buffer.alloc(8);
1246
+ counter.writeBigUInt64BE(BigInt(step));
1247
+ const digest = createHmac("sha1", secret).update(counter).digest();
1248
+ const offset = digest[19] & 15;
1249
+ const binary = digest.readUInt32BE(offset) & 2147483647;
1250
+ return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
1251
+ }
1252
+ function matchStep(secret, code, now, lastStep) {
1253
+ if (!/^\d{6}$/.test(code))
1254
+ return null;
1255
+ const current = stepAt(now);
1256
+ let matched = null;
1257
+ for (let step = current - TOTP.drift;step <= current + TOTP.drift; step++) {
1258
+ const fresh = lastStep === null || step > lastStep;
1259
+ if (equalCodes(codeAt(secret, step), code) && fresh && matched === null) {
1260
+ matched = step;
1261
+ }
1262
+ }
1263
+ return matched;
1264
+ }
1265
+ function equalCodes(a, b) {
1266
+ let difference = a.length ^ b.length;
1267
+ for (let i = 0;i < a.length; i++) {
1268
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
1269
+ }
1270
+ return difference === 0;
1271
+ }
1272
+ function otpauthUri(issuer, account, secret) {
1273
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
1274
+ const query = new URLSearchParams({
1275
+ secret,
1276
+ issuer,
1277
+ algorithm: "SHA1",
1278
+ digits: String(TOTP.digits),
1279
+ period: String(TOTP.stepMs / 1000)
1280
+ });
1281
+ return `otpauth://totp/${label}?${query}`;
1282
+ }
1283
+
1284
+ // src/auth/second-factor/factor.ts
1285
+ var isActive = (factor) => factor !== null && factor.confirmedAt !== null;
1286
+ function requireSettings(context, where, why) {
1287
+ const configured = context.config.secondFactor;
1288
+ if (configured === null) {
1289
+ throw new TypeError(`${where}: ${why}, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`);
1290
+ }
1291
+ return configured;
1292
+ }
1293
+ function acceptCode(configured, record, factor, code, now, where) {
1294
+ const { plain, keyId } = unseal(configured.sealer, factor.secret, record.id, where);
1295
+ const step = matchStep(fromBase32(plain), code, now, factor.lastStep);
1296
+ if (step === null)
1297
+ return null;
1298
+ return {
1299
+ ...factor,
1300
+ lastStep: step,
1301
+ secret: keyId === configured.sealer.sealWith ? factor.secret : seal(configured.sealer, plain, record.id)
1302
+ };
1303
+ }
1304
+
1305
+ // src/auth/first-proof.ts
1306
+ async function proveFirstEmail(context, type, user, where) {
1307
+ await endWhatTheSquatterOpened(context, user.id);
1308
+ let dropped = false;
1309
+ let disabled = false;
1310
+ const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (record, now) => {
1311
+ dropped = record.password !== null;
1312
+ disabled = isActive(record.secondFactor);
1313
+ return {
1314
+ emailVerifiedAt: now,
1315
+ ...dropped ? { password: null } : {},
1316
+ ...record.secondFactor === null ? {} : { secondFactor: null }
1317
+ };
1318
+ });
1319
+ try {
1320
+ await endWhatTheSquatterOpened(context, proved.id);
1321
+ } finally {
1322
+ await emit(context, "user.emailVerified", proved, proved.updatedAt);
1323
+ if (dropped) {
1324
+ await emit(context, "user.passwordChanged", proved, proved.updatedAt);
1325
+ }
1326
+ if (disabled) {
1327
+ await emit(context, "user.secondFactorDisabled", proved, proved.updatedAt);
1328
+ }
1329
+ }
1330
+ return proved;
1331
+ }
1332
+ async function endWhatTheSquatterOpened(context, userId) {
1333
+ await context.store.sessions.revokeUserSessions(userId, context.clock.now());
1334
+ await endWhatThePasswordOpened(context, userId);
1335
+ }
1336
+
1203
1337
  // src/auth/email-sign-in.ts
1204
1338
  async function signInHolder(context, type, email) {
1205
1339
  const record = await holderOfEmail(context, type, email);
@@ -1219,9 +1353,7 @@ async function finishEmailSignIn(context, type, finish, signIn) {
1219
1353
  }
1220
1354
  if (user.emailVerifiedAt !== null)
1221
1355
  return finish(user, where);
1222
- const proved = await writeUser(context, user.id, type, { ifVersion: user.version }, where, (_, now) => ({ emailVerifiedAt: now }));
1223
- await emit(context, "user.emailVerified", proved, proved.updatedAt);
1224
- return finish(proved, where);
1356
+ return finish(await proveFirstEmail(context, type, user, where), where);
1225
1357
  }
1226
1358
 
1227
1359
  // src/auth/magic-link.ts
@@ -1260,7 +1392,7 @@ async function confirmLink(context, type, finish, secret, where) {
1260
1392
  }
1261
1393
 
1262
1394
  // src/auth/derived-keys.ts
1263
- import { createHmac, hkdfSync } from "node:crypto";
1395
+ import { createHmac as createHmac2, hkdfSync } from "node:crypto";
1264
1396
  var derived = new WeakMap;
1265
1397
  function derivedKey(sealer, purpose, keyId = sealer.sealWith) {
1266
1398
  let keys = derived.get(sealer);
@@ -1280,7 +1412,7 @@ function derivedKey(sealer, purpose, keyId = sealer.sealWith) {
1280
1412
  return key;
1281
1413
  }
1282
1414
  function keyedHash(key, parts) {
1283
- return createHmac("sha256", key).update(parts.join("\x00")).digest();
1415
+ return createHmac2("sha256", key).update(parts.join("\x00")).digest();
1284
1416
  }
1285
1417
 
1286
1418
  // src/auth/sign-in-attempts.ts
@@ -1329,108 +1461,6 @@ async function restartSignInCount(context, type, record, where) {
1329
1461
  await context.store.tokens.consumeToken(counted.link, "secondFactor", context.clock.now());
1330
1462
  }
1331
1463
 
1332
- // src/auth/totp.ts
1333
- import { createHmac as createHmac2, randomBytes as randomBytes4 } from "node:crypto";
1334
- var TOTP = { digits: 6, stepMs: 30000, drift: 1 };
1335
- var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1336
- function mintTotpSecret() {
1337
- return toBase32(randomBytes4(20));
1338
- }
1339
- function toBase32(bytes) {
1340
- let bits = 0;
1341
- let value = 0;
1342
- let out = "";
1343
- for (const byte of bytes) {
1344
- value = value << 8 | byte;
1345
- bits += 8;
1346
- while (bits >= 5) {
1347
- out += BASE32[value >>> bits - 5 & 31];
1348
- bits -= 5;
1349
- }
1350
- }
1351
- if (bits > 0)
1352
- out += BASE32[value << 5 - bits & 31];
1353
- return out;
1354
- }
1355
- function fromBase32(text) {
1356
- let bits = 0;
1357
- let value = 0;
1358
- const out = [];
1359
- for (const char of text) {
1360
- const index = BASE32.indexOf(char);
1361
- if (index === -1)
1362
- throw new TypeError("totp: the secret is not base32");
1363
- value = value << 5 | index;
1364
- bits += 5;
1365
- if (bits >= 8) {
1366
- out.push(value >>> bits - 8 & 255);
1367
- bits -= 8;
1368
- }
1369
- }
1370
- return Buffer.from(out);
1371
- }
1372
- var stepAt = (now) => Math.floor(now.getTime() / TOTP.stepMs);
1373
- function codeAt(secret, step) {
1374
- const counter = Buffer.alloc(8);
1375
- counter.writeBigUInt64BE(BigInt(step));
1376
- const digest = createHmac2("sha1", secret).update(counter).digest();
1377
- const offset = digest[19] & 15;
1378
- const binary = digest.readUInt32BE(offset) & 2147483647;
1379
- return String(binary % 10 ** TOTP.digits).padStart(TOTP.digits, "0");
1380
- }
1381
- function matchStep(secret, code, now, lastStep) {
1382
- if (!/^\d{6}$/.test(code))
1383
- return null;
1384
- const current = stepAt(now);
1385
- let matched = null;
1386
- for (let step = current - TOTP.drift;step <= current + TOTP.drift; step++) {
1387
- const fresh = lastStep === null || step > lastStep;
1388
- if (equalCodes(codeAt(secret, step), code) && fresh && matched === null) {
1389
- matched = step;
1390
- }
1391
- }
1392
- return matched;
1393
- }
1394
- function equalCodes(a, b) {
1395
- let difference = a.length ^ b.length;
1396
- for (let i = 0;i < a.length; i++) {
1397
- difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
1398
- }
1399
- return difference === 0;
1400
- }
1401
- function otpauthUri(issuer, account, secret) {
1402
- const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
1403
- const query = new URLSearchParams({
1404
- secret,
1405
- issuer,
1406
- algorithm: "SHA1",
1407
- digits: String(TOTP.digits),
1408
- period: String(TOTP.stepMs / 1000)
1409
- });
1410
- return `otpauth://totp/${label}?${query}`;
1411
- }
1412
-
1413
- // src/auth/second-factor/factor.ts
1414
- var isActive = (factor) => factor !== null && factor.confirmedAt !== null;
1415
- function requireSettings(context, where, why) {
1416
- const configured = context.config.secondFactor;
1417
- if (configured === null) {
1418
- throw new TypeError(`${where}: ${why}, and janus() was given no secondFactor — pass secondFactor: { issuer, keys }`);
1419
- }
1420
- return configured;
1421
- }
1422
- function acceptCode(configured, record, factor, code, now, where) {
1423
- const { plain, keyId } = unseal(configured.sealer, factor.secret, record.id, where);
1424
- const step = matchStep(fromBase32(plain), code, now, factor.lastStep);
1425
- if (step === null)
1426
- return null;
1427
- return {
1428
- ...factor,
1429
- lastStep: step,
1430
- secret: keyId === configured.sealer.sealWith ? factor.secret : seal(configured.sealer, plain, record.id)
1431
- };
1432
- }
1433
-
1434
1464
  // src/auth/second-factor/redeem.ts
1435
1465
  var spendChallenge = (context, challenge, where) => spendOneTime(context, challenge, "secondFactor", where, "challenge");
1436
1466
  async function openChallenge(context, type, challenge, where) {
@@ -2270,5 +2300,5 @@ export {
2270
2300
  systemClock
2271
2301
  };
2272
2302
 
2273
- //# debugId=56E4EBD3ED51BF6B64756E2164756E21
2303
+ //# debugId=BC1254710D035B0264756E2164756E21
2274
2304
  //# sourceMappingURL=index.js.map