@nxgt/janus 0.20.0 → 0.22.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 +112 -12
- package/dist/auth/context/index.d.ts +1 -1
- package/dist/auth/context/index.d.ts.map +1 -1
- package/dist/auth/context/user.d.ts +7 -0
- package/dist/auth/context/user.d.ts.map +1 -1
- package/dist/auth/email-flows/prove-first.d.ts +23 -0
- package/dist/auth/email-flows/prove-first.d.ts.map +1 -0
- package/dist/auth/email-flows/verify-email.d.ts +6 -2
- package/dist/auth/email-flows/verify-email.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/proven-sign-in.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +43 -0
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- package/dist/auth/types/find-by-email.d.ts +31 -0
- package/dist/auth/types/find-by-email.d.ts.map +1 -0
- package/dist/auth/types/index.d.ts +2 -1
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +2 -1
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/users/any-type-api.d.ts +2 -2
- package/dist/auth/users/any-type-api.d.ts.map +1 -1
- package/dist/auth/users/find-by-email.d.ts +11 -0
- package/dist/auth/users/find-by-email.d.ts.map +1 -0
- package/dist/auth/users/type-api.d.ts +1 -1
- package/dist/auth/users/type-api.d.ts.map +1 -1
- package/dist/index.js +74 -41
- package/dist/index.js.map +10 -8
- package/docs/README.md +2 -2
- package/docs/guide/email-flows.md +60 -1
- package/docs/guide/errors.md +3 -3
- package/docs/guide/events.md +4 -4
- package/docs/guide/magic-link.md +7 -2
- package/docs/guide/sessions.md +37 -6
- package/docs/guide/users.md +70 -0
- package/docs/guide/vocabulary.md +1 -1
- package/docs/roadmap.md +16 -15
- package/docs/troubleshooting.md +56 -6
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -11,9 +11,9 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
11
11
|
|
|
12
12
|
| Page | Read it when |
|
|
13
13
|
| --- | --- |
|
|
14
|
-
| [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
|
|
14
|
+
| [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` — or finding a user by e-mail on a type with no password (`findByEmail`) |
|
|
15
15
|
| [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry — or sign in a user your application proved itself, such as on accepting an invitation (`signInProven`) |
|
|
16
|
-
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back — and how requests that send e-mail are throttled per address or user |
|
|
16
|
+
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back — or proving an address on your own proof, such as an invitation (`verifyEmail.proveFirst`) — and how requests that send e-mail are throttled per address or user |
|
|
17
17
|
| [Signing in with an e-mailed link](guide/magic-link.md) | You are signing users in with a link sent by e-mail — a magic link: the page that confirms it from a `POST` so mail scanners spend nothing, the lifetime, the errors, and what ends a link |
|
|
18
18
|
| [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 |
|
|
19
19
|
| [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 |
|
|
@@ -111,6 +111,7 @@ with or without a password — see [sign-in codes](sign-in-code.md) and
|
|
|
111
111
|
readonly verifyEmail: {
|
|
112
112
|
send(user: UserRef): Promise<IssuedToken>; // { token, email, expiresAt }
|
|
113
113
|
confirm(token: string): Promise<User>;
|
|
114
|
+
proveFirst(user: UserRef): Promise<FirstProof<User>>; // { user, proved } — privileged, below
|
|
114
115
|
};
|
|
115
116
|
```
|
|
116
117
|
|
|
@@ -130,6 +131,64 @@ Changing the e-mail with `update` sets `emailVerified` back to `false` — and
|
|
|
130
131
|
the next sign-in by code or link then [drops the password and the second
|
|
131
132
|
factor, and signs out every session](magic-link.md#an-account-someone-else-registered).
|
|
132
133
|
|
|
134
|
+
## Proving an e-mail on your own proof — `proveFirst`
|
|
135
|
+
|
|
136
|
+
`verifyEmail.confirm` proves an address and changes nothing else: the user
|
|
137
|
+
asked for the link from inside their own account. A first sign-in by link
|
|
138
|
+
or code goes further, since anybody may have signed up with the address
|
|
139
|
+
before its holder ([why](magic-link.md#an-account-someone-else-registered)).
|
|
140
|
+
`verifyEmail.proveFirst(user)` applies that same first proof when the proof
|
|
141
|
+
is **yours** — an invitation token your server mailed to the address, and
|
|
142
|
+
the person followed:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
interface FirstProof<User> {
|
|
146
|
+
readonly user: User; // after the call: emailVerified is true either way
|
|
147
|
+
readonly proved: boolean; // true: this call proved it; false: it was already proved
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { user, proved } = await auth.verifyEmail.proveFirst(invited);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
On an e-mail **never proved**, it is exactly the ending of
|
|
154
|
+
`magicLink.confirm` and `signInCode.confirm` — the same code runs:
|
|
155
|
+
|
|
156
|
+
- one write, under the version it read, marks the e-mail verified and drops
|
|
157
|
+
the password and the second factor, active or waiting, with its recovery
|
|
158
|
+
codes;
|
|
159
|
+
- every session is revoked, and the reset links and second-factor
|
|
160
|
+
challenges are spent — before the write, so an outage there leaves the
|
|
161
|
+
e-mail unproved and a retry runs it all again, and after it, so a sign-in
|
|
162
|
+
landing in between is ended too;
|
|
163
|
+
- `user.emailVerified` is sent, then `user.passwordChanged` when a password
|
|
164
|
+
was dropped and `user.secondFactorDisabled` when an active factor was.
|
|
165
|
+
|
|
166
|
+
On an e-mail **already proved**, nothing is written, revoked or sent, and
|
|
167
|
+
`proved` is `false`. Whether it was proved is read off the user, as the
|
|
168
|
+
confirms read it: `emailVerified`.
|
|
169
|
+
|
|
170
|
+
**Call it before `setPassword` and before `signInProven`.** It drops the
|
|
171
|
+
password and revokes every session, so in the other order it erases the
|
|
172
|
+
password the invitee just chose, and signs out the session just opened.
|
|
173
|
+
Accepting an invitation is `findByEmail` (or `create`), `proveFirst`,
|
|
174
|
+
`setPassword` for a type with a password, then `signInProven` —
|
|
175
|
+
[sessions](sessions.md#accepting-an-invitation) has the route in full.
|
|
176
|
+
|
|
177
|
+
**It is privileged.** janus checks no credential: call it only right after
|
|
178
|
+
your server verified a proof that reached the inbox — an invitation token
|
|
179
|
+
it mailed to that address — on the user the proof names, whose current
|
|
180
|
+
e-mail is the address it reached. Never with an id a request supplied:
|
|
181
|
+
whoever chooses it can drop anybody's password and second factor.
|
|
182
|
+
|
|
183
|
+
It sends no e-mail, issues no token, and counts in neither the mail
|
|
184
|
+
throttle nor the sign-in throttle. An inactive user is `USER_INACTIVE`; a
|
|
185
|
+
malformed id, an unknown one or one of another type is `NOT_FOUND`, as for
|
|
186
|
+
`signInProven`; a user written between the read and the write is
|
|
187
|
+
`VERSION_CONFLICT`, and nothing is proved; an outage is `STORE_FAILED`.
|
|
188
|
+
`@nxgt/janus-telemetry` traces it as `janus.<type>.verifyEmail.proveFirst`
|
|
189
|
+
and writes `janus.email.verified` with `janus.email.proveFirst: true` when
|
|
190
|
+
it proved — the user's id, never the address.
|
|
191
|
+
|
|
133
192
|
## `resetPassword`
|
|
134
193
|
|
|
135
194
|
```ts
|
|
@@ -506,5 +565,5 @@ never the token, and no refusal's message contains it.
|
|
|
506
565
|
- [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
|
|
507
566
|
- [Sign-in links](magic-link.md) — the fourth: a link that signs in, confirmed from a `POST` so a mail scanner spends nothing
|
|
508
567
|
- [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
|
|
509
|
-
- [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms send
|
|
568
|
+
- [User events](events.md) — `user.emailVerified` and `user.passwordReset`, which the confirms and `proveFirst` send
|
|
510
569
|
- [Errors](errors.md) — every code, and the status it deserves
|
package/docs/guide/errors.md
CHANGED
|
@@ -49,15 +49,15 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
49
49
|
| Code | Class | Status | When | Carries |
|
|
50
50
|
| --- | --- | --- | --- | --- |
|
|
51
51
|
| `STORE_FAILED` | `StoreFailure` | 503 | A store could not answer. **Never a negative answer** | `slot`, `operation`, `cause` |
|
|
52
|
-
| `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, `signInProven`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
|
|
52
|
+
| `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, `signInProven`, `verifyEmail.proveFirst`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
|
|
53
53
|
| `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login | `login`, `userType` |
|
|
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` |
|
|
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. From `verifyEmail.proveFirst`, the user was written between its read and its write: nothing was proved — call it again | `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
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
|
| `MAIL_THROTTLED` | `MailThrottledError` | 429 | A request that sends e-mail was made too often for one address or one user in the window — see [the mail throttle](email-flows.md#requests-that-send-e-mail-are-throttled). **Nothing was issued, spent or rotated**: the last link or code sent still works | `retryAfter`, the whole seconds to the end of the window, at least 1; `userType`; `userId` only for the per-user flows. No `reason` |
|
|
60
|
-
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link — or to the application, by `signInProven` | `userId` |
|
|
60
|
+
| `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, the right code, or a live sign-in link — or to the application, by `signInProven` and `verifyEmail.proveFirst` | `userId` |
|
|
61
61
|
| `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` |
|
|
62
62
|
| `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses); for a sign-in link: ask for a new link — see [sign-in links](magic-link.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
|
|
63
63
|
| `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — a recovery code not the user's, or already used — see [recovery codes](second-factor.md#signing-in-with-one) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm` and from `recover`: what the challenge has left, `0` once it is spent; from `regenerateRecoveryCodes`, what the user's 15-minute window has left, `0` past the fifth — see [regenerating them](second-factor.md#regenerating-them). None from `activate`. `userId` |
|
package/docs/guide/events.md
CHANGED
|
@@ -44,12 +44,12 @@ The words — **user event**, **listener** — are defined in
|
|
|
44
44
|
| `type` | Sent by | Not sent |
|
|
45
45
|
| --- | --- | --- |
|
|
46
46
|
| `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`) |
|
|
47
|
-
| `user.emailVerified` | `verifyEmail.confirm`; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
|
|
47
|
+
| `user.emailVerified` | `verifyEmail.confirm` and `verifyEmail.proveFirst`; `resetPassword.confirm` and `magicLink.confirm`, whose link proves the e-mail; `signInCode.confirm`, whose code does | for an e-mail already verified, or a refused confirm |
|
|
48
48
|
| `user.passwordReset` | `resetPassword.confirm` | for `setPassword` or `changePassword` — they are not resets |
|
|
49
|
-
| `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; `signInCode.confirm` and `
|
|
49
|
+
| `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; `signInCode.confirm`, `magicLink.confirm` and `verifyEmail.proveFirst` when their first proof of the e-mail dropped a password, after `user.emailVerified` | for a reset, which is `user.passwordReset` alone; for a `changePassword` refused (`CREDENTIALS_INVALID`) |
|
|
50
50
|
| `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 |
|
|
51
51
|
| `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 |
|
|
52
|
-
| `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm` and `
|
|
52
|
+
| `user.secondFactorDisabled` | `secondFactor.disable`, when it removed an **active** factor; `signInCode.confirm`, `magicLink.confirm` and `verifyEmail.proveFirst` when their first proof of the e-mail removed one, after `user.emailVerified` and any `user.passwordChanged` — even if an outage interrupts the sign-outs | for a user who had no factor, a factor still waiting for its first code — it was never asked for — or a second `disable` |
|
|
53
53
|
| `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
|
|
54
54
|
| `user.recoveryCodeUsed` | `secondFactor.recover`, once the recovery code is spent — even if opening the session then fails | for a recovery code refused. How many are left is not in the event: `secondFactor.recoveryCodesLeft(event.userId)` reads it |
|
|
55
55
|
| `user.newDeviceSignedIn` | `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm` and `signInProven`, given a `device` whose token did not prove this user signed in there — once the sign-in is complete. It carries `sessionId`, the new session's. See [devices](devices.md) | for `signUp`, which mints the first token; for a call given no `device`; for a known device; for a challenge, which opens no session; for a sign-in refused — a wrong password, or a `signIn` refused after its session opened because a password was written meanwhile |
|
|
@@ -245,7 +245,7 @@ Where the listener runs within a flow, and what an outage does to it:
|
|
|
245
245
|
| `verifyEmail.confirm` | after the write — the flow's last step | — |
|
|
246
246
|
| `update` | after the write — the flow's last step | — |
|
|
247
247
|
| `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` |
|
|
248
|
-
| `signInCode.confirm`, `magicLink.confirm` | on an e-mail never verified: **after** every session is revoked and the reset links and second-factor challenges left open are spent — before the write and again after it — and **before** the new session or second-factor challenge is opened. `user.passwordChanged` follows `user.emailVerified` when a password was dropped, then `user.secondFactorDisabled` when an active second factor was removed. An e-mail already verified sends nothing | fails the call; the events are sent all the same, from a `finally`. An outage in the sign-outs before the write fails the call with nothing written and nothing sent: the next code or
|
|
248
|
+
| `signInCode.confirm`, `magicLink.confirm`, `verifyEmail.proveFirst` | on an e-mail never verified: **after** every session is revoked and the reset links and second-factor challenges left open are spent — before the write and again after it — and **before** the new session or second-factor challenge is opened. `user.passwordChanged` follows `user.emailVerified` when a password was dropped, then `user.secondFactorDisabled` when an active second factor was removed. An e-mail already verified sends nothing | fails the call; the events are sent all the same, from a `finally`. An outage in the sign-outs before the write fails the call with nothing written and nothing sent: the next code, link or `proveFirst` proves the e-mail again |
|
|
249
249
|
| `secondFactor.activate`, `secondFactor.regenerateRecoveryCodes`, `secondFactor.disable` | after the write — the flow's last step | — |
|
|
250
250
|
| `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` |
|
|
251
251
|
| `signIn`, `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm`, `magicLink.confirm`, `signInProven` — `user.newDeviceSignedIn` | **last**, once the session is open and the sign-in complete: after `user.recoveryCodeUsed`, and after the events of a first proof of the e-mail | fails the call, and `user.newDeviceSignedIn` is not sent: it comes only once the sign-in is complete |
|
package/docs/guide/magic-link.md
CHANGED
|
@@ -58,7 +58,10 @@ clinic.staff.magicLink;
|
|
|
58
58
|
token it issued and they followed — call
|
|
59
59
|
[`auth.signInProven(userId)`](sessions.md#signing-in-on-your-own-proof--signinproven):
|
|
60
60
|
the same sign-in, second factor included, with no e-mail sent, no mail
|
|
61
|
-
throttle counted and no earlier link spent.
|
|
61
|
+
throttle counted and no earlier link spent. Call
|
|
62
|
+
[`verifyEmail.proveFirst`](email-flows.md#proving-an-e-mail-on-your-own-proof--provefirst)
|
|
63
|
+
before it: the invitation reached the inbox, and a first proof drops what
|
|
64
|
+
somebody who signed up with the address left on it, as a link does. A `request` made only to be
|
|
62
65
|
confirmed at once is counted per address, so five anonymous requests for the
|
|
63
66
|
invitee's address would make the acceptance answer `MAIL_THROTTLED`.
|
|
64
67
|
|
|
@@ -307,7 +310,9 @@ their own phone. When Ada later signs in by link, `confirm` proves an e-mail
|
|
|
307
310
|
never verified, and **drops the password and the second factor, and signs
|
|
308
311
|
out every session** before it opens hers — as a
|
|
309
312
|
[password reset](email-flows.md) does. The same holds for a
|
|
310
|
-
[code](sign-in-code.md)
|
|
313
|
+
[code](sign-in-code.md), and for
|
|
314
|
+
[`verifyEmail.proveFirst`](email-flows.md#proving-an-e-mail-on-your-own-proof--provefirst),
|
|
315
|
+
which applies this proof when the proof is your own — an invitation.
|
|
311
316
|
|
|
312
317
|
- In the write that proves the e-mail, the password is removed
|
|
313
318
|
(`hasPassword` turns `false`), and so is the second factor, active or
|
package/docs/guide/sessions.md
CHANGED
|
@@ -226,22 +226,33 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
226
226
|
|
|
227
227
|
const hash = (token: string) => createHash('sha256').update(token).digest('base64url');
|
|
228
228
|
|
|
229
|
-
// Inviting:
|
|
229
|
+
// Inviting: mail a link of yours to the address — nothing of janus's is sent.
|
|
230
230
|
export async function invite(email: string) {
|
|
231
|
-
const user = await auth.create({ email, name: '' }); // no password: they will never need one
|
|
232
231
|
const token = randomBytes(32).toString('base64url');
|
|
233
|
-
await invitations.insert({ hash: hash(token),
|
|
232
|
+
await invitations.insert({ hash: hash(token), email, expiresAt: inSevenDays() });
|
|
234
233
|
await sendMail(email, `Join us: https://app.example/invitation?token=${token}`);
|
|
235
234
|
}
|
|
236
235
|
|
|
237
236
|
// Accepting — from the page's POST, never from the link's GET, which a mail scanner follows.
|
|
238
237
|
export async function accept(request: Request): Promise<Response> {
|
|
239
|
-
const
|
|
238
|
+
const body = (await request.json()) as { token: string; password?: string };
|
|
239
|
+
const { token } = body;
|
|
240
240
|
// One conditional write: spent once, and only while it has not expired.
|
|
241
241
|
const invitation = await invitations.spend(hash(String(token)), new Date());
|
|
242
242
|
if (invitation === null) return Response.json({ code: 'INVITATION_INVALID' }, { status: 404 });
|
|
243
243
|
|
|
244
|
-
|
|
244
|
+
// 1. The address is the invitation's, never the request's. The person may already have an
|
|
245
|
+
// account — findByEmail counts nothing, sends nothing — or gets one, with no password.
|
|
246
|
+
const user =
|
|
247
|
+
(await auth.findByEmail(invitation.email)) ??
|
|
248
|
+
(await auth.create({ email: invitation.email, name: '' }));
|
|
249
|
+
// 2. The invitation reached the inbox: prove the e-mail. On an address never proved this
|
|
250
|
+
// drops whatever someone who signed up with it set — password, second factor, sessions.
|
|
251
|
+
await auth.verifyEmail.proveFirst(user);
|
|
252
|
+
// 3. Only now the invitee's own password, if your form asks for one: proveFirst would drop it.
|
|
253
|
+
if (typeof body.password === 'string') await auth.setPassword(user, body.password);
|
|
254
|
+
// 4. Last: proveFirst revokes every session, so the new one opens after it.
|
|
255
|
+
const result = await auth.signInProven(user, { device: deviceOf(request) });
|
|
245
256
|
if (result.status === 'secondFactor') {
|
|
246
257
|
// keep result.challenge for the next request — never in a URL — and ask for a code
|
|
247
258
|
return Response.json({ next: 'code' });
|
|
@@ -253,6 +264,25 @@ export async function accept(request: Request): Promise<Response> {
|
|
|
253
264
|
}
|
|
254
265
|
```
|
|
255
266
|
|
|
267
|
+
**The order is the recipe.** `verifyEmail.proveFirst` (since 0.22) applies
|
|
268
|
+
the first proof of the e-mail that a sign-in link would: on an address
|
|
269
|
+
never proved, somebody may have signed up with it first, with a password
|
|
270
|
+
and a second factor of their own, and `signInProven` alone would leave them
|
|
271
|
+
both — and that person's sessions — in place. It drops them and revokes
|
|
272
|
+
every session, so it comes **before** `setPassword`, which would otherwise
|
|
273
|
+
be erased, and **before** `signInProven`, whose session would otherwise be
|
|
274
|
+
revoked. On an address already proved it does nothing. It is privileged as
|
|
275
|
+
`signInProven` is: call it on the user your verified invitation names,
|
|
276
|
+
never on an id from the request
|
|
277
|
+
([e-mail flows](email-flows.md#proving-an-e-mail-on-your-own-proof--provefirst)).
|
|
278
|
+
|
|
279
|
+
**`findByEmail` works on every type with an e-mail, password or not** —
|
|
280
|
+
since 0.21. A type with no password has no `findByLogin`, and must not
|
|
281
|
+
declare one just to be looked up: that would open the password and reset
|
|
282
|
+
flows on it. It answers whether an account exists, so call it only here,
|
|
283
|
+
after the invitation was verified, on the address the invitation holds
|
|
284
|
+
([users](users.md#finding-a-user-by-e-mail--findbyemail)).
|
|
285
|
+
|
|
256
286
|
**Before 0.20 this was `magicLink.request(email)` then `magicLink.confirm`,
|
|
257
287
|
at once — use `signInProven` instead.** `request` is counted against the
|
|
258
288
|
mail throttle per address, five per 10 minutes by default: anybody asking
|
|
@@ -278,7 +308,8 @@ a link or a code already in the person's inbox still works afterwards.
|
|
|
278
308
|
`TypeError`, as on every sign-in.
|
|
279
309
|
- **No e-mail proved.** `emailVerified` stays as it was: the call does not
|
|
280
310
|
know your proof reached the inbox. Nothing is dropped either — unlike a
|
|
281
|
-
first proof by link or code, the password and second factor stay.
|
|
311
|
+
first proof by link or code, the password and second factor stay. When
|
|
312
|
+
your proof did reach the inbox, call `verifyEmail.proveFirst` first.
|
|
282
313
|
- **No throttle.** Neither the mail throttle nor the sign-in throttle counts
|
|
283
314
|
it, and it restarts neither: a login past its password attempts is still
|
|
284
315
|
throttled for `signIn`. Limit your own acceptance route per client, as
|
package/docs/guide/users.md
CHANGED
|
@@ -198,6 +198,12 @@ With a `password`, besides:
|
|
|
198
198
|
| `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` |
|
|
199
199
|
| `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` |
|
|
200
200
|
|
|
201
|
+
With an e-mail — the field `email` names, password or not — besides:
|
|
202
|
+
|
|
203
|
+
| Method | Answers | Rejects with |
|
|
204
|
+
| --- | --- | --- |
|
|
205
|
+
| `findByEmail(email)` | the user, or `null`; the address trimmed and lowercased as the e-mail flows do, whatever the login; a value that is no address is nobody's — **server-side only**, see [below](#finding-a-user-by-e-mail--findbyemail) | `STORE_FAILED` only |
|
|
206
|
+
|
|
201
207
|
With `secondFactor` configured, a type with a password also answers
|
|
202
208
|
`secondFactor.enroll`, `activate`, `disable` and `confirm` — see
|
|
203
209
|
[the second factor](second-factor.md).
|
|
@@ -216,6 +222,7 @@ await auth.get(grace.id); // User, or NOT_FOUND
|
|
|
216
222
|
const renamed = await auth.update(grace, { name: 'Grace Hopper' }, { ifVersion: grace.version });
|
|
217
223
|
await auth.setActive(renamed, true);
|
|
218
224
|
await auth.findByLogin(' GRACE@example.com '); // found: normalised as sign-up normalised it
|
|
225
|
+
await auth.findByEmail(' GRACE@example.com '); // found: normalised as the e-mail flows normalise it
|
|
219
226
|
await auth.setPassword(renamed, 'a new password');
|
|
220
227
|
await auth.changePassword(renamed, { current: 'a new password', next: 'another password' });
|
|
221
228
|
await auth.delete(renamed); // true
|
|
@@ -298,6 +305,69 @@ const staffById = new DataLoader(async (ids: readonly string[]) => {
|
|
|
298
305
|
});
|
|
299
306
|
```
|
|
300
307
|
|
|
308
|
+
### Finding a user by e-mail — `findByEmail`
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
findByEmail(email: string): Promise<User | null>
|
|
312
|
+
// typed by FindByEmailApi<User>, exported from @nxgt/janus
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Every type with an e-mail has it, **with or without a password** — and a
|
|
316
|
+
type with no password has no `findByLogin`. It is the lookup an invitation
|
|
317
|
+
needs: the person was invited at an address, may already hold an account of
|
|
318
|
+
a passwordless type, and is signed in with `signInProven` once your server
|
|
319
|
+
verified the invitation and `verifyEmail.proveFirst` proved the address
|
|
320
|
+
([sessions](sessions.md#accepting-an-invitation)):
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
const clinic = janus({
|
|
324
|
+
users: {
|
|
325
|
+
patient: { schema: Patient, password: { login: 'email' } },
|
|
326
|
+
member: { schema: z.object({ email: z.email(), name: z.string() }) }, // no password, by design
|
|
327
|
+
},
|
|
328
|
+
store,
|
|
329
|
+
hasher,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const invitation = await invitations.spend(hash(token), new Date()); // your proof, verified first
|
|
333
|
+
if (invitation === null) return notFound();
|
|
334
|
+
const member =
|
|
335
|
+
(await clinic.member.findByEmail(invitation.email)) ??
|
|
336
|
+
(await clinic.member.create({ email: invitation.email, name: '' }));
|
|
337
|
+
await clinic.member.verifyEmail.proveFirst(member); // before signInProven: it revokes every session
|
|
338
|
+
await clinic.member.signInProven(member);
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Do not declare a `password` on such a type to get `findByLogin`: it would
|
|
342
|
+
open `signIn`, `resetPassword` and the password changes on a type that has
|
|
343
|
+
none by design.
|
|
344
|
+
|
|
345
|
+
- **The holder the e-mail flows find.** The address is trimmed and
|
|
346
|
+
lowercased — the rule every e-mail follows, whatever the type's
|
|
347
|
+
`password.normalize` — and compared with the stored e-mail. On a type that
|
|
348
|
+
signs in with a username, the user is found by their e-mail, and a
|
|
349
|
+
username that only looks like an address is nobody's:
|
|
350
|
+
`magicLink.request`, `signInCode.request` and `resetPassword.request`
|
|
351
|
+
would find the same user, or nobody.
|
|
352
|
+
- **Per type.** The same address may hold a patient and a member; each
|
|
353
|
+
type answers its own, and `null` for the other's.
|
|
354
|
+
- **Inactive users are answered**, as by `find`. `signInProven` then
|
|
355
|
+
refuses them with `USER_INACTIVE`.
|
|
356
|
+
- **Nothing counted, issued or written.** Neither the mail throttle nor the
|
|
357
|
+
sign-in throttle counts it, so it answers past both, and leaves both as
|
|
358
|
+
they were. The store is read once, by `findUserByLogin`.
|
|
359
|
+
- **A value that is no address is nobody's.** Not a string, empty once
|
|
360
|
+
trimmed, or holding a NUL or a lone surrogate: `null` without asking the
|
|
361
|
+
store — as `find` answers a malformed id, never a throw, since the value
|
|
362
|
+
may come from a request.
|
|
363
|
+
- **An outage is `STORE_FAILED`**, never `null`: a lookup that could not
|
|
364
|
+
ask is not an absence.
|
|
365
|
+
- **Server-side only.** It answers whether an account exists, at once and
|
|
366
|
+
with no throttle. Never call it on an address a visitor typed into an
|
|
367
|
+
anonymous route; call it on the address your own proof holds.
|
|
368
|
+
`@nxgt/janus-telemetry` traces it as `janus.<type>.findByEmail`, with the
|
|
369
|
+
`user.id` it found and never the address.
|
|
370
|
+
|
|
301
371
|
### Paging every user
|
|
302
372
|
|
|
303
373
|
```ts
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -43,7 +43,7 @@ either finds this row.
|
|
|
43
43
|
| **credential** | What a caller presents to prove who they are: a login and a password at sign-in (`CredentialError`, `CREDENTIALS_INVALID`), or a token on a request — always written *session credential* | |
|
|
44
44
|
| **password policy** | The rules a new password must meet: `minLength`, `normalize` | the model |
|
|
45
45
|
| **session** | A signed-in period, carried by a **session token** | |
|
|
46
|
-
| **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out, a password reset, or the first proof of an e-mail by a code
|
|
46
|
+
| **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out, a password reset, or the first proof of an e-mail by a code, a link or `verifyEmail.proveFirst`; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
|
|
47
47
|
| **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
|
|
48
48
|
| **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
|
|
49
49
|
| **one-time token** | A single-use token sent by e-mail in a link, for verification, a password reset or a sign-in | "code": a code is typed, a token is followed in a link |
|
package/docs/roadmap.md
CHANGED
|
@@ -76,6 +76,22 @@ Nothing yet.
|
|
|
76
76
|
The last ten, newest first, each with the version it came in. Everything
|
|
77
77
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
78
78
|
|
|
79
|
+
- **Proving an e-mail on the application's own proof, v0.22.0** —
|
|
80
|
+
`auth.<type>.verifyEmail.proveFirst(user)` applies the first proof of an
|
|
81
|
+
e-mail that a sign-in link or code applies — verified, the password and
|
|
82
|
+
the second factor dropped, every session revoked — when the proof is the
|
|
83
|
+
application's, such as an invitation token mailed to the address. An
|
|
84
|
+
e-mail already proved changes nothing; the answer says which,
|
|
85
|
+
`{ user, proved }`. Nothing is counted, sent or issued. An invitation is
|
|
86
|
+
accepted in this order: find or create, `proveFirst`, `setPassword`,
|
|
87
|
+
`signInProven`. Privileged: never with an id a request supplied.
|
|
88
|
+
- **Finding a user by e-mail on every type, v0.21.0** —
|
|
89
|
+
`auth.<type>.findByEmail(email)` answers the user of the type holding the
|
|
90
|
+
address, or `null`, on every type with an e-mail — a type with no
|
|
91
|
+
password included, which has no `findByLogin`. The address is normalised
|
|
92
|
+
and compared as the e-mail flows do; nothing is counted, issued or
|
|
93
|
+
written. It is the lookup before `signInProven` when an invitation holds
|
|
94
|
+
an address. Server-side only: it tells whether an account exists.
|
|
79
95
|
- **Signing in on the application's own proof, v0.20.0** —
|
|
80
96
|
`auth.<type>.signInProven(user, { device })` signs in a user the
|
|
81
97
|
application proved itself — an invitation token it issued — and answers
|
|
@@ -151,18 +167,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
151
167
|
`signIn: { throttle: false }` turns it off. A tokens store that cannot
|
|
152
168
|
count fails the sign-in with `STORE_FAILED`. No change for adapters: the
|
|
153
169
|
counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
|
|
154
|
-
- **A password or an e-mail changed, in the user events, v0.14.0** —
|
|
155
|
-
`user.passwordChanged`, sent by `changePassword` and `setPassword` once the
|
|
156
|
-
older reset links are spent (a reset stays `user.passwordReset` alone), and
|
|
157
|
-
`user.emailChanged`, sent by an `update` that changed the e-mail, carrying
|
|
158
|
-
`formerEmail` — the one event with more than the user's id — so a notice
|
|
159
|
-
can reach the inbox the account just left, as `@nxgt/janus-mail`'s
|
|
160
|
-
`passwordChanged` and `emailChanged` notices do. Breaking for a `switch`
|
|
161
|
-
that exhausts `UserEventType`.
|
|
162
|
-
- **An old reset link stops working, v0.13.0** — `resetPassword.request`
|
|
163
|
-
spends the links sent before, so only the last e-mail's works, and writing
|
|
164
|
-
a password — a link's `confirm`, `changePassword`, `setPassword` — spends
|
|
165
|
-
every reset link still live, so a link sent before the change cannot
|
|
166
|
-
replace the new password. Both answer `TOKEN_SPENT`; an outage spending
|
|
167
|
-
them fails the call with `STORE_FAILED`. No change for adapters: the
|
|
168
|
-
existing `TokenStore.spendUserTokens` does it.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -53,6 +53,7 @@ How the messages are shaped:
|
|
|
53
53
|
- [`STORE_FAILED` — `users.findUsers answered no list …`](#store_failed--usersfindusers-answered-no-list-an-absence-is-a-shorter-list-so-this-store-forgot-to-answer)
|
|
54
54
|
- [`<call>: ids must be an array of user ids`](#call-ids-must-be-an-array-of-user-ids)
|
|
55
55
|
- [`findMany` answers fewer users than ids](#findmany-answers-fewer-users-than-ids)
|
|
56
|
+
- [`findByEmail` answers `null` for an address somebody holds](#findbyemail-answers-null-for-an-address-somebody-holds)
|
|
56
57
|
- [`NOT_FOUND` — `<call>: no <type> has this id`](#not_found--call-no-type-has-this-id)
|
|
57
58
|
- [`LOGIN_TAKEN` — `<call>: the login is taken by another <type>`](#login_taken--call-the-login-is-taken-by-another-type)
|
|
58
59
|
- [`VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`](#version_conflict--call-expected-version-n-found-m)
|
|
@@ -62,6 +63,7 @@ How the messages are shaped:
|
|
|
62
63
|
- [`CREDENTIALS_INVALID` — `<call>: too many passwords tried at this login …`](#credentials_invalid--call-too-many-passwords-tried-at-this-login--wait-for-the-next-window)
|
|
63
64
|
- [`MAIL_THROTTLED` — `<call>: too many e-mails asked for this address …`, or `… for this user …`](#mail_throttled--call-too-many-e-mails-asked-for-this-address--the-last-one-sent-still-works-use-it-or-wait-for-the-next-window)
|
|
64
65
|
- [Accepting an invitation answers `MAIL_THROTTLED`](#accepting-an-invitation-answers-mail_throttled)
|
|
66
|
+
- [The password chosen when accepting an invitation answers `CREDENTIALS_INVALID` (`noPassword`)](#the-password-chosen-when-accepting-an-invitation-answers-credentials_invalid-nopassword)
|
|
65
67
|
- [`MAIL_THROTTLED` never reaches the visitor: `request` runs in the background](#mail_throttled-never-reaches-the-visitor-request-runs-in-the-background)
|
|
66
68
|
- [`<flow>.prepare(…).send: already called — …`](#flowpreparesend-already-called--a-prepared-request-sends-once-call-flowprepare-again-for-another)
|
|
67
69
|
- [`STORE_FAILED` — `<call>: the store dropped the attempts it had just stored`](#store_failed--call-the-store-dropped-the-attempts-it-had-just-stored)
|
|
@@ -584,9 +586,22 @@ const byId = new Map((await auth.staff.findMany(ids)).map((user) => [user.id, us
|
|
|
584
586
|
const member = byId.get(row.staffId); // undefined: nobody of this type holds it
|
|
585
587
|
```
|
|
586
588
|
|
|
589
|
+
### `findByEmail` answers `null` for an address somebody holds
|
|
590
|
+
|
|
591
|
+
**When:** `auth.<type>.findByEmail(email)` on an address you can see on a user.
|
|
592
|
+
**Why:** one of three. The user is of **another type**: each type answers its own users, and the same address may hold one of each. The address is the type's **login, not its e-mail**: on a type that signs in with a username, only the field `email` names is looked up, and a username that looks like an address is nobody's. Or the value is **no address**: not a string — a `{ email }` object from JavaScript, an `undefined` read off a record — empty once trimmed, or holding a NUL or a lone surrogate; it answers `null` without asking the store, as `find` answers a malformed id.
|
|
593
|
+
**Fix:** ask the type the user is of, pass the e-mail itself, and for a username use `findByLogin`:
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
const member = await clinic.member.findByEmail(invitation.email); // a string: the address the invitation holds
|
|
597
|
+
const staff = await clinic.staff.findByLogin('grace'); // a username is a login, not an e-mail
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
An outage is never `null` here: it rejects with `STORE_FAILED`.
|
|
601
|
+
|
|
587
602
|
### `NOT_FOUND` — `<call>: no <type> has this id`
|
|
588
603
|
|
|
589
|
-
`NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `signInProven`, `verifyEmail.send`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
|
|
604
|
+
`NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `signInProven`, `verifyEmail.send`, `verifyEmail.proveFirst`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
|
|
590
605
|
|
|
591
606
|
**When:** the id names nobody, names a user of another type (`auth.staff.get(patientId)`), or is not an id at all.
|
|
592
607
|
**Why:** `get*` calls turn an absence into `NOT_FOUND`; `find*` calls answer `null` instead. `signInProven` is a `get`: a sign-in that found nobody throws, and on it `NOT_FOUND` means the proof you verified names a user deleted since, or you passed the id of another type's user — or a value that is not the proof's id at all.
|
|
@@ -617,6 +632,13 @@ const user = await auth.get(id);
|
|
|
617
632
|
await auth.update(user, { name }, { ifVersion: user.version });
|
|
618
633
|
```
|
|
619
634
|
|
|
635
|
+
**From `verifyEmail.proveFirst`**, with no `ifVersion` of yours: the user
|
|
636
|
+
was written between the read that found the e-mail never proved and the
|
|
637
|
+
write that proves it — by the user, or by a second acceptance of the same
|
|
638
|
+
invitation. Nothing was proved, though the sessions may already be revoked.
|
|
639
|
+
Call it again: it reads the user anew, and answers `proved: false` when the
|
|
640
|
+
other write proved the e-mail.
|
|
641
|
+
|
|
620
642
|
**From `secondFactor.confirm`**, the message is the store's:
|
|
621
643
|
`updateUser: expected version <n>, found <m>`.
|
|
622
644
|
|
|
@@ -775,19 +797,47 @@ sent still works, though the invitee was sent none.
|
|
|
775
797
|
loop from filling an inbox. A sign-in your server already proved — the
|
|
776
798
|
invitation token it issued — does not need an e-mailed link, and should not
|
|
777
799
|
share its count.
|
|
778
|
-
**Fix:** call `signInProven` with the
|
|
800
|
+
**Fix:** call `signInProven` with the user from the invitation's record, after
|
|
779
801
|
verifying the token yourself. It counts nothing, sends nothing, spends no
|
|
780
|
-
link already sent, and answers what `signIn` answers.
|
|
802
|
+
link already sent, and answers what `signIn` answers. An invitation that
|
|
803
|
+
holds an address rather than an id finds the account with `findByEmail`,
|
|
804
|
+
which counts nothing either — on any type with an e-mail, password or not.
|
|
781
805
|
|
|
782
806
|
```ts
|
|
783
807
|
const invitation = await invitations.spend(hash(token), new Date()); // yours: once, unexpired
|
|
784
808
|
if (invitation === null) return new Response(null, { status: 404 });
|
|
785
|
-
const
|
|
809
|
+
const user = await auth.findByEmail(invitation.email); // or invitation.userId, when you stored the id
|
|
810
|
+
if (user === null) return new Response(null, { status: 404 });
|
|
811
|
+
await auth.verifyEmail.proveFirst(user); // the invitation reached the inbox — before signInProven
|
|
812
|
+
const result = await auth.signInProven(user, { device: deviceOf(request) });
|
|
786
813
|
```
|
|
787
814
|
|
|
788
815
|
Never pass it an id the request carries: it checks no credential. See
|
|
789
816
|
[sessions](guide/sessions.md#signing-in-on-your-own-proof--signinproven).
|
|
790
817
|
|
|
818
|
+
### The password chosen when accepting an invitation answers `CREDENTIALS_INVALID` (`noPassword`)
|
|
819
|
+
|
|
820
|
+
**When:** the acceptance route calls `setPassword` with the password the
|
|
821
|
+
invitee chose, then `verifyEmail.proveFirst`; the invitee is signed in, and
|
|
822
|
+
their next `signIn` with that password answers `CREDENTIALS_INVALID` with
|
|
823
|
+
`reason: 'noPassword'`. Or the route calls `signInProven` before
|
|
824
|
+
`proveFirst`, and the session it just opened is anonymous on the next
|
|
825
|
+
request.
|
|
826
|
+
**Why:** on an e-mail never proved, `proveFirst` drops the password and the
|
|
827
|
+
second factor and revokes every session — whatever was set before it is
|
|
828
|
+
taken for what somebody who signed up with the address left there. It
|
|
829
|
+
cannot tell the invitee's fresh password from a squatter's.
|
|
830
|
+
**Fix:** prove first, then set the password, then sign in:
|
|
831
|
+
|
|
832
|
+
```ts
|
|
833
|
+
const user = (await auth.findByEmail(invitation.email)) ?? (await auth.create({ email: invitation.email, name }));
|
|
834
|
+
await auth.verifyEmail.proveFirst(user); // 1. drops what was there before
|
|
835
|
+
await auth.setPassword(user, body.password); // 2. the invitee's own
|
|
836
|
+
const result = await auth.signInProven(user, { device: deviceOf(request) }); // 3. last
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
See [e-mail flows](guide/email-flows.md#proving-an-e-mail-on-your-own-proof--provefirst).
|
|
840
|
+
|
|
791
841
|
### `MAIL_THROTTLED` never reaches the visitor: `request` runs in the background
|
|
792
842
|
|
|
793
843
|
**When:** the application runs `magicLink.request`, `signInCode.request` or
|
|
@@ -867,7 +917,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
867
917
|
|
|
868
918
|
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
869
919
|
|
|
870
|
-
**When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven`, for an inactive user: the application's proof does not reactivate anybody.
|
|
920
|
+
**When:** `signIn`, with the **right** password, for a user set inactive. Also `secondFactor.confirm` and `secondFactor.recover`, for a user set inactive after `signIn` asked for a code, `signInCode.confirm`, for a user set inactive after the code was sent, and `magicLink.confirm`, for one set inactive after the link was sent. And `signInProven` and `verifyEmail.proveFirst`, for an inactive user: the application's proof does not reactivate anybody, and proves nothing on an inactive one.
|
|
871
921
|
**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. `magicLink.confirm` checks after spending the link, which is spent too, and `magicLink.request` answers `null` for an inactive user.
|
|
872
922
|
**Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
|
|
873
923
|
|
|
@@ -1006,7 +1056,7 @@ const page = await access.list(user, 'view', 'record', {
|
|
|
1006
1056
|
|
|
1007
1057
|
**When:** `signIn`, `findByLogin`, `setPassword`, `changePassword`, `resetPassword.*`, from JavaScript. In TypeScript, these methods are absent from a type without `password`.
|
|
1008
1058
|
**Why:** the user type has no `password` option.
|
|
1009
|
-
**Fix:** `users: { staff: { schema: Staff, password: { login: 'email' } } }
|
|
1059
|
+
**Fix:** `users: { staff: { schema: Staff, password: { login: 'email' } } }` — when the type should sign in with a password. To **find** a user of a type with no password, call `findByEmail(email)` instead of `findByLogin`: declaring a password only to look users up opens `signIn` and `resetPassword` on a type that has none by design.
|
|
1010
1060
|
|
|
1011
1061
|
### `authenticate()` answers `null` although a valid cookie was sent
|
|
1012
1062
|
|