@aweftjs/auth 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 +548 -0
- package/dist/auth-client.d.ts +248 -0
- package/dist/auth-client.js +287 -0
- package/dist/client-modules/Reset.d.ts +12 -0
- package/dist/client-modules/Reset.js +61 -0
- package/dist/client-modules/Session.d.ts +6 -0
- package/dist/client-modules/Session.js +59 -0
- package/dist/client-modules/SignIn.d.ts +13 -0
- package/dist/client-modules/SignIn.js +58 -0
- package/dist/client-modules/Verify.d.ts +12 -0
- package/dist/client-modules/Verify.js +54 -0
- package/dist/client-modules/stage-token.d.ts +3 -0
- package/dist/client-modules/stage-token.js +9 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +33 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +11 -0
- package/dist/cookie.d.ts +15 -0
- package/dist/cookie.js +39 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +42 -0
- package/dist/links.d.ts +37 -0
- package/dist/links.js +77 -0
- package/dist/mail.d.ts +55 -0
- package/dist/mail.js +53 -0
- package/dist/modules/Check.d.ts +10 -0
- package/dist/modules/Check.js +20 -0
- package/dist/modules/Enter.d.ts +57 -0
- package/dist/modules/Enter.js +143 -0
- package/dist/modules/Gate.d.ts +6 -0
- package/dist/modules/Gate.js +43 -0
- package/dist/modules/Password.d.ts +34 -0
- package/dist/modules/Password.js +138 -0
- package/dist/modules/Roles.d.ts +32 -0
- package/dist/modules/Roles.js +135 -0
- package/dist/modules/Session.d.ts +39 -0
- package/dist/modules/Session.js +159 -0
- package/dist/modules/State.d.ts +8 -0
- package/dist/modules/State.js +22 -0
- package/dist/modules/Verify.d.ts +32 -0
- package/dist/modules/Verify.js +97 -0
- package/dist/names.d.ts +25 -0
- package/dist/names.js +44 -0
- package/dist/password.d.ts +4 -0
- package/dist/password.js +41 -0
- package/dist/props.d.ts +15 -0
- package/dist/props.js +35 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.js +11 -0
- package/dist/users.d.ts +9 -0
- package/dist/users.js +12 -0
- package/errors.txt +29 -0
- package/package.json +62 -0
- package/src/auth-client.ts +531 -0
- package/src/client-modules/Reset.tsx +97 -0
- package/src/client-modules/Session.ts +70 -0
- package/src/client-modules/SignIn.tsx +110 -0
- package/src/client-modules/Verify.tsx +82 -0
- package/src/client-modules/stage-token.ts +11 -0
- package/src/client.ts +38 -0
- package/src/context.ts +21 -0
- package/src/cookie.ts +35 -0
- package/src/index.ts +53 -0
- package/src/links.ts +107 -0
- package/src/mail.ts +88 -0
- package/src/modules/Check.ts +31 -0
- package/src/modules/Enter.ts +189 -0
- package/src/modules/Gate.ts +47 -0
- package/src/modules/Password.ts +155 -0
- package/src/modules/Roles.ts +163 -0
- package/src/modules/Session.ts +196 -0
- package/src/modules/State.ts +30 -0
- package/src/modules/Verify.ts +118 -0
- package/src/names.ts +49 -0
- package/src/password.ts +43 -0
- package/src/props.ts +47 -0
- package/src/token.ts +15 -0
- package/src/users.ts +18 -0
- package/surface.txt +19 -0
- package/text.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Torrin Leonard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
# @aweftjs/auth
|
|
2
|
+
|
|
3
|
+
The first battery: server modules for who is on a connection and what they may do. A gate that
|
|
4
|
+
reads `public` and `needs`, sessions as documents in your store, sign-in and sign-up by email
|
|
5
|
+
and password, the names a person holds, and a per-user state document shared on every
|
|
6
|
+
connection of theirs. A second source, `mail`, adds email verification, password change,
|
|
7
|
+
forgot and reset, and needs the notify battery. `@aweftjs/auth/client` is the browser half: who
|
|
8
|
+
the page is, what they hold, signing in and out, that state document, the mail calls, and four
|
|
9
|
+
page modules a stage loads by name.
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { auth, mail, paths } from '@aweftjs/auth';
|
|
15
|
+
import { fromDirectory } from '@aweftjs/modules/node';
|
|
16
|
+
import { notify } from '@aweftjs/notify';
|
|
17
|
+
import { createServer } from '@aweftjs/server';
|
|
18
|
+
import { node } from '@aweftjs/server/node';
|
|
19
|
+
import { createStore, memoryDriver } from '@aweftjs/store';
|
|
20
|
+
|
|
21
|
+
const store = createStore({ driver: memoryDriver(), declare: { ...paths, title: ['title'] } });
|
|
22
|
+
|
|
23
|
+
const server = createServer({
|
|
24
|
+
sources: [fromDirectory('./modules'), auth, mail, notify],
|
|
25
|
+
store,
|
|
26
|
+
gate: 'auth/Gate',
|
|
27
|
+
listener: node({ port: 8080 }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await server.start();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`auth` is a source, so `start` loads all six of these modules with everything else your
|
|
34
|
+
sources list, and each reads the `store` the server handed in. `gate: 'auth/Gate'` names the
|
|
35
|
+
gate; to keep this policy and add a rule of your own, write a module that `deps` on
|
|
36
|
+
`auth/Gate` and name that instead. `paths` is the three store declarations they query: `email`
|
|
37
|
+
on user documents, `user` and `expires` on session and link documents. Your own source goes
|
|
38
|
+
first, so a module of the same name in your directory replaces one of these. `mail` is the
|
|
39
|
+
two modules that send email, and `notify` is what they send through: leave both out and
|
|
40
|
+
`auth` alone loads as before; list `mail` without `notify` and `start` refuses with `missing`,
|
|
41
|
+
naming `notify/Send`.
|
|
42
|
+
|
|
43
|
+
## The modules
|
|
44
|
+
|
|
45
|
+
| module | public | what it does |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `auth/Gate` | | `identify` reads the session cookie; `access` allows a module that declares `public: true` to anyone, one that declares `needs` to a signed-in user holding the name, and any other module only to a signed-in user |
|
|
48
|
+
| `auth/Session` | yes | `issue(user)`, `revoke(token)`, `revokeAll(user, except?)`, `whoIs(request)`, `setCookie(token, request)`, a `call` answering `{ user }` for the asking connection, and `DELETE /api/session` |
|
|
49
|
+
| `auth/Roles` | | `may(user, name)`, `grant(user, ...names)`, `revoke(user, ...names)`, `names(user)`; shares `roles:<user>` read-only under the topic `roles`, and a `call` answering the `implies` table |
|
|
50
|
+
| `auth/Enter` | yes | `enter(email, password)` signs in, or signs up when nobody has the email; `checkPassword(password)` answers the reasons the rules refuse one; `POST /api/session` does the first and sets the cookie |
|
|
51
|
+
| `auth/Check` | yes | `exists(email)`, and a `call` answering `{ exists }` for `{ email }` |
|
|
52
|
+
| `auth/State` | | shares `state:<user>` from the store on the connection, under the topic `state` |
|
|
53
|
+
|
|
54
|
+
And in `mail`, both naming `notify/Send` in their `deps`:
|
|
55
|
+
|
|
56
|
+
| module | public | what it does |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `auth/Verify` | yes | `send(user)` mails a one-time link; `confirm(token)` takes it, writes `emailVerified` and grants the name `verified`; `POST /api/verify/send` and `POST /api/verify` |
|
|
59
|
+
| `auth/Password` | yes | `change(user, current, password, keep?)`, `forgot(email)`, `reset(token, password)`; `POST /api/password`, `POST /api/password/forgot`, `POST /api/password/reset` |
|
|
60
|
+
|
|
61
|
+
**A module of yours declares `public: true`, `needs`, or nothing.** Absent means private: only
|
|
62
|
+
a connection with a user reaches it. `needs: 'reports'` (or a list, meaning every name in it)
|
|
63
|
+
means a signed-in user who holds that name; anonymous is refused `private` whatever else the
|
|
64
|
+
module declares, and a person lacking the name is refused `needs`. Those are this gate's words;
|
|
65
|
+
`@aweftjs/server` does not know them.
|
|
66
|
+
|
|
67
|
+
## Names
|
|
68
|
+
|
|
69
|
+
A person holds **names**. A role and a feature are the same kind of thing: `admin`,
|
|
70
|
+
`verified`, `posts.delete`, `products.abc123`. A name is non-empty text with no whitespace.
|
|
71
|
+
Names are dotted, and holding one covers everything under it: `products` covers
|
|
72
|
+
`products.abc123.read`, `products.abc123` covers one product, and `*` covers everything.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// modules/reports/Monthly.ts: only a person holding `reports` reaches it
|
|
76
|
+
export default () => ({ needs: 'reports', call: () => monthly() });
|
|
77
|
+
|
|
78
|
+
// modules/products/Read.ts: a rule finer than a module, asked of auth/Roles
|
|
79
|
+
import { codecError } from '@aweftjs/codec';
|
|
80
|
+
|
|
81
|
+
export const deps = ['auth/Roles'];
|
|
82
|
+
export default ({ imports }) => ({
|
|
83
|
+
call: async ({ id }, context) => {
|
|
84
|
+
if (!(await imports.Roles.may(context.user, `products.${id}.read`))) throw codecError('needs', `product ${id} is not yours to read`, 'Ask for access.');
|
|
85
|
+
return product(id);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// modules/admin/Grant.ts: who grants is yours; this one lets an administrator hand out names
|
|
90
|
+
export const deps = ['auth/Roles'];
|
|
91
|
+
export default ({ imports }) => ({
|
|
92
|
+
needs: 'admin',
|
|
93
|
+
call: ({ user, name }) => imports.Roles.grant(user, name),
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**A table says which names imply others**, and it is configuration on `auth/Roles`, the same
|
|
98
|
+
same-named-file way every battery module is configured. `first` is the names the first person
|
|
99
|
+
to sign up is granted, so a fresh application has an administrator without a script:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// modules/auth/Roles.ts
|
|
103
|
+
export const config = {
|
|
104
|
+
implies: { admin: ['*'], moderator: ['posts.delete', 'posts.hide'], member: ['products.read'] },
|
|
105
|
+
first: ['admin'],
|
|
106
|
+
};
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`may(user, name)` is true when the person was granted the name, was granted one that covers
|
|
110
|
+
it, or was granted one the table implies it from, transitively. The table is keyed by the
|
|
111
|
+
exact name held: `admin.super` implies what `admin.super` lists, not what `admin` does, so a
|
|
112
|
+
hierarchy of roles is written into the table. `holds(granted, implies, name)` is the same
|
|
113
|
+
check as a plain function, exported from the root for code that already holds a list.
|
|
114
|
+
|
|
115
|
+
**Every check reads the store.** There is no cache and no per-connection snapshot: a grant or a
|
|
116
|
+
revoke is seen by the next call, request or connection the person makes, and by the page they
|
|
117
|
+
have open, through the share. A module with no `needs` costs nothing new; a gated one costs one
|
|
118
|
+
document read per check.
|
|
119
|
+
|
|
120
|
+
**Who grants is yours.** No route ships. `grant` and `revoke` are for a module of your own,
|
|
121
|
+
inside the process, which is the one place the security suite's administrator case says a
|
|
122
|
+
grant can come from. `first` goes to the first person to sign up after the module is loaded on
|
|
123
|
+
a store with nobody in it, and a marker document (`auth:first`) says it has happened; loaded on
|
|
124
|
+
a store where people already exist, the module writes the marker for nobody as it is made, so
|
|
125
|
+
an application that configures `first` once its people exist hands nothing to the next
|
|
126
|
+
stranger. Two sign-ups in the same instant in one process settle to one first; two processes
|
|
127
|
+
over one store can each see a first, the same way two sign-ups for one email can both succeed.
|
|
128
|
+
|
|
129
|
+
**`verified` is a name like any other.** `auth/Verify` grants it once the person has opened the
|
|
130
|
+
link, so a module that wants a verified person says `needs: 'verified'`.
|
|
131
|
+
|
|
132
|
+
## Signing in and out
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
POST /api/session { "email": "ada@example.com", "password": "..." }
|
|
136
|
+
201 { "user": "<id>", "created": true } the email was new: signed up
|
|
137
|
+
200 { "user": "<id>", "created": false } signed in
|
|
138
|
+
401 { "reasons": [{ "code": "password", ... }] }
|
|
139
|
+
400 { "reasons": [{ "code": "email" | "password", ... }] }
|
|
140
|
+
403 { "reasons": [{ "code": ..., ... }] } a sign-up your refuseSignUp closed the door to
|
|
141
|
+
Set-Cookie: session=<token>; Path=/; HttpOnly; SameSite=Lax[; Secure][; Max-Age=...]
|
|
142
|
+
|
|
143
|
+
DELETE /api/session
|
|
144
|
+
200 { "user": null }, and the cookie is cleared
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Identity is fixed for a connection's life, read from the cookie at the handshake. After
|
|
148
|
+
either route the client reconnects; a cookie cannot be set on an open socket.
|
|
149
|
+
|
|
150
|
+
**What the cookie means.** This gate refuses nobody at the door. Every cookie of the name is
|
|
151
|
+
read in the order the browser sent them (it sends every one whose scope matches, so one from
|
|
152
|
+
another path or another application on the host arrives beside ours): an empty value, a
|
|
153
|
+
value that is not a token, and a token that names no live session (a store reset, a
|
|
154
|
+
sign-out, a timeout) are skipped, the first live one wins, and none is anonymous. An
|
|
155
|
+
anonymous client can always reach `DELETE /api/session` to clear what it holds. A refusal at
|
|
156
|
+
the handshake is for a gate that keys on something a client cannot repair by signing in; the
|
|
157
|
+
server's README shows one.
|
|
158
|
+
|
|
159
|
+
**No lifetime ships.** A session lasts until revoked. To set one, configure `auth/Session`
|
|
160
|
+
the way any module is configured, with a same-named file in your own source:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// modules/auth/Session.ts
|
|
164
|
+
export const config = { sessionMs: 30 * 24 * 3600 * 1000, cookie: 'session' };
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`sessionMs` that is not a positive number is refused when the module is made. `Secure` is on
|
|
168
|
+
the cookie whenever the request came over TLS as the listener sees it; behind a proxy that
|
|
169
|
+
terminates TLS, start the Node listener with `forwarded: true` so the scheme comes from
|
|
170
|
+
`x-forwarded-proto`. Where the site is served over TLS, name the cookie `__Host-session`: a
|
|
171
|
+
browser then refuses to take it from anywhere else, and refuses it over plain HTTP, which is
|
|
172
|
+
why that is not the name it ships with.
|
|
173
|
+
|
|
174
|
+
**A session that ended is swept.** `revoke` writes `expires` as the moment it happened, and
|
|
175
|
+
`auth/Session` removes every session whose end is more than `keep` days ago (30), when the
|
|
176
|
+
module is made and every `sweepMs` (an hour). A session with no lifetime that was never revoked
|
|
177
|
+
is never swept. Both are configuration beside `sessionMs`.
|
|
178
|
+
|
|
179
|
+
## The bounds
|
|
180
|
+
|
|
181
|
+
The sign-in route counts before it hashes, so a flood buys no hashing. Each number is
|
|
182
|
+
configuration on `auth/Enter`, the same same-named-file way:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// modules/auth/Enter.ts
|
|
186
|
+
export const config = {
|
|
187
|
+
attemptsPerEmail: 5, // per email inside the window; a sign-in that succeeds clears it
|
|
188
|
+
attemptsPerAddress: 20, // per peer address inside the window, whatever the emails
|
|
189
|
+
attemptsWindowMs: 900_000, // fifteen minutes
|
|
190
|
+
hashesInFlight: 8, // beyond it, 503 with Retry-After: 1 and no hash started
|
|
191
|
+
passwordMin: 8, // characters; shorter is 400 before anything is hashed
|
|
192
|
+
passwordMax: 256, // longer is 400 too, so a hash is never asked of a megabyte
|
|
193
|
+
refusePassword: null, // (password) => boolean | Promise<boolean>: true refuses with 400
|
|
194
|
+
refuseSignUp: null, // (signUp) => Refusal | undefined, sync or async: a refusal answers 403
|
|
195
|
+
};
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Over either attempt count the answer is 429 with a `Retry-After` in seconds and the reason
|
|
199
|
+
`attempts`. The address is what the gate put in the context, which is what the listener saw:
|
|
200
|
+
behind a proxy, start the Node listener with `forwarded` or every client is one address. The
|
|
201
|
+
counts are in memory and a restart clears them; each holds at most 65 536 keys, and past that
|
|
202
|
+
the oldest key under its count goes first, so a flood of fresh emails frees no locked email, and
|
|
203
|
+
a flood that locks that many does. `refusePassword` is where a breached-password list or a
|
|
204
|
+
lookup goes; the battery ships none. Any composition is taken: eight spaces are a password.
|
|
205
|
+
|
|
206
|
+
**Who may sign up is yours** (design 291). `refuseSignUp` is asked once per sign-up, after the
|
|
207
|
+
counts and the password rule and before anything is hashed, with `email` (normalised), `extra`
|
|
208
|
+
(every field of the body but `email` and `password`: an invite token, a role picked on the
|
|
209
|
+
form), `context` (what the gate identified: `user` null, the peer's `address`) and `store`, so
|
|
210
|
+
a rule that reads a document of yours needs no second way to reach it. A refusal it answers is
|
|
211
|
+
the route's 403 with that one reason, and nothing is made. A sign-in never asks it, and neither
|
|
212
|
+
does `enter()` called from a module of yours: the rule is the door's, not the function's. A rule
|
|
213
|
+
that throws is the route's 500. The battery ships none; an application that keeps invites in
|
|
214
|
+
its store writes the lookup here, and marks the invite used in the same function, since a
|
|
215
|
+
password the rule before it refused never reaches it.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
// modules/auth/Enter.ts: sign-up by invitation
|
|
219
|
+
export const config = {
|
|
220
|
+
refuseSignUp: async ({ email, extra, store }) => {
|
|
221
|
+
const token = typeof extra.invite === 'string' ? extra.invite : '';
|
|
222
|
+
if (token === '' || !(await redeem(store, token, email))) return { code: 'invite', message: 'sign-up is by invitation' };
|
|
223
|
+
return undefined;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Hashing takes memory: scrypt at these parameters holds about 16 MiB per password, so
|
|
229
|
+
`hashesInFlight` is the memory bound and the attempt counts are the rate bound. The count on
|
|
230
|
+
every route before the gate is `@aweftjs/server`'s (`limits`).
|
|
231
|
+
|
|
232
|
+
**Enumeration is accepted.** `auth/Check` answers whether an email has an account, and a sign-up
|
|
233
|
+
answers 201 where a sign-in answers 200, so anyone can learn whether an address is registered.
|
|
234
|
+
A sign-in form asks before asking for a password, and one route signs up and in. What makes it
|
|
235
|
+
survivable is the per-email count: knowing an address exists buys five tries in fifteen minutes.
|
|
236
|
+
|
|
237
|
+
## The mail flows
|
|
238
|
+
|
|
239
|
+
`mail` is `auth/Verify` and `auth/Password`. Both send through `notify/Send` with
|
|
240
|
+
`channels: ['email']`, so `notify` is in `sources` and configured with a mailer. Each needs one
|
|
241
|
+
setting with no default, because a battery never picks a URL: `url`, a function from the token
|
|
242
|
+
to the address of the page that takes it, which is where you put `auth/Verify` and
|
|
243
|
+
`auth/Reset` from the client half.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
// modules/auth/Verify.ts
|
|
247
|
+
export const config = {
|
|
248
|
+
url: (token) => `https://app.example/verify?token=${token}`,
|
|
249
|
+
subject: 'Verify your email address',
|
|
250
|
+
verifyMs: 86_400_000, // the link lives a day
|
|
251
|
+
sendsPerUser: 5, // mails one person may ask for inside sendsWindowMs (a day)
|
|
252
|
+
sendsWindowMs: 86_400_000,
|
|
253
|
+
resendMs: 60_000, // and a minute between two
|
|
254
|
+
sweepMs: 3_600_000, // how often expired links are removed
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// modules/auth/Password.ts
|
|
258
|
+
export const config = {
|
|
259
|
+
url: (token) => `https://app.example/reset?token=${token}`,
|
|
260
|
+
subject: 'Reset your password',
|
|
261
|
+
resetMs: 3_600_000, // the link lives an hour
|
|
262
|
+
attemptsPerUser: 5, // change attempts inside attemptsWindowMs (fifteen minutes); the current password is a password being guessed
|
|
263
|
+
attemptsWindowMs: 900_000,
|
|
264
|
+
forgotPerEmail: 5, // forgot asks per email and per address inside forgotWindowMs (a day)
|
|
265
|
+
forgotPerAddress: 20,
|
|
266
|
+
forgotWindowMs: 86_400_000,
|
|
267
|
+
sweepMs: 3_600_000,
|
|
268
|
+
};
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
POST /api/verify/send signed in: mails the link
|
|
273
|
+
200 { "ok": true } 401 private 409 verified 429 attempts 502 mail
|
|
274
|
+
POST /api/verify { "token": "..." } anyone: takes the link once
|
|
275
|
+
200 { "user": "<id>" } 400 token | taken
|
|
276
|
+
|
|
277
|
+
POST /api/password { "current": "...", "password": "..." } signed in
|
|
278
|
+
200 { "ok": true } 401 password (the current one is wrong) 400 password (the new one) 429 attempts
|
|
279
|
+
POST /api/password/forgot { "email": "..." } anyone: 200 whatever the address
|
|
280
|
+
200 { "ok": true } 400 email 429 attempts 502 mail
|
|
281
|
+
POST /api/password/reset { "token": "...", "password": "..." } anyone
|
|
282
|
+
200 { "user": "<id>" } 400 token | taken | password
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**A link is one use, and lives its lifetime.** A token is what a session token is, sixteen
|
|
286
|
+
random bytes; the document `verify:<token>` or `reset:<token>` names who it is for and when it
|
|
287
|
+
ends. Taking it marks the document `taken`, and the sweep removes it once it has expired,
|
|
288
|
+
taken or not. So a token names one of three things: a live link, a taken one (400 `taken`,
|
|
289
|
+
which for a verification link means the address is verified, and a page may say so), or
|
|
290
|
+
nothing (400 `token`, for a link nobody issued or one past its end; design 294). Taking a
|
|
291
|
+
verification link writes `emailVerified` on the user and grants `verified`. Taking a reset
|
|
292
|
+
link sets the password and ends every session of the person. A change needs the current
|
|
293
|
+
password, sets the new one, and ends every other session, keeping the one that asked. The new
|
|
294
|
+
password goes through `auth/Enter`'s `checkPassword`, so `passwordMin`, `passwordMax` and
|
|
295
|
+
`refusePassword` apply once.
|
|
296
|
+
|
|
297
|
+
**The mail says one thing and carries one link.** The body is the sentence and the address as
|
|
298
|
+
text, the HTML the same with the address as a link, under `subject`. For a mail of your own,
|
|
299
|
+
write a module that names `notify/Send` and calls `send` with your `html`; the two here own no
|
|
300
|
+
template. A mailer that answers anything but ok, or throws, is 502 with the reason `mail`, and
|
|
301
|
+
the link still stands, so the person asks again once the mailer is back. That reason carries
|
|
302
|
+
`detail` and `fix` beside `code` and `message`: `message` is the one sentence a page shows the
|
|
303
|
+
person, `detail` is what the mailer said, word for word, and `fix` tells whoever runs the server
|
|
304
|
+
where to look (design 293).
|
|
305
|
+
|
|
306
|
+
**`forgot` answers 200 for an address nobody has**, and sends nothing, although `auth/Check`
|
|
307
|
+
enumerates: the mail route is the one that costs a send, and a stranger typing addresses must
|
|
308
|
+
not steer it. While the mailer is down a known address answers 502 and an unknown one 200,
|
|
309
|
+
which tells them apart; `auth/Check` already does. The counts are in memory and a restart
|
|
310
|
+
clears them, as sign-in's are.
|
|
311
|
+
|
|
312
|
+
**A verification mail goes only when asked.** Nothing is sent at sign-up. A page asks for it
|
|
313
|
+
with `verify()`, or a module of yours calls `Verify.send(user)` after `enter`.
|
|
314
|
+
|
|
315
|
+
## The client half
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
import { createClient } from '@aweftjs/client';
|
|
319
|
+
import { createAuth } from '@aweftjs/auth/client';
|
|
320
|
+
|
|
321
|
+
const client = createClient(); // the page's own origin
|
|
322
|
+
const auth = createAuth(client); // the page's own origin, and global fetch
|
|
323
|
+
|
|
324
|
+
auth.user.effect((who) => header.textContent = who ?? 'signed out');
|
|
325
|
+
|
|
326
|
+
const outcome = await auth.enter('ada@example.com', 'correct horse battery staple');
|
|
327
|
+
if ('refused' in outcome) show(outcome.refused);
|
|
328
|
+
// A sign-up rule on the server reads a third argument: auth.enter(email, password, { invite })
|
|
329
|
+
|
|
330
|
+
const state = await auth.state<State>().ready;
|
|
331
|
+
state.theme = 'dark'; // applies here, and goes
|
|
332
|
+
|
|
333
|
+
deleteButton.hidden = auth.names.map(() => !auth.may('posts.delete')); // shown or hidden; the server refuses either way
|
|
334
|
+
await auth.verify(); // the verification mail
|
|
335
|
+
await auth.change(current, next); // the password; the socket stays signed in
|
|
336
|
+
await auth.forgot('ada@example.com'); // the reset mail, 200 whatever the address
|
|
337
|
+
await auth.reset(token, next); // from the link; every session ends, this page's too
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`createAuth(client, { origin?, fetch? })` adds identity to a connection. It never opens or
|
|
341
|
+
closes the connection for good; `@aweftjs/client` owns the socket. After sign-in and sign-out it
|
|
342
|
+
asks the client to reconnect, because identity is fixed per socket.
|
|
343
|
+
|
|
344
|
+
**`user` has three states.** `undefined` until the server has answered, `null` for an anonymous
|
|
345
|
+
connection, and the user's id otherwise. It is a read-only cell, so a page renders all three and
|
|
346
|
+
follows the value through sign-in and sign-out. It is asked again on every socket that opens,
|
|
347
|
+
because identity is read from the cookie at the handshake and is fixed for the connection's
|
|
348
|
+
life. A drop leaves the last value alone; the socket after it refreshes.
|
|
349
|
+
|
|
350
|
+
**`enter` and `leave` reconnect.** A browser cannot set a cookie on an open socket, so after
|
|
351
|
+
either route the client drops that socket and opens a new one, and the call resolves once `user`
|
|
352
|
+
is known again. `enter` answers `{ user, created }` on 200 or 201 and `{ refused }` on 400 or
|
|
353
|
+
401, in the same shape `auth/Enter` uses on the server. `leave` resolves once the page is
|
|
354
|
+
anonymous again.
|
|
355
|
+
|
|
356
|
+
**`names` and `may` are for showing and hiding.** `names` is a read-only cell: `undefined`
|
|
357
|
+
until the server has answered, `[]` for an anonymous connection, the granted list otherwise,
|
|
358
|
+
following the `roles` share while the socket is open, so a name granted on the server appears
|
|
359
|
+
on the page with no reconnect. `may(name)` runs the same check the server's gate runs, over
|
|
360
|
+
those names and the table the `auth/Roles` call answered; false until `names` is known. A page
|
|
361
|
+
hides a button with it. The server's gate is what refuses, and a page that shows the button
|
|
362
|
+
anyway changes nothing.
|
|
363
|
+
|
|
364
|
+
**`verify`, `change`, `forgot` and `reset` are the mail routes**, each answering `{ ok: true }`
|
|
365
|
+
or `{ refused }` with the route's reasons, and rejecting `<name>-failed` for any other status.
|
|
366
|
+
`verify()` asks for the mail and `verify(token)` takes the link; neither reconnects, and
|
|
367
|
+
`verified` reaches `names` through the share. `change` keeps the session that asked. `reset`
|
|
368
|
+
reconnects, because every session of the person is over, this page's included when it was
|
|
369
|
+
theirs, so `user` reads `null` afterwards.
|
|
370
|
+
|
|
371
|
+
**`state()` is the signed-in user's own document.** Its `ready` rejects with `anonymous` when
|
|
372
|
+
there is no user, at once rather than waiting for a topic the server will never offer, and waits
|
|
373
|
+
for the answer when nobody has said yet. One connection carries one state document: the server
|
|
374
|
+
offers the topic once per socket, so asking twice hands back the same handle until `stop()`,
|
|
375
|
+
after which it refuses `stopped`, and only the socket `enter` or `leave` opens brings a new one.
|
|
376
|
+
`check(email)` asks `auth/Check`. `stop()` stops following the connection and leaves the client
|
|
377
|
+
running; every call after it refuses `stopped`.
|
|
378
|
+
|
|
379
|
+
**When `user` changes underneath the page, the handle it holds is stopped.** The server forgets
|
|
380
|
+
the session, or another user's cookie replaces it, and the client comes back on its own as
|
|
381
|
+
somebody else. The handle the page is still holding was made for the old user, so it is stopped
|
|
382
|
+
and follows the server no further; the next `state()` answers for whoever the connection is now,
|
|
383
|
+
and a page follows `user` to notice. `enter` and `leave` refuse with `closed` when their route
|
|
384
|
+
answered but the client has since been closed.
|
|
385
|
+
|
|
386
|
+
**Two seams outside a browser**, and a page needs neither. `origin` is where the session routes
|
|
387
|
+
are, the page's own by default and read when a route is first called, so making an auth outside a
|
|
388
|
+
page is fine and only calling one refuses `no-origin`. `fetch` makes the two HTTP calls, the
|
|
389
|
+
global by default; a Node program hands in one that carries the cookie, because Node's `fetch`
|
|
390
|
+
keeps no cookie jar. The client's own `open` seam is the third, and it is `@aweftjs/client`'s: a
|
|
391
|
+
Node program hands in a socket carrying the same cookie header.
|
|
392
|
+
[`recipes/client`](https://github.com/torrinworx/aweft/tree/main/recipes/client) runs all three.
|
|
393
|
+
|
|
394
|
+
## The page modules
|
|
395
|
+
|
|
396
|
+
`@aweftjs/auth/client` also exports `authClient`, a source of four modules for a stage's
|
|
397
|
+
`sources`. Put it there and the sign-in form, the verification page and the reset form are
|
|
398
|
+
each one line in the acts map.
|
|
399
|
+
|
|
400
|
+
```tsx
|
|
401
|
+
import { authClient } from '@aweftjs/auth/client';
|
|
402
|
+
|
|
403
|
+
<StageContext
|
|
404
|
+
sources={[app, authClient]}
|
|
405
|
+
client={client}
|
|
406
|
+
acts={{ '': Home, notes: 'notes/Page', join: 'auth/SignIn', verify: 'auth/Verify', reset: 'auth/Reset' }}
|
|
407
|
+
refused="join"
|
|
408
|
+
>
|
|
409
|
+
<Stage />
|
|
410
|
+
</StageContext>
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
| module | what it is |
|
|
414
|
+
|---|---|
|
|
415
|
+
| `auth/Session` | `createAuth` over the `client` the stage handed the loader. Its instance is the `Auth` above, so any module of yours that needs to know who the page is names it in `deps` |
|
|
416
|
+
| `auth/SignIn` | an act module: the sign-in and sign-up form, in one, because `enter` does both |
|
|
417
|
+
| `auth/Verify` | an act module: with a `token` in the act's parameters or the URL's query it takes the link as it mounts and says what happened; without one it offers a signed-in person the mail |
|
|
418
|
+
| `auth/Reset` | an act module: without a `token` the forgot form, with one the new-password form |
|
|
419
|
+
|
|
420
|
+
**The battery never picks a URL.** `auth/SignIn` lands on the address you name it at, and
|
|
421
|
+
`refused: 'join'` is what puts it in front of a page your own gate module refused. `auth/Verify`
|
|
422
|
+
and `auth/Reset` land where you name them too, and the `url` you configure on the server's
|
|
423
|
+
`auth/Verify` and `auth/Password` is what points the mail at those addresses:
|
|
424
|
+
`verify: 'auth/Verify'` in the acts map goes with `url: (token) =>
|
|
425
|
+
'https://app.example/verify?token=' + token` in `modules/auth/Verify.ts`.
|
|
426
|
+
|
|
427
|
+
**Configuring `auth/Session`** is the ordinary module thing: a file exporting only `config`, in a
|
|
428
|
+
source before this one. It reads `origin` and `fetch`, the same two seams `createAuth` takes.
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
// modules/auth/Session.ts, in your own source
|
|
432
|
+
export const config = { origin: 'https://api.example.com' };
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**Replacing any of them** is the same rule every battery module has: a module of that name in
|
|
436
|
+
an earlier source wins. A sign-in form of your own is `auth/SignIn` in your own directory.
|
|
437
|
+
|
|
438
|
+
**`auth/SignIn` picks no URL after a successful `enter`.** The visitor stays on the address they
|
|
439
|
+
asked for. When the form is what `refused` put there, the stage handed it a `retry`, and it calls
|
|
440
|
+
that: the act the URL chose is built again in place, so the gated page appears with no navigation.
|
|
441
|
+
On a URL of its own the form has no `retry` and a success does nothing at all.
|
|
442
|
+
|
|
443
|
+
**No gate module ships.** "Allowed" means something different in every application, so the page
|
|
444
|
+
writes its own and the act that needs it names it in `deps`. A gate that wants a name waits for
|
|
445
|
+
`names` the same way it waits for `user`, and reads `may`:
|
|
446
|
+
|
|
447
|
+
`user` reads `undefined` until the first socket answers, so a gate waits for the first answer that
|
|
448
|
+
is not `undefined`. Reading `undefined` as "not signed in" would let a stranger in for as long as
|
|
449
|
+
the handshake takes. The wait is safe on a static render too, because there is no socket there and
|
|
450
|
+
identity is answered from the first read.
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
import { codecError } from '@aweftjs/codec';
|
|
454
|
+
|
|
455
|
+
export const deps = ['auth/Session'];
|
|
456
|
+
|
|
457
|
+
/** The first value of a cell that is not `undefined`, which is the first real answer. */
|
|
458
|
+
const answered = (cell) => {
|
|
459
|
+
const held = cell.get();
|
|
460
|
+
return held !== undefined ? held : new Promise((done) => {
|
|
461
|
+
const off = cell.watch((now) => {
|
|
462
|
+
if (now === undefined) return;
|
|
463
|
+
off();
|
|
464
|
+
done(now);
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
export default ({ imports }) => ({
|
|
470
|
+
require: async (name) => {
|
|
471
|
+
const who = await answered(imports.Session.user);
|
|
472
|
+
if (who === null) {
|
|
473
|
+
throw codecError('anonymous', 'this page is for a signed-in user', 'Sign in first.');
|
|
474
|
+
}
|
|
475
|
+
await answered(imports.Session.names);
|
|
476
|
+
if (name !== undefined && !imports.Session.may(name)) {
|
|
477
|
+
throw codecError('needs', `this page is for someone holding ${name}`, 'Ask for access.');
|
|
478
|
+
}
|
|
479
|
+
return who;
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
The act calls `require()`, or `require('reports')`, in its own factory, the load rejects, and
|
|
485
|
+
the stage shows whatever `refused` names. The check on the page is what decides which act
|
|
486
|
+
shows; the server's gate is what decides what the act can reach. [`recipes/client`](https://github.com/torrinworx/aweft/tree/main/recipes/client)
|
|
487
|
+
is the whole pattern in one small application.
|
|
488
|
+
|
|
489
|
+
**A static render has no connection.** Handed no `client`, `auth/Session` is anonymous at once:
|
|
490
|
+
`user` reads `null` and `names` reads `[]` from the first read, `may` is false, `state()`
|
|
491
|
+
rejects `anonymous`, and `enter`, `leave`, `check` and the four mail calls refuse `no-client`.
|
|
492
|
+
So it is an anonymous static render: a gated act shows the refused act, and a module reading a
|
|
493
|
+
document with no client renders its waiting state. Handed something that is not a client, it
|
|
494
|
+
refuses `no-client` too, naming what was missing.
|
|
495
|
+
|
|
496
|
+
## What is stored
|
|
497
|
+
|
|
498
|
+
Six kinds of document in your store, named by prefix:
|
|
499
|
+
|
|
500
|
+
- `user:<id>`: `email`, `name`, `password` (the scrypt hash, with its parameters and salt
|
|
501
|
+
written into it; never the password), `emailVerified`, `createdAt`, `modifiedAt`. Never
|
|
502
|
+
shared on a link.
|
|
503
|
+
- `session:<token>`: `user`, `expires` (the lifetime's end, the moment of revocation, or null
|
|
504
|
+
for never), `status` (`active` or `revoked`), `createdAt`.
|
|
505
|
+
- `roles:<user>`: `names`, what the person was granted, and `modifiedAt`. Shared to its own
|
|
506
|
+
user under `roles`, refusing every commit. `auth:first` is the one-line marker that says a
|
|
507
|
+
first sign-up has been seen.
|
|
508
|
+
- `state:<user>`: whatever you keep per user. `auth/State` shares it under `state`, accepting
|
|
509
|
+
every commit, because it is theirs.
|
|
510
|
+
- `verify:<token>` and `reset:<token>`: `user`, `expires`, `createdAt`, and `taken` once it
|
|
511
|
+
has been. Removed by the sweep once expired.
|
|
512
|
+
|
|
513
|
+
The state document is an `@aweftjs/core` observable, as every document in the store is: put
|
|
514
|
+
a list in it with `createArray` from `@aweftjs/core`, an object with `createObject`, and group
|
|
515
|
+
writes with `atomic`; that package's README shows the shapes. **Keep its root an object.**
|
|
516
|
+
`paths` declares `email`, `user` and `expires` for every document in the store, and the store
|
|
517
|
+
refuses a declared path that meets an array, so a document whose root is an array cannot be
|
|
518
|
+
written to a store that declares them; a list goes in a field. A store that does not declare
|
|
519
|
+
them refuses `auth/Session` as it is made, naming the fix.
|
|
520
|
+
|
|
521
|
+
Ids are the stack's ids: twelve random bytes from the platform's secure source, sixteen
|
|
522
|
+
characters. A session token is sixteen random bytes of its own from the same source, twenty-two
|
|
523
|
+
characters, because a credential needs 128 bits and an id was never one. Passwords are hashed
|
|
524
|
+
with Node's own `scrypt` and compared with Node's own `timingSafeEqual`; there is no dependency,
|
|
525
|
+
and no test here measures timing.
|
|
526
|
+
|
|
527
|
+
Two sign-ups for one email in the same instant can both succeed, because a unique index is
|
|
528
|
+
the store driver's and none ships. A sign-in with a mistyped new email creates an account:
|
|
529
|
+
that is what one module for both means, and `auth/Check` exists so a form can ask first.
|
|
530
|
+
|
|
531
|
+
The gate puts the peer address in the context beside `user` and `session`, as the listener saw
|
|
532
|
+
it, so a route of yours can count by it the way `auth/Enter` does.
|
|
533
|
+
|
|
534
|
+
## What it never does
|
|
535
|
+
|
|
536
|
+
Decide which names exist, beyond `verified`, or what any of them means. Grant over the wire.
|
|
537
|
+
Expire a name. Show another person's names to a page. Change an email address, and so decide
|
|
538
|
+
what one does to `verified`. Send a mail nobody asked for. Own a mail template. Pick a URL.
|
|
539
|
+
Cache a check: every `may` reads the store, and an application that loads this battery has
|
|
540
|
+
said the read is worth it.
|
|
541
|
+
|
|
542
|
+
`securityChecks()` from `@aweftjs/testing` runs against this battery from its own suite and from
|
|
543
|
+
`recipes/full-stack/tests/security.test.ts`; that file is how an application runs it against
|
|
544
|
+
itself.
|
|
545
|
+
|
|
546
|
+
The design notes are in
|
|
547
|
+
[`docs/design/`](https://github.com/torrinworx/aweft/tree/main/docs/design) 071 and 074, 185 and
|
|
548
|
+
245 for the client half, 289 for names and 290 for the mail flows.
|