@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +548 -0
  3. package/dist/auth-client.d.ts +248 -0
  4. package/dist/auth-client.js +287 -0
  5. package/dist/client-modules/Reset.d.ts +12 -0
  6. package/dist/client-modules/Reset.js +61 -0
  7. package/dist/client-modules/Session.d.ts +6 -0
  8. package/dist/client-modules/Session.js +59 -0
  9. package/dist/client-modules/SignIn.d.ts +13 -0
  10. package/dist/client-modules/SignIn.js +58 -0
  11. package/dist/client-modules/Verify.d.ts +12 -0
  12. package/dist/client-modules/Verify.js +54 -0
  13. package/dist/client-modules/stage-token.d.ts +3 -0
  14. package/dist/client-modules/stage-token.js +9 -0
  15. package/dist/client.d.ts +27 -0
  16. package/dist/client.js +33 -0
  17. package/dist/context.d.ts +11 -0
  18. package/dist/context.js +11 -0
  19. package/dist/cookie.d.ts +15 -0
  20. package/dist/cookie.js +39 -0
  21. package/dist/index.d.ts +35 -0
  22. package/dist/index.js +42 -0
  23. package/dist/links.d.ts +37 -0
  24. package/dist/links.js +77 -0
  25. package/dist/mail.d.ts +55 -0
  26. package/dist/mail.js +53 -0
  27. package/dist/modules/Check.d.ts +10 -0
  28. package/dist/modules/Check.js +20 -0
  29. package/dist/modules/Enter.d.ts +57 -0
  30. package/dist/modules/Enter.js +143 -0
  31. package/dist/modules/Gate.d.ts +6 -0
  32. package/dist/modules/Gate.js +43 -0
  33. package/dist/modules/Password.d.ts +34 -0
  34. package/dist/modules/Password.js +138 -0
  35. package/dist/modules/Roles.d.ts +32 -0
  36. package/dist/modules/Roles.js +135 -0
  37. package/dist/modules/Session.d.ts +39 -0
  38. package/dist/modules/Session.js +159 -0
  39. package/dist/modules/State.d.ts +8 -0
  40. package/dist/modules/State.js +22 -0
  41. package/dist/modules/Verify.d.ts +32 -0
  42. package/dist/modules/Verify.js +97 -0
  43. package/dist/names.d.ts +25 -0
  44. package/dist/names.js +44 -0
  45. package/dist/password.d.ts +4 -0
  46. package/dist/password.js +41 -0
  47. package/dist/props.d.ts +15 -0
  48. package/dist/props.js +35 -0
  49. package/dist/token.d.ts +3 -0
  50. package/dist/token.js +11 -0
  51. package/dist/users.d.ts +9 -0
  52. package/dist/users.js +12 -0
  53. package/errors.txt +29 -0
  54. package/package.json +62 -0
  55. package/src/auth-client.ts +531 -0
  56. package/src/client-modules/Reset.tsx +97 -0
  57. package/src/client-modules/Session.ts +70 -0
  58. package/src/client-modules/SignIn.tsx +110 -0
  59. package/src/client-modules/Verify.tsx +82 -0
  60. package/src/client-modules/stage-token.ts +11 -0
  61. package/src/client.ts +38 -0
  62. package/src/context.ts +21 -0
  63. package/src/cookie.ts +35 -0
  64. package/src/index.ts +53 -0
  65. package/src/links.ts +107 -0
  66. package/src/mail.ts +88 -0
  67. package/src/modules/Check.ts +31 -0
  68. package/src/modules/Enter.ts +189 -0
  69. package/src/modules/Gate.ts +47 -0
  70. package/src/modules/Password.ts +155 -0
  71. package/src/modules/Roles.ts +163 -0
  72. package/src/modules/Session.ts +196 -0
  73. package/src/modules/State.ts +30 -0
  74. package/src/modules/Verify.ts +118 -0
  75. package/src/names.ts +49 -0
  76. package/src/password.ts +43 -0
  77. package/src/props.ts +47 -0
  78. package/src/token.ts +15 -0
  79. package/src/users.ts +18 -0
  80. package/surface.txt +19 -0
  81. package/text.json +50 -0
