@nxgt/janus 0.10.0 → 0.12.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 +118 -18
- package/dist/auth/config/janus-config.d.ts +2 -0
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolved-config.d.ts +1 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/index.d.ts +2 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/port/assert-stores.d.ts.map +1 -1
- package/dist/auth/port/memory/sessions.d.ts.map +1 -1
- package/dist/auth/port/types/sessions.d.ts +16 -1
- package/dist/auth/port/types/sessions.d.ts.map +1 -1
- package/dist/auth/port/types/tokens.d.ts +7 -2
- package/dist/auth/port/types/tokens.d.ts.map +1 -1
- package/dist/auth/second-factor/{regenerate-attempts.d.ts → app-code-attempts.d.ts} +13 -10
- package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -0
- package/dist/auth/second-factor/codes-left.d.ts +14 -0
- package/dist/auth/second-factor/codes-left.d.ts.map +1 -0
- package/dist/auth/second-factor/flows.d.ts +1 -0
- package/dist/auth/second-factor/flows.d.ts.map +1 -1
- package/dist/auth/sessions/authenticate.d.ts.map +1 -1
- package/dist/auth/sessions/fresh.d.ts +26 -0
- package/dist/auth/sessions/fresh.d.ts.map +1 -0
- package/dist/auth/sessions/standing.d.ts +20 -0
- package/dist/auth/sessions/standing.d.ts.map +1 -0
- package/dist/auth/step-up/codes.d.ts +29 -0
- package/dist/auth/step-up/codes.d.ts.map +1 -0
- package/dist/auth/step-up/flows.d.ts +19 -0
- package/dist/auth/step-up/flows.d.ts.map +1 -0
- package/dist/auth/step-up/open.d.ts +23 -0
- package/dist/auth/step-up/open.d.ts.map +1 -0
- package/dist/auth/step-up/request.d.ts +12 -0
- package/dist/auth/step-up/request.d.ts.map +1 -0
- package/dist/auth/types/index.d.ts +1 -0
- 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/types/second-factor.d.ts +9 -0
- package/dist/auth/types/second-factor.d.ts.map +1 -1
- package/dist/auth/types/step-up.d.ts +79 -0
- package/dist/auth/types/step-up.d.ts.map +1 -0
- 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/type-api.d.ts +1 -1
- package/dist/auth/users/type-api.d.ts.map +1 -1
- package/dist/chunks/{index-5e2fw7he.js → index-j2gd8fry.js} +12 -3
- package/dist/chunks/{index-5e2fw7he.js.map → index-j2gd8fry.js.map} +4 -4
- package/dist/chunks/{index-5tm04t5p.js → index-jp2kka6d.js} +2 -2
- package/dist/chunks/{index-6f6wja93.js → index-km7ffctx.js} +2 -2
- package/dist/chunks/{index-4m49cye4.js → index-rd2rddvv.js} +15 -12
- package/dist/chunks/{index-4m49cye4.js.map → index-rd2rddvv.js.map} +3 -3
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/sessions/index.d.ts.map +1 -1
- package/dist/conformance/cases/sessions/reauthentication.d.ts +4 -0
- package/dist/conformance/cases/sessions/reauthentication.d.ts.map +1 -0
- package/dist/conformance/cases/tokens/index.d.ts.map +1 -1
- package/dist/conformance/cases/tokens/kinds.d.ts +4 -0
- package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -0
- package/dist/conformance/index.js +147 -60
- package/dist/conformance/index.js.map +8 -6
- package/dist/errors/janus-error/codes.d.ts +7 -0
- package/dist/errors/janus-error/codes.d.ts.map +1 -1
- package/dist/errors/janus-error/index.d.ts +1 -1
- package/dist/errors/janus-error/index.d.ts.map +1 -1
- package/dist/errors/janus-error/refusals.d.ts +8 -0
- package/dist/errors/janus-error/refusals.d.ts.map +1 -1
- package/dist/errors/janus-error/status.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +184 -19
- package/dist/index.js.map +18 -11
- package/dist/permissions/index.js +3 -3
- package/docs/README.md +1 -0
- package/docs/guide/adapters.md +38 -4
- package/docs/guide/errors.md +1 -0
- package/docs/guide/events.md +21 -8
- package/docs/guide/second-factor.md +45 -16
- package/docs/guide/sessions.md +7 -0
- package/docs/guide/step-up.md +178 -0
- package/docs/guide/users.md +1 -0
- package/docs/guide/vocabulary.md +3 -2
- package/docs/roadmap.md +18 -23
- package/docs/troubleshooting.md +183 -8
- package/package.json +1 -1
- package/dist/auth/second-factor/regenerate-attempts.d.ts.map +0 -1
- /package/dist/chunks/{index-5tm04t5p.js.map → index-jp2kka6d.js.map} +0 -0
- /package/dist/chunks/{index-6f6wja93.js.map → index-km7ffctx.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-jp2kka6d.js";
|
|
8
8
|
import {
|
|
9
9
|
PermissionDepthError2,
|
|
10
10
|
isSetOf2,
|
|
11
11
|
formatEntity2
|
|
12
|
-
} from "../chunks/index-
|
|
12
|
+
} from "../chunks/index-j2gd8fry.js";
|
|
13
13
|
import {
|
|
14
14
|
createMemoryRelations2
|
|
15
|
-
} from "../chunks/index-
|
|
15
|
+
} from "../chunks/index-km7ffctx.js";
|
|
16
16
|
|
|
17
17
|
// src/permissions/resolve/resolved.ts
|
|
18
18
|
function admits(holders, subject) {
|
package/docs/README.md
CHANGED
|
@@ -16,6 +16,7 @@ below are defined once, in [Words](guide/vocabulary.md#words).
|
|
|
16
16
|
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
|
|
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
|
+
| [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 |
|
|
19
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 |
|
|
20
21
|
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
|
|
21
22
|
|
package/docs/guide/adapters.md
CHANGED
|
@@ -135,6 +135,7 @@ interface SessionStore {
|
|
|
135
135
|
insertSession(record: SessionRecord): Promise<void>;
|
|
136
136
|
findSessionByTokenHash(tokenHash: string): Promise<SessionRecord | null>;
|
|
137
137
|
extendSession(id: SessionId, expiresAt: Date): Promise<SessionRecord | null>; // null once revoked
|
|
138
|
+
reauthenticateSession(id: SessionId, at: Date): Promise<SessionRecord | null>; // authenticatedAt only; null once revoked
|
|
138
139
|
revokeSession(id: SessionId, at: Date): Promise<boolean>;
|
|
139
140
|
revokeUserSessions(userId: Id, at: Date, except?: SessionId): Promise<number>;
|
|
140
141
|
deleteUserSessions(userId: Id): Promise<number>;
|
|
@@ -159,14 +160,14 @@ update. A read followed by a write lets two requests redeem one reset token.
|
|
|
159
160
|
A token is its hash, never its secret, and what it is for:
|
|
160
161
|
|
|
161
162
|
```ts
|
|
162
|
-
type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode';
|
|
163
|
+
type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode' | 'stepUp';
|
|
163
164
|
|
|
164
165
|
interface TokenRecord {
|
|
165
166
|
readonly tokenHash: string;
|
|
166
167
|
readonly kind: TokenKind;
|
|
167
168
|
readonly userId: Id;
|
|
168
169
|
readonly address: string; // '' for a secondFactor challenge: nothing was sent
|
|
169
|
-
readonly codeHash: string | null; //
|
|
170
|
+
readonly codeHash: string | null; // an e-mailed code, hashed — signInCode, or stepUp by e-mail; else null
|
|
170
171
|
readonly attempts: number; // 0 at insertion
|
|
171
172
|
readonly expiresAt: Date;
|
|
172
173
|
readonly spentAt: Date | null;
|
|
@@ -175,7 +176,11 @@ interface TokenRecord {
|
|
|
175
176
|
```
|
|
176
177
|
|
|
177
178
|
A token redeemed for another kind is unknown: every method that takes a
|
|
178
|
-
`kind` matches on it. A `
|
|
179
|
+
`kind` matches on it. A `stepUp` token is a signed-in user's confirmation of
|
|
180
|
+
a sensitive action; kept apart from `signInCode`, a sign-in code never
|
|
181
|
+
confirms an action, nor an action's code signs anyone in. A store that lists
|
|
182
|
+
the kinds — a `CHECK`, an enum in a validator — lists `stepUp` too, which
|
|
183
|
+
`tokens.everyKind` proves. A `secondFactor` token is the challenge `signIn`
|
|
179
184
|
answers — or the count of a user's attempts at `regenerateRecoveryCodes`,
|
|
180
185
|
a token of the same kind whose secret nobody is given, never redeemed and
|
|
181
186
|
only counted — and its `address` is `''`: a column or a validator that refuses an
|
|
@@ -190,6 +195,35 @@ expiry — a TTL index, a Redis key TTL — may drop a lapsed session or token
|
|
|
190
195
|
before anyone asks: reads then answer `null`, and `deleteUserSessions` does
|
|
191
196
|
not count it. The conformance suite accepts both.
|
|
192
197
|
|
|
198
|
+
### `SessionStore.reauthenticateSession`
|
|
199
|
+
|
|
200
|
+
Moves `authenticatedAt` to `at` — **only while the session is not revoked**
|
|
201
|
+
— and answers the record as written, every other field as it was. It is
|
|
202
|
+
what a step-up calls once a signed-in user proved again who they are, so
|
|
203
|
+
that "signed in less than ten minutes ago" is one field of the session
|
|
204
|
+
`authenticate` already read. `null` for no session, or a revoked one: a
|
|
205
|
+
confirmation racing a revocation must never bring the session back, so the
|
|
206
|
+
condition is in the write — the same one `extendSession` has:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import type { SessionStore } from '@nxgt/janus';
|
|
210
|
+
|
|
211
|
+
export const reauthenticateSession: SessionStore['reauthenticateSession'] = async (id, at) => {
|
|
212
|
+
const written = await sessions.findOneAndUpdate(
|
|
213
|
+
{ _id: id, revokedAt: null },
|
|
214
|
+
{ $set: { authenticatedAt: at } },
|
|
215
|
+
{ returnDocument: 'after' },
|
|
216
|
+
);
|
|
217
|
+
return written === null ? null : toSession(written);
|
|
218
|
+
};
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
In SQL, `update … set authenticated_at = $2 where id = $1 and revoked_at is
|
|
222
|
+
null returning *`; in Redis, one Lua script that checks `revokedAt` is empty
|
|
223
|
+
before its `HSET`. The expiry does not move, so neither does a TTL. It has
|
|
224
|
+
its own outage case, and `sessions.reauthenticateRace` races it against
|
|
225
|
+
`revokeSession`.
|
|
226
|
+
|
|
193
227
|
### `TokenStore.countAttempt`
|
|
194
228
|
|
|
195
229
|
Counts one attempt at a code against a token, and answers the token **as it
|
|
@@ -368,7 +402,7 @@ compile error naming the missing method.
|
|
|
368
402
|
|
|
369
403
|
| Suite | Cases | Harness opens |
|
|
370
404
|
| --- | --- | --- |
|
|
371
|
-
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` |
|
|
405
|
+
| `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 54: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fourteen of them | `{ stores, faults?, close? }` |
|
|
372
406
|
| `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
|
|
373
407
|
|
|
374
408
|
`harness.open()` is called **once per case** and must answer fresh, empty
|
package/docs/guide/errors.md
CHANGED
|
@@ -57,6 +57,7 @@ that wired the library, so no handler needs to tell it apart.
|
|
|
57
57
|
| `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
|
|
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
|
+
| `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` |
|
|
60
61
|
| `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) | `userId` on `TOKEN_STALE` |
|
|
61
62
|
| `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` |
|
|
62
63
|
| `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`; `regenerateRecoveryCodes` without an active factor; `confirm` or `recover` after the factor was disabled | `userId` |
|
package/docs/guide/events.md
CHANGED
|
@@ -48,7 +48,7 @@ The words — **user event**, **listener** — are defined in
|
|
|
48
48
|
| `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
49
|
| `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
50
|
| `user.recoveryCodesRegenerated` | `secondFactor.regenerateRecoveryCodes`: the codes the user held stopped working | for `activate`, whose first codes come with `user.secondFactorEnabled`; for a code refused |
|
|
51
|
-
| `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:
|
|
51
|
+
| `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 |
|
|
52
52
|
| `user.deleted` | `delete`, when it deleted the user | for a replay that finds nobody, or an id of another user type |
|
|
53
53
|
|
|
54
54
|
A reset whose link verifies the e-mail sends both, `user.passwordReset`
|
|
@@ -81,26 +81,39 @@ function onUserEvent(event: UserEvent): void {
|
|
|
81
81
|
### Telling the user a recovery code was used
|
|
82
82
|
|
|
83
83
|
A recovery code used by someone else is a sign-in without the user's phone,
|
|
84
|
-
so tell the user each time
|
|
85
|
-
|
|
84
|
+
so tell the user each time — and how many codes they have left, which the
|
|
85
|
+
event does not carry: `secondFactor.recoveryCodesLeft` reads it, after the
|
|
86
|
+
write, so the code just spent is already out of the count.
|
|
87
|
+
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)'s
|
|
88
|
+
`recoveryCodeUsed` is the e-mail, in English and French:
|
|
86
89
|
|
|
87
90
|
```ts
|
|
88
|
-
// `
|
|
91
|
+
// `mail` is janusMail({ … }), and the user schema holds a name and a locale.
|
|
89
92
|
const auth = janus({
|
|
90
93
|
...config,
|
|
91
94
|
async events(event) {
|
|
92
95
|
if (event.type === 'user.recoveryCodeUsed') {
|
|
93
96
|
const user = await auth.get(event.userId);
|
|
94
|
-
await
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
const recoveryCodesLeft = await auth.secondFactor.recoveryCodesLeft(user);
|
|
98
|
+
if (recoveryCodesLeft === null) return; // the factor was turned off since
|
|
99
|
+
const when = new Intl.DateTimeFormat(user.locale, {
|
|
100
|
+
dateStyle: 'long',
|
|
101
|
+
timeStyle: 'short',
|
|
102
|
+
timeZone: 'Europe/Paris', // the user's, when you keep it
|
|
103
|
+
}).format(event.occurredAt);
|
|
104
|
+
await mail.recoveryCodeUsed(
|
|
105
|
+
{ name: user.name, locale: user.locale, email: user.email },
|
|
106
|
+
{ when, recoveryCodesLeft }, // "You have 9 recovery codes left."
|
|
98
107
|
);
|
|
99
108
|
}
|
|
100
109
|
},
|
|
101
110
|
});
|
|
102
111
|
```
|
|
103
112
|
|
|
113
|
+
With several user types, the count is the type's:
|
|
114
|
+
`auth.patient.secondFactor.recoveryCodesLeft(event.userId)`, on
|
|
115
|
+
`event.userType`.
|
|
116
|
+
|
|
104
117
|
## What an event carries
|
|
105
118
|
|
|
106
119
|
**The user named by id, and nothing else.** No login, no e-mail, no field of
|
|
@@ -421,6 +421,28 @@ spent — even if opening the session then fails. It is the one to tell the
|
|
|
421
421
|
user about: a recovery code used by someone else is a sign-in without their
|
|
422
422
|
phone.
|
|
423
423
|
|
|
424
|
+
### Counting what is left
|
|
425
|
+
|
|
426
|
+
`recoveryCodesLeft(user)` reads the count `recover` answered, for whoever
|
|
427
|
+
did not see that answer: a `user.recoveryCodeUsed` listener, which carries
|
|
428
|
+
the user's id only, or a security settings page that says "3 recovery codes
|
|
429
|
+
left".
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
await auth.secondFactor.recoveryCodesLeft(user); // 9 — or null, with no active factor
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
- **`null` without an active factor**: a user with none, or one still
|
|
436
|
+
waiting for its first code, holds no codes to count. `0` is an active
|
|
437
|
+
factor whose codes are all spent: send them to
|
|
438
|
+
[regenerate](#regenerating-them).
|
|
439
|
+
- **An unknown id is `NOT_FOUND`**, as for `get`; a store that fails throws
|
|
440
|
+
`STORE_FAILED`. It writes nothing, and takes no key: it counts hashes.
|
|
441
|
+
- **Read after the write**: in a `user.recoveryCodeUsed` listener it counts
|
|
442
|
+
the code just spent out.
|
|
443
|
+
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)'s
|
|
444
|
+
`recoveryCodeUsed` says it in the e-mail — see [User events](events.md#telling-the-user-a-recovery-code-was-used).
|
|
445
|
+
|
|
424
446
|
### Regenerating them
|
|
425
447
|
|
|
426
448
|
```ts
|
|
@@ -466,21 +488,16 @@ its first ten.
|
|
|
466
488
|
| `VERSION_CONFLICT` | `{ ifVersion }` no longer matches, or another call changed the user meanwhile — the same code tried twice at once regenerates once |
|
|
467
489
|
|
|
468
490
|
Like `enroll` and `disable`, `regenerateRecoveryCodes` does not know who is
|
|
469
|
-
calling: pass the user from `auth.authenticate(request)`, and
|
|
470
|
-
[recent
|
|
471
|
-
first. The app's code is the one check `janus` makes itself.
|
|
491
|
+
calling: pass the user from `auth.authenticate(request)`, and ask for a
|
|
492
|
+
[recent proof](#asking-before-enroll-and-disable-is-your-policy) first. The app's code is the one check `janus` makes itself.
|
|
472
493
|
|
|
473
494
|
```ts
|
|
474
|
-
import { TokenError } from '@nxgt/janus';
|
|
475
|
-
|
|
476
|
-
const RECENT = 5 * 60_000;
|
|
495
|
+
import { TokenError, assertFresh } from '@nxgt/janus';
|
|
477
496
|
|
|
478
497
|
export async function regenerateRecoveryCodes(request: Request): Promise<Response> {
|
|
479
498
|
const current = await auth.authenticate(request);
|
|
480
499
|
if (current === null) return new Response(null, { status: 401 });
|
|
481
|
-
|
|
482
|
-
return Response.json({ error: 'signInAgain' }, { status: 403 });
|
|
483
|
-
}
|
|
500
|
+
assertFresh(current.session, '5m'); // STEP_UP_REQUIRED: your error handler answers 403, and the client steps up
|
|
484
501
|
const { code } = (await request.json()) as { code: string };
|
|
485
502
|
try {
|
|
486
503
|
const { recoveryCodes } = await auth.secondFactor.regenerateRecoveryCodes(current.user, code);
|
|
@@ -548,25 +565,36 @@ again first is the application's decision** — a support tool may disable a
|
|
|
548
565
|
factor for a user who lost their phone, while a user's own settings page
|
|
549
566
|
should not let a stolen session switch it off.
|
|
550
567
|
|
|
551
|
-
A common rule is a **recent
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
568
|
+
A common rule is a **recent proof**: the session signed in, or was
|
|
569
|
+
confirmed by a [step-up](step-up.md), a few minutes ago — so its holder just
|
|
570
|
+
gave the password, the code, or both. `assertFresh` reads
|
|
571
|
+
`session.authenticatedAt`, which renewal never moves, and throws
|
|
572
|
+
`STEP_UP_REQUIRED` (403) for an older session:
|
|
555
573
|
|
|
556
574
|
```ts
|
|
557
|
-
|
|
575
|
+
import { JanusError, assertFresh, statusOf } from '@nxgt/janus';
|
|
558
576
|
|
|
559
577
|
export async function disableSecondFactor(request: Request): Promise<Response> {
|
|
560
578
|
const current = await auth.authenticate(request);
|
|
561
579
|
if (current === null) return new Response(null, { status: 401 });
|
|
562
|
-
|
|
563
|
-
|
|
580
|
+
try {
|
|
581
|
+
assertFresh(current.session, '5m');
|
|
582
|
+
} catch (error) {
|
|
583
|
+
// STEP_UP_REQUIRED: the client asks for auth.stepUp, then sends this again
|
|
584
|
+
if (error instanceof JanusError) {
|
|
585
|
+
return Response.json({ code: error.code }, { status: statusOf(error.code) });
|
|
586
|
+
}
|
|
587
|
+
throw error;
|
|
564
588
|
}
|
|
565
589
|
await auth.secondFactor.disable(current.user);
|
|
566
590
|
return new Response(null, { status: 204 });
|
|
567
591
|
}
|
|
568
592
|
```
|
|
569
593
|
|
|
594
|
+
For a user whose factor is active, `stepUp.request` asks for their app's
|
|
595
|
+
code, never an e-mailed one: a stolen session and a read inbox together still
|
|
596
|
+
cannot switch the factor off.
|
|
597
|
+
|
|
570
598
|
Use the same check before `enroll`, before `regenerateRecoveryCodes` — which
|
|
571
599
|
asks for the app's code besides — before `changePassword`, and before
|
|
572
600
|
anything else a stolen session should not be able to do.
|
|
@@ -822,6 +850,7 @@ interface SecondFactorApi<U> {
|
|
|
822
850
|
disable(user: UserRef, options?: WriteOptions): Promise<U>;
|
|
823
851
|
confirm(challenge: string, code: string): Promise<SignedIn<U>>;
|
|
824
852
|
recover(challenge: string, code: string): Promise<RecoveredSignIn<U>>; // code: a recovery code
|
|
853
|
+
recoveryCodesLeft(user: UserRef): Promise<number | null>; // null: no active factor
|
|
825
854
|
};
|
|
826
855
|
}
|
|
827
856
|
|
package/docs/guide/sessions.md
CHANGED
|
@@ -98,6 +98,12 @@ With several user types, each type has its own `session`.
|
|
|
98
98
|
Expiry is decided by the core on every read, not by the store: a store may
|
|
99
99
|
still hold a lapsed session, and `authenticate` answers it as anonymous.
|
|
100
100
|
|
|
101
|
+
**Renewal is not freshness.** `session.authenticatedAt` is when the session
|
|
102
|
+
last proved who it is — at the sign-in, or since by a
|
|
103
|
+
[step-up](step-up.md) — and renewal never moves it. A sensitive route checks
|
|
104
|
+
it with `assertFresh(session, '10m')`, which throws `STEP_UP_REQUIRED` for
|
|
105
|
+
an older one.
|
|
106
|
+
|
|
101
107
|
## The cookie
|
|
102
108
|
|
|
103
109
|
```ts
|
|
@@ -227,5 +233,6 @@ cannot be replayed. `Session` is the stored record without that hash: `id`,
|
|
|
227
233
|
|
|
228
234
|
- [Users](users.md) — `signUp`, `signIn`, the configuration
|
|
229
235
|
- [The second factor](second-factor.md) — the challenge `signIn` answers instead of a session
|
|
236
|
+
- [Step-up](step-up.md) — proving again who you are, and `assertFresh`
|
|
230
237
|
- [E-mail flows](email-flows.md) — verification and password reset
|
|
231
238
|
- [Errors](errors.md) — `STORE_FAILED`, `UNSUPPORTED` and the rest
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Confirming a sensitive action — the step-up
|
|
2
|
+
|
|
3
|
+
This page is for asking a signed-in user to prove again who they are before
|
|
4
|
+
something a stolen session should not do alone: changing the e-mail,
|
|
5
|
+
disabling the second factor, deleting the account. `janus` issues and checks
|
|
6
|
+
a code, and **stamps the session as freshly confirmed**; sending the e-mail
|
|
7
|
+
is yours, and so is deciding which routes ask.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { assertFresh, createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
const auth = janus({
|
|
14
|
+
user: z.object({ email: z.email() }),
|
|
15
|
+
password: { login: 'email' },
|
|
16
|
+
store: createMemoryStores(),
|
|
17
|
+
hasher: scryptHasher(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// The route that deletes the account asks for a recent proof:
|
|
21
|
+
const current = await auth.authenticate(request);
|
|
22
|
+
if (current === null) return new Response(null, { status: 401 });
|
|
23
|
+
assertFresh(current.session, '10m'); // STEP_UP_REQUIRED (403) past ten minutes
|
|
24
|
+
|
|
25
|
+
// The client, told STEP_UP_REQUIRED, asks for a step-up…
|
|
26
|
+
const issued = await auth.stepUp.request(current.user);
|
|
27
|
+
await sendMail(issued.email, 'Confirm it is you', `${issued.code} confirms the action. It expires in 10 minutes.`);
|
|
28
|
+
// …keeps issued.challenge, and sends it back with the code the user typed:
|
|
29
|
+
const session = await auth.stepUp.confirm(request, issued.challenge, code);
|
|
30
|
+
session.authenticatedAt; // now: the action goes through for the next ten minutes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A step-up is two requests, as a sign-in by code is: one issues the code, the
|
|
34
|
+
other takes it and stamps the session. It **opens no session** and hands out
|
|
35
|
+
no new token: the session the request presents is the one confirmed, which
|
|
36
|
+
is why `confirm` takes the request.
|
|
37
|
+
|
|
38
|
+
## What "fresh" means
|
|
39
|
+
|
|
40
|
+
`session.authenticatedAt` is when the session last proved who it is: at the
|
|
41
|
+
sign-in, and since then at every `stepUp.confirm` on it. Renewing a sliding
|
|
42
|
+
session does not move it. So a session signed in five minutes ago is fresh
|
|
43
|
+
already — the user just gave their password — and one signed in days ago
|
|
44
|
+
is fresh again once a step-up confirms it.
|
|
45
|
+
|
|
46
|
+
`assertFresh(session, maxAge, clock?)` is that check, and reads nothing
|
|
47
|
+
else: no store, no request. It throws `StepUpRequiredError`, code
|
|
48
|
+
`STEP_UP_REQUIRED`, which `statusOf` answers 403, when the session proved
|
|
49
|
+
who it is `maxAge` ago or more. Pass the `clock` you gave `janus()` when it
|
|
50
|
+
is not the system's — `fixedClock` in a spec. A `maxAge` that is not a
|
|
51
|
+
[duration](vocabulary.md) is a `TypeError`: it is written in the code.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { StepUpRequiredError } from '@nxgt/janus';
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
assertFresh(current.session, '5m');
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof StepUpRequiredError) {
|
|
60
|
+
return Response.json({ code: error.code }, { status: 403 });
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Which types have it
|
|
67
|
+
|
|
68
|
+
`stepUp` exists on every user type with an e-mail, as `signInCode` does. A
|
|
69
|
+
type with no e-mail has no `stepUp`: it is absent from its type.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const clinic = janus({
|
|
73
|
+
users: {
|
|
74
|
+
patient: { schema: Patient, password: { login: 'email' } }, // has stepUp
|
|
75
|
+
staff: { schema: Staff, password: { login: 'username' } }, // no e-mail field
|
|
76
|
+
},
|
|
77
|
+
store,
|
|
78
|
+
hasher,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// @ts-expect-error — staff have no e-mail to send a code to
|
|
82
|
+
clinic.staff.stepUp;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Requesting a step-up
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const issued = await auth.stepUp.request(user); // a user, or its id
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
What it answers depends on the user:
|
|
92
|
+
|
|
93
|
+
| The user | `issued.via` | What to do |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| no active second factor | `'email'` | send `issued.code` to `issued.email`, keep `issued.challenge` |
|
|
96
|
+
| an active second factor | `'secondFactor'` | send nothing: ask for the code their app shows, keep `issued.challenge` |
|
|
97
|
+
|
|
98
|
+
A user whose factor is active **confirms with their app, never by e-mail**:
|
|
99
|
+
a step-up is never weaker than the sign-in their account asks for, so
|
|
100
|
+
somebody who reads the user's inbox cannot disable the factor with a stolen
|
|
101
|
+
session. Without `secondFactor` in `janus()`'s configuration, and on a type
|
|
102
|
+
with no password, `via` is always `'email'`, and the type says so: `issued.code`
|
|
103
|
+
is there without narrowing.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const issued = await auth.stepUp.request(current.user);
|
|
107
|
+
if (issued.via === 'email') {
|
|
108
|
+
await sendMail(issued.email, 'Confirm it is you', issued.code);
|
|
109
|
+
}
|
|
110
|
+
return Response.json({ challenge: issued.challenge, via: issued.via });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**One step-up is live per user**: a request spends every step-up issued
|
|
114
|
+
before it. The challenge lapses after ten minutes by default —
|
|
115
|
+
`janus({ tokens: { stepUp: '5m' } })` changes it.
|
|
116
|
+
|
|
117
|
+
`request` refuses a user of no such id, or one with no e-mail, with
|
|
118
|
+
`NOT_FOUND`, and an inactive one with `USER_INACTIVE`. It is meant for the
|
|
119
|
+
user of the session: call it with `current.user`, never with an id a client
|
|
120
|
+
sent.
|
|
121
|
+
|
|
122
|
+
## Confirming
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const session = await auth.stepUp.confirm(request, challenge, code);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`confirm` checks the code, then moves `authenticatedAt` of **the session the
|
|
129
|
+
request presents** to now — read from the request as `authenticate` reads
|
|
130
|
+
it: `Authorization: Bearer`, `X-Session-Token`, then the cookie. It answers
|
|
131
|
+
that session. The session must be a standing one of the user the challenge
|
|
132
|
+
is for: a request with no session, a revoked or lapsed one, or another
|
|
133
|
+
user's is refused as `TOKEN_UNKNOWN`, as a challenge nobody issued is.
|
|
134
|
+
|
|
135
|
+
| Code | When |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `CODE_INVALID`, with `attemptsLeft` | the code does not match; the fifth wrong one spends the challenge |
|
|
138
|
+
| `TOKEN_UNKNOWN` | no such challenge, a sign-in code's challenge, or a request whose session is not a standing one of the challenge's user — or was revoked while the code was checked |
|
|
139
|
+
| `TOKEN_SPENT`, `TOKEN_EXPIRED` | the challenge was used, replaced by a later request, or lapsed |
|
|
140
|
+
| `TOKEN_STALE` | an e-mailed code, and the user changed their e-mail since |
|
|
141
|
+
| `SECOND_FACTOR_ACTIVE` | an e-mailed code, and the user activated a second factor since: request again, the app confirms now |
|
|
142
|
+
| `SECOND_FACTOR_NOT_ENROLLED` | an app's challenge, and the factor was disabled since |
|
|
143
|
+
| `USER_INACTIVE` | the user was deactivated |
|
|
144
|
+
| `VERSION_CONFLICT` | an app's code sent twice at once: the other confirmation stamped the session |
|
|
145
|
+
|
|
146
|
+
Every attempt is counted before anything is compared — a guess that fails
|
|
147
|
+
for any reason has cost one — as for a sign-in code. An app's codes are also
|
|
148
|
+
counted **per user**, five per 15-minute window, in the same count as
|
|
149
|
+
`regenerateRecoveryCodes`: a stolen session asking for challenge after
|
|
150
|
+
challenge still gets five guesses per window, not five per challenge. The
|
|
151
|
+
app's code accepted is spent, as at a sign-in: it confirms nothing else.
|
|
152
|
+
|
|
153
|
+
**Rate-limit `request` per user.** An e-mailed code has no such window:
|
|
154
|
+
five guesses per challenge, and a new challenge takes only a new `request`.
|
|
155
|
+
A stolen session could ask again and again — each request also sends an
|
|
156
|
+
e-mail and cancels the code before it. Limit `stepUp.request` per user —
|
|
157
|
+
a few an hour is plenty for a person — as you limit `signInCode.request`
|
|
158
|
+
per address; the `janus.stepUp.asked` event of `@nxgt/janus-telemetry` is
|
|
159
|
+
the signal to alert on.
|
|
160
|
+
|
|
161
|
+
A step-up and a sign-in code are **two kinds of token**: a sign-in code's
|
|
162
|
+
challenge confirms no action, and a step-up's code signs nobody in.
|
|
163
|
+
|
|
164
|
+
## Nothing else is written
|
|
165
|
+
|
|
166
|
+
`stepUp.confirm` writes the session's `authenticatedAt` and, for an app's
|
|
167
|
+
code, the step the factor accepted. It sends no user event — nothing about
|
|
168
|
+
the user changed — and opens no session. With `@nxgt/janus-telemetry`, both
|
|
169
|
+
calls are traced like every other flow.
|
|
170
|
+
|
|
171
|
+
## See also
|
|
172
|
+
|
|
173
|
+
- [Sessions](sessions.md) — `authenticate`, and what a session holds.
|
|
174
|
+
- [The second factor](second-factor.md) — the app's codes, and asking
|
|
175
|
+
before `disable`.
|
|
176
|
+
- [Signing in with an e-mailed code](sign-in-code.md) — the same six digits,
|
|
177
|
+
to sign in.
|
|
178
|
+
- [Errors](errors.md) — every code and its status.
|
package/docs/guide/users.md
CHANGED
|
@@ -95,6 +95,7 @@ that cuts an emoji in half, say.
|
|
|
95
95
|
| `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
|
|
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
|
+
| `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
|
|
98
99
|
| `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) |
|
|
99
100
|
| `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
|
|
100
101
|
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -51,8 +51,8 @@ either finds this row.
|
|
|
51
51
|
| **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** once `enroll` stored it, waiting for a first code (`confirmedAt: null`); **active** once `activate` accepted one — `hasSecondFactor`, and only then does `signIn` ask for a code | "2FA", "MFA", "OTP device"; "confirmed" for the state — to *confirm* is to redeem a challenge |
|
|
52
52
|
| **recovery code** | One of ten single-use codes `secondFactor.activate` and `secondFactor.regenerateRecoveryCodes` answer **once** — ten characters, shown `xxxxx-xxxxx` — that `secondFactor.recover` takes instead of a TOTP code, for a user whose authenticator app is gone. Stored only as keyed hashes. Always written in full: *code* alone is a one-time code, and a recovery code is never one | "backup code", "scratch code", "recovery key" |
|
|
53
53
|
| **attempt** | One code tried against a challenge, counted by `countAttempt` in the token's `attempts` before the code is checked. A challenge takes five, a second factor's or a sign-in code's | "try"; "retry" is a repeated write, never a guess |
|
|
54
|
-
| **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm` — or `secondFactor.recover`, with a recovery code; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`. Stored as its hash, as a one-time token of kind `secondFactor` or `
|
|
55
|
-
| **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request` for the same user (at most one
|
|
54
|
+
| **challenge** | A secret the visitor keeps, redeemed once with a code — which is what *confirm* means for it. `signIn` answers one instead of a session when the user's second factor is active, for `secondFactor.confirm` — or `secondFactor.recover`, with a recovery code; `signInCode.request` answers one beside the sign-in code, for `signInCode.confirm`; `stepUp.request` answers one for `stepUp.confirm`. Stored as its hash, as a one-time token of kind `secondFactor`, `signInCode` or `stepUp`: five minutes or ten by default, and five attempts | "MFA token", "pending session", "ticket" |
|
|
55
|
+
| **spent** | A one-time token or a challenge that can no longer be redeemed — `spentAt` set, `TOKEN_SPENT`, and never unset. Spent by its redemption, by its fifth attempt (a wrong code, or a call through another user type's API), by a redemption after it expired, by a refusal that ends it (`USER_INACTIVE`, `TOKEN_STALE`, `SECOND_FACTOR_NOT_ENROLLED`), or by what ends it early: the next `signInCode.request` or `stepUp.request` for the same user (at most one of each is live at a time), or a password written by `resetPassword.confirm`, `setPassword` or `changePassword` (every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
|
|
56
56
|
| **step** | The thirty-second period a TOTP code belongs to. `lastStep` is the step of the last code accepted, and only a later step counts: that is why a code is accepted once | "window", "period", "counter", in prose |
|
|
57
57
|
| **seal**, **sealing key** | To seal is to encrypt a TOTP secret — AES-256-GCM, bound to the user's id — before a store sees it. A sealing key is one `{ id, key }` of `secondFactor.keys`: the first seals, every one opens | "encrypt", "encryption key", "master key", "pepper" |
|
|
58
58
|
| **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
|
|
@@ -60,6 +60,7 @@ either finds this row.
|
|
|
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
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, 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
|
+
| **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 |
|
|
63
64
|
| **listener** | The one function `janus({ events })` hands every user event to, after the write and awaited | "handler", "hook", "subscriber" |
|
|
64
65
|
|
|
65
66
|
### Permissions
|
package/docs/roadmap.md
CHANGED
|
@@ -5,17 +5,12 @@ dates here, and the version something shipped in is the only number.
|
|
|
5
5
|
|
|
6
6
|
## Now
|
|
7
7
|
|
|
8
|
-
Nothing
|
|
8
|
+
Nothing between releases.
|
|
9
9
|
|
|
10
10
|
## Next
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
not do alone: changing the e-mail, disabling the second factor, deleting
|
|
15
|
-
the account. The same six digits, challenge and five attempts as a sign-in
|
|
16
|
-
code, bound to the session that asked rather than opening one — which
|
|
17
|
-
takes a token kind of its own, so a sign-in code can never confirm an
|
|
18
|
-
action nor an action's code sign anyone in.
|
|
12
|
+
Nothing yet.
|
|
13
|
+
|
|
19
14
|
## Later
|
|
20
15
|
|
|
21
16
|
- **More official adapters** — the ports are cut where atomicity is not
|
|
@@ -66,6 +61,21 @@ Nothing is in progress between releases; the next item is below.
|
|
|
66
61
|
The last ten, newest first, each with the version it came in. Everything
|
|
67
62
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
68
63
|
|
|
64
|
+
- **Confirm an action with a code (step-up), v0.12.0** —
|
|
65
|
+
`auth.stepUp.request(user)` and `stepUp.confirm(request, challenge, code)`:
|
|
66
|
+
a signed-in user proves again who they are before a sensitive action, with
|
|
67
|
+
a six-digit code sent by e-mail — or, when their second factor is active,
|
|
68
|
+
the code from their app. The confirmation moves the session's
|
|
69
|
+
`authenticatedAt` and opens no session, and `assertFresh(session, maxAge)`
|
|
70
|
+
refuses an older one with `STEP_UP_REQUIRED` (403). A token kind of its
|
|
71
|
+
own, `stepUp`, and `SessionStore.reauthenticateSession`, in the three
|
|
72
|
+
adapters and the conformance suite.
|
|
73
|
+
- **The recovery codes left, read again, v0.11.0** —
|
|
74
|
+
`secondFactor.recoveryCodesLeft(user)` answers the count `recover`
|
|
75
|
+
answered, for a `user.recoveryCodeUsed` listener — the event names the
|
|
76
|
+
user only — or a security settings page; `null` for a user with no active
|
|
77
|
+
factor. The listener can now tell the user how many codes remain, as
|
|
78
|
+
`@nxgt/janus-mail`'s `recoveryCodeUsed` e-mail does.
|
|
69
79
|
- **Recovery codes, v0.10.0** — a user who loses their authenticator app
|
|
70
80
|
still signs in, with no operator resetting the account.
|
|
71
81
|
`secondFactor.activate` answers `{ user, recoveryCodes }`: ten single-use
|
|
@@ -136,18 +146,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
136
146
|
without a password included; an active second factor is still asked for.
|
|
137
147
|
A challenge lives ten minutes (`tokens.signInCode`) and takes five
|
|
138
148
|
attempts, and only the code's hash is stored, keyed by the challenge.
|
|
139
|
-
- **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
|
|
140
|
-
and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
|
|
141
|
-
`confirm`, for every user type with a password; each secret sealed with
|
|
142
|
-
AES-256-GCM under keys your application holds, and rotated by adding a key.
|
|
143
|
-
Breaking once configured: `signIn` answers `{ status: 'signedIn', … }` or
|
|
144
|
-
`{ status: 'secondFactor', challenge, expiresAt }`. A challenge lives five
|
|
145
|
-
minutes and takes five attempts, a code is accepted once, and a refused one
|
|
146
|
-
throws `CODE_INVALID` with `attemptsLeft`.
|
|
147
|
-
- **The store port holds a second factor and counts attempts on a token,
|
|
148
|
-
v0.4.0** — `UserRecord.secondFactor`, a token's `codeHash` and `attempts`,
|
|
149
|
-
the token kinds `'secondFactor'` and `'signInCode'`, and
|
|
150
|
-
`TokenStore.countAttempt`, one conditional write per attempt, with six new
|
|
151
|
-
conformance cases. The published adapters implement them in
|
|
152
|
-
`@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3 and
|
|
153
|
-
`@nxgt/janus-redis` 0.2.
|