@nxgt/janus 0.16.1 → 0.17.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 +99 -11
- package/dist/auth/config/index.d.ts +1 -1
- package/dist/auth/config/index.d.ts.map +1 -1
- package/dist/auth/config/janus-config.d.ts +24 -3
- package/dist/auth/config/janus-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-config.d.ts.map +1 -1
- package/dist/auth/config/resolve-devices.d.ts +5 -0
- package/dist/auth/config/resolve-devices.d.ts.map +1 -0
- package/dist/auth/config/resolved-config.d.ts +4 -0
- package/dist/auth/config/resolved-config.d.ts.map +1 -1
- package/dist/auth/devices/hint.d.ts +30 -0
- package/dist/auth/devices/hint.d.ts.map +1 -0
- package/dist/auth/devices/index.d.ts +10 -0
- package/dist/auth/devices/index.d.ts.map +1 -0
- package/dist/auth/devices/report.d.ts +11 -0
- package/dist/auth/devices/report.d.ts.map +1 -0
- package/dist/auth/devices/token.d.ts +13 -0
- package/dist/auth/devices/token.d.ts.map +1 -0
- package/dist/auth/email-sign-in.d.ts +4 -2
- package/dist/auth/email-sign-in.d.ts.map +1 -1
- package/dist/auth/events.d.ts +18 -5
- package/dist/auth/events.d.ts.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/magic-link.d.ts.map +1 -1
- package/dist/auth/second-factor/challenge.d.ts +2 -2
- package/dist/auth/second-factor/challenge.d.ts.map +1 -1
- package/dist/auth/second-factor/flows.d.ts +6 -4
- package/dist/auth/second-factor/flows.d.ts.map +1 -1
- package/dist/auth/second-factor/recovery.d.ts +6 -2
- package/dist/auth/second-factor/recovery.d.ts.map +1 -1
- package/dist/auth/sessions/open-session.d.ts +7 -1
- package/dist/auth/sessions/open-session.d.ts.map +1 -1
- package/dist/auth/sign-in-code.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +2 -2
- 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/auth/types/magic-link.d.ts +2 -2
- package/dist/auth/types/magic-link.d.ts.map +1 -1
- package/dist/auth/types/password.d.ts +10 -3
- package/dist/auth/types/password.d.ts.map +1 -1
- package/dist/auth/types/second-factor.d.ts +6 -3
- package/dist/auth/types/second-factor.d.ts.map +1 -1
- package/dist/auth/types/sign-in.d.ts +32 -0
- package/dist/auth/types/sign-in.d.ts.map +1 -1
- package/dist/auth/users/flow-types.d.ts +2 -1
- package/dist/auth/users/flow-types.d.ts.map +1 -1
- package/dist/auth/users/password-flows.d.ts.map +1 -1
- package/dist/auth/users/sign-in.d.ts +5 -1
- package/dist/auth/users/sign-in.d.ts.map +1 -1
- package/dist/index.js +172 -73
- package/dist/index.js.map +19 -15
- package/docs/README.md +2 -1
- package/docs/guide/devices.md +428 -0
- package/docs/guide/events.md +53 -8
- package/docs/guide/magic-link.md +25 -3
- package/docs/guide/second-factor.md +30 -5
- package/docs/guide/sessions.md +8 -0
- package/docs/guide/sign-in-code.md +22 -3
- package/docs/guide/users.md +3 -2
- package/docs/guide/vocabulary.md +4 -3
- package/docs/roadmap.md +12 -8
- package/docs/troubleshooting.md +210 -4
- package/package.json +1 -1
|
@@ -213,6 +213,8 @@ interface SignedIn<U> {
|
|
|
213
213
|
readonly user: U;
|
|
214
214
|
readonly session: Session;
|
|
215
215
|
readonly token: string;
|
|
216
|
+
readonly newDevice: boolean; // with janus({ devices }): see devices.md
|
|
217
|
+
readonly deviceToken: string | null;
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
interface SecondFactorRequired {
|
|
@@ -284,6 +286,26 @@ const signedIn = await auth.secondFactor.confirm(challenge, code);
|
|
|
284
286
|
| `USER_INACTIVE` | the user was deactivated since `signIn`. The challenge is spent | answer 403, as `signIn` would |
|
|
285
287
|
| `SECOND_FACTOR_NOT_ENROLLED` | the factor was disabled since `signIn`. The challenge is spent | sign in again: the password alone now opens a session |
|
|
286
288
|
|
|
289
|
+
### Giving the device again
|
|
290
|
+
|
|
291
|
+
With [`janus({ devices })`](devices.md), the challenge carries **no
|
|
292
|
+
device**: whatever `signIn` was given, `confirm` — and `recover` — decide
|
|
293
|
+
whether the device is new only from what they are given themselves. Pass
|
|
294
|
+
the same `{ device }` again, from the same cookie:
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
const result = await auth.signIn({ email, password }, { device: cookie ?? null });
|
|
298
|
+
if (result.status === 'secondFactor') {
|
|
299
|
+
// the next request, with the code — and the device cookie again
|
|
300
|
+
const signedIn = await auth.secondFactor.confirm(challenge, code, { device: cookie ?? null });
|
|
301
|
+
// { status: 'signedIn', …, newDevice, deviceToken }: user.newDeviceSignedIn sent for a new one
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
A confirmation given no device is untracked: `newDevice: false`,
|
|
306
|
+
`deviceToken: null`, no event. The challenge answer itself has neither
|
|
307
|
+
field, and sends nothing.
|
|
308
|
+
|
|
287
309
|
### Attempts
|
|
288
310
|
|
|
289
311
|
A challenge takes **five attempts**. Every call to `confirm` counts one, in
|
|
@@ -392,7 +414,7 @@ and `recoveryCodesLeft` says how many remain.
|
|
|
392
414
|
type RecoveredSignIn<U> = SignedIn<U> & { readonly recoveryCodesLeft: number };
|
|
393
415
|
|
|
394
416
|
const signedIn = await auth.secondFactor.recover(challenge, ' 7K2MQ X9D4C ');
|
|
395
|
-
// { status: 'signedIn', user, session, token, recoveryCodesLeft: 9 }
|
|
417
|
+
// { status: 'signedIn', user, session, token, newDevice, deviceToken, recoveryCodesLeft: 9 }
|
|
396
418
|
```
|
|
397
419
|
|
|
398
420
|
- **Read as the user may type it**: case, spaces and dashes are ignored, `o`
|
|
@@ -421,7 +443,8 @@ at the same moment, exactly one gets a session.
|
|
|
421
443
|
`recover` sends a [`user.recoveryCodeUsed` event](events.md) once the code is
|
|
422
444
|
spent — even if opening the session then fails. It is the one to tell the
|
|
423
445
|
user about: a recovery code used by someone else is a sign-in without their
|
|
424
|
-
phone.
|
|
446
|
+
phone. Given a [`device`](devices.md) that is new, it sends
|
|
447
|
+
`user.newDeviceSignedIn` after it.
|
|
425
448
|
|
|
426
449
|
### Counting what is left
|
|
427
450
|
|
|
@@ -875,8 +898,8 @@ interface SecondFactorApi<U> {
|
|
|
875
898
|
activate(user: UserRef, code: string, options?: WriteOptions): Promise<RecoveryCodesIssued<U>>;
|
|
876
899
|
regenerateRecoveryCodes(user: UserRef, code: string, options?: WriteOptions): Promise<RecoveryCodesIssued<U>>;
|
|
877
900
|
disable(user: UserRef, options?: WriteOptions): Promise<U>;
|
|
878
|
-
confirm(challenge: string, code: string): Promise<SignedIn<U>>;
|
|
879
|
-
recover(challenge: string, code: string): Promise<RecoveredSignIn<U>>; // code: a recovery code
|
|
901
|
+
confirm(challenge: string, code: string, options?: SignInOptions): Promise<SignedIn<U>>;
|
|
902
|
+
recover(challenge: string, code: string, options?: SignInOptions): Promise<RecoveredSignIn<U>>; // code: a recovery code
|
|
880
903
|
recoveryCodesLeft(user: UserRef): Promise<number | null>; // null: no active factor
|
|
881
904
|
};
|
|
882
905
|
}
|
|
@@ -890,7 +913,8 @@ type RecoveredSignIn<U> = SignedIn<U> & { readonly recoveryCodesLeft: number };
|
|
|
890
913
|
```
|
|
891
914
|
|
|
892
915
|
`RecoveryCodesIssued` and `RecoveredSignIn` are exported types of
|
|
893
|
-
`@nxgt/janus`.
|
|
916
|
+
`@nxgt/janus`. `SignInOptions` is `{ device?: string | null }` — see
|
|
917
|
+
[devices](devices.md#giving-a-sign-in-the-device).
|
|
894
918
|
|
|
895
919
|
`UserRef` is a user or its id; `WriteOptions` is `{ ifVersion? }`, as on
|
|
896
920
|
every write — see [Users](users.md#ifversion). Every call may also reject
|
|
@@ -901,6 +925,7 @@ with `STORE_FAILED`.
|
|
|
901
925
|
- [Sign-in codes](sign-in-code.md) — a sign-in by e-mailed code, which still asks for an active factor, with the same challenge
|
|
902
926
|
- [Sign-in links](magic-link.md) — a sign-in by e-mailed link, which does the same
|
|
903
927
|
- [Sessions](sessions.md) — the cookie `confirm`'s session is sent in, and `authenticatedAt`
|
|
928
|
+
- [Devices](devices.md) — the `device` `confirm` and `recover` are given again, and the notice of a new one
|
|
904
929
|
- [User events](events.md) — `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`
|
|
905
930
|
- [Errors](errors.md) — `CODE_INVALID`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` and their statuses
|
|
906
931
|
- [Writing an adapter](adapters.md#a-users-password-and-second-factor) — what a store keeps of a factor and its recovery codes
|
package/docs/guide/sessions.md
CHANGED
|
@@ -168,6 +168,13 @@ const { user, session, token } = result; // status 'signedIn': as above
|
|
|
168
168
|
its session is sent the same way. See
|
|
169
169
|
[the second factor](second-factor.md#signing-in-switch-on-status).
|
|
170
170
|
|
|
171
|
+
**With `janus({ devices })`, a second cookie rides beside the session's.**
|
|
172
|
+
A sign-in given `{ device }` answers a `deviceToken` for the client to keep
|
|
173
|
+
in a long-lived cookie of its own, apart from the session cookie: it
|
|
174
|
+
outlives every session, and a sign-out leaves it, so the device stays
|
|
175
|
+
known. `auth.cookie` builds only the session cookie — see
|
|
176
|
+
[devices](devices.md#keeping-the-token-the-cookie) for the device cookie.
|
|
177
|
+
|
|
171
178
|
`signOutEverywhere(user, { except })` revokes every standing session of a
|
|
172
179
|
user, but the one named, and answers how many it revoked — "sign out
|
|
173
180
|
everywhere else". An `except` that names no session of theirs — an unknown
|
|
@@ -237,5 +244,6 @@ cannot be replayed. `Session` is the stored record without that hash: `id`,
|
|
|
237
244
|
- [Users](users.md) — `signUp`, `signIn`, the configuration
|
|
238
245
|
- [The second factor](second-factor.md) — the challenge `signIn` answers instead of a session
|
|
239
246
|
- [Step-up](step-up.md) — proving again who you are, and `assertFresh`
|
|
247
|
+
- [Devices](devices.md) — the device cookie beside the session cookie, and a sign-in from a new device
|
|
240
248
|
- [E-mail flows](email-flows.md) — verification and password reset
|
|
241
249
|
- [Errors](errors.md) — `STORE_FAILED`, `UNSUPPORTED` and the rest
|
|
@@ -312,6 +312,22 @@ a password, in an instance given a `secondFactor` — and reading `token`
|
|
|
312
312
|
before narrowing on `status` is a compile error there. Anywhere else it
|
|
313
313
|
answers `SignedIn`.
|
|
314
314
|
|
|
315
|
+
### The device
|
|
316
|
+
|
|
317
|
+
With [`janus({ devices })`](devices.md), `confirm` takes the device token
|
|
318
|
+
the client holds as its third argument, and tells a sign-in from a new
|
|
319
|
+
device as `signIn` does:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
const signedIn = await auth.signInCode.confirm(challenge, code, { device: cookie ?? null });
|
|
323
|
+
// { status: 'signedIn', …, newDevice, deviceToken }: keep deviceToken in the device cookie
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
A new device sends `user.newDeviceSignedIn` — last, after the events a first
|
|
327
|
+
proof of the e-mail sends. When `confirm` answers a second factor's
|
|
328
|
+
challenge, the challenge carries no device: give it again to
|
|
329
|
+
`secondFactor.confirm`. Without `{ device }`, nothing is tracked.
|
|
330
|
+
|
|
315
331
|
### What `confirm` refuses
|
|
316
332
|
|
|
317
333
|
| Rejects with | When | What to do |
|
|
@@ -499,14 +515,16 @@ it('signs in with the e-mailed code, and not after ten minutes', async () => {
|
|
|
499
515
|
interface SignInCodeApi<U, Answer = SignedIn<U>> {
|
|
500
516
|
readonly signInCode: {
|
|
501
517
|
request(email: string): Promise<IssuedCode<U> | null>;
|
|
502
|
-
confirm(challenge: string, code: string): Promise<Answer>;
|
|
518
|
+
confirm(challenge: string, code: string, options?: SignInOptions): Promise<Answer>;
|
|
503
519
|
};
|
|
504
520
|
}
|
|
505
521
|
```
|
|
506
522
|
|
|
507
523
|
`Answer` is `SignInResult<U>` on a type with a password in an instance given
|
|
508
|
-
a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode
|
|
509
|
-
`SignInCodeApi` are exported from `@nxgt/janus`, as
|
|
524
|
+
a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode`,
|
|
525
|
+
`SignInCodeApi` and `SignInOptions` are exported from `@nxgt/janus`, as
|
|
526
|
+
types; `SignInOptions` is `{ device?: string | null }` — see
|
|
527
|
+
[devices](devices.md#giving-a-sign-in-the-device).
|
|
510
528
|
|
|
511
529
|
## See also
|
|
512
530
|
|
|
@@ -514,6 +532,7 @@ a `secondFactor`, and `SignedIn<U>` everywhere else. `IssuedCode` and
|
|
|
514
532
|
- [Sign-in links](magic-link.md) — the same sign-in with a link instead of a code: nothing to type, on whichever device opens it
|
|
515
533
|
- [E-mail verification and password reset](email-flows.md) — the other flows that send an e-mail, and `TOKEN_STALE`
|
|
516
534
|
- [Sessions](sessions.md) — the cookie the session is sent in
|
|
535
|
+
- [Devices](devices.md) — the `device` `confirm` takes, and the notice of a new one
|
|
517
536
|
- [Errors](errors.md) — every code and its status
|
|
518
537
|
- [`@nxgt/janus-telemetry`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-telemetry/docs/guide/tracing.md#a-code-sent-by-e-mail) — the events a sign-in by code writes
|
|
519
538
|
- [Troubleshooting](../troubleshooting.md#sign-in-codes) — by the message you see
|
package/docs/guide/users.md
CHANGED
|
@@ -99,6 +99,7 @@ that cuts an emoji in half, say.
|
|
|
99
99
|
| `tokens.stepUp` | `Duration` | `'10m'` | How long a step-up's challenge — and its e-mailed code — lives. See [step-up](step-up.md) |
|
|
100
100
|
| `signIn.throttle` | `{ attempts?, window? }` or `false` | `{ attempts: 10, window: '15m' }` | Passwords one login may try per window, the right one included, before `signIn` answers `CREDENTIALS_INVALID` with `retryAfter` until the window ends. `false` counts nothing. See [passwords](passwords.md#password-guessing-is-throttled) |
|
|
101
101
|
| `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) |
|
|
102
|
+
| `devices` | `{ keys }` | none | The keys device tokens are signed with, to tell a sign-in from a new device. Without it, no sign-in may be given a `device` — see [devices](devices.md#wiring) |
|
|
102
103
|
| `events` | `UserEventListener` | none | Called with every user event — `user.created`, `user.deleted`, … — after the write, awaited. See [user events](events.md) |
|
|
103
104
|
|
|
104
105
|
A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
|
|
@@ -188,8 +189,8 @@ With a `password`, besides:
|
|
|
188
189
|
|
|
189
190
|
| Method | Answers | Rejects with |
|
|
190
191
|
| --- | --- | --- |
|
|
191
|
-
| `signUp(fields & { password })` | `{ status: 'signedIn', user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
192
|
-
| `signIn({ [login]: string, password })` | `{ status: 'signedIn', user, session, token }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID` — with `retryAfter` once the login is throttled — `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
192
|
+
| `signUp(fields & { password }, { device? })` | `{ status: 'signedIn', user, session, token, newDevice, deviceToken }` — `newDevice` always `false`; a first `deviceToken` when given a `device` ([devices](devices.md)) | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
193
|
+
| `signIn({ [login]: string, password }, { device? })` | `{ status: 'signedIn', user, session, token, newDevice, deviceToken }` — or, with `secondFactor` configured and the user's factor active, `{ status: 'secondFactor', challenge, expiresAt, userId }`: switch on `status` | `CREDENTIALS_INVALID` — with `retryAfter` once the login is throttled — `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
193
194
|
| `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
|
|
194
195
|
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call; spends the user's reset links and second-factor challenges still waiting, and signs nobody out; sends `user.passwordChanged` | `PASSWORD_TOO_SHORT` |
|
|
195
196
|
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call; spends the user's reset links and second-factor challenges still waiting; sends `user.passwordChanged` | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -56,11 +56,12 @@ either finds this row.
|
|
|
56
56
|
| **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`, `magicLink.request`, `stepUp.request` or `resetPassword.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 reset link and every second-factor challenge left waiting) | "used up", "invalidated"; "consumed" only in `consumeToken`'s name; "revoked" is a session's |
|
|
57
57
|
| **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 |
|
|
58
58
|
| **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" |
|
|
59
|
-
| **token** |
|
|
59
|
+
| **device**, **device token** | A device is a browser or an app a user signs in from, as the client's long-lived cookie — or a bearer client's storage — tells it. A **device token** is what janus signs for it with `devices.keys`, answers as `deviceToken`, and takes back as `device`: `d1.<key id>.<device id>.<mac>`, bound to one user, stored nowhere. A sign-in from a **new device** — one whose token did not prove this user signed in there — answers `newDevice: true` and sends `user.newDeviceSignedIn`. See [devices](devices.md) | "fingerprint", "trusted device", "remembered browser"; "token" alone |
|
|
60
|
+
| **token** | Never alone in prose: a *session token*, a *one-time token* or a *device token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
|
|
60
61
|
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it. `magicLink` sends one too, to sign in; `signInCode` sends a one-time code instead | |
|
|
61
62
|
| **issued** | What a flow answers for the application to send: an `IssuedToken` from `verifyEmail.send`, `resetPassword.request` or `magicLink.request`, an `IssuedCode` from `signInCode.request` — the address with it. `@nxgt/janus-mail` takes it as it is | "result", "payload" |
|
|
62
|
-
| **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 —
|
|
63
|
-
| **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.passwordChanged`, `user.emailChanged`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail` — with an `id` of its own. See [user events](events.md) | "webhook" — a webhook is one way to deliver it; "event" alone where it could be read as `@nxgt/janus-telemetry`'s audit log record — on a page about user events, "the event" is fine |
|
|
63
|
+
| **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 — *two-factor enabled*, *two-factor disabled*, *recovery code used* and *new sign-in*. `@nxgt/janus-mail` sends all six | "alert", "notification" |
|
|
64
|
+
| **user event** | What happened to a user, once it is written: `user.created`, `user.emailVerified`, `user.passwordReset`, `user.passwordChanged`, `user.emailChanged`, `user.secondFactorEnabled`, `user.secondFactorDisabled`, `user.recoveryCodesRegenerated`, `user.recoveryCodeUsed`, `user.newDeviceSignedIn`, `user.deleted` — a `UserEvent`, naming the user by id alone — `user.emailChanged` adds `formerEmail`, `user.newDeviceSignedIn` adds `sessionId` — 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 |
|
|
64
65
|
| **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 |
|
|
65
66
|
| **listener** | The one function `janus({ events })` hands every user event to, after the write and awaited | "handler", "hook", "subscriber" |
|
|
66
67
|
|
package/docs/roadmap.md
CHANGED
|
@@ -70,6 +70,18 @@ Nothing yet.
|
|
|
70
70
|
The last ten, newest first, each with the version it came in. Everything
|
|
71
71
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
72
72
|
|
|
73
|
+
- **A sign-in from a new device, v0.17.0** — `janus({ devices: { keys } })`,
|
|
74
|
+
and `{ device }` as the last argument of `signUp`, `signIn`,
|
|
75
|
+
`secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm` and
|
|
76
|
+
`magicLink.confirm`: the device token the client holds, or `null`. Every
|
|
77
|
+
answer that opens a session says `newDevice` and hands back a
|
|
78
|
+
`deviceToken` for a long-lived cookie, and a new device sends
|
|
79
|
+
`user.newDeviceSignedIn`, with the new session's `sessionId`, once the
|
|
80
|
+
sign-in is complete. Nothing is stored: the token is a device id signed
|
|
81
|
+
for the user, so the store ports are unchanged. A sign-up is never new, a
|
|
82
|
+
call given no device is not tracked, and removing a key forgets every
|
|
83
|
+
device it signed. Breaking for a `switch` that exhausts `UserEventType`,
|
|
84
|
+
and for a `SignedIn` built by hand. See [devices](guide/devices.md).
|
|
73
85
|
- **The first sign-in by link or code ends a squatter's password and second
|
|
74
86
|
factor, v0.16.0** — when `signInCode.confirm` or `magicLink.confirm`
|
|
75
87
|
proves an e-mail never verified, the password and the second factor with
|
|
@@ -152,11 +164,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
152
164
|
Maizzle when the package is built, and only filled in at send time, every
|
|
153
165
|
value escaped: no template engine in your server. A transport that fails
|
|
154
166
|
throws, like a store.
|
|
155
|
-
- **A permission id no store can keep is held by nobody** — an object or
|
|
156
|
-
subject id holding a NUL character or a lone surrogate answers `false` from
|
|
157
|
-
`can()` and an empty page from `list()`, before any store call, and a
|
|
158
|
-
`fromField` holding one names nobody, nor does a `lookup` answering one;
|
|
159
|
-
`grant()` and `revoke()` refuse it with a `TypeError` rather than
|
|
160
|
-
`STORE_FAILED` on PostgreSQL alone. The relation store suite holds every
|
|
161
|
-
adapter to reading back every other character in an id exactly as written.
|
|
162
|
-
— v0.8.3
|
package/docs/troubleshooting.md
CHANGED
|
@@ -39,6 +39,7 @@ How the messages are shaped:
|
|
|
39
39
|
- [`janus: secondFactor.issuer must name your application …`](#janus-secondfactorissuer-must-name-your-application--the-authenticator-app-shows-it-beside-the-account)
|
|
40
40
|
- [`janus: secondFactor.keys: the key "<id>" is not 32 bytes in base64 …`](#janus-secondfactorkeys-the-key-id-is-not-32-bytes-in-base64--make-one-with-openssl-rand--base64-32)
|
|
41
41
|
- [`"hasSecondFactor" is a field janus sets itself; rename it`](#hassecondfactor-is-a-field-janus-sets-itself-rename-it)
|
|
42
|
+
- [`janus: devices.keys: …`](#janus-deviceskeys-expected-at-least-one-key---id-key--the-first-seals)
|
|
42
43
|
- [`janus: events must be a function that takes a user event …`](#janus-events-must-be-a-function-that-takes-a-user-event--webhooks---from-nxgtjanus-webhooks-or-your-own)
|
|
43
44
|
- [`janus: signIn.throttle …` wiring messages](#janus-signinthrottle-wiring-messages)
|
|
44
45
|
- [Other `janus:` wiring messages](#other-janus-wiring-messages)
|
|
@@ -97,7 +98,7 @@ How the messages are shaped:
|
|
|
97
98
|
- [The link signed in another device than the one that asked](#the-link-signed-in-another-device-than-the-one-that-asked)
|
|
98
99
|
- [`magicLink.request` answers `null` for a user who exists](#magiclinkrequest-answers-null-for-a-user-who-exists)
|
|
99
100
|
- [`TS2339: Property 'magicLink' does not exist on type 'TypeApi<…>'.`](#ts2339-property-magiclink-does-not-exist-on-type-typeapi)
|
|
100
|
-
- [`
|
|
101
|
+
- [`TS2559: Type 'string' has no properties in common with type 'SignInOptions'.`, on `magicLink.confirm`](#ts2559-type-string-has-no-properties-in-common-with-type-signinoptions-on-magiclinkconfirm)
|
|
101
102
|
- `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE`, `USER_INACTIVE` and `VERSION_CONFLICT` from `magicLink.confirm`, and `TS2339` on its `token`: in their entries above.
|
|
102
103
|
|
|
103
104
|
**Step-up**
|
|
@@ -111,6 +112,18 @@ How the messages are shaped:
|
|
|
111
112
|
- [`TS2339: Property 'code' does not exist on type 'StepUpByEmail<…> | StepUpByApp<…>'.`](#ts2339-property-code-does-not-exist-on-type-stepupbyemail--stepupbyapp)
|
|
112
113
|
- [`TS2339: Property 'stepUp' does not exist on type 'TypeApi<…>'.`](#ts2339-property-stepup-does-not-exist-on-type-typeapi)
|
|
113
114
|
|
|
115
|
+
**Devices**
|
|
116
|
+
- [`<call>: a device was given, but janus() has no devices — pass devices: { keys }`](#call-a-device-was-given-but-janus-has-no-devices--pass-devices--keys-)
|
|
117
|
+
- [`<call>: options must be { device }, or left out — the token goes under device`](#call-options-must-be--device--or-left-out--the-token-goes-under-device)
|
|
118
|
+
- [`<call>: options.device must be the device token the client holds, or null when it holds none`](#call-optionsdevice-must-be-the-device-token-the-client-holds-or-null-when-it-holds-none)
|
|
119
|
+
- [Every sign-in reports a new device](#every-sign-in-reports-a-new-device)
|
|
120
|
+
- [A new device is never reported: `newDevice` is always `false`](#a-new-device-is-never-reported-newdevice-is-always-false)
|
|
121
|
+
- [`TS2379: Argument of type '{ device: string | undefined; }' is not assignable to parameter of type 'SignInOptions' …`](#ts2379-argument-of-type--device-string--undefined--is-not-assignable-to-parameter-of-type-signinoptions-with-exactoptionalpropertytypes-true)
|
|
122
|
+
- [`TS2739: Type '{ … }' is missing the following properties from type 'SignedIn<…>': newDevice, deviceToken`](#ts2739-type----is-missing-the-following-properties-from-type-signedin-newdevice-devicetoken)
|
|
123
|
+
- [`TS2559: Type 'string' has no properties in common with type 'SignInOptions'.`, on `secondFactor.confirm` or `signInCode.confirm`](#ts2559-type-string-has-no-properties-in-common-with-type-signinoptions-on-secondfactorconfirm-or-signincodeconfirm)
|
|
124
|
+
- [`TS2322: Type 'string | null' is not assignable to type 'string'.`, on `deviceToken`](#ts2322-type-string--null-is-not-assignable-to-type-string-on-devicetoken)
|
|
125
|
+
- `janus: devices.keys: …`: under [Configuring `janus()`](#configuring-janus).
|
|
126
|
+
|
|
114
127
|
**User events**
|
|
115
128
|
- [`[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`](#janus_event_failed-warning-janus-the-events-listener-failed-on-type-event-id-for-user-user-id-name)
|
|
116
129
|
- [An event you expected never arrived](#an-event-you-expected-never-arrived)
|
|
@@ -395,6 +408,33 @@ janus({
|
|
|
395
408
|
});
|
|
396
409
|
```
|
|
397
410
|
|
|
411
|
+
### `janus: devices.keys: expected at least one key — [{ id, key }], the first seals`
|
|
412
|
+
|
|
413
|
+
Also:
|
|
414
|
+
|
|
415
|
+
- `janus: devices.keys: every key needs an id of letters, digits, _ and -, at most 64 of them`
|
|
416
|
+
- `janus: devices.keys: two keys have the id "<id>"`
|
|
417
|
+
- `janus: devices.keys: the key "<id>" is not 32 bytes in base64 — make one with openssl rand -base64 32`
|
|
418
|
+
|
|
419
|
+
**When:** `janus({ ..., devices })`.
|
|
420
|
+
**Why:** device tokens are signed with these keys, in the format of
|
|
421
|
+
`secondFactor.keys`: a list of at least one `{ id, key }`, the key 32 random
|
|
422
|
+
bytes in base64. An empty list, a key from an environment variable that is
|
|
423
|
+
not set, a hex key or a passphrase is refused.
|
|
424
|
+
**Fix:** make a key of its own — not a sealing key of the second factor's —
|
|
425
|
+
and keep it with your other secrets:
|
|
426
|
+
|
|
427
|
+
```sh
|
|
428
|
+
openssl rand -base64 32
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
janus({
|
|
433
|
+
...,
|
|
434
|
+
devices: { keys: [{ id: '2026-09', key: process.env.DEVICES_KEY_2026_09 ?? '' }] }, // the first signs
|
|
435
|
+
});
|
|
436
|
+
```
|
|
437
|
+
|
|
398
438
|
### `"hasSecondFactor" is a field janus sets itself; rename it`
|
|
399
439
|
|
|
400
440
|
**When:** `tsc`, on the `janus({...})` call, when your schema declares a `hasSecondFactor` field. This version added it to the fields janus sets on every user.
|
|
@@ -1289,12 +1329,17 @@ Or `@nxgt/janus` is older than 0.15, which brought `magicLink`.
|
|
|
1289
1329
|
**Fix:** name the field that holds the e-mail with the type's `email`
|
|
1290
1330
|
option, or upgrade `@nxgt/janus`.
|
|
1291
1331
|
|
|
1292
|
-
### `
|
|
1332
|
+
### `TS2559: Type 'string' has no properties in common with type 'SignInOptions'.`, on `magicLink.confirm`
|
|
1333
|
+
|
|
1334
|
+
Before 0.17: `TS2554: Expected 1 arguments, but got 2.`
|
|
1293
1335
|
|
|
1294
1336
|
**When:** `tsc`, on `magicLink.confirm(challenge, code)` — a sign-in code's
|
|
1295
1337
|
route copied for a link.
|
|
1296
|
-
**Why:** a link carries no code: its token is the whole secret.
|
|
1297
|
-
|
|
1338
|
+
**Why:** a link carries no code: its token is the whole secret. The second
|
|
1339
|
+
argument is `SignInOptions`, `{ device }`, which a string is not.
|
|
1340
|
+
**Fix:** `magicLink.confirm(token)`, with the token the link's page posted —
|
|
1341
|
+
and `magicLink.confirm(token, { device: cookie ?? null })` with
|
|
1342
|
+
[devices](guide/devices.md).
|
|
1298
1343
|
|
|
1299
1344
|
---
|
|
1300
1345
|
|
|
@@ -1432,6 +1477,164 @@ a code has nowhere to be sent.
|
|
|
1432
1477
|
|
|
1433
1478
|
---
|
|
1434
1479
|
|
|
1480
|
+
## Devices
|
|
1481
|
+
|
|
1482
|
+
The three messages below are bare `TypeError`s, thrown by a sign-in before
|
|
1483
|
+
anything is written — a `signUp` creates no user. `<call>` is the call you
|
|
1484
|
+
wrote: `signIn`, `secondFactor.recover`, `patient.magicLink.confirm`. A
|
|
1485
|
+
device token itself is never refused: one that proves nothing is a new
|
|
1486
|
+
device. [The devices guide](guide/devices.md) has the whole flow.
|
|
1487
|
+
|
|
1488
|
+
### `<call>: a device was given, but janus() has no devices — pass devices: { keys }`
|
|
1489
|
+
|
|
1490
|
+
**When:** a sign-in given `{ device }` — `{ device: null }` included — on a
|
|
1491
|
+
`janus()` wired without `devices`. Most often one instance of several: the
|
|
1492
|
+
one that serves another route — the second factor's code, a sign-in link —
|
|
1493
|
+
was built from another configuration.
|
|
1494
|
+
**Why:** a device token is signed and checked with `devices.keys`; without
|
|
1495
|
+
keys there is nothing to sign it with, and answering `newDevice: false` would
|
|
1496
|
+
hide a notice you meant to send.
|
|
1497
|
+
**Fix:** give every `janus()` that signs users in the same `devices` — build
|
|
1498
|
+
the configuration once and import it:
|
|
1499
|
+
|
|
1500
|
+
```ts
|
|
1501
|
+
export const devices = { keys: [{ id: '2026-09', key: process.env.DEVICES_KEY_2026_09 ?? '' }] } as const;
|
|
1502
|
+
janus({ ...config, devices });
|
|
1503
|
+
```
|
|
1504
|
+
|
|
1505
|
+
Or stop passing `{ device }` where devices are not wanted.
|
|
1506
|
+
|
|
1507
|
+
### `<call>: options must be { device }, or left out — the token goes under device`
|
|
1508
|
+
|
|
1509
|
+
**When:** from JavaScript, or through a cast, options that are not an
|
|
1510
|
+
object — most often the token itself, `auth.signIn(credentials, deviceOf(c))`,
|
|
1511
|
+
where `{ device: deviceOf(c) }` belongs. Left unrefused, that sign-in would
|
|
1512
|
+
be silently untracked: no token, no notice.
|
|
1513
|
+
**Why:** the last argument is `SignInOptions`, `{ device }`, or nothing.
|
|
1514
|
+
**Fix:**
|
|
1515
|
+
|
|
1516
|
+
```ts
|
|
1517
|
+
await auth.signIn(credentials, { device: token ?? null });
|
|
1518
|
+
```
|
|
1519
|
+
|
|
1520
|
+
### `<call>: options.device must be the device token the client holds, or null when it holds none`
|
|
1521
|
+
|
|
1522
|
+
**When:** from JavaScript, or through a cast, a `device` that is neither a
|
|
1523
|
+
string nor `null`: a number, an object — the cookie jar, a parsed cookie, the
|
|
1524
|
+
previous `SignedIn` instead of its `deviceToken`. `undefined` is not refused:
|
|
1525
|
+
it means "not tracked".
|
|
1526
|
+
**Why:** `device` is the token the client holds, as a string, or `null` when
|
|
1527
|
+
it holds none.
|
|
1528
|
+
**Fix:**
|
|
1529
|
+
|
|
1530
|
+
```ts
|
|
1531
|
+
await auth.signIn(credentials, { device: previous.deviceToken }); // the string, or null
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
### Every sign-in reports a new device
|
|
1535
|
+
|
|
1536
|
+
**When:** `newDevice` is `true` — and `user.newDeviceSignedIn` sent, and a
|
|
1537
|
+
notice e-mailed — at every sign-in of a user who always uses the same
|
|
1538
|
+
browser.
|
|
1539
|
+
**Why:** the token the sign-in is given does not prove this user signed in
|
|
1540
|
+
there. In order of likelihood:
|
|
1541
|
+
|
|
1542
|
+
- **the device cookie is not kept.** It was never set from `deviceToken`;
|
|
1543
|
+
it was set without `Max-Age` or `Expires`, so the browser drops it when it
|
|
1544
|
+
closes; it is `Secure` on a plain `http://` origin other than
|
|
1545
|
+
`localhost`; its `Path` or `Domain` leaves out a sign-in route; or it is
|
|
1546
|
+
written under one name and read under another;
|
|
1547
|
+
- **a confirmation is given `null`** rather than the cookie — a
|
|
1548
|
+
`{ device: null }` written in the second factor's route — so each
|
|
1549
|
+
confirmation is a device holding no token;
|
|
1550
|
+
- **a device key was removed**, or two instances hold different keys: each
|
|
1551
|
+
device is new once after a removal, and at every sign-in that reaches the
|
|
1552
|
+
other instance;
|
|
1553
|
+
- **two people share the browser** and take turns: one cookie holds one
|
|
1554
|
+
user's token, and the other's proves nothing.
|
|
1555
|
+
|
|
1556
|
+
**Fix:** set the cookie from every answer's `deviceToken`, long-lived and
|
|
1557
|
+
sent to every sign-in route, and read it under the same name:
|
|
1558
|
+
|
|
1559
|
+
```ts
|
|
1560
|
+
headers.append('Set-Cookie', `device=${signedIn.deviceToken}; Max-Age=34560000; Path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
Give the confirmations the cookie too, and every `janus()` the same
|
|
1564
|
+
`devices`. The shared browser is not a bug: it is the price of storing
|
|
1565
|
+
nothing.
|
|
1566
|
+
|
|
1567
|
+
### A new device is never reported: `newDevice` is always `false`
|
|
1568
|
+
|
|
1569
|
+
**When:** a user signs in from a browser that never saw them, and nothing is
|
|
1570
|
+
reported: `newDevice: false`, `deviceToken: null`, no event.
|
|
1571
|
+
**Why:**
|
|
1572
|
+
|
|
1573
|
+
- **no `device` was given** — absent, or `undefined`: a cookie read as
|
|
1574
|
+
`string | undefined` passed as it is, which compiles unless your tsconfig
|
|
1575
|
+
has `exactOptionalPropertyTypes`. Absent means *not tracked*, not *new*;
|
|
1576
|
+
- **the device was not given again** to `secondFactor.confirm` or `recover`:
|
|
1577
|
+
for a user with an active second factor, the challenge `signIn` answered
|
|
1578
|
+
carries none, and the confirmation decides alone;
|
|
1579
|
+
- `newDevice` read on `signUp`'s answer, which is never new.
|
|
1580
|
+
|
|
1581
|
+
**Fix:** pass `null` for a client with no token, and the device to every
|
|
1582
|
+
call that opens a session:
|
|
1583
|
+
|
|
1584
|
+
```ts
|
|
1585
|
+
const device = cookie ?? null;
|
|
1586
|
+
const result = await auth.signIn(credentials, { device });
|
|
1587
|
+
if (result.status === 'secondFactor') await auth.secondFactor.confirm(result.challenge, code, { device });
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
### `TS2379: Argument of type '{ device: string | undefined; }' is not assignable to parameter of type 'SignInOptions' with 'exactOptionalPropertyTypes: true'.`
|
|
1591
|
+
|
|
1592
|
+
It goes on: `Type 'string | undefined' is not assignable to type 'string | null'.`
|
|
1593
|
+
|
|
1594
|
+
**When:** `tsc`, with `exactOptionalPropertyTypes`, on a sign-in given a
|
|
1595
|
+
cookie read as `string | undefined`.
|
|
1596
|
+
**Why:** `undefined` would mean *not tracked*: a browser without a cookie
|
|
1597
|
+
would never be given a token, and never reported. `null` says the client
|
|
1598
|
+
holds none.
|
|
1599
|
+
**Fix:** `{ device: cookie ?? null }`. Without that compiler option, the same
|
|
1600
|
+
line compiles and tracks nothing — write `?? null` there too.
|
|
1601
|
+
|
|
1602
|
+
### `TS2739: Type '{ … }' is missing the following properties from type 'SignedIn<…>': newDevice, deviceToken`
|
|
1603
|
+
|
|
1604
|
+
**When:** `tsc`, on a `SignedIn` built by hand — a test double of `signIn`,
|
|
1605
|
+
a stub of `auth` — after upgrading to 0.17.
|
|
1606
|
+
**Why:** every answer that opens a session now carries `newDevice` and
|
|
1607
|
+
`deviceToken`.
|
|
1608
|
+
**Fix:**
|
|
1609
|
+
|
|
1610
|
+
```ts
|
|
1611
|
+
const signedIn: SignedIn<User> = { status: 'signedIn', user, session, token, newDevice: false, deviceToken: null };
|
|
1612
|
+
```
|
|
1613
|
+
|
|
1614
|
+
### `TS2559: Type 'string' has no properties in common with type 'SignInOptions'.`, on `secondFactor.confirm` or `signInCode.confirm`
|
|
1615
|
+
|
|
1616
|
+
Also: `Type '"<value>"' has no properties in common with type 'SignInOptions'.`
|
|
1617
|
+
for a literal token, and `TS2345: Argument of type 'string | undefined' is
|
|
1618
|
+
not assignable to parameter of type 'SignInOptions | undefined'.` — or
|
|
1619
|
+
`'string | null'` — for a cookie or an earlier `deviceToken`, given as is.
|
|
1620
|
+
|
|
1621
|
+
**When:** `tsc`, on `secondFactor.confirm(challenge, code, token)` or
|
|
1622
|
+
`signInCode.confirm(challenge, code, token)` — the device token given as a
|
|
1623
|
+
bare string, or as a nullable one.
|
|
1624
|
+
**Why:** the options come last, as an object: `{ device }`.
|
|
1625
|
+
**Fix:** `secondFactor.confirm(challenge, code, { device: token ?? null })`.
|
|
1626
|
+
|
|
1627
|
+
### `TS2322: Type 'string | null' is not assignable to type 'string'.`, on `deviceToken`
|
|
1628
|
+
|
|
1629
|
+
**When:** `tsc`, reading `signedIn.deviceToken` as a `string` — to set a
|
|
1630
|
+
cookie.
|
|
1631
|
+
**Why:** it is `null` when the sign-in was given no `device`: nothing was
|
|
1632
|
+
minted then.
|
|
1633
|
+
**Fix:** set the cookie only for a token — `if (signedIn.deviceToken !==
|
|
1634
|
+
null) …` — or give every sign-in a `device`.
|
|
1635
|
+
|
|
1636
|
+
---
|
|
1637
|
+
|
|
1435
1638
|
## User events
|
|
1436
1639
|
|
|
1437
1640
|
### `[JANUS_EVENT_FAILED] Warning: janus: the events listener failed on <type> <event id> for user <user id>: <name>`
|
|
@@ -1454,6 +1657,9 @@ process.on('warning', (warning) => {
|
|
|
1454
1657
|
**Why:** one of these, in order of likelihood:
|
|
1455
1658
|
- the e-mail was already verified: nothing changed, so nothing is sent;
|
|
1456
1659
|
- `delete` deleted nobody — a replay, or an id of another user type;
|
|
1660
|
+
- a `user.newDeviceSignedIn` for a sign-in given no `device`, from a known
|
|
1661
|
+
device, or confirmed without the device given again — see
|
|
1662
|
+
[A new device is never reported](#a-new-device-is-never-reported-newdevice-is-always-false);
|
|
1457
1663
|
- the flow was refused, or the store failed during the write itself — the call threw, and nothing was written or sent;
|
|
1458
1664
|
- the process stopped between the write and the listener — events are sent at most once, from memory;
|
|
1459
1665
|
- the listener threw: look for `JANUS_EVENT_FAILED` in the process's warnings.
|