@nxgt/janus 0.11.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.
Files changed (79) hide show
  1. package/README.md +101 -15
  2. package/dist/auth/config/janus-config.d.ts +2 -0
  3. package/dist/auth/config/janus-config.d.ts.map +1 -1
  4. package/dist/auth/config/resolved-config.d.ts +1 -0
  5. package/dist/auth/config/resolved-config.d.ts.map +1 -1
  6. package/dist/auth/index.d.ts +2 -1
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  9. package/dist/auth/port/memory/sessions.d.ts.map +1 -1
  10. package/dist/auth/port/types/sessions.d.ts +16 -1
  11. package/dist/auth/port/types/sessions.d.ts.map +1 -1
  12. package/dist/auth/port/types/tokens.d.ts +7 -2
  13. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  14. package/dist/auth/second-factor/{regenerate-attempts.d.ts → app-code-attempts.d.ts} +13 -10
  15. package/dist/auth/second-factor/app-code-attempts.d.ts.map +1 -0
  16. package/dist/auth/sessions/authenticate.d.ts.map +1 -1
  17. package/dist/auth/sessions/fresh.d.ts +26 -0
  18. package/dist/auth/sessions/fresh.d.ts.map +1 -0
  19. package/dist/auth/sessions/standing.d.ts +20 -0
  20. package/dist/auth/sessions/standing.d.ts.map +1 -0
  21. package/dist/auth/step-up/codes.d.ts +29 -0
  22. package/dist/auth/step-up/codes.d.ts.map +1 -0
  23. package/dist/auth/step-up/flows.d.ts +19 -0
  24. package/dist/auth/step-up/flows.d.ts.map +1 -0
  25. package/dist/auth/step-up/open.d.ts +23 -0
  26. package/dist/auth/step-up/open.d.ts.map +1 -0
  27. package/dist/auth/step-up/request.d.ts +12 -0
  28. package/dist/auth/step-up/request.d.ts.map +1 -0
  29. package/dist/auth/types/index.d.ts +1 -0
  30. package/dist/auth/types/index.d.ts.map +1 -1
  31. package/dist/auth/types/janus.d.ts +2 -1
  32. package/dist/auth/types/janus.d.ts.map +1 -1
  33. package/dist/auth/types/step-up.d.ts +79 -0
  34. package/dist/auth/types/step-up.d.ts.map +1 -0
  35. package/dist/auth/users/any-type-api.d.ts +2 -2
  36. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  37. package/dist/auth/users/type-api.d.ts +1 -1
  38. package/dist/auth/users/type-api.d.ts.map +1 -1
  39. package/dist/chunks/{index-5e2fw7he.js → index-j2gd8fry.js} +12 -3
  40. package/dist/chunks/{index-5e2fw7he.js.map → index-j2gd8fry.js.map} +4 -4
  41. package/dist/chunks/{index-5tm04t5p.js → index-jp2kka6d.js} +2 -2
  42. package/dist/chunks/{index-6f6wja93.js → index-km7ffctx.js} +2 -2
  43. package/dist/chunks/{index-4m49cye4.js → index-rd2rddvv.js} +15 -12
  44. package/dist/chunks/{index-4m49cye4.js.map → index-rd2rddvv.js.map} +3 -3
  45. package/dist/conformance/cases/outage.d.ts.map +1 -1
  46. package/dist/conformance/cases/sessions/index.d.ts.map +1 -1
  47. package/dist/conformance/cases/sessions/reauthentication.d.ts +4 -0
  48. package/dist/conformance/cases/sessions/reauthentication.d.ts.map +1 -0
  49. package/dist/conformance/cases/tokens/index.d.ts.map +1 -1
  50. package/dist/conformance/cases/tokens/kinds.d.ts +4 -0
  51. package/dist/conformance/cases/tokens/kinds.d.ts.map +1 -0
  52. package/dist/conformance/index.js +147 -60
  53. package/dist/conformance/index.js.map +8 -6
  54. package/dist/errors/janus-error/codes.d.ts +7 -0
  55. package/dist/errors/janus-error/codes.d.ts.map +1 -1
  56. package/dist/errors/janus-error/index.d.ts +1 -1
  57. package/dist/errors/janus-error/index.d.ts.map +1 -1
  58. package/dist/errors/janus-error/refusals.d.ts +8 -0
  59. package/dist/errors/janus-error/refusals.d.ts.map +1 -1
  60. package/dist/errors/janus-error/status.d.ts.map +1 -1
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +175 -18
  64. package/dist/index.js.map +16 -10
  65. package/dist/permissions/index.js +3 -3
  66. package/docs/README.md +1 -0
  67. package/docs/guide/adapters.md +38 -4
  68. package/docs/guide/errors.md +1 -0
  69. package/docs/guide/second-factor.md +22 -16
  70. package/docs/guide/sessions.md +7 -0
  71. package/docs/guide/step-up.md +178 -0
  72. package/docs/guide/users.md +1 -0
  73. package/docs/guide/vocabulary.md +3 -2
  74. package/docs/roadmap.md +12 -16
  75. package/docs/troubleshooting.md +182 -7
  76. package/package.json +1 -1
  77. package/dist/auth/second-factor/regenerate-attempts.d.ts.map +0 -1
  78. /package/dist/chunks/{index-5tm04t5p.js.map → index-jp2kka6d.js.map} +0 -0
  79. /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-5tm04t5p.js";
