@nxgt/janus 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -8
- 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/index.d.ts +1 -1
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/index.js +61 -41
- package/dist/index.js.map +8 -7
- package/docs/README.md +1 -1
- 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 +23 -3
- package/docs/guide/users.md +2 -1
- package/docs/guide/vocabulary.md +1 -1
- package/docs/roadmap.md +9 -8
- package/docs/troubleshooting.md +34 -2
- package/package.json +3 -2
package/docs/README.md
CHANGED
|
@@ -13,7 +13,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
13
13
|
| --- | --- |
|
|
14
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
|
@@ -235,16 +235,23 @@ export async function invite(email: string) {
|
|
|
235
235
|
|
|
236
236
|
// Accepting — from the page's POST, never from the link's GET, which a mail scanner follows.
|
|
237
237
|
export async function accept(request: Request): Promise<Response> {
|
|
238
|
-
const
|
|
238
|
+
const body = (await request.json()) as { token: string; password?: string };
|
|
239
|
+
const { token } = body;
|
|
239
240
|
// One conditional write: spent once, and only while it has not expired.
|
|
240
241
|
const invitation = await invitations.spend(hash(String(token)), new Date());
|
|
241
242
|
if (invitation === null) return Response.json({ code: 'INVITATION_INVALID' }, { status: 404 });
|
|
242
243
|
|
|
243
|
-
// The address is the invitation's, never the request's. The person may already have an
|
|
244
|
+
// 1. The address is the invitation's, never the request's. The person may already have an
|
|
244
245
|
// account — findByEmail counts nothing, sends nothing — or gets one, with no password.
|
|
245
246
|
const user =
|
|
246
247
|
(await auth.findByEmail(invitation.email)) ??
|
|
247
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.
|
|
248
255
|
const result = await auth.signInProven(user, { device: deviceOf(request) });
|
|
249
256
|
if (result.status === 'secondFactor') {
|
|
250
257
|
// keep result.challenge for the next request — never in a URL — and ask for a code
|
|
@@ -257,6 +264,18 @@ export async function accept(request: Request): Promise<Response> {
|
|
|
257
264
|
}
|
|
258
265
|
```
|
|
259
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
|
+
|
|
260
279
|
**`findByEmail` works on every type with an e-mail, password or not** —
|
|
261
280
|
since 0.21. A type with no password has no `findByLogin`, and must not
|
|
262
281
|
declare one just to be looked up: that would open the password and reset
|
|
@@ -289,7 +308,8 @@ a link or a code already in the person's inbox still works afterwards.
|
|
|
289
308
|
`TypeError`, as on every sign-in.
|
|
290
309
|
- **No e-mail proved.** `emailVerified` stays as it was: the call does not
|
|
291
310
|
know your proof reached the inbox. Nothing is dropped either — unlike a
|
|
292
|
-
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.
|
|
293
313
|
- **No throttle.** Neither the mail throttle nor the sign-in throttle counts
|
|
294
314
|
it, and it restarts neither: a login past its password attempts is still
|
|
295
315
|
throttled for `signIn`. Limit your own acceptance route per client, as
|
package/docs/guide/users.md
CHANGED
|
@@ -316,7 +316,7 @@ Every type with an e-mail has it, **with or without a password** — and a
|
|
|
316
316
|
type with no password has no `findByLogin`. It is the lookup an invitation
|
|
317
317
|
needs: the person was invited at an address, may already hold an account of
|
|
318
318
|
a passwordless type, and is signed in with `signInProven` once your server
|
|
319
|
-
verified the invitation
|
|
319
|
+
verified the invitation and `verifyEmail.proveFirst` proved the address
|
|
320
320
|
([sessions](sessions.md#accepting-an-invitation)):
|
|
321
321
|
|
|
322
322
|
```ts
|
|
@@ -334,6 +334,7 @@ if (invitation === null) return notFound();
|
|
|
334
334
|
const member =
|
|
335
335
|
(await clinic.member.findByEmail(invitation.email)) ??
|
|
336
336
|
(await clinic.member.create({ email: invitation.email, name: '' }));
|
|
337
|
+
await clinic.member.verifyEmail.proveFirst(member); // before signInProven: it revokes every session
|
|
337
338
|
await clinic.member.signInProven(member);
|
|
338
339
|
```
|
|
339
340
|
|
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,15 @@ 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.
|
|
79
88
|
- **Finding a user by e-mail on every type, v0.21.0** —
|
|
80
89
|
`auth.<type>.findByEmail(email)` answers the user of the type holding the
|
|
81
90
|
address, or `null`, on every type with an e-mail — a type with no
|
|
@@ -158,11 +167,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
158
167
|
`signIn: { throttle: false }` turns it off. A tokens store that cannot
|
|
159
168
|
count fails the sign-in with `STORE_FAILED`. No change for adapters: the
|
|
160
169
|
counts are `secondFactor` tokens, counted by `TokenStore.countAttempt`.
|
|
161
|
-
- **A password or an e-mail changed, in the user events, v0.14.0** —
|
|
162
|
-
`user.passwordChanged`, sent by `changePassword` and `setPassword` once the
|
|
163
|
-
older reset links are spent (a reset stays `user.passwordReset` alone), and
|
|
164
|
-
`user.emailChanged`, sent by an `update` that changed the e-mail, carrying
|
|
165
|
-
`formerEmail` — the one event with more than the user's id — so a notice
|
|
166
|
-
can reach the inbox the account just left, as `@nxgt/janus-mail`'s
|
|
167
|
-
`passwordChanged` and `emailChanged` notices do. Breaking for a `switch`
|
|
168
|
-
that exhausts `UserEventType`.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -63,6 +63,7 @@ How the messages are shaped:
|
|
|
63
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)
|
|
64
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)
|
|
65
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)
|
|
66
67
|
- [`MAIL_THROTTLED` never reaches the visitor: `request` runs in the background](#mail_throttled-never-reaches-the-visitor-request-runs-in-the-background)
|
|
67
68
|
- [`<flow>.prepare(…).send: already called — …`](#flowpreparesend-already-called--a-prepared-request-sends-once-call-flowprepare-again-for-another)
|
|
68
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)
|
|
@@ -600,7 +601,7 @@ An outage is never `null` here: it rejects with `STORE_FAILED`.
|
|
|
600
601
|
|
|
601
602
|
### `NOT_FOUND` — `<call>: no <type> has this id`
|
|
602
603
|
|
|
603
|
-
`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`.
|
|
604
605
|
|
|
605
606
|
**When:** the id names nobody, names a user of another type (`auth.staff.get(patientId)`), or is not an id at all.
|
|
606
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.
|
|
@@ -631,6 +632,13 @@ const user = await auth.get(id);
|
|
|
631
632
|
await auth.update(user, { name }, { ifVersion: user.version });
|
|
632
633
|
```
|
|
633
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
|
+
|
|
634
642
|
**From `secondFactor.confirm`**, the message is the store's:
|
|
635
643
|
`updateUser: expected version <n>, found <m>`.
|
|
636
644
|
|
|
@@ -800,12 +808,36 @@ const invitation = await invitations.spend(hash(token), new Date()); // yours: o
|
|
|
800
808
|
if (invitation === null) return new Response(null, { status: 404 });
|
|
801
809
|
const user = await auth.findByEmail(invitation.email); // or invitation.userId, when you stored the id
|
|
802
810
|
if (user === null) return new Response(null, { status: 404 });
|
|
811
|
+
await auth.verifyEmail.proveFirst(user); // the invitation reached the inbox — before signInProven
|
|
803
812
|
const result = await auth.signInProven(user, { device: deviceOf(request) });
|
|
804
813
|
```
|
|
805
814
|
|
|
806
815
|
Never pass it an id the request carries: it checks no credential. See
|
|
807
816
|
[sessions](guide/sessions.md#signing-in-on-your-own-proof--signinproven).
|
|
808
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
|
+
|
|
809
841
|
### `MAIL_THROTTLED` never reaches the visitor: `request` runs in the background
|
|
810
842
|
|
|
811
843
|
**When:** the application runs `magicLink.request`, `signInCode.request` or
|
|
@@ -885,7 +917,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
885
917
|
|
|
886
918
|
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
887
919
|
|
|
888
|
-
**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.
|
|
889
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.
|
|
890
922
|
**Fix:** answer 403, or reactivate, then sign in again: `await auth.setActive(user, true)`.
|
|
891
923
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Embeddable, type-safe identities and permissions: bring your own database",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -62,9 +62,10 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/bun": "^1.4.2",
|
|
65
|
+
"typescript-6": "npm:typescript@~6.0.3",
|
|
65
66
|
"zod": "^4"
|
|
66
67
|
},
|
|
67
68
|
"peerDependencies": {
|
|
68
|
-
"typescript": "^6.0.3"
|
|
69
|
+
"typescript": "^6.0.3 || ^7.0.0"
|
|
69
70
|
}
|
|
70
71
|
}
|