@@ -0,0 +1,196 @@
1
+ // auth/Session: sessions as documents, the cookie, who a request is (design 074), and the
2
+ // sweep that removes a session once it has been over for `keep` days (design 275).
3
+
4
+ import { codecError } from '@aweftjs/codec';
5
+ import { atomic } from '@aweftjs/core';
6
+ import type { ModuleProps } from '@aweftjs/modules';
7
+ import type { Identified, Peer } from '@aweftjs/server';
8
+
9
+ import { type AuthContext, userOf } from '../context.ts';
10
+ import { cookiesOf, setCookie } from '../cookie.ts';
11
+ import { json, storeOf } from '../props.ts';
12
+ import { isToken, mintToken } from '../token.ts';
13
+
14
+ export const defaults = { cookie: 'session', keep: 30, sweepMs: 3_600_000 };
15
+
16
+ /** What a `session:<token>` document holds. */
17
+ export interface SessionDocument extends Record<string, unknown> {
18
+ user: string;
19
+ /** When it stopped, or stops, being valid: the lifetime's end, the moment of revocation, or null for never. */
20
+ expires: number | null;
21
+ status: 'active' | 'revoked';
22
+ createdAt: number;
23
+ }
24
+
25
+ export interface Session {
26
+ readonly public: true;
27
+ /** Mint a session for a user. Returns its token, which is the cookie's value. */
28
+ issue(user: string): Promise<string>;
29
+ /** End a session. True when it was active. */
30
+ revoke(token: string): Promise<boolean>;
31
+ /** End every active session of a user but the one named. Returns how many it ended. */
32
+ revokeAll(user: string, except?: string): Promise<number>;
33
+ /** Who a request is, from its cookies, and where it came from. Always a context: this gate refuses nobody at the door. */
34
+ whoIs(request: Request, peer?: Peer): Promise<Identified<AuthContext>>;
35
+ /** The `Set-Cookie` value that sets the cookie to a token, or clears it for null. */
36
+ setCookie(token: string | null, request: Request): string;
37
+ /** Who the asking connection is. The module is public, so an anonymous one hears `{ user: null }`. */
38
+ call(args: unknown, context: unknown): { user: string | null };
39
+ /** Remove every session that has been over for longer than `keep` days. Returns how many. */
40
+ sweep(): Promise<number>;
41
+ stop(): Promise<void>;
42
+ readonly routes: Record<string, (request: Request, context: AuthContext) => Promise<Response>>;
43
+ }
44
+
45
+ const refuse = (detail: string, fix: string): Error => codecError('invalid-config', `auth/Session was given ${detail}`, fix);
46
+
47
+ // Over 2^31 - 1 milliseconds Node fires a timer after one millisecond instead.
48
+ const MAX_TIMER = 2_147_483_647;
49
+
50
+ export default async ({ config, ...props }: ModuleProps): Promise<Session> => {
51
+ const store = storeOf(props);
52
+ const cookie = String(config.cookie);
53
+ // Configuration is typed by hand in a same-named file, so a lifetime that is not a positive
54
+ // number is a mistake to stop here rather than a session that quietly never expires.
55
+ if (config.sessionMs !== undefined
56
+ && !(typeof config.sessionMs === 'number' && Number.isFinite(config.sessionMs) && config.sessionMs > 0)) {
57
+ throw refuse(`sessionMs ${JSON.stringify(config.sessionMs)}`, 'Set sessionMs to a positive number of milliseconds, or leave it out.');
58
+ }
59
+ const sessionMs = config.sessionMs as number | undefined;
60
+ if (typeof config.keep !== 'number' || !(config.keep > 0) || !Number.isFinite(config.keep)) {
61
+ throw refuse(`keep ${JSON.stringify(config.keep)}`, 'Set keep to a positive number of days.');
62
+ }
63
+ if (typeof config.sweepMs !== 'number' || !(config.sweepMs > 0) || config.sweepMs > MAX_TIMER) {
64
+ throw refuse(`sweepMs ${JSON.stringify(config.sweepMs)}`, 'Set sweepMs to a positive number of milliseconds, at most 2147483647.');
65
+ }
66
+ const keep = config.keep;
67
+ const sweepMs = config.sweepMs;
68
+ const doc = (token: string): string => `session:${token}`;
69
+
70
+ // A document that was never written has no commits, and `open` would create it. Nothing
71
+ // in the store says whether a document exists without opening it, so the head stands in.
72
+ const read = async (token: string): Promise<SessionDocument | undefined> => {
73
+ if (await store.head(doc(token)) === 0) return undefined;
74
+ const handle = await store.open(doc(token));
75
+ const held = { ...(handle.root as SessionDocument) };
76
+ await store.close(handle);
77
+ return held;
78
+ };
79
+
80
+ const issue = async (user: string): Promise<string> => {
81
+ const token = mintToken();
82
+ const handle = await store.open(doc(token));
83
+ const now = Date.now();
84
+ atomic(() => {
85
+ Object.assign(handle.root, {
86
+ user, expires: sessionMs === undefined ? null : now + sessionMs, status: 'active', createdAt: now,
87
+ } satisfies SessionDocument);
88
+ });
89
+ await store.settled(handle);
90
+ await store.close(handle);
91
+ return token;
92
+ };
93
+
94
+ const revoke = async (token: string): Promise<boolean> => {
95
+ if (await store.head(doc(token)) === 0) return false;
96
+ const handle = await store.open(doc(token));
97
+ const held = handle.root as SessionDocument;
98
+ // The sweep can remove the document between the head and the open, and the open then
99
+ // makes an empty one; a session with no user was never issued, and nothing is written.
100
+ if (held.user === undefined) {
101
+ await store.close(handle);
102
+ await store.remove(doc(token));
103
+ return false;
104
+ }
105
+ const was = held.status === 'active';
106
+ // `expires` becomes the moment it ended, so one declared path says when any session was
107
+ // last valid and the sweep needs no second one.
108
+ if (was) {
109
+ atomic(() => {
110
+ held.status = 'revoked';
111
+ held.expires = Date.now();
112
+ });
113
+ }
114
+ await store.settled(handle);
115
+ await store.close(handle);
116
+ return was;
117
+ };
118
+
119
+ // The `user` path is declared for every document, so the answer is filtered to this
120
+ // battery's own sessions; `revoke` says which of them were still active.
121
+ const revokeAll = async (user: string, except?: string): Promise<number> => {
122
+ let ended = 0;
123
+ for (const { doc: name } of await store.find({ where: [{ field: 'user', op: 'eq', value: user }] })) {
124
+ if (!name.startsWith('session:')) continue;
125
+ const token = name.slice('session:'.length);
126
+ if (token === except) continue;
127
+ if (await revoke(token)) ended += 1;
128
+ }
129
+ return ended;
130
+ };
131
+
132
+ // Every cookie of the name, in order: the first that is a token naming a live session wins,
133
+ // and none is anonymous. A value that is not a token is skipped rather than refused, since
134
+ // a cookie of the same name from another path or another application is not tampering, and
135
+ // refusing would lock the client out of the one route that clears it (design 074).
136
+ const whoIs = async (request: Request, peer?: Peer): Promise<Identified<AuthContext>> => {
137
+ const address = peer?.address;
138
+ for (const token of cookiesOf(request, cookie)) {
139
+ if (!isToken(token)) continue;
140
+ const session = await read(token);
141
+ if (session === undefined || session.status !== 'active') continue;
142
+ if (session.expires !== null && session.expires <= Date.now()) continue;
143
+ return { context: { user: session.user, session: token, address } };
144
+ }
145
+ // A fresh object each time: the server hands the same reference to every hook and event
146
+ // of one connection, and a module keys connections apart by it (design 260).
147
+ return { context: { user: null, session: null, address } };
148
+ };
149
+
150
+ const sweep = async (): Promise<number> => {
151
+ const cutoff = Date.now() - keep * 86_400_000;
152
+ let removed = 0;
153
+ for (const { doc: name, fields } of await store.find({ where: [{ field: 'expires', op: 'lt', value: cutoff }] })) {
154
+ // The path is declared for every document, so the answer is filtered to this
155
+ // battery's own; a session with no end carries null, which is never under the cutoff.
156
+ if (!name.startsWith('session:') || typeof fields.expires !== 'number' || fields.expires >= cutoff) continue;
157
+ await store.remove(name);
158
+ removed += 1;
159
+ }
160
+ return removed;
161
+ };
162
+
163
+ // Loud at load rather than at the first sweep an hour in: the sweep queries a declared
164
+ // path, and a store that has not declared it refuses the query.
165
+ try {
166
+ await store.find({ where: [{ field: 'expires', op: 'lt', value: 0 }], limit: 1 });
167
+ } catch {
168
+ throw codecError(
169
+ 'undeclared', 'the store does not declare the paths this battery queries',
170
+ 'Spread paths from @aweftjs/auth into the store\'s declare.',
171
+ );
172
+ }
173
+ await sweep();
174
+ const timer = setInterval(() => { void sweep().catch(() => undefined); }, sweepMs);
175
+ timer.unref?.();
176
+
177
+ return {
178
+ public: true,
179
+ issue,
180
+ revoke,
181
+ revokeAll,
182
+ whoIs,
183
+ setCookie: (token, request) => setCookie(cookie, token, request, sessionMs),
184
+ // The only moment a page can learn who it is comes after its socket opens, because
185
+ // identity is fixed at the handshake (design 185).
186
+ call: (_args, context) => ({ user: userOf(context) }),
187
+ sweep,
188
+ stop: async () => { clearInterval(timer); },
189
+ routes: {
190
+ 'DELETE /api/session': async (request, context) => {
191
+ if (context.session !== null) await revoke(context.session);
192
+ return json(200, { user: null }, { 'set-cookie': setCookie(cookie, null, request) });
193
+ },
194
+ },
195
+ };
196
+ };
@@ -0,0 +1,30 @@
1
+ // auth/State: the user's own state document, shared on every connection of theirs. Private,
2
+ // so the gate never lets an anonymous connection this far (design 074).
3
+
4
+ import type { ModuleProps } from '@aweftjs/modules';
5
+ import { type Connection, open } from '@aweftjs/server';
6
+
7
+ import { type AuthContext, userOf } from '../context.ts';
8
+ import { notGated, storeOf } from '../props.ts';
9
+
10
+ export interface State {
11
+ connection(connection: Connection<AuthContext>): Promise<() => Promise<void>>;
12
+ }
13
+
14
+ export default (props: ModuleProps): State => {
15
+ const store = storeOf(props);
16
+ return {
17
+ connection: async ({ link, context }) => {
18
+ const user = userOf(context);
19
+ if (user === null) {
20
+ // Only a gate that is not the auth gate lets an anonymous connection reach a private
21
+ // module. Loud, because sharing nothing in silence would look like an empty state.
22
+ throw notGated();
23
+ }
24
+ const handle = await store.open(`state:${user}`);
25
+ // The user's own document: whatever they write is theirs to write.
26
+ link.share('state', handle.root, open);
27
+ return async () => { await store.close(handle); };
28
+ },
29
+ };
30
+ };
@@ -0,0 +1,118 @@
1
+ // auth/Verify: a one-time link by mail, and the name `verified` once it is clicked (design 290).
2
+
3
+ import { atomic } from '@aweftjs/core';
4
+ import type { ModuleProps } from '@aweftjs/modules';
5
+ import { type Refusal, sliding } from '@aweftjs/server';
6
+
7
+ import { type AuthContext, userOf } from '../context.ts';
8
+ import { type Links, NOT_LIVE, TAKEN, links } from '../links.ts';
9
+ import { type Mailer, type Outcome, mailLink, textOf, urlOf } from '../mail.ts';
10
+ import { bodyOf, json, numberOf, storeOf } from '../props.ts';
11
+ import { userDoc } from '../users.ts';
12
+ import type { UserDocument } from './Enter.ts';
13
+ import type { Roles } from './Roles.ts';
14
+
15
+ export const deps = ['auth/Roles', 'notify/Send'];
16
+
17
+ export const defaults = {
18
+ subject: 'Verify your email address',
19
+ url: null,
20
+ verifyMs: 86_400_000,
21
+ sendsPerUser: 5,
22
+ sendsWindowMs: 86_400_000,
23
+ resendMs: 60_000,
24
+ sweepMs: 3_600_000,
25
+ };
26
+
27
+ /** The name a verified person holds. */
28
+ export const VERIFIED = 'verified';
29
+
30
+ export type Confirmed = { readonly user: string } | { readonly refused: readonly Refusal[] };
31
+
32
+ export interface Verify {
33
+ readonly public: true;
34
+ /** Mail the person a link. Refuses `verified` for a person already verified, and `mail` when it did not go. */
35
+ send(user: string): Promise<Outcome>;
36
+ /** Take a link: `emailVerified` is written and `verified` granted. Refuses `taken` for a link already used and `token` for one that never was or is past its end. */
37
+ confirm(token: unknown): Promise<Confirmed>;
38
+ stop(): void;
39
+ readonly routes: Record<string, (request: Request, context: AuthContext) => Promise<Response>>;
40
+ }
41
+
42
+ const MODULE = 'auth/Verify';
43
+
44
+
45
+ export default ({ imports, config, ...props }: ModuleProps): Verify => {
46
+ const store = storeOf(props);
47
+ const Roles = imports.Roles as Roles;
48
+ const mailer = imports.Send as Mailer;
49
+ const subject = textOf(MODULE, config, 'subject');
50
+ const url = urlOf(MODULE, config);
51
+ const perUser = sliding({ count: numberOf(MODULE, config, 'sendsPerUser'), windowMs: numberOf(MODULE, config, 'sendsWindowMs') });
52
+ const resend = sliding({ count: 1, windowMs: numberOf(MODULE, config, 'resendMs') });
53
+ const held: Links = links(store, 'verify', numberOf(MODULE, config, 'verifyMs'), numberOf(MODULE, config, 'sweepMs'));
54
+
55
+ const verified = async (user: string): Promise<boolean> => {
56
+ if (await store.head(userDoc(user)) === 0) return false;
57
+ const handle = await store.open(userDoc(user));
58
+ const is = (handle.root as Partial<UserDocument>).emailVerified === true;
59
+ await store.close(handle);
60
+ return is;
61
+ };
62
+
63
+ const send = async (user: string): Promise<Outcome> => {
64
+ if (await verified(user)) return { refused: [{ code: 'verified', message: 'this email is already verified' }] };
65
+ const token = await held.issue(user);
66
+ const failed = await mailLink(mailer, user, subject, 'Confirm your email address by opening this link:', url(token));
67
+ return failed === undefined ? { ok: true } : { refused: [failed] };
68
+ };
69
+
70
+ const confirm = async (token: unknown): Promise<Confirmed> => {
71
+ const link = await held.take(token);
72
+ if (link === undefined) return { refused: [NOT_LIVE] };
73
+ if ('taken' in link) return { refused: [TAKEN] };
74
+ const { user } = link;
75
+ const handle = await store.open(userDoc(user));
76
+ atomic(() => {
77
+ const root = handle.root as Partial<UserDocument>;
78
+ root.emailVerified = true;
79
+ root.modifiedAt = Date.now();
80
+ });
81
+ await store.settled(handle);
82
+ await store.close(handle);
83
+ await Roles.grant(user, VERIFIED);
84
+ return { user };
85
+ };
86
+
87
+ const tooMany = (retryAfter: number): Response =>
88
+ json(429, { reasons: [{ code: 'attempts', message: 'too many verification mails; wait and try again' }] }, { 'retry-after': String(retryAfter) });
89
+
90
+ return {
91
+ public: true,
92
+ send,
93
+ confirm,
94
+ stop: () => { held.stop(); },
95
+ routes: {
96
+ 'POST /api/verify/send': async (_request, context) => {
97
+ const user = userOf(context);
98
+ if (user === null) return json(401, { reasons: [{ code: 'private', message: 'sign in to verify your email' }] });
99
+ const inWindow = perUser.take(user);
100
+ if (!inWindow.ok) return tooMany(inWindow.retryAfter);
101
+ const since = resend.take(user);
102
+ if (!since.ok) return tooMany(since.retryAfter);
103
+ const outcome = await send(user);
104
+ if ('refused' in outcome) {
105
+ const [reason] = outcome.refused;
106
+ return json(reason?.code === 'mail' ? 502 : 409, { reasons: outcome.refused });
107
+ }
108
+ return json(200, { ok: true });
109
+ },
110
+ 'POST /api/verify': async (request) => {
111
+ const body = await bodyOf(request);
112
+ const outcome = await confirm(body?.token);
113
+ if ('refused' in outcome) return json(400, { reasons: outcome.refused });
114
+ return json(200, outcome);
115
+ },
116
+ },
117
+ };
118
+ };
package/src/names.ts ADDED
@@ -0,0 +1,49 @@
1
+ // Names: the one kind of thing a person can hold, and the check over them (design 289).
2
+ //
3
+ // Pure, and shared by both planes: the server module reads the store and hands the list here,
4
+ // the page reads the shared document and does the same, so one function is the answer.
5
+
6
+ /** A table from a name to the names it implies. */
7
+ export type Implies = Readonly<Record<string, readonly string[]>>;
8
+
9
+ /** Non-empty text with no whitespace in it. */
10
+ export const isName = (value: unknown): value is string =>
11
+ typeof value === 'string' && value !== '' && !/\s/.test(value);
12
+
13
+ /** Does holding `held` cover `name`: the same, everything, or a dotted parent of it. */
14
+ const covers = (held: string, name: string): boolean =>
15
+ held === '*' || held === name || name.startsWith(`${held}.`);
16
+
17
+ /**
18
+ * Does a person who was granted `granted` hold `name`.
19
+ *
20
+ * True when a granted name covers it, or a name the table says a granted name implies does,
21
+ * transitively. Holding a name covers every name under it: `products` covers
22
+ * `products.abc123.read`, and `*` covers everything. The table is keyed by the exact name held,
23
+ * so holding `admin.super` implies what `admin.super` lists and not what `admin` does.
24
+ *
25
+ * Params:
26
+ * granted: the names the person was granted
27
+ * implies: the table, from a name to the names it implies; a cycle in it is fine
28
+ * name: the name asked about
29
+ *
30
+ * Returns: whether the person holds it.
31
+ *
32
+ * Example:
33
+ * holds(['admin'], { admin: ['*'] }, 'posts.delete'); // true
34
+ * holds(['products.abc123'], {}, 'products.abc123.read'); // true
35
+ * holds(['products.abc123'], {}, 'products.def456.read'); // false
36
+ */
37
+ export const holds = (granted: readonly string[], implies: Implies, name: string): boolean => {
38
+ const seen = new Set<string>();
39
+ const queue = [...granted];
40
+ while (queue.length > 0) {
41
+ const held = queue.pop()!;
42
+ if (seen.has(held)) continue;
43
+ seen.add(held);
44
+ if (covers(held, name)) return true;
45
+ const more = Object.hasOwn(implies, held) ? implies[held]! : [];
46
+ for (const implied of more) queue.push(implied);
47
+ }
48
+ return false;
49
+ };
@@ -0,0 +1,43 @@
1
+ // Passwords: hashed with Node's own scrypt, compared with Node's own `timingSafeEqual`
2
+ // (design 074). No test here measures the comparison's timing; the claim is which function
3
+ // is called, and that is all.
4
+ //
5
+ // The stored text carries the parameters and the salt beside the hash, so a later change of
6
+ // parameters is a re-hash on the next sign-in and never a migration.
7
+
8
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
9
+
10
+ // Node's own defaults for scrypt, written into every hash so they can change.
11
+ const COST = 16384;
12
+ const BLOCK = 8;
13
+ const PARALLEL = 1;
14
+ const SALT_BYTES = 16;
15
+ const KEY_BYTES = 64;
16
+
17
+ const derive = (password: string, salt: Buffer, N: number, r: number, p: number, length: number): Promise<Buffer> =>
18
+ new Promise((done, fail) => {
19
+ scrypt(password, salt, length, { N, r, p, maxmem: 128 * N * r * 2 }, (error, key) => (error ? fail(error) : done(key)));
20
+ });
21
+
22
+ /** The text to store for a password. Never the password. */
23
+ export const hashPassword = async (password: string): Promise<string> => {
24
+ const salt = randomBytes(SALT_BYTES);
25
+ const key = await derive(password, salt, COST, BLOCK, PARALLEL, KEY_BYTES);
26
+ return ['scrypt', COST, BLOCK, PARALLEL, salt.toString('base64url'), key.toString('base64url')].join('$');
27
+ };
28
+
29
+ /** Does the password match the stored text? False, never a throw, for text that is not a hash. */
30
+ export const verifyPassword = async (password: string, stored: unknown): Promise<boolean> => {
31
+ if (typeof stored !== 'string') return false;
32
+ const [kind, N, r, p, salt, hash] = stored.split('$');
33
+ if (kind !== 'scrypt' || salt === undefined || hash === undefined) return false;
34
+ const expected = Buffer.from(hash, 'base64url');
35
+ if (expected.length === 0) return false;
36
+ let key: Buffer;
37
+ try {
38
+ key = await derive(password, Buffer.from(salt, 'base64url'), Number(N), Number(r), Number(p), expected.length);
39
+ } catch {
40
+ return false;
41
+ }
42
+ return key.length === expected.length && timingSafeEqual(key, expected);
43
+ };
package/src/props.ts ADDED
@@ -0,0 +1,47 @@
1
+ // What every module here reads off the loader's props: the application's store.
2
+
3
+ import { codecError } from '@aweftjs/codec';
4
+ import type { Store } from '@aweftjs/store';
5
+
6
+ /**
7
+ * The store the loader was made with. Loud when it was not, rather than undefined at the first write.
8
+ *
9
+ * Throws: `no-store` when the loader's props carry no store.
10
+ */
11
+ export const storeOf = (props: Readonly<Record<string, unknown>>): Store => {
12
+ const store = props.store as Store | undefined;
13
+ if (store === undefined || typeof store.open !== 'function') {
14
+ throw codecError(
15
+ 'no-store', 'the loader needs a store in its props',
16
+ 'Pass store to createServer, or props: { store } to a loader you build yourself.',
17
+ );
18
+ }
19
+ return store;
20
+ };
21
+
22
+ export const invalidConfig = (module: string, detail: string, fix: string): Error =>
23
+ codecError('invalid-config', `${module} was given ${detail}`, fix);
24
+
25
+ /** A positive finite number out of a module's config, or `invalid-config` naming the module. */
26
+ export const numberOf = (module: string, config: Readonly<Record<string, unknown>>, key: string): number => {
27
+ const held: unknown = config[key];
28
+ if (typeof held !== 'number' || !(held > 0) || !Number.isFinite(held)) throw invalidConfig(module, `${key} ${JSON.stringify(held)}`, 'Give that setting a number above zero.');
29
+ return held;
30
+ };
31
+
32
+ export const json = (status: number, body: unknown, headers: Record<string, string> = {}): Response =>
33
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
34
+
35
+ /** The JSON body of a request, or undefined when there is none worth the name. */
36
+ export const bodyOf = async (request: Request): Promise<Record<string, unknown> | undefined> => {
37
+ try {
38
+ const body: unknown = await request.json();
39
+ return body !== null && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : undefined;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ };
44
+
45
+ /** The refusal a private module raises when a gate that is not this battery's let an anonymous connection reach it. */
46
+ export const notGated = (): Error =>
47
+ codecError('not-gated', 'an anonymous connection reached a private module', 'Put auth/Gate in front of the server, or make the module public.');
package/src/token.ts ADDED
@@ -0,0 +1,15 @@
1
+ // A token: what a session, a verification link and a reset link are named by (designs 275, 290).
2
+ //
3
+ // Sixteen random bytes of its own, not an id: an id is twelve bytes, the width a document
4
+ // needs, and a credential needs 128 bits. Sixteen bytes are exactly twenty-two base64url
5
+ // characters with no padding.
6
+
7
+ import { randomBytes } from 'node:crypto';
8
+
9
+ const TOKEN_BYTES = 16;
10
+ const TOKEN = /^[A-Za-z0-9_-]{22}$/;
11
+
12
+ export const mintToken = (): string => randomBytes(TOKEN_BYTES).toString('base64url');
13
+
14
+ /** Is this text shaped like a token this battery minted. */
15
+ export const isToken = (value: unknown): value is string => typeof value === 'string' && TOKEN.test(value);
package/src/users.ts ADDED
@@ -0,0 +1,18 @@
1
+ // User documents: `user:<id>`, found by the declared `email` path (design 074).
2
+
3
+ import type { Store } from '@aweftjs/store';
4
+
5
+ /** One address, one spelling: trimmed and lowercased, which is what the index holds. */
6
+ export const normalEmail = (email: string): string => email.trim().toLowerCase();
7
+
8
+ /** Enough of a check to keep a string with no address in it out of the index. */
9
+ export const looksLikeEmail = (email: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
10
+
11
+ /** The document name of the user with this email, or undefined. */
12
+ export const findUser = async (store: Store, email: string): Promise<string | undefined> => {
13
+ const hits = await store.find({ where: [{ field: 'email', op: 'eq', value: normalEmail(email) }] });
14
+ return hits.find((hit) => hit.doc.startsWith('user:'))?.doc;
15
+ };
16
+
17
+ export const userDoc = (id: string): string => `user:${id}`;
18
+ export const idOfUserDoc = (doc: string): string => doc.slice('user:'.length);
package/surface.txt ADDED
@@ -0,0 +1,19 @@
1
+ type AuthContext: interface AuthContext { readonly user: string | null; readonly session: string | null; readonly address?: string | undefined; }
2
+ type Implies: type Implies = Readonly<Record<string, readonly string[]>>;
3
+ type RefuseSignUp: type RefuseSignUp = (signUp: SignUp) => Refusal | undefined | Promise<Refusal | undefined>;
4
+ type Roles: interface Roles { may(user: string, name: string): Promise<boolean>; grant(user: string, ...names: string[]): Promise<void>; revoke(user: string, ...names: string[]): Promise<void>; names(user: string): Promise<string[]>; first(user: string): Promise<boolean>; call(args: unknown, context: unknown): { implies: Implies; }; connection(connection: Connection<AuthContext>): Promise<() => Promise<void>>; }
5
+ type SignUp: interface SignUp { readonly email: string; readonly extra: Readonly<Record<string, unknown>>; readonly context: AuthContext; readonly store: Store; }
6
+ value auth: Source
7
+ value holds: (granted: readonly string[], implies: Readonly<Record<string, readonly string[]>>, name: string) => boolean
8
+ value mail: Source
9
+ value paths: Readonly<Record<string, readonly string[]>>
10
+ ./client type Auth: interface Auth { readonly user: Derived<string | null | undefined>; readonly names: Derived<readonly string[] | undefined>; may(name: string): boolean; enter(email: string, password: string, extra?: Readonly<Record<string, unknown>>): Promise<Entered>; leave(): Promise<void>; state<T extends object>(): Handle<T>; check(email: string): Promise<boolean>; verify(token?: string): Promise<Outcome>; change(current: string, password: string): Promise<Outcome>; forgot(email: string): Promise<Outcome>; reset(token: string, password: string): Promise<Outcome>; stop(): void; }
11
+ ./client type AuthOptions: interface AuthOptions { readonly origin?: string | undefined; readonly fetch?: Fetcher | undefined; }
12
+ ./client type Entered: type Entered = { readonly user: string; readonly created: boolean; } | { readonly refused: readonly Refusal[]; };
13
+ ./client type FetchInit: interface FetchInit { method: string; headers: Record<string, string>; body?: string; credentials: 'same-origin'; }
14
+ ./client type FetchResponse: interface FetchResponse { status: number; ok: boolean; json(): Promise<unknown>; }
15
+ ./client type Fetcher: type Fetcher = (url: string, init: FetchInit) => Promise<FetchResponse>;
16
+ ./client type Outcome: type Outcome = { readonly ok: true; } | { readonly refused: Refusals; };
17
+ ./client value authClient: Source
18
+ ./client value createAuth: (client: Client, options?: AuthOptions) => Auth
19
+ ./text.json data ./text.json
package/text.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "Email": [
3
+ "src/client-modules/Reset.tsx",
4
+ "src/client-modules/SignIn.tsx"
5
+ ],
6
+ "If that address has an account, a link is on its way.": [
7
+ "src/client-modules/Reset.tsx"
8
+ ],
9
+ "New password": [
10
+ "src/client-modules/Reset.tsx"
11
+ ],
12
+ "Password": [
13
+ "src/client-modules/SignIn.tsx"
14
+ ],
15
+ "Reset your password": [
16
+ "src/client-modules/Reset.tsx"
17
+ ],
18
+ "Send the link": [
19
+ "src/client-modules/Reset.tsx",
20
+ "src/client-modules/Verify.tsx"
21
+ ],
22
+ "Set the password": [
23
+ "src/client-modules/Reset.tsx"
24
+ ],
25
+ "Sign in": [
26
+ "src/client-modules/SignIn.tsx"
27
+ ],
28
+ "Sign in first, then ask for the link.": [
29
+ "src/client-modules/Verify.tsx"
30
+ ],
31
+ "The link is on its way. Open it from your mail.": [
32
+ "src/client-modules/Verify.tsx"
33
+ ],
34
+ "Verify your email": [
35
+ "src/client-modules/Verify.tsx"
36
+ ],
37
+ "We will send a link to your email address.": [
38
+ "src/client-modules/Verify.tsx"
39
+ ],
40
+ "Your email address is verified.": [
41
+ "src/client-modules/Verify.tsx"
42
+ ],
43
+ "Your password is set. Sign in with it.": [
44
+ "src/client-modules/Reset.tsx"
45
+ ],
46
+ "you@example.com": [
47
+ "src/client-modules/Reset.tsx",
48
+ "src/client-modules/SignIn.tsx"
49
+ ]
50
+ }