7
+ } from "../chunks/index-jp2kka6d.js";
8
8
  import {
9
9
  PermissionDepthError2,
10
10
  isSetOf2,
11
11
  formatEntity2
12
- } from "../chunks/index-5e2fw7he.js";
12
+ } from "../chunks/index-j2gd8fry.js";
13
13
  import {
14
14
  createMemoryRelations2
15
- } from "../chunks/index-6f6wja93.js";
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
 
@@ -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; // a signInCode's code, hashed; null for every other kind
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 `secondFactor` token is the challenge `signIn`
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? })` | 49: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — thirteen of them | `{ stores, faults?, close? }` |
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
@@ -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` |
@@ -488,21 +488,16 @@ its first ten.
488
488
  | `VERSION_CONFLICT` | `{ ifVersion }` no longer matches, or another call changed the user meanwhile — the same code tried twice at once regenerates once |
489
489
 
490
490
  Like `enroll` and `disable`, `regenerateRecoveryCodes` does not know who is
491
- calling: pass the user from `auth.authenticate(request)`, and apply your
492
- [recent sign-in rule](#asking-before-enroll-and-disable-is-your-policy)
493
- 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.
494
493
 
495
494
  ```ts
496
- import { TokenError } from '@nxgt/janus';
497
-
498
- const RECENT = 5 * 60_000;
495
+ import { TokenError, assertFresh } from '@nxgt/janus';
499
496
 
500
497
  export async function regenerateRecoveryCodes(request: Request): Promise<Response> {
501
498
  const current = await auth.authenticate(request);
502
499
  if (current === null) return new Response(null, { status: 401 });
503
- if (Date.now() - current.session.authenticatedAt.getTime() > RECENT) {
504
- return Response.json({ error: 'signInAgain' }, { status: 403 });
505
- }
500
+ assertFresh(current.session, '5m'); // STEP_UP_REQUIRED: your error handler answers 403, and the client steps up
506
501
  const { code } = (await request.json()) as { code: string };
507
502
  try {
508
503
  const { recoveryCodes } = await auth.secondFactor.regenerateRecoveryCodes(current.user, code);
@@ -570,25 +565,36 @@ again first is the application's decision** — a support tool may disable a
570
565
  factor for a user who lost their phone, while a user's own settings page
571
566
  should not let a stolen session switch it off.
572
567
 
573
- A common rule is a **recent sign-in**: the session was opened a few minutes
574
- ago, so its holder just gave the password — and the code, when the factor is
575
- active. `session.authenticatedAt` is when the session was opened, and renewal
576
- does not move it:
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:
577
573
 
578
574
  ```ts
579
- const RECENT = 5 * 60_000;
575
+ import { JanusError, assertFresh, statusOf } from '@nxgt/janus';
580
576
 
581
577
  export async function disableSecondFactor(request: Request): Promise<Response> {
582
578
  const current = await auth.authenticate(request);
583
579
  if (current === null) return new Response(null, { status: 401 });
584
- if (Date.now() - current.session.authenticatedAt.getTime() > RECENT) {
585
- return Response.json({ error: 'signInAgain' }, { status: 403 });
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;
586
588
  }
587
589
  await auth.secondFactor.disable(current.user);
588
590
  return new Response(null, { status: 204 });
589
591
  }
590
592
  ```
591
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
+
592
598
  Use the same check before `enroll`, before `regenerateRecoveryCodes` — which
593
599
  asks for the app's code besides — before `changePassword`, and before
594
600
  anything else a stolen session should not be able to do.
@@ -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.
@@ -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
 
@@ -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 `signInCode`: 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` for the same user (at most one sign-in code 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 |
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 is in progress between releases; the next item is below.
8
+ Nothing between releases.
9
9
 
10
10
  ## Next
11
11
 
12
- - **Confirm an action with an e-mailed code (step-up)** — a signed-in user
13
- proves they still read their inbox before something a stolen session should
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,15 @@ 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.
69
73
  - **The recovery codes left, read again, v0.11.0** —
70
74
  `secondFactor.recoveryCodesLeft(user)` answers the count `recover`
71
75
  answered, for a `user.recoveryCodeUsed` listener — the event names the
@@ -142,11 +146,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
142
146
  without a password included; an active second factor is still asked for.
143
147
  A challenge lives ten minutes (`tokens.signInCode`) and takes five
144
148
  attempts, and only the code's hash is stored, keyed by the challenge.
145
- - **A TOTP second factor, v0.5.0** — `janus({ secondFactor: { issuer, keys } })`
146
- and `auth.<type>.secondFactor`'s `enroll`, `activate`, `disable` and
147
- `confirm`, for every user type with a password; each secret sealed with
148
- AES-256-GCM under keys your application holds, and rotated by adding a key.
149
- Breaking once configured: `signIn` answers `{ status: 'signedIn', … }` or
150
- `{ status: 'secondFactor', challenge, expiresAt }`. A challenge lives five
151
- minutes and takes five attempts, a code is accepted once, and a refused one
152
- throws `CODE_INVALID` with `attemptsLeft`.