@nxgt/janus 0.13.0 → 0.14.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 +56 -15
- package/dist/auth/config/index.d.ts +1 -1
- package/dist/auth/config/index.d.ts.map +1 -1
- package/dist/auth/config/janus-config.d.ts +23 -0
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-sign-in.d.ts +5 -0
- package/dist/auth/config/resolve-sign-in.d.ts.map +1 -0
- package/dist/auth/config/resolved-config.d.ts +5 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/derived-keys.d.ts +9 -0
- package/dist/auth/derived-keys.d.ts.map +1 -0
- package/dist/auth/events.d.ts +19 -4
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/one-time/index.d.ts +3 -1
- package/dist/auth/one-time/index.d.ts.map +1 -1
- package/dist/auth/one-time/window.d.ts +50 -0
- package/dist/auth/one-time/window.d.ts.map +1 -0
- package/dist/auth/password-written.d.ts +7 -0
- package/dist/auth/password-written.d.ts.map +1 -1
- package/dist/auth/second-factor/app-code-attempts.d.ts +5 -8
- package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery-codes.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery.d.ts.map +1 -1
- package/dist/auth/sign-in-attempts.d.ts +43 -0
- package/dist/auth/sign-in-attempts.d.ts.map +1 -0
- package/dist/auth/types/password.d.ts +8 -0
- package/dist/auth/types/password.d.ts.map +1 -1
- package/dist/auth/users/fields-patch.d.ts +8 -2
- package/dist/auth/users/fields-patch.d.ts.map +1 -1
- package/dist/auth/users/record-flows.d.ts.map +1 -1
- package/dist/auth/users/sign-in.d.ts +2 -2
- package/dist/auth/users/sign-in.d.ts.map +1 -1
- package/dist/auth/users/update.d.ts +12 -0
- package/dist/auth/users/update.d.ts.map +1 -0
- package/dist/chunks/{index-km7ffctx.js → index-6c9fbtv2.js} +2 -2
- package/dist/chunks/{index-rd2rddvv.js → index-baazjyvc.js} +2 -2
- package/dist/chunks/{index-j2gd8fry.js → index-hnwggyxs.js} +3 -2
- package/dist/chunks/{index-j2gd8fry.js.map → index-hnwggyxs.js.map} +3 -3
- package/dist/chunks/{index-jp2kka6d.js → index-kj3st57z.js} +2 -2
- package/dist/conformance/index.js +3 -3
- package/dist/errors/janus-error/base.d.ts +1 -0
- package/dist/errors/janus-error/base.d.ts.map +1 -1
- package/dist/errors/janus-error/codes.d.ts +3 -1
- package/dist/errors/janus-error/codes.d.ts.map +1 -1
- package/dist/errors/janus-error/options.d.ts +9 -2
- package/dist/errors/janus-error/options.d.ts.map +1 -1
- package/dist/index.js +226 -86
- package/dist/index.js.map +20 -15
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +2 -2
- package/docs/guide/errors.md +10 -2
- package/docs/guide/events.md +73 -12
- package/docs/guide/passwords.md +115 -17
- package/docs/guide/second-factor.md +4 -2
- package/docs/guide/users.md +11 -6
- package/docs/guide/vocabulary.md +1 -1
- package/docs/roadmap.md +27 -18
- package/docs/troubleshooting.md +55 -2
- package/package.json +1 -1
- /package/dist/chunks/{index-km7ffctx.js.map → index-6c9fbtv2.js.map} +0 -0
- /package/dist/chunks/{index-rd2rddvv.js.map → index-baazjyvc.js.map} +0 -0
- /package/dist/chunks/{index-jp2kka6d.js.map → index-kj3st57z.js.map} +0 -0
|
@@ -4,15 +4,15 @@ import {
|
|
|
4
4
|
UNSTORABLE,
|
|
5
5
|
MAX_PAGE_SIZE2,
|
|
6
6
|
pageLimit2
|
|
7
|
-
} from "../chunks/index-
|
|
7
|
+
} from "../chunks/index-kj3st57z.js";
|
|
8
8
|
import {
|
|
9
9
|
PermissionDepthError2,
|
|
10
10
|
isSetOf2,
|
|
11
11
|
formatEntity2
|
|
12
|
-
} from "../chunks/index-
|
|
12
|
+
} from "../chunks/index-hnwggyxs.js";
|
|
13
13
|
import {
|
|
14
14
|
createMemoryRelations2
|
|
15
|
-
} from "../chunks/index-
|
|
15
|
+
} from "../chunks/index-6c9fbtv2.js";
|
|
16
16
|
|
|
17
17
|
// src/permissions/resolve/resolved.ts
|
|
18
18
|
function admits(holders, subject) {
|
package/docs/README.md
CHANGED
|
@@ -17,8 +17,8 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
17
17
|
| [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
|
|
18
18
|
| [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, recovery codes for a lost phone, disabling |
|
|
19
19
|
| [Step-up](guide/step-up.md) | A sensitive action — changing the e-mail, disabling the second factor, deleting the account — should ask a signed-in user to prove again who they are: `assertFresh`, `stepUp.request` and `confirm`, by e-mailed code or by their app |
|
|
20
|
-
| [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets their password, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
|
|
21
|
-
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or
|
|
20
|
+
| [User events](guide/events.md) | You want to hear when a user is created, verifies their e-mail, resets or changes their password, changes their e-mail, turns the second factor on or off, uses a recovery code or is deleted — to queue it, sync it, or send it as a webhook |
|
|
21
|
+
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system — or how `signIn` throttles password guessing per login, and what you still limit per client |
|
|
22
22
|
|
|
23
23
|
### Permissions — `@nxgt/janus/permissions`
|
|
24
24
|
|
package/docs/guide/errors.md
CHANGED
|
@@ -54,7 +54,7 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
54
54
|
| `VERSION_CONFLICT` | `StoreConflict` (`on: 'version'`) | 409 | `ifVersion` no longer matches; nothing was written. From `secondFactor.confirm` or `recover`, the same code used by two sign-ins at once: the other opened the session | `expectedVersion`, `actualVersion` |
|
|
55
55
|
| `USER_INVALID` | `UserInvalidError` | 400 | The fields failed the schema | `issues`, field by field |
|
|
56
56
|
| `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
|
|
57
|
-
| `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
|
|
57
|
+
| `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** — and any password, the right one included, at a login past its attempts in the window — see [throttling](passwords.md#password-guessing-is-throttled) | `reason`, for your logs only; `retryAfter`, the seconds until the next window, when throttled |
|
|
58
58
|
| `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
|
|
59
59
|
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, or the right code | `userId` |
|
|
60
60
|
| `STEP_UP_REQUIRED` | `StepUpRequiredError` | 403 | `assertFresh`: the session proved who it is `maxAge` ago or more. Not a denial of the action — ask for a [step-up](step-up.md), then send the request again | `userId` |
|
|
@@ -81,7 +81,11 @@ async function signIn(email: string, password: string): Promise<Response> {
|
|
|
81
81
|
return Response.json({ token });
|
|
82
82
|
} catch (error) {
|
|
83
83
|
if (error instanceof CredentialError && error.code === 'CREDENTIALS_INVALID') {
|
|
84
|
-
console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword'
|
|
84
|
+
console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword' | 'throttled'
|
|
85
|
+
if (error.retryAfter !== undefined) {
|
|
86
|
+
const headers = { 'retry-after': String(error.retryAfter) };
|
|
87
|
+
return Response.json({ error: 'invalid', retryAfter: error.retryAfter }, { status: 401, headers });
|
|
88
|
+
}
|
|
85
89
|
return Response.json({ error: 'invalid' }, { status: 401 });
|
|
86
90
|
}
|
|
87
91
|
if (error instanceof StoreFailure) return new Response(null, { status: 503 });
|
|
@@ -96,6 +100,10 @@ async function signIn(email: string, password: string): Promise<Response> {
|
|
|
96
100
|
would read as "no".
|
|
97
101
|
- **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
|
|
98
102
|
the new `version`.
|
|
103
|
+
- **`CREDENTIALS_INVALID`'s `retryAfter`** belongs in the body and a
|
|
104
|
+
`Retry-After` header: the login is throttled, and the right password is
|
|
105
|
+
refused too until then. It says nothing of whether the login exists — an
|
|
106
|
+
unknown one is throttled alike.
|
|
99
107
|
- **`CODE_INVALID`'s `attemptsLeft`** belongs in the body — the form can say
|
|
100
108
|
how many attempts are left. `0` means the challenge is spent: send the visitor
|
|
101
109
|
back to the password.
|
package/docs/guide/events.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# User events
|
|
2
2
|
|
|
3
3
|
This page is for hearing what happens to a user once it is written: created,
|
|
4
|
-
e-mail verified, password reset
|
|
5
|
-
codes regenerated or one used, deleted. Another service can then follow
|
|
4
|
+
e-mail verified, password reset or changed, e-mail changed, second factor
|
|
5
|
+
turned on or off, recovery codes regenerated or one used, deleted. Another service can then follow
|
|
6
6
|
without polling. `janus` hands each event to one function you give it;
|
|
7
7
|
**delivering it is yours** — or
|
|
8
8
|
[`@nxgt/janus-webhooks`](https://www.npmjs.com/package/@nxgt/janus-webhooks)'s:
|
|
@@ -38,13 +38,15 @@ received[0];
|
|
|
38
38
|
The words — **user event**, **listener** — are defined in
|
|
39
39
|
[the vocabulary](vocabulary.md#identities).
|
|
40
40
|
|
|
41
|
-
## The
|
|
41
|
+
## The ten types
|
|
42
42
|
|
|
43
43
|
| `type` | Sent by | Not sent |
|
|
44
44
|
| --- | --- | --- |
|
|
45
45
|
| `user.created` | `create`, `signUp` — once the user is inserted, even if `signUp`'s session then fails to open | for a sign-up refused (`LOGIN_TAKEN`, `USER_INVALID`, `PASSWORD_TOO_SHORT`) |
|
|
46
46
|
| `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
|
|
47
47
|
| `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
|
|
48
|
+
| `user.passwordChanged` | `changePassword` and `setPassword` — a first password set on a user created without one included — once the password is written — even if spending the reset links and second-factor challenges it ends then fails | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
|
|
49
|
+
| `user.emailChanged` | `update`, when it changed the e-mail — added, replaced or removed. It carries `formerEmail`, the address before | for an update that leaves the e-mail alone, or changes only its case (`Ada@…` for `ada@…`): compared normalised, the same test that makes the new address unverified; for a type with no e-mail; for an update refused |
|
|
48
50
|
| `user.secondFactorEnabled` | `secondFactor.activate`, once the first code made the factor active | for `enroll`, which leaves the factor waiting and asked for nowhere; for a code refused |
|
|
49
51
|
| `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
|
|
50
52
|
| `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
|
|
@@ -52,9 +54,13 @@ The words — **user event**, **listener** — are defined in
|
|
|
52
54
|
| `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
|
|
53
55
|
|
|
54
56
|
A reset whose link verifies the e-mail sends both, `user.passwordReset`
|
|
55
|
-
first.
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
first. **A reset does not also send `user.passwordChanged`**: a listener that
|
|
58
|
+
cares about every new password handles both types, as the `switch` below
|
|
59
|
+
does — one event per write, rather than two a listener would have to tell
|
|
60
|
+
apart. Switch on `type`: the ten are a closed set, and TypeScript refuses an
|
|
61
|
+
eleventh. A new type is a compile error in a `switch` that exhausts them — the
|
|
62
|
+
two recovery-code types, added in 0.10, and the two change types, added in
|
|
63
|
+
0.14, each broke such a `switch` until it handled them.
|
|
58
64
|
|
|
59
65
|
```ts
|
|
60
66
|
function onUserEvent(event: UserEvent): void {
|
|
@@ -64,7 +70,10 @@ function onUserEvent(event: UserEvent): void {
|
|
|
64
70
|
case 'user.emailVerified':
|
|
65
71
|
return unlockFeatures(event.userId);
|
|
66
72
|
case 'user.passwordReset':
|
|
67
|
-
|
|
73
|
+
case 'user.passwordChanged':
|
|
74
|
+
return noticeTheUser(event); // @nxgt/janus-mail's passwordChanged — see below
|
|
75
|
+
case 'user.emailChanged':
|
|
76
|
+
return noticeTheFormerAddress(event); // to event.formerEmail — see below
|
|
68
77
|
case 'user.secondFactorEnabled':
|
|
69
78
|
case 'user.secondFactorDisabled':
|
|
70
79
|
return noticeTheUser(event); // @nxgt/janus-mail's twoFactorEnabled or twoFactorDisabled
|
|
@@ -114,14 +123,59 @@ With several user types, the count is the type's:
|
|
|
114
123
|
`auth.patient.secondFactor.recoveryCodesLeft(event.userId)`, on
|
|
115
124
|
`event.userType`.
|
|
116
125
|
|
|
126
|
+
### Telling the user their password or e-mail changed
|
|
127
|
+
|
|
128
|
+
A password changed by someone else, or an e-mail moved to their inbox, is how
|
|
129
|
+
an account is taken over — so tell the user each time. For a password, read
|
|
130
|
+
the user and write to their address. For an e-mail, write to the **former**
|
|
131
|
+
address: the new one belongs to whoever changed it. The event carries it,
|
|
132
|
+
since nothing else keeps it once the write landed:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// `mail` is janusMail({ … }), and the user schema holds a name and a locale.
|
|
136
|
+
const auth = janus({
|
|
137
|
+
...config,
|
|
138
|
+
async events(event) {
|
|
139
|
+
if (event.type === 'user.passwordChanged') {
|
|
140
|
+
const user = await auth.get(event.userId);
|
|
141
|
+
await mail.passwordChanged({ name: user.name, locale: user.locale, email: user.email });
|
|
142
|
+
}
|
|
143
|
+
if (event.type === 'user.emailChanged' && event.formerEmail != null) {
|
|
144
|
+
const user = await auth.get(event.userId);
|
|
145
|
+
await mail.emailChanged({
|
|
146
|
+
name: user.name,
|
|
147
|
+
locale: user.locale,
|
|
148
|
+
formerEmail: event.formerEmail, // to the inbox the account just left
|
|
149
|
+
newEmail: user.email,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`formerEmail` is `null` for a user who had no e-mail before the update:
|
|
157
|
+
there is no former inbox to tell. The example assumes a required e-mail; with
|
|
158
|
+
an optional one, an update that removes it sends the event too, and the user
|
|
159
|
+
read back has no `newEmail` to name — check `user.email` before the notice. Pass the same notices on `user.passwordReset`
|
|
160
|
+
too, if a reset should be told as well.
|
|
161
|
+
|
|
117
162
|
## What an event carries
|
|
118
163
|
|
|
119
|
-
**The user named by id, and nothing else
|
|
120
|
-
the schema, no password or hash, no session or one-time
|
|
164
|
+
**The user named by id, and nothing else** — with one exception, below. No
|
|
165
|
+
login, no field of the schema, no password or hash, no session or one-time
|
|
166
|
+
token. An event can
|
|
121
167
|
land in a queue, a log or another company's endpoint; whoever receives it
|
|
122
168
|
reads the rest from where it is kept — `auth.get(event.userId)` — if they
|
|
123
169
|
may. A user deleted since is `NOT_FOUND` there, which is the answer.
|
|
124
170
|
|
|
171
|
+
**The exception is `formerEmail`, on `user.emailChanged` only**: the address
|
|
172
|
+
the user had before the update, as it was stored, or `null` when they had
|
|
173
|
+
none. Once the write landed it is kept nowhere else, and a notice to the
|
|
174
|
+
inbox the account just left needs it. It is absent from every other type.
|
|
175
|
+
`@nxgt/janus-webhooks` does not carry it: it strips it before the queue, so
|
|
176
|
+
it reaches no endpoint, no queue and no `onGivingUp` report — send the
|
|
177
|
+
notice from the listener, in the process that wrote.
|
|
178
|
+
|
|
125
179
|
`id` is minted for the event, a UUIDv7 that sorts by time. Deliver on it:
|
|
126
180
|
a queue job id, a primary key, a webhook's `webhook-id` header. Two events
|
|
127
181
|
never share one.
|
|
@@ -148,6 +202,8 @@ Where the listener runs within a flow, and what an outage does to it:
|
|
|
148
202
|
| `create` | after the insert — its last step | — |
|
|
149
203
|
| `signUp` | after the insert, **before** the session is opened | fails the call; the event is already sent |
|
|
150
204
|
| `verifyEmail.confirm` | after the write — the flow's last step | — |
|
|
205
|
+
| `update` | after the write — the flow's last step | — |
|
|
206
|
+
| `changePassword`, `setPassword` | **after** the reset links and the second-factor challenges left open are spent | fails the call; the event is sent all the same, from a `finally` |
|
|
151
207
|
| `signInCode.confirm` | after the write, **before** the session or the second-factor challenge is opened | fails the call; the event is already sent |
|
|
152
208
|
| `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
|
|
153
209
|
| `secondFactor.recover` | after the recovery code is spent and the session opened — or failed to open | fails the call; the recovery code is spent and the event sent all the same, from a `finally` |
|
|
@@ -155,8 +211,9 @@ Where the listener runs within a flow, and what an outage does to it:
|
|
|
155
211
|
| `delete` | **after** the user's sessions and one-time tokens are removed, and the relation tuples naming them when `relations` is wired | fails the call; the event is sent all the same, from a `finally` |
|
|
156
212
|
|
|
157
213
|
So a listener that takes its time never leaves an old session alive after a
|
|
158
|
-
reset,
|
|
159
|
-
|
|
214
|
+
reset, one that hears of a password changed finds the older reset links
|
|
215
|
+
already spent, and one that checks permissions on `user.deleted` finds the
|
|
216
|
+
tuples gone. A retry could not send a lost event either way: `delete` then finds
|
|
160
217
|
nobody, and the reset link is spent.
|
|
161
218
|
|
|
162
219
|
`occurredAt` is the write's own time: the `createdAt` or `updatedAt` it
|
|
@@ -231,6 +288,8 @@ type UserEventType =
|
|
|
231
288
|
| 'user.created'
|
|
232
289
|
| 'user.emailVerified'
|
|
233
290
|
| 'user.passwordReset'
|
|
291
|
+
| 'user.passwordChanged'
|
|
292
|
+
| 'user.emailChanged'
|
|
234
293
|
| 'user.secondFactorEnabled'
|
|
235
294
|
| 'user.secondFactorDisabled'
|
|
236
295
|
| 'user.recoveryCodesRegenerated'
|
|
@@ -243,6 +302,7 @@ interface UserEvent {
|
|
|
243
302
|
readonly occurredAt: Date; // when the write landed: the createdAt or updatedAt written, or the time read just before a deletion
|
|
244
303
|
readonly userId: Id;
|
|
245
304
|
readonly userType: string;
|
|
305
|
+
readonly formerEmail?: string | null; // on user.emailChanged only: the address before, null for none
|
|
246
306
|
}
|
|
247
307
|
|
|
248
308
|
type UserEventListener = (event: UserEvent) => void | Promise<void>;
|
|
@@ -257,5 +317,6 @@ see [troubleshooting](../troubleshooting.md#janus-events-must-be-a-function-that
|
|
|
257
317
|
|
|
258
318
|
- [E-mail verification and password reset](email-flows.md) — the flows that send `user.emailVerified` and `user.passwordReset`
|
|
259
319
|
- [The second factor](second-factor.md) — `activate` and `disable`, which send `user.secondFactorEnabled` and `user.secondFactorDisabled`; [recovery codes](second-factor.md#recovery-codes), which send `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
|
|
260
|
-
- [Users](users.md) — `create`, `signUp`, `delete`
|
|
320
|
+
- [Users](users.md) — `create`, `signUp`, `update`, `delete`
|
|
321
|
+
- [Passwords](passwords.md) — `changePassword` and `setPassword`, which send `user.passwordChanged`
|
|
261
322
|
- [Troubleshooting](../troubleshooting.md) — `JANUS_EVENT_FAILED`
|
package/docs/guide/passwords.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Password hashing
|
|
2
2
|
|
|
3
|
-
This page is for choosing a password hasher, moving from one to another,
|
|
4
|
-
importing hashes written by another system
|
|
3
|
+
This page is for choosing a password hasher, moving from one to another,
|
|
4
|
+
importing hashes written by another system, and how `signIn` throttles
|
|
5
|
+
password guessing. The login and the length policy
|
|
5
6
|
are on the [users](users.md#options) page.
|
|
6
7
|
|
|
7
8
|
```ts
|
|
@@ -122,28 +123,125 @@ Write each imported user's hash as their `PasswordRecord` with your store's
|
|
|
122
123
|
scrypt the first time they sign in. Wire one verifier
|
|
123
124
|
per prefix the old system wrote (`$2a$`, `$2b$`, `$2y$` for bcrypt).
|
|
124
125
|
|
|
125
|
-
##
|
|
126
|
+
## Password guessing is throttled
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
run:
|
|
128
|
+
**`signIn` counts the passwords tried at each login, and past ten in a
|
|
129
|
+
15-minute window it refuses every one — the right password included — until
|
|
130
|
+
the window ends.** On by default. The refusal is the same `CREDENTIALS_INVALID`
|
|
131
|
+
as a wrong password, with `reason: 'throttled'` for your logs and
|
|
132
|
+
`retryAfter`, the seconds until the next window, for the client:
|
|
133
133
|
|
|
134
134
|
```ts
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
import { JanusError } from '@nxgt/janus';
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return await auth.signIn({ email, password });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof JanusError && error.code === 'CREDENTIALS_INVALID') {
|
|
141
|
+
const headers = error.retryAfter === undefined
|
|
142
|
+
? undefined
|
|
143
|
+
: { 'retry-after': String(error.retryAfter) };
|
|
144
|
+
return Response.json({ code: error.code, retryAfter: error.retryAfter }, { status: 401, headers });
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
[`janusErrors()`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-hono/docs/guide/routes.md#sign-up-and-sign-in) in
|
|
151
|
+
`@nxgt/janus-hono` and `janusGraphQLError()` in `@nxgt/janus-graphql` do this
|
|
152
|
+
for you, `Retry-After` header included.
|
|
153
|
+
|
|
154
|
+
What it does, precisely:
|
|
155
|
+
|
|
156
|
+
- **Nothing locks.** The next window signs in: no number of wrong
|
|
157
|
+
passwords blocks an account beyond its window. **But the right password is
|
|
158
|
+
refused while the login is throttled**, so somebody who knows only a login
|
|
159
|
+
can keep its password sign-in shut by trying ten passwords every window.
|
|
160
|
+
That is the price of counting per login; limit per client address (below),
|
|
161
|
+
and a [sign-in code](sign-in-code.md), when you wire them, still opens
|
|
162
|
+
the account.
|
|
163
|
+
- **Per login, known or not.** A login nobody holds is counted as a
|
|
164
|
+
registered one is — `unknownLogin` ten times, then `throttled` — so the
|
|
165
|
+
throttle does not say which logins exist; an unknown login is still
|
|
166
|
+
compared against a dummy hash, as before. The login is counted as
|
|
167
|
+
[`password.normalize`](users.md#passwordnormalize) writes it, within its
|
|
168
|
+
user type: `ADA@example.test` and `ada@example.test` share one count.
|
|
169
|
+
- **Counted before anything is compared**, by the store, in one write per
|
|
170
|
+
attempt: of twenty passwords tried at once, exactly ten are compared. A
|
|
171
|
+
throttled attempt compares nothing, so it costs no hashing either.
|
|
172
|
+
- **A sign-in that opens a session starts the count again** for that
|
|
173
|
+
login: the right password of an active user — and, with a second factor
|
|
174
|
+
active, only once its code or a recovery code opens the session. A
|
|
175
|
+
password that only opens a challenge restarts nothing, so knowing the
|
|
176
|
+
password buys no more than ten challenges per window.
|
|
177
|
+
- **The windows are fixed slices of the clock**, not sliding: ten attempts
|
|
178
|
+
at the end of one window and ten at the start of the next are allowed.
|
|
179
|
+
|
|
180
|
+
Change the limit or the window, or turn it off — `SignInConfig` and
|
|
181
|
+
`SignInThrottleConfig` are the option's types, for a wrapper of your own:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
janus({ ..., signIn: { throttle: { attempts: 5, window: '1h' } } });
|
|
185
|
+
janus({ ..., signIn: { throttle: false } }); // counts nothing: limit signIn yourself
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Where the counts live
|
|
189
|
+
|
|
190
|
+
In the tokens store, as `secondFactor` tokens that belong to no user: the
|
|
191
|
+
store needs no new method, and an adapter that passes the conformance suite
|
|
192
|
+
counts correctly. Each is named by a keyed hash of the login, never the login
|
|
193
|
+
— keyed by your `secondFactor` keys when you wire them, and by a fixed key
|
|
194
|
+
otherwise, which only keeps the login out of plain sight: a dump of the
|
|
195
|
+
tokens then tells which logins were tried, for a guessed list. Each expires
|
|
196
|
+
two windows after its window starts — half an hour by default — **Redis and MongoDB drop it then;
|
|
197
|
+
PostgreSQL keeps it** until something deletes it, and every login tried,
|
|
198
|
+
registered or not, adds a row per window. Schedule a delete with
|
|
199
|
+
`@nxgt/janus-drizzle`:
|
|
200
|
+
|
|
201
|
+
```sql
|
|
202
|
+
delete from tokens where expires_at < now() - interval '1 hour'; -- janus.tokens when your tables have a schema of their own
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`tokens` has no index on `expires_at`, so the delete scans the table: add
|
|
206
|
+
`create index on tokens (expires_at)` in a migration of yours if it grows.
|
|
207
|
+
|
|
208
|
+
The key is your first `secondFactor` key: wiring
|
|
209
|
+
`secondFactor` for the first time, or putting a new key first, starts every
|
|
210
|
+
login's count again. **A Redis tokens store that is flushed, or evicts keys under
|
|
211
|
+
memory pressure, forgets them** and every count starts again: give Redis
|
|
212
|
+
`maxmemory-policy noeviction` (see `@nxgt/janus-redis`).
|
|
213
|
+
|
|
214
|
+
### When the store cannot count
|
|
215
|
+
|
|
216
|
+
`signIn` throws `STORE_FAILED` — answer 503. It never answers a refusal the
|
|
217
|
+
visitor could not have caused, and never lets a password through uncounted:
|
|
218
|
+
**the throttle fails closed**, so a tokens-store outage stops password
|
|
219
|
+
sign-ins as a users-store outage would. A sign-in whose password was right and
|
|
220
|
+
whose count could not start again also throws `STORE_FAILED`, before any
|
|
221
|
+
session is opened.
|
|
222
|
+
|
|
223
|
+
The store's round-trips are observable, as its latency is: a login that
|
|
224
|
+
signed in this window costs a few more probes than one nobody holds.
|
|
225
|
+
|
|
226
|
+
### What you still limit yourself
|
|
227
|
+
|
|
228
|
+
The throttle counts per login. It does not see:
|
|
229
|
+
|
|
230
|
+
- **One password tried against many logins** (password spraying), nor many
|
|
231
|
+
logins from one client: limit `signIn` **per client address** in front of
|
|
232
|
+
the call, with the limiter you already run.
|
|
233
|
+
- `changePassword`, which compares the current password too: limit it per
|
|
234
|
+
user.
|
|
235
|
+
- `resetPassword.request` and `signInCode.request`, which send e-mail: limit
|
|
236
|
+
them per address.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
if (!(await limiter.consume(`sign-in:${clientAddress}`))) {
|
|
137
240
|
return new Response(null, { status: 429, headers: { 'retry-after': '900' } });
|
|
138
241
|
}
|
|
139
242
|
const signedIn = await auth.signIn({ email, password });
|
|
140
243
|
```
|
|
141
244
|
|
|
142
|
-
Key it on the login normalised as
|
|
143
|
-
[`password.normalize`](users.md#passwordnormalize) does — `lowercaseTrim` by
|
|
144
|
-
default, as above — and add a second limit per client address; a few
|
|
145
|
-
attempts per quarter hour per login is plenty for a person. `changePassword` compares the current password too: limit it per user.
|
|
146
|
-
|
|
147
245
|
## What never happens
|
|
148
246
|
|
|
149
247
|
- The plain password is never stored, and never appears in an error message.
|
|
@@ -155,4 +253,4 @@ attempts per quarter hour per login is plenty for a person. `changePassword` com
|
|
|
155
253
|
## See also
|
|
156
254
|
|
|
157
255
|
- [Users](users.md) — `signIn`, `setPassword`, `changePassword`
|
|
158
|
-
- [Errors](errors.md) — `CREDENTIALS_INVALID
|
|
256
|
+
- [Errors](errors.md) — `CREDENTIALS_INVALID`, its `reason` and `retryAfter`
|
|
@@ -306,8 +306,10 @@ try {
|
|
|
306
306
|
```
|
|
307
307
|
|
|
308
308
|
Five attempts at a million values is a one-in-200,000 chance per password
|
|
309
|
-
guessed right. A new challenge takes a new sign-in, with the password,
|
|
310
|
-
|
|
309
|
+
guessed right. A new challenge takes a new sign-in, with the password, and
|
|
310
|
+
`signIn`'s throttle allows ten per login per 15-minute window — so the
|
|
311
|
+
password alone buys at most ten challenges a window; a limit per client is
|
|
312
|
+
yours to add.
|
|
311
313
|
|
|
312
314
|
A call made through **another user type's** API — `auth.staff.secondFactor.confirm`
|
|
313
315
|
for a patient's challenge — answers `TOKEN_UNKNOWN` and compares nothing, but
|
package/docs/guide/users.md
CHANGED
|
@@ -96,6 +96,7 @@ that cuts an emoji in half, say.
|
|
|
96
96
|
| `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
|
|
97
97
|
| `tokens.signInCode` | `Duration` | `'10m'` | How long an e-mailed sign-in code and its challenge live. See [sign-in codes](sign-in-code.md) |
|
|
98
98
|
| `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
|
|
99
|
+
| `signIn.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 10, window: '15m' }` | Passwords one login may try per window, the right one included, before `signIn` answers `CREDENTIALS_INVALID` with `retryAfter` until the window ends. `false` counts nothing. See [passwords](passwords.md#password-guessing-is-throttled) |
|
|
99
100
|
| `secondFactor` | `{ issuer, keys, challenge? }` | none | A TOTP second factor for every type with a password. Changes what `signIn` answers — see [the second factor](second-factor.md#configuration) |
|
|
100
101
|
| `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
|
|
101
102
|
|
|
@@ -178,7 +179,7 @@ In the one-type form these are on `auth` itself; with several types, on
|
|
|
178
179
|
| `find(id)` | the user, or `null` — for a malformed id, an unknown one, or one of another type | |
|
|
179
180
|
| `get(id)` | the user | `NOT_FOUND` |
|
|
180
181
|
| `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
|
|
181
|
-
| `update(user, patch, { ifVersion? })` | the user as written | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
182
|
+
| `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
182
183
|
| `setActive(user, active, { ifVersion? })` | the user as written | `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
183
184
|
| `delete(user)` | `true`, or `false` for an unknown id or one of another type | |
|
|
184
185
|
|
|
@@ -187,10 +188,10 @@ With a `password`, besides:
|
|
|
187
188
|
| Method | Answers | Rejects with |
|
|
188
189
|
| --- | --- | --- |
|
|
189
190
|
| `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
190
|
-
| `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID
|
|
191
|
+
| `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID` — with `retryAfter` once the login is throttled — `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
191
192
|
| `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
|
|
192
|
-
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's reset links and second-factor challenges still waiting, and signs nobody out | `PASSWORD_TOO_SHORT` |
|
|
193
|
-
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's reset links and second-factor challenges still waiting | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
193
|
+
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's reset links and second-factor challenges still waiting, and signs nobody out; sends `user.passwordChanged` | `PASSWORD_TOO_SHORT` |
|
|
194
|
+
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's reset links and second-factor challenges still waiting; sends `user.passwordChanged` | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
194
195
|
|
|
195
196
|
With `secondFactor` configured, a type with a password also answers
|
|
196
197
|
`secondFactor.enroll`, `activate`, `disable` and `confirm` — see
|
|
@@ -220,7 +221,11 @@ await auth.delete(renamed); // true
|
|
|
220
221
|
The patch is spread over the stored fields and the result is checked against
|
|
221
222
|
the schema, so a patch can never leave a user the schema would refuse.
|
|
222
223
|
Changing the e-mail sets `emailVerified` back to `false`, and moves the login
|
|
223
|
-
with it when the e-mail is the login.
|
|
224
|
+
with it when the e-mail is the login. It also sends a
|
|
225
|
+
[`user.emailChanged` event](events.md#telling-the-user-their-password-or-e-mail-changed)
|
|
226
|
+
carrying `formerEmail`, the address before — so the notice goes to the inbox
|
|
227
|
+
the account just left. A change of case only (`Ada@…` for `ada@…`) is no
|
|
228
|
+
change, and sends nothing.
|
|
224
229
|
|
|
225
230
|
### `ifVersion`
|
|
226
231
|
|
|
@@ -293,6 +298,6 @@ fields against your schema, not that `password` is a string.
|
|
|
293
298
|
## See also
|
|
294
299
|
|
|
295
300
|
- [Sessions](sessions.md) — `authenticate`, the cookie, signing out
|
|
296
|
-
- [User events](events.md) — what `create`, `signUp` and `delete` send
|
|
301
|
+
- [User events](events.md) — what `create`, `signUp`, `update`, `setPassword`, `changePassword` and `delete` send
|
|
297
302
|
- [Errors](errors.md) — every code, and the status it deserves
|
|
298
303
|
- [Writing an adapter](adapters.md) — the identity stores behind `store`
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -59,7 +59,7 @@ either finds this row.
|
|
|
59
59
|
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `signInCode` sends a one-time code instead | |
|
|
60
60
|
| **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send` or `resetPassword.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is | "result", "payload" |
|
|
61
61
|
| **notice** | An e-mail that tells a user of a change and asks for nothing: *password changed*, *e-mail changed* — the latter sent to the former address — and *two-factor enabled*, *two-factor disabled*. `@nxgt/janus-mail` sends all four | "alert", "notification" |
|
|
62
|
-
| **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.deleted` — a `UserEvent`, naming the user by id alone
|
|
62
|
+
| **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.passwordChanged`, `user.emailChanged`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail` — with an `id` of its own. See [user events](events.md) | "webhook" — a webhook is one way to deliver it; "event" alone where it could be read as `@nxgt/janus-telemetry`'s audit log record — on a page about user events, "the event" is fine |
|
|
63
63
|
| **step-up**, **fresh** | A step-up is a signed-in user proving again who they are before a sensitive action — `stepUp.request`, then `stepUp.confirm`, with an e-mailed code or, when their second factor is active, their app's — which moves the session's `authenticatedAt` to now and opens no session. A session is **fresh** for `maxAge` after it proved who it is, at the sign-in or by a step-up; `assertFresh` refuses an older one with `STEP_UP_REQUIRED`. See [step-up](step-up.md) | "re-authentication", "sudo mode", "elevated session"; "recent sign-in" once a step-up counts too |
|
|
64
64
|
| **listener** | The one function `janus({ events })` hands every user event to, after the write and awaited | "handler", "hook", "subscriber" |
|
|
65
65
|
|
package/docs/roadmap.md
CHANGED
|
@@ -13,6 +13,12 @@ Nothing yet.
|
|
|
13
13
|
|
|
14
14
|
## Later
|
|
15
15
|
|
|
16
|
+
- **Collecting lapsed one-time tokens** — `collectExpired()` deletes lapsed
|
|
17
|
+
sessions only. On a store with no TTL (PostgreSQL), lapsed tokens stay
|
|
18
|
+
until you delete them, and the sign-in throttle adds one per login tried
|
|
19
|
+
per window. A port method to collect them, as sessions are, would let the
|
|
20
|
+
core do it.
|
|
21
|
+
|
|
16
22
|
- **More official adapters** — the ports are cut where atomicity is not
|
|
17
23
|
required, so users, sessions and permission tuples can each live in the
|
|
18
24
|
database that suits them. MongoDB is the first adapter
|
|
@@ -46,6 +52,9 @@ Nothing yet.
|
|
|
46
52
|
Valibot, ArkType); none is imposed as a peer.
|
|
47
53
|
- **A silent hasher fallback** — a user type with a password and no `hasher`
|
|
48
54
|
is refused at wiring, rather than hashed with something you did not choose.
|
|
55
|
+
- **An account lockout** — a login past its attempts waits for the next
|
|
56
|
+
window, and no number of wrong passwords blocks an account for longer:
|
|
57
|
+
a lockout would let anyone lock anyone out.
|
|
49
58
|
- **Answering `null` or `false` on an outage** — a store that cannot answer
|
|
50
59
|
throws `STORE_FAILED`, and a permission walk past `maxDepth` throws
|
|
51
60
|
`PERMISSION_DEPTH`. Neither will become a denial: that turns an outage into
|
|
@@ -61,6 +70,24 @@ Nothing yet.
|
|
|
61
70
|
The last ten, newest first, each with the version it came in. Everything
|
|
62
71
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
63
72
|
|
|
73
|
+
- **Password guessing throttled per login, v0.14.0** — past ten passwords
|
|
74
|
+
tried at one login in a 15-minute window, `signIn` answers
|
|
75
|
+
`CREDENTIALS_INVALID` with `reason: 'throttled'` and `retryAfter`, the right
|
|
76
|
+
password included, until the window ends. Nothing locks; a login nobody
|
|
77
|
+
holds is counted alike; a password sign-in that opens a session —
|
|
78
|
+
after its second factor, when one is active — starts the count again.
|
|
79
|
+
On by default: `signIn: { throttle: { attempts, window } }` changes it,
|
|
80
|
+
`signIn: { throttle: false }` turns it off. A tokens store that cannot
|
|
81
|
+
count fails the sign-in with `STORE_FAILED`. No change for adapters: the
|
|
82
|
+
counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
|
|
83
|
+
- **A password or an e-mail changed, in the user events, v0.14.0** —
|
|
84
|
+
`user.passwordChanged`, sent by `changePassword` and `setPassword` once the
|
|
85
|
+
older reset links are spent (a reset stays `user.passwordReset` alone), and
|
|
86
|
+
`user.emailChanged`, sent by an `update` that changed the e-mail, carrying
|
|
87
|
+
`formerEmail` — the one event with more than the user's id — so a notice
|
|
88
|
+
can reach the inbox the account just left, as `@nxgt/janus-mail`'s
|
|
89
|
+
`passwordChanged` and `emailChanged` notices do. Breaking for a `switch`
|
|
90
|
+
that exhausts `UserEventType`.
|
|
64
91
|
- **An old reset link stops working, v0.13.0** — `resetPassword.request`
|
|
65
92
|
spends the links sent before, so only the last e-mail's works, and writing
|
|
66
93
|
a password — a link's `confirm`, `changePassword`, `setPassword` — spends
|
|
@@ -127,21 +154,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
127
154
|
Webhooks specification (HMAC-SHA256, secrets that rotate) and posted to
|
|
128
155
|
your endpoints, retried with backoff, and reported to `onGivingUp` when
|
|
129
156
|
given up — never dropped in silence. `verifyWebhook` is the receiving side.
|
|
130
|
-
- **User events, v0.8.0** — `janus({ events })` takes one listener, called
|
|
131
|
-
with `user.created`, `user.emailVerified`, `user.passwordReset` and
|
|
132
|
-
`user.deleted` once the write landed, and awaited before the flow answers.
|
|
133
|
-
An event names the user by id alone, with a UUIDv7 of its own to deliver
|
|
134
|
-
it once; a listener that throws fails no flow and is a
|
|
135
|
-
`JANUS_EVENT_FAILED` warning.
|
|
136
|
-
- **At most one sign-in code live per user, and challenges that end when
|
|
137
|
-
they should, v0.7.0** — `signInCode.request` spends the codes sent before,
|
|
138
|
-
even when requests race, so only the last e-mail's works; writing a
|
|
139
|
-
password spends the second-factor challenges left waiting, and a sign-in
|
|
140
|
-
still running when it lands is refused; another user type's `confirm` spends a challenge
|
|
141
|
-
at its fifth attempt; `verifyEmail.confirm` and `resetPassword.confirm`
|
|
142
|
-
check the e-mail again on the record they write. `SecondFactorRequired`
|
|
143
|
-
carries `userId`, for logs and rate limits. For adapters:
|
|
144
|
-
`TokenStore.spendUserTokens(userId, kind, at, except?)`, with four new conformance
|
|
145
|
-
cases, implemented in `@nxgt/janus-drizzle`, `@nxgt/janus-mongo` and
|
|
146
|
-
`@nxgt/janus-redis` — and the port now says a read sees every write that
|
|
147
|
-
completed before it: never a secondary or a read replica.
|