@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,159 @@
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
+ import { codecError } from '@aweftjs/codec';
4
+ import { atomic } from '@aweftjs/core';
5
+ import { userOf } from "../context.js";
6
+ import { cookiesOf, setCookie } from "../cookie.js";
7
+ import { json, storeOf } from "../props.js";
8
+ import { isToken, mintToken } from "../token.js";
9
+ export const defaults = { cookie: 'session', keep: 30, sweepMs: 3_600_000 };
10
+ const refuse = (detail, fix) => codecError('invalid-config', `auth/Session was given ${detail}`, fix);
11
+ // Over 2^31 - 1 milliseconds Node fires a timer after one millisecond instead.
12
+ const MAX_TIMER = 2_147_483_647;
13
+ export default async ({ config, ...props }) => {
14
+ const store = storeOf(props);
15
+ const cookie = String(config.cookie);
16
+ // Configuration is typed by hand in a same-named file, so a lifetime that is not a positive
17
+ // number is a mistake to stop here rather than a session that quietly never expires.
18
+ if (config.sessionMs !== undefined
19
+ && !(typeof config.sessionMs === 'number' && Number.isFinite(config.sessionMs) && config.sessionMs > 0)) {
20
+ throw refuse(`sessionMs ${JSON.stringify(config.sessionMs)}`, 'Set sessionMs to a positive number of milliseconds, or leave it out.');
21
+ }
22
+ const sessionMs = config.sessionMs;
23
+ if (typeof config.keep !== 'number' || !(config.keep > 0) || !Number.isFinite(config.keep)) {
24
+ throw refuse(`keep ${JSON.stringify(config.keep)}`, 'Set keep to a positive number of days.');
25
+ }
26
+ if (typeof config.sweepMs !== 'number' || !(config.sweepMs > 0) || config.sweepMs > MAX_TIMER) {
27
+ throw refuse(`sweepMs ${JSON.stringify(config.sweepMs)}`, 'Set sweepMs to a positive number of milliseconds, at most 2147483647.');
28
+ }
29
+ const keep = config.keep;
30
+ const sweepMs = config.sweepMs;
31
+ const doc = (token) => `session:${token}`;
32
+ // A document that was never written has no commits, and `open` would create it. Nothing
33
+ // in the store says whether a document exists without opening it, so the head stands in.
34
+ const read = async (token) => {
35
+ if (await store.head(doc(token)) === 0)
36
+ return undefined;
37
+ const handle = await store.open(doc(token));
38
+ const held = { ...handle.root };
39
+ await store.close(handle);
40
+ return held;
41
+ };
42
+ const issue = async (user) => {
43
+ const token = mintToken();
44
+ const handle = await store.open(doc(token));
45
+ const now = Date.now();
46
+ atomic(() => {
47
+ Object.assign(handle.root, {
48
+ user, expires: sessionMs === undefined ? null : now + sessionMs, status: 'active', createdAt: now,
49
+ });
50
+ });
51
+ await store.settled(handle);
52
+ await store.close(handle);
53
+ return token;
54
+ };
55
+ const revoke = async (token) => {
56
+ if (await store.head(doc(token)) === 0)
57
+ return false;
58
+ const handle = await store.open(doc(token));
59
+ const held = handle.root;
60
+ // The sweep can remove the document between the head and the open, and the open then
61
+ // makes an empty one; a session with no user was never issued, and nothing is written.
62
+ if (held.user === undefined) {
63
+ await store.close(handle);
64
+ await store.remove(doc(token));
65
+ return false;
66
+ }
67
+ const was = held.status === 'active';
68
+ // `expires` becomes the moment it ended, so one declared path says when any session was
69
+ // last valid and the sweep needs no second one.
70
+ if (was) {
71
+ atomic(() => {
72
+ held.status = 'revoked';
73
+ held.expires = Date.now();
74
+ });
75
+ }
76
+ await store.settled(handle);
77
+ await store.close(handle);
78
+ return was;
79
+ };
80
+ // The `user` path is declared for every document, so the answer is filtered to this
81
+ // battery's own sessions; `revoke` says which of them were still active.
82
+ const revokeAll = async (user, except) => {
83
+ let ended = 0;
84
+ for (const { doc: name } of await store.find({ where: [{ field: 'user', op: 'eq', value: user }] })) {
85
+ if (!name.startsWith('session:'))
86
+ continue;
87
+ const token = name.slice('session:'.length);
88
+ if (token === except)
89
+ continue;
90
+ if (await revoke(token))
91
+ ended += 1;
92
+ }
93
+ return ended;
94
+ };
95
+ // Every cookie of the name, in order: the first that is a token naming a live session wins,
96
+ // and none is anonymous. A value that is not a token is skipped rather than refused, since
97
+ // a cookie of the same name from another path or another application is not tampering, and
98
+ // refusing would lock the client out of the one route that clears it (design 074).
99
+ const whoIs = async (request, peer) => {
100
+ const address = peer?.address;
101
+ for (const token of cookiesOf(request, cookie)) {
102
+ if (!isToken(token))
103
+ continue;
104
+ const session = await read(token);
105
+ if (session === undefined || session.status !== 'active')
106
+ continue;
107
+ if (session.expires !== null && session.expires <= Date.now())
108
+ continue;
109
+ return { context: { user: session.user, session: token, address } };
110
+ }
111
+ // A fresh object each time: the server hands the same reference to every hook and event
112
+ // of one connection, and a module keys connections apart by it (design 260).
113
+ return { context: { user: null, session: null, address } };
114
+ };
115
+ const sweep = async () => {
116
+ const cutoff = Date.now() - keep * 86_400_000;
117
+ let removed = 0;
118
+ for (const { doc: name, fields } of await store.find({ where: [{ field: 'expires', op: 'lt', value: cutoff }] })) {
119
+ // The path is declared for every document, so the answer is filtered to this
120
+ // battery's own; a session with no end carries null, which is never under the cutoff.
121
+ if (!name.startsWith('session:') || typeof fields.expires !== 'number' || fields.expires >= cutoff)
122
+ continue;
123
+ await store.remove(name);
124
+ removed += 1;
125
+ }
126
+ return removed;
127
+ };
128
+ // Loud at load rather than at the first sweep an hour in: the sweep queries a declared
129
+ // path, and a store that has not declared it refuses the query.
130
+ try {
131
+ await store.find({ where: [{ field: 'expires', op: 'lt', value: 0 }], limit: 1 });
132
+ }
133
+ catch {
134
+ throw codecError('undeclared', 'the store does not declare the paths this battery queries', 'Spread paths from @aweftjs/auth into the store\'s declare.');
135
+ }
136
+ await sweep();
137
+ const timer = setInterval(() => { void sweep().catch(() => undefined); }, sweepMs);
138
+ timer.unref?.();
139
+ return {
140
+ public: true,
141
+ issue,
142
+ revoke,
143
+ revokeAll,
144
+ whoIs,
145
+ setCookie: (token, request) => setCookie(cookie, token, request, sessionMs),
146
+ // The only moment a page can learn who it is comes after its socket opens, because
147
+ // identity is fixed at the handshake (design 185).
148
+ call: (_args, context) => ({ user: userOf(context) }),
149
+ sweep,
150
+ stop: async () => { clearInterval(timer); },
151
+ routes: {
152
+ 'DELETE /api/session': async (request, context) => {
153
+ if (context.session !== null)
154
+ await revoke(context.session);
155
+ return json(200, { user: null }, { 'set-cookie': setCookie(cookie, null, request) });
156
+ },
157
+ },
158
+ };
159
+ };
@@ -0,0 +1,8 @@
1
+ import type { ModuleProps } from '@aweftjs/modules';
2
+ import { type Connection } from '@aweftjs/server';
3
+ import { type AuthContext } from '../context.ts';
4
+ export interface State {
5
+ connection(connection: Connection<AuthContext>): Promise<() => Promise<void>>;
6
+ }
7
+ declare const _default: (props: ModuleProps) => State;
8
+ export default _default;
@@ -0,0 +1,22 @@
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
+ import { open } from '@aweftjs/server';
4
+ import { userOf } from "../context.js";
5
+ import { notGated, storeOf } from "../props.js";
6
+ export default (props) => {
7
+ const store = storeOf(props);
8
+ return {
9
+ connection: async ({ link, context }) => {
10
+ const user = userOf(context);
11
+ if (user === null) {
12
+ // Only a gate that is not the auth gate lets an anonymous connection reach a private
13
+ // module. Loud, because sharing nothing in silence would look like an empty state.
14
+ throw notGated();
15
+ }
16
+ const handle = await store.open(`state:${user}`);
17
+ // The user's own document: whatever they write is theirs to write.
18
+ link.share('state', handle.root, open);
19
+ return async () => { await store.close(handle); };
20
+ },
21
+ };
22
+ };
@@ -0,0 +1,32 @@
1
+ import type { ModuleProps } from '@aweftjs/modules';
2
+ import { type Refusal } from '@aweftjs/server';
3
+ import { type AuthContext } from '../context.ts';
4
+ import { type Outcome } from '../mail.ts';
5
+ export declare const deps: string[];
6
+ export declare const defaults: {
7
+ subject: string;
8
+ url: null;
9
+ verifyMs: number;
10
+ sendsPerUser: number;
11
+ sendsWindowMs: number;
12
+ resendMs: number;
13
+ sweepMs: number;
14
+ };
15
+ /** The name a verified person holds. */
16
+ export declare const VERIFIED = "verified";
17
+ export type Confirmed = {
18
+ readonly user: string;
19
+ } | {
20
+ readonly refused: readonly Refusal[];
21
+ };
22
+ export interface Verify {
23
+ readonly public: true;
24
+ /** Mail the person a link. Refuses `verified` for a person already verified, and `mail` when it did not go. */
25
+ send(user: string): Promise<Outcome>;
26
+ /** 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. */
27
+ confirm(token: unknown): Promise<Confirmed>;
28
+ stop(): void;
29
+ readonly routes: Record<string, (request: Request, context: AuthContext) => Promise<Response>>;
30
+ }
31
+ declare const _default: ({ imports, config, ...props }: ModuleProps) => Verify;
32
+ export default _default;
@@ -0,0 +1,97 @@
1
+ // auth/Verify: a one-time link by mail, and the name `verified` once it is clicked (design 290).
2
+ import { atomic } from '@aweftjs/core';
3
+ import { sliding } from '@aweftjs/server';
4
+ import { userOf } from "../context.js";
5
+ import { NOT_LIVE, TAKEN, links } from "../links.js";
6
+ import { mailLink, textOf, urlOf } from "../mail.js";
7
+ import { bodyOf, json, numberOf, storeOf } from "../props.js";
8
+ import { userDoc } from "../users.js";
9
+ export const deps = ['auth/Roles', 'notify/Send'];
10
+ export const defaults = {
11
+ subject: 'Verify your email address',
12
+ url: null,
13
+ verifyMs: 86_400_000,
14
+ sendsPerUser: 5,
15
+ sendsWindowMs: 86_400_000,
16
+ resendMs: 60_000,
17
+ sweepMs: 3_600_000,
18
+ };
19
+ /** The name a verified person holds. */
20
+ export const VERIFIED = 'verified';
21
+ const MODULE = 'auth/Verify';
22
+ export default ({ imports, config, ...props }) => {
23
+ const store = storeOf(props);
24
+ const Roles = imports.Roles;
25
+ const mailer = imports.Send;
26
+ const subject = textOf(MODULE, config, 'subject');
27
+ const url = urlOf(MODULE, config);
28
+ const perUser = sliding({ count: numberOf(MODULE, config, 'sendsPerUser'), windowMs: numberOf(MODULE, config, 'sendsWindowMs') });
29
+ const resend = sliding({ count: 1, windowMs: numberOf(MODULE, config, 'resendMs') });
30
+ const held = links(store, 'verify', numberOf(MODULE, config, 'verifyMs'), numberOf(MODULE, config, 'sweepMs'));
31
+ const verified = async (user) => {
32
+ if (await store.head(userDoc(user)) === 0)
33
+ return false;
34
+ const handle = await store.open(userDoc(user));
35
+ const is = handle.root.emailVerified === true;
36
+ await store.close(handle);
37
+ return is;
38
+ };
39
+ const send = async (user) => {
40
+ if (await verified(user))
41
+ return { refused: [{ code: 'verified', message: 'this email is already verified' }] };
42
+ const token = await held.issue(user);
43
+ const failed = await mailLink(mailer, user, subject, 'Confirm your email address by opening this link:', url(token));
44
+ return failed === undefined ? { ok: true } : { refused: [failed] };
45
+ };
46
+ const confirm = async (token) => {
47
+ const link = await held.take(token);
48
+ if (link === undefined)
49
+ return { refused: [NOT_LIVE] };
50
+ if ('taken' in link)
51
+ return { refused: [TAKEN] };
52
+ const { user } = link;
53
+ const handle = await store.open(userDoc(user));
54
+ atomic(() => {
55
+ const root = handle.root;
56
+ root.emailVerified = true;
57
+ root.modifiedAt = Date.now();
58
+ });
59
+ await store.settled(handle);
60
+ await store.close(handle);
61
+ await Roles.grant(user, VERIFIED);
62
+ return { user };
63
+ };
64
+ const tooMany = (retryAfter) => json(429, { reasons: [{ code: 'attempts', message: 'too many verification mails; wait and try again' }] }, { 'retry-after': String(retryAfter) });
65
+ return {
66
+ public: true,
67
+ send,
68
+ confirm,
69
+ stop: () => { held.stop(); },
70
+ routes: {
71
+ 'POST /api/verify/send': async (_request, context) => {
72
+ const user = userOf(context);
73
+ if (user === null)
74
+ return json(401, { reasons: [{ code: 'private', message: 'sign in to verify your email' }] });
75
+ const inWindow = perUser.take(user);
76
+ if (!inWindow.ok)
77
+ return tooMany(inWindow.retryAfter);
78
+ const since = resend.take(user);
79
+ if (!since.ok)
80
+ return tooMany(since.retryAfter);
81
+ const outcome = await send(user);
82
+ if ('refused' in outcome) {
83
+ const [reason] = outcome.refused;
84
+ return json(reason?.code === 'mail' ? 502 : 409, { reasons: outcome.refused });
85
+ }
86
+ return json(200, { ok: true });
87
+ },
88
+ 'POST /api/verify': async (request) => {
89
+ const body = await bodyOf(request);
90
+ const outcome = await confirm(body?.token);
91
+ if ('refused' in outcome)
92
+ return json(400, { reasons: outcome.refused });
93
+ return json(200, outcome);
94
+ },
95
+ },
96
+ };
97
+ };
@@ -0,0 +1,25 @@
1
+ /** A table from a name to the names it implies. */
2
+ export type Implies = Readonly<Record<string, readonly string[]>>;
3
+ /** Non-empty text with no whitespace in it. */
4
+ export declare const isName: (value: unknown) => value is string;
5
+ /**
6
+ * Does a person who was granted `granted` hold `name`.
7
+ *
8
+ * True when a granted name covers it, or a name the table says a granted name implies does,
9
+ * transitively. Holding a name covers every name under it: `products` covers
10
+ * `products.abc123.read`, and `*` covers everything. The table is keyed by the exact name held,
11
+ * so holding `admin.super` implies what `admin.super` lists and not what `admin` does.
12
+ *
13
+ * Params:
14
+ * granted: the names the person was granted
15
+ * implies: the table, from a name to the names it implies; a cycle in it is fine
16
+ * name: the name asked about
17
+ *
18
+ * Returns: whether the person holds it.
19
+ *
20
+ * Example:
21
+ * holds(['admin'], { admin: ['*'] }, 'posts.delete'); // true
22
+ * holds(['products.abc123'], {}, 'products.abc123.read'); // true
23
+ * holds(['products.abc123'], {}, 'products.def456.read'); // false
24
+ */
25
+ export declare const holds: (granted: readonly string[], implies: Implies, name: string) => boolean;
package/dist/names.js ADDED
@@ -0,0 +1,44 @@
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
+ /** Non-empty text with no whitespace in it. */
6
+ export const isName = (value) => typeof value === 'string' && value !== '' && !/\s/.test(value);
7
+ /** Does holding `held` cover `name`: the same, everything, or a dotted parent of it. */
8
+ const covers = (held, name) => held === '*' || held === name || name.startsWith(`${held}.`);
9
+ /**
10
+ * Does a person who was granted `granted` hold `name`.
11
+ *
12
+ * True when a granted name covers it, or a name the table says a granted name implies does,
13
+ * transitively. Holding a name covers every name under it: `products` covers
14
+ * `products.abc123.read`, and `*` covers everything. The table is keyed by the exact name held,
15
+ * so holding `admin.super` implies what `admin.super` lists and not what `admin` does.
16
+ *
17
+ * Params:
18
+ * granted: the names the person was granted
19
+ * implies: the table, from a name to the names it implies; a cycle in it is fine
20
+ * name: the name asked about
21
+ *
22
+ * Returns: whether the person holds it.
23
+ *
24
+ * Example:
25
+ * holds(['admin'], { admin: ['*'] }, 'posts.delete'); // true
26
+ * holds(['products.abc123'], {}, 'products.abc123.read'); // true
27
+ * holds(['products.abc123'], {}, 'products.def456.read'); // false
28
+ */
29
+ export const holds = (granted, implies, name) => {
30
+ const seen = new Set();
31
+ const queue = [...granted];
32
+ while (queue.length > 0) {
33
+ const held = queue.pop();
34
+ if (seen.has(held))
35
+ continue;
36
+ seen.add(held);
37
+ if (covers(held, name))
38
+ return true;
39
+ const more = Object.hasOwn(implies, held) ? implies[held] : [];
40
+ for (const implied of more)
41
+ queue.push(implied);
42
+ }
43
+ return false;
44
+ };
@@ -0,0 +1,4 @@
1
+ /** The text to store for a password. Never the password. */
2
+ export declare const hashPassword: (password: string) => Promise<string>;
3
+ /** Does the password match the stored text? False, never a throw, for text that is not a hash. */
4
+ export declare const verifyPassword: (password: string, stored: unknown) => Promise<boolean>;
@@ -0,0 +1,41 @@
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
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
8
+ // Node's own defaults for scrypt, written into every hash so they can change.
9
+ const COST = 16384;
10
+ const BLOCK = 8;
11
+ const PARALLEL = 1;
12
+ const SALT_BYTES = 16;
13
+ const KEY_BYTES = 64;
14
+ const derive = (password, salt, N, r, p, length) => new Promise((done, fail) => {
15
+ scrypt(password, salt, length, { N, r, p, maxmem: 128 * N * r * 2 }, (error, key) => (error ? fail(error) : done(key)));
16
+ });
17
+ /** The text to store for a password. Never the password. */
18
+ export const hashPassword = async (password) => {
19
+ const salt = randomBytes(SALT_BYTES);
20
+ const key = await derive(password, salt, COST, BLOCK, PARALLEL, KEY_BYTES);
21
+ return ['scrypt', COST, BLOCK, PARALLEL, salt.toString('base64url'), key.toString('base64url')].join('$');
22
+ };
23
+ /** Does the password match the stored text? False, never a throw, for text that is not a hash. */
24
+ export const verifyPassword = async (password, stored) => {
25
+ if (typeof stored !== 'string')
26
+ return false;
27
+ const [kind, N, r, p, salt, hash] = stored.split('$');
28
+ if (kind !== 'scrypt' || salt === undefined || hash === undefined)
29
+ return false;
30
+ const expected = Buffer.from(hash, 'base64url');
31
+ if (expected.length === 0)
32
+ return false;
33
+ let key;
34
+ try {
35
+ key = await derive(password, Buffer.from(salt, 'base64url'), Number(N), Number(r), Number(p), expected.length);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ return key.length === expected.length && timingSafeEqual(key, expected);
41
+ };
@@ -0,0 +1,15 @@
1
+ import type { Store } from '@aweftjs/store';
2
+ /**
3
+ * The store the loader was made with. Loud when it was not, rather than undefined at the first write.
4
+ *
5
+ * Throws: `no-store` when the loader's props carry no store.
6
+ */
7
+ export declare const storeOf: (props: Readonly<Record<string, unknown>>) => Store;
8
+ export declare const invalidConfig: (module: string, detail: string, fix: string) => Error;
9
+ /** A positive finite number out of a module's config, or `invalid-config` naming the module. */
10
+ export declare const numberOf: (module: string, config: Readonly<Record<string, unknown>>, key: string) => number;
11
+ export declare const json: (status: number, body: unknown, headers?: Record<string, string>) => Response;
12
+ /** The JSON body of a request, or undefined when there is none worth the name. */
13
+ export declare const bodyOf: (request: Request) => Promise<Record<string, unknown> | undefined>;
14
+ /** The refusal a private module raises when a gate that is not this battery's let an anonymous connection reach it. */
15
+ export declare const notGated: () => Error;
package/dist/props.js ADDED
@@ -0,0 +1,35 @@
1
+ // What every module here reads off the loader's props: the application's store.
2
+ import { codecError } from '@aweftjs/codec';
3
+ /**
4
+ * The store the loader was made with. Loud when it was not, rather than undefined at the first write.
5
+ *
6
+ * Throws: `no-store` when the loader's props carry no store.
7
+ */
8
+ export const storeOf = (props) => {
9
+ const store = props.store;
10
+ if (store === undefined || typeof store.open !== 'function') {
11
+ throw codecError('no-store', 'the loader needs a store in its props', 'Pass store to createServer, or props: { store } to a loader you build yourself.');
12
+ }
13
+ return store;
14
+ };
15
+ export const invalidConfig = (module, detail, fix) => codecError('invalid-config', `${module} was given ${detail}`, fix);
16
+ /** A positive finite number out of a module's config, or `invalid-config` naming the module. */
17
+ export const numberOf = (module, config, key) => {
18
+ const held = config[key];
19
+ if (typeof held !== 'number' || !(held > 0) || !Number.isFinite(held))
20
+ throw invalidConfig(module, `${key} ${JSON.stringify(held)}`, 'Give that setting a number above zero.');
21
+ return held;
22
+ };
23
+ export const json = (status, body, headers = {}) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
24
+ /** The JSON body of a request, or undefined when there is none worth the name. */
25
+ export const bodyOf = async (request) => {
26
+ try {
27
+ const body = await request.json();
28
+ return body !== null && typeof body === 'object' && !Array.isArray(body) ? body : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ };
34
+ /** The refusal a private module raises when a gate that is not this battery's let an anonymous connection reach it. */
35
+ export const notGated = () => codecError('not-gated', 'an anonymous connection reached a private module', 'Put auth/Gate in front of the server, or make the module public.');
@@ -0,0 +1,3 @@
1
+ export declare const mintToken: () => string;
2
+ /** Is this text shaped like a token this battery minted. */
3
+ export declare const isToken: (value: unknown) => value is string;
package/dist/token.js ADDED
@@ -0,0 +1,11 @@
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
+ import { randomBytes } from 'node:crypto';
7
+ const TOKEN_BYTES = 16;
8
+ const TOKEN = /^[A-Za-z0-9_-]{22}$/;
9
+ export const mintToken = () => randomBytes(TOKEN_BYTES).toString('base64url');
10
+ /** Is this text shaped like a token this battery minted. */
11
+ export const isToken = (value) => typeof value === 'string' && TOKEN.test(value);
@@ -0,0 +1,9 @@
1
+ import type { Store } from '@aweftjs/store';
2
+ /** One address, one spelling: trimmed and lowercased, which is what the index holds. */
3
+ export declare const normalEmail: (email: string) => string;
4
+ /** Enough of a check to keep a string with no address in it out of the index. */
5
+ export declare const looksLikeEmail: (email: string) => boolean;
6
+ /** The document name of the user with this email, or undefined. */
7
+ export declare const findUser: (store: Store, email: string) => Promise<string | undefined>;
8
+ export declare const userDoc: (id: string) => string;
9
+ export declare const idOfUserDoc: (doc: string) => string;
package/dist/users.js ADDED
@@ -0,0 +1,12 @@
1
+ // User documents: `user:<id>`, found by the declared `email` path (design 074).
2
+ /** One address, one spelling: trimmed and lowercased, which is what the index holds. */
3
+ export const normalEmail = (email) => email.trim().toLowerCase();
4
+ /** Enough of a check to keep a string with no address in it out of the index. */
5
+ export const looksLikeEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
6
+ /** The document name of the user with this email, or undefined. */
7
+ export const findUser = async (store, email) => {
8
+ const hits = await store.find({ where: [{ field: 'email', op: 'eq', value: normalEmail(email) }] });
9
+ return hits.find((hit) => hit.doc.startsWith('user:'))?.doc;
10
+ };
11
+ export const userDoc = (id) => `user:${id}`;
12
+ export const idOfUserDoc = (doc) => doc.slice('user:'.length);
package/errors.txt ADDED
@@ -0,0 +1,29 @@
1
+ anonymous: Wait for user to read a string, or call enter first; an anonymous connection has no state.
2
+ change-failed: Check the server is running and that auth/Password is loaded from the mail source, then try again.
3
+ closed: Make a new client with createClient, and a new auth over it.
4
+ enter-failed: Check the server is running and that auth/Enter is loaded, then try again.
5
+ forgot-failed: Check the server is running and that auth/Password is loaded from the mail source, then try again.
6
+ invalid-config: A name is non-empty text with no whitespace: admin, verified, products.abc123.
7
+ invalid-config: Give first, and each entry of implies, a list of names: non-empty text with no whitespace.
8
+ invalid-config: Give implies an object from a name to the list of names it implies.
9
+ invalid-config: Give passwordMax at least passwordMin.
10
+ invalid-config: Give refusePassword a function of the password answering true to refuse it, or null.
11
+ invalid-config: Give refuseSignUp a function of the sign-up answering a refusal to refuse it and nothing to allow it, or null.
12
+ invalid-config: Give that setting a number above zero.
13
+ invalid-config: Give that setting some text.
14
+ invalid-config: Give url a function of the token answering the address of the page that takes it: (token) => `https://app.example/verify?token=${token}`.
15
+ invalid-config: Set keep to a positive number of days.
16
+ invalid-config: Set sessionMs to a positive number of milliseconds, or leave it out.
17
+ invalid-config: Set sweepMs to a positive number of milliseconds, at most 2147483647.
18
+ invalid-name: A name is non-empty text with no whitespace: admin, verified, products.abc123.
19
+ invalid-user: Hand it the id the gate put on the context, which is text.
20
+ leave-failed: Check the server is running and that auth/Session is loaded, then try again.
21
+ malformed: Call it as { email: "someone@example.com" }.
22
+ no-client: Pass the client createClient answered as the StageContext client, or none at all.
23
+ no-origin: Pass origin to createAuth; outside a page there is no origin to read one from.
24
+ no-store: Pass store to createServer, or props: { store } to a loader you build yourself.
25
+ not-gated: Put auth/Gate in front of the server, or make the module public.
26
+ reset-failed: Check the server is running and that auth/Password is loaded from the mail source, then try again.
27
+ stopped: Make a new auth with createAuth; a stopped one follows no connection.
28
+ undeclared: Spread paths from @aweftjs/auth into the store's declare.
29
+ verify-failed: Check the server is running and that auth/Verify is loaded from the mail source, then try again.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@aweftjs/auth",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=24.12.0"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/torrinworx/aweft.git",
12
+ "directory": "packages/auth"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "README.md",
18
+ "surface.txt",
19
+ "errors.txt",
20
+ "text.json"
21
+ ],
22
+ "description": "The first battery: a gate that reads public, sessions as documents, sign-in and sign-up by email and password, and a per-user state document, as server modules, with the page half as client modules.",
23
+ "exports": {
24
+ ".": {
25
+ "aweft-source": "./src/index.ts",
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./client": {
30
+ "aweft-source": "./src/client.ts",
31
+ "types": "./dist/client.d.ts",
32
+ "default": "./dist/client.js"
33
+ },
34
+ "./text.json": "./text.json"
35
+ },
36
+ "scripts": {
37
+ "prepack": "node ../build/scripts/build-package.ts",
38
+ "test": "node --import @aweftjs/build/loader --test tests/*.test.ts"
39
+ },
40
+ "dependencies": {
41
+ "@aweftjs/client": "^0.1.0",
42
+ "@aweftjs/codec": "^0.1.0",
43
+ "@aweftjs/core": "^0.1.0",
44
+ "@aweftjs/modules": "^0.1.0",
45
+ "@aweftjs/server": "^0.1.0",
46
+ "@aweftjs/store": "^0.1.0",
47
+ "@aweftjs/ui": "^0.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "@aweftjs/build": "^0.1.0",
51
+ "@aweftjs/notify": "^0.1.0",
52
+ "@aweftjs/sync": "^0.1.0",
53
+ "@aweftjs/testing": "^0.1.0"
54
+ },
55
+ "aweft": {
56
+ "tier": "integrator",
57
+ "branchCoverage": 80
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }