@nxgt/janus 0.1.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/LICENSE +21 -0
- package/README.md +554 -0
- package/dist/auth/config.d.ts +201 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/context.d.ts +87 -0
- package/dist/auth/context.d.ts.map +1 -0
- package/dist/auth/hashers.d.ts +35 -0
- package/dist/auth/hashers.d.ts.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/janus.d.ts +38 -0
- package/dist/auth/janus.d.ts.map +1 -0
- package/dist/auth/outage.d.ts +61 -0
- package/dist/auth/outage.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts +16 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -0
- package/dist/auth/port/memory.d.ts +25 -0
- package/dist/auth/port/memory.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +393 -0
- package/dist/auth/port/types.d.ts.map +1 -0
- package/dist/auth/secrets.d.ts +17 -0
- package/dist/auth/secrets.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts +31 -0
- package/dist/auth/sessions.d.ts.map +1 -0
- package/dist/auth/standard-schema.d.ts +41 -0
- package/dist/auth/standard-schema.d.ts.map +1 -0
- package/dist/auth/types.d.ts +353 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/users.d.ts +9 -0
- package/dist/auth/users.d.ts.map +1 -0
- package/dist/chunks/index-658b6mr2.js +269 -0
- package/dist/chunks/index-658b6mr2.js.map +11 -0
- package/dist/chunks/index-6dytvy3h.js +93 -0
- package/dist/chunks/index-6dytvy3h.js.map +11 -0
- package/dist/chunks/index-6p56fpbe.js +147 -0
- package/dist/chunks/index-6p56fpbe.js.map +12 -0
- package/dist/chunks/index-fgb3t64y.js +77 -0
- package/dist/chunks/index-fgb3t64y.js.map +10 -0
- package/dist/conformance/assert.d.ts +36 -0
- package/dist/conformance/assert.d.ts.map +1 -0
- package/dist/conformance/cases/outage.d.ts +3 -0
- package/dist/conformance/cases/outage.d.ts.map +1 -0
- package/dist/conformance/cases/sessions.d.ts +3 -0
- package/dist/conformance/cases/sessions.d.ts.map +1 -0
- package/dist/conformance/cases/tokens.d.ts +3 -0
- package/dist/conformance/cases/tokens.d.ts.map +1 -0
- package/dist/conformance/cases/users.d.ts +3 -0
- package/dist/conformance/cases/users.d.ts.map +1 -0
- package/dist/conformance/describe.d.ts +74 -0
- package/dist/conformance/describe.d.ts.map +1 -0
- package/dist/conformance/fixtures.d.ts +11 -0
- package/dist/conformance/fixtures.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +33 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +1113 -0
- package/dist/conformance/index.js.map +18 -0
- package/dist/conformance/reference.d.ts +13 -0
- package/dist/conformance/reference.d.ts.map +1 -0
- package/dist/conformance/relations.d.ts +67 -0
- package/dist/conformance/relations.d.ts.map +1 -0
- package/dist/conformance/types.d.ts +71 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/errors/janus-error.d.ts +259 -0
- package/dist/errors/janus-error.d.ts.map +1 -0
- package/dist/ids/id.d.ts +55 -0
- package/dist/ids/id.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +962 -0
- package/dist/index.js.map +19 -0
- package/dist/pagination/cursor-page.d.ts +53 -0
- package/dist/pagination/cursor-page.d.ts.map +1 -0
- package/dist/permissions/engine.d.ts +46 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/index.d.ts +24 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +685 -0
- package/dist/permissions/index.js.map +15 -0
- package/dist/permissions/input.d.ts +29 -0
- package/dist/permissions/input.d.ts.map +1 -0
- package/dist/permissions/model.d.ts +368 -0
- package/dist/permissions/model.d.ts.map +1 -0
- package/dist/permissions/port/memory.d.ts +17 -0
- package/dist/permissions/port/memory.d.ts.map +1 -0
- package/dist/permissions/port/types.d.ts +84 -0
- package/dist/permissions/port/types.d.ts.map +1 -0
- package/dist/permissions/resolve.d.ts +59 -0
- package/dist/permissions/resolve.d.ts.map +1 -0
- package/dist/permissions/reverse.d.ts +53 -0
- package/dist/permissions/reverse.d.ts.map +1 -0
- package/dist/permissions/walk.d.ts +29 -0
- package/dist/permissions/walk.d.ts.map +1 -0
- package/dist/subjects/notation.d.ts +35 -0
- package/dist/subjects/notation.d.ts.map +1 -0
- package/dist/subjects/subject.d.ts +75 -0
- package/dist/subjects/subject.d.ts.map +1 -0
- package/dist/time/clock.d.ts +31 -0
- package/dist/time/clock.d.ts.map +1 -0
- package/dist/time/duration.d.ts +23 -0
- package/dist/time/duration.d.ts.map +1 -0
- package/docs/README.md +17 -0
- package/docs/guide/adapters.md +296 -0
- package/docs/guide/email-flows.md +146 -0
- package/docs/guide/errors.md +146 -0
- package/docs/guide/passwords.md +136 -0
- package/docs/guide/permissions.md +347 -0
- package/docs/guide/sessions.md +211 -0
- package/docs/guide/users.md +277 -0
- package/docs/guide/vocabulary.md +163 -0
- package/docs/roadmap.md +93 -0
- package/docs/troubleshooting.md +648 -0
- package/package.json +70 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Password hashing
|
|
2
|
+
|
|
3
|
+
This page is for choosing a password hasher, moving from one to another, and
|
|
4
|
+
importing hashes written by another system. The login and the length policy
|
|
5
|
+
are on the [users](users.md#options) page.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
10
|
+
|
|
11
|
+
const User = z.object({ email: z.email() });
|
|
12
|
+
const store = createMemoryStores();
|
|
13
|
+
|
|
14
|
+
const auth = janus({
|
|
15
|
+
user: User,
|
|
16
|
+
password: { login: 'email' },
|
|
17
|
+
store,
|
|
18
|
+
hasher: scryptHasher(), // N = 2^17, r = 8, p = 1
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The examples below reuse `User` and `store`.
|
|
23
|
+
|
|
24
|
+
A user type with a password and no `hasher` is refused at wiring with a
|
|
25
|
+
`TypeError`. There is no silent fallback.
|
|
26
|
+
|
|
27
|
+
## The two hashers
|
|
28
|
+
|
|
29
|
+
| Hasher | Runs on | Hash | Parameters |
|
|
30
|
+
| --- | --- | --- | --- |
|
|
31
|
+
| `scryptHasher({ cost? })` | Node and Bun, no dependency | `$scrypt$ln=17,r=8,p=1$<salt>$<key>` | OWASP's: `cost` is log2(N), `17` by default, `10` to `20`; about 128 MiB per hash |
|
|
32
|
+
| `bunHasher()` | Bun only, through `Bun.password` | `$argon2id$v=19$m=65536,t=2,p=1$…` | Pinned, so a change of default in a Bun release is not inherited silently |
|
|
33
|
+
|
|
34
|
+
`bunHasher()` throws a `TypeError` when called outside Bun; the package still
|
|
35
|
+
imports cleanly under Node, because nothing reads `Bun` until then.
|
|
36
|
+
|
|
37
|
+
Lower `cost` only in tests — `10` is fast and still exercises every line:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const hasher = scryptHasher({ cost: 10 });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Hashes describe themselves
|
|
44
|
+
|
|
45
|
+
Every hash starts with its hasher's `prefix`, and carries its parameters. So
|
|
46
|
+
**every wired hasher can verify, and exactly one hashes**: `hasher` writes new
|
|
47
|
+
hashes, and `verifiers` only read the ones a database was written with before.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { bunHasher, scryptHasher } from '@nxgt/janus';
|
|
51
|
+
|
|
52
|
+
const auth = janus({
|
|
53
|
+
user: User,
|
|
54
|
+
password: { login: 'email' },
|
|
55
|
+
store,
|
|
56
|
+
hasher: bunHasher(), // new hashes: argon2id
|
|
57
|
+
verifiers: [scryptHasher()], // existing scrypt hashes still verify
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Two hashers claiming the same prefix are refused at wiring: which one verified
|
|
62
|
+
would depend on the order they were listed in. A stored hash whose prefix no
|
|
63
|
+
wired hasher claims is `HASH_UNSUPPORTED` at sign-in, and the error reports
|
|
64
|
+
the prefix, never the hash.
|
|
65
|
+
|
|
66
|
+
## Rehash on sign-in
|
|
67
|
+
|
|
68
|
+
When a password matches a **stale** hash, `signIn` rewrites it with `hasher`.
|
|
69
|
+
A hash is stale when:
|
|
70
|
+
|
|
71
|
+
- a `verifiers` hasher wrote it, or
|
|
72
|
+
- `hasher` wrote it with other parameters than it uses now — a raised scrypt
|
|
73
|
+
`cost`, or argon2id parameters other than the pinned ones.
|
|
74
|
+
|
|
75
|
+
Moving off a hasher, or raising its cost, therefore reaches every active user
|
|
76
|
+
with no migration to run. The password's `updatedAt` is kept, since the
|
|
77
|
+
password did not change; the user's `version` moves. The write is conditional
|
|
78
|
+
on the version just read: if a concurrent update wins, the sign-in still
|
|
79
|
+
succeeds and the next one tries again. An outage on that write fails the
|
|
80
|
+
sign-in with `STORE_FAILED`.
|
|
81
|
+
|
|
82
|
+
That `version` move is why a user object read **before** a sign-in, then passed
|
|
83
|
+
as `ifVersion`, can get `VERSION_CONFLICT`. Read the user again.
|
|
84
|
+
|
|
85
|
+
## Importing hashes from another system
|
|
86
|
+
|
|
87
|
+
A `PasswordHasher` is four members, and a verifier for a foreign format is a
|
|
88
|
+
small object:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
interface PasswordHasher {
|
|
92
|
+
readonly prefix: string; // every hash it writes starts with this
|
|
93
|
+
hash(plain: string): Promise<string>;
|
|
94
|
+
verify(plain: string, hash: string): Promise<boolean>;
|
|
95
|
+
needsRehash?(hash: string): boolean; // its own hash, written with outdated parameters
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { type PasswordHasher, scryptHasher } from '@nxgt/janus';
|
|
101
|
+
|
|
102
|
+
// Your bcrypt library's compare — bcryptjs's `compare`, for one.
|
|
103
|
+
declare function compareBcrypt(plain: string, hash: string): Promise<boolean>;
|
|
104
|
+
|
|
105
|
+
const legacyBcrypt: PasswordHasher = {
|
|
106
|
+
prefix: '$2b$',
|
|
107
|
+
hash: () => Promise.reject(new Error('bcrypt only verifies here')), // never called: it is not `hasher`
|
|
108
|
+
verify: (plain, hash) => compareBcrypt(plain, hash),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const auth = janus({
|
|
112
|
+
user: User,
|
|
113
|
+
password: { login: 'email' },
|
|
114
|
+
store,
|
|
115
|
+
hasher: scryptHasher(),
|
|
116
|
+
verifiers: [legacyBcrypt],
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Write each imported user's hash as their `PasswordRecord` with your store's
|
|
121
|
+
`insertUser` — `janus()` has no call that takes a hash — and each one moves to
|
|
122
|
+
scrypt the first time they sign in. Wire one verifier
|
|
123
|
+
per prefix the old system wrote (`$2a$`, `$2b$`, `$2y$` for bcrypt).
|
|
124
|
+
|
|
125
|
+
## What never happens
|
|
126
|
+
|
|
127
|
+
- The plain password is never stored, and never appears in an error message.
|
|
128
|
+
- The hash never reaches a `User` — `hasPassword` says whether there is one.
|
|
129
|
+
- `signIn` compares against a dummy hash when nobody holds the login, so the
|
|
130
|
+
time taken does not reveal which accounts exist. The store's own latency
|
|
131
|
+
still can, and that limit is stated rather than denied.
|
|
132
|
+
|
|
133
|
+
## See also
|
|
134
|
+
|
|
135
|
+
- [Users](users.md) — `signIn`, `setPassword`, `changePassword`
|
|
136
|
+
- [Errors](errors.md) — `CREDENTIALS_INVALID` and its `reason`
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# Permissions — `@nxgt/janus/permissions`
|
|
2
|
+
|
|
3
|
+
This page is for modelling who may do what, and asking: `defineModel`,
|
|
4
|
+
`fromField`, `when`, then `can`, `list`, `grant` and `revoke`. It is
|
|
5
|
+
Zanzibar's model — relations between objects and subjects, permissions
|
|
6
|
+
computed from them — **without its infrastructure**: the tuples live in your
|
|
7
|
+
database, so a read follows a write and there is nothing to cache.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
12
|
+
import { createMemoryRelations, defineModel, permissions } from '@nxgt/janus/permissions';
|
|
13
|
+
|
|
14
|
+
const relations = createMemoryRelations();
|
|
15
|
+
|
|
16
|
+
const auth = janus({
|
|
17
|
+
users: {
|
|
18
|
+
staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
|
|
19
|
+
},
|
|
20
|
+
store: createMemoryStores(),
|
|
21
|
+
relations, // deleting a user deletes every tuple naming them
|
|
22
|
+
hasher: scryptHasher(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const model = defineModel({
|
|
26
|
+
subjects: auth.types, // 'staff': a user type is a subject type
|
|
27
|
+
types: {
|
|
28
|
+
team: {
|
|
29
|
+
relations: { member: ['staff', 'team#member'], lead: ['staff'] },
|
|
30
|
+
permissions: { manage: ['lead'], view: ['member', 'manage'] },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const access = permissions({ model, store: relations });
|
|
36
|
+
|
|
37
|
+
const grace = await auth.staff.create({ username: 'grace' });
|
|
38
|
+
const team = { type: 'team', id: 't1' } as const;
|
|
39
|
+
|
|
40
|
+
await access.grant(team, 'lead', grace);
|
|
41
|
+
await access.can(grace, 'view', team); // true: view includes manage, which includes lead
|
|
42
|
+
await access.revoke(team, 'lead', grace);
|
|
43
|
+
await access.can(grace, 'view', team); // false
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## The model
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
function defineModel<const C extends ModelConfig>(config: C & CheckedModel<C>): PermissionModel<C>;
|
|
50
|
+
|
|
51
|
+
interface ModelConfig {
|
|
52
|
+
readonly subjects: readonly string[]; // pass auth.types
|
|
53
|
+
readonly types: {
|
|
54
|
+
readonly [objectType: string]: {
|
|
55
|
+
readonly relations?: { readonly [name: string]: readonly string[] | FromField };
|
|
56
|
+
readonly permissions?: { readonly [name: string]: readonly (string | When)[] };
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**A relation** lists who may hold it:
|
|
63
|
+
|
|
64
|
+
| Holder | Means |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `'staff'` | one user of type `staff` |
|
|
67
|
+
| `'team'` | one object of type `team` — what an arrow follows |
|
|
68
|
+
| `'team#member'` | a **subject set**: every member of a team |
|
|
69
|
+
| `fromField('doctorId', 'staff')` | read from the object's own data — see [`fromField`](#fromfield) |
|
|
70
|
+
|
|
71
|
+
**A permission** is the union of its rules:
|
|
72
|
+
|
|
73
|
+
| Rule | Means |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `'member'` | a relation of the same object |
|
|
76
|
+
| `'manage'` | another permission of the same object |
|
|
77
|
+
| `'team->view'` | an **arrow**: whoever holds `view` on the object's `team` |
|
|
78
|
+
| `when('doctor', (ctx: { onShift: boolean }) => ctx.onShift)` | a rule under a condition — see [`when`](#when) |
|
|
79
|
+
|
|
80
|
+
A permission may be asked of `can` and `list` like a relation, and a relation
|
|
81
|
+
like a permission.
|
|
82
|
+
|
|
83
|
+
### What the compiler refuses
|
|
84
|
+
|
|
85
|
+
A relation naming a type that does not exist, a rule naming nothing, an arrow
|
|
86
|
+
to a permission its target lacks, a name that is both a relation and a
|
|
87
|
+
permission: each is a compile error **on the offending key**, and the error
|
|
88
|
+
lists what you could have written.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
defineModel({
|
|
92
|
+
subjects: ['staff'],
|
|
93
|
+
types: {
|
|
94
|
+
team: {
|
|
95
|
+
// @ts-expect-error — '"staf" is not a subject type or a subject set; name one of' …
|
|
96
|
+
relations: { member: ['staf'] },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### What `defineModel` refuses at run time
|
|
103
|
+
|
|
104
|
+
With a `TypeError`, when the model is defined — never at a check:
|
|
105
|
+
|
|
106
|
+
- a name that is not camelCase;
|
|
107
|
+
- an object type named like a user type;
|
|
108
|
+
- a permission that reaches itself without crossing a relation (`view:
|
|
109
|
+
['edit'], edit: ['view']`) — no data could ever end that loop;
|
|
110
|
+
- a subject set or an arrow that would have to read **another** object's
|
|
111
|
+
`fromField` — only the object passed to `can()` carries its data. Store that
|
|
112
|
+
relation instead.
|
|
113
|
+
|
|
114
|
+
A loop that crosses a relation — a folder viewable through its parent — is
|
|
115
|
+
fine: the data ends it.
|
|
116
|
+
|
|
117
|
+
## `fromField`
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
function fromField(field, subjectType): FromField;
|
|
121
|
+
function fromField(field, subjectType, { lookup }): ReversibleFromField;
|
|
122
|
+
type Lookup = (subjectId: string) => Promise<readonly string[]>;
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A relation read from the object's own data — a record's `doctorId` — rather
|
|
126
|
+
than a tuple kept in sync with it. Nothing is stored, and `grant` refuses it at
|
|
127
|
+
compile time. `can()` is given the object, and the compiler requires every
|
|
128
|
+
field a `fromField` of its type reads:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const model = defineModel({
|
|
132
|
+
subjects: ['staff'],
|
|
133
|
+
types: {
|
|
134
|
+
record: {
|
|
135
|
+
relations: { doctor: fromField('doctorId', 'staff') },
|
|
136
|
+
permissions: { view: ['doctor'] },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const access = permissions({ model, store: createMemoryRelations() });
|
|
141
|
+
|
|
142
|
+
const record = { id: 'r1', doctorId: grace.id, title: 'Chart' }; // as your database answered it
|
|
143
|
+
await access.can(grace, 'view', { type: 'record', ...record });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Spread the loaded object.** A field missing at run time is a `TypeError`,
|
|
147
|
+
never a denial. `null` in the field holds nobody.
|
|
148
|
+
|
|
149
|
+
`list()` cannot read a field of objects it has not found, so it asks `lookup`
|
|
150
|
+
for the ids of the objects whose field names the subject. A `list()` that would
|
|
151
|
+
reach a `fromField` without one is a compile error:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
fromField('doctorId', 'staff', { lookup: (staffId) => db.records.ids({ doctorId: staffId }) });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A lookup is your code, and it is not guarded: one that throws rejects `list()`
|
|
158
|
+
with its own error. **Never answer `[]` for a database that could not answer**
|
|
159
|
+
— that is a denial made of an outage.
|
|
160
|
+
|
|
161
|
+
## `when`
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
function when<const Rule extends string, Ctx>(rule: Rule, test: (ctx: Ctx) => boolean): When<Rule, Ctx>;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Puts a condition written in TypeScript on a rule — any rule of the same type:
|
|
168
|
+
a relation, a permission, an arrow. The test is synchronous and pure: it
|
|
169
|
+
decides on what the caller passes, and reads nothing. Its `ctx` is what `can()`
|
|
170
|
+
and `list()` then **require**, and only for the permissions whose rules reach
|
|
171
|
+
it:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const model = defineModel({
|
|
175
|
+
subjects: ['staff'],
|
|
176
|
+
types: {
|
|
177
|
+
record: {
|
|
178
|
+
relations: { doctor: fromField('doctorId', 'staff') },
|
|
179
|
+
permissions: {
|
|
180
|
+
edit: [when('doctor', (ctx: { onShift: boolean }) => ctx.onShift)],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const access = permissions({ model, store: createMemoryRelations() });
|
|
186
|
+
|
|
187
|
+
await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: true } });
|
|
188
|
+
// @ts-expect-error — ctx is required: edit reaches a condition
|
|
189
|
+
await access.can(grace, 'edit', { type: 'record', ...record });
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## `permissions()`
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
function permissions<C extends ModelConfig>(options: PermissionsOptions<C>): Permissions<C>;
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| Option | Type | Default | Effect |
|
|
199
|
+
| --- | --- | --- | --- |
|
|
200
|
+
| `model` | `PermissionModel` | — | What `defineModel` answered |
|
|
201
|
+
| `store` | `RelationStore` | — | Where tuples live: `createMemoryRelations()` in tests, `createMongoRelations(db)` from `@nxgt/janus-mongo` |
|
|
202
|
+
| `maxDepth` | positive integer | `25` | How many relations a check may cross. Past it: `PERMISSION_DEPTH` |
|
|
203
|
+
|
|
204
|
+
It connects to nothing, and refuses a store missing a method with a
|
|
205
|
+
`TypeError`.
|
|
206
|
+
|
|
207
|
+
### `can`
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
can(subject, permission, object, options?): Promise<boolean>;
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- `subject` is a user from `janus()` as it is — any object with `type` and
|
|
214
|
+
`id` — an object of the model, or `null` for anonymous, which answers `false`
|
|
215
|
+
before any store call.
|
|
216
|
+
- `permission` must be a relation or a permission of the object's type.
|
|
217
|
+
- `object` is `{ type, id }` plus every field its `fromField`s read.
|
|
218
|
+
- `options.ctx` is required exactly when a `when` is reachable.
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
await access.can(grace, 'view', { type: 'record', ...record }); // true
|
|
222
|
+
await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } }); // false
|
|
223
|
+
await access.can(null, 'view', { type: 'record', ...record }); // false, no store call
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**A denial is `false`; a failure throws.** A store that cannot answer is
|
|
227
|
+
`STORE_FAILED`; a walk past `maxDepth` is `PERMISSION_DEPTH`. Neither is ever
|
|
228
|
+
`false`, which would deny everybody everything during an outage and say
|
|
229
|
+
nothing. A cycle in the data — a team member of itself — is cut, and is not an
|
|
230
|
+
error.
|
|
231
|
+
|
|
232
|
+
### `list`
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
list(subject, permission, objectType, options?): Promise<CursorPage<string>>;
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The ids of the objects of `objectType` on which `subject` holds `permission`,
|
|
239
|
+
ascending, by pages — what `can()` answers `true` for, found without naming
|
|
240
|
+
them.
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
const page = await access.list(grace, 'view', 'record', { limit: 50 });
|
|
244
|
+
page.items; // readonly string[]
|
|
245
|
+
page.nextCursor; // string | null
|
|
246
|
+
await access.list(grace, 'view', 'record', { after: page.nextCursor, limit: 50 });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`limit` is 20 by default and at most 100; `ctx` is required as for `can`.
|
|
250
|
+
`null` answers an empty page before any store call.
|
|
251
|
+
|
|
252
|
+
`list()` walks backwards from the subject, reading every page of the reverse
|
|
253
|
+
index for every id each step reaches. That is fine for what one user can see,
|
|
254
|
+
and not for a subject set holding most of the database — write a query of your
|
|
255
|
+
own for that.
|
|
256
|
+
|
|
257
|
+
### `grant` and `revoke`
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
grant(object, relation, holder): Promise<void>;
|
|
261
|
+
revoke(object, relation, holder): Promise<void>;
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Both are typed from the model: only a stored relation (never a `fromField`),
|
|
265
|
+
and only a holder the relation admits.
|
|
266
|
+
|
|
267
|
+
The same rule holds when reading: `can()` and `list()` follow only the holders
|
|
268
|
+
a relation admits. A tuple stored past `grant()` — by an older model, or by
|
|
269
|
+
hand — that the model does not admit grants nothing. `revoke()` refuses it as
|
|
270
|
+
it refuses to `grant()` it, so remove it with the store:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
await relations.write({
|
|
274
|
+
remove: [
|
|
275
|
+
{
|
|
276
|
+
object: { type: 'record', id: 'r1' },
|
|
277
|
+
relation: 'viewer',
|
|
278
|
+
subject: { type: 'team', id: 't1' },
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Narrowing a model therefore hides the tuples it no longer admits; it does not
|
|
285
|
+
delete them, and widening it again brings them back.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
await access.grant({ type: 'team', id: 't1' }, 'member', grace);
|
|
289
|
+
await access.grant({ type: 'team', id: 't1' }, 'member', { type: 'team', id: 't2', relation: 'member' }); // a subject set
|
|
290
|
+
await access.grant({ type: 'record', id: 'r1' }, 'team', { type: 'team', id: 't1' }); // what 'team->view' follows
|
|
291
|
+
await access.revoke({ type: 'team', id: 't1' }, 'member', grace);
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Both are idempotent: granting what is held, or revoking what is not, is not an
|
|
295
|
+
error. Each writes one tuple.
|
|
296
|
+
|
|
297
|
+
### Deleting
|
|
298
|
+
|
|
299
|
+
Wire the relation store into `janus({ relations })`, and deleting a user
|
|
300
|
+
deletes every tuple naming them. Deleting an object's tuples is yours, from
|
|
301
|
+
your own code, when you delete the object:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
await relations.deleteEntity({ type: 'record', id: 'r1' }); // answers how many tuples it removed
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## A route guard
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { PermissionDepthError, StoreFailure } from '@nxgt/janus';
|
|
311
|
+
|
|
312
|
+
export async function getRecord(request: Request, id: string): Promise<Response> {
|
|
313
|
+
try {
|
|
314
|
+
const current = await auth.authenticate(request, { type: 'staff' });
|
|
315
|
+
const record = await loadRecord(id);
|
|
316
|
+
if (record === null) return new Response(null, { status: 404 });
|
|
317
|
+
|
|
318
|
+
const allowed = await access.can(current?.user ?? null, 'view', { type: 'record', ...record });
|
|
319
|
+
if (!allowed) return new Response(null, { status: current ? 403 : 401 });
|
|
320
|
+
return Response.json(record);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error instanceof StoreFailure) return new Response(null, { status: 503 });
|
|
323
|
+
if (error instanceof PermissionDepthError) return new Response(null, { status: 500 });
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Subjects and the notation
|
|
330
|
+
|
|
331
|
+
A user **is** a subject: `subjectOf(user)` from `@nxgt/janus` answers its
|
|
332
|
+
`{ type, id }`, and `can` takes the user as it is. Tuples print in Zanzibar's
|
|
333
|
+
notation, typed — see [the shared vocabulary](vocabulary.md#subjects-and-the-tuple-notation).
|
|
334
|
+
|
|
335
|
+
## The relation store
|
|
336
|
+
|
|
337
|
+
`RelationStore` is six methods answering one-hop questions about stored tuples
|
|
338
|
+
— `write`, `has`, `findSubjectSets`, `findEntities`, `findObjects`,
|
|
339
|
+
`deleteEntity`. The traversal is the core's. `createMemoryRelations()` is the
|
|
340
|
+
reference implementation, and [Writing an adapter](adapters.md) covers the
|
|
341
|
+
rest.
|
|
342
|
+
|
|
343
|
+
## See also
|
|
344
|
+
|
|
345
|
+
- [Users](users.md) — `janus({ relations })`, and user types as subject types
|
|
346
|
+
- [Errors](errors.md) — `STORE_FAILED` and `PERMISSION_DEPTH`
|
|
347
|
+
- [Writing an adapter](adapters.md) — `RelationStore` and its conformance suite
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Sessions
|
|
2
|
+
|
|
3
|
+
This page is for everything after a user has signed in: finding who a request
|
|
4
|
+
belongs to, the session cookie, renewal, signing out, and testing expiry.
|
|
5
|
+
Signing up and signing in are on the [users](users.md) page.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
10
|
+
|
|
11
|
+
const auth = janus({
|
|
12
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
13
|
+
password: { login: 'email' },
|
|
14
|
+
store: createMemoryStores(),
|
|
15
|
+
hasher: scryptHasher(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export async function me(request: Request): Promise<Response> {
|
|
19
|
+
const current = await auth.authenticate(request);
|
|
20
|
+
if (current === null) return new Response(null, { status: 401 });
|
|
21
|
+
|
|
22
|
+
const headers = new Headers();
|
|
23
|
+
if (current.renewed) {
|
|
24
|
+
headers.set('Set-Cookie', auth.cookie.serialize(current.token, current.session));
|
|
25
|
+
}
|
|
26
|
+
return Response.json({ id: current.user.id, email: current.user.email }, { headers });
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## `authenticate`
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
authenticate<T extends User['type'] = User['type']>(
|
|
34
|
+
request: RequestLike,
|
|
35
|
+
options?: { readonly type?: T },
|
|
36
|
+
): Promise<Authenticated<Extract<User, { type: T }>> | null>;
|
|
37
|
+
|
|
38
|
+
interface Authenticated<U> {
|
|
39
|
+
readonly user: U;
|
|
40
|
+
readonly session: Session; // the stored session, without its token hash
|
|
41
|
+
readonly token: string; // the token the request presented
|
|
42
|
+
readonly renewed: boolean; // true when this call moved session.expiresAt
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`request` is a `Request`, a `Headers`, anything with `headers` (a Node
|
|
47
|
+
`IncomingMessage`), or a plain header record:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
await auth.authenticate(new Headers({ authorization: `Bearer ${token}` }));
|
|
51
|
+
await auth.authenticate({ 'x-session-token': token });
|
|
52
|
+
await auth.authenticate({ headers: { cookie: `janus-session=${token}` } });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It reads `Authorization: Bearer`, then `X-Session-Token`, then the cookie.
|
|
56
|
+
**The first credential present wins, not the first valid one**: a client that
|
|
57
|
+
sends a lapsed bearer beside a live cookie is anonymous. An `Authorization`
|
|
58
|
+
header of another scheme (`Basic`) is not a session credential.
|
|
59
|
+
|
|
60
|
+
It answers `null` — anonymous — for no credential, an unknown token, a lapsed
|
|
61
|
+
or revoked session, a user deleted or inactive, or a user of another type than
|
|
62
|
+
`options.type`:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const staff = await clinic.authenticate(request, { type: 'staff' }); // a patient's session → null
|
|
66
|
+
staff?.user.username; // typed as staff
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**An outage is not anonymous.** When a store cannot answer, `authenticate`
|
|
70
|
+
rejects with `STORE_FAILED` — answer 503. A 401 would sign everybody out during
|
|
71
|
+
the outage and send them to a sign-in page that cannot work either:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { StoreFailure } from '@nxgt/janus';
|
|
75
|
+
|
|
76
|
+
export async function guarded(request: Request): Promise<Response> {
|
|
77
|
+
try {
|
|
78
|
+
const current = await auth.authenticate(request);
|
|
79
|
+
return current === null ? new Response(null, { status: 401 }) : Response.json(current.user);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof StoreFailure) return new Response(null, { status: 503 });
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Lifespan and renewal
|
|
88
|
+
|
|
89
|
+
| Option | Type | Default | Effect |
|
|
90
|
+
| --- | --- | --- | --- |
|
|
91
|
+
| `session.lifespan` | `Duration` | `'7d'` | How long a session lives from when it was opened or last renewed |
|
|
92
|
+
| `session.renewAfter` | `Duration \| false` | `'1d'` | Once this much has passed, `authenticate` moves `expiresAt` to a whole `lifespan` from now and answers `renewed: true`. `false`: a fixed lifespan |
|
|
93
|
+
|
|
94
|
+
A sliding session is written **at most once per `renewAfter`**, not on every
|
|
95
|
+
request. When `renewed` is `true`, send the cookie again — its `Expires` moved.
|
|
96
|
+
With several user types, each type has its own `session`.
|
|
97
|
+
|
|
98
|
+
Expiry is decided by the core on every read, not by the store: a store may
|
|
99
|
+
still hold a lapsed session, and `authenticate` answers it as anonymous.
|
|
100
|
+
|
|
101
|
+
## The cookie
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const auth = janus({
|
|
105
|
+
user: z.object({ email: z.email() }),
|
|
106
|
+
password: { login: 'email' },
|
|
107
|
+
store: createMemoryStores(),
|
|
108
|
+
hasher: scryptHasher(),
|
|
109
|
+
cookie: { name: 'sid', sameSite: 'strict', domain: 'example.com' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const { session, token } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
|
|
113
|
+
|
|
114
|
+
auth.cookie.name; // 'sid'
|
|
115
|
+
auth.cookie.serialize(token, session); // 'sid=…; Expires=…; Path=/; Domain=example.com; HttpOnly; SameSite=Strict; Secure'
|
|
116
|
+
auth.cookie.clear(); // the same cookie, expired
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Option | Type | Default | Effect |
|
|
120
|
+
| --- | --- | --- | --- |
|
|
121
|
+
| `name` | string | `'janus-session'` | A cookie-name token: no space, `;` or `=` |
|
|
122
|
+
| `domain` | string | none | `Domain=` |
|
|
123
|
+
| `path` | string | `'/'` | `Path=` |
|
|
124
|
+
| `sameSite` | `'lax' \| 'strict' \| 'none'` | `'lax'` | `SameSite=` |
|
|
125
|
+
| `secure` | boolean | `true` | `Secure`. `sameSite: 'none'` requires it |
|
|
126
|
+
|
|
127
|
+
`HttpOnly` is always set. The cookie methods are synchronous and write
|
|
128
|
+
nothing: they build a `Set-Cookie` value for you to send.
|
|
129
|
+
|
|
130
|
+
## Signing in and out
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
export async function signIn(request: Request): Promise<Response> {
|
|
134
|
+
const { email, password } = (await request.json()) as { email: string; password: string };
|
|
135
|
+
const { user, session, token } = await auth.signIn({ email, password });
|
|
136
|
+
return Response.json(
|
|
137
|
+
{ id: user.id, token }, // a native client keeps the token and sends it as a bearer
|
|
138
|
+
{ headers: { 'Set-Cookie': auth.cookie.serialize(token, session) } },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function signOut(request: Request): Promise<Response> {
|
|
143
|
+
await auth.signOut(request); // false when the request presents no session, or an unknown one
|
|
144
|
+
return new Response(null, { status: 204, headers: { 'Set-Cookie': auth.cookie.clear() } });
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`signOutEverywhere(user, { except })` revokes every standing session of a
|
|
149
|
+
user, but the one named, and answers how many it revoked — "sign out
|
|
150
|
+
everywhere else":
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const current = await auth.authenticate(request);
|
|
154
|
+
if (current !== null) {
|
|
155
|
+
await auth.signOutEverywhere(current.user, { except: current.session.id });
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`changePassword` leaves other sessions open; call this after it when that is
|
|
160
|
+
your policy. `resetPassword.confirm` signs out everywhere on its own.
|
|
161
|
+
|
|
162
|
+
## `collectExpired`
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const removed: number = await auth.collectExpired();
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Deletes lapsed sessions, for a store that keeps them. It rejects with
|
|
169
|
+
`UNSUPPORTED` when the sessions store does not implement the optional
|
|
170
|
+
`deleteExpiredSessions` — a store with its own TTL, such as
|
|
171
|
+
`@nxgt/janus-mongo`'s, does not. The reference store implements it.
|
|
172
|
+
|
|
173
|
+
## Testing expiry
|
|
174
|
+
|
|
175
|
+
`fixedClock` is shipped for this:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { expect, it } from 'bun:test';
|
|
179
|
+
import { z } from 'zod';
|
|
180
|
+
import { createMemoryStores, fixedClock, janus, scryptHasher } from '@nxgt/janus';
|
|
181
|
+
|
|
182
|
+
it('lapses after seven days', async () => {
|
|
183
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1));
|
|
184
|
+
const auth = janus({
|
|
185
|
+
user: z.object({ email: z.email() }),
|
|
186
|
+
password: { login: 'email' },
|
|
187
|
+
store: createMemoryStores(),
|
|
188
|
+
hasher: scryptHasher({ cost: 10 }), // fast in tests; keep the default in production
|
|
189
|
+
clock,
|
|
190
|
+
});
|
|
191
|
+
const { token } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
|
|
192
|
+
const bearer = { authorization: `Bearer ${token}` };
|
|
193
|
+
|
|
194
|
+
clock.advance(8 * 24 * 60 * 60 * 1000);
|
|
195
|
+
|
|
196
|
+
expect(await auth.authenticate(bearer)).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## What is stored
|
|
201
|
+
|
|
202
|
+
The token is 32 random bytes, handed back once in `SignedIn` and
|
|
203
|
+
`Authenticated`. The store holds only its `sha256`, so a dump of the store
|
|
204
|
+
cannot be replayed. `Session` is the stored record without that hash: `id`,
|
|
205
|
+
`userId`, `authenticatedAt`, `expiresAt`, `revokedAt`, `createdAt`.
|
|
206
|
+
|
|
207
|
+
## See also
|
|
208
|
+
|
|
209
|
+
- [Users](users.md) — `signUp`, `signIn`, the configuration
|
|
210
|
+
- [E-mail flows](email-flows.md) — verification and password reset
|
|
211
|
+
- [Errors](errors.md) — `STORE_FAILED`, `UNSUPPORTED` and the rest